홈/보안/certificate has expired 원인 5분기 판정법 (nginx·openssl)
보안SSL 인증서 만료인증서 만료 오류 해결

certificate has expired 원인 5분기 판정법 (nginx·openssl)

SSL 인증서 만료와 TLS 연결 오류를 OpenSSL로 진단합니다. 인증서 파일과 서버 응답을 대조해 갱신 실패, nginx reload 누락, 체인 구성과 암호군 문제를 구분하고 만료 감시를 설정합니다.

certificate has expired 원인 5분기 판정법 (nginx·openssl)
목차
  1. 갱신은 분명히 됐는데 왜 아직도 만료라고 뜨는가
  2. 에러 원문 → 원인 판정표 (여기서 30초 안에 끝난다)
  3. 30초 진단 명령 세트 — 출력의 어느 줄을 보는가
  4. SNI 다중 도메인: '특정 도메인만' 실패할 때
  5. 복구 런북
  6. 재발 방지 — 파일이 아니라 소켓을 감시한다
  7. 자주 묻는 질문 (FAQ)

갱신은 분명히 됐는데 왜 아직도 만료라고 뜨는가

새벽 2시에 흔히 벌어지는 장면이 있다. certbot renew 로그에는 Congratulations, all renewals succeeded가 찍혀 있고, ls -l /etc/letsencrypt/live/example.com/을 보면 파일 타임스탬프도 방금 전이다. 그런데 브라우저는 여전히 NET::ERR_CERT_DATE_INVALID를 띄운다.

여기서 반드시 잡고 가야 할 전제가 하나 있다.

디스크의 인증서 파일과, 서버 프로세스가 메모리에 물고 있는 인증서는 완전히 별개의 물건이다.

nginx나 haproxy는 기동/reload 시점에 인증서 파일을 읽어 메모리에 올린다. 그 이후 파일이 바뀌어도 프로세스는 알지 못한다. 그래서 "파일을 확인했다"는 것은 진단이 아니다. 진단은 소켓에서 서버가 실제로 무엇을 내려주는지 확인하는 것이다.

이 글의 범위는 명확하다. 서버가 내려주는 인증서의 만료·체인 구성·프로토콜 협상 실패만 다룬다. 클라이언트 쪽 신뢰 저장소나 CA 번들 문제(사내 루트 CA 미설치, JDK cacerts, 파이썬 certifi 등)는 원인 계층이 다르므로 해당 지점에서 링크만 건다.

적용 범위는 다음과 같다.

항목범위
OSLinux 일반 (RHEL/Rocky 89, Ubuntu 20.0424.04)
서버nginx 1.18+, haproxy 2.4+
도구OpenSSL 1.1.1 / 3.x, curl 7.x+, certbot / acme.sh
제외클라이언트 트러스트스토어, 사내 CA 배포, mTLS 클라이언트 인증서

에러 원문 → 원인 판정표 (여기서 30초 안에 끝난다)

스크롤하지 말고 지금 보고 있는 에러 문자열을 왼쪽 열에서 찾는다.

에러 원문유력 원인함께 나타나야 하는 증상(오판 차단)
certificate has expired / ERR_CERT_DATE_INVALID / Verify return code: 10(a) notAfter 실제 경과 — 갱신 자체가 안 됨파일 fingerprint와 소켓 fingerprint가 동일. certbot 로그에 실패 흔적
위와 같은 에러인데 파일은 최신(b) reload 누락파일과 소켓 fingerprint가 다름. ps -o lstart 기동 시각 < 인증서 갱신 시각
unable to get local issuer certificate / Verify return code: 21 (특정 클라이언트만)(c) 중간 CA 체인 누락브라우저는 정상(AIA 보정), curl·Java·모바일만 실패. Certificate chain의 depth 1이 없음
sslv3 alert handshake failure / SSL_ERROR_SYSCALL / nginx error.log의 no shared cipher(d) 프로토콜·암호군 불일치인증서 날짜는 정상. 특정 클라이언트·특정 TLS 버전에서만 실패
certificate is not yet valid / notBefore가 미래(e) 시계 오차 또는 체인 상단 교체date -u가 실제 시각과 어긋남, 또는 컨테이너 시계 드리프트

각 분기의 확정 근거는 아래 진단 명령 세트에서 한 번에 나온다. 추측으로 certbot renew를 다시 돌리는 것은 (b)~(e) 상황에서 아무것도 고치지 못하고 rate limit만 소모한다.


30초 진단 명령 세트 — 출력의 어느 줄을 보는가

1) 소켓에서 실제 체인 확인

Bash
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | head -40

정상(체인 완전) 출력 예시:

TEXT
Certificate chain
 0 s:CN = example.com
   i:C = US, O = Let's Encrypt, CN = R11
 1 s:C = US, O = Let's Encrypt, CN = R11
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
---
Verify return code: 0 (ok)

depth 1 누락 출력 예시:

TEXT
Certificate chain
 0 s:CN = example.com
   i:C = US, O = Let's Encrypt, CN = R11
---
Verify return code: 21 (unable to verify the first certificate)

읽는 법은 단순하다.

  • depth 0 = 서버 인증서(리프)
  • depth 1 = 중간 CA
  • depth 2 = 루트(보통 생략되며, 생략이 정상)

depth 1이 통째로 비어 있으면 ssl_certificate에 fullchain.pem이 아니라 cert.pem을 넣었을 가능성이 압도적으로 높다. 이게 (c) 분기다.

Verify return code 두 값의 차이를 혼동하면 30분이 날아간다.

코드의미조치 방향
10 (certificate has expired)날짜 문제 — (a) 또는 (b)갱신 여부 + reload 여부 확인
21 (unable to verify the first certificate)체인 문제 — (c)fullchain 지정 교정
0 (ok)서버 측 정상이후는 클라이언트 신뢰 저장소 영역

2) 서버가 내려준 날짜만 딱 뽑기

Bash
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -dates -subject -issuer

예상 정상 결과:

TEXT
notBefore=Aug 20 03:11:02 2026 GMT
notAfter=Nov 18 03:11:01 2026 GMT
subject=CN = example.com
issuer=C = US, O = Let's Encrypt, CN = R11

핵심은 이 값이 파일이 아니라 소켓 기준이라는 점이다. notAfter가 과거면 (a) 또는 (b), notBefore가 미래면 (e)다.

3) curl로 교차 확인

Bash
curl -vI https://example.com 2>&1 | grep -Ei 'expire date|start date|SSL certificate|issuer'

SSL certificate problem: unable to get local issuer certificate가 나오는데 브라우저에서는 멀쩡하다면 거의 확정적으로 (c)다. Chrome·Edge·Safari 등은 AIA(Authority Information Access) 주소에서 누락된 중간 CA를 내려받아 보정하고, Firefox는 미리 내려받아 둔 중간 CA 목록으로 보정한다. Firefox intermediate preloading curl·Java·구형 모바일 스택은 이런 보정을 하지 않으므로 서버가 체인을 보내야 한다.

4) 실제 서빙 중인 인증서 경로 확정

Bash
nginx -T 2>/dev/null | grep -nE 'server_name|ssl_certificate' | head -40

nginx -T는 include된 모든 설정을 펼쳐서 보여준다. sites-enabled에 예전 vhost가 남아 있어 엉뚱한 경로를 물고 있는 경우가 자주 보고된다.

5) reload 누락 확정 — 파일 vs 소켓 fingerprint 대조

이 두 줄이 이 글에서 가장 실전적인 기법이다.

Bash
# 파일 쪽 지문
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -fingerprint -sha256

# 소켓 쪽 지문
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -fingerprint -sha256
  • 두 값이 같다 → 서버는 파일대로 서빙 중. 만료라면 갱신 자체가 실패한 (a)
  • 두 값이 다르다 → 파일은 새것, 프로세스는 옛것. (b) reload 누락 확정. 더 볼 것 없다

보강 근거는 프로세스 기동 시각이다.

Bash
ss -tlnp | grep :443
PID=$(pgrep -f 'nginx: master' | head -1)
ps -o pid,lstart,cmd -p "$PID"
stat -L -c '%n %y' /etc/letsencrypt/live/example.com/fullchain.pem   # -L: 심볼릭 링크가 가리키는 실제 파일 시각

ps -o lstart 값이 인증서 파일의 mtime보다 이르면 그 프로세스가 기동 시점에 새 인증서를 읽지 않은 것이다. 단 nginx -s reload·systemctl reload는 마스터 PID와 기동 시각을 바꾸지 않고 설정·인증서만 다시 읽으므로, 이 비교는 보조 근거로만 쓰고 결론은 위의 지문 대조로 낸다. nginx는 인증서를 읽은 뒤 파일을 열어 두지 않으므로 lsof로는 확인할 수 없다(예전 판의 lsof 단계는 삭제했다).

6) no shared cipher 계열 — (d) 확정

nginx error.log에 다음 줄이 있으면 날짜·체인은 잊어도 된다.

TEXT
SSL_do_handshake() failed (SSL: error:1408A0C1:SSL routines:ssl3_get_client_hello:no shared cipher)   # OpenSSL 1.0.x
SSL_do_handshake() failed (SSL: error:0A0000C1:SSL routines::no shared cipher)                         # OpenSSL 3.x

서버의 OpenSSL 버전에 따라 오류 코드 표기가 다르므로 no shared cipher 문자열로 찾는다.

지원 프로토콜을 직접 훑어 어디서 끊기는지 본다.

Bash
for p in tls1_2 tls1_3; do
  printf '%-8s ' "$p"
  openssl s_client -brief -connect example.com:443 -servername example.com -$p </dev/null 2>&1 \
    | grep -q 'CONNECTION ESTABLISHED' && echo OK || echo FAIL
done
# TLS 1.0/1.1 지원 여부까지 보려면(OpenSSL 3.x 클라이언트는 보안 수준을 0으로 낮춰야 시도 자체를 함)
for p in tls1 tls1_1; do
  printf '%-8s ' "$p"
  openssl s_client -brief -connect example.com:443 -servername example.com -$p \
    -cipher 'DEFAULT:@SECLEVEL=0' </dev/null 2>&1 \
    | grep -q 'CONNECTION ESTABLISHED' && echo OK || echo FAIL
done

예전 판의 반복문은 실패해도 출력되는 Cipher is (NONE) 줄까지 성공으로 세어 항상 OK가 나왔다. -brief의 CONNECTION ESTABLISHED로 판정해야 한다. 또 OpenSSL 3.x 클라이언트는 기본 보안 수준(1)에서 TLS 1.2 미만을 아예 쓰지 않으므로, -tls1이 FAIL이어도 서버 탓이 아닐 수 있다. OpenSSL 보안 수준

TLS 1.2/1.3만 OK이고 1.0/1.1이 FAIL이면 그건 정상적인 보안 설정이다. 이때 실패하는 클라이언트는 구형 스택이므로 서버를 낮추기보다 클라이언트를 올리는 게 맞다. 반대로 TLS 1.2도 FAIL이면 ssl_ciphers 설정과 키 타입(RSA/ECDSA) 불일치를 의심한다. ECDSA 전용 cipher suite만 남겨둔 상태에서 RSA 키 인증서를 물리면 no shared cipher가 그대로 터진다.

권장 기준선:

Nginx
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;

7) 시계 오차 — (e)

Bash
date -u
timedatectl status | grep -E 'System clock|NTP'

System clock synchronized: no면 컨테이너/VM 시계 드리프트를 먼저 잡는다. 시계가 미래로 어긋난 서버는 정상 인증서도 certificate has expired로 판정하고, 과거로 어긋나면 certificate is not yet valid를 낸다.


SNI 다중 도메인: '특정 도메인만' 실패할 때

한 IP에 여러 vhost가 붙어 있으면 -servername 유무에 따라 결과가 갈린다.

servername 붙였을 때:

Bash
openssl s_client -connect 203.0.113.10:443 -servername shop.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject
# subject=CN = shop.example.com

뺐을 때:

Bash
openssl s_client -connect 203.0.113.10:443 </dev/null 2>/dev/null \
  | openssl x509 -noout -subject
# subject=CN = www.example.com     ← default_server의 인증서

이 차이가 의미하는 것을 표로 정리한다.

관찰해석조치
servername 있을 때만 정상정상 동작. SNI 미지원 클라이언트만 실패구형 Android 4.x·Java 6 등 → 클라이언트 업그레이드 또는 전용 IP 분리
servername 넣어도 다른 CN이 나옴server_name 오타 또는 vhost 미매칭 → default_server로 흘러감nginx -T로 server_name 확인, 와일드카드 포함 여부 점검
CN은 맞는데 브라우저가 이름 불일치SAN에 해당 호스트 없음아래 SAN 확인 후 재발급
L4/CDN 뒤에서만 실패앞단이 SNI를 원본에 전달하지 않음프록시의 SNI 전달(proxy_ssl_server_name on 등) 설정

SAN 확인:

Bash
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName
TEXT
X509v3 Subject Alternative Name:
    DNS:example.com, DNS:www.example.com

shop.example.com으로 접속하는데 위 목록에 없으면 그 도메인은 처음부터 이 인증서의 대상이 아니다.

여기까지 왔는데 Verify return code: 0 (ok)이고 SAN도 맞는다면 서버 측 문제는 끝난 것이다. 그런데도 특정 클라이언트만 실패한다면 원인은 클라이언트 신뢰 저장소로 넘어간다. Java·Go 계열은 x509 certificate signed by unknown authority 계열 문제, Python 클라이언트는 SSLCertVerificationError CERTIFICATE_VERIFY_FAILED 원인 5종 판별법을 참고하면 된다.


복구 런북

cert.pem vs fullchain.pem — (c) 원인의 대부분

잘못된 설정:

Nginx
server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/cert.pem;      # ← 서버 인증서만
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
}

교정본:

Nginx
server {
    listen 443 ssl;
    http2 on;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem; # ← 서버+중간 CA
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_cache shared:SSL:10m;
}

cert.pem은 리프 하나뿐이고 fullchain.pem은 리프 + 중간 CA다. 한 줄 차이로 depth 1이 사라진다.

OCSP stapling은 CA에 따라 다르다. Let's Encrypt는 2025-05-07부터 인증서에 OCSP URL을 넣지 않고 2025-08-06에 OCSP 응답 서버를 껐다. 이후 발급된 Let's Encrypt 인증서에 ssl_stapling on;을 두면 nginx가 OCSP 주소가 없다는 경고를 남기고 stapling을 하지 않는다. 예전 판의 교정본에 있던 ssl_stapling on; ssl_stapling_verify on;은 그래서 뺐다. OCSP를 계속 제공하는 CA의 인증서를 쓸 때만 켜고, 이때는 ssl_trusted_certificate와 resolver도 함께 설정한다. Let's Encrypt OCSP 종료 · nginx ssl_stapling

키-인증서 매칭과 체인 검증

Bash
# 키와 인증서가 같은 쌍인지: 공개키 해시 비교 (RSA·ECDSA 공통, 두 해시가 같아야 정상)
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -pubkey | openssl sha256
sudo openssl pkey -in /etc/letsencrypt/live/example.com/privkey.pem -pubout | openssl sha256

# 체인 검증
openssl verify -untrusted /etc/letsencrypt/live/example.com/chain.pem \
  /etc/letsencrypt/live/example.com/cert.pem
# 기대 출력: cert.pem: OK

예전 판은 openssl rsa -modulus를 썼는데, certbot 2.0부터 새 인증서의 기본 키가 ECDSA(P-256)라 그 명령은 openssl rsa에서 오류가 난다. 위의 -pubkey/pkey -pubout 비교는 키 종류와 상관없이 동작한다. certbot 변경 이력

반영 순서

Bash
sudo nginx -t                      # syntax is ok / test is successful 확인 필수
sudo systemctl reload nginx
# 반영 확인 — 소켓 지문 재확인
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -dates -fingerprint -sha256

nginx -t가 실패한 상태에서 reload하면 기존 설정이 유지되어 "고쳤는데 그대로"인 상황이 재현된다. haproxy는 reload 시 소켓 인계 방식에 따라 순간적으로 기존 연결이 유지되므로, 갱신 반영 여부는 반드시 새 연결로 확인한다.

reload를 갱신에 묶기 (정석)

Bash
# 1회 실행에 훅 지정 (이번 renew 실행에만 적용된다고 보고, 상시 적용은 아래 훅 파일로)
sudo certbot renew --deploy-hook "systemctl reload nginx"

# 훅 파일로 고정: certbot이 갱신에 성공할 때마다 실행
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh >/dev/null <<'EOF'
#!/bin/sh
/usr/bin/nginx -t && /bin/systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

훅 파일은 인증서가 실제로 갱신됐을 때만 실행된다. 적용 후 sudo certbot renew --dry-run으로 훅까지 실행되는지 확인하고, 끝나면 소켓 지문을 다시 본다. certbot 사용 문서: 갱신 훅

acme.sh는 --reloadcmd로 같은 역할을 한다.

Bash
acme.sh --install-cert -d example.com \
  --key-file /etc/nginx/ssl/example.com.key \
  --fullchain-file /etc/nginx/ssl/example.com.crt \
  --reloadcmd "nginx -t && systemctl reload nginx"

공인 TLS 인증서의 최대 유효기간은 CA/Browser Forum 투표 SC-081에 따라 발급일 기준 2026-03-15부터 200일, 2027-03-15부터 100일, 2029-03-15부터 47일로 줄어든다. CA/B Forum Ballot SC-081v3 갱신이 잦아질수록 reload 누락 장애가 일어날 기회도 늘어난다. 갱신 성공과 서비스 반영은 별개 이벤트이기 때문이다. 쓰는 CA의 실제 발급 기간은 그 CA의 공지로 확인한다.

롤백

Bash
ls -l /etc/letsencrypt/archive/example.com/
# 롤백 전 증적 보존 (필수)
sudo cp -a /etc/letsencrypt/live/example.com /root/incident-$(date +%Y%m%d%H%M)/
sudo journalctl -u nginx --since "1 hour ago" > /root/incident-nginx.log

# 이전 버전으로 심볼릭 링크 되돌리기 (예: 12 → 11): 네 파일을 같은 번호로 함께 바꾼다
N=11
cd /etc/letsencrypt/live/example.com
for f in cert chain fullchain privkey; do
  sudo ln -sfn "../../archive/example.com/${f}${N}.pem" "${f}.pem"
done
sudo openssl x509 -in fullchain.pem -noout -enddate      # 아직 유효한지 먼저 확인
sudo nginx -t && sudo systemctl reload nginx

롤백은 이전 인증서가 아직 유효기간 내일 때만 의미가 있다. 만료된 것으로 되돌리면 상황이 더 나빠진다. 일부 파일만 되돌리면 키와 인증서가 짝이 맞지 않아 nginx가 기동하지 않는다. 다음 certbot renew는 새 번호로 링크를 다시 만들므로, 롤백은 원인을 고칠 때까지의 임시 조치다.


재발 방지 — 파일이 아니라 소켓을 감시한다

파일 mtime을 감시하는 스크립트는 (b) reload 누락을 절대 잡지 못한다. 감시 대상은 반드시 소켓이어야 한다.

Bash
#!/usr/bin/env bash
# /usr/local/bin/tls-expiry-check.sh
set -uo pipefail

DOMAINS=(
  "example.com:443"
  "shop.example.com:443"
  "api.example.com:443"
)
THRESHOLD_DAYS="${THRESHOLD_DAYS:-30}"
NOW_EPOCH=$(date +%s)
EXIT_CODE=0

for entry in "${DOMAINS[@]}"; do
  host="${entry%%:*}"
  port="${entry##*:}"

  end_date=$(echo | timeout 10 openssl s_client \
      -connect "${host}:${port}" -servername "${host}" 2>/dev/null \
    | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)

  if [ -z "${end_date}" ]; then
    echo "CRITICAL ${host} - 인증서 조회 실패 (접속/핸드셰이크 오류)"
    EXIT_CODE=2
    continue
  fi

  end_epoch=$(date -d "${end_date}" +%s 2>/dev/null) || {
    echo "CRITICAL ${host} - 날짜 파싱 실패: ${end_date}"; EXIT_CODE=2; continue; }

  days_left=$(( (end_epoch - NOW_EPOCH) / 86400 ))

  if   [ "${days_left}" -lt 0 ]; then
    echo "CRITICAL ${host} - 이미 만료됨 (${end_date})"; EXIT_CODE=2
  elif [ "${days_left}" -lt "${THRESHOLD_DAYS}" ]; then
    echo "WARNING  ${host} - ${days_left}일 남음 (${end_date})"
    [ "${EXIT_CODE}" -lt 1 ] && EXIT_CODE=1
  else
    echo "OK       ${host} - ${days_left}일 남음"
  fi
done

exit "${EXIT_CODE}"
Bash
sudo chmod +x /usr/local/bin/tls-expiry-check.sh
/usr/local/bin/tls-expiry-check.sh

예상 정상 출력:

TEXT
OK       example.com - 74일 남음
OK       shop.example.com - 74일 남음
WARNING  api.example.com - 12일 남음 (Sep 13 08:22:10 2026 GMT)

종료 코드는 0(정상) / 1(경고) / 2(치명)로 나뉘므로 Nagios·Zabbix 계열이나 사내 알림 스크립트에 그대로 물릴 수 있다.

cron 등록:

Bash
# crontab -e
# cron은 셸의 줄 이어쓰기(\)를 지원하지 않는다: 명령은 반드시 한 줄로 쓴다.
# cron 환경에는 로그인 셸의 변수가 없으므로 웹훅 주소를 crontab에 직접 정의한다.
WEBHOOK_URL=https://hooks.example.com/REPLACE_ME
0 9 * * * /usr/local/bin/tls-expiry-check.sh >> /var/log/tls-expiry.log 2>&1 || curl -s -X POST -H 'Content-Type: application/json' -d "{\"text\":\"[TLS] 인증서 만료 경고 - $(hostname)\"}" "$WEBHOOK_URL"

systemd service + timer (권장):

INI
# /etc/systemd/system/tls-expiry-check.service
[Unit]
Description=TLS certificate expiry check (socket-based)
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
Environment=THRESHOLD_DAYS=30
ExecStart=/usr/local/bin/tls-expiry-check.sh
StandardOutput=journal
StandardError=journal
INI
# /etc/systemd/system/tls-expiry-check.timer
[Unit]
Description=Run TLS expiry check daily

[Timer]
OnCalendar=*-*-* 09:00:00
RandomizedDelaySec=600
Persistent=true
Unit=tls-expiry-check.service

[Install]
WantedBy=timers.target
Bash
sudo systemctl daemon-reload
sudo systemctl enable --now tls-expiry-check.timer
systemctl list-timers tls-expiry-check.timer

Persistent=true는 서버가 꺼져 있어 실행 시각을 놓쳤을 때 부팅 직후 한 번 실행해 준다. 배치 서버처럼 상시 가동이 아닌 장비에서 특히 중요하다.

마지막으로 한 문장만 기억하면 된다. 감시 대상은 파일이 아니라 소켓이다. 소켓을 보면 갱신 실패도, reload 누락도, 체인 누락도 한 번에 걸린다.


자주 묻는 질문 (FAQ)

Q. certbot renew가 성공했는데도 브라우저에 만료가 뜹니다. 무엇부터 봐야 하나요? A. 파일 fingerprint와 소켓 fingerprint를 대조하세요. openssl x509 -in fullchain.pem -noout -fingerprint -sha256 값과 openssl s_client로 받은 fingerprint가 다르면 reload 누락이 확정입니다. nginx -t && systemctl reload nginx로 즉시 해소되며, 재발 방지는 --deploy-hook으로 reload를 갱신에 묶는 것입니다.

Q. 브라우저에서는 정상인데 curl과 Java 앱만 unable to get local issuer certificate가 납니다. A. 중간 CA 체인 누락(depth 1 없음)일 가능성이 높습니다. 브라우저는 AIA 내려받기나 미리 내려받은 중간 CA 목록으로 누락분을 보정하지만 다른 스택은 그러지 않습니다. nginx ssl_certificate가 cert.pem으로 되어 있지 않은지 확인하고 fullchain.pem으로 교체한 뒤 reload하세요. 그 후에도 Verify return code: 0인데 특정 클라이언트만 실패하면 그때부터는 클라이언트 신뢰 저장소 문제입니다.

Q. nginx error.log에 no shared cipher가 계속 찍힙니다. A. 인증서 날짜 문제가 아니라 협상 실패입니다. 서버가 TLS 1.0/1.1을 껐는데 구형 클라이언트가 붙는 경우, 또는 ssl_ciphers에 ECDSA 전용 스위트만 남겨둔 채 RSA 키 인증서를 서빙하는 경우가 대표적입니다. -tls1_2, -tls1_3 옵션으로 어느 버전에서 끊기는지 먼저 확정한 뒤 cipher 목록과 키 타입 조합을 맞추세요.

✦ ✦ ✦
출처·검증 안내

글마다 출처와 확인 범위가 다릅니다. 아래에 확인일·확인 범위·출처가 기록된 글만 그 범위에서 확인된 것입니다. 기록이 없는 글은 적용 전 사용 중인 버전의 공식 문서로 직접 확인하고, 오류는 이메일로 알려주세요. 편집 정책 · 이메일로 제보

이 글에는 사람이 확인한 날짜·범위 기록이 아직 없습니다.

발행 · ·업데이트 ·
주제 관련 공식 문서(자동 연결 · 문장별 근거 아님)NGINX 공식 문서 ↗OWASP 공식 문서 ↗OpenSSL 공식 문서 ↗

댓글

첫 번째 댓글을 남겨보세요.