Files
keycloak-pattern/docs/guides/experiments/d4-certificate-renewal.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

50 KiB
Raw Blame History

D-4 재현 가이드 — 갱신은 성공했는데 왜 옛 인증서가 나가는지 직접 본다

해설 문서: docs/experiment-d4-certificate-renewal.md · 증거 원문: docs/evidence/d4-certificate-renewal/

이 가이드가 끝나면

당신 터미널에서 이것들을 직접 본다.

보게 되는 것 어디서
이름 세 개가 한 인증서에 들어 있는 것 (와일드카드 아님) openssl s_client
체인이 4단계이고 Verify return code: 0 인 것 같은 명령
타이머는 SUCCESS 인데 reload 를 부르는 것이 아무 데도 없는 systemctl cat · 훅 3개 디렉터리
nginx 워커가 22.4시간째 그대로인 것 = reload 가 한 번도 없었다 ps -eo lstart
두 기계 시계가 106초 어긋나 있는 것 date · Date: 헤더
갱신에 성공했는데 밖에서 본 일련번호가 안 바뀌는 5초 간격 감시
사람이 reload 한 그 순간 바뀌는 것 같은 감시
reload 가 정말 무중단인 것 0.2초 폴링 · 42초짜리 전송 중 요청

전제

  • 이 실험만은 클러스터가 아니라 호스트를 본다. kubectl 은 한 번도 안 쓴다.
  • 관찰은 당신 개발 머신(dev)에서 한다. 밖에서 본 것이 이 실험의 답이고, dev 의 시계가 이 실험대에서 유일하게 정확한 시계이기 때문이다(1-7).
  • 호스트(test-server)에는 ssh test-server 로 붙는다.
  • 호스트의 sudo 는 비밀번호를 요구한다. 게스트(kc-lab-1/2)는 무암호지만 호스트는 다르다. 그래서 몇 단계는 사람이 직접 쳐야 한다 — 2-1 에 표로 있다.
  • 이 호스트의 certbot 은 5.7.0, 플러그인은 dns-cloudflare · manual · null · standalone · webroot 다. nginx 플러그인은 없다.
  • 04-TLS 단계를 이미 밟았다면 여기 나오는 결론이 그 문서의 5절에 요약되어 있다. 이 가이드는 그것을 어떻게 측정했는지를 적는다.

주의 — 이건 진짜 인증서를 발급하는 실험이다

certbot renew --force-renewal되돌릴 수 없다. 새 인증서가 실제로 발급되고, Let's Encrypt 의 발급 한도(주당 중복 인증서 5장)를 한 장 깎는다. 그러므로

  • 먼저 --dry-run 으로 절차만 확인한다(2-2),
  • 강제 갱신은 이 실험 전체에서 한 번만 쓴다,
  • 그 한 번을 헛되게 쓰지 않도록 대조군을 먼저 잡는다(1-8).

옛 인증서는 무효가 되지 않는다. 만료 전까지는 그대로 유효하므로 서비스가 깨지지는 않는다. 다만 되돌릴 수 없으므로 순서를 지킨다. 밖에서 보이는 인증서를 디스크와 다시 맞추는 것은 5-1. reload 한 줄이다.

표시 규약

표시
실측 2026-09-04 감시 구간 08:10:5109:02 UTC 실행 기록의 출력 원문. 증거 파일에 그대로 있다
실측(호스트) 증거 파일이 아니라 이 호스트에서 확인된 설정값
형태 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다
미검증 손으로 치기 좋게 이 가이드에서 고친 형태이거나, 이 실험이 하지 않은 확장

★ 시각 표기 규약 — 이 가이드에만 있다

이 실험은 두 기계의 시계를 섞어 빼는 바람에 숫자를 한 번 틀렸다. 그래서 이 문서는 모든 시각에 어느 시계인지 붙인다.

표기
08:58:52 (dev) 개발 머신 시계. 외부 기준과 일치한다
17:22:13 KST (ts) test-server 시계. 106초 빠르다
08:20:27 (실제) 보정한 값

0. 왜 이 실험을 하는가

인증서 갱신 자동화는 대개 여기까지 확인하고 끝난다.

systemctl list-timers certbot-renew.timer     # 돈다
journalctl -u certbot-renew.service           # SUCCESS

이 실험은 그 뒤를 묻는다. 갱신된 인증서를 누가 서버에 읽히는가.

   ① certbot 이 새 인증서를 받는다        ← 타이머가 책임진다
   ② 파일이 디스크에 써진다               ← certbot 이 한다
   ③ nginx 가 그 파일을 다시 읽는다        ← ★ 누가?

「갱신 성공」과 「새 인증서 서빙」은 다른 사건이다. ③ 을 하는 것이 아무것도 없으면, ①②는 매번 성공하고 사용자는 만료된 인증서를 본다.

그리고 이 결함은 88일 동안 보이지 않는다. 타이머는 매일 두 번 돌고 매번 SUCCESS 로 끝난다. 만료 30일 전까지는 갱신 자체를 하지 않으므로 발현할 기회가 없고, 발현하는 날의 증상은 인증서 만료다 — 그날에도 로그는 SUCCESS 라고 적혀 있다.

부수 질문이 하나 더 있다. ③ 을 실제로 하면(= nginx reload) 진행 중이던 요청은 어떻게 되는가. 「nginx reload 는 무중단」이라고 다들 말하지만 이 실험대는 그것을 재 본 적이 없었고, 재 보지 않은 명제는 쓰지 않는다는 규칙에 따라 유보해 뒀다. 여기서 잰다.


1. 기준선 — 강제 갱신을 걸기 전에

사람이 칠 수 있는 명령은 사실상 한 번뿐이다(강제 갱신). 그 한 번을 헛되게 쓰지 않으려면 주입 전에 잴 것을 전부 재 둬야 한다.

인증서 → 체인 → 이름 → 타이머 → ★ 누가 reload 하나 → 워커 PID → ★ 시계 → 대조군

1-1. 밖에서 본 인증서 — 읽는 형태부터

확인 — 처음 한 번은 협상 과정을 통째로 읽는다

curl -v https://auth.hyeonworks.com/realms/master -o /dev/null

* 로 시작하는 줄에서 TLS 판·subject·issuer·SSL certificate verify ok. 를 본다. TLS 에서 막힐 때 봐야 할 것이 전부 여기 있다.

이제 인증서 자체를 뜯는다.

확인

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

실측01-certificate-state.txt

subject=CN = auth.hyeonworks.com
issuer=C = US, O = Let's Encrypt, CN = YE2
notBefore=Sep  3 00:47:23 2026 GMT
notAfter=Dec  2 00:47:22 2026 GMT
X509v3 Subject Alternative Name:
    DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com

어디를 봐야 하는가

  • serial — 이 값이 바뀌는 것이 「새 인증서를 서빙한다」의 정의다. 적어 둔다. 감시 전체가 이 값을 본다
  • notAfter — 만료
  • SAN 이 세 줄이고 와일드카드가 아니다

notBefore 를 발급 시각으로 읽지 않는다

Let's Encrypt 는 notBefore 를 정확히 한 시간 백데이트한다. 클라이언트 시계가 조금 빨라도 「아직 유효하지 않은 인증서」가 되지 않게 하려는 것이다.

즉 위 인증서의 notBefore=Sep 3 00:47:23발급 시각이 아니다. 그렇다고 여기에 한 시간을 더한 값을 발급 시각으로 그대로 쓰지도 않는다 — 이 실험대의 두 인증서에서 CT 로그의 SCT 가 그보다 약 89초 앞선다(1-1 뒤의 확인, 그리고 D-4a 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

실측07-renewal-hook-missing.txt

    Log ID: C2:31:7E:57:...:52:CD   Timestamp: Sep  3 01:45:53.183 2026 GMT
    Log ID: 46:AF:86:3D:...:50:5F   Timestamp: Sep  3 01:45:53.352 2026 GMT

어디를 봐야 하는가Timestamp 두 개. 이건 CT 로그가 자기 시계로 찍은 시각이며, 이 실험대의 어느 기계와도 무관한 제3의 기준이다. 1-7 에서 시계가 어긋난 것이 드러났을 때 이 값이 심판이 된다.

1-2. 체인이 완전한가 — 흔한 실수 하나

확인

echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
  | grep -E '^ *[0-9]+ s:|^ *i:|Verify return code'

실측01-certificate-state.txt

 0 s:CN = auth.hyeonworks.com
 1 s:C = US, O = Let's Encrypt, CN = YE2
 2 s:C = US, O = ISRG, CN = Root YE
 3 s:C = US, O = Internet Security Research Group, CN = ISRG Root X2
Verify return code: 0 (ok)

어디를 봐야 하는가번호가 몇까지 가는가, 그리고 마지막 줄.

파일 내용 nginx 에 넣으면
cert.pem 리프만 일부 클라이언트에서 검증 실패
fullchain.pem 리프 + 중간 정상

단계가 1개면 cert.pem 을 쓴 것이다. 브라우저는 중간 인증서를 캐시하거나 AIA 로 보완해서 대개 정상으로 보이고, 캐시가 없는 클라이언트(모바일 앱, curl, 다른 서버)에서만 깨진다. 그래서 발견이 늦다. 이 명령이 유일하게 믿을 수 있는 판정이다.

이 실험대는 4단계로 정상이다.

1-3. 이름 세 개가 한 장인가

확인

for H in auth app1 app2; do
  echo "-- $H.hyeonworks.com"
  echo | openssl s_client -connect $H.hyeonworks.com:443 -servername $H.hyeonworks.com 2>/dev/null \
    | openssl x509 -noout -serial
done

어디를 봐야 하는가 — 세 일련번호가 서로 같은가. 값 자체는 의미가 없고 일치 여부만 본다.

이 결과가 의미하는 것 — 같으면 SAN 하나에 이름 셋이 든 한 장이고, 갱신도 한 번에 끝난다. 다르면 인증서가 여러 장이라 훅도 장마다 돌고, 한 장만 갱신됐을 때 나머지 이름이 만료되는 상황이 생긴다.

이 제약이 B-7 에서 실제 비용을 만들었다. oauth2-proxy 를 올릴 네 번째 호스트명이 없어 Grafana 가 쓰던 app2 를 빌려야 했고, 그동안 관측 스택의 웹 UI 가 내려가 있었다.

「인증서에 이름을 몇 개 넣을 것인가」는 TLS 설정이 아니라 나중에 무엇을 배포할 수 있는가를 정하는 결정이다.

1-4. 갱신 자동화는 도는가

여기까지는 sudo 없이 읽힌다. 실제로 이 실험대가 그 범위에서 다 읽었다.

확인

ssh test-server 'systemctl list-timers certbot-renew.timer'

실측01-certificate-state.txt

NEXT                            LEFT      LAST                         PASSED   UNIT
Fri 2026-09-04 17:03:46 KST     1h 54min  Fri 2026-09-04 03:19:39 KST  11h ago  certbot-renew.timer
타이머 enabled: enabled
타이머 active: active

어디를 봐야 하는가NEXT/LEFT 가 채워져 있는가, LAST/PASSED 가 하루 안쪽인가. 표가 통째로 비면 타이머가 없는 것이다(이름이 배포판마다 다르다 — systemctl list-timers --all | grep -i certbot).

확인 — 실제로 돌았고 성공했는가

ssh test-server 'systemctl status certbot-renew.service'
ssh test-server 'journalctl -u certbot-renew.service --since today'

실측07-renewal-hook-missing.txt

  Active: inactive (dead) since Fri 2026-09-04 17:04:11 KST
  Process: 28452 ExecStart=/usr/bin/certbot -q renew (code=exited, status=0/SUCCESS)

  Sep 04 03:19:39 Starting Renew certificates acquired via Certbot...
  Sep 04 03:19:41 Finished Renew certificates acquired via Certbot.
  Sep 04 17:04:09 Starting Renew certificates acquired via Certbot...
  Sep 04 17:04:11 Finished Renew certificates acquired via Certbot.

어디를 봐야 하는가status=0/SUCCESS, 그리고 오늘 두 번 돌았다는 것.

이 결과가 의미하는 것여기서 확인을 멈추면 「괜찮다」로 끝난다. 대부분의 문서가 여기까지다. 그런데 남은 기간을 보면 아직 갱신은 하지도 않았다.

확인

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

실측

만료: Dec  2 00:47:22 2026 GMT
남은 일수: 88일

Let's Encrypt 는 90일 발급이고 certbot 은 30일 남았을 때 갱신한다. 즉 실제 갱신까지 약 58일 남았고, 그때까지 이 절차는 한 번도 시험되지 않는다. 「타이머가 active 니까 괜찮다」가 확인이 아닌 이유가 이것이다.

1-5. ★ 그런데 무엇이 nginx 를 reload 하는가 — 세 곳을 본다

갱신된 인증서를 서버에 읽히는 경로는 셋뿐이다. 셋을 하나씩 연다.

① 갱신 유닛이 뭔가 더 하는가

확인

ssh test-server 'systemctl cat certbot-renew.service'
ssh test-server 'systemctl cat certbot-renew.timer'

실측07-renewal-hook-missing.txt

  # /usr/lib/systemd/system/certbot-renew.service
  [Unit]
  Description=Renew certificates acquired via Certbot
  [Service]
  Type=oneshot
  ExecStart=/usr/bin/certbot -q renew
  PrivateTmp=true

  OnCalendar=*-*-* 00/12:00:00
  RandomizedDelaySec=12h
  Persistent=true

어디를 봐야 하는가ExecStart= 한 줄, 그리고 그 아래에 ExecStartPost= 가 있는지 없는지. ExecStart 의 인자에 --deploy-hook 이 붙어 있는지도 본다. 여기 없는 것을 보는 것이 이 명령의 목적이다.

이 결과가 의미하는 것ExecStart 가 전부다. 배포판(Arch)이 넣어준 기본 유닛이 그렇다. 이 유닛은 인증서를 새로 받는 데까지만 책임지고, 받은 것을 누가 읽게 만드는 일은 아무도 하지 않는다.

systemctl cat유닛 파일에 적힌 것을, systemctl show기본값까지 합쳐 실제 적용되는 것을 보여 준다. 여기서는 「적혀 있지 않다」가 답이므로 cat 이 맞다.

② 훅 디렉터리에 뭐가 있는가 — 여기부터 root 가 필요하다

확인 — sudo 없이 쳐 보면 이렇게 나온다

ssh test-server 'ls -laR /etc/letsencrypt/renewal-hooks/'

실측07-renewal-hook-missing.txt

ls: cannot access '/etc/letsencrypt/renewal-hooks/': Permission denied

이 빈 출력을 「비어 있다」로 읽으면 안 된다. 실제로 이 실험대는 B-7 에서 같은 실수를 했다 — nginx 설정을 읽으려던 시도가 계속 빈 결과였는데, 그게 sudo 의 조용한 실패였다는 것을 한참 뒤에 알았다.

하기 — 사람이 비밀번호를 친다

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

실측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 ..

/etc/letsencrypt/renewal-hooks/post/:
total 8
...
/etc/letsencrypt/renewal-hooks/pre/:
total 8
...

어디를 봐야 하는가total 8. .. 뿐. 셋 다 비었다.

ssh -t-t 가 필요하다. tty 를 붙여 줘야 sudo 가 비밀번호를 물어볼 수 있다. 없으면 「비밀번호가 필요하다」에서 끝난다.

③ certbot 이 스스로 고칠 수 있는가

하기 — 사람이 친다

ssh -t test-server 'sudo certbot plugins'

실측13-verdict.txt

         Discovered plugins: dns-cloudflare, manual, null, standalone, webroot
         (certbot 5.7.0)

어디를 봐야 하는가 — 목록에 nginx 가 없다. certbot --nginx 로 받은 인증서라면 certbot 이 nginx 설정을 직접 만지고 reload 까지 하는데, 이 호스트는 webroot 로 받았고 nginx 플러그인 자체가 설치되어 있지 않다.

세 곳이 전부 비어 있다

# 경로 상태
1 certbot-renew.serviceExecStartPost 없다
2 renewal-hooks/{deploy,post,pre}/ 셋 다 비었다
3 certbot 의 nginx 플러그인 없다

하나라도 있었으면 자동으로 반영됐을 것이다. 이 표가 이 실험의 원인 진단이고, 아직 아무것도 주입하지 않은 상태에서 이미 나왔다.

1-6. ★ nginx 워커 PID — 판정 기준을 여기서 세운다

「reload 됐는가」를 로그 문구로 판정하지 않는다. 프로세스로 판정한다.

확인

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

실측07-renewal-hook-missing.txt

    585    1  80529  Thu Sep  3 19:00:39 2026  nginx: master process /usr/bin/nginx
    586  585  80529  Thu Sep  3 19:00:39 2026  nginx: worker process

어디를 봐야 하는가 — 네 칸을 다 본다.

    585    1  80529  Thu Sep  3 19:00:39   nginx: master process
    586  585  80529  Thu Sep  3 19:00:39   nginx: worker process
     │     │     │            │
     │     │     │            └─ lstart: 이 프로세스가 뜬 시각
     │     │     └─ etimes: 떠 있는 초 (80529초 = 22.4시간)
     │     └─ ppid: 부모. 워커의 부모가 마스터다
     └─ pid

reload 는 마스터를 유지한 채 워커만 새로 띄운다. 그러므로

마스터 PID 워커 PID 판정
그대로 바뀜 reload 됐다
그대로 그대로 reload 가 없었다
바뀜 바뀜 reload 가 아니라 재시작이다

이 결과가 의미하는 것 — 마스터 585, 워커 586. 번호가 붙어 있다 — 마스터 기동 직후의 첫 fork 그대로다. 둘의 lstart 가 같고 etimes 도 같다. 즉 22.4시간 동안 reload 가 한 번도 없었다.

이 두 줄을 적어 둔다. 4-2 와 5-2 에서 이 값과 비교한다.

1-7. ★ 시계를 먼저 잰다 — 나중에 재면 늦는다

두 기계의 로그를 나란히 놓기 전에 확인한다. 이 실험은 이걸 나중에 하는 바람에 공백 수치를 한 번 틀렸다.

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

A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
echo "$A"; echo "$B"; echo "$C"

어디를 봐야 하는가 — 세 수를 눈으로 뺀다. AC 는 같은 기계에서 SSH 왕복 직전·직후에 찍은 것이므로, 그 가운데가 「저쪽 시각을 잰 순간의 이쪽 시각」이다. B 가 그보다 크면 저쪽이 빠른 것이다.

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

curl -sI https://www.google.com | grep -i '^date:'
curl -sI https://acme-v02.api.letsencrypt.org/directory | grep -i '^date:'
date -u
ssh test-server 'date -u; timedatectl show -p NTP -p NTPSynchronized'

실측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 다. 그리고 세 번 재서 값이 흔들리지 않는 것.

이 결과가 의미하는 것dev 가 정확하고 test-server 가 106초 빠르다.

   실제 시각 = test-server 시계  106초
   실제 시각 = dev 시계               (보정 불필요)

그러므로 이 실험의 모든 관측은 dev 에서 한다. 호스트에서만 알 수 있는 값(파일 mtime, 훅 로그)은 보정해서 쓴다.

왜 이걸 주입 전에 하나. 주입 후에는 「그때 저 시계가 얼마나 어긋나 있었나」를 되짚을 수 없다. 그리고 이 실험은 실제로 보정 없이 뺀 값 2199초를 문서에 적었다가 나중에 2305초로 정정했다.

1-8. 대조군 — 근거를 재려면 (선택)

여기부터는 「무중단인가」를 문서에 남길 근거가 필요할 때만 한다. 일련번호가 언제 바뀌는지만 보려면 1-1 의 명령을 손으로 두 번 치면 된다.

주입 중에 오류가 한 번 나왔을 때 평시 오류율을 모르면 아무것도 증명하지 못한다. 그래서 대조군을 먼저 잡는다.

대조군 ① — 새 연결

하기 — 0.2초 × 900회 = 180초

i=0
while [ $i -lt 900 ]; do
  curl -s -o /dev/null -w '%{http_code} %{time_total} %{time_appconnect}\n' \
    --max-time 5 https://auth.hyeonworks.com/realms/master
  i=$((i+1)); sleep 0.2
done > /tmp/d4-control.txt
awk '{print $1}' /tmp/d4-control.txt | sort | uniq -c

실측05-control-no-injection.txt

표본 900 개

[상태코드 분포]
      900 200

[응답시간 ms]
  최소 67  중앙 98  p95 195  최대 1121  평균 106.9

[TLS 핸드셰이크 ms — 0 이면 연결 재사용, >0 이면 새 핸드셰이크]
  핸드셰이크 발생 900회 / 900  평균 83 ms  최대 1100 ms

[비정상 응답 원문 — 있으면 아래에 전부]
  비200 총 0

어디를 봐야 하는가uniq -c 의 줄이 하나이고 그 값이 900 200 인가. 그리고 핸드셰이크가 900/900 이라는 것.

이 결과가 의미하는 것 — 대조군이 깨끗하다. 그래서 주입 중 비200 이 한 번만 나와도 주입 탓으로 귀속할 수 있다. 대조군에 이미 오류가 섞여 있으면 주입을 하지 않는다 — 판정할 수 없기 때문이다.

그리고 핸드셰이크 900/900 은 매 요청이 새 연결이라는 뜻이다. 즉 이 장치는 「새 연결을 받아주는가」만 잰다. 계획서가 물은 것은 「진행 중이던 요청은 어떻게 되는가」이므로 장치가 하나 더 필요하다.

대조군 ② — 진행 중이던 요청

reload 순간에 실제로 전송 중인 요청이 있어야 한다. 845KB 짜리 관리 콘솔 번들을 일부러 느리게 받아 요청 하나를 42초 동안 살려 둔다.

하기 — 먼저 큰 파일의 경로를 찾는다(버전마다 달라진다)

JS=$(curl -s https://auth.hyeonworks.com/admin/master/console/ \
     | grep -oE '/resources/[a-z0-9]+/admin/[^"]+\.js' | head -1)
echo "$JS"

실측06-inflight-control.txt

  대상: https://auth.hyeonworks.com/resources/55yjq/admin/keycloak.v2/assets/main-BbID33M6.js

하기

curl -s --limit-rate 20k -o /tmp/inflight.bin \
  -w '코드=%{http_code} 바이트=%{size_download} 시간=%{time_total} 연결수=%{num_connects}\n' \
  "https://auth.hyeonworks.com$JS"

실측06-inflight-control.txt

[대조군: 주입 없이 1회]
  코드=200  받은바이트=845361  총시간=41.392198s  연결수=1  실효속도=20423B/s
  기대 크기 845361 / 실제 845361 bytes

판정 기준 (주입 시 이 값들과 비교한다)
  · 코드 200 + 크기 845361 = 진행 중이던 요청이 끝까지 살아남았다(graceful)
  · 코드 000 또는 크기 부족  = reload 가 진행 중이던 연결을 끊었다
  · 연결수 2 이상            = 중간에 끊겨 curl 이 다시 붙었다

어디를 봐야 하는가연결수=1. 이게 판정의 핵심이다. 끊겼다가 curl 이 다시 붙었으면 2 가 된다.

감시를 켠다 — 여기서부터는 파일로 만든다

세 감시가 동시에 돌아야 하고, 각각 루프와 종료 조건이 있다. 이쯤 되면 한 줄 명령이 아니라 프로그램이다. 파일로 쓴다.

vim /tmp/d4-watch-serial.sh
#!/bin/sh
# file: /tmp/d4-watch-serial.sh
# 5초마다 밖에서 본 인증서의 일련번호와 만료일을 찍는다.
# /tmp/d4-stop 파일이 생기면 멈춘다.
HOST=auth.hyeonworks.com
while [ ! -f /tmp/d4-stop ]; do
  S=$(echo | openssl s_client -connect "$HOST:443" -servername "$HOST" 2>/dev/null \
      | openssl x509 -noout -serial -enddate | tr '\n' ' ')
  echo "$(date -u +%H:%M:%S) $S"
  sleep 5
done
vim /tmp/d4-poll.sh
#!/bin/sh
# file: /tmp/d4-poll.sh
# 0.2초마다 새 연결 하나. 상태코드와 소요 시간만 남긴다.
while [ ! -f /tmp/d4-stop ]; do
  echo "$(date -u +%H:%M:%S.%2N) $(curl -s -o /dev/null \
    -w '%{http_code} %{time_total}' --max-time 5 \
    https://auth.hyeonworks.com/realms/master)"
  sleep 0.2
done
vim /tmp/d4-inflight.sh
#!/bin/sh
# file: /tmp/d4-inflight.sh
# 42초짜리 요청을 끊김 없이 연달아 돌린다 — reload 순간에 반드시 하나가 떠 있게.
# ★ curl 의 종료 코드를 반드시 남긴다. 안 남기면 측정 장치의 실패와
#   서버의 실패를 구별할 수 없다 (08-inflight-artifact.txt).
URL="https://auth.hyeonworks.com$1"
while [ ! -f /tmp/d4-stop ]; do
  R=$(curl -s --limit-rate 20k -o /dev/null \
      -w '코드=%{http_code} 바이트=%{size_download} 시간=%{time_total} 연결수=%{num_connects}' \
      "$URL"); E=$?
  echo "$(date -u +%H:%M:%S) $R curl종료=$E"
  [ $E -ne 0 ] && sleep 1
done

하기

chmod +x /tmp/d4-watch-serial.sh /tmp/d4-poll.sh /tmp/d4-inflight.sh
rm -f /tmp/d4-stop
setsid /tmp/d4-watch-serial.sh > /tmp/d4-serial.txt   2>&1 < /dev/null &
setsid /tmp/d4-poll.sh         > /tmp/d4-poll.txt     2>&1 < /dev/null &
setsid /tmp/d4-inflight.sh "$JS" > /tmp/d4-inflight.txt 2>&1 < /dev/null &

되돌리기 — 셋 다 멈춘다

touch /tmp/d4-stop

setsid 가 필요하다. 그냥 & 로 띄우면 부모 셸이 끝날 때 같이 죽는다 (A-3 에서 파드 안 &exec 종료와 함께 죽은 것과 같은 함정이다). 이 실험은 사람이 다른 창에서 sudo 를 치는 동안 감시가 살아 있어야 한다.

확인 — 30초쯤 두고 감시가 실제로 쌓이는지 본다

tail -3 /tmp/d4-serial.txt
tail -3 /tmp/d4-poll.txt
tail -3 /tmp/d4-inflight.txt

어디를 봐야 하는가 — 세 파일 다 줄이 늘고 있는가. 여기서 비어 있으면 주입해도 아무것도 안 남는다.


2. 주입 — 강제 갱신 (사람이 친다)

2-1. 무엇을 사람이 쳐야 하나

하는 일 어디서 sudo
밖에서 인증서·체인·SAN 읽기 dev 필요 없다
타이머·유닛·journal 읽기 test-server 필요 없다 (이 실험대에서 확인)
nginx 워커 PID 읽기 test-server 필요 없다
nginx 설정에서 인증서 경로 찾기 test-server 필요 없다
훅 디렉터리 보기 test-server 비밀번호
certbot certificates · archive/ 보기 test-server 비밀번호
certbot renew --force-renewal test-server 비밀번호
nginx -s reload test-server 비밀번호

호스트에서 비대화 sudo 는 반드시 실패한다.

실측01-certificate-state.txt

$ sudo -n -l
sudo: a password is required
$ sudo -n systemctl reload nginx
sudo: a password is required

그러므로 이 네 줄은 자동화할 수 없다. ssh -t 로 tty 를 붙여 사람이 비밀번호를 친다. 이 실험이 처음에 강제 갱신을 못 하고 「미측정」으로 남긴 이유가 정확히 이것이다.

2-2. 먼저 --dry-run

하기

ssh -t test-server 'sudo certbot renew --dry-run'

어디를 봐야 하는가 — 끝의 simulated renewals 요약. 그리고 훅을 넣었다면 Running deploy-hook command 줄(미검증 — 이 실험대는 훅이 없는 상태에서 쟀다).

이 결과가 의미하는 것 — dry-run 은 인증서를 발급하지 않고 한도도 안 깎는다. 절차가 도는지, 검증이 통과하는지까지만 말해 준다. 파일이 실제로 바뀌었을 때 nginx 가 그것을 집는지는 dry-run 으로 알 수 없다.

2-3. 강제 갱신

되돌리기 — 없다. 새 인증서는 되돌릴 수 없고, 한도를 한 장 깎는다. 1-8 의 감시 세 개가 돌고 있는지 다시 확인하고 친다.

하기

date -u '+%H:%M:%S 갱신 시작 (dev)'
ssh -t test-server 'sudo certbot renew --force-renewal'

어디를 봐야 하는가Congratulations, all renewals succeeded: 와 그 아래 fullchain.pem (success).

시각을 dev 시계로 적어 둔다. 호스트가 찍는 시각은 106초 빠르다.


3. 주입이 실제로 걸렸는지 확인한다

「갱신 실패」와 「갱신은 됐는데 안 집었다」를 가르는 절이다. 이 실험은 처음에 이 둘을 구별하지 못해 두 갈래로 적어 뒀었다.

3-1. 디스크에 새 파일이 써졌나

하기 — 사람이 친다

ssh -t test-server 'sudo certbot certificates'

실측12-certbot-state.txt

Found the following certs:
  Certificate Name: auth.hyeonworks.com
    Serial Number: 6c7cb6df1da8a6d7995d93c264bb9ecea1d
    Key Type: ECDSA
    Identifiers: auth.hyeonworks.com app1.hyeonworks.com app2.hyeonworks.com
    Expiry Date: 2026-12-03 07:21:52+00:00 (VALID: 89 days)
    Certificate Path: /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem
    Private Key Path: /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem

어디를 봐야 하는가Serial NumberExpiry Date. 6c7cb6df…ea1d 는 1-1 에서 적어 둔 값과 다르다. 만료일도 하루 밀렸다 (Dec 2Dec 3).

이 결과가 의미하는 것certbot 쪽에서는 갱신이 끝났다.

3-2. 파일이 언제 써졌나

하기 — 사람이 친다

ssh -t test-server 'sudo ls -la --time-style=full-iso /etc/letsencrypt/archive/auth.hyeonworks.com/'

실측12-certbot-state.txt

-rw-r--r-- 1 root root 1359 2026-09-03 10:47:40.915923507 +0900 cert1.pem
-rw-r--r-- 1 root root 1359 2026-09-04 17:22:13.508494637 +0900 cert2.pem
-rw-r--r-- 1 root root 3523 2026-09-03 10:47:40.916215769 +0900 chain1.pem
-rw-r--r-- 1 root root 3523 2026-09-04 17:22:13.508658811 +0900 chain2.pem
-rw-r--r-- 1 root root 4882 2026-09-03 10:47:40.916339551 +0900 fullchain1.pem
-rw-r--r-- 1 root root 4882 2026-09-04 17:22:13.508821612 +0900 fullchain2.pem
-rw------- 1 root root  241 2026-09-03 10:47:40.916079294 +0900 privkey1.pem
-rw------- 1 root root  241 2026-09-04 17:22:13.507717972 +0900 privkey2.pem

어디를 봐야 하는가 — 번호가 1 과 2 두 벌이라는 것, 그리고 2 번들의 mtime 2026-09-04 17:22:13.

★ 이 시각은 (ts) 다. test-server 시계이고 106초 빠르다. 실제로는 08:20:27 (실제) 이다. 4-5 에서 이 보정을 쓴다.

privkey2.pem 의 권한이 -rw------- 인 것도 본다. 개인키는 D-3 의 주제와 같은 문제를 안고 있다 — 파일 하나를 얻으면 끝이다.


4. 효과를 관찰한다

4-1. ★ 그런데 밖에서는 아무것도 안 바뀌었다

확인

tail -3 /tmp/d4-serial.txt

실측07-renewal-hook-missing.txt

  serial=0520BB6416D569E26697B1691440F523B853
  notBefore=Sep  3 00:47:23 2026 GMT     ← 어제 것 그대로
  notAfter=Dec  2 00:47:22 2026 GMT

일련번호 감시 161표본(약 13분) 동안 단 한 번도 바뀌지 않았다.

어디를 봐야 하는가 — 일련번호가 1-1 에서 적어 둔 값 그대로인가. 디스크(3-1)의 6c7cb6df…다르다.

이 결과가 의미하는 것

   디스크    새 인증서   (6c7cb6df…)
   네트워크  옛 인증서   (0520BB…)

두 사건이 갈라졌다. 여기서 「갱신이 실패했다」고 결론 내리면 틀린다 — 3-1 에서 성공을 이미 봤다.

4-2. nginx 워커가 그대로다

확인 — 1-6 과 똑같은 명령

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

실측

    585    1  80529  Thu Sep  3 19:00:39 2026  nginx: master process /usr/bin/nginx
    586  585  80529  Thu Sep  3 19:00:39 2026  nginx: worker process

어디를 봐야 하는가워커 PID 586 이 그대로다. etimes 도 계속 늘고 있을 뿐 리셋되지 않았다.

이 결과가 의미하는 것reload 가 없었다. 그리고 1-6 에서 정한 판정 기준이 여기서 답을 낸다 — 로그를 뒤질 필요가 없다.

4-3. 개념 — 왜 파일이 바뀌어도 nginx 는 모르는가

무엇인가. nginx 는 ssl_certificate 가 가리키는 파일을 기동 시점에 한 번 읽어 메모리에 들고 있다. 요청마다 디스크를 다시 보지 않는다.

확인 — nginx 가 무엇을 물고 있는지 본다(sudo 불필요)

ssh test-server 'grep -rn ssl_certificate /etc/nginx/'

실측07-renewal-hook-missing.txt

/etc/nginx/sites-available/keycloak-lab:18: ssl_certificate     /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem;
/etc/nginx/sites-available/keycloak-lab:19: ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem;

왜 여기 나오나. live/심볼릭 링크다. certbot 은 갱신하면 이 링크가 새 archive/ 파일을 가리키도록 바꾼다.

   /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem
        └─▶ (전) ../../archive/auth.hyeonworks.com/fullchain1.pem
        └─▶ (후) ../../archive/auth.hyeonworks.com/fullchain2.pem

경로는 그대로인데 내용만 바뀐다. 그래서 nginx 설정을 고칠 필요가 없고, 바로 그 때문에 「설정이 그대로니 괜찮다」고 착각하기 쉽다. 필요한 것은 설정 변경이 아니라 reload 다.

없거나 틀리면. 인증서가 만료되어 브라우저가 NET::ERR_CERT_DATE_INVALID 를 띄운다. 그 시점에 디스크에는 멀쩡한 인증서가 들어 있고 갱신 로그도 SUCCESS 다 — 그래서 원인을 찾는 데 오래 걸린다.

4-4. 원인은 하나가 아니라 셋이 겹쳤다

1-5 에서 이미 본 표가 여기서 판정이 된다.

# 경로 상태
1 certbot-renew.serviceExecStartPost 없다
2 renewal-hooks/{deploy,post,pre}/ 셋 다 비었다
3 certbot 의 nginx 플러그인 없다

세 경로 전부가 비어 있다. 하나라도 있었으면 자동으로 반영됐다.

4-5. ★ 공백을 계산한다 — 여기가 이 실험이 한 번 틀린 자리다

하기 — 감시에서 언제 바뀌었는지 찾는다(5절에서 사람이 reload 한 뒤)

grep -v '0520BB' /tmp/d4-serial.txt | head

실측09-serial-timeline.txt · 13-verdict.txt

  08:10:51 ~ 08:58:47   serial=0520BB...B853  notAfter=Dec  2   ← 옛 것
  08:58:52              serial=06C7CB...EA1D  notAfter=Dec  3   ← 바뀐 순간

  08:22:13 ~ 08:58:52 구간에서 옛 인증서로 관측된 횟수: 428회

두 시각을 나란히 놓는다. 그런데 시계가 다르다.

시각 어느 시계
새 인증서 디스크 기록 17:22:13 KST08:22:13 UTC (ts) — 106초 빠르다
실제 서빙 시작 08:58:52 (dev) — 정확

틀린 계산 — 그대로 빼면

   08:58:52  08:22:13 = 2199초 (36분 39초)   ✘

맞는 계산 — 디스크 기록 시각을 실제 시각으로 보정한 뒤 뺀다

   디스크 기록 : 08:22:13 (ts)  106초 = 08:20:27 (실제)
   서빙 시작   : 08:58:52 (dev)          = 08:58:52 (실제)
   ────────────────────────────────────────────
   공백        : 2305초 = 38분 25초              ✔

어디를 봐야 하는가106초는 두 값의 차이(2199)에 비하면 5% 도 안 된다. 그래서 D-4 에서는 결론이 안 바뀌었다. 하지만 D-4a 는 1~2초를 재는 실험이고, 거기서는 같은 106초가 결과를 완전히 뒤집는다 — 보정하지 않으면 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로 불가능해진다.

두 시계에서 온 값을 빼면서 그 사실을 적지 않으면, 자릿수가 아니라 방향까지 틀릴 수 있다. 음수 지연이 나오면 계산이 아니라 시계를 의심한다.

그리고 이 38분은 우연히 짧았을 뿐이다. reload 를 시킨 것은 사람이지 자동화가 아니다. 아무도 안 했다면 다음 nginx 재시작까지 — 즉 무기한 — 옛 인증서를 서빙했을 것이다.

4-6. 왜 88일 동안 안 보이나

   오늘        타이머 두 번 SUCCESS         (갱신할 것이 없으므로 아무 일도 안 한다)
   +58일쯤     만료 30일 전 → 실제 갱신     ← 여기서 처음으로 절차가 시험된다
   +88일       만료                         ← 증상이 나타나는 날

발현하는 날의 증상은 「인증서 만료」이고, 그날에도 로그는 SUCCESS 다. 그래서 이 결함은 로그 감시로는 못 잡는다. 잡으려면 밖에서 notAfter 를 재야 한다.

확인 — 감시로 쓸 만한 한 줄. 미검증

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

어디를 봐야 하는가Certificate will not expire 인가 Certificate will expire 인가. 2592000 은 30일(초)이다. 서버에 로그인하지 않고, 밖에서, 실제로 서빙 중인 것을 본다 — 이 세 가지가 이 실험의 교훈이다.

4-7. 답 ② — reload 는 무중단인가 (근거)

5절에서 사람이 reload 한 뒤에 판정한다. 감시 세 개를 멈추고 센다.

하기

touch /tmp/d4-stop
grep -vE ' 200 ' /tmp/d4-poll.txt | head
grep -v '코드=200' /tmp/d4-inflight.txt | head

실측13-verdict.txt

새 연결 — 0.2초 폴링, 08:10:51 ~ 09:02

  전체 표본 8856건 / 비200 **0건**

  응답시간                     n      중앙     p95      최대
  ─────────────────────────────────────────────────────────
  장기 평시 08:20~08:50      5398   98.0ms  205.7ms  1942.9ms
  reload 직전 2분56초         489  116.0ms  200.8ms   387.7ms
  reload 직후 2분08초         342  132.5ms  204.3ms   475.0ms

어디를 봐야 하는가p95 가 205.7 → 204.3 으로 사실상 동일하고 최대값은 오히려 낮다. 10초 구간 중앙값은 reload 전후 모두 80~190ms 사이를 오간다 — WiFi 잡음이지 reload 의 흔적이 아니다.

진행 중이던 요청 — 계획서가 정확히 물은 지점

08:58:40  요청 시작 (845KB @ 20k/s)
08:58:52  ← nginx -s reload.  요청 시작 12초 뒤, 전송 한가운데
08:59:21  종료: 코드=200  바이트=845361(전량)  연결수=1  curl종료=0
관측 읽는 법
바이트가 전량이다 잘리지 않았다
연결수가 1이다 중간에 끊겨 재연결한 게 아니다
코드 200 옛 워커가 이 요청을 끝까지 책임졌다

이 결과가 의미하는 것reload 는 무중단이다. 옛 인증서로 시작한 연결이 새 워커 전환을 관통해 끝까지 갔다. in-flight 전체 50건 중 종료코드 ≠ 0 은 0건이다.

★ 측정 장치가 거짓말할 뻔했다

in-flight 감시에서 76건이 실패했다. 그대로 적었으면 「갱신 중 대규모 요청 실패」라는 오보가 됐을 것이다. 서버 탓이 아니었다.

실측08-inflight-artifact.txt

  08:15:04 코드=000 바이트=0 시간=0.001148 연결수=0     ← 여기부터
  ... (76건, 전부 08:15:04)
  08:15:04 코드=200 바이트=845361 시간=42.236496 연결수=1   ← 곧바로 복귀
근거
같은 순간 폴링 49건 전부 200
연결수 0 — TCP 연결 시도조차 못 했다
소요 시간 50µs — DNS 조회보다도 짧다
재현 0/100
nginx 그 시각에 아무 일도 안 했다(워커 22.4시간째)

대조군이 오보를 막았다. 그리고 원인은 특정하지 못했다curl-s 로 돌려 오류 메시지를 버렸고 종료 코드도 안 남겼기 때문이다. 1-8 의 d4-inflight.shcurl종료=$E 가 들어 있는 것이 그 수정이다.

측정 장치가 실패했을 때 왜 실패했는지 남기지 않으면, 그 실패를 대상 탓으로 돌릴지 장치 탓으로 돌릴지 판단할 근거가 없다.


5. 복구 — 사람이 reload 한다

이 절이 곧 4-1 의 공백을 닫는 사건이다. 순서상 관찰을 다 끝낸 뒤에 친다.

5-1. reload

하기 — 사람이 친다

date -u '+%H:%M:%S reload (dev)'
ssh -t test-server 'sudo nginx -t && sudo nginx -s reload'

어디를 봐야 하는가test is successful 두 줄이 먼저 나오고, 그다음 아무 말 없이 끝난다(-s reload 는 조용하다).

개념 — 왜 reload 이고 restart 가 아닌가

실측(호스트) — 이 호스트의 nginx.service 유효 설정

Type=forking          Restart=on-failure    RestartUSec=100ms
StartLimitBurst=5     StartLimitIntervalUSec=10s
KillMode=mixed        KillSignal=SIGQUIT    PrivateTmp=true

확인 — 이 값들은 유닛 파일이 아니라 실제 적용값이라 show 로 본다

ssh test-server 'systemctl show nginx -p Type -p Restart -p RestartUSec \
  -p StartLimitBurst -p StartLimitIntervalUSec -p KillMode -p KillSignal -p PrivateTmp'

이 값들이 왜 중요한가

설정 읽는 법
KillSignal=SIGQUIT 정지 신호가 nginx 의 graceful shutdown 신호다 — stop 도 연결을 끊지 않고 빠진다
Restart=on-failure + RestartUSec=100ms 죽으면 0.1초 뒤 다시 띄운다
StartLimitBurst=5 / StartLimitIntervalUSec=10s 10초 안에 5번 실패하면 systemd 가 포기한다. 설정이 깨진 채 restart 를 반복하면 nginx 가 내려간 채로 멈춘다
PrivateTmp=true 이 서비스의 /tmp자기만의 것이다. 여기 뭔가를 쓰면 밖에서 안 보인다

그래서 nginx -t 를 먼저 친다. 설정이 깨진 상태에서 reload 를 보내면 마스터가 새 워커를 못 띄우지만 옛 워커는 그대로 서비스를 계속한다 — 인증서는 안 바뀌어도 서비스는 안 죽는다. restart 는 그 안전장치가 없다.

systemctl reload nginx 도 같은 일을 한다(유닛에 ExecReload 가 있을 때). 이 실험대에서 실제로 친 것은 nginx -s reload 이고, D-4a 의 훅도 그것을 쓴다 — 미검증인 쪽은 systemctl reload 다.

5-2. 바뀌었나 — 두 곳을 본다

확인 ① 워커 PID

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

실측d4a-deploy-hook/01-hook-verified.txt 의 기준선. D-4 에서 사람이 reload 한 결과가 이 워커다

      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 한 것

어디를 봐야 하는가마스터 585 는 그대로, 워커는 586 → 28829. 1-6 에서 세운 판정 기준 그대로다.

확인 ② 밖에서 본 일련번호

tail -3 /tmp/d4-serial.txt

실측09-serial-timeline.txt

  08:58:52              serial=06C7CB...EA1D  notAfter=Dec  3   ← 바뀐 순간

어디를 봐야 하는가 — 일련번호가 3-1 에서 본 디스크의 값과 같아졌는가.

이 결과가 의미하는 것 — 디스크와 네트워크가 다시 일치한다. 그 사이의 2305초가 이 실험의 답이다.

5-3. 원상복구 확인표

항목 명령 돌아왔을 때
서빙 인증서 1-1 의 openssl … -serial 3-1 의 새 일련번호와 같다
체인 1-2 4단계, Verify return code: 0
이름 셋 1-3 세 일련번호가 서로 같다
nginx ps -eo pid,ppid,etimes,lstart,args | grep nginx: 마스터 그대로, 워커 새것
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200
감시 ls /tmp/d4-stop 있어야 한다(멈춘 상태). 없으면 touch
남은 프로세스 ps -ef | grep d4- 없어야 한다
임시 파일 ls -l /tmp/d4-*.txt /tmp/inflight.bin 근거로 남기거나 지운다

인증서는 원상복구되지 않는다. 새것이 정상이고, 옛것으로 돌아갈 이유도 없다.

5-4. 진짜 고치는 법

이 절차는 사람이 reload 를 쳤기 때문에 38분에서 끝났다. 자동으로 되게 하려면 훅이 필요하다.

# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/sh
nginx -t && nginx -s reload

deploy/실제로 갱신된 인증서가 있을 때만 실행된다. post/ 는 갱신 여부와 무관하게 매번 돌므로, 하루 두 번 쓸데없이 워커를 갈아치우게 된다.

★ 이 처방은 검증됐다 — D-4a. 훅 파일 하나로 발급 → 서빙이 38분 25초에서 1~2초가 됐다(약 1150배). 처방을 적고 시험하지 않는 것이야말로 이 실험대가 계속 경계해 온 실수라서, 별도 실험으로 분리했다. D-4 를 여기까지 했으면 D-4a 를 이어서 한다.


막히면

전부 이 실험대가 실제로 겪은 증상이다.

증상 원인 확인
호스트에서 아무 명령이나 빈 결과 sudo 가 조용히 실패했다 sudo -n -la password is required. ssh -t 로 다시
ssh test-server 'sudo …' 가 멈춰 있다 tty 가 없어 비밀번호를 못 묻는다 ssh -t
갱신했는데 일련번호가 그대로 그게 이 실험의 결과다 워커 PID 를 본다 — 4-2
워커 PID 로 판정이 안 선다 마스터까지 바뀌었다 reload 가 아니라 재시작이다. lstart 를 본다
훅 디렉터리가 Permission denied root 전용 「비었다」로 읽지 않는다 — 1-5
감시가 셸을 닫으면 죽는다 & 만 붙였다 setsid — 1-8
in-flight 에 실패가 무더기로 로컬 아티팩트일 수 있다 같은 시각 폴링·연결수·소요 시간·재현 — 4-7
공백이 음수로 나온다 두 시계를 그대로 뺐다 1-7 로 돌아간다
notBefore 로 발급 시각을 계산했다 LE 는 정확히 한 시간 백데이트한다 SCT 를 본다 — 1-1
crt.sh 에 인증서가 안 나온다 색인이 진실의 부분집합이다 SCT 는 인증서 안에 있다. -ext ct_precert_scts
nginx 에러 로그가 중간에 잘린다 한 항목이 2048바이트에서 잘린다(NGX_MAX_ERROR_STR) 저널 포맷을 바꿔도 안 늘어난다. access 로그를 본다
체인이 1단계 cert.pem 을 썼다 03 의 ssl_certificate 한 줄 — 1-2
발급 한도에 걸렸다 주당 중복 인증서 5장 --dry-run 으로 먼저 — 2-2

crt.sh 에 관한 곁다리 — 실측

발급 사실은 Certificate Transparency 에 남으므로 sudo 없이 확인할 수 있을 것 같았다. 실제로 서빙 중인 인증서에는 SCT 가 2개 박혀 있다. 그런데

실측07-renewal-hook-missing.txt

  $ curl -s 'https://crt.sh/?q=auth.hyeonworks.com&output=json'
  []                                            ← 0건
  $ curl -s 'https://crt.sh/?q=hyeonworks.com&output=json'
  13건, 최신 not_before=2026-08-11              ← auth 는 없다

인증서에 SCT 가 박혀 있다는 것과 crt.sh 가 그것을 색인했다는 것은 다르다. 관측 도구가 진실의 부분집합만 본다는, A-2 의 up 지표와 같은 종류의 함정이다.


다음

실험 D-4 가 남긴 것
D-4a deploy 훅 처방이 듣는지 시험한다. 38분 25초 → 1~2초
D-3 비밀 관리 privkey.pem 도 비밀이다. 파일 하나가 전부다
B-7 SAN 이 세 개뿐이라 네 번째 호스트명을 못 썼다 — 1-3
운영 감시는 로그가 아니라 밖에서 본 notAfter 로 한다 — 4-6
전부 두 기계의 시각을 나란히 놓기 전에 시계부터 잰다 — 1-7
전부 처방을 적었으면 시험한다. 이 문서는 처방만 적고 끝냈다가 D-4a 를 따로 해야 했다