Files
document-haness/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d4-certificate-renewal.md
T
DongHyeonkaandClaude Opus 5 32e39e20aa fix(setup): 실험대에서 35편을 끝까지 밟고 어긋난 명령·결과 31건을 고친다
test-server 를 비우고 다시 세운 뒤 Setup 기록 35편(virtualization 9 ·
keycloak-session-store 26)을 문서에 적힌 명령 그대로 쳤다. 어긋난 자리를
기록과 SSOT 양쪽에 실측과 함께 넣었다.

막히던 것
- 04 의 인증서 경로가 live/hyeonworks.com 이라 nginx 가 [emerg] 로 안 떴다.
  실제 계보는 live/auth.hyeonworks.com 이고 「문제가 생기면」은 진단이 거꾸로였다
- 인증서가 와일드카드가 아니다. SAN 이 auth·app1·app2 셋뿐이라 그 밖의 이름은
  TLS 에서 끊기고 curl 이 exit 60 · %{http_code} 000 을 낸다. SSOT 안에서
  두 문단이 서로 어긋나 있었다
- A-7 14번 ①이 kc-lab-1 에서 여섯 줄 다 실패하는데 마지막 date 만 「차단」을 찍는다

검사가 실패할 수 없던 자리
- B-1 의 세션 키 고르기는 앞 단계가 $KEY 를 채워 둬서 루프가 한 건도 못 맞혀도
  통과한다. KEY= 로 비우고 키마다 1/0 을 찍게 바꿨다
- k3s-agent 유닛의 sed -i 는 패턴에 $HOME 이 들어 있어 아무 줄도 안 바꾼 채 성공한다

certbot
- renew --dry-run 의 종료 코드는 성공도 0, 실패도 0, 다른 사유의 실패는 1 이다.
  본문의 renew failure(s) 로만 판정할 수 있다
- --dry-run 은 staging 서버를 쓰는데 renewal/*.conf 의 account= 는 운영 계정을
  가리킨다. 실패한 dry-run 이 staging 계정을 하나 더 만들어 다음 실행이 계속 멎는다
- 훅을 755 로 놓고 시뮬레이션이 성공해도 Running deploy-hook command 는 안 나온다.
  certbot 2.1.0 에는 --run-deploy-hooks 도 없다
- 강제 갱신은 실제로 쳤고 서빙까지 닿았다. serial 06F3E0EF…1373 → 065547…3DF1,
  notAfter Dec 3 → Dec 16, nginx worker 2629 4712 → 4745 4754

독자가 칠 수 있는 형태로
- 안 되는 형태가 번호 붙은 단계에 앉아 있던 8곳을 뒤집고, 되는 형태를 ①로 올렸다
- 랩 안에서 공개 이름을 치는 curl 65줄에 --resolve 를 붙였다. 붙인 형태를 실제로
  쳐서 문서가 적은 값과 같은지 확인했다
- 힙독·sed -i·echo >>·&&·|| 를 편집기 + 파일 리스팅 + 분할 형태로 바꿨다
- 닫는 코드펜스가 빠져 뒤 200여 줄의 블록 종류가 뒤집혀 있던 곳을 포함해 3곳을 고쳤다

관문: check_body PASS · check_prose error 0 · check_evidence 두 프로젝트 문제 없음 ·
verify-tech-log-tree error 0 · verify-project-layout error 0 · 코드펜스 전수 0건

남은 것: B-0 주입은 keycloak-pattern 저장소의 소스를 고치고 이미지를 다시 구워야
해서 안 했다(unknown).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:38:12 +09:00

67 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
id kind slug title topic topicName project status studio pinnedVersions source sourceRevision
9349a3fe-5234-48ae-af9f-029ffc0d2296 SETUP reproduce-d4-certificate-renewal 인증서를 강제로 갱신하고 밖에서 보이는 일련번호가 언제 바뀌는지 잰다 operations-that-report-success 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신 keycloak-session-store 게시 전 https://hyeonworks.com/studio/documents/9349a3fe-5234-48ae-af9f-029ffc0d2296/edit
name version
certbot 5.7.0
final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-4
cdac9b8178391311d8eca1ebc6cac15bb62d79af

인증서를 강제로 갱신하고 밖에서 보이는 일련번호가 언제 바뀌는지 잰다

호스트의 인증서를 강제로 갱신하고, 디스크가 바뀐 시각과 밖에서 본 일련번호가 바뀐 시각을 각각 재는 절차다. 되돌릴 수 없는 한 줄을 치므로 여덟 칸을 먼저 재 두고 감시 셋을 띄운 뒤에 친다.

관계

  • 새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다 이 절차가 만드는 두 시각의 차이를 그 기록이 결론으로 적는다. 결론이 필요하면 그쪽을 읽는다.
  • 실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다 여기서 전송 중인 요청을 감시하다 만나는 실패 무더기가 그 기록이 다루는 사건이다.
  • 적용됐는지는 로그 문구가 아니라 상태로 판정한다 이 절차는 「reload 됐는가」를 로그에서 찾지 않고 nginx 워커 PID 로 가른다. 그 기준을 주입 전에 세운다.
  • 두 시계에서 온 값을 빼지 않는다 주입 전에 시계를 재는 단계가 그 기준을 지키려고 있다. 이 절차는 그것을 나중에 하는 바람에 숫자를 한 번 틀렸다.
  • 갱신 타이머가 실제 갱신에서도 도는가 이 절차는 강제 갱신으로만 잰다. 타이머가 스스로 갱신하는 경로는 만료 30일 전에야 조건이 성립해 여기서 확인하지 못한다.
  • deploy 훅 파일 하나를 넣고 nginx 워커가 저절로 갈리는지 확인한다 여기서 찾은 원인을 고치는 편이다. 이 절차는 결함을 만들어 재고 그쪽은 고침을 넣어 잰다. 되돌리기도 반대라 갈라 두었다.

본문

읽기 전에 — 어디서 치는가

기계가 둘이고 표시가 둘이다. 밖에서 보는 curl·openssl·감시 스크립트는 [dev] 에서 치고, 호스트를 들여다보는 ssh·systemctl·certbot·nginx[test-server] 로 간다. D층 다섯 편 가운데 kubectl 을 한 번도 안 쓰는 편은 이것 하나다.

무엇
관찰하는 기계 개발 머신 dev. 밖에서 본 것이 이 실험의 답이다
건드리는 기계 호스트 test-server. SSH(Secure Shell, 원격 셸 접속)로 붙는다
시계 dev 는 외부 기준과 맞고 test-server 는 106초 빠르다
sudo 게스트는 무암호지만 호스트는 비밀번호를 묻는다. 네 단계는 사람이 친다
certbot 5.7.0. 플러그인은 dns-cloudflare · manual · null · standalone · webroot 이고 nginx 플러그인은 없다
원래 실행 2026-09-04 감시 구간 08:10:5109:02 UTC
도구 jq 가 이 실험대에 없다

터미널은 둘을 연다. 감시 셋이 한쪽에서 돌고 있어야 하고, 사람이 비밀번호를 치는 ssh -t 가 다른 쪽에서 간다.

시각 표기 규약이 이 편에 따로 있다. 두 기계의 시계를 섞어 빼는 바람에 숫자를 한 번 틀렸고, 그래서 모든 시각에 어느 시계인지를 붙인다.

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

이 실험이 가르는 것

인증서 갱신 자동화의 확인은 대개 두 줄에서 끝난다 — 타이머가 도는가, 로그가 SUCCESS 인가. 이 절차는 그 뒤를 묻는다. 갱신된 인증서를 누가 서버에 읽히는가.

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

③ 을 하는 것이 아무것도 없으면 ①②는 매번 성공하고 사용자는 만료된 인증서를 본다. 그리고 이 결함은 88일 동안 보이지 않는다. 타이머는 매일 두 번 돌고 매번 SUCCESS 로 끝나는데, 만료 30일 전까지는 갱신 자체를 하지 않으므로 발현할 기회가 없다. 발현하는 날의 증상은 인증서 만료이고, 그날에도 로그는 SUCCESS 라고 적혀 있다.

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

절차를 끝까지 밟으면 이름 셋이 한 인증서에 들어 있는 것, 체인이 4단계이고 Verify return code: 0 인 것, 타이머는 SUCCESS 인데 reload 를 부르는 것이 아무 데도 없는 것, nginx 워커가 22.4시간째 그대로인 것, 두 기계 시계가 106초 어긋나 있는 것, 갱신에 성공했는데 밖에서 본 일련번호가 안 바뀌는 것, 사람이 reload 한 그 순간 바뀌는 것, reload 가 정말 무중단인 것을 자기 화면에서 보게 된다.

전제와 되돌리기

  • 이 편만은 클러스터가 아니라 호스트를 본다. kubectl 은 한 번도 안 쓴다.
  • 관찰은 dev 에서 한다. 밖에서 본 것이 이 실험의 답이고, dev 의 시계가 이 실험대에서 유일하게 정확하다.
  • 호스트의 sudo 는 비밀번호를 요구한다. 게스트와 다르므로 몇 단계는 사람이 직접 쳐야 한다.

★ 진짜 인증서를 발급하는 실험이다. certbot renew --force-renewal 은 되돌릴 수 없다. 새 인증서가 실제로 발급되고 Let's Encrypt 의 주당 중복 인증서 5장 한도를 한 장 깎는다. 그래서 순서가 정해져 있다 — 먼저 --dry-run 으로 절차만 확인하고, 강제 갱신은 이 실험 전체에서 한 번만 쓰며, 그 한 번을 헛되게 쓰지 않도록 대조군을 먼저 잡는다.

옛 인증서는 무효가 되지 않는다. 만료 전까지 그대로 유효하므로 서비스가 깨지지는 않는다. 밖에서 보이는 인증서를 디스크와 다시 맞추는 것은 복구 절의 nginx -s reload 한 줄이고, 감시 셋을 멈추는 것도 한 줄이다. 감시 셋이 무엇인지는 주입 전에 §11 에서 세우고, 멈추는 이 한 줄은 복구 절 ⑤ 에서 친다 — reload 보다 먼저 치면 안 된다.

touch /tmp/d4-stop

주입 전에 같은 명령으로 먼저 본다

강제 갱신은 사실상 한 번만 칠 수 있다. 그 한 번을 헛되게 쓰지 않으려면 주입 전에 잴 것을 전부 재 둬야 한다. 여덟 칸이고, 뒤로 갈수록 이 편만의 것이 된다.

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

1. 협상 과정을 통째로 읽고 인증서를 뜯는다

무엇을 보는가 — 지금 밖에서 보이는 인증서가 무엇인지. 처음 한 번은 값만 뽑지 않고 TLS(전송 계층 보안, 연결을 암호화하는 규격) 협상 과정을 통째로 읽는다.

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

어디를 보나 — 실측은 이렇다(observed, 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(Subject Alternative Name, 한 인증서가 담는 이름 목록)이 세 줄이며 와일드카드가 아니라는 것도 같이 본다.

notBefore 를 발급 시각으로 읽지 않는다. Let's Encrypt 는 notBefore 를 정확히 한 시간 백데이트한다 — 클라이언트 시계가 조금 빨라도 「아직 유효하지 않은 인증서」가 되지 않게 하려고 그렇게 적는다. 여기에 한 시간을 더한 값을 발급 시각으로 그대로 쓰지도 않는다. 이 실험대의 두 인증서에서 CT(Certificate Transparency, 발급 사실을 공개 로그에 남기는 구조) 로그의 SCT 가 그보다 약 89초 앞선다.

2. 발급 시각의 외부 기준을 SCT 에서 잡는다

무엇을 보는가 — 이 실험대의 두 기계와 무관한 제3의 시계.

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

어디를 보나 — 실측은 이렇다(observed, 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

이 값이 뜻하는 것 — SCT(Signed Certificate Timestamp, CT 로그가 인증서에 서명해 박아 주는 시각)는 CT 로그가 자기 시계로 찍은 값이다. 시계가 어긋난 것이 드러났을 때 이 값이 심판이 된다.

3. 체인이 몇 단계인지 본다

무엇을 보는가 — 서버가 리프만 보내는지 중간 인증서까지 보내는지.

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'

어디를 보나 — 번호가 몇까지 가는가와 마지막 줄을 본다. 실측은 4단계로 정상이다(observed, 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)

이 값이 뜻하는 것 — 단계가 1개면 cert.pem 을 nginx 에 넣었다는 뜻이다.

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

브라우저는 중간 인증서를 캐시하거나 AIA(Authority Information Access, 발급자 인증서를 어디서 받는지 적은 확장) 로 보완해서 대개 정상으로 보이고, 캐시가 없는 클라이언트에서만 깨진다. 그래서 발견이 늦다.

4. 이름 셋이 한 장인지 본다

무엇을 보는가 — 세 호스트명이 한 인증서를 쓰는지.

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 설정이 아니라 나중에 무엇을 배포할 수 있는가를 정한다.

5. 갱신 자동화가 도는지 본다

무엇을 보는가 — 타이머가 떠 있고 오늘 돌았는지. 여기는 sudo 없이 읽힌다.

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

어디를 보나 — 실측은 이렇다(observed, 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 으로 찾는다.

기계 이름과 유닛 이름이 둘 다 이 실험대와 다르다. 2026-09-17 에 쳐서 확인했다(observed).

첫째, 이 편은 ssh test-server 로 간다고 적는데 certbot 은 엣지 게스트에 있다. 기반 가이드 03 이 엣지 nginx 를 호스트에서 kc-lab-edge 로 옮겼고 04 가 certbot 을 거기 깔았기 때문이다. test-server 에도 certbot 실행 파일은 있지만 타이머가 없다.

[test-server]    systemctl list-timers certbot-renew.timer  →  0 timers listed.
[kc-lab-edge]    systemctl list-timers --all | grep -i certbot
                 Thu 2026-09-17 17:06:05 UTC  10h left  certbot.timer  certbot.service

둘째, Debian 12 의 유닛 이름은 certbot.timer · certbot.service 이고 certbot-renew.* 가 아니다. 그래서 이 편의 systemctl list-timers certbot-renew.timersystemctl cat certbot-renew.service 는 이 실험대에서 빈 표와 오류를 낸다. 아래부터는 ssh kc-lab-edge 로 가고 유닛 이름은 --all | grep -i certbot 이 찾아 준 것을 쓴다.

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

실측은 이렇다(observed, 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 니까 괜찮다」로는 확인이 되지 않는다.

6. 무엇이 nginx 를 reload 하는지 경로 셋을 연다

무엇을 보는가 — 갱신된 인증서를 서버에 읽히는 경로는 셋뿐이고, 셋을 하나씩 연다. 아직 아무것도 주입하지 않았는데 이 실험의 원인 진단이 여기서 이미 끝난다.

ssh kc-lab-edge 'systemctl cat certbot.service'
ssh kc-lab-edge 'systemctl cat certbot.timer'
ssh kc-lab-edge 'sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/'

2026-09-17 실측(observed) — 이름은 다르지만 이 편이 찾는 것은 그대로 없다.

# /lib/systemd/system/certbot.service
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew --no-random-sleep-on-renew
PrivateTmp=true

/etc/letsencrypt/renewal-hooks/deploy/   total 0

ExecStartPost--deploy-hook 도 없고 훅 디렉터리도 비어 있다. 갱신은 돌지만 받은 것을 누가 읽게 만드는 일은 아무도 하지 않는다 — 이 편의 결론이 유닛 이름과 무관하게 성립한다는 뜻이다.

어디를 보나 — 원래 실행의 실측은 이렇다(observed, 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 이 붙어 있는지도 본다. 여기 없는 것을 보는 것이 이 명령의 목적이다. 배포판이 넣어 준 기본 유닛이라 인증서를 새로 받는 데까지만 책임진다.

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

훅 디렉터리부터는 root 가 필요하다. sudo 없이 쳐 보면 이렇게 나온다(observed, 07-renewal-hook-missing.txt).

ssh test-server 'ls -laR /etc/letsencrypt/renewal-hooks/'
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/'

실측은 이렇다(observed, 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 가 비밀번호를 물어볼 수 있고 없으면 「비밀번호가 필요하다」에서 끝난다.

ssh -t test-server 'sudo certbot plugins'

실측은 이렇다(observed, 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 플러그인 없다

셋 중 하나만 있었어도 갱신된 인증서가 저절로 반영된다.

7. nginx 워커 PID 로 판정 기준을 세운다

목적 — 「reload 됐는가」를 로그 문구가 아니라 PID(Process ID, 프로세스 번호)로 판정하게 만든다.

1. 마스터와 워커를 한 줄씩 찍는다.

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

예상 결과 — 실측은 이렇다(observed, 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. 번호가 붙어 있고 둘의 lstart 가 같고 etimes 도 같다 — 마스터 기동 직후의 첫 fork 그대로이므로 22.4시간 동안 reload 가 한 번도 없었다. 이 두 줄을 적어 둔다. 관찰 절과 복구 절이 이 값과 비교한다.

문제가 생기면 — 출력이 비면 grep 'nginx:' 의 콜론을 빠뜨렸거나 nginx 가 떠 있지 않다. systemctl status nginx 부터 본다.

이 단계도 기계가 틀렸다(2026-09-17, observed). 위 명령은 test-server 에서 nginx 를 찾는데, 기반 가이드 03 이 nginx 를 kc-lab-edge 로 옮겼다. 그래서 랩 호스트에서는 한 줄도 안 나온다 — 5·6 절이 certbot 을 엣지에서 찾는 것과 같은 이유다. 찾는 곳을 엣지로 바꾼다.

ssh kc-lab-edge "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
   1065       1   11145 Thu Sep 17 04:27:39 2026 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
   1106    1065   11135 Thu Sep 17 04:27:49 2026 nginx: worker process

이 두 줄은 위 예시와 한 군데가 다르고, 그 다름이 판정표를 그대로 보여 준다. 위 예시는 마스터와 워커의 lstartetimes 가 같아서 「기동 이후 reload 가 없었다」였다. 여기서는 워커가 10초 늦게 떴고 etimes 도 10 작다 — 03 을 밟으면서 systemctl reload nginx 를 친 흔적이다. 마스터는 그대로 두고 워커만 갈아 끼운 것이 숫자로 남는다.

또 하나. 엣지의 마스터 명령줄은 /usr/sbin/nginx -g daemon on; master_process on; 이고 위 예시의 /usr/bin/nginx 와 경로가 다르다. Arch 호스트와 Debian 게스트의 패키징 차이다.

8. 두 기계의 시계 차를 지금 잰다

목적 — 주입 후에는 되짚을 수 없는 값을 확보한다. SSH(Secure Shell, 원격 셸 접속) 왕복에 걸리는 시간까지 함께 본다. 이 실험은 이걸 나중에 하는 바람에 공백 수치를 한 번 틀렸다.

1. SSH 왕복 직전·직후의 이쪽 시각과 저쪽 시각을 나란히 찍는다.

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

예상 결과 — 소수점까지 있는 epoch 초 세 줄. 눈으로 뺀다. AC 는 같은 기계에서 왕복 직전·직후에 찍은 것이므로 그 가운데가 「저쪽 시각을 잰 순간의 이쪽 시각」이고, B 가 그보다 크면 저쪽이 빠르다. 계산을 명령에 넣지 않는 것이 이 형태의 요점인데, 두 값의 차를 셸이 대신 빼 주면 어느 시계에서 온 값인지가 출력에서 사라진다.

2. 어느 쪽이 맞는지는 외부 기준으로 가른다.

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'

실측은 이렇다(observed, 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 다. 세 번 재서 값이 흔들리지 않는 것도 같이 보는데, 흔들리면 네트워크 지연이 섞인 것이고 안정적이면 진짜 왜곡이다.

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

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

문제가 생기면 — 세 번의 값이 흔들리면 네트워크 지연이 섞였다. 회선이 조용할 때 다시 잰다.

9. 대조군 900건을 잡는다

목적 — 주입 중에 오류가 나왔을 때 평시 오류율과 견줄 수 있게 한다. 일련번호가 언제 바뀌는지만 보려면 openssl … -serial 을 손으로 두 번 치면 되지만, 평시 오류율을 모르면 주입 중의 오류 한 건이 아무것도 증명하지 못한다.

1. 0.2초 간격으로 900번 친다.

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

2. 상태코드 분포를 센다.

awk '{print $1}' /tmp/d4-control.txt | sort | uniq -c

예상 결과 — 실측은 이렇다(observed, 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

위 실측 블록은 ② 의 화면이 아니다. ② 가 내는 것은 [상태코드 분포] 한 덩어리뿐이고, 응답시간 넷과 핸드셰이크 두 줄은 원래 실행이 따로 돌린 집계의 결과다. 그 집계를 내는 명령은 원본 가이드에 없다(unknown). 그래서 아래 판정 가운데 상태코드는 ② 로 확인할 수 있고 핸드셰이크 수는 그럴 수 없다. /tmp/d4-control.txt 의 셋째 칸이 %{time_appconnect} 이니 눈으로 훑어 0.000000 이 섞여 있는지는 볼 수 있다.

왜 필요한가uniq -c 의 줄이 하나이고 그 값이 900 200 이면 대조군이 깨끗하므로, 주입 중 비200 이 한 번만 나와도 주입 탓으로 귀속할 수 있다. 대조군에 이미 오류가 섞여 있으면 주입을 하지 않는다. 판정할 수 없기 때문이다.

문제가 생기면 — 핸드셰이크가 900/900 이 아니면 연결이 재사용됐다. 그 장치로는 「새 연결을 받아주는가」를 못 잰다.

10. 전송 중인 요청을 만드는 장치를 잡는다

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

1. 큰 파일의 경로를 찾는다. 버전마다 달라진다.

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

실측은 이렇다(observed, 06-inflight-control.txt).

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

2. 845KB 짜리 번들을 일부러 느리게 받아 요청 하나를 42초 동안 살려 둔다.

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

예상 결과 — 실측은 이렇다(observed, 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 가 된다.

문제가 생기면 — 받은 바이트가 845361 이 아니면 번들이 바뀌었다. ① 을 다시 쳐서 경로와 크기를 새로 잡는다.

11. 감시 셋을 파일로 쓰고 띄운다

목적 — 각각 루프와 종료 조건이 있어 한 줄 명령이 아니라 프로그램이다. 파일로 쓴다.

1. 일련번호 감시를 쓴다.

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

2. 새 연결 폴링을 쓴다.

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

3. 전송 중인 요청 감시를 쓴다.

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

4. 실행 권한을 주고 셋을 띄운다. §10 ① 을 친 그 창에서 이어 친다 — 마지막 줄의 $JS 는 그 창의 셸 변수다. 터미널을 둘 여는 편이지만 $JS 는 창을 따라가지 않는다. 새 창에서 띄우면 빈 문자열이 들어가 전송 중 감시가 845KB 번들 대신 루트 URL 을 받는다.

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 &

5. 셋 다 줄이 늘고 있는지 30초쯤 두고 본다.

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

예상 결과 — 세 파일 다 줄이 늘어난다. 여기서 비어 있으면 주입해도 아무것도 안 남는다. 전송 중 파일은 한 줄이 42초짜리라 30초 안에 한 줄도 안 붙을 수 있고, 반대로 초 단위로 줄이 쏟아지면 $JS 가 빈 값이다 — 짧은 응답을 받고 있다는 뜻이다. 그때는 echo "$JS" 부터 다시 본다.

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

문제가 생기면 — 파일이 비어 있으면 chmod +x 를 빠뜨렸거나 /tmp/d4-stop 이 지워지지 않았다. rm -f /tmp/d4-stop 부터 다시 친다.

주입

무엇을 사람이 쳐야 하는지가 먼저다. 이 편에서 sudo 가 갈리는 곳이 넷이다.

하는 일 어디서 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 는 반드시 실패한다(observed, 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 를 붙여 사람이 비밀번호를 친다. 그래서 이 실험은 처음에 강제 갱신을 못 하고 그 항목을 미측정으로 남겼다.

12. 먼저 dry-run 으로 절차만 확인한다

목적 — 한 번뿐인 강제 갱신을 오타 때문에 날리지 않는다.

1. 발급 없이 절차만 돌린다.

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

예상 결과 — 끝의 simulated renewals 요약이 나온다. 훅을 넣어도 Running deploy-hook command 줄은 안 나온다(2026-09-17, observed) — certbot 2.1.0 의 dry-run 은 deploy 훅을 건너뛴다. 근거는 D-4a 의 「인증서를 세우고 훅까지 놓아도」 절에 있다. 그리고 같은 명령이 DNS 전파 때문에 한 번 실패하고 다음 번에 성공하기도 한다.

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

문제가 생기면여기서 실패했다고 강제 갱신도 실패하는 것은 아니다(2026-09-17, observed). --dry-run 은 Let's Encrypt 의 staging 서버로 붙는데 renewal/*.confaccount =운영 계정을 가리킨다. 그래서 staging 쪽 ACME 계정이 둘 이상이면 dry-run 만 Please choose an account 로 멈는다 — 운영 경로에는 그 계정이 하나뿐이라 같은 일이 안 일어난다. 오류 문구에 account 가 들어 있으면 D-4a 의 「--dry-run 이 보는 계정」 절을 먼저 읽는다. 자격증명 파일이 없거나 DNS-01 검증이 안 되는 경우는 두 경로가 같은 것을 쓰므로 강제 갱신에서도 그대로 날 것으로 보는데, 이 실험대에서 그렇게 재 보지는 않았다(unknown).

13. 강제 갱신을 한 번 친다

목적 — 디스크의 인증서를 실제로 바꾼다. 되돌릴 수 없고 한도를 한 장 깎는다.

1. 감시 셋이 돌고 있는지 다시 확인하고, 시작 시각을 dev 시계로 찍는다.

date -u '+%H:%M:%S 갱신 시작 (dev)'

2. 사람이 비밀번호를 치고 강제 갱신을 건다.

ssh -t test-server 'sudo certbot renew --force-renewal'

예상 결과Congratulations, all renewals succeeded: 와 그 아래 fullchain.pem (success).

왜 필요한가 — 시각은 dev 시계로 적어 둔다. 호스트가 찍는 시각은 106초 빠르다.

문제가 생기면 — 발급 한도에 걸렸으면 주당 중복 인증서 5장을 이미 썼다는 뜻이다. 다음 주까지 기다린다.

★ 2026-09-17 에 다시 치는 곳은 test-server 가 아니라 kc-lab-edge(observed). 인증서와 nginx 가 엣지 게스트로 옵기면서 명령을 치는 기계도 바뀜었고, 거기는 NOPASSWD sudo 라 -t 가 필요 없다.

sudo certbot renew --force-renewal

결과는 Congratulations, all renewals succeeded: 였고 certbot exit=0 이었다. 배포 훅이 돌아 서빙하는 인증서까지 바뀌었는데, 그 대조는 D-4a 의 「엣지에서 다시 치고」 절에 있다 — 일련번호가 06F3E0EF4D1BB03DE58130EAAD1176101373 에서 065547991777D11A408CEA90D945DDA03DF1 로, notAfterDec 3 에서 Dec 16 으로 바뀜다.

주입 검증

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

14. certbot 쪽에서 갱신이 끝났는지 본다

무엇을 보는가 — certbot 이 관리하는 인증서의 일련번호와 만료일.

ssh -t test-server 'sudo certbot certificates'

어디를 보나 — 실측은 이렇다(observed, 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 Number 가 주입 전에 적어 둔 값과 다르고 만료일도 하루 밀렸다(Dec 2Dec 3). certbot 쪽에서는 갱신이 끝났다.

15. 파일이 언제 써졌는지 본다

무엇을 보는가archive/ 에 새 벌이 생겼는지와 그 mtime.

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

어디를 보나 — 실측은 이렇다(observed, 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) 라 106초 빠르고 실제로는 08:20:27 (실제) 이며, 관찰 절에서 이 보정을 쓴다. privkey2.pem 의 권한이 -rw------- 인 것도 본다 — 개인키는 D-3 의 주제와 같은 문제를 안고 있어서 파일 하나를 얻으면 끝난다.

관찰

16. 밖에서 본 일련번호를 본다

무엇을 보는가 — 디스크가 바뀐 뒤 네트워크로 나가는 인증서.

tail -3 /tmp/d4-serial.txt

어디를 보나 — 실측은 이렇다(observed, 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분) 동안 단 한 번도 바뀌지 않았다.

이 값이 뜻하는 것 — 주입 검증에서 본 디스크의 6c7cb6df… 와 다르다. 두 사건이 갈라졌다.

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

여기서 「갱신이 실패했다」고 결론 내리면 틀린다. 주입 검증에서 성공을 이미 봤다.

17. 워커 PID 로 reload 여부를 판정한다

무엇을 보는가 — 7번에서 적어 둔 두 줄과 지금의 두 줄.

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

어디를 보나 — 실측은 주입 전과 같다(observed).

    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 가 없었다. 주입 전에 정한 판정 기준이 여기서 답을 내므로 로그를 뒤질 일이 없다.

18. nginx 가 무엇을 물고 있는지 본다

무엇을 보는가ssl_certificate 가 가리키는 경로. sudo 없이 읽힌다.

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

어디를 보나 — 실측은 이렇다(observed, 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;

이 값이 뜻하는 것 — nginx 는 이 파일을 기동 시점에 한 번 읽어 메모리에 들고 있고 요청마다 디스크를 다시 보지 않는다. 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 라 원인을 찾는 데 오래 걸린다.

6번에서 적어 둔 표가 여기서 판정이 된다. 세 경로 전부가 비어 있다.

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

19. 갱신에서 서빙까지 몇 초였나

무엇을 보는가 — 일련번호가 언제 바뀌었는지. 바뀌는 사건 자체는 복구 절에서 사람이 reload 를 친 뒤에 일어난다. 그러니 이 명령은 복구 절 ① 부터 ④ 까지를 치고 돌아와서 친다. 여기서 먼저 치면 아직 안 바뀐 로그를 보는 것이라 빈 줄만 나온다.

0520BB 는 이 실험대의 옛 일련번호다. 주입 전에 §1 에서 적어 둔 자기 호스트의 serial 앞머리로 갈아 끼워 친다. 그대로 두면 모든 줄이 통과해서 「바뀐 순간」이 안 골라진다.

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

어디를 보나 — 실측은 이렇다(observed, 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초가 결과를 완전히 뒤집는데, 보정하지 않으면 뺀 값이 참값보다 약 106초 어긋나고 보정을 반대로 걸면 음수 지연이 나와 물리적으로 성립하지 않는다. 그러니 두 시각의 차가 음수로 나오면 계산이 아니라 시계를 의심한다.

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

20. 88일 동안 안 보이는 까닭을 확인한다

무엇을 보는가 — 이 결함이 언제 발현하는지의 시간표.

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

발현하는 날의 증상은 인증서 만료이고, 그날에도 로그는 SUCCESS 다. 이 결함은 로그 감시로는 못 잡는다. 잡으려면 밖에서 notAfter 를 재야 하고, 감시로 쓸 만한 한 줄이 가이드에 있다. 미검증이다(unknown).

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일을 초로 적은 값이다. 서버에 로그인하지 않고, 밖에서, 실제로 서빙 중인 것을 본다 — 이 셋이 이 실험의 교훈이라고 가이드는 적는다.

21. 감시를 멈추고 무중단인지 센다

목적 — 부수 질문의 답을 낸다. 판정은 복구 절에서 사람이 reload 를 친 뒤에 한다.

여기서 순서가 갈린다. 이 절의 두 명령과 §19 의 grep복구 절 ① 부터 ④ 까지를 친 뒤에 친다. 감시 셋은 reload 를 치는 그 순간까지 돌고 있어야 하기 때문이다 — 일련번호가 바뀌는 것도, 전송 중이던 요청이 reload 를 관통하는 것도 그 순간에만 로그에 찍힌다. 그래서 감시를 멈추는 touch /tmp/d4-stop 은 이 절이 아니라 복구 절 ⑤ 에 있다. 여기서 먼저 멈추면 §19 의 grep 과 아래 두 줄과 복구 ④ 의 tail 이 전부 빈손으로 나오고, 빈손은 화면에서 「아무 일도 없었다」와 구별되지 않는다.

1. 폴링에서 200 이 아닌 줄을 고른다.

grep -vE ' 200 ' /tmp/d4-poll.txt | head

2. 전송 중 요청에서 200 이 아닌 줄을 고른다.

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

예상 결과 — 실측은 이렇다(observed, 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 의 흔적이 아니다.

진행 중이던 요청이 계획서가 정확히 물은 지점이다(observed, 13-verdict.txt).

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

reload 는 무중단이다. 옛 인증서로 시작한 연결이 새 워커 전환을 관통해 끝까지 갔다. 전송 중 요청 전체 50건 중 종료코드 ≠ 0 은 0건이다.

50건 과 다음 절의 76건 은 같은 축에서 센 값이 아니다. 여기의 50건은 42초짜리 요청이 끝까지 간 횟수이고, 다음 절의 76건은 08:15:04 한 초에 몰려 찍힌 즉시 실패 줄이다. 둘을 더하거나 빼서 맞추려 들지 않는다 — 가이드가 두 숫자를 한 축으로 맞춰 놓지 않았고(unknown), 자기 화면의 /tmp/d4-inflight.txt 줄 수는 이 둘 어느 쪽과도 다를 수 있다.

왜 필요한가 — 이 8856건과 845361바이트가 잰 것은 사람이 건 reload 다. 08:58:52nginx -s reload 는 복구 절에서 사람이 ssh -t 로 붙어 친 한 줄이고, certbot 이 부르는 자동 reload 는 이 실험대에 아직 없다 — 훅 디렉터리 셋이 비어 있다는 것이 바로 이 편의 진단이기 때문이다. 훅이 거는 reload 는 D-4a 에서 넣고 거기서 따로 쟀다. 「reload 가 무중단이다」는 명제는 두 경우에 같은 기제로 성립하지만, 여기 실린 두 수치는 사람이 건 쪽을 잰 값이다.

문제가 생기면 — 전송 중 감시에 실패가 무더기로 찍혔으면 장치 쪽을 먼저 의심한다. 다음 절이 그 사건이다.

22. 측정 장치가 거짓말할 뻔한 곳을 가른다

무엇을 보는가 — 전송 중 감시의 실패 76건. 그대로 적었으면 「갱신 중 대규모 요청 실패」라는 오보가 됐을 것이다(observed, 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 로 돌려 오류 메시지를 버렸고 종료 코드도 안 남겼기 때문이다. 감시 스크립트에 curl종료=$E 가 들어 있는 것이 그 수정이다. 측정 장치가 실패했을 때 왜 실패했는지 남기지 않으면, 그 실패를 대상 탓으로 돌릴지 장치 탓으로 돌릴지 판단할 근거가 없다.

복구와 원상복구 확인표

이 절이 곧 관찰 절의 두 시각을 닫는 사건이다. 관찰 절을 §22 까지 읽은 뒤에 치되, §19 와 §21 의 grep 세 줄은 여기 ④ 를 마치고 돌아가서 친다 — 그 셋이 세는 것이 바로 이 절의 reload 이기 때문이다.

1. reload 시각을 dev 시계로 남긴다.

date -u '+%H:%M:%S reload (dev)'

2. 설정을 검사하고 reload 한다.

ssh -t test-server 'sudo nginx -t && sudo nginx -s reload'

test is successful 두 줄이 먼저 나오고 그다음 아무 말 없이 끝난다. -s reload 는 조용하다.

reload 이고 restart 가 아닌가. 이 호스트의 nginx.service 유효 설정이 답이다. 유닛 파일이 아니라 실제 적용값이라 show 로 본다.

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

실측(호스트)은 이렇다(observed — 증거 파일이 아니라 이 호스트에서 확인한 값이다).

Type=forking          Restart=on-failure    RestartUSec=100ms
StartLimitBurst=5     StartLimitIntervalUSec=10s
KillMode=mixed        KillSignal=SIGQUIT    PrivateTmp=true
설정 읽는 법
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 는 그 안전장치가 없다.

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

3. 바뀌었는지는 두 곳을 본다. 먼저 nginx 쪽이다.

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

실측은 이렇다(observed, d4a-deploy-hook/01-hook-verified.txt 가 D-4a 첫머리에 찍은 값이고, 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 다. 주입 전에 세운 판정 기준 그대로다.

4. 밖에서 본 일련번호를 본다.

tail -3 /tmp/d4-serial.txt
  08:58:52              serial=06C7CB...EA1D  notAfter=Dec  3   ← 바뀐 순간

일련번호가 주입 검증에서 본 디스크의 값과 같아졌는가를 본다. 같아졌으면 디스크와 네트워크가 다시 일치한 것이고, 그 사이의 2305초 가 이 실험의 답이다.

5. 이제 감시 셋을 멈춘다. ④ 가 새 일련번호를 냈으면 감시가 할 일은 끝났다.

touch /tmp/d4-stop

멈춤 파일은 루프를 끝낼 뿐이고 세 로그 파일은 지우지 않는다. 여기까지 치고 나서 관찰 절의 §19 와 §21 로 돌아가 그 절의 grep 을 친다. 그 두 절의 판정은 이 reload 가 로그에 찍힌 뒤라야 선다.

항목 명령 돌아왔을 때
서빙 인증서 주입 전에 친 openssl … -serial 주입 검증의 새 일련번호와 같다
체인 주입 전에 친 Verify return code 한 줄 4단계, Verify return code: 0
이름 셋 주입 전에 친 for H in auth app1 app2 세 일련번호가 서로 같다
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 근거로 남기거나 지운다

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

두 가지가 이 표에서 끝나지 않는다. 감시 셋은 setsid 로 띄워서 PID 를 안 받아 뒀으므로 ps -ef | grep d4- 에 뭔가 남아 있을 때 그것을 죽이는 명령이 가이드에 없다(unknown). §11 이 만든 /tmp/d4-watch-serial.sh · /tmp/d4-poll.sh · /tmp/d4-inflight.sh/tmp/d4-stop 을 지우는 줄도 없다(unknown). 임시 파일 행은 /tmp/d4-*.txt/tmp/inflight.bin 만 센다.

진짜 고치는 법은 이 절차 밖에 있다. 이 절차가 38분에서 끝난 것은 사람이 reload 를 쳤기 때문이고, 자동으로 되게 하려면 훅이 필요하다.

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

deploy/ 는 실제로 갱신된 인증서가 있을 때만 실행된다. post/ 는 갱신 여부와 무관하게 매번 돌므로 하루 두 번 쓸데없이 워커를 갈아치우게 된다. 이 처방은 D-4a 에서 실제로 넣고 검증했다 — 훅 파일 하나로 발급에서 서빙까지가 38분 25초 에서 1~2초가 됐다. 처방을 적고 시험하지 않는 것이야말로 이 실험대가 계속 경계해 온 실수라서 별도 실험으로 분리했다.

막히면

전부 이 실험대가 실제로 겪은 증상이라고 가이드가 적는다.

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

crt.sh 에 관한 곁다리도 실측이다. 발급 사실은 Certificate Transparency 에 남으므로 sudo 없이 확인할 수 있을 것 같았고, 실제로 서빙 중인 인증서에는 SCT 가 2개 박혀 있다. 그런데 색인 쪽은 달랐다(observed, 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 지표와 같은 종류의 함정이다.

이 실험대에서 아직 못 밟은 단계

2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. 못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.

  • 남은 것 — 인증서 발급과 갱신·reload 판정 전부(9~22 절). Cloudflare API 토큰이 있어야 DNS-01 이 돈다. 지금까지 밟은 것 — 기계·유닛 이름 대조(5 절), 기본 유닛에 훅이 없다는 확인(6 절 셋 다), nginx 워커 PID 판정 기준(7 절, 기계를 엣지로 고쳐서), 두 기계의 시계 차(8 절).
  • 막는 것https://auth.hyeonworks.com 이 서지 않는다. 남은 것은 인증서 하나다(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt guest_input 구멍과 유닛의 ExecStartPost 가 들어가면서 열렸고(http 가 밖에서 200), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다.
  • 그때까지 이 편의 실측 가운데 (observed) 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다. 나머지는 원래 실행의 값이다.

무엇이 관측이고 무엇이 아닌가

  • (observed) 주입 전 인증서의 subject·issuer·notBefore=Sep 3 00:47:23 2026 GMT·notAfter=Dec 2 00:47:22 2026 GMT 와 SAN 세 이름, SCT 두 줄, 체인 네 줄과 Verify return code: 0 (ok), 타이머 표와 status=0/SUCCESS, 남은 일수: 88일, 유닛 본문과 ExecStart=/usr/bin/certbot -q renew, 훅 디렉터리의 Permission deniedsudo 로 본 total 8 셋, Discovered plugins: dns-cloudflare, manual, null, standalone, webrootcertbot 5.7.0, 워커 두 줄(585·586·80529), 시계 측정 네 줄과 +106.1 세 번, 대조군 900건, 전송 중 대조군의 845361·41.392198s, sudo -n -l 두 줄, Serial Number: 6c7cb6df1da8a6d7995d93c264bb9ecea1d, archive/ 여덟 줄과 2026-09-04 17:22:13 mtime, 감시의 serial=0520BB6416D569E26697B1691440F523B853 과 161표본, ssl_certificate 두 줄, 일련번호가 바뀐 08:58:52 와 그 앞 구간의 428회, 폴링 8856건 과 구간 셋의 중앙·p95·최대, 전송 중 세 줄과 845361·연결수=1·curl종료=0, 아티팩트 76건과 50µs·0/100, reload 뒤의 워커 28829, crt.sh 의 [] 와 13건.
  • (observed, 호스트 확인) nginx.service 의 유효 설정 여덟 값. 증거 파일이 아니라 이 호스트에서 확인한 값이라 가이드가 실측(호스트)으로 따로 표시했다.
  • 이 편이 잰 reload 는 사람이 쳤다08:58:52nginx -s reload 는 복구 절에서 사람이 ssh -t 로 붙어 친 한 줄이다. 훅이 부르는 자동 reload 는 이 실험대에 아직 없었고(그것이 이 편의 진단이다) D-4a 에서 넣어 따로 쟀다. 무중단 판정의 8856건845361 바이트는 사람이 건 reload 를 잰 값이다.
  • 2199초 는 이 실험이 스스로 정정했다 — 처음에 archive/cert2.pem 의 mtime(test-server 시계)과 일련번호 관측(dev 시계)을 그대로 빼서 2199초 로 적었고, 시계 왜곡 106초를 보정한 뒤 2305초 로 고쳤다. 틀린 값과 맞는 값을 둘 다 적어 둔 까닭은 어느 쪽이 왜 틀렸는지가 이 편의 교훈이기 때문이다. 보정을 자기 검증한 것은 D-4a 이고, 거기서는 같은 106초가 결과를 뒤집는다.
  • (observed, 2026-09-17) certbot renew --dry-run 은 훅을 놓아도 Running deploy-hook command 를 안 낸다 — certbot 2.1.0 의 dry-run 이 deploy 훅을 건너뛰고 --run-deploy-hooks 플래그도 없다. openssl … -checkend 2592000 감시 한 줄, systemctl reload nginx 형태는 여전히 미검증이다(unknown).
  • 비밀은 옮기지 않았다 — 이 편이 다루는 파일 중 비밀인 것은 privkey2.pem 하나이고 크기(241)와 권한(-rw-------)만 적었다. 내용은 열지 않았고 가이드도 열지 않는다. 일련번호·Log ID·호스트명은 식별자라 그대로 적었다.
  • 이 실험이 재지 않은 것 — 훅이 진짜로 실패했을 때 certbot 이 무엇을 찍는지, 전송 중 아티팩트 76건의 원인, 타이머가 스스로 갱신하는 경로(만료 30일 전에야 조건이 성립한다). 전송 중 감시는 전체 50건이었고 그 이상 반복하지 않았다.