D-4 named the fix and never ran it. Running it:
/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/sh
nginx -t && nginx -s reload
certbot reports "Hook 'deploy-hook' ran", the master stays at PID 585 and
the worker is replaced 28829 -> 37252, and the served certificate changes.
One trap worth naming: certbot prefixes the hook output with "ran with error
output" whenever the hook writes anything to stderr, and nginx's routine
types_hash warning goes to stderr. Everything inside is success — "test is
successful", "signal process started". A monitor that greps for "error"
would read a working hook as a failure. The worker PID is what to check.
Timing needed a clock correction. test-server has NTP off and runs 106
seconds fast; the dev machine matches Google and the Let's Encrypt ACME
endpoint exactly. Corrected, the hook's nginx -t lands at 12:27:50 UTC
against the new certificate's SCT at 12:27:49.054 — one second. The
correction validates itself: uncorrected, the hook would have run 104
seconds before the certificate existed.
That same skew is why D-4's gap was reported 106 seconds short; corrected to
2305s in the previous commit.
Remaining and left alone: whether certbot-renew.timer performs a real
renewal, which cannot be tested for about 89 days.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
20 KiB
D-4 — 인증서 갱신
브랜치 feature/keycloak-d4-certificate-renewal ·
증거 docs/evidence/d4-certificate-renewal/ ·
2026-09-04 17:25–17:35 KST
구조
핵심 발견 — 갱신은 성공했는데 38분 25초 동안 반영되지 않았다.
다이어그램 규약은
diagrams/_style.md. 실험대 전체 구조는diagrams/lab-topology.svg.
0. 결론부터
| 확인 | 결과 |
|---|---|
| 인증서 구성 | SAN 3개 (auth/app1/app2), 와일드카드 아님 |
| 체인 완결성 | 정상. Verify return code: 0 (ok), 4단계 |
| 갱신 자동화 | 타이머는 동작한다. 오늘 두 번 status=0/SUCCESS |
| ★ 갱신 반영 | 안 된다. 새 인증서가 디스크에 있는 채로 38분 25초 동안 옛 인증서를 서빙했다 |
| ★ reload 무중단 | 무중단이다. 8856건 전부 200 · 전송 한가운데였던 42초 요청이 845361바이트 전량 수신 |
한 문장으로
"갱신 성공"과 "새 인증서 서빙"은 다른 사건이다. 그리고 이 실험대에는 앞의 사건을 뒤의 사건으로 잇는 것이 아무것도 없다.
이게 왜 무서운가 — 이 결함은 88일 동안 보이지 않는다. 타이머는 정상이고
매번 SUCCESS 로 끝난다. 만료 30일 전까지는 certbot 이 갱신 자체를 하지 않아
"반영이 안 된다"는 사실이 발현할 기회가 없기 때문이다. 발현하는 날의 증상은
인증서 만료이고, 그날에도 로그에는 SUCCESS 라고 적혀 있을 것이다.
1. 인증서 구성 — B-7 에서 실제로 걸린 제약
X509v3 Subject Alternative Name:
DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com
세 이름뿐이고 와일드카드가 아니다.
이 제약이 B-7 에서 실제 비용을 만들었다. oauth2-proxy 를 올릴 호스트명이 없어 Grafana 가 쓰던
app2를 빌려야 했고, 그 때문에 관측 스택의 웹 UI 가 실험 동안 내려가 있었다."인증서에 이름을 몇 개 넣을 것인가" 는 TLS 설정이 아니라 나중에 무엇을 배포할 수 있는가를 정하는 결정이다.
| 이 실험대 | 와일드카드였다면 | |
|---|---|---|
| 새 호스트 추가 | 인증서 재발급 필요 | 바로 가능 |
| DNS-01 검증 | 필요 | 필요 (와일드카드는 DNS-01 만 가능) |
| 노출 | 이름 3개만 | 하위 전체가 한 키에 묶인다 |
2. 체인이 완전한가 — 흔한 실수 확인
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)
중간 인증서가 함께 제공된다.
개념 — fullchain.pem vs cert.pem
certbot 은 두 파일을 만든다.
| 파일 | 내용 | nginx 에 넣으면 |
|---|---|---|
cert.pem |
리프만 | 일부 클라이언트에서 검증 실패 |
fullchain.pem |
리프 + 중간 | 정상 |
브라우저는 중간 인증서를 캐시하고 있어 cert.pem 으로도 대개 동작한다.
그래서 실수해도 개발 중에는 안 드러나고, 캐시가 없는 클라이언트
(모바일 앱, curl, 다른 서버)에서만 깨진다.
openssl s_client -connect <host>:443 -servername <host> | grep -E "^ *[0-9] s:"
단계가 2개 이상이면 fullchain 이고, 1개면 cert.pem 을 쓴 것이다. 이 실험대는 4단계로 정상이다.
3. 갱신 자동화는 동작한다
NEXT LEFT LAST PASSED
Fri 2026-09-04 17:03:46 KST 1h 54min Fri 2026-09-04 03:19:39 KST 11h ago
타이머 enabled: enabled / active: active
하루 두 번 돌고, 11시간 전에 실제로 실행됐다.
만료: Dec 2 00:47:22 2026 GMT
남은 일수: 88일
아직 갱신하지 않은 것이 정상이다 — Let's Encrypt 는 90일 발급이고 certbot 은 30일 남았을 때 갱신한다. 지금 실행돼도 아무것도 안 한다.
타이머가 돌았다는 것과 갱신이 됐다는 것은 다르다. "타이머가 active 니까 괜찮다" 는 확인이 아니다. 실제 갱신은 58일 뒤이며, 그때 처음으로 절차가 시험된다.
4. ★ 강제 갱신 — 두 개의 답
4-1. 먼저 대조군을 잡았다
주입 전에 평시를 재둔다. 이걸 건너뛰면 주입 결과를 해석할 수 없다 — A-6 에서 −41% 대조군을 "영향 없음"이라 적었던 것도, A-8 에서 표본 9개로 무중단을 주장한 것도 같은 종류의 실수였다.
| 대조군 | 결과 |
|---|---|
| 새 연결 (0.2초 × 900회 / 180초) | 900 전부 200, 오류 0 · 중앙 98ms · p95 195ms |
| 진행 중 요청 (845KB @ 20k/s) | 200 · 845361바이트 · 연결수 1 · 42.3초 완주 |
두 번째 장치가 왜 따로 필요했는가 — 첫 번째 폴링은 TLS 핸드셰이크가 900/900 이다. 매 요청이 새 연결이라는 뜻이고, 그래서 "새 연결을 받아주는가"만 잰다. 계획서가 물은 것은 "진행 중이던 요청은 어떻게 되는가" 이므로, reload 순간에 실제로 전송 중인 요청이 있어야 한다. 845KB 짜리 관리 콘솔 번들을 일부러 느리게 받아 요청 하나를 42초 동안 살려 두었다.
4-2. 갱신은 성공했는데, 인증서가 바뀌지 않았다
sudo certbot renew --force-renewal 을 실행한 뒤에도 밖에서 본 인증서가
그대로였다. 처음에는 갱신이 실패한 줄 알았다. 아니었다.
디스크 (/etc/letsencrypt/archive/auth.hyeonworks.com/)
cert1.pem 2026-09-03 10:47:40 KST 처음 발급
cert2.pem 2026-09-04 17:22:13 KST ← 강제 갱신, 새로 써졌다
네트워크 (5초 간격 564표본)
08:10:51 ~ 08:58:47 serial=0520BB…B853 notAfter=Dec 2 ← 옛 것
08:58:52 serial=06C7CB…EA1D notAfter=Dec 3 ← 바뀐 순간
| 시각 (UTC) | |
|---|---|
| 새 인증서 디스크 기록 | 08:22:13 |
실제 서빙 시작 (nginx -s reload) |
08:58:52 |
| 공백 | 2305초 = 38분 25초 (시계 보정 후 — 아래 참조) |
★ 시계 보정 (D-4a 에서 발견) — 처음 적은 2199초(36분 39초) 는
archive/파일 시각(test-server 시계)과 일련번호 관측(dev 시계)을 그대로 뺀 값이었다. 외부 기준(Google · Let's EncryptDate헤더)으로 재보니 dev 가 정확하고 test-server 가 106초 빠르다(3회 측정 전부 +106.1초,NTPSynchronized=no). 보정하면 2305초 = 38분 25초다. 보정값은 D-4a 새 인증서의 SCT(Sep 4 12:27:49.054 GMT, CT 로그가 서명한 독립 시계)가 보정한 훅 시각의 1초 앞에 놓이는 것으로 교차 검증된다.
그 사이 428번 옛 인증서로 관측됐다. 그리고 이 36분은 우연히 짧았을 뿐이다 — reload 를 시킨 것은 사람이지 자동화가 아니다. 아무도 안 했다면 다음 nginx 재시작까지, 즉 무기한 옛 인증서를 서빙했을 것이다.
개념 — 왜 파일이 바뀌어도 nginx 는 모르는가
무엇인가. nginx 는 ssl_certificate 가 가리키는 파일을 기동 시점에
한 번 읽어 메모리에 들고 있다. 요청마다 디스크를 다시 보지 않는다.
왜 여기 나오나. certbot 은 live/ 심볼릭 링크가 새 archive/ 파일을
가리키도록 바꾼다. 경로는 그대로인데 내용만 바뀐다. 그래서 nginx 설정을
고칠 필요가 없고, 바로 그 때문에 "설정이 그대로니 괜찮다"고 착각하기 쉽다.
필요한 것은 설정 변경이 아니라 reload 다.
없거나 틀리면. 인증서가 만료되어 브라우저가 NET::ERR_CERT_DATE_INVALID
를 띄운다. 그 시점에 디스크에는 멀쩡한 인증서가 들어 있고 갱신 로그도
SUCCESS 다 — 그래서 원인을 찾는 데 오래 걸린다.
확인. nginx 는 reload 하면 마스터를 유지한 채 워커만 새로 띄운다.
ps -eo pid,ppid,etimes,lstart,args | grep nginx
# 585 1 80529 Thu Sep 3 19:00:39 nginx: master process
# 586 585 80529 Thu Sep 3 19:00:39 nginx: worker process
# ↑ 워커가 마스터 기동 직후의 첫 fork(585→586) 그대로 22.4시간째
# = reload 가 한 번도 없었다
원인은 하나가 아니라 셋이 겹쳤다
| # | 경로 | 상태 |
|---|---|---|
| 1 | certbot-renew.service 의 ExecStartPost |
없다 — ExecStart=/usr/bin/certbot -q renew 가 전부 |
| 2 | /etc/letsencrypt/renewal-hooks/{deploy,post,pre}/ |
셋 다 비어 있다 (total 8, . 과 .. 뿐) |
| 3 | certbot 의 nginx 플러그인 | 없다 — dns-cloudflare, manual, null, standalone, webroot |
세 경로 전부가 비어 있다. 하나라도 있었으면 자동으로 반영됐다. certbot 5.7.0 에서 확인한 값이다.
고치는 법
# 가장 단순한 방법 — deploy 훅 하나
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'EOS'
#!/bin/sh
nginx -t && nginx -s reload
EOS
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
deploy/ 는 실제로 갱신된 인증서가 있을 때만 실행된다. post/ 는 갱신
여부와 무관하게 매번 돌므로, 하루 두 번 쓸데없이 reload 하게 된다.
확인 방법 — 훅을 넣었다고 끝이 아니다. 다음 강제 갱신 때 nginx 워커
PID 가 바뀌는지 봐야 한다.
★ 검증됨 (D-4a) — 이 처방을 실제로 넣고 강제 갱신했다. 워커가
28829 → 37252로 자동 교체됐고, 발급에서 서빙까지 1~2초였다. 다만 certbot 출력의Hook 'deploy-hook' ran with error output은 실패가 아니다 — nginx 의 경고가 stderr 로 나갔을 뿐이다. 판정은 문구가 아니라 워커 PID 로 한다.
4-3. reload 는 무중단이다 — 측정됨
계획서의 질문에 대한 답이다.
새 연결 — 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 | 옛 워커가 이 요청을 끝까지 책임졌다 |
옛 인증서로 시작한 연결이 새 워커 전환을 관통해 끝까지 갔다. in-flight 전체 50건 중 종료코드 ≠ 0 은 0건이다.
문서가 "nginx 의 reload 는 설계상 무중단이지만 확인하지 않았으므로 그렇게 쓰면 안 된다"고 유보했던 명제가, 이제 측정으로 뒷받침된다. 그리고 유보가 옳았다 — 같은 자리에서 유보 없이 썼다면 맞았겠지만, 바로 옆의 "갱신하면 반영된다"는 명제는 틀렸다. 어느 쪽이 틀릴지는 미리 알 수 없다.
4-4. 남은 미측정
| 항목 | 상태 |
|---|---|
certbot-renew.timer 가 실제 갱신을 하는가 |
미측정. 약 89일 뒤에야 조건이 성립한다(갱신으로 만료일이 밀렸다) |
| deploy 훅을 넣으면 자동 반영되는가 | 측정 완료 → D-4a. 듣는다 — 발급에서 서빙까지 1~2초(여기 38분 25초의 약 1150분의 1) |
증거 파일
증거 수집: 2026-09-04 · 감시 08:10:51 – 09:02 UTC (파일 mtime 기준)
| 파일 | 종류 | 무엇을 보여주는가 |
|---|---|---|
01-certificate-state.txt |
터미널 | SAN 3개 · 체인 4단계 Verify return code: 0 |
05-control-no-injection.txt |
터미널 | 대조군 1 — 900/900 200, 오류 0 · 핸드셰이크 900/900 |
06-inflight-control.txt |
터미널 | 대조군 2 — 42초 in-flight 장치, 845361바이트 완주 |
07-renewal-hook-missing.txt |
터미널 | nginx 워커 PID 로 본 "reload 없음" · 유닛에 ExecStartPost 없음 · crt.sh 로는 못 가림 |
08-inflight-artifact.txt |
터미널 | 76건 실패가 로컬 아티팩트임을 대조 폴링으로 가려낸 기록 |
09-serial-timeline.txt |
터미널 | 일련번호 564표본 — 08:58:52 에 바뀌는 순간 |
10-reload-poll-window.txt |
터미널 | reload 전후 60초 새 연결 원문 (비200 0건) |
11-inflight-full.txt |
터미널 | in-flight 전체 — reload 를 관통한 08:58:40 요청 포함 |
12-certbot-state.txt |
터미널 | certbot certificates · archive/ mtime · 훅 3개 디렉터리 전부 비었음 |
13-verdict.txt |
터미널 | 판정 — 38분 25초 공백 + reload 무중단, 수치 전량 |
파일별 상세는 evidence/d4-certificate-renewal/README.md.
5. 재현 절차 (명령어)
# ─────────────────────────────────────────────────────────────
# 1. 인증서 내용 — 밖에서 볼 수 있다(sudo 불필요)
# ─────────────────────────────────────────────────────────────
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 -dates -subject -ext subjectAltName
done
# 세 이름의 일련번호가 같으면 → 인증서 1장에 SAN 3개다.
# B-7 이 네 번째 이름을 못 쓴 이유가 여기 있다.
# ─────────────────────────────────────────────────────────────
# 2. 체인 완결성 — 단계가 1개면 cert.pem 을 쓴 것이다
# ─────────────────────────────────────────────────────────────
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'
# ─────────────────────────────────────────────────────────────
# 3. ★ 대조군을 먼저 잡는다 — 이걸 건너뛰면 주입 결과를 해석할 수 없다
# ─────────────────────────────────────────────────────────────
# 3-a. 새 연결 가용성: 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 # 비200 이 몇 개인가
# 3-b. 진행 중 요청: 845KB 를 20k/s 로 받아 42초 동안 살려 둔다
JS=$(curl -s https://auth.hyeonworks.com/admin/master/console/ \
| grep -oE '/resources/[a-z0-9]+/admin/[^"]+\.js' | head -1)
curl -s --limit-rate 20k -o /tmp/inflight.bin \
-w '코드=%{http_code} 바이트=%{size_download} 시간=%{time_total} 연결수=%{num_connects}\n' \
"https://auth.hyeonworks.com$JS"
# ─────────────────────────────────────────────────────────────
# 4. 감시를 켠다 — 일련번호가 바뀌면 3분 더 재고 스스로 멈춘다
# ─────────────────────────────────────────────────────────────
BASE=$(echo | openssl s_client -connect auth.hyeonworks.com:443 \
-servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -serial | cut -d= -f2)
rm -f /tmp/d4-stop
# 감시 0 — 일련번호. 바뀌는 순간이 nginx 가 새 인증서를 집은 순간이다
setsid bash -c '
BASE='"$BASE"'
while [ ! -f /tmp/d4-stop ]; do
S=$(echo | openssl s_client -connect auth.hyeonworks.com:443 \
-servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -serial -enddate | tr "\n" " ")
echo "$(date -u +%H:%M:%S) $S"
case "$S" in *"$BASE"*) ;; *) sleep 180; touch /tmp/d4-stop;; esac
sleep 5
done' > /tmp/d4-serial.txt 2>&1 < /dev/null &
# 감시 1 — 새 연결
setsid bash -c '
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' > /tmp/d4-poll.txt 2>&1 < /dev/null &
# 감시 2 — 진행 중 요청을 끊김 없이 연달아 (reload 순간에 반드시 하나가 떠 있게)
setsid bash -c '
while [ ! -f /tmp/d4-stop ]; do
echo "$(date -u +%H:%M:%S) $(curl -s --limit-rate 20k -o /dev/null \
-w "코드=%{http_code} 바이트=%{size_download} 시간=%{time_total} 연결수=%{num_connects}" \
https://auth.hyeonworks.com'"$JS"')"
done' > /tmp/d4-inflight.txt 2>&1 < /dev/null &
# ★ setsid 가 필요하다. 그냥 & 로 띄우면 부모 셸이 끝날 때 같이 죽는다
# (A-3 에서 파드 안 & 가 exec 종료와 함께 죽은 것과 같은 함정이다).
# ─────────────────────────────────────────────────────────────
# 5. 주입 — 여기만 sudo 가 필요하다
# ─────────────────────────────────────────────────────────────
ssh test-server
sudo certbot renew --force-renewal
# ─────────────────────────────────────────────────────────────
# 6. 판정
# ─────────────────────────────────────────────────────────────
grep -vE ' 200 ' /tmp/d4-poll.txt # 새 연결이 끊긴 순간 (대조군은 0건)
grep -v '코드=200' /tmp/d4-inflight.txt # 진행 중 요청이 끊긴 순간
grep -v "$BASE" /tmp/d4-serial.txt | head # 새 인증서가 서빙되기 시작한 시각
6. 남긴 것
| 강제 갱신 + 무중단 측정 | sudo 권한이 필요하다 |
| SAN 확장 | 새 호스트를 쓰려면 재발급 — B-7 에서 실제로 걸렸다 |
| D-3 과 연결 | 인증서 개인키도 같은 비밀 관리 문제다 |