기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만 있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문 종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다. SSOT 결함 둘을 고쳤다. - kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이 「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽 `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다. - virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데 원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다. 기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은 둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더 든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다). 계약을 셋 고쳤다. - kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/** 28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는 것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온 것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 커밋도 28개가 어긋났다). - virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고 재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다. - kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고 넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다. style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져 (실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고, engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고 있었다(Question 기록에서 11.94 → 3.86). verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
11 KiB
kind, slug, title, topic, topicName, project, status, sourceRevision, source
| kind | slug | title | topic | topicName | project | status | sourceRevision | source | |||
|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | renewal-succeeded-while-the-old-certificate-kept-serving | 갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초 | build-completion-judgment | 끝났다는 판정 | virtualization | 게시 전 | 9465582b5d1630eb4ae7c4e078021486919bf6b6 |
|
갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초
갱신은 매번 SUCCESS 로 끝나는데 nginx 는 옛 인증서를 계속 내보냈다. certbot-renew.service 에 훅이 없어 갱신에서 서빙까지 2305초 걸렸고, deploy 훅을 두니 1~2초였다. 만료 30일 전까지는 갱신을 안 해 88일 동안 아무 표시도 나지 않는다.
관계
- 인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다 이 인증서를 어떻게 받기로 했는지가 그 결정이고, 여기서는 받은 뒤의 갱신을 다룬다.
- 도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다 갱신 로그의 SUCCESS 와 훅 로그의 error output 이 둘 다 상태를 잘못 말한 사건이다.
- 가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다 이 단계의 확인을 어느 기계에서 치느냐가 결과를 바꾸고, 층별 확인이 그 순서를 정한다.
- 단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다 훅을 호스트에 두었다가 잃은 것도, 확인 명령을 엣지 안에서 친 것도 그 규칙이 받는 결함이다.
- 가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가 2305초를 잰 배치와 지금 배치가 다르므로 다시 세운 실험대에서 재야 할 값이 여기 있다.
문제
인증서 갱신은 매번 성공으로 끝나는데 서버가 내보내는 인증서는 옛것이었다.
certbot-renew.timer : 정상 certbot-renew.service 의 결과 : SUCCESS 유닛의 ExecStartPost 또는 --deploy-hook : x nginx 가 새 인증서를 읽었나 : x 갱신에서 서빙까지 : 2305초 (38분 25초) 무엇이 reload 했나 : 사람이 직접
발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 SUCCESS 라고 적혀 있다.
결론
certbot-renew.service 는 /usr/bin/certbot -q renew 한 줄이고 인증서를 새로 받는 데까지만 책임진다. nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있고 certbot 은 live/ 심볼릭 링크만 갈아 끼우므로, 경로는 그대로이고 내용만 바뀌어 nginx 는 바뀐 줄 모른다.
훅도 사람도 없으면 다음 nginx 재시작까지, 사실상 무기한으로 옛 인증서가 나간다.
해결 : /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh 에 nginx -t && nginx -s reload 를 두고 chmod +x 를 준다 실행 권한이 없으면 : certbot 이 조용히 건너뛴다 post/ 가 아니라 deploy/ 인 까닭 : post/ 는 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치운다 훅을 저장소에 두는 까닭 : 호스트에만 두었을 때는 호스트를 초기화하면 아무 오류 없이 사라졌다 판정 : 로그 문구가 아니라 강제 갱신 전후의 nginx 워커 PID 로 한다
훅을 넣어도 안전했다. reload 는 무중단이었고 새 연결 8856건이 전부 200, p95 는 205.7ms 대 204.3ms 로 변화가 없었다.
검증 환경
엣지 게스트 : kc-lab-edge, 192.168.122.10, Debian 12 nginx : nginx/1.22.1 certbot 플러그인 : dns-cloudflare 발급 대상 : -d hyeonworks.com -d '*.hyeonworks.com' lineage 디렉터리 : /etc/letsencrypt/live/hyeonworks.com/ nginx 가 읽는 파일 : fullchain.pem · privkey.pem 타이머 : certbot-renew.timer → certbot-renew.service 인증서 유효기간 : 오늘 + 90일 2305초를 잴 때의 배치 : 훅이 물리 호스트에 있었다 지금의 배치 : certbot · 인증서 · 갱신 타이머 · deploy 훅이 전부 엣지 게스트에 있다
재현 조건
- 엣지 게스트에 certbot 과 python3-certbot-dns-cloudflare 를 깔고 와일드카드 인증서를 받는다.
- nginx 443 블록에 fullchain.pem 과 privkey.pem 경로를 적고 reload 한다.
- systemctl cat certbot-renew.service 로 ExecStartPost 와 --deploy-hook 이 없는지 본다.
- ps -eo pid,lstart,args 로 nginx 워커 PID 를 적어 둔다.
- sudo certbot renew --force-renewal 을 친다.
- 워커 PID 를 다시 본다. 바뀌지 않았으면 옛 인증서가 계속 나가고 있다.
- deploy/reload-nginx.sh 를 두고 chmod +x 를 준 뒤 4번부터 6번까지를 다시 한다.
본문
배포판 유닛이 어디까지 책임지나
Let's Encrypt 인증서는 90일짜리이고 갱신은 certbot-renew.timer 가 건다. 타이머가 부르는 유닛을 열어 보면 한 줄이다.
systemctl cat certbot-renew.service
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew
PrivateTmp=true
ExecStartPost 도 --deploy-hook 도 없다. 이 유닛은 인증서를 새로 받는 데까지만 책임지고, 받은 것을 nginx 가 읽게 만드는 일은 아무도 하지 않는다. 배포판이 이렇게 준다는 것이 요점이라, 「기본값이니 괜찮겠지」가 이 결함이 사는 곳이다.
그래서 이 글의 훅이 모든 배포판에 필요하지는 않다. 위 유닛에 ExecStartPost 나 --deploy-hook 이 이미 적혀 있는 배포판이라면 훅을 새로 넣을 것이 아니라 거기 적힌 명령이 nginx 를 reload 하는지만 본다.
nginx 는 인증서를 언제 읽나
nginx 는 ssl_certificate 에 적힌 파일을 기동과 reload 시점에 읽어 메모리에 들고 있고, certbot 은 live/ 아래의 심볼릭 링크를 새 파일로 갈아 끼운다. 설정에 적힌 경로는 한 글자도 바뀌지 않고 그 경로가 가리키는 파일만 바뀌므로, nginx 쪽에서는 다시 읽을 계기가 생기지 않는다. 갱신이 끝난 뒤에 nginx 로 reload 를 걸어 주는 것이 없으면 옛 인증서가 계속 나간다.
88일 동안 이 결함이 보이지 않는다
타이머는 정상이고 갱신은 매번 SUCCESS 로 끝난다. 만료 30일 전까지는 갱신 자체를 하지 않으므로 88일 동안 발현할 기회가 없다. 발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 SUCCESS 라고 적혀 있다.
훅을 저장소에 두는 까닭도 여기에 있다. 호스트에만 두었을 때는 호스트를 초기화하면 아무 오류 없이 사라졌고, 사라진 것도 갱신이 실제로 일어나는 날까지는 드러나지 않는다.
훅 두 줄과 실행 권한
# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/sh
nginx -t && nginx -s reload
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
실행 권한이 없으면 certbot 이 조용히 건너뛴다. post/ 가 아니라 deploy/ 에 넣는 것은 post/ 가 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치우기 때문이다.
이 실험대에서 잰 차이는 이렇다.
| 훅 없음 | 훅 있음 | |
|---|---|---|
| 갱신 → 서빙 | 2305초 (38분 25초) | 1~2초 |
| 무엇이 reload 했나 | 사람이 직접 | certbot deploy 훅 |
훅도 사람도 없었다면 다음 nginx 재시작까지, 사실상 무기한이다.
판정은 로그 문구가 아니라 워커 PID 로 한다
sudo certbot renew --dry-run
dry-run 은 훅이 호출되는지까지만 말해 준다. 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 따로 본다.
순서가 dry-run 먼저인 것은 뒤엣것이 상태를 바꾸기 때문이다. --force-renewal 은 인증서를 실제로 새로 받아 발급 한도(주당 중복 5장)를 깎으므로, 진짜 판정이 필요할 때 한 번만 쓴다.
# 강제 갱신 전에 워커 PID 를 적어 둔다
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
sudo certbot renew --force-renewal
# 워커 PID 가 바뀌었으면 reload 된 것이다
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
reload 는 마스터를 그대로 두고 워커만 갈아 끼우므로, 전후로 워커 PID 가 바뀌면 새 설정이 적용됐다. lstart 를 같이 뽑는 것은 PID 가 우연히 재사용됐을 때를 가르기 위해서다.
certbot 이 Hook 'deploy-hook' ran with error output 이라고 찍는데 실패가 아니다. nginx 의 types_hash 경고가 stderr 로 나갔을 뿐이고 내용은 test is successful 과 signal process started 다. 로그에서 error 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.
훅을 넣어도 안전한가
갱신 때마다 reload 가 도는 것이 서비스에 영향을 주는지도 쟀다. 새 연결 8856건이 전부 200 이었고 p95 는 205.7ms 대 204.3ms 로 변화가 없었다. 845KB 를 20k/s 로 받는 중이던 요청은 전송 12초째에 reload 를 맞고도 845361바이트를 온전히 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임진다.
수치를 내기 전에 시계를 쟀다
갱신 시각과 reload 시각이 다른 기계에 찍히므로, 두 시각을 빼기 전에 시계부터 쟀다. test-server 가 NTP 미동기로 106초 빨랐고, 보정하지 않은 첫 계산은 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로 불가능했다. 음수 지연이 나오면 계산이 아니라 시계를 의심한다.
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
# 왜곡 ≈ B − (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다
curl -sI https://www.google.com | grep -i '^date:'
timedatectl show -p NTP -p NTPSynchronized
확인하지 못한 것
2305초는 훅이 물리 호스트에만 있던 시절의 값이다. 지금은 certbot 과 인증서와 갱신 타이머와 deploy 훅이 전부 엣지 게스트에 있고, 그 배치에서 다시 재면 같은 수가 나오는지는 재지 않았다. 그래서 이 글의 2305초는 훅이 없으면 이만큼 벌어진다는 한 번의 측정이고 지금 배치의 값이 아니다. 1~2초 쪽도 같은 시기에 잰 값이다.
측정 출력을 final/evidence/ 에 남기지 않았다. 2305초도 8856건도 p95 두 값도 845361바이트도 106초도 SSOT 본문에만 있다. 워커 PID 를 전후로 비교한 출력도 저장해 두지 않았다.
88일이라는 잠복 기간은 만료 30일 전에야 갱신을 시작한다는 동작에서 끌어낸 것이지, 한 주기를 실제로 돌려 본 값이 아니다. 훅을 뺀 채 만료일까지 가 본 기록은 없고 그런 기록이 있을 까닭도 없다.