갱신은 분명히 됐는데 왜 아직도 만료라고 뜨는가
새벽 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 등)는 원인 계층이 다르므로 해당 지점에서 링크만 건다.
적용 범위는 다음과 같다.
| 항목 | 범위 |
|---|---|
| OS | Linux 일반 (RHEL/Rocky 8 |
| 서버 | 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) 소켓에서 실제 체인 확인
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | head -40정상(체인 완전) 출력 예시:
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 누락 출력 예시:
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) 서버가 내려준 날짜만 딱 뽑기
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| openssl x509 -noout -dates -subject -issuer예상 정상 결과:
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로 교차 확인
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)다. 브라우저는 AIA fetching으로 누락된 중간 CA를 스스로 내려받아 보정하지만, curl·Java·구형 모바일 스택은 그렇지 않다.
4) 실제 서빙 중인 인증서 경로 확정
nginx -T 2>/dev/null | grep -nE 'server_name|ssl_certificate' | head -40nginx -T는 include된 모든 설정을 펼쳐서 보여준다. sites-enabled에 예전 vhost가 남아 있어 엉뚱한 경로를 물고 있는 경우가 자주 보고된다.
5) reload 누락 확정 — 파일 vs 소켓 fingerprint 대조
이 두 줄이 이 글에서 가장 실전적인 기법이다.
# 파일 쪽 지문
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 누락 확정. 더 볼 것 없다
보강 근거는 프로세스 기동 시각이다.
ss -tlnp | grep :443
PID=$(pgrep -f 'nginx: master' | head -1)
ps -o pid,lstart,cmd -p "$PID"
sudo lsof -p "$PID" | grep -i pem
stat -c '%n %y' /etc/letsencrypt/live/example.com/fullchain.pemps -o lstart 값이 인증서 파일의 mtime보다 이르면 그 프로세스는 새 인증서를 읽은 적이 없다.
6) no shared cipher 계열 — (d) 확정
nginx error.log에 다음 줄이 있으면 날짜·체인은 잊어도 된다.
SSL_do_handshake() failed (SSL: error:1408A0C1:SSL routines:ssl3_get_client_hello:no shared cipher)지원 프로토콜을 직접 훑어 어디서 끊기는지 본다.
for p in tls1 tls1_1 tls1_2 tls1_3; do
printf '%-8s ' "$p"
echo | openssl s_client -connect example.com:443 -servername example.com -$p 2>&1 \
| grep -qE 'Verify return code|Cipher is' && echo OK || echo FAIL
doneTLS 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가 그대로 터진다.
권장 기준선:
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)
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 붙였을 때:
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뺐을 때:
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 확인:
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| openssl x509 -noout -ext subjectAltNameX509v3 Subject Alternative Name:
DNS:example.com, DNS:www.example.comshop.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) 원인의 대부분
잘못된 설정:
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;
}교정본:
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;
ssl_stapling on;
ssl_stapling_verify on;
}cert.pem은 리프 하나뿐이고 fullchain.pem은 리프 + 중간 CA다. 한 줄 차이로 depth 1이 사라진다.
키-인증서 매칭과 체인 검증
# 키와 인증서가 같은 쌍인지 (두 해시가 같아야 정상)
openssl x509 -noout -modulus -in /etc/letsencrypt/live/example.com/cert.pem | openssl sha256
openssl rsa -noout -modulus -in /etc/letsencrypt/live/example.com/privkey.pem | openssl sha256
# 체인 검증
openssl verify -untrusted /etc/letsencrypt/live/example.com/chain.pem \
/etc/letsencrypt/live/example.com/cert.pem
# 기대 출력: cert.pem: OKECDSA 키라면 openssl rsa 대신 openssl ec -noout -text | grep pub -A3로 공개키를 비교한다.
반영 순서
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 -sha256nginx -t가 실패한 상태에서 reload하면 기존 설정이 유지되어 "고쳤는데 그대로"인 상황이 재현된다. haproxy는 reload 시 소켓 인계 방식에 따라 순간적으로 기존 연결이 유지되므로, 갱신 반영 여부는 반드시 새 연결로 확인한다.
reload를 갱신에 묶기 (정석)
# certbot
sudo certbot renew --deploy-hook "systemctl reload nginx"
# 또는 훅 파일로 고정
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.shacme.sh는 --reloadcmd로 같은 역할을 한다.
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"인증서 유효기간을 단계적으로 줄이자는 업계 논의(90일에서 더 짧은 주기로)가 이어지면서 수동 갱신은 사실상 선택지가 아니게 되고 있다. 역설적으로 주기가 짧아질수록 reload 누락 장애의 발생 빈도는 올라간다. 갱신 성공과 서비스 반영은 별개 이벤트이기 때문이다. 구체적인 일정과 정책은 CA/Browser Forum 및 각 CA 공식 공지를 직접 확인해야 한다.
롤백
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)
cd /etc/letsencrypt/live/example.com
sudo ln -sf ../../archive/example.com/fullchain11.pem fullchain.pem
sudo ln -sf ../../archive/example.com/privkey11.pem privkey.pem
sudo nginx -t && sudo systemctl reload nginx롤백은 이전 인증서가 아직 유효기간 내일 때만 의미가 있다. 만료된 것으로 되돌리면 상황이 더 나빠진다.
재발 방지 — 파일이 아니라 소켓을 감시한다
파일 mtime을 감시하는 스크립트는 (b) reload 누락을 절대 잡지 못한다. 감시 대상은 반드시 소켓이어야 한다.
#!/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}"sudo chmod +x /usr/local/bin/tls-expiry-check.sh
/usr/local/bin/tls-expiry-check.sh예상 정상 출력:
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 등록:
# crontab -e
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 (권장):
# /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# /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.targetsudo systemctl daemon-reload
sudo systemctl enable --now tls-expiry-check.timer
systemctl list-timers tls-expiry-check.timerPersistent=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로 누락분을 스스로 받아오지만 다른 스택은 그러지 않습니다. 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 목록과 키 타입 조합을 맞추세요.
Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
댓글
첫 번째 댓글을 남겨보세요.