Let's Encrypt 자동갱신이 실패하는 흔한 이유
갱신이 실패한 건 대개 인증서가 만료되고 나서야 안다. Let’s Encrypt는 2025년 6월부터 만료 알림 메일을 보내지 않아서, 따로 감시하지 않으면 경고 없이 만료일을 맞는다. 90일 인증서는 60일 시점(만료 30일 전)에 갱신되도록 설계돼 있다. 만료가 20일 안으로 들어왔는데 인증서가 그대로라면 그사이 갱신 주기가 통째로 실패한 것이다.
dry-run부터
sudo certbot renew --dry-run
--dry-run은 스테이징 서버를 상대로 실제 갱신 절차를 리허설한다. 여기서 성공하면 설정은 정상이고 문제는
스케줄러 쪽이다. 실패하면 로그에 찍힌 오류로 원인을 가린다. 성공하는데도 서비스가 옛 인증서를 내보내면 갱신
뒤 적용 단계를 본다.
dry-run이 실패할 때
HTTP-01 챌린지 경로가 막혔다
발급 때는 됐는데 갱신에서 실패하는 고전적인 경우다. 발급한 뒤에 서버 설정이 바뀌었다.
Let’s Encrypt는 http://도메인/.well-known/acme-challenge/토큰을 80 포트로 가져갈 수 있어야 한다. 갱신 실패
로그에 Invalid response나 Connection refused가 있으면 이 경로부터 본다. 자주 보는 원인은 이렇다.
- HTTP→HTTPS 전면 리다이렉트를 도입했다. 리다이렉트는 80·443 포트로 10번까지 따라가고, HTTPS로 넘어가도 인증서는 검사하지 않는다(만료됐어도 된다). 문제는 리다이렉트 대상이 다른 vhost로 떨어져 토큰 파일이 없을 때다.
- 방화벽에서 80을 막았다. “우린 HTTPS만 쓰니까” 하고 80을 닫으면 HTTP-01도 죽는다.
- 배포 구조를 바꾸면서
--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로 전환한다.
검증 서버가 못 들어온다
로그에 Timeout during connect가 찍히면 Let’s Encrypt 검증 서버가 서버에 닿지 못한 것이다.
해외 IP를 방화벽에서 막아 두는 식으로 국가나 IP 대역을 차단한 서버가 그렇다. Let’s Encrypt는 여러 국가의 여러 밴티지 포인트에서 동시에 검증하므로, 특정 국가만 허용하는 방화벽에서는 실패한다. 검증 IP는 공개되지 않아서 화이트리스트로는 풀 수 없다. 80 포트는 전체에 여는 게 원칙이다.
IPv6도 함정이다. AAAA 레코드가 있으면 검증은 IPv6를 먼저 쓴다. v6 연결 자체가 안 되면 첫 요청은 IPv4로 다시 시도하지만, 리다이렉트를 따라간 다음 요청에는 그 재시도가 없다. v6로 연결은 되는데 엉뚱한 서버나 vhost가 응답해도 다시 시도하지 않는다. 그래서 v4로는 멀쩡한 사이트가 갱신만 실패한다.
dig +short AAAA example.com # AAAA 가 있는데
curl -6 -m 5 http://example.com/ # v6 로 안 열리면 그게 원인
이쪽이 진단하기 가장 어렵다. 서버 안에서는 모든 게 정상으로 보이기 때문이다. 바깥에서, 다른 망에서 확인해야 잡힌다.
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를 새 방식으로 한 번 더 실행해 덮어쓰는 쪽이
안전하다.
dry-run은 성공할 때
타이머가 안 돈다
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
갱신은 됐는데 옛 인증서가 나간다
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으로 고친다. deploy hook은 갱신이 성공했을 때만 실행된다.
# /etc/letsencrypt/renewal-hooks/deploy/reload.sh
#!/bin/sh
nginx -t && systemctl reload nginx
리로드 절차부터 불안하다면 Nginx 인증서 교체와 무중단 리로드를 먼저 보고 오는 게 낫다.
재시도하다 레이트리밋에 걸렸다
실패를 알아채고 급하게 여러 번 재시도하다 한도에 걸리는 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 인증서 만료일 확인하는 법을 먼저 본다.