Let's Encrypt 자동갱신이 실패하는 흔한 이유 — certbot renew 오류별 해결

Let’s Encrypt 만료 알림 메일을 받고서야 갱신 실패를 아는 경우가 대부분이다. 알림이 왔다는 것 자체가 이미 이상 신호다 — 90일 인증서는 60일 시점(만료 30일 전)에 갱신되도록 설계돼 있고, 만료 20일 전 알림 메일은 그 사이 최소 한 번의 갱신 주기가 전부 실패했다는 뜻이기 때문이다.

실패 패턴은 놀랄 만큼 반복적이다. 순서대로 짚는다.

0. 지금 상태부터 확인

sudo certbot renew --dry-run

--dry-run 은 스테이징 서버로 실제 갱신 절차를 리허설한다. 여기서 성공하면 설정은 정상이고 문제는 스케줄러 쪽이다. 실패하면 아래 1~4번, 성공하는데도 서비스가 옛 인증서를 내보내면 5번으로 간다.

1. 타이머 자체가 안 돈다

certbot 을 수동으로 실행하면 되는데 자동으로만 안 되는 경우. 스케줄러가 죽어 있다.

systemctl list-timers | grep certbot     # systemd 타이머 확인
systemctl status certbot.timer
grep -r certbot /etc/cron.* /var/spool/cron 2>/dev/null   # cron 기반이면
  • 타이머가 아예 없음 — 패키지 설치 방식에 따라 타이머가 안 깔리는 경우가 있다 (pip 설치가 대표적). certbot.timer 를 직접 만들거나 cron 에 등록해야 한다
  • dead 상태 — systemctl enable --now certbot.timer
  • 스냅 설치면 snap.certbot.renew.timer 가 대신 존재한다

갱신 성공 여부를 타이머 존재로 판단하지 말 것. 판단 기준은 로그다:

grep -E "renew|error" /var/log/letsencrypt/letsencrypt.log | tail -20

2. HTTP-01: 챌린지 경로가 막혔다

발급 때는 됐는데 갱신에서 실패하는 고전 패턴. 발급 후 서버 설정이 바뀌었기 때문이다.

Let’s Encrypt 는 http://도메인/.well-known/acme-challenge/토큰 을 80 포트로 가져갈 수 있어야 한다. 갱신 실패 로그에 Invalid response 나 Connection refused 가 있으면 이 경로부터 본다.

흔한 원인:

  • HTTP→HTTPS 전면 리다이렉트 도입. 리다이렉트 자체는 따라가 주지만, 리다이렉트된 HTTPS 가 만료 직전 인증서라 검증이 꼬이거나, 리다이렉트 대상이 다른 vhost 로 떨어지는 경우
  • 방화벽에서 80 차단 — “우린 HTTPS만 쓰니까”라며 80을 닫으면 HTTP-01 도 죽는다
  • webroot 경로 변경 — 배포 구조를 바꾸면서 --webroot-path 가 가리키던 디렉토리가 사라진 경우
# 챌린지 경로가 실제로 응답하는지
echo test | sudo tee /var/www/html/.well-known/acme-challenge/probe
curl -s http://example.com/.well-known/acme-challenge/probe   # → test

교정하기 싫으면 챌린지를 바꾸는 것도 방법이다 — 80 포트를 열 수 없는 환경이면 DNS-01 로 전환한다 (아래 4번).

3. 검증 서버가 못 들어온다 — 지역·망 문제

로그에 Timeout during connect 가 찍히면 Let’s Encrypt 검증 서버가 서버에 도달하지 못한 것이다.

  • 국가·IP 대역 차단 — 해외 IP 를 방화벽에서 막아둔 서버. Let’s Encrypt 는 여러 국가의 여러 밴티지 포인트에서 동시에 검증하므로 특정 국가만 허용하는 방화벽에서는 반드시 실패한다. 검증 IP 는 공개되지 않으므로 화이트리스트로는 풀 수 없다 — 80 포트는 전체 개방이 원칙이다
  • IPv6 함정 — AAAA 레코드가 있으면 검증은 IPv6 를 우선한다. DNS 에는 AAAA 가 있는데 서버가 v6 리슨을 안 하면, v4 로는 멀쩡한 사이트가 갱신만 실패한다
dig +short AAAA example.com        # AAAA 가 있는데
curl -6 -m 5 http://example.com/   # v6 로 안 열리면 그게 원인

이 부류가 진단이 가장 어렵다 — 서버 안에서는 모든 게 정상으로 보이기 때문이다. 바깥에서, 다른 망에서 확인해야 잡힌다.

4. DNS-01: API 자격증명 만료

와일드카드 인증서는 DNS-01 만 가능하고, DNS-01 은 DNS 제공자 API 로 TXT 레코드를 넣는다. 그 API 토큰이 만료·폐기되면 갱신이 죽는다.

  • 로그에 Error determining zone / 401 / 403 계열이 찍힌다
  • DNS 제공자를 이전했는데 certbot 플러그인은 옛 제공자를 보고 있는 경우도 같은 부류
ls /etc/letsencrypt/renewal/          # 도메인별 갱신 설정
grep -E "authenticator|dns_" /etc/letsencrypt/renewal/example.com.conf

갱신 설정 파일(renewal/*.conf)은 발급 당시의 방법을 기억한다. 발급 방법을 바꿨으면 이 파일도 따라와야 한다. 파일을 손으로 고치는 것보다 같은 도메인으로 certbot certonly 를 새 방식으로 한 번 더 실행해 덮어쓰는 쪽이 안전하다.

5. 갱신은 됐는데 옛 인증서가 서빙된다

certbot renew 는 성공 로그를 남겼는데 브라우저는 여전히 만료 임박 날짜를 보여주는 경우. 갱신과 적용은 별개 단계다.

certbot 은 /etc/letsencrypt/live/도메인/ 의 symlink 를 새 파일로 돌려놓을 뿐이고, nginx·Apache 는 시작할 때 읽은 인증서를 메모리에 들고 있다. 리로드가 없으면 디스크만 새것이 된다.

# 디스크의 인증서와 서빙 중인 인증서 비교
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -enddate
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -enddate

두 날짜가 다르면 이 케이스다. 해결은 deploy hook — 갱신이 성공했을 때만 실행된다.

# /etc/letsencrypt/renewal-hooks/deploy/reload.sh
#!/bin/sh
nginx -t && systemctl reload nginx

리로드 절차 자체가 불안하면 Nginx 인증서 교체와 무중단 리로드를 먼저 정리하고 오는 게 낫다.

6. 레이트리밋에 걸렸다

실패를 눈치채고 급하게 여러 번 재시도하다 한도에 걸리는 2차 사고.

  • 로그: too many certificates already issued / too many failed authorizations
  • 실패 검증은 시간당 5회 제한 — 원인을 안 고치고 재시도만 반복하면 금방 잠긴다
  • 동일 인증서 세트는 주당 발급 한도가 있다

걸렸으면 기다리는 수밖에 없다. 그래서 재시도 전에 반드시 --dry-run 이다 — 스테이징은 한도가 훨씬 느슨해서, 원인 교정을 리허설로 확인한 뒤 실 발급은 한 번만 한다.

정리 — 갱신 실패를 미리 아는 구조

이 글의 어떤 패턴이든, 만료 알림 메일로 아는 건 이미 늦다. 최소한의 감시는 이렇다.

# 만료 21일 이내면 비정상 (60일 주기 갱신이 실패 중이라는 뜻)
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -checkend $((21*86400)) \
  || echo "ALERT: 갱신 주기가 실패하고 있음"

이걸 cron 에 넣고 슬랙·메일로 쏘면 된다. 감시 기준을 만료일이 아니라 “정상이라면 이미 갱신됐어야 하는 시점” 으로 잡는 것이 핵심이다. 만료일 확인 방법 자체가 처음이면 SSL 인증서 만료일 확인하는 방법부터.