Files
keycloak-pattern/docs/guides/experiments/d4a-deploy-hook.md
T
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

27 KiB
Raw Blame History

D-4a 재현 가이드 — 훅 파일 하나가 38분을 1초로 만드는 것을 직접 본다

해설 문서: docs/experiment-d4a-deploy-hook.md · 증거 원문: docs/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 를 먼저 한다. 특히 두 가지가 없으면 이 실험은 성립하지 않는다.
    • 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. 남기는 이유 에 있다.

표시 규약

표시
실측 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 — 판정 기준을 먼저 잡는다

확인

ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"

실측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. 지금 서빙 중인 인증서

확인

echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
  | openssl x509 -noout -serial -dates

어디를 봐야 하는가serial. 적어 둔다. 4-4 에서 이 값이 바뀐다.

확인 — 발급 시각의 외부 기준도 지금 봐 둔다

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. 훅 디렉터리가 비어 있는가 — 사람이 친다

하기

ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/'

실측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초 어긋나 있으면 그 답이 통째로 사라진다. 그리고 왜곡은 사후에 되짚을 수 없다 — 지금 재 둔다.

확인 — 왕복 사이에 상대 시각을 끼워 세 번 잰다

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 의 차이를 눈으로 뺀다. 그리고 세 번의 값이 서로 비슷한가 — 흔들리면 네트워크 지연이 섞인 것이고, 안정적이면 진짜 왜곡이다.

확인 — 어느 쪽이 맞는지는 외부 기준으로 가른다

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

    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. 주입 — 파일 하나

되돌리기 — 먼저 읽는다

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 && 를 앞에 두는가

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 가 필요 없는 곳에서 미리 해 둔다.

하기

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.servicePrivateTmp=true (실측(호스트))라 그 서비스가 보는 /tmp 은 다른 곳이다 — 훅이 나중에 /tmp 에 로그를 남기도록 만들면 타이머가 돌렸을 때 그 파일을 밖에서 찾을 수 없다(미검증 — 이 실험은 훅에 로그를 넣지 않았다). 훅의 로그는 logger 로 저널에 보내거나 /var/log 아래에 쓴다.

2-4. 설치 — 여기부터 사람이 친다

하기 — 호스트에 붙어서 직접 친다

ssh -t test-server

호스트의 셸에서:

sudo install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/

되돌리기

sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

원래 실행은 이 전부를 한 줄로 쳤다. 사람이 비밀번호를 한 번만 치게 하려는 것이다. 참고로 적어 둔다.

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. 파일이 그 자리에 있고 실행 비트가 있는가

하기 — 호스트 셸에서

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

확인 — 손으로 한 번 돌려 본다. 이게 가장 확실한 사전 점검이다

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 이 훅을 부르는지 먼저 본다

하기미검증. 원래 실행은 곧바로 강제 갱신을 했다

sudo certbot renew --dry-run

어디를 봐야 하는가 — 출력에 Running deploy-hook command 계열의 줄이 나오는가, 그리고 simulated renewals 요약.

이 결과가 의미하는 것 — dry-run 은 인증서를 발급하지 않고 한도도 안 깎는다. 훅이 호출되는지까지만 말해 준다. 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다 — 그래서 4절이 필요하다.


4. 효과를 관찰한다

4-1. 강제 갱신 — 사람이 친다

되돌리기 — 없다. 인증서 한 장을 실제로 발급한다.

하기 — 호스트 셸에서

date -u '+%H:%M:%S 갱신 시작 (ts 시계)'
sudo certbot renew --force-renewal

시각을 기록하되 어느 시계인지 반드시 적는다. 호스트에서 찍은 것은 (ts) 이고 106초 빠르다.

4-2. ★ certbot 출력 — 함정이 여기 있다

실측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 과 똑같은 명령

ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"

실측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 는 우연히 재사용될 수 있다. lstartetimes 가 「방금」을 가리켜야 진짜 새 워커다.

4-4. 서빙 인증서가 바뀌었다

확인

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

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

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

    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일 뒤)에 인증서 만료로 나타난다.

정말 지워야 한다면:

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

그날이 오면 무엇을 볼 것인가 — 두 줄이면 된다.

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.servicePrivateTmp=true logger 로 저널에 보내거나 /var/log 아래에 쓴다(미검증)
nginx 경고가 계속 거슬린다 types_hash_max_size 기본값 갱신과 무관하다. 고치려면 nginx.conf 를 손본다

이 실험이 남기는 한 문장

처방을 적었으면 시험한다.

D-4 는 원인을 정확히 셋으로 특정하고 고치는 법까지 적었다. 그 처방이 듣는지 확인하는 데 든 비용은 파일 하나와 명령 두 줄이었다. 그런데 확인하지 않은 채로 문서에 남았다면, 「고치는 법」 항목은 다음 갱신일까지 아무도 시험하지 않은 문장으로 남았을 것이다 — 그리고 그날이 바로 시험할 수 없는 날이다.


다음

실험 D-4a 가 남긴 것
D-4 인증서 갱신 공백 수치가 2305초로 정정됐다 — 4-7
D-3 비밀 관리 privkey2.pem 도 같은 문제를 안고 있다
04-TLS 구축 단계 이 훅은 구축 절차에 들어가야 한다. 사후에 붙이는 것이 아니다
관측 밖에서 notAfter 를 재는 감시가 로그 감시보다 정직하다
전부 판정 기준은 문구가 아니라 상태다. 여기서는 워커 PID 였다
전부 1~2초를 재려면 시계부터 잰다. 106초는 그 자체로 결론을 뒤집는다