# 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).