# D-4a 재현 가이드 — 훅 파일 하나가 38분을 1초로 만드는 것을 직접 본다 해설 문서: [`docs/experiment-d4a-deploy-hook.md`](../../experiment-d4a-deploy-hook.md) · 증거 원문: [`docs/evidence/d4a-deploy-hook/`](../../evidence/d4a-deploy-hook/) ## 이 가이드가 끝나면 당신 터미널에서 이것들을 **직접 본다.** | 보게 되는 것 | 어디서 | |---|---| | 훅 디렉터리가 비어 있는 것 → 파일 하나를 넣는 것 | `ls -l` | | certbot 이 **`ran with error output`** 이라고 찍는데 **실패가 아닌** 것 | certbot 출력 원문 | | 마스터는 그대로고 워커만 **자동으로** 갈리는 것 | `ps -eo lstart` | | 서빙 인증서가 **그 자리에서** 바뀌는 것 | `openssl s_client` | | 발급에서 서빙까지 **1~2초**인 것 | SCT + 보정한 훅 시각 | | 보정하지 않으면 **훅이 발급보다 104초 먼저** 돈 것이 되는 것 | 같은 계산 | | `notBefore` 가 **발급 시각이 아닌** 것 | 인증서 필드 | ## 전제 - **[`D-4`](d4-certificate-renewal.md) 를 먼저 한다.** 특히 두 가지가 없으면 이 실험은 성립하지 않는다. - **1-6** — 「reload 판정은 워커 PID 로 한다」는 기준 - **1-7** — 두 기계 시계의 왜곡을 **미리** 재 둔 값 - 관찰은 **dev 에서**, 주입은 **`test-server` 에서 사람이** 친다. - **호스트의 `sudo` 는 비밀번호를 요구한다.** 이 실험의 주입은 전부 그쪽이다. - 이 호스트의 certbot 은 **5.7.0**, **nginx 플러그인은 없다.** ## 주의 — 인증서를 한 장 더 쓴다 `certbot renew --force-renewal` 을 **또** 한 번 친다. D-4 에서 한 번 썼다면 이번이 두 번째이고, **Let's Encrypt 의 주당 중복 인증서 5장 한도를 두 장 쓴 셈**이 된다. 세어 두고, 절차만 확인하려면 `--dry-run` 을 먼저 쓴다. **그리고 이 실험의 주입은 되돌리지 않는 편이 낫다.** 훅은 결함을 고치는 파일이다. 지우면 D-4 의 상태로 돌아간다 — 지우는 명령은 [5-1. 남기는 이유](#5-1-남기는-이유) 에 있다. ## 표시 규약 | 표시 | 뜻 | |---|---| | **실측** | 2026-09-04 12:27 UTC(실제) 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 | | **실측(호스트)** | 증거 파일이 아니라 **이 호스트에서 확인된 설정값** | | **형태** | 값이 매번 달라지는 출력 | | **미검증** | 손으로 치기 좋게 고친 형태이거나, 이 실험이 하지 않은 확장 | ## ★ 시각 표기 규약 **이 실험은 1~2초를 잰다. 106초 어긋난 시계를 섞으면 결과가 뒤집힌다.** | 표기 | 뜻 | |---|---| | `12:27:49 (실제)` | 보정한 값. 외부 기준과 일치 | | `21:29:36 KST (ts)` | test-server 시계. **106초 빠르다** | | `12:29:05 (dev)` | 개발 머신 시계. 보정 불필요 | --- # 0. 왜 이 실험을 하는가 D-4 는 결함을 찾고 **처방을 적어두고 검증하지 않았다.** | D-4 가 남긴 항목 | 상태 | |---|---| | deploy 훅을 넣으면 자동 반영되는가 | **미측정. 훅은 아직 넣지 않았다** | **처방이 듣는지 모르는 채 「이렇게 고치면 된다」고 쓰는 것**은, 이 실험대가 스물세 번 경계해 온 바로 그 실수다. 그래서 별도 실험으로 분리했다. 판정할 것은 셋이다. | # | 질문 | 무엇으로 가르나 | |---|---|---| | ① | 훅이 **실행되는가** | certbot 출력 | | ② | nginx 가 **정말 reload 되는가** | **워커 PID** (문구가 아니라) | | ③ | **얼마나 빠른가** | SCT ↔ 보정한 훅 시각 | --- # 1. 기준선 — 훅을 넣기 전에 ## 1-1. 워커 PID — 판정 기준을 먼저 잡는다 **확인** ```bash ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep" ``` **실측** — [`01-hook-verified.txt`](../../evidence/d4a-deploy-hook/01-hook-verified.txt) ``` 585 1 ... Thu Sep 3 19:00:39 nginx: master process 28829 585 ... Fri Sep 4 18:00:35 nginx: worker process ← D-4 에서 사람이 reload 한 것 ``` **어디를 봐야 하는가** — 마스터 PID 와 워커 PID **두 숫자**, 그리고 워커의 `lstart`. **이 세 값을 적어 둔다. 4-3 의 판정이 이 값과의 비교다.** **이 결과가 의미하는 것** — 워커 28829 는 D-4 에서 **사람이** `nginx -s reload` 를 쳐서 생긴 것이다. 마스터는 여전히 585, 어제 19:00:39 에 뜬 그대로다. **마스터가 유지되고 워커만 바뀌는 것이 reload 의 서명**이라는 것을 D-4 에서 확인했고, 이 실험은 그 기준을 그대로 쓴다. ## 1-2. 지금 서빙 중인 인증서 **확인** ```bash echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \ | openssl x509 -noout -serial -dates ``` **어디를 봐야 하는가** — `serial`. **적어 둔다.** 4-4 에서 이 값이 바뀐다. **확인** — 발급 시각의 외부 기준도 지금 봐 둔다 ```bash 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 ``` **어디를 봐야 하는가** — `Timestamp` 두 줄. **CT 로그가 자기 시계로 서명한 시각**이고, 이 실험대의 두 기계와 무관한 제3의 기준이다. 4-5 에서 이 값이 심판이 된다. ## 1-3. 훅 디렉터리가 비어 있는가 — 사람이 친다 **하기** ```bash ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/' ``` **실측** — [`d4-certificate-renewal/12-certbot-state.txt`](../../evidence/d4-certificate-renewal/12-certbot-state.txt) ``` /etc/letsencrypt/renewal-hooks/deploy/: total 8 drwxr-xr-x 2 root root 4096 2026-09-03 10:46:54.658474560 +0900 . drwxr-xr-x 5 root root 4096 2026-09-03 10:46:54.658520760 +0900 .. ``` **어디를 봐야 하는가** — **`total 8` 과 `.` `..` 뿐.** > **`sudo` 없이 치면 `Permission denied` 다.** 그 빈 출력을 「비어 있다」로 읽는 > 것이 D-4 에서 실제로 걸렸던 함정이다. ## 1-4. ★ 시계 왜곡을 먼저 잰다 — 나중에 재면 값을 해석할 수 없다 **이 실험의 답은 1~2초다.** 시계가 106초 어긋나 있으면 그 답이 통째로 사라진다. **그리고 왜곡은 사후에 되짚을 수 없다** — 지금 재 둔다. **확인** — 왕복 사이에 상대 시각을 끼워 세 번 잰다 ```bash 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) echo "A=$A B=$B C=$C" done ``` **어디를 봐야 하는가** — 세 줄 각각에서 `B` 와 `(A+C)/2` 의 차이를 **눈으로** 뺀다. 그리고 **세 번의 값이 서로 비슷한가** — 흔들리면 네트워크 지연이 섞인 것이고, 안정적이면 진짜 왜곡이다. **확인** — 어느 쪽이 맞는지는 외부 기준으로 가른다 ```bash date -u curl -sI https://www.google.com | grep -i '^date:' curl -sI https://acme-v02.api.letsencrypt.org/directory | grep -i '^date:' ssh test-server 'date -u; timedatectl show -p NTP -p NTPSynchronized' ``` **실측** — [`01-hook-verified.txt`](../../evidence/d4a-deploy-hook/01-hook-verified.txt) ``` dev → Google 차이 +0초 dev → Let's Encrypt ACME 차이 +0초 test-server → Google 차이 -105초 (즉 test-server 가 105초 빠르다) ssh 왕복 왜곡 3회 측정: +106.1 / +106.1 / +106.1초 (안정적) ``` **어디를 봐야 하는가** — `NTPSynchronized`. 이 호스트는 **`no`** 다. 그리고 세 번 다 `+106.1` 로 흔들리지 않았다는 것. **이 결과가 의미하는 것** — **dev 가 정확하고 test-server 가 106초 빠르다.** ``` 실제 시각 = test-server 시계 − 106초 ``` **왜 Let's Encrypt 의 `Date:` 도 보나** — 이 실험이 재는 사건의 한쪽 끝이 **Let's Encrypt 의 발급**이기 때문이다. 그쪽 기준과 dev 가 일치한다는 것을 확인해 두면, 4-5 의 비교가 같은 시간축 위에서 성립한다. --- # 2. 주입 — 파일 하나 **되돌리기 — 먼저 읽는다** ```bash ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh' ``` **단, 지우면 D-4 의 상태로 돌아간다.** 이 파일은 고장이 아니라 고침이다. ## 2-1. 개념 — `pre/` · `deploy/` · `post/` 는 다르다 | 디렉터리 | 언제 실행되나 | |---|---| | `pre/` | 갱신 **시도** 전 | | **`deploy/`** | **실제로 갱신된 인증서가 있을 때만** | | `post/` | 갱신 여부와 **무관하게** 매번 | **왜 `deploy/` 인가.** 타이머는 하루 두 번 돈다. `post/` 에 넣으면 **갱신이 없는 날에도 하루 두 번 nginx 를 reload** 하게 된다 — 아무 이득 없이 워커만 갈아치우는 셈이다. `deploy/` 는 certbot 이 `RENEWED_LINEAGE` 를 넘겨줄 때, 즉 **실제로 갱신했을 때만** 돈다. **없거나 틀리면.** D-4 가 측정한 그대로다 — 갱신은 성공하고 서빙은 안 바뀐다. 그리고 그 상태로 타이머는 `SUCCESS` 를 찍는다. ## 2-2. 왜 `nginx -t &&` 를 앞에 두는가 ```sh nginx -t && nginx -s reload ``` 설정이 깨진 상태에서 `nginx -s reload` 를 보내면 마스터가 **새 워커를 못 띄운다.** `-t` 로 먼저 검사하고 통과할 때만 reload 한다. **실패하면 옛 워커가 그대로 서비스를 계속한다** — 인증서는 안 바뀌지만 **서비스는 죽지 않는다.** 이 순서 하나가 「인증서가 안 바뀐다」와 「사이트가 내려간다」를 가른다. > **`restart` 를 쓰지 않는 이유**도 같다. **실측(호스트)** 로 확인한 > `nginx.service` 의 유효 설정은 `Restart=on-failure` · `RestartUSec=100ms` · > `StartLimitBurst=5` · `StartLimitIntervalUSec=10s` 다. 설정이 깨진 채 > `restart` 를 걸면 **10초 안에 5번 실패하고 systemd 가 포기한다** — nginx 가 > 내려간 채로 멈춘다. ## 2-3. sudo 없는 곳에 파일을 미리 만들어 둔다 **사람이 비밀번호를 치며 실행할 명령은 짧을수록 좋다.** 내용 작성은 sudo 가 필요 없는 곳에서 미리 해 둔다. **하기** ```bash ssh test-server "printf '#!/bin/sh\nnginx -t && nginx -s reload\n' > /tmp/reload-nginx.sh" ssh test-server 'cat /tmp/reload-nginx.sh' ``` **형태** ``` #!/bin/sh nginx -t && nginx -s reload ``` **어디를 봐야 하는가** — 두 줄이 맞게 들어갔는가. **`#!/bin/sh` 가 첫 줄이어야 한다.** > **`/tmp` 를 여기서 쓰는 것은 괜찮다.** 이건 당신의 대화형 셸이 쓰는 `/tmp` > 이기 때문이다. 다만 **`certbot-renew.service` 는 `PrivateTmp=true`** > (**실측(호스트)**)라 **그 서비스가 보는 `/tmp` 은 다른 곳**이다 — 훅이 > 나중에 `/tmp` 에 로그를 남기도록 만들면 **타이머가 돌렸을 때 그 파일을 밖에서 > 찾을 수 없다**(**미검증** — 이 실험은 훅에 로그를 넣지 않았다). > 훅의 로그는 `logger` 로 저널에 보내거나 `/var/log` 아래에 쓴다. ## 2-4. 설치 — 여기부터 사람이 친다 **하기** — 호스트에 붙어서 직접 친다 ```bash ssh -t test-server ``` 호스트의 셸에서: ```bash sudo install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/ ``` **되돌리기** ```bash sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh ``` > 원래 실행은 이 전부를 **한 줄**로 쳤다. 사람이 비밀번호를 한 번만 치게 > 하려는 것이다. 참고로 적어 둔다. > ```bash > 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"' > ``` > **읽기는 어렵다.** 처음 할 때는 위처럼 한 줄씩 치고, 익숙해지면 합친다. --- # 3. 주입이 실제로 걸렸는지 확인한다 **갱신을 걸기 전에** 훅이 제자리에, 실행 가능한 상태로 있는지 본다. **한 번뿐인 강제 갱신을 오타 때문에 날리지 않기 위해서다.** ## 3-1. 파일이 그 자리에 있고 실행 비트가 있는가 **하기** — 호스트 셸에서 ```bash sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/ ``` **형태** ``` total 4 -rwxr-xr-x 1 root root 40 Sep 4 21:2x reload-nginx.sh ``` **어디를 봐야 하는가 — 세 가지다.** - **`x` 비트** (`-rwxr-xr-x`). 없으면 certbot 이 그냥 건너뛴다 - **디렉터리가 `deploy/`** 인가. `post/` 에 들어가면 매번 돈다 - 소유자가 `root` **확인** — 손으로 한 번 돌려 본다. **이게 가장 확실한 사전 점검이다** ```bash sudo /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh ``` **형태** ``` nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful ``` **어디를 봐야 하는가** — `test is successful`. **이때 워커 PID 도 바뀐다** (이 스크립트는 실제로 reload 한다). 1-1 을 다시 재서 새 값을 기준선으로 삼는다. ## 3-2. certbot 이 훅을 부르는지 먼저 본다 **하기** — **미검증**. 원래 실행은 곧바로 강제 갱신을 했다 ```bash sudo certbot renew --dry-run ``` **어디를 봐야 하는가** — 출력에 `Running deploy-hook command` 계열의 줄이 나오는가, 그리고 `simulated renewals` 요약. **이 결과가 의미하는 것** — dry-run 은 **인증서를 발급하지 않고 한도도 안 깎는다.** 훅이 **호출되는지**까지만 말해 준다. **호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다** — 그래서 4절이 필요하다. --- # 4. 효과를 관찰한다 ## 4-1. 강제 갱신 — 사람이 친다 **되돌리기 — 없다.** 인증서 한 장을 실제로 발급한다. **하기** — 호스트 셸에서 ```bash date -u '+%H:%M:%S 갱신 시작 (ts 시계)' sudo certbot renew --force-renewal ``` **시각을 기록하되 어느 시계인지 반드시 적는다.** 호스트에서 찍은 것은 `(ts)` 이고 **106초 빠르다.** ## 4-2. ★ certbot 출력 — 함정이 여기 있다 **실측** — [`02-certbot-with-hook.txt`](../../evidence/d4a-deploy-hook/02-certbot-with-hook.txt) ``` 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, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size 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, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size 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) ``` **어디를 봐야 하는가 — 다섯 줄을 하나씩 읽는다.** | 줄 | 실제 의미 | |---|---| | `Hook 'deploy-hook' ran with error output:` | **훅이 실행됐고, stderr 에 뭔가 있었다** | | `[warn] could not build optimal types_hash` | nginx 의 일반 경고. **갱신과 무관** | | `nginx: … test is successful` | **`nginx -t` 통과** | | `[notice] … signal process started` | **`nginx -s reload` 가 신호를 보냈다** | | `Congratulations, all renewals succeeded` | 갱신 성공 | ### ★ `ran with error output` 은 실패가 아니다 **certbot 은 훅이 stderr 에 무엇이라도 쓰면 이 문구를 붙인다.** 종료 코드를 말하는 것이 아니다. 여기서 stderr 로 나간 것은 nginx 의 `types_hash` 경고뿐이고, **내용은 전부 성공**이다. > **로그에서 `error` 를 grep 하는 감시를 걸어두면 성공한 훅을 실패로 > 오독한다.** 그리고 반대 방향도 위험하다 — 이 실험은 **훅이 진짜로 실패했을 때 > certbot 이 무엇을 찍는지 재지 않았다**(**미검증**). > > **그래서 판정은 문구가 아니라 다음 절의 워커 PID 로 한다.** ## 4-3. 판정 — 워커가 교체됐다 **확인** — 1-1 과 **똑같은 명령** ```bash ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep" ``` **실측** — [`03-after-state.txt`](../../evidence/d4a-deploy-hook/03-after-state.txt) ``` 585 1 95412 Thu Sep 3 19:00:39 2026 nginx: master process /usr/bin/nginx 37252 585 74 Fri Sep 4 21:29:36 2026 nginx: worker process ``` **어디를 봐야 하는가** | | 전 | 후 | 판정 | |---|---|---|---| | 마스터 | **585** | **585** | 그대로 | | 워커 | 28829 | **37252** | **바뀌었다** | | 워커 `lstart` | Fri Sep 4 18:00:35 (ts) | **Fri Sep 4 21:29:36 (ts)** | 방금 떴다 | | 워커 `etimes` | — | **74** | 74초 전 | **이 결과가 의미하는 것** — **마스터 PID 는 유지되고 워커만 바뀌었다.** D-4 에서 「reload 되었는가」를 판정하려고 세운 방법이 **그대로 작동한다.** 그리고 이번에는 사람이 아니라 **훅이** 했다. > **`etimes` 74 를 같이 보는 이유** — PID 는 우연히 재사용될 수 있다. > `lstart` 와 `etimes` 가 「방금」을 가리켜야 진짜 새 워커다. ## 4-4. 서빙 인증서가 바뀌었다 **확인** ```bash echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \ | openssl x509 -noout -serial -dates -ext subjectAltName ``` **실측** — [`03-after-state.txt`](../../evidence/d4a-deploy-hook/03-after-state.txt) ``` serial=06F3E0EF4D1BB03DE58130EAAD1176101373 notBefore=Sep 4 11:29:18 2026 GMT notAfter=Dec 3 11:29:17 2026 GMT X509v3 Subject Alternative Name: DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com ``` **어디를 봐야 하는가** — `serial` 이 1-2 에서 적어 둔 값과 **다른가.** D-4 의 인증서(`06C7CB…EA1D`)에서 바뀌었다. SAN 은 세 이름 그대로다. **이 결과가 의미하는 것** — **훅 하나로 ①②③ 중 ①②가 끝났다.** 남은 것은 「얼마나 빨랐나」다. ## 4-5. ★ 얼마나 빨랐나 — 시계 보정이 여기서 결과를 정한다 **가진 시각은 셋이고, 두 개는 다른 시계에서 왔다.** | 사건 | 원래 값 | 어느 시계 | |---|---|---| | 인증서 발급 | SCT `Sep 4 12:27:49.054 GMT` | **CT 로그** (독립) | | 훅의 `nginx -t` | 로그 `2026/09/04 21:29:36` | **(ts)** | | 새 워커 기동 | `lstart Fri Sep 4 21:29:36` | **(ts)** | | 훅의 `nginx -s reload` | 로그 `2026/09/04 21:29:37` | **(ts)** | **확인** — 새 인증서의 SCT ```bash 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 ``` **실측** — [`01-hook-verified.txt`](../../evidence/d4a-deploy-hook/01-hook-verified.txt) ``` Signed Certificate Timestamp: Sep 4 12:27:49.054 2026 GMT Signed Certificate Timestamp: Sep 4 12:27:49.048 2026 GMT ``` **보정한다** — `(ts)` 값에서 106초를 뺀다. ``` 12:27:49.05 인증서 발급 ← SCT (외부 권위 기준) 12:27:50 훅 nginx -t ← 로그 21:29:36 KST(ts) − 106초 12:27:50 새 워커 37252 기동 ← lstart 21:29:36 KST(ts) − 106초 12:27:51 훅 nginx -s reload ← 로그 21:29:37 KST(ts) − 106초 ``` **어디를 봐야 하는가** — **발급에서 서빙까지 1~2초.** ### 보정이 자기 검증된다 **독립 시계인 SCT 가 보정한 훅 시각의 1초 앞에 정확히 놓인다.** **보정하지 않으면 어떻게 되나** — 훅 로그 `12:29:36 (ts→UTC)` 에서 SCT `12:27:49` 를 빼면 **+107초**, 즉 **훅이 발급보다 104초 먼저 실행된 것**이 된다. **물리적으로 불가능하다.** > **음수 지연이 나오면 계산이 아니라 시계를 의심한다.** 그리고 그 의심을 > 가르는 것은 **제3의 시계**다 — 여기서는 CT 로그의 SCT 였다. ### ★ `notBefore` 로는 계산하지 않는다 인증서에는 `notBefore=Sep 4 11:29:18` 이라고 적혀 있다. **이건 발급 시각이 아니다.** **Let's Encrypt 는 `notBefore` 를 정확히 한 시간 백데이트한다** — 클라이언트 시계가 조금 빨라도 「아직 유효하지 않은 인증서」가 되지 않게 하려는 것이다. 그리고 한 시간을 더한 값(`12:29:18`)을 발급 시각으로 그대로 쓰지도 않는다. 이 실험대의 두 인증서에서 **SCT 는 그보다 일관되게 약 89초 앞섰다.** | 인증서 | `notBefore` | `notBefore` + 1시간 | SCT | 차이 | |---|---|---|---|---| | D-4 이전 것 | `Sep 3 00:47:23` | `01:47:23` | `01:45:53.18` | 약 89.8초 | | D-4a 새것 | `Sep 4 11:29:18` | `12:29:18` | `12:27:49.05` | 약 88.9초 | **이 차이의 원인은 이 실험이 규명하지 않았다.** 다만 **시각의 기준으로는 SCT 를 쓴다** — 그것이 보정을 자기 검증한 값이기 때문이다. **`notBefore` 를 그대로 발급 시각으로 쓰면 한 시간을 잃는다.** ## 4-6. D-4 와의 대조 | | 훅 없음 (D-4) | **훅 있음 (D-4a)** | |---|---|---| | 갱신 → 서빙 | **2305초 = 38분 25초** | **1~2초** | | 무엇이 reload 했나 | 사람이 친 `nginx -s reload` | **certbot deploy 훅** | | 아무도 안 했다면 | 다음 nginx 재시작까지 = **사실상 무기한** | 해당 없음 | | 차이 | | **약 1150배** | **바뀐 것은 파일 하나, 두 줄이다.** ## 4-7. 부수 정정 — D-4 의 2199초는 틀렸다 **이 실험이 시계를 재는 바람에 앞 실험의 숫자가 정정됐다.** D-4 에서 적은 **2199초(36분 39초)** 는 `archive/cert2.pem` 의 mtime (**test-server 시계**)과 일련번호 관측(**dev 시계**)을 **그대로 뺀** 값이었다. | | 시각 (실제 UTC) | |---|---| | 새 인증서 디스크 기록 | **08:20:27** ← mtime `17:22:13 KST (ts)` − 106초 | | 실제 서빙 시작 | 08:58:52 ← dev 관측, 보정 불필요 | | **공백** | **2305초 = 38분 25초** | > **두 시계에서 온 값을 빼면서 그 사실을 적지 않으면, 자릿수가 아니라 방향까지 > 틀릴 수 있다.** D-4 에서는 오차가 106초여서 결론이 안 바뀌었지만, > **1~2초를 재는 D-4a 에서는 결과를 완전히 뒤집었다.** --- # 5. 복구 — 훅은 남긴다 ## 5-1. 남기는 이유 **이 주입은 고장이 아니라 고침이다.** 지우면 D-4 의 상태로 돌아가고, 그 결함은 **다음 실제 갱신(약 89일 뒤)에 인증서 만료로** 나타난다. 정말 지워야 한다면: ```bash ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh' ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/' ``` **어디를 봐야 하는가** — 다시 `total 8`. ## 5-2. 남은 미검증 — 타이머가 스스로 갱신하는 경로 | 항목 | 상태 | |---|---| | `certbot-renew.timer` 가 **실제 갱신**을 하는가 | **미측정.** 만료 30일 전(약 89일 뒤)에야 조건이 성립한다 | 훅은 `--force-renewal` 로 검증했다. **타이머가 스스로 갱신하는 경로**는 시간이 지나야 시험할 수 있다. 다만 그 경로도 **같은 `certbot renew` 를 부르고 같은 `deploy/` 훅을 실행**하므로 남은 미지수는 「타이머가 뜨는가」 하나이고, 그것은 D-4 에서 이미 확인했다(오늘 두 번 `status=0/SUCCESS`). **그날이 오면 무엇을 볼 것인가** — 두 줄이면 된다. ```bash ssh test-server "ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep" echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \ | openssl x509 -noout -serial -enddate ``` **어디를 봐야 하는가** — 워커 `lstart` 가 **갱신 시각 근처인가**, 그리고 `notAfter` 가 밀렸는가. **문구가 아니라 이 둘이다.** ## 5-3. 원상복구 확인표 | 항목 | 명령 | 이렇게 되어 있어야 한다 | |---|---|---| | 훅 | `sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/` | `-rwxr-xr-x … reload-nginx.sh` **(남긴다)** | | nginx | `ps -eo pid,ppid,etimes,lstart,args \| grep nginx:` | 마스터 그대로, 워커 새것 | | 서빙 인증서 | `openssl … -serial -dates` | 4-4 의 새 일련번호 | | 체인 | D-4 1-2 | 4단계, `Verify return code: 0` | | 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` | | 임시 파일 | `ssh test-server 'ls -l /tmp/reload-nginx.sh /tmp/d4a-renew.txt'` | 지워도 된다. 훅은 `/etc` 에 설치됐다 | | 발급 한도 | — | 이번 주에 몇 장 썼는지 세어 둔다 | --- # 막히면 | 증상 | 원인 | 확인 | |---|---|---| | `ran with error output` 을 보고 실패로 판단했다 | **stderr 에 뭔가 있으면 무조건 붙는 문구다** | **워커 PID** — 4-3 | | 훅이 아예 안 불렸다 | `x` 비트가 없거나 `deploy/` 가 아니다 | `sudo ls -l …/deploy/` — 3-1 | | 훅은 돌았는데 워커가 그대로 | `nginx -t` 가 실패해 `&&` 뒤가 안 돌았다 | 훅을 손으로 실행 — 3-1 | | 워커도 마스터도 바뀌었다 | reload 가 아니라 **재시작**됐다 | `lstart` 두 줄을 본다 | | 지연이 음수로 나온다 | **두 시계를 그대로 뺐다** | 1-4 로 돌아간다 | | 발급 시각이 한 시간 어긋난다 | **`notBefore` 를 발급 시각으로 읽었다** | SCT 를 본다 — 4-5 | | 시계 왜곡을 지금 재려는데 값이 흔들린다 | 네트워크 지연이 섞였다 | 3회 이상 재서 안정적인지 본다 — 1-4 | | 호스트 명령이 조용히 빈 결과 | **sudo 가 비밀번호를 못 물었다** | `ssh -t` 로 다시 | | 훅 로그를 `/tmp` 에 썼는데 안 보인다 | **`certbot-renew.service` 는 `PrivateTmp=true`** | `logger` 로 저널에 보내거나 `/var/log` 아래에 쓴다(**미검증**) | | nginx 경고가 계속 거슬린다 | `types_hash_max_size` 기본값 | 갱신과 무관하다. 고치려면 `nginx.conf` 를 손본다 | --- # 이 실험이 남기는 한 문장 **처방을 적었으면 시험한다.** D-4 는 원인을 정확히 셋으로 특정하고 고치는 법까지 적었다. **그 처방이 듣는지 확인하는 데 든 비용은 파일 하나와 명령 두 줄이었다.** 그런데 확인하지 않은 채로 문서에 남았다면, 「고치는 법」 항목은 **다음 갱신일까지 아무도 시험하지 않은 문장**으로 남았을 것이다 — 그리고 그날이 바로 시험할 수 없는 날이다. --- # 다음 | 실험 | D-4a 가 남긴 것 | |---|---| | [D-4](d4-certificate-renewal.md) 인증서 갱신 | **공백 수치가 2305초로 정정됐다** — 4-7 | | [D-3](d3-secret-management.md) 비밀 관리 | 새 `privkey2.pem` 도 같은 문제를 안고 있다 | | [04-TLS](../04-tls/) 구축 단계 | 이 훅은 **구축 절차에 들어가야 한다.** 사후에 붙이는 것이 아니다 | | 관측 | **밖에서 `notAfter` 를 재는 감시**가 로그 감시보다 정직하다 | | 전부 | **판정 기준은 문구가 아니라 상태다.** 여기서는 워커 PID 였다 | | 전부 | **1~2초를 재려면 시계부터 잰다.** 106초는 그 자체로 결론을 뒤집는다 |