환경  Certbot 4.x · Let's Encrypt · 2026-08-30 확인

인증서는 한 번 걸어 두면 잊게 됩니다. 잘 돌 때는 아무 신호도 없으니까요. 그러다 어느 아침에 사이트 전체가 경고 화면으로 바뀝니다. 로그를 뒤져 보면 갱신은 몇 달 전부터 실패하고 있었고, 아무도 그 로그를 안 봤을 뿐이죠. 만료는 서서히 오는 사고인데 티는 한순간에 납니다. 만료일이 오기 전에 확인할 수 있는 자리가 몇 군데 있습니다.

이 목록을 꺼내야 하는 때

웹서버 설정을 손댄 날, 도메인을 새로 붙인 날, 서버를 새 인스턴스로 옮긴 날. 이 세 경우에 꺼내면 됩니다. 갱신이 깨지는 건 대부분 인증서 쪽이 아니라 그 주변이 바뀌었을 때예요.

예전에는 그래도 안전망이 하나 있었습니다. 만료가 가까워지면 메일이 왔으니까요. 그 알림은 2025년 6월 4일로 끝났습니다. 자동 갱신이 자리를 잡았고, 수백만 개의 메일 주소를 쥐고 있는 것 자체가 부담이라는 이유였습니다.

그래서 지금은 갱신이 멈춰도 알려 주는 사람이 없습니다. 크론이 조용히 실패하고, 로그는 쌓이고, 만료일에 전화가 옵니다. 감시를 따로 붙이지 않았다면 그 사이 구간이 통째로 비어 있는 셈이죠.

게다가 인증서 수명은 계속 짧아지는 쪽으로 갑니다. 공개 TLS 인증서의 최대 유효기간을 398일에서 47일까지 줄이는 일정이 2026년 3월에 시작해 2029년 3월에 끝납니다. 갱신 주기가 짧아질수록 손으로 때우는 방식은 버티기 어렵습니다.

만료 알림 메일은 2025년 6월 4일로 끝났습니다. 이제 갱신이 멈춘 걸 알려 주는 건 내가 붙여 둔 점검뿐입니다.

갱신 명령이 실제로 도는지부터 봅니다

설치할 때 타이머나 크론이 같이 깔리니 대개는 그냥 돕니다. 문제는 '대개'입니다. 패키지를 바꿔 설치했거나 스냅에서 apt 로 옮겨 왔다면 등록해 둔 자리가 사라졌을 수 있습니다.

확인은 두 줄이면 끝납니다. 타이머 목록에 갱신 유닛이 있는지, 그리고 다음 실행 시각이 찍혀 있는지. 크론으로 도는 구성이라면 그쪽 파일을 봅니다.

실행 주기는 하루 두 번이 표준입니다. 자주 돌린다고 인증서가 매번 새로 발급되지는 않아요. renew 는 만료가 임박한 것만 갱신하기 때문에 원하는 만큼 자주 돌려도 됩니다.

'임박'의 기준은 버전에 따라 다릅니다. Certbot 4.0.0 부터는 남은 수명이 전체의 3분의 1 미만일 때 갱신 대상으로 봅니다. 수명이 10일 이하인 인증서는 절반이 기준이고요. 90일짜리라면 대략 만료 30일 전부터 갱신이 걸린다는 뜻입니다. 그러니 한 달 가까이 실패해도 겉으로는 멀쩡합니다.

systemctl list-timers | grep -i certbot
# NEXT 열에 다음 실행 시각이 찍혀 있어야 한다

# 크론으로 도는 구성이라면
cat /etc/cron.d/certbot
# 0 */12 * * * ... certbot -q renew

# 마지막으로 뭘 했는지
sudo tail -n 40 /var/log/letsencrypt/letsencrypt.log

타이머가 등록돼 있는 것과 실제로 돌고 있는 건 다른 이야기입니다

리허설은 스테이징에서 돌립니다

갱신이 성공할지는 갱신해 봐야 압니다. 그런데 실제로 발급을 받아 보면 발급 한도를 갉아먹죠. 짧은 시간에 여러 번 시도하면 한도에 걸려 정작 필요할 때 못 받는 상황이 옵니다.

그래서 리허설용 서버가 따로 있습니다. dry-run 을 붙이면 스테이징 서버로 붙어서 실제 발급 없이 전 과정을 밟아 봅니다. 명령줄이나 설정 파일에서 server 를 따로 지정하지 않았다면 그렇습니다.

여기서 걸리는 건 대개 도메인 검증입니다. 80 포트가 막혔거나, 웹서버가 검증 경로를 다른 곳으로 리다이렉트하거나, DNS 가 다른 곳을 가리키고 있거나. 만료 3일 전에 알게 될 일을 지금 알게 되는 겁니다.

특정 인증서만 보고 싶으면 이름을 지정합니다. 이름은 certificates 로 확인합니다. 어떤 도메인이 어느 인증서에 묶여 있고 언제 만료되는지 한 화면에 나옵니다.

sudo certbot certificates
# Certificate Name: example.com
#   Domains: example.com www.example.com
#   Expiry Date: ... (VALID: 41 days)

sudo certbot renew --dry-run --cert-name example.com
# 스테이징으로 붙어 실제 발급 없이 전 과정을 밟는다

실패를 만료일이 아니라 오늘 만나는 방법입니다

dry-run 이 통과하지 못하면 그건 미래의 장애가 아니라 이미 나 있는 고장입니다.

그런데 dry-run 은 배포 훅을 건너뜁니다

여기에 함정이 하나 있습니다. dry-run 이 초록불인데도 실전에서 서비스가 옛 인증서를 계속 물고 있는 경우요. 훅이 도는 범위가 서로 다르기 때문입니다.

pre-hook 과 post-hook 은 dry-run 에서도 기본으로 실행됩니다. 반면 deploy-hook 은 돌지 않습니다. run-deploy-hooks 를 따로 붙여야 돕니다. 그러니 웹서버 reload 를 deploy-hook 에 걸어 뒀다면, 리허설에서는 그 부분을 통째로 안 밟고 지나간 겁니다.

그렇다고 reload 를 post-hook 으로 옮기는 건 답이 아닙니다. post-hook 은 갱신을 시도한 뒤에 무조건 돌아서, 갱신할 게 없던 날에도 하루 두 번씩 웹서버를 흔듭니다. 갱신에 성공했을 때만 돌리려고 있는 게 deploy-hook 이에요.

그러니 리허설을 완전하게 하려면 훅까지 태워 봐야 합니다. 다만 이때는 훅이 진짜로 실행되니, 서비스에 영향이 가는 스크립트라면 시점을 골라서 돌립니다.

# 갱신에 성공했을 때만 도는 자리에 reload 를 건다
sudo certbot renew --deploy-hook "systemctl reload nginx"

# 리허설에서 훅까지 태워 보려면
sudo certbot renew --dry-run --run-deploy-hooks

# 훅 스크립트를 파일로 두는 쪽이 관리가 쉽다
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

dry-run 이 통과했다고 배포 훅까지 확인된 건 아닙니다

갱신 설정 파일이 아직 현실과 맞는가

인증서마다 발급 당시의 선택이 파일로 남습니다. 어떤 방식으로 검증했는지, 웹루트가 어디인지, 어떤 서버로 붙는지 같은 것들이죠. 갱신은 매번 이 파일을 그대로 다시 씁니다.

그래서 발급한 뒤에 웹루트를 옮겼거나 nginx 앞에 다른 걸 세웠다면 그 순간부터 갱신은 실패합니다. 파일에는 예전 경로가 남아 있으니까요. 사람은 바꿨는데 인증서 쪽은 못 들은 셈입니다.

고칠 때 파일을 직접 여는 건 권하지 않습니다. 잘못 손대면 갱신 설정 자체가 망가집니다. 대신 reconfigure 를 씁니다. 바꾼 옵션으로 스테이징에서 먼저 갱신을 시험해 보고, 성공하면 그때 저장합니다. 안전망이 안에 들어 있는 셈이죠.

구버전이라 reconfigure 가 없다면 순서가 하나 늘어납니다. 바꾼 옵션을 명령줄에 붙여 dry-run 을 돌리고, 통과하면 강제 갱신으로 한 번 실제 발급을 받아 설정을 굳힙니다.

sudo cat /etc/letsencrypt/renewal/example.com.conf
# authenticator = webroot
# webroot_path = /var/www/html      ← 지금도 여기가 맞나
# installer = nginx

# 바꿀 때는 파일을 직접 고치지 말고
sudo certbot reconfigure --cert-name example.com \
  --webroot-path /srv/example/public

renewal/*.conf 는 읽어서 확인만 하고, 수정은 명령으로 합니다

웹루트를 옮긴 날 갱신은 그 자리에서 깨지지만, 티는 두 달 뒤에 납니다.

파일은 새것인데 서버가 옛것을 물고 있을 때

갱신은 분명히 성공했다고 나오는데 브라우저에는 여전히 만료된 인증서가 보이는 경우가 있습니다. 로그도 깨끗하고 파일 날짜도 오늘이고요. 이럴 때는 웹서버가 어느 파일을 보고 있는지부터 의심합니다.

발급된 인증서는 archive 아래에 번호를 붙여 차곡차곡 쌓이고, live 아래의 심볼릭 링크가 늘 최신을 가리킵니다. 갱신은 이 링크를 새 파일 쪽으로 옮기는 방식으로 끝납니다.

그런데 설정하다가 파일을 웹서버 디렉터리로 복사해 둔 경우가 꽤 있습니다. 그러면 갱신이 아무리 잘 돌아도 웹서버는 복사본을 계속 읽습니다. 복사하지 말고 live 경로를 직접 가리키라고 문서가 못을 박아 둔 이유죠.

경로가 맞다면 다음은 reload 입니다. 웹서버는 인증서를 뜰 때 읽어서 들고 있으니, 파일이 바뀌어도 다시 읽으라고 하기 전까지는 옛것을 씁니다. 이 자리가 앞에서 본 deploy-hook 입니다.

ls -l /etc/letsencrypt/live/example.com/
# fullchain.pem -> ../../archive/example.com/fullchain3.pem

# 웹서버 설정이 live 경로를 직접 가리키는지 확인한다
grep -rn "ssl_certificate" /etc/nginx/ | grep -v "letsencrypt/live"
# 여기에 뭔가 걸리면 복사본을 보고 있을 가능성이 크다

복사본을 물고 있으면 갱신 성공 로그와 만료 경고가 같이 뜹니다

마지막 확인은 밖에서 합니다

서버 안에서 보는 파일과 밖에서 받는 인증서가 다를 수 있습니다. 앞단에 CDN 이나 로드밸런서가 있으면 특히 그렇죠. 그쪽이 자기 인증서를 따로 들고 있으니까요.

그러니 마지막 판정은 실제로 붙어서 받아 봅니다. 만료일과 발급 대상이 기대한 값인지 보면 됩니다. 이 한 줄이면 파일 확인 열 줄보다 확실합니다.

여러 도메인을 한 서버에 물렸다면 도메인마다 따로 확인합니다. SNI 로 갈라지기 때문에 하나가 맞다고 나머지가 맞지는 않습니다.

이걸 사람이 매번 하기는 어려우니, 남은 날짜를 재서 임계값 아래면 알리는 스크립트를 하나 걸어 둡니다. 만료 알림 메일이 사라진 자리를 메우는 건 결국 이런 장치입니다.

echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
  | openssl x509 -noout -subject -dates
# subject=CN=example.com
# notBefore=... / notAfter=...

# 남은 날짜가 20일 밑이면 실패로 끝나는 형태
end=$(echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
  | openssl x509 -noout -enddate | cut -d= -f2)
left=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
echo "$left days"; [ "$left" -ge 20 ]

판정은 서버 안의 파일이 아니라 밖에서 받은 인증서로 합니다

앞단에 뭔가 서 있으면 서버의 파일과 세상이 보는 인증서는 얼마든지 다를 수 있습니다.

어디까지 맞으면 통과인가

다섯 가지가 맞으면 됩니다. 타이머에 다음 실행 시각이 찍혀 있을 것. dry-run 이 통과할 것. renewal 설정의 경로가 지금 구성과 같을 것. 웹서버가 live 경로를 직접 가리킬 것. 그리고 밖에서 받은 인증서의 만료일이 넉넉할 것.

하나라도 어긋나면 해당하는 자리로 돌아갑니다. 타이머가 없으면 등록부터, dry-run 이 막히면 80 포트와 리다이렉트, 경로가 다르면 reconfigure, 파일은 새것인데 밖은 옛것이면 reload 와 심볼릭 링크입니다.

다 맞았어도 남는 게 하나 있습니다. 갱신이 조용히 멈춘 걸 알아채는 장치요. 알림 메일이 사라진 뒤로는 이게 유일한 안전망입니다. 남은 날짜를 재는 스크립트든 감시 도구의 인증서 체크든, 실패했을 때 사람에게 닿는 경로가 있어야 합니다.

점검 자체는 몇 분이면 끝납니다. 웹서버 설정을 만진 날마다 한 번씩만 돌려 두면, 만료로 사이트가 막히는 아침을 겪을 일은 거의 없습니다.

d=example.com
systemctl list-timers | grep -i certbot          # 다음 실행 시각
sudo certbot renew --dry-run --cert-name "$d"     # 통과
sudo grep -E 'authenticator|webroot_path' /etc/letsencrypt/renewal/"$d".conf
ls -l /etc/letsencrypt/live/"$d"/fullchain.pem    # archive 로 가는 링크
echo | openssl s_client -servername "$d" -connect "$d":443 2>/dev/null \
  | openssl x509 -noout -enddate                  # 밖에서 본 만료일

이 다섯 줄이 다 맞으면 만료일까지는 손댈 게 없습니다

자동 갱신은 걸어 두는 것으로 끝나지 않습니다. 확인하지 않은 자동은 아직 자동이 아닙니다.

조치 후 확인할 것

  • 타이머나 크론에 갱신 명령이 걸려 있고 다음 실행 시각이 찍히는지 본다. renew 는 만료가 임박한 것만 갱신하니 하루 두 번이라도 부담이 없다.
  • certbot renew --dry-run 으로 스테이징에서 먼저 밟아 본다. 여기서 막히면 발급 한도를 쓰기 전에 원인을 찾을 수 있다.
  • dry-run 은 deploy-hook 을 건너뛴다. 웹서버 reload 를 거기 걸어 뒀다면 --run-deploy-hooks 로 한 번은 태워 본다.
  • renewal/*.conf 의 webroot_path 와 authenticator 가 지금 구성과 맞는지 읽어 본다. 바꿀 때는 파일을 직접 고치지 말고 certbot reconfigure 를 쓴다.
  • 웹서버 설정이 /etc/letsencrypt/live 경로를 직접 가리키는지 보고, 마지막은 openssl s_client 로 밖에서 받은 인증서의 만료일로 판정한다.

인증서 만료는 예고가 확실한 사고입니다. 날짜가 정해져 있고, 그 전에 확인할 방법도 다 있죠. 그런데도 매년 누군가는 당합니다. 잘 돌 때 아무 소리도 안 나서 그렇습니다. 알림 메일이 사라진 지금은 더 그렇고요. 웹서버 설정을 만진 날에 위 다섯 줄만 찍어 보면, 만료일 아침에 전화를 받는 쪽에서 빠질 수 있습니다.