Files
keycloak-pattern/docs/guides/experiments/d4a-deploy-hook.md
DongHyeonkaandClaude Opus 5 6f6ab86345 docs(guides): reproduction guides for all 26 experiments
Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.

Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.

The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:

  A-0   emptying the session table without a restart leaves cache entries
        that get counted as replication arriving
  A-2   dropping -o /dev/null fuses body and status into one string
  A-3   presence of "ready to accept connections" instead of its timestamp
  B-2   row count alone reads an UPDATE as nothing having happened
  B-4   tr ',' '\n' splits ["admin","editor"] so only admin is seen
  B-7   no login screen means the cookie died and SSO re-authenticated
  C-1   counting sessions without joining realm counts your own kcadm one
  D-1   kubectl exec without -i restores nothing and still exits 0
  D-4a  "ran with error output" is what success looks like

Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.

Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.

Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:00 +09:00

628 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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초는 그 자체로 결론을 뒤집는다 |