# D-4 — 인증서 갱신 브랜치 `feature/keycloak-d4-certificate-renewal` · 증거 [`docs/evidence/d4-certificate-renewal/`](evidence/d4-certificate-renewal/) · 2026-09-04 17:25–17:35 KST --- ## 구조 ![D-4 구조 — 체인과 SAN 제약](diagrams/d4-cert-chain.svg) **핵심 발견** — 갱신은 성공했는데 **38분 25초** 동안 반영되지 않았다. ![D-4 — 갱신과 서빙 사이의 공백](diagrams/d4-renewal-gap.svg) > 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md). > 실험대 전체 구조는 [`diagrams/lab-topology.svg`](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, 다른 서버)에서만 깨진다.** ```bash openssl s_client -connect :443 -servername | 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 Encrypt `Date` 헤더)으로 재보니 > **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 하면 마스터를 유지한 채 워커만 새로 띄운다. ```bash 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 에서 확인한 값이다. #### 고치는 법 ```bash # 가장 단순한 방법 — 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](experiment-d4a-deploy-hook.md))** — 이 처방을 실제로 > 넣고 강제 갱신했다. 워커가 `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](experiment-d4a-deploy-hook.md). **듣는다** — 발급에서 서빙까지 **1~2초**(여기 38분 25초의 약 1150분의 1) | --- ## 증거 파일 **증거 수집: 2026-09-04 · 감시 08:10:51 – 09:02 UTC** (파일 mtime 기준) | 파일 | 종류 | 무엇을 보여주는가 | |---|---|---| | [`01-certificate-state.txt`](evidence/d4-certificate-renewal/01-certificate-state.txt) | 터미널 | SAN 3개 · 체인 4단계 `Verify return code: 0` | | [`05-control-no-injection.txt`](evidence/d4-certificate-renewal/05-control-no-injection.txt) | 터미널 | **대조군 1** — 900/900 200, 오류 0 · 핸드셰이크 900/900 | | [`06-inflight-control.txt`](evidence/d4-certificate-renewal/06-inflight-control.txt) | 터미널 | **대조군 2** — 42초 in-flight 장치, 845361바이트 완주 | | [`07-renewal-hook-missing.txt`](evidence/d4-certificate-renewal/07-renewal-hook-missing.txt) | 터미널 | nginx 워커 PID 로 본 "reload 없음" · 유닛에 `ExecStartPost` 없음 · crt.sh 로는 못 가림 | | [`08-inflight-artifact.txt`](evidence/d4-certificate-renewal/08-inflight-artifact.txt) | 터미널 | 76건 실패가 **로컬 아티팩트**임을 대조 폴링으로 가려낸 기록 | | [`09-serial-timeline.txt`](evidence/d4-certificate-renewal/09-serial-timeline.txt) | 터미널 | 일련번호 564표본 — 08:58:52 에 바뀌는 순간 | | [`10-reload-poll-window.txt`](evidence/d4-certificate-renewal/10-reload-poll-window.txt) | 터미널 | reload 전후 60초 새 연결 원문 (비200 0건) | | [`11-inflight-full.txt`](evidence/d4-certificate-renewal/11-inflight-full.txt) | 터미널 | in-flight 전체 — reload 를 관통한 08:58:40 요청 포함 | | [`12-certbot-state.txt`](evidence/d4-certificate-renewal/12-certbot-state.txt) | 터미널 | `certbot certificates` · `archive/` mtime · **훅 3개 디렉터리 전부 비었음** | | [`13-verdict.txt`](evidence/d4-certificate-renewal/13-verdict.txt) | 터미널 | **판정** — 38분 25초 공백 + reload 무중단, 수치 전량 | 파일별 상세는 [`evidence/d4-certificate-renewal/README.md`](evidence/d4-certificate-renewal/README.md). ## 5. 재현 절차 (명령어) ```bash # ───────────────────────────────────────────────────────────── # 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 과 연결** | 인증서 **개인키**도 같은 비밀 관리 문제다 |