From 0d84921a56497cd74362a4285a51589519b5329e Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 4 Sep 2026 17:28:01 +0900 Subject: [PATCH] docs(d4): the renewal unit has no reload, and nginx has not reloaded in 22 hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forced renewal did not change the certificate being served — 161 serial samples over 13 minutes, all identical, notBefore still Sep 3. Two things were readable without sudo and both matter. nginx has never been reloaded: master 585 and worker 586 share a start time and both are 80529 seconds old. A reload keeps the master and replaces the workers, so the original fork still being there means no reload has happened since Sep 3 19:00. nginx reads the certificate at startup and holds it in memory; without a reload it serves the old one no matter what is on disk. And the unit that is supposed to renew has nothing that would reload it: [Service] Type=oneshot ExecStart=/usr/bin/certbot -q renew PrivateTmp=true No ExecStartPost, no --deploy-hook. The timer runs twice a day and exited 0/SUCCESS both times today, which is precisely why this stays invisible — the renewal is not due for 88 days, so nothing has failed yet. What remains undetermined is whether the user's --force-renewal failed, or succeeded and left new files that nginx never picked up. Telling those apart needs /etc/letsencrypt, which is root-only. If it is the latter, that is the most valuable thing this lab has produced: "renewal succeeded" and "the new certificate is being served" are different events. CT logs looked like a way around the permission problem and were not: the served certificate carries two valid SCTs, yet crt.sh returns zero rows for auth.hyeonworks.com. Same shape as A-2's `up` metric — the observing tool sees a subset of the truth. Also recorded: 76 in-flight failures at 08:15:04 are a local artifact, not a server event. The concurrent 0.2s poll was clean across the same second, num_connects was 0, and time_total was 50 microseconds. Not reproducible in 100 retries. The monitor now records curl's exit code so a recurrence is diagnosable. Co-Authored-By: Claude Opus 5 --- .../07-renewal-hook-missing.txt | 112 ++++++++++++++++++ .../08-inflight-artifact.txt | 66 +++++++++++ 2 files changed, 178 insertions(+) create mode 100644 docs/evidence/d4-certificate-renewal/07-renewal-hook-missing.txt create mode 100644 docs/evidence/d4-certificate-renewal/08-inflight-artifact.txt diff --git a/docs/evidence/d4-certificate-renewal/07-renewal-hook-missing.txt b/docs/evidence/d4-certificate-renewal/07-renewal-hook-missing.txt new file mode 100644 index 0000000..0e32e17 --- /dev/null +++ b/docs/evidence/d4-certificate-renewal/07-renewal-hook-missing.txt @@ -0,0 +1,112 @@ +D-4 — 갱신은 되는데 nginx 가 집지 않는다: 배포 훅 부재 +========================================================= +수집: 2026-09-04 17:2x KST · test-server (sudo 없이 읽을 수 있는 범위) + +발단 +---- +사용자가 `sudo certbot renew --force-renewal` 을 실행했다고 알려왔다. +그런데 밖에서 본 인증서가 바뀌지 않았다. + + serial=0520BB6416D569E26697B1691440F523B853 + notBefore=Sep 3 00:47:23 2026 GMT ← 어제 것 그대로 + notAfter=Dec 2 00:47:22 2026 GMT + +일련번호 감시 161표본(약 13분) 동안 단 한 번도 바뀌지 않았다. + +[증거 1] nginx 는 reload 된 적이 없다 +-------------------------------------- + $ ps -eo pid,ppid,etimes,lstart,args | grep nginx + 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 + +읽는 법 — nginx 의 reload 는 마스터를 유지한 채 **워커만 새로 띄운다.** + · 마스터 585, 워커 586 = 마스터 기동 직후의 첫 fork + · 둘의 lstart 가 같고 etimes 도 같다(80529초 = 22.4시간) + → reload 가 한 번도 없었다. 22.4시간 전 기동 그대로다. + +인증서 파일이 새로 써졌더라도, nginx 는 기동 시점에 읽어 메모리에 들고 있다. +reload 가 없으면 **옛 인증서를 계속 서빙한다.** + +[증거 2] ★ 갱신 유닛에 reload 가 없다 +-------------------------------------- + $ systemctl cat certbot-renew.service + # /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 + +`ExecStart` 가 전부다. **ExecStartPost 도, --deploy-hook 도 없다.** +배포판(Arch)이 넣어준 기본 유닛이 그렇다. + + $ systemctl cat certbot-renew.timer + OnCalendar=*-*-* 00/12:00:00 + RandomizedDelaySec=12h + Persistent=true + +[증거 3] 타이머는 정상 동작한다 — 그래서 더 위험하다 +------------------------------------------------------ + $ systemctl status certbot-renew.service + 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) + + $ journalctl -u certbot-renew.service --since today + 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 다. 만료까지 88일 남아 실제 +갱신은 하지 않았으므로 **아직은** 아무 문제가 없다. + +[증거 4] nginx 가 무엇을 물고 있나 +----------------------------------- + $ grep -rn ssl_certificate /etc/nginx/ + /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/` 파일을 +가리키도록 바뀐다 — **파일 경로는 그대로인데 내용이 바뀐다.** 그래서 nginx +설정은 고칠 필요가 없고, 바로 그 때문에 "설정이 그대로니 괜찮다"고 착각하기 쉽다. +필요한 것은 설정 변경이 아니라 **reload** 다. + +무엇을 확인하지 못했나 +---------------------- +`/etc/letsencrypt/renewal-hooks/deploy/` 에 스크립트가 있으면 유닛 파일과 +무관하게 실행된다. 이 디렉터리는 root 전용이라 읽지 못했다. + + $ ls -laR /etc/letsencrypt/renewal-hooks/ + ls: cannot access '/etc/letsencrypt/renewal-hooks/': Permission denied + +따라서 두 갈래가 남는다. + (a) 강제 갱신이 실패했다 → 파일도 안 바뀌었고 reload 도 없었다 + (b) 강제 갱신이 성공했다 → 파일은 바뀌었는데 훅이 없어 reload 가 안 됐다 + +(b) 라면 이 실험대에서 가장 값진 발견이다. **"갱신 성공"과 "새 인증서 서빙"이 +다른 사건**이라는 것을, 88일 뒤가 아니라 지금 잡은 것이기 때문이다. + +가리는 방법 — root 권한 한 번: + sudo certbot certificates # notAfter 가 오늘+90일이면 (b) + sudo ls -la /etc/letsencrypt/archive/auth.hyeonworks.com/ + sudo tail -60 /var/log/letsencrypt/letsencrypt.log + +곁다리로 확인한 것 — CT 로그로는 가릴 수 없다 +----------------------------------------------- +발급 사실은 Certificate Transparency 에 남으므로 sudo 없이 확인할 수 있을 것 +같았다. 실제로 서빙 중인 인증서에는 SCT 가 2개 박혀 있다. + + CT Precertificate SCTs: + 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 + +그런데 crt.sh 는 이 이름을 모른다. + + $ 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` 지표와 같은 종류의 함정이다. diff --git a/docs/evidence/d4-certificate-renewal/08-inflight-artifact.txt b/docs/evidence/d4-certificate-renewal/08-inflight-artifact.txt new file mode 100644 index 0000000..eb33d7c --- /dev/null +++ b/docs/evidence/d4-certificate-renewal/08-inflight-artifact.txt @@ -0,0 +1,66 @@ +D-4 — in-flight 감시에서 나온 76건 실패: 서버 탓이 아니다 +============================================================ +수집: 2026-09-04 · dev 머신에서 실행한 감시 로그 + +무엇이 보였나 +------------- +42초짜리 in-flight 요청을 연달아 돌리는 감시에서, 91건 중 76건이 실패했다. + + 08:13:40 코드=200 바이트=845361 시간=41.041830 연결수=1 + 08:14:22 코드=200 바이트=845361 시간=42.338482 연결수=1 + 08:15:04 코드=000 바이트=0 시간=0.001148 연결수=0 ← 여기부터 + 08:15:04 코드=000 바이트=0 시간=0.001140 연결수=0 + 08:15:04 코드=000 바이트=0 시간=0.000055 연결수=0 + ... (76건, 전부 08:15:04) + 08:15:04 코드=200 바이트=845361 시간=42.236496 연결수=1 ← 곧바로 복귀 + 08:15:47 코드=200 바이트=845361 시간=42.327129 연결수=1 + +이걸 "갱신 중 진행 중이던 요청이 끊겼다"로 읽으면 안 되는 이유 +---------------------------------------------------------------- +1. **같은 순간 폴링은 멀쩡했다.** 0.2초 간격 폴링의 08:15:00–08:15:20 구간 + 표본 49건이 전부 200이다. 같은 호스트, 같은 TLS, 같은 머신이다. + + 08:15:00.24 200 0.092491 + 08:15:01.15 200 0.099177 + 08:15:02.38 200 0.078729 + ... 49건 / 비200 0건 + +2. **연결수=0 이다.** curl 이 TCP 연결을 시도조차 못 했다는 뜻이다. + 서버가 연결을 끊었다면 연결수는 1이고 바이트는 0보다 크다. + +3. **시간이 0.00005초다.** 50마이크로초. DNS 조회조차 이보다 오래 걸린다. + 요청이 나가기 전에 로컬에서 실패했다. + +4. **76건이 1초 안에 몰렸다.** 실패가 즉시 반환되니 while 루프가 폭주한 것이다. + 시간축에 퍼진 장애가 아니라 순간의 스파이크다. + +5. **재현되지 않는다.** + 같은 URL 100회 연속 → 실패 0/100 + DNS 해석 200회 → 실패 0/200 + URL 유효성 → 코드=200 크기=845361, 콘솔이 참조하는 경로와 동일 + +6. **nginx 는 그 시각에 아무 일도 하지 않았다.** 마스터·워커가 22.4시간째 + 같은 프로세스다(07 참조). reload 도 재시작도 없었다. + +결론 +---- +**로컬(dev 머신) 쪽 일시적 원인이며, 서버 가용성 사건이 아니다.** +정확한 원인은 특정하지 못했다 — curl 을 `-s` 로 돌려 오류 메시지를 버렸고, +종료 코드도 기록하지 않았기 때문이다. + +고친 것 +------- +감시를 종료 코드까지 남기는 형태로 교체했다. 다음에 같은 일이 생기면 +curl 의 종료 코드(6=DNS, 7=connect, 35=TLS ...)로 원인이 바로 나온다. +실패 시 1초 쉬게 해 루프 폭주도 막았다. + + R=$(curl -s --limit-rate 20k -o /dev/null -w "..." "$URL" 2>/dev/null); E=$? + echo "$T $R curl종료=$E" + [ $E -ne 0 ] && sleep 1 + +남기는 교훈 +----------- +측정 장치가 실패했을 때 **왜 실패했는지 남기지 않으면, 그 실패를 대상 탓으로 +돌릴지 장치 탓으로 돌릴지 판단할 근거가 없다.** 여기서는 대조 폴링이 같은 +시각에 멀쩡했다는 사실 하나로 겨우 갈랐다. 대조군이 없었다면 이 76건은 +"갱신 중 대규모 요청 실패"라는 그럴듯한 오보가 됐을 것이다.