기록 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>
187 lines
11 KiB
Markdown
187 lines
11 KiB
Markdown
---
|
||
kind: CASE
|
||
slug: renewal-succeeded-while-the-old-certificate-kept-serving
|
||
title: 갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초
|
||
topic: build-completion-judgment
|
||
topicName: 끝났다는 판정
|
||
project: virtualization
|
||
status: 게시 전
|
||
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
|
||
source:
|
||
- final/document.md#190-단계-04-let's-encrypt-와-인증서-갱신
|
||
- final/document.md#189-단계-03-엣지-nginx-라우팅과-호스트-dnat
|
||
- final/document.md#194-이-부에서-파생될-open-question
|
||
---
|
||
|
||
# 갱신은 매번 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 훅이 전부 엣지 게스트에 있다
|
||
|
||
## 재현 조건
|
||
|
||
1. 엣지 게스트에 certbot 과 python3-certbot-dns-cloudflare 를 깔고 와일드카드 인증서를 받는다.
|
||
2. nginx 443 블록에 fullchain.pem 과 privkey.pem 경로를 적고 reload 한다.
|
||
3. systemctl cat certbot-renew.service 로 ExecStartPost 와 --deploy-hook 이 없는지 본다.
|
||
4. ps -eo pid,lstart,args 로 nginx 워커 PID 를 적어 둔다.
|
||
5. sudo certbot renew --force-renewal 을 친다.
|
||
6. 워커 PID 를 다시 본다. 바뀌지 않았으면 옛 인증서가 계속 나가고 있다.
|
||
7. deploy/reload-nginx.sh 를 두고 chmod +x 를 준 뒤 4번부터 6번까지를 다시 한다.
|
||
|
||
## 본문
|
||
|
||
<!-- body:start -->
|
||
|
||
## 배포판 유닛이 어디까지 책임지나
|
||
|
||
Let's Encrypt 인증서는 90일짜리이고 갱신은 `certbot-renew.timer` 가 건다. 타이머가 부르는 유닛을 열어 보면 한 줄이다.
|
||
|
||
```bash
|
||
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` 라고 적혀 있다.
|
||
|
||
훅을 저장소에 두는 까닭도 여기에 있다. 호스트에만 두었을 때는 호스트를 초기화하면 아무 오류 없이 사라졌고, 사라진 것도 갱신이 실제로 일어나는 날까지는 드러나지 않는다.
|
||
|
||
## 훅 두 줄과 실행 권한
|
||
|
||
```sh
|
||
# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||
#!/bin/sh
|
||
nginx -t && nginx -s reload
|
||
```
|
||
|
||
```bash
|
||
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 로 한다
|
||
|
||
```bash
|
||
sudo certbot renew --dry-run
|
||
```
|
||
|
||
dry-run 은 훅이 호출되는지까지만 말해 준다. 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 따로 본다.
|
||
|
||
순서가 dry-run 먼저인 것은 뒤엣것이 상태를 바꾸기 때문이다. `--force-renewal` 은 인증서를 실제로 새로 받아 발급 한도(주당 중복 5장)를 깎으므로, 진짜 판정이 필요할 때 한 번만 쓴다.
|
||
|
||
```bash
|
||
# 강제 갱신 전에 워커 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초 먼저 실행된 것이 되어 물리적으로 불가능했다. 음수 지연이 나오면 계산이 아니라 시계를 의심한다.
|
||
|
||
```bash
|
||
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일 전에야 갱신을 시작한다는 동작에서 끌어낸 것이지, 한 주기를 실제로 돌려 본 값이 아니다. 훅을 뺀 채 만료일까지 가 본 기록은 없고 그런 기록이 있을 까닭도 없다.
|
||
|
||
<!-- body:end -->
|