Files
keycloak-pattern/docs/experiment-d4-certificate-renewal.md
T
DongHyeonkaandClaude Opus 5 faf55dc1be docs(d4): the renewal succeeded and was not served for 36 minutes 39 seconds
The forced renewal did work. What did not work was anything making nginx
notice.

  disk     cert2.pem written 2026-09-04 17:22:13 KST
  network  old serial through 08:58:47 UTC, new serial from 08:58:52 UTC
  gap      2199 seconds, and the old certificate was observed 428 times in it

The gap closed only because a person ran `nginx -s reload`. Nothing else
would have: certbot-renew.service is just `ExecStart=/usr/bin/certbot -q
renew` with no ExecStartPost, all three of renewal-hooks/{deploy,post,pre}
are empty, and certbot 5.7.0 here has no nginx plugin (dns-cloudflare,
manual, null, standalone, webroot). Three paths, all empty. Any one of them
would have been enough.

nginx holds the certificate in memory from startup, and certbot swaps the
live/ symlink rather than the path — so the config still looks right while
the served certificate is stale. Master 585 and worker 586 sharing a start
time 22.4 hours old is what proved no reload had happened.

This is invisible for 88 days. The timer ran twice today and exited
0/SUCCESS both times, because a renewal is not due yet. The day it becomes
due, the symptom is an expired certificate and the log still says SUCCESS.

The other half of the plan's question now has a measurement rather than an
expectation. The reload is genuinely graceful: 8856 polled connections, zero
non-200, p95 205.7ms before against 204.3ms after. And an in-flight request
— 845KB pulled at 20k/s, so still transferring 12 seconds in when the reload
landed — completed with all 845361 bytes over a single connection.

The earlier note that nginx reload "is graceful by design but must not be
written that way without checking" was right to hold back. The neighbouring
belief, that renewing gets the new certificate served, was the one that was
false, and there was no way to tell in advance which it would be.

Adds diagrams/d4-renewal-gap.svg and evidence 09-13.

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

19 KiB
Raw Blame History

D-4 — 인증서 갱신

브랜치 feature/keycloak-d4-certificate-renewal · 증거 docs/evidence/d4-certificate-renewal/ · 2026-09-04 17:2517:35 KST


구조

D-4 구조 — 체인과 SAN 제약

핵심 발견 — 갱신은 성공했는데 36분 39초 동안 반영되지 않았다.

D-4 — 갱신과 서빙 사이의 공백

다이어그램 규약은 diagrams/_style.md. 실험대 전체 구조는 diagrams/lab-topology.svg.


0. 결론부터

확인 결과
인증서 구성 SAN 3개 (auth/app1/app2), 와일드카드 아님
체인 완결성 정상. Verify return code: 0 (ok), 4단계
갱신 자동화 타이머는 동작한다. 오늘 두 번 status=0/SUCCESS
★ 갱신 반영 안 된다. 새 인증서가 디스크에 있는 채로 36분 39초 동안 옛 인증서를 서빙했다
★ 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
공백 2199초 = 36분 39초

그 사이 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.serviceExecStartPost 없다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 가 바뀌는지 봐야 한다. 이 실험이 그 확인 절차다.

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실제 갱신을 하는가 미측정. 약 58일 뒤에야 조건이 성립한다
deploy 훅을 넣으면 자동 반영되는가 미측정. 훅은 아직 넣지 않았다

증거 파일

증거 수집: 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 터미널 판정 — 36분 39초 공백 + 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 과 연결 인증서 개인키도 같은 비밀 관리 문제다