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>
10 KiB
D-4a — deploy 훅은 정말 듣는가
브랜치 feature/keycloak-d4a-deploy-hook ·
증거 docs/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. 주입 — 파일 하나
# /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 를 찍는다.
확인.
sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/ # 비어 있으면 아무도 안 부른다
왜 nginx -t && 를 앞에 두는가
설정이 깨진 상태에서 nginx -s reload 를 보내면 마스터가 새 워커를 못 띄운다.
-t 로 먼저 검사하고 통과할 때만 reload 한다. 실패하면 옛 워커가 그대로
서비스를 계속한다 — 인증서는 안 바뀌지만 서비스는 죽지 않는다.
실행
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. 재현 절차 (명령어)
# ── 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 |
터미널 | 판정 전문 · 시계 보정과 SCT 교차검증 · D-4 대조 |
02-certbot-with-hook.txt |
터미널 | certbot renew --force-renewal 원문 |
03-after-state.txt |
터미널 | 실행 후 nginx 프로세스 · 서빙 인증서 |
파일별 상세는 evidence/d4a-deploy-hook/README.md.