--- kind: CASE slug: the-certificate-that-took-38-minutes-to-reach-the-wire title: 새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다 topic: operations-that-report-success topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신 project: keycloak-session-store status: 게시 전 lastVerifiedOn: 2026-09-04 sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af source: - final/document.md#선택이-코드와-흐름에-반영되는-방식-d4 assets: - key: renewal-to-serving-gap file: ../../../final/assets/renewal-to-serving-gap/renewal-to-serving-gap.svg evidence: - ../../../final/evidence/raw/d4-certificate-renewal__07-renewal-hook-missing.txt - ../../../final/evidence/raw/d4-certificate-renewal__09-serial-timeline.txt - ../../../final/evidence/raw/d4-certificate-renewal__12-certbot-state.txt - ../../../final/evidence/raw/d4-certificate-renewal__13-verdict.txt --- # 새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다 새 인증서가 디스크에 기록된 08:20:27 부터 밖에서 일련번호가 바뀐 08:58:52 까지 2305초, 38분 25초 동안 옛 인증서가 나갔다. 갱신을 nginx 에 알리는 경로 셋이 모두 비어 있었기 때문이고, 38분에 멈춘 것도 사람이 reload 를 쳤기 때문이다. 그동안 certbot 타이머는 매번 SUCCESS 로 끝났다. ## 관계 - **deploy 훅 하나가 그 공백을 1~2초로 줄였다** 이 실험은 처방을 적어 놓고 검증하지 않았는데, 그 처방을 실제로 넣고 다시 잰 것이 그 실험이다. - **reload 를 사람이 아니라 deploy 훅이 부르게 한다** reload 를 부르는 경로가 셋 다 비어 있다는 이 관측이 그 결정의 근거다. - **갱신 타이머가 실제 갱신에서도 도는가** 여기서는 강제 갱신으로만 확인했고, 만료 30일 전에 타이머가 스스로 갱신하는 경로는 아직 열려 있다. ## 문제 호스트 nginx 가 Let's Encrypt 인증서로 TLS(전송 계층 보안, 연결을 암호화하는 규격)를 끝내고 traefik 으로 넘긴다. certbot 타이머는 하루 두 번 돌고 종료 상태는 매번 SUCCESS 다. 원래 답하려던 물음은 nginx reload 중 진행 중이던 요청이 어떻게 되는가였다. 그것을 재려고 강제 갱신을 걸었는데 밖에서 본 인증서의 일련번호가 바뀌지 않았다. 갱신이 실패한 것인지, 갱신은 됐는데 nginx 가 새 인증서를 읽지 않은 것인지는 이 시점에 갈라져 있지 않았다. ## 결론 갱신은 성공했고 nginx 가 새 인증서를 읽지 않았다. 새 인증서 디스크 기록 : 08:20:27 밖에서 본 일련번호가 바뀐 시각 : 08:58:52 그 사이 공백 : 2305초 = 38분 25초 그 구간에서 옛 인증서로 관측한 횟수 : 428회 certbot-renew.service 의 ExecStartPost : x renewal-hooks 의 deploy, post, pre : x — 셋 다 비었음 certbot 의 nginx 플러그인 : x 08:58:52 에 reload 를 부른 것 : 자동화가 아니라 사람 그 reload 가 무중단이었는가 : o — 새 연결 8856건 전부 200, 전송 중이던 요청도 전량 수신 reload 를 부르는 경로 셋이 다 비어 있었다. 하나라도 있었으면 갱신과 동시에 반영됐다. 사람이 치지 않았다면 다음 nginx 재시작까지 옛 인증서가 나갔을 것이다. 이 결함은 88일 동안 드러나지 않는다. 타이머는 정상이고 매번 SUCCESS 로 끝나며, 만료 30일 전까지는 certbot 이 갱신 자체를 하지 않아 발현할 기회가 없다. ## 검증 환경 호스트 : test-server, Arch Linux, 12GB, WiFi only TLS 종단 : 호스트 nginx, Let's Encrypt 인증서, traefik 으로 프록시 인증서 : auth, app1, app2 세 이름이 한 인증서의 SAN(Subject Alternative Name, 한 인증서가 담는 이름 목록)에 있고 와일드카드가 아니다 갱신 도구 : certbot, nginx 플러그인 없음 호스트 sudo : 비밀번호를 요구한다. 갱신과 reload 는 사람이 직접 친다 시계 : test-server 는 NTP 가 꺼져 있어 106초 빨랐다. 아래 시각과 공백은 보정한 값이다 일련번호 폴링 : 5초 간격, 564표본 측정일 : 2026-09-04 ## 재현 조건 1. 갱신 전 인증서의 일련번호와 notAfter 를 밖에서 읽어 둔다. 2. 주입 전에 새 연결을 0.2초 간격으로 900회 보내 평시 오류율을 잰다. 3. 845KB 짜리 응답을 20k/s 로 느리게 받아 42초 동안 살아 있는 요청 하나를 만든다. 4. certbot 으로 강제 갱신을 건다. 5. 5초 간격으로 일련번호를 폴링하면서, archive 디렉터리에 새 인증서 파일이 써진 시각과 견준다. 6. certbot-renew.service 의 유닛 파일, renewal-hooks 의 세 디렉터리, certbot 이 찾은 플러그인 목록을 각각 확인한다. 7. nginx 의 마스터와 워커 PID 를 읽어 워커가 언제 뜬 것인지 본다. 8. 두 시계에서 온 값을 빼기 전에 왜곡을 재서 보정한다. 9. 사람이 직접 reload 를 친다. 자동화가 없으므로 여기서 멈춘 것을 푸는 것도 사람이다. 10. reload 전후로 나눠 새 연결의 응답 시간 분포와 비200 건수를 세고, 전송 중이던 요청이 받은 바이트와 연결 수를 본다. ## 본문 ## 대조군을 먼저 잡았다 계획서의 물음은 「nginx reload 중 진행 중이던 요청은 어떻게 되는가」였다. 갱신 중에 비200 이 한 번 나왔다고 해도 평시 오류율을 모르면 그것이 갱신 탓인지 알 수 없으므로, 주입 전에 두 가지를 먼저 쟀다. | 무엇을 쟀나 | 결과 | |---|---| | 새 연결 (0.2초 × 900회 / 180초) | 900 전부 200, 오류 0 · 중앙 98ms · p95 195ms | | 진행 중 요청 (845KB @ 20k/s) | 200 · 845361바이트 · 연결수 1 · 42.3초 완주 | 두 번째를 따로 잰 까닭은 첫 폴링이 「새 연결을 받아주는가」만 재기 때문이다. 0.2초 폴링은 TLS 핸드셰이크가 900/900 이라 매 요청이 새 연결이고, 계획서가 물은 「진행 중이던 요청」은 reload 순간에 실제로 전송 중인 요청이 있어야 재진다. 그래서 845KB 짜리 번들을 일부러 느리게 받아 요청 하나를 42초 동안 살려 두었다. 강제 갱신은 되돌릴 수 없고 Let's Encrypt 의 주당 중복 인증서 5장 한도를 한 장 깎는다. 그래서 이 실험 전체에서 강제 갱신을 한 번만 쓰기로 정했고, 대조군 둘이 그 한 번보다 앞에 왔다. 이 호스트는 sudo 가 비밀번호를 요구해서 강제 갱신도 reload 도 사람이 직접 쳐야 했다. 비대화식 sudo 는 반드시 실패해서, 강제 갱신은 처음에 미측정으로 남아 있었다. 명령 한 줄을 헛되이 쓰지 않는 것이 이 실험 설계의 일부였다. ## 강제 갱신을 걸었는데 일련번호가 바뀌지 않았다 갱신을 걸고 5초 간격으로 일련번호를 읽었더니 564표본 내내 옛 값이 나왔다. 디스크에는 새 인증서가 있었다. ```text label="디스크와 네트워크가 서로 다른 인증서를 말한다" 디스크 cert2.pem 2026-09-04 17:22:13 KST 기록됨 네트워크 일련번호 564표본 내내 옛 것. 08:58:52 에야 바뀜 ``` 그래서 「갱신이 실패했다」가 아니라 「갱신은 됐는데 nginx 가 집지 않았다」로 갈렸다. | 무엇이 언제였나 | 시각 (실제 UTC) | |---|---| | 새 인증서 디스크 기록 | `08:20:27` | | 실제 서빙 시작 | `08:58:52` | | 공백 | 2305초 = 38분 25초, 그 사이 428회 관측 | 이 2305초는 시계를 보정한 값이고, 처음 적은 값은 2199초 곧 36분 39초였다. 디스크 기록 시각은 archive 디렉터리의 mtime 이라 test-server 시계이고 일련번호를 관측한 쪽은 dev 머신 시계인데, 그 둘을 그대로 뺐기 때문이다. test-server 는 NTP 가 꺼져 있어 106초 빨랐고 dev 머신은 Google 및 Let's Encrypt ACME 응답과 0초 차였다. 이 차이는 훅을 넣고 1~2초를 재려던 다음 실험에서 드러났고, 거기서 2199초를 2305초로 고치면서 관련 문서를 전부 정정했다. 38분에서 멈춘 것도 이 결함의 성질이 아니라 우연이다. `08:58:52` 에 reload 를 시킨 것은 자동화가 아니라 사람이었고, 아무도 치지 않았다면 다음 nginx 재시작까지 옛 인증서가 계속 나갔다. ## reload 를 부를 수 있는 경로가 셋인데 셋 다 비어 있었다 certbot 이 갱신에 성공한 뒤 nginx 에 그것을 알리는 방법은 이 호스트에서 셋이었다. | 어디서 reload 를 부를 수 있나 | 거기에 무엇이 있었나 | |---|---| | certbot-renew.service 의 `ExecStartPost` | 없음. 배포판이 넣어 준 유닛에 `ExecStart` 하나뿐이다 | | `/etc/letsencrypt/renewal-hooks/` 의 `deploy` · `post` · `pre` | 셋 다 비었음 | | certbot 의 nginx 플러그인 | 없음 — `dns-cloudflare, manual, null, standalone, webroot` | nginx 가 읽는 인증서는 `fullchain.pem` 이고, 그 파일을 기동할 때 한 번 읽어 메모리에 들고 있다. certbot 은 설정이 가리키는 경로를 고치는 대신 `live/` 심볼릭 링크가 새 파일을 가리키게 갈아끼우므로, 경로는 그대로이고 가리키는 대상만 바뀐다. nginx 설정에는 손댈 것이 없고 바로 그 때문에 설정만 읽으면 멀쩡해 보인다. 고칠 것은 설정이 아니라 reload 를 부르는 경로다. ![certbot 이 archive 에 새 인증서를 쓰고 live 링크를 옮기지만, nginx 워커가 교체되지 않아 옛 인증서를 계속 서빙하는 구성.](../../../final/assets/renewal-to-serving-gap/renewal-to-serving-gap.svg) 그림에서 `nginx 워커` 로 들어오는 화살표는 둘이다. `live/fullchain.pem` 에서 오는 쪽에는 「reload 필요」가 붙어 있고, 그 reload 를 부르는 신호는 `renewal-hooks/deploy` 에서 온다. 이 실험대에서는 그 디렉터리가 비어 있어 신호를 보낼 것이 없었고, 그래서 새 인증서가 기록된 뒤에도 워커는 옛 인증서를 들고 있었다. ## reload 가 있었는지는 워커 PID 로 가른다 nginx 의 reload 는 마스터를 유지한 채 워커만 새로 띄운다. 그래서 마스터 PID(Process ID, 프로세스 번호)가 그대로이고 워커 PID 만 바뀌었으면 reload 가 된 것이고, 둘 다 그대로이면 없었던 것이다. ```text label="갱신 직후 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 ``` 워커 586 은 마스터 585 가 기동한 직후의 첫 fork 이고 기동 시각도 경과 시간도 마스터와 같다. 22.4시간 동안 워커가 한 번도 교체되지 않았으므로 reload 도 한 번도 없었다. 로그에 무엇이 적혔는지를 보지 않고 지금 떠 있는 프로세스만으로 갈린다. ## 88일 동안 드러나지 않는다 certbot 타이머는 정상이고 실행은 매번 SUCCESS 로 끝난다. certbot 은 만료 30일 전이 되어야 갱신을 시도하므로, 그때까지는 갱신 자체가 없어서 「갱신해도 반영되지 않는다」는 결함이 나타날 기회가 없다. 이 호스트에서는 그 구간이 88일이다. 발현하는 날의 증상은 인증서 만료이고, 그날에도 타이머 로그에는 SUCCESS 라고 적혀 있다. 강제 갱신을 걸어 일련번호를 밖에서 폴링하지 않았다면 이 실험대에서도 그날까지 보이지 않았다. ## reload 자체는 무중단이었다 원래 물음이었던 「reload 중 진행 중이던 요청은 어떻게 되는가」는 답이 나왔다. 다만 잰 것은 자동화가 부른 reload 가 아니라 `08:58:52` 에 사람이 친 reload 다. ```text label="사람이 친 reload 전후" 새 연결 8856건 전부 200 · p95 205.7 → 204.3ms 진행 중 요청 전송 12초째에 reload · 845361바이트 전량 · 연결수 1 ``` 845KB 짜리 응답을 20k/s 로 느리게 받아 42초 동안 살려 둔 요청이 있었고, 그 전송 한가운데에서 reload 가 걸렸다. 받은 바이트가 전량이고 연결 수가 1이므로 중간에 끊겨 다시 연결한 것이 아니다. 옛 워커가 그 요청을 끝까지 책임졌다. 새 연결 쪽도 같다. p95 가 205.7 밀리초에서 204.3 밀리초로 사실상 그대로이고 비200 은 한 건도 없었다. ## 확인하지 않은 것 실제 만료가 임박한 상태를 만들지 않았다. 이 결함이 만료로 드러나는 경로는 재지 않았다. 무중단을 확인한 reload 는 사람이 건 것이다. certbot 의 deploy 훅이 부르는 reload 에서도 같은지는 여기서 재지 않았고, 훅을 넣고 다시 돌린 실험이 그것을 이어받았다.