/보안/certificate has expired 원인 5분기 판정법 (nginx·openssl)
보안certificate has expiredSSL인증서만료

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

certificate has expired, ERR_CERT_DATE_INVALID, no shared cipher를 에러 원문만으로 30초 안에 원인 5가지로 판정하는 표와 openssl 진단 명령, reload 누락 확정 기법, 소켓 기반 만료 감시 스크립트를 정리했습니다.

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

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

새벽 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_certificatefullchain.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)다. 브라우저는 AIA fetching으로 누락된 중간 CA를 스스로 내려받아 보정하지만, 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"
sudo lsof -p "$PID" | grep -i pem
stat -c '%n %y' /etc/letsencrypt/live/example.com/fullchain.pem

ps -o lstart 값이 인증서 파일의 mtime보다 이르면 그 프로세스는 새 인증서를 읽은 적이 없다.

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)

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

Bash
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
done

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;
    ssl_stapling on;
    ssl_stapling_verify on;
}

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

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

Bash
# 키와 인증서가 같은 쌍인지 (두 해시가 같아야 정상)
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: OK

ECDSA 키라면 openssl rsa 대신 openssl ec -noout -text | grep pub -A3로 공개키를 비교한다.

반영 순서

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
# 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.sh

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"

인증서 유효기간을 단계적으로 줄이자는 업계 논의(90일에서 더 짧은 주기로)가 이어지면서 수동 갱신은 사실상 선택지가 아니게 되고 있다. 역설적으로 주기가 짧아질수록 reload 누락 장애의 발생 빈도는 올라간다. 갱신 성공과 서비스 반영은 별개 이벤트이기 때문이다. 구체적인 일정과 정책은 CA/Browser Forum 및 각 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)
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 누락을 절대 잡지 못한다. 감시 대상은 반드시 소켓이어야 한다.

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
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로 누락분을 스스로 받아오지만 다른 스택은 그러지 않습니다. nginx ssl_certificatecert.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 목록과 키 타입 조합을 맞추세요.

확인 정보
✦ ✦ ✦
편집 검토 · Editorial Review

Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.

편집 책임 · Nodelog 기술 편집팀·발행 · ·업데이트 ·

댓글

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