docs(d4): the renewal succeeded and was not served for 36 minutes 39 seconds

The forced renewal did work. What did not work was anything making nginx
notice.

  disk     cert2.pem written 2026-09-04 17:22:13 KST
  network  old serial through 08:58:47 UTC, new serial from 08:58:52 UTC
  gap      2199 seconds, and the old certificate was observed 428 times in it

The gap closed only because a person ran `nginx -s reload`. Nothing else
would have: certbot-renew.service is just `ExecStart=/usr/bin/certbot -q
renew` with no ExecStartPost, all three of renewal-hooks/{deploy,post,pre}
are empty, and certbot 5.7.0 here has no nginx plugin (dns-cloudflare,
manual, null, standalone, webroot). Three paths, all empty. Any one of them
would have been enough.

nginx holds the certificate in memory from startup, and certbot swaps the
live/ symlink rather than the path — so the config still looks right while
the served certificate is stale. Master 585 and worker 586 sharing a start
time 22.4 hours old is what proved no reload had happened.

This is invisible for 88 days. The timer ran twice today and exited
0/SUCCESS both times, because a renewal is not due yet. The day it becomes
due, the symptom is an expired certificate and the log still says SUCCESS.

The other half of the plan's question now has a measurement rather than an
expectation. The reload is genuinely graceful: 8856 polled connections, zero
non-200, p95 205.7ms before against 204.3ms after. And an in-flight request
— 845KB pulled at 20k/s, so still transferring 12 seconds in when the reload
landed — completed with all 845361 bytes over a single connection.

The earlier note that nginx reload "is graceful by design but must not be
written that way without checking" was right to hold back. The neighbouring
belief, that renewing gets the new certificate served, was the one that was
false, and there was no way to tell in advance which it would be.

Adds diagrams/d4-renewal-gap.svg and evidence 09-13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-04 18:04:44 +09:00
co-authored by Claude Opus 5
parent 0d84921a56
commit faf55dc1be
9 changed files with 1295 additions and 104 deletions
+155 -95
View File
@@ -10,6 +10,10 @@
![D-4 구조 — 체인과 SAN 제약](diagrams/d4-cert-chain.svg)
**핵심 발견** — 갱신은 성공했는데 36분 39초 동안 반영되지 않았다.
![D-4 — 갱신과 서빙 사이의 공백](diagrams/d4-renewal-gap.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
@@ -21,12 +25,19 @@
|---|---|
| 인증서 구성 | **SAN 3개** (`auth`/`app1`/`app2`), 와일드카드 아님 |
| 체인 완결성 | **정상.** `Verify return code: 0 (ok)`, 4단계 |
| 갱신 자동화 | **동작.** `certbot-renew.timer` enabled·active, 11시간 전 실행됨 |
| 남은 기간 | **88일** (갱신까지 약 58일) |
| **강제 갱신 실측** | **★ 못 했다.** `sudo: a password is required` |
| 갱신 자동화 | 타이머는 **동작한다.** 오늘 두 번 `status=0/SUCCESS` |
| **★ 갱신 반영** | **안 된다.** 새 인증서가 디스크에 있는 채로 **36분 39초** 동안 옛 인증서를 서빙했다 |
| **★ reload 무중단** | **무중단이다.** 8856건 전부 200 · 전송 한가운데였던 42초 요청이 845361바이트 전량 수신 |
**측정한 것과 못 한 것을 나눠 적는다.** 못 한 것을 안 한 것처럼 쓰면
이 기록 전체의 신뢰가 깎인다.
### 한 문장으로
> **"갱신 성공"과 "새 인증서 서빙"은 다른 사건이다.**
> 그리고 이 실험대에는 앞의 사건을 뒤의 사건으로 잇는 것이 **아무것도 없다.**
이게 왜 무서운가 — 이 결함은 **88일 동안 보이지 않는다.** 타이머는 정상이고
매번 `SUCCESS` 로 끝난다. 만료 30일 전까지는 certbot 이 갱신 자체를 하지 않아
"반영이 안 된다"는 사실이 발현할 기회가 없기 때문이다. 발현하는 날의 증상은
**인증서 만료**이고, 그날에도 로그에는 `SUCCESS` 라고 적혀 있을 것이다.
---
@@ -112,115 +123,164 @@ certbot 은 **30일 남았을 때** 갱신한다. 지금 실행돼도 아무것
---
## 4. ★ 못 한 것 — 강제 갱신과 무중단 확인
## 4. ★ 강제 갱신 — 두 개의 답
계획서의 D-4 는 이렇게 적혀 있었다.
### 4-1. 먼저 대조군을 잡았다
주입 전에 평시를 재둔다. **이걸 건너뛰면 주입 결과를 해석할 수 없다**
A-6 에서 −41% 대조군을 "영향 없음"이라 적었던 것도, A-8 에서 표본 9개로
무중단을 주장한 것도 같은 종류의 실수였다.
| 대조군 | 결과 |
|---|---|
| 새 연결 (0.2초 × 900회 / 180초) | **900 전부 200, 오류 0** · 중앙 98ms · p95 195ms |
| 진행 중 요청 (845KB @ 20k/s) | 200 · 845361바이트 · 연결수 1 · **42.3초 완주** |
두 번째 장치가 왜 따로 필요했는가 — 첫 번째 폴링은 **TLS 핸드셰이크가
900/900** 이다. 매 요청이 새 연결이라는 뜻이고, 그래서 "새 연결을 받아주는가"만
잰다. 계획서가 물은 것은 **"진행 중이던 요청은 어떻게 되는가"** 이므로,
reload 순간에 실제로 전송 중인 요청이 있어야 한다. 845KB 짜리 관리 콘솔
번들을 일부러 느리게 받아 요청 하나를 42초 동안 살려 두었다.
### 4-2. 갱신은 성공했는데, 인증서가 바뀌지 않았다
`sudo certbot renew --force-renewal` 을 실행한 뒤에도 밖에서 본 인증서가
그대로였다. 처음에는 갱신이 실패한 줄 알았다. 아니었다.
```
디스크 (/etc/letsencrypt/archive/auth.hyeonworks.com/)
cert1.pem 2026-09-03 10:47:40 KST 처음 발급
cert2.pem 2026-09-04 17:22:13 KST ← 강제 갱신, 새로 써졌다
네트워크 (5초 간격 564표본)
08:10:51 ~ 08:58:47 serial=0520BB…B853 notAfter=Dec 2 ← 옛 것
08:58:52 serial=06C7CB…EA1D notAfter=Dec 3 ← 바뀐 순간
```
| | 시각 (UTC) |
|---|---|
| 새 인증서 디스크 기록 | 08:22:13 |
| 실제 서빙 시작 (`nginx -s reload`) | 08:58:52 |
| **공백** | **2199초 = 36분 39초** |
그 사이 **428번** 옛 인증서로 관측됐다. 그리고 이 36분은 **우연히 짧았을
뿐이다** — reload 를 시킨 것은 사람이지 자동화가 아니다. 아무도 안 했다면
다음 nginx 재시작까지, 즉 무기한 옛 인증서를 서빙했을 것이다.
#### 개념 — 왜 파일이 바뀌어도 nginx 는 모르는가
**무엇인가.** nginx 는 `ssl_certificate` 가 가리키는 파일을 **기동 시점에
한 번 읽어 메모리에 들고 있다.** 요청마다 디스크를 다시 보지 않는다.
**왜 여기 나오나.** certbot 은 `live/` 심볼릭 링크가 새 `archive/` 파일을
가리키도록 바꾼다. **경로는 그대로인데 내용만 바뀐다.** 그래서 nginx 설정을
고칠 필요가 없고, 바로 그 때문에 "설정이 그대로니 괜찮다"고 착각하기 쉽다.
필요한 것은 설정 변경이 아니라 **reload** 다.
**없거나 틀리면.** 인증서가 만료되어 브라우저가 `NET::ERR_CERT_DATE_INVALID`
를 띄운다. 그 시점에 디스크에는 멀쩡한 인증서가 들어 있고 갱신 로그도
`SUCCESS` 다 — 그래서 원인을 찾는 데 오래 걸린다.
**확인.** nginx 는 reload 하면 마스터를 유지한 채 워커만 새로 띄운다.
```bash
ps -eo pid,ppid,etimes,lstart,args | grep nginx
# 585 1 80529 Thu Sep 3 19:00:39 nginx: master process
# 586 585 80529 Thu Sep 3 19:00:39 nginx: worker process
# ↑ 워커가 마스터 기동 직후의 첫 fork(585→586) 그대로 22.4시간째
# = reload 가 한 번도 없었다
```
#### 원인은 하나가 아니라 셋이 겹쳤다
| # | 경로 | 상태 |
|---|---|---|
| 1 | `certbot-renew.service``ExecStartPost` | **없다**`ExecStart=/usr/bin/certbot -q renew` 가 전부 |
| 2 | `/etc/letsencrypt/renewal-hooks/{deploy,post,pre}/` | **셋 다 비어 있다** (`total 8`, `.``..` 뿐) |
| 3 | certbot 의 nginx 플러그인 | **없다**`dns-cloudflare, manual, null, standalone, webroot` |
**세 경로 전부가 비어 있다.** 하나라도 있었으면 자동으로 반영됐다.
certbot 5.7.0 에서 확인한 값이다.
#### 고치는 법
```bash
sudo certbot renew --force-renewal
# 가장 단순한 방법 — deploy 훅 하나
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'EOS'
#!/bin/sh
nginx -t && nginx -s reload
EOS
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
**실행할 수 없었다.**
`deploy/`**실제로 갱신된 인증서가 있을 때만** 실행된다. `post/` 는 갱신
여부와 무관하게 매번 돌므로, 하루 두 번 쓸데없이 reload 하게 된다.
**확인 방법** — 훅을 넣었다고 끝이 아니다. 다음 강제 갱신 때 `nginx` 워커
PID 가 바뀌는지 봐야 한다. 이 실험이 그 확인 절차다.
### 4-3. reload 는 무중단이다 — 측정됨
계획서의 질문에 대한 답이다.
**새 연결** — 0.2초 폴링, 08:10:51 ~ 09:02, **전체 8856건 / 비200 0건**
| 구간 | n | 중앙 | p95 | 최대 |
|---|---|---|---|---|
| 장기 평시 08:20~08:50 | 5398 | 98.0ms | 205.7ms | 1942.9ms |
| reload 직전 2분56초 | 489 | 116.0ms | 200.8ms | 387.7ms |
| reload 직후 2분08초 | 342 | 132.5ms | **204.3ms** | 475.0ms |
p95 가 205.7 → 204.3 으로 **사실상 동일**하고 최대값은 오히려 낮다.
10초 구간 중앙값은 reload 전후 모두 80~190ms 사이를 오간다 — WiFi 잡음이지
reload 의 흔적이 아니다.
**진행 중이던 요청** — 여기가 계획서가 정확히 물은 지점이다.
```
$ sudo -n -l
sudo: a password is required
$ sudo -n systemctl reload nginx
sudo: a password is required
08:58:40 요청 시작 (845KB @ 20k/s)
08:58:52 ← nginx -s reload. 요청 시작 12초 뒤, 전송 한가운데
08:59:21 종료: 코드=200 바이트=845361(전량) 연결수=1 curl종료=0
```
**test-server 의 sudo 는 비밀번호를 요구한다.** 게스트(kc-lab-1/2)는 무암호라
A층에서 `conntrack`·`tc` 를 자유롭게 썼는데, **호스트는 다르다.**
> **이 사실은 B-7 에서 처음 드러났다** — nginx 설정을 읽으려던 시도가 계속
> 빈 결과였고, 그게 **sudo 의 조용한 실패**였다. 여기서 다시 확인된다.
### 아직 답하지 못한 것 — 과 그 앞에서 먼저 한 것
`sudo` 를 못 쓰니 **주입은 못 한다.** 하지만 주입 없이도 할 수 있는 일이
하나 남아 있었고, 그게 사실 더 중요하다 — **대조군**이다.
> 갱신 중에 `000` 이 한 번 나왔다고 하자. 그게 갱신 탓인가?
> **평시 오류율을 모르면 대답할 수 없다.** A-6 에서 41% 를 "영향 없음"이라
> 적었다가 틀린 것도, A-8 에서 표본 9개로 무중단을 주장한 것도 같은 종류의
> 실수였다. 그래서 주입보다 대조군을 먼저 잡는다.
#### 대조군 1 — 새 연결을 받아주는가 (0.2초 × 900회 = 180초)
```
표본 900 개
[상태코드] 900 × 200 ← 오류 0
[응답시간] 최소 67 · 중앙 98 · p95 195 · 최대 1121 ms
[TLS 핸드셰이크] 900회 / 900 ← 매 요청이 새 연결이다
```
**잡음 바닥이 0이다.** 이 뜻은 명확하다 — 갱신 중에 비200 이 단 한 번이라도
나오면, 그건 갱신 탓으로 귀속해도 된다. 대조군이 0이 아니었다면 그렇게 말할 수
없었다.
#### 대조군 2 — 그런데 위 장치는 D-4 의 질문에 답하지 못한다
핸드셰이크가 900/900 이라는 것은 **매 요청이 새 TCP 연결**이라는 뜻이다.
그래서 이 폴링이 재는 것은 "새 연결을 받아주는가" 이지, 계획서가 물은
**"갱신 중 진행 중이던 요청은 어떻게 되는가"** 가 아니다. reload 순간에
실제로 전송 중인 요청이 있어야 잴 수 있다.
장치를 따로 만들었다 — 845KB 짜리 관리 콘솔 번들을 `--limit-rate 20k`
일부러 느리게 받아, 요청 하나를 **42초 동안 살아 있게** 만든다.
```
코드=200 받은바이트=845361 총시간=41.392198s 연결수=1 실효속도=20423B/s
기대 크기 845361 / 실제 845361 bytes
```
주입 시 이 값과 비교해 판정한다.
| 관측 | 뜻 |
| 관측 | 읽는 법 |
|---|---|
| 코드 200 · 크기 845361 · 연결수 1 | 진행 중이던 요청이 끝까았다 = graceful |
| 코드 000 또는 크기 부족 | reload 가 진행 중이던 연결을 끊었다 |
| 연결수 ≥ 2 | 중간에 끊겨 curl 이 다시 붙었다 (사용자에겐 지연으로 보인다) |
| 바이트가 전량이다 | 잘리았다 |
| **연결수가 1이다** | 중간에 끊겨 재연결한 게 아니다 |
| 코드 200 | 옛 워커가 이 요청을 끝까지 책임졌다 |
#### 남은 항목
옛 인증서로 시작한 연결이 **새 워커 전환을 관통해** 끝까지 갔다.
in-flight 전체 50건 중 종료코드 ≠ 0 은 **0건**이다.
| 계획서의 항목 | 상태 |
> 문서가 "nginx 의 reload 는 설계상 무중단이지만 **확인하지 않았으므로 그렇게
> 쓰면 안 된다**"고 유보했던 명제가, 이제 측정으로 뒷받침된다.
> **그리고 유보가 옳았다** — 같은 자리에서 유보 없이 썼다면 맞았겠지만,
> 바로 옆의 "갱신하면 반영된다"는 명제는 틀렸다. 어느 쪽이 틀릴지는 미리 알 수 없다.
### 4-4. 남은 미측정
| 항목 | 상태 |
|---|---|
| 갱신 중 새 연결이 끊기는가 | **대조군 확보** (평시 0/900 실패) · 주입 대기 |
| 갱신 중 진행 중이던 요청은 | **장치 확보** (42초 in-flight) · 주입 대기 |
| `certbot-renew.timer` 가 실제 갱신을 하는가 | **미측정** (58일 뒤에야 알 수 있다) |
주입 명령은 하나뿐이고, 비밀번호가 있는 사람만 실행할 수 있다.
```bash
ssh test-server
sudo certbot renew --force-renewal
```
### 이론적으로는 무엇을 기대하는가
```
certbot renew → 새 인증서 파일 저장
└─ deploy-hook: nginx -s reload
└─ nginx 는 새 워커를 띄우고 옛 워커는 진행 중 요청을 끝낸 뒤 종료
→ graceful. 진행 중 요청은 옛 인증서로 완결된다
```
**nginx 의 reload 는 설계상 무중단**이지만, **확인하지 않았으므로 그렇게
쓰면 안 된다.** 이 실험대에서 반복해 배운 것이 바로 그것이다 —
A-1 의 NetworkPolicy, A-3 의 `--grace-period=0`, B-5 의 AOF 모두
**"그럴 것이다" 가 틀렸던 사례**다.
---
| `certbot-renew.timer`**실제 갱신**을 하는가 | **미측정.** 약 58일 뒤에야 조건이 성립한다 |
| deploy 훅을 넣으면 자동 반영되는가 | **미측정.** 훅은 아직 넣지 않았다 |
---
## 증거 파일
**증거 수집 시각: 2026-09-04 15:09 15:09 KST** (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)
**증거 수집: 2026-09-04 · 감시 08:10:51 09:02 UTC** (파일 mtime 기준)
| 파일 | 종류 |
|---|---|
| [`01-certificate-state.txt`](evidence/d4-certificate-renewal/01-certificate-state.txt) | 터미널 원문 |
| 파일 | 종류 | 무엇을 보여주는가 |
|---|---|---|
| [`01-certificate-state.txt`](evidence/d4-certificate-renewal/01-certificate-state.txt) | 터미널 | SAN 3개 · 체인 4단계 `Verify return code: 0` |
| [`05-control-no-injection.txt`](evidence/d4-certificate-renewal/05-control-no-injection.txt) | 터미널 | **대조군 1** — 900/900 200, 오류 0 · 핸드셰이크 900/900 |
| [`06-inflight-control.txt`](evidence/d4-certificate-renewal/06-inflight-control.txt) | 터미널 | **대조군 2** — 42초 in-flight 장치, 845361바이트 완주 |
| [`07-renewal-hook-missing.txt`](evidence/d4-certificate-renewal/07-renewal-hook-missing.txt) | 터미널 | nginx 워커 PID 로 본 "reload 없음" · 유닛에 `ExecStartPost` 없음 · crt.sh 로는 못 가림 |
| [`08-inflight-artifact.txt`](evidence/d4-certificate-renewal/08-inflight-artifact.txt) | 터미널 | 76건 실패가 **로컬 아티팩트**임을 대조 폴링으로 가려낸 기록 |
| [`09-serial-timeline.txt`](evidence/d4-certificate-renewal/09-serial-timeline.txt) | 터미널 | 일련번호 564표본 — 08:58:52 에 바뀌는 순간 |
| [`10-reload-poll-window.txt`](evidence/d4-certificate-renewal/10-reload-poll-window.txt) | 터미널 | reload 전후 60초 새 연결 원문 (비200 0건) |
| [`11-inflight-full.txt`](evidence/d4-certificate-renewal/11-inflight-full.txt) | 터미널 | in-flight 전체 — reload 를 관통한 08:58:40 요청 포함 |
| [`12-certbot-state.txt`](evidence/d4-certificate-renewal/12-certbot-state.txt) | 터미널 | `certbot certificates` · `archive/` mtime · **훅 3개 디렉터리 전부 비었음** |
| [`13-verdict.txt`](evidence/d4-certificate-renewal/13-verdict.txt) | 터미널 | **판정** — 36분 39초 공백 + reload 무중단, 수치 전량 |
파일별 상세는 [`evidence/d4-certificate-renewal/README.md`](evidence/d4-certificate-renewal/README.md).