D-4 named the fix and never ran it. Running it:
/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/sh
nginx -t && nginx -s reload
certbot reports "Hook 'deploy-hook' ran", the master stays at PID 585 and
the worker is replaced 28829 -> 37252, and the served certificate changes.
One trap worth naming: certbot prefixes the hook output with "ran with error
output" whenever the hook writes anything to stderr, and nginx's routine
types_hash warning goes to stderr. Everything inside is success — "test is
successful", "signal process started". A monitor that greps for "error"
would read a working hook as a failure. The worker PID is what to check.
Timing needed a clock correction. test-server has NTP off and runs 106
seconds fast; the dev machine matches Google and the Let's Encrypt ACME
endpoint exactly. Corrected, the hook's nginx -t lands at 12:27:50 UTC
against the new certificate's SCT at 12:27:49.054 — one second. The
correction validates itself: uncorrected, the hook would have run 104
seconds before the certificate existed.
That same skew is why D-4's gap was reported 106 seconds short; corrected to
2305s in the previous commit.
Remaining and left alone: whether certbot-renew.timer performs a real
renewal, which cannot be tested for about 89 days.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
10 KiB
Markdown
267 lines
10 KiB
Markdown
# D-4a — deploy 훅은 정말 듣는가
|
||
|
||
브랜치 `feature/keycloak-d4a-deploy-hook` ·
|
||
증거 [`docs/evidence/d4a-deploy-hook/`](evidence/d4a-deploy-hook/) ·
|
||
2026-09-04 12:27 UTC (실제 시각)
|
||
|
||
D-4 는 결함을 찾고 **처방을 적어두고 검증하지 않았다.**
|
||
처방이 듣는지 모르는 채 "이렇게 고치면 된다"고 쓰는 것은,
|
||
이 실험대가 스물세 번 경계해 온 바로 그 실수다.
|
||
|
||
---
|
||
|
||
## 0. 결론부터
|
||
|
||
| 확인 | 결과 |
|
||
|---|---|
|
||
| 훅이 실행되는가 | **된다.** certbot 이 `Hook 'deploy-hook' ran` 을 찍는다 |
|
||
| nginx 가 reload 되는가 | **된다.** 마스터 585 유지, 워커 28829 → **37252** |
|
||
| **얼마나 빠른가** | **발급 → 서빙 1~2초** (D-4 는 38분 25초) |
|
||
| 함정 | **`ran with error output` 은 실패가 아니다** |
|
||
| **★ 부수 발견** | **test-server 시계가 106초 빠르다.** D-4 의 공백 수치를 정정했다 |
|
||
|
||
---
|
||
|
||
## 1. D-4 가 무엇을 남겼나
|
||
|
||
| 항목 | 상태 |
|
||
|---|---|
|
||
| deploy 훅을 넣으면 자동 반영되는가 | **미측정. 훅은 아직 넣지 않았다** |
|
||
|
||
D-4 는 원인을 셋으로 특정했고(유닛에 `ExecStartPost` 없음 · 훅 3경로 비었음 ·
|
||
nginx 플러그인 없음) 처방을 적었다. **그 처방을 실행해 본 적이 없다.**
|
||
|
||
---
|
||
|
||
## 2. 주입 — 파일 하나
|
||
|
||
```bash
|
||
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||
#!/bin/sh
|
||
nginx -t && nginx -s reload
|
||
```
|
||
|
||
### 개념 — `deploy/` 와 `post/` 는 다르다
|
||
|
||
| 디렉터리 | 언제 실행되나 |
|
||
|---|---|
|
||
| `pre/` | 갱신 **시도** 전 |
|
||
| **`deploy/`** | **실제로 갱신된 인증서가 있을 때만** |
|
||
| `post/` | 갱신 여부와 **무관하게** 매번 |
|
||
|
||
**왜 `deploy/` 인가.** 타이머는 하루 두 번 돈다. `post/` 에 넣으면 갱신이
|
||
없는 날에도 하루 두 번 nginx 를 reload 하게 된다 — 아무 이득 없이 워커만
|
||
갈아치우는 셈이다. `deploy/` 는 **`RENEWED_LINEAGE` 가 있을 때만** 돈다.
|
||
|
||
**없거나 틀리면.** D-4 가 측정한 그대로다 — 갱신은 성공하고 서빙은 안 바뀐다.
|
||
그리고 그 상태로 타이머는 `SUCCESS` 를 찍는다.
|
||
|
||
**확인.**
|
||
```bash
|
||
sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/ # 비어 있으면 아무도 안 부른다
|
||
```
|
||
|
||
### 왜 `nginx -t &&` 를 앞에 두는가
|
||
|
||
설정이 깨진 상태에서 `nginx -s reload` 를 보내면 마스터가 새 워커를 못 띄운다.
|
||
`-t` 로 먼저 검사하고 통과할 때만 reload 한다. 실패하면 **옛 워커가 그대로
|
||
서비스를 계속한다** — 인증서는 안 바뀌지만 서비스는 죽지 않는다.
|
||
|
||
### 실행
|
||
|
||
```bash
|
||
install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/
|
||
certbot renew --force-renewal
|
||
```
|
||
|
||
> **이 스크립트는 실험 자동화가 아니라 시험 대상 자체다.**
|
||
> certbot 은 훅을 파일로만 받는다. 절차는 전부 명령어로 되어 있다.
|
||
|
||
---
|
||
|
||
## 3. 결과 — certbot 이 훅을 실행했다
|
||
|
||
```
|
||
Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
|
||
Renewing an existing certificate for auth.hyeonworks.com and 2 more
|
||
Hook 'deploy-hook' ran with error output:
|
||
2026/09/04 21:29:36 [warn] 37250#37250: could not build optimal types_hash, …
|
||
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
|
||
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||
2026/09/04 21:29:37 [warn] 37251#37251: could not build optimal types_hash, …
|
||
2026/09/04 21:29:37 [notice] 37251#37251: signal process started
|
||
|
||
Congratulations, all renewals succeeded:
|
||
/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success)
|
||
```
|
||
|
||
### ★ 함정 — "ran with error output" 은 실패가 아니다
|
||
|
||
certbot 은 훅이 **stderr 에 무엇이라도 쓰면** 이 문구를 붙인다.
|
||
여기 stderr 로 나간 것은 nginx 의 `types_hash` 경고뿐이고, 내용은 전부 성공이다.
|
||
|
||
| 줄 | 실제 의미 |
|
||
|---|---|
|
||
| `[warn] could not build optimal types_hash` | nginx 의 일반 경고. 갱신과 무관 |
|
||
| `nginx: … test is successful` | **`nginx -t` 통과** |
|
||
| `[notice] signal process started` | **`nginx -s reload` 가 신호를 보냄** |
|
||
|
||
> 로그에서 `error` 를 grep 하는 감시를 걸어두면 **성공한 훅을 실패로 오독한다.**
|
||
> 실패를 확인하려면 문구가 아니라 **워커 PID 를 봐야 한다.**
|
||
|
||
---
|
||
|
||
## 4. 검증 — 워커가 교체됐다
|
||
|
||
| | PID | 기동 시각 (test-server 시계) |
|
||
|---|---|---|
|
||
| 마스터 (전·후 동일) | **585** | Thu Sep 3 19:00:39 |
|
||
| 워커 (전) | 28829 | Fri Sep 4 18:00:35 ← D-4 에서 **사람이** reload |
|
||
| **워커 (후)** | **37252** | Fri Sep 4 21:29:36 ← **훅이 자동으로** |
|
||
|
||
**마스터 PID 는 유지되고 워커만 바뀌었다.** D-4 에서 "reload 되었는가"를
|
||
판정하려고 세운 방법이 그대로 작동한다.
|
||
|
||
서빙 인증서도 바뀌었다.
|
||
|
||
```
|
||
serial=06F3E0EF4D1BB03DE58130EAAD1176101373
|
||
notBefore=Sep 4 11:29:18 2026 GMT
|
||
notAfter=Dec 3 11:29:17 2026 GMT
|
||
SAN: app1 / app2 / auth .hyeonworks.com
|
||
```
|
||
|
||
---
|
||
|
||
## 5. ★ 얼마나 빨랐나 — 그리고 시계 문제
|
||
|
||
훅 로그와 워커 `lstart` 는 **test-server 시계**다. 그런데 그 시계는
|
||
NTP 동기가 꺼져 있다.
|
||
|
||
```
|
||
dev → Google +0초
|
||
dev → Let's Encrypt ACME +0초
|
||
test-server → Google −105초 (test-server 가 빠르다)
|
||
ssh 왕복 3회 측정 +106.1 / +106.1 / +106.1초
|
||
```
|
||
|
||
**dev 가 정확하고 test-server 가 106초 빠르다.**
|
||
|
||
### 독립 검증 — SCT
|
||
|
||
새 인증서에 박힌 Certificate Transparency 서명 시각은 **CT 로그의 자체
|
||
시계**로 찍힌다. 양쪽 어느 것과도 무관한 제3의 기준이다.
|
||
|
||
```
|
||
Signed Certificate Timestamp: Sep 4 12:27:49.054 2026 GMT
|
||
Signed Certificate Timestamp: Sep 4 12:27:49.048 2026 GMT
|
||
```
|
||
|
||
보정한 타임라인 (전부 실제 UTC):
|
||
|
||
```
|
||
12:27:49.05 인증서 발급 ← SCT (외부 권위 기준)
|
||
12:27:50 훅 nginx -t ← 로그 21:29:36 KST − 106초
|
||
12:27:50 새 워커 37252 기동 ← lstart 21:29:36 KST − 106초
|
||
12:27:51 훅 nginx -s reload ← 로그 21:29:37 KST − 106초
|
||
```
|
||
|
||
**발급에서 서빙까지 1~2초.**
|
||
|
||
그리고 **보정이 자기 검증된다** — 독립 시계인 SCT 가 보정한 훅 시각의 1초
|
||
앞에 정확히 놓인다. 보정하지 않으면 훅이 발급보다 **104초 먼저** 실행된 것이
|
||
되어 물리적으로 불가능해진다.
|
||
|
||
### 부수 정정 — D-4 의 2199초는 틀렸다
|
||
|
||
D-4 에서 적은 **2199초(36분 39초)** 는 `archive/cert2.pem` 의 mtime
|
||
(test-server 시계)과 일련번호 관측(dev 시계)을 **그대로 뺀** 값이었다.
|
||
|
||
| | 시각 (실제 UTC) |
|
||
|---|---|
|
||
| 새 인증서 디스크 기록 | **08:20:27** ← mtime 17:22:13 KST − 106초 |
|
||
| 실제 서빙 시작 | 08:58:52 ← dev 관측, 보정 불필요 |
|
||
| **공백** | **2305초 = 38분 25초** |
|
||
|
||
관련 문서를 전부 정정했다.
|
||
|
||
> **두 시계에서 온 값을 빼면서 그 사실을 적지 않으면, 자릿수가 아니라
|
||
> 방향까지 틀릴 수 있다.** D-4 에서는 오차가 106초여서 결론이 안 바뀌었지만,
|
||
> 1~2초를 재는 D-4a 에서는 결과를 완전히 뒤집었다.
|
||
|
||
---
|
||
|
||
## 6. 대조
|
||
|
||
| | 훅 없음 (D-4) | **훅 있음 (D-4a)** |
|
||
|---|---|---|
|
||
| 갱신 → 서빙 | **2305초 = 38분 25초** | **1~2초** |
|
||
| 무엇이 reload 했나 | 사람이 친 `nginx -s reload` | certbot deploy 훅 |
|
||
| 아무도 안 했다면 | 다음 nginx 재시작까지 = **사실상 무기한** | 해당 없음 |
|
||
| 차이 | | **약 1150배** |
|
||
|
||
---
|
||
|
||
## 7. 남는 것
|
||
|
||
| 항목 | 상태 |
|
||
|---|---|
|
||
| `certbot-renew.timer` 가 **실제 갱신**을 하는가 | **미측정.** 만료 30일 전(약 89일 뒤)에야 조건이 성립한다 |
|
||
|
||
훅은 `--force-renewal` 로 검증했다. **타이머가 스스로 갱신하는 경로**는
|
||
시간이 지나야 시험할 수 있다. 다만 그 경로도 같은 `certbot renew` 를 부르고
|
||
같은 `deploy/` 훅을 실행하므로, 남은 미지수는 "타이머가 뜨는가" 하나다 —
|
||
그리고 그것은 D-4 에서 이미 확인했다(오늘 두 번 `status=0/SUCCESS`).
|
||
|
||
---
|
||
|
||
## 8. 재현 절차 (명령어)
|
||
|
||
```bash
|
||
# ── 0. 기준선 — 이 워커 PID 가 바뀌는지가 판정이다
|
||
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
|
||
echo | openssl s_client -connect auth.hyeonworks.com:443 \
|
||
-servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -serial -dates
|
||
|
||
# ── 1. ★ 시계 왜곡을 먼저 잰다. 나중에 재면 값을 해석할 수 없다
|
||
for i in 1 2 3; do
|
||
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
|
||
python3 -c "print(f'왜곡 {$B-($A+$C)/2:+.1f}초')"
|
||
done
|
||
# 어느 쪽이 맞는지는 외부 기준으로 가른다
|
||
for H in https://www.google.com https://acme-v02.api.letsencrypt.org/directory; do
|
||
A=$(date -u +%s)
|
||
D=$(curl -sI --max-time 10 "$H" | grep -i '^date:' | sed 's/^[Dd]ate: *//' | tr -d '\r')
|
||
C=$(date -u +%s)
|
||
python3 -c "print(f'$H 차이 {$(date -u -d \"$D\" +%s)-($A+$C)//2:+d}초')"
|
||
done
|
||
|
||
# ── 2. 훅을 sudo 없는 곳에 미리 만들어 둔다 (사람이 칠 명령을 짧게)
|
||
ssh test-server "printf '#!/bin/sh\nnginx -t && nginx -s reload\n' > /tmp/reload-nginx.sh"
|
||
|
||
# ── 3. 주입 — 여기만 sudo 가 필요하다
|
||
ssh -t test-server 'sudo sh -c "install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/ && certbot renew --force-renewal > /tmp/d4a-renew.txt 2>&1; chmod 644 /tmp/d4a-renew.txt; tail -25 /tmp/d4a-renew.txt"'
|
||
|
||
# ── 4. 판정 — 문구가 아니라 워커 PID 로 본다
|
||
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
|
||
# 마스터 PID 그대로 + 워커 PID 바뀜 = reload 됨
|
||
|
||
# ── 5. 얼마나 빨랐나 — SCT 가 발급 시각의 외부 기준이다
|
||
echo | openssl s_client -connect auth.hyeonworks.com:443 \
|
||
-servername auth.hyeonworks.com 2>/dev/null \
|
||
| openssl x509 -noout -ext ct_precert_scts | grep Timestamp
|
||
# 훅 로그 시각에서 왜곡을 빼고 SCT 와 비교한다
|
||
```
|
||
|
||
---
|
||
|
||
## 증거 파일
|
||
|
||
| 파일 | 종류 | 무엇을 보여주는가 |
|
||
|---|---|---|
|
||
| [`01-hook-verified.txt`](evidence/d4a-deploy-hook/01-hook-verified.txt) | 터미널 | 판정 전문 · 시계 보정과 SCT 교차검증 · D-4 대조 |
|
||
| [`02-certbot-with-hook.txt`](evidence/d4a-deploy-hook/02-certbot-with-hook.txt) | 터미널 | `certbot renew --force-renewal` 원문 |
|
||
| [`03-after-state.txt`](evidence/d4a-deploy-hook/03-after-state.txt) | 터미널 | 실행 후 nginx 프로세스 · 서빙 인증서 |
|
||
|
||
파일별 상세는 [`evidence/d4a-deploy-hook/README.md`](evidence/d4a-deploy-hook/README.md).
|