기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만 있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문 종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다. SSOT 결함 둘을 고쳤다. - kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이 「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽 `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다. - virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데 원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다. 기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은 둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더 든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다). 계약을 셋 고쳤다. - kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/** 28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는 것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온 것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 커밋도 28개가 어긋났다). - virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고 재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다. - kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고 넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다. style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져 (실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고, engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고 있었다(Question 기록에서 11.94 → 3.86). verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 KiB
kind, slug, title, topic, topicName, project, status, lastVerifiedOn, sourceRevision, source, assets, evidence
| kind | slug | title | topic | topicName | project | status | lastVerifiedOn | sourceRevision | source | assets | evidence | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | a-deploy-hook-closed-the-gap-to-two-seconds | deploy 훅 하나가 그 공백을 1~2초로 줄였다 | operations-that-report-success | 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신 | keycloak-session-store | 게시 전 | 2026-09-04 | cdac9b8178391311d8eca1ebc6cac15bb62d79af |
|
|
|
deploy 훅 하나가 그 공백을 1~2초로 줄였다
훅을 넣자 갱신에서 서빙까지가 12초로 줄었고, reload 를 부른 것도 사람이 아니라 certbot 의 deploy 훅이었다. 훅이 없던 같은 구간은 38분 25초였다. 그 12초를 재려면 시계 왜곡 106초를 보정해야 했고, certbot 은 훅이 성공했을 때도 로그에 error 라는 낱말을 찍는다.
관계
- 새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다 그 실험이 공백을 재고 처방을 적었고, 이 실험이 그 처방을 넣어 전후를 같은 방법으로 견줬다.
- reload 를 사람이 아니라 deploy 훅이 부르게 한다 훅을 넣기 전후의 이 두 값이 그 결정이 근거로 삼은 측정이다.
- 적용됐는지는 로그 문구가 아니라 상태로 판정한다 certbot 이 성공한 훅에도 error 를 찍는 것을 여기서 보고 그 기준을 세웠다.
문제
앞선 실험은 갱신과 서빙 사이의 공백 2305초를 재고, reload 를 부르는 경로 셋이 모두 비어 있다는 원인까지 확정했다. 고치는 방법도 적었다. certbot 의 renewal-hooks 가운데 deploy 디렉터리에 reload 스크립트 하나를 넣는다는 것이었다.
그 처방은 넣어 보지 않은 상태였다. 훅을 넣으면 공백이 실제로 사라지는지, 그 reload 가 진행 중이던 요청에 무엇을 하는지는 재지 않은 채였다. 처방이 듣는지 모르는 채 「이렇게 고치면 된다」고 적는 것은 이 실험대가 경계해 온 실수라, 훅을 넣는 일을 실험 하나로 따로 떼어 냈다.
결론
훅을 넣자 갱신에서 서빙까지가 1~2초가 됐고, reload 를 부른 것은 certbot 의 deploy 훅이었다.
갱신에서 서빙까지 : 훅 없음 2305초 = 38분 25초, 훅 있음 1~2초 reload 를 부른 것 : 훅 없음 사람, 훅 있음 certbot deploy 훅 사람이 건 reload 중 새 연결 8856건 : 전부 200 p95 : reload 직전 205.7ms, 직후 204.3ms 전송 12초째에 reload 를 맞은 42초짜리 요청 : 845361바이트를 온전히 받았다. 연결수 1 훅이 거는 reload 가 무중단인가 : 재지 않았다
certbot 은 훅이 성공했을 때도 Hook deploy-hook ran with error output 이라고 찍는다. 내용은 nginx 의 types_hash 경고가 stderr 로 나간 것이고, 같은 출력 안에 test is successful 과 signal process started 가 들어 있다. 로그에서 error 를 grep 하는 감시를 걸면 성공한 훅을 실패로 읽는다.
12초를 재려면 시계 보정이 먼저다. test-server 는 NTP 가 꺼져 있어 106초 빨랐고, 보정하지 않고 그냥 빼면 훅이 발급보다 107초 뒤로 보인다 — 참값 12초보다 약 106초 어긋난 값이다. 보정을 반대쪽에 걸면 음수 지연이 나오는데, 훅은 갱신이 끝나야 돌기 때문에 그런 순서는 성립하지 않는다.
검증 환경
호스트 : test-server, Arch Linux, 12GB, WiFi only TLS 종단 : 호스트 nginx, Let's Encrypt 인증서, traefik 으로 프록시 넣은 훅 : certbot 의 renewal-hooks 가운데 deploy 디렉터리에 reload 스크립트 하나 갱신 방식 : 강제 갱신. 타이머가 스스로 도는 갱신은 아니다 시계 : test-server 가 106초 빠르다. dev 머신은 Google 및 Let's Encrypt ACME 응답과 0초 차 보정의 교차 기준 : 새 인증서에 박힌 SCT 두 개. CT 로그가 자기 시계로 서명한 값이다 측정일 : 2026-09-04
재현 조건
-
certbot 의 renewal-hooks 가운데 deploy 디렉터리에 nginx 설정을 검사하고 reload 하는 스크립트를 넣는다.
-
강제 갱신을 걸기 전에 nginx 의 마스터와 워커 PID 를 읽어 둔다.
-
새 연결을 0.2초 간격으로 보내는 폴링과, 845KB 짜리 응답을 20k/s 로 느리게 받는 요청 하나를 함께 띄운다.
-
그 요청이 전송 중일 때 강제 갱신을 건다.
-
갱신이 끝나면 마스터와 워커 PID 를 다시 읽어 마스터가 유지되고 워커만 바뀌었는지 본다.
-
certbot 출력에서 훅 실행 줄을 찾아 error 라는 낱말이 실패를 뜻하는지 내용을 열어 확인한다.
-
새 인증서의 notBefore 를 발급 시각으로 쓰지 않는다. 1시간 백데이트를 되돌리고, 두 시계의 왜곡을 재서 보정한 뒤 SCT 와 견준다.
-
느리게 받던 요청이 몇 바이트를 받고 끝났는지, 연결을 몇 번 맺었는지 확인한다.
본문
훅을 어느 디렉터리에 넣나
certbot 은 갱신 과정의 세 시점에 사용자가 넣어 둔 스크립트를 실행해 준다. 세 디렉터리의 차이는 실행 조건이다.
| 어느 디렉터리에 넣나 | 언제 도나 |
|---|---|
deploy/ |
실제로 갱신된 인증서가 있을 때만. RENEWED_LINEAGE 가 있을 때 돈다 |
post/ |
갱신 여부와 무관하게 매번 |
이 호스트의 타이머는 하루 두 번 돈다. post/ 에 reload 를 넣으면 갱신이 없는 날에도 하루 두 번 워커가 교체되고, 워커가 바뀔 때마다 keep-alive 연결이 끊긴다. deploy/ 는 갱신이 실제로 일어난 날에만 돌므로 reload 를 걸 곳은 그쪽이다. 넣은 것은 nginx 설정을 먼저 검사하고 통과하면 reload 신호를 보내는 스크립트 하나다.
검사를 앞에 둔 것은 설정이 깨진 상태로 reload 신호를 보내면 마스터가 새 워커를 못 띄우기 때문이다. 검사에서 걸리면 reload 가 아예 가지 않고 옛 워커가 그대로 서비스를 계속한다 — 인증서는 안 바뀌지만 사이트가 내려가지는 않는다. reload 대신 restart 를 쓰지 않은 까닭도 같다. 이 호스트의 nginx.service 는 Restart=on-failure 에 RestartUSec=100ms · StartLimitBurst=5 · StartLimitIntervalUSec=10s 라, 설정이 깨진 채 restart 를 걸면 10초 안에 5번 실패하고 systemd 가 포기한다. 그러면 nginx 가 내려간 채로 멈춘다.
훅을 넣고 같은 방법으로 다시 쟀다
앞선 실험과 같이 강제 갱신을 걸고 밖에서 일련번호를 폴링했다.
| 갱신에서 서빙까지 무엇이 달라졌나 | 훅 없음 | 훅 있음 |
|---|---|---|
| 걸린 시간 | 2305초 = 38분 25초 | 1~2초 |
| reload 를 부른 것 | 사람 | certbot deploy 훅 |
그림은 certbot 갱신 성공 에서 새 인증서 서빙 까지를 한 줄로 잇는다. 갱신이 끝나면 deploy 훅 이 돌고, 그 훅이 nginx 워커 교체 로 reload 신호를 보내며, 워커가 새로 뜬 뒤에야 새 인증서가 나간다. 훅이 없던 동안에는 두 번째 단계가 비어 있어 사슬이 이어지지 않았고, 같은 구간이 훅 없음 2305초와 훅 있음 1~2초로 갈린다.
1~2초를 재려니 시계가 먼저 걸렸다
훅 실행 시각과 인증서 발급 시각을 그냥 빼면 107초가 나온다. 참값은 1~2초라 약 106초가 어긋난 값이고, 보정을 반대쪽에 걸면 음수가 되는데 훅은 갱신이 끝나야 돌기 때문에 그런 순서는 성립하지 않는다.
원인은 둘이었다. 하나는 Let's Encrypt 가 notBefore 에 발급 시각보다 정확히 1시간 앞선 값을 넣기 때문이다. 클라이언트 시계가 조금 느려도 아직 유효하지 않은 인증서로 거부되지 않게 하려는 여유다. 그래서 notBefore 를 발급 시각으로 읽으면 1시간이 어긋난다. 1시간을 되돌리고 나서야 남은 106초가 드러났고, 그것이 다른 하나였다. test-server 는 시계를 서버에 맞춰 주는 NTP(Network Time Protocol)가 꺼져 있어 106초 빨랐고, dev 머신은 Google 및 Let's Encrypt ACME 응답과 0초 차였다. 두 시계에서 온 값을 그냥 뺀 탓이었다.
보정이 맞는지는 제3의 시계로 확인했다. 새 인증서에는 SCT(Signed Certificate Timestamp, 공개 로그가 인증서 발급을 받아 적고 서명해 돌려준 시각)가 두 개 박혀 있고 그 타임스탬프는 CT 로그가 자기 시계로 서명한 값이라 dev 머신도 test-server 도 아니다. SCT 의 Sep 4 12:27:49.054 GMT 가 보정한 훅 시각의 정확히 1초 앞에 놓였다.
같은 왜곡이 앞선 실험의 공백에도 걸려 있었고, 보정하기 전에는 2199초로 106초 짧게 적혀 있었다.
certbot 이 찍은 error 는 실패가 아니었다
certbot 출력에는 Hook 'deploy-hook' ran with error output 이 찍혔다. 훅이 실패한 것으로 읽히는 문구인데 인증서는 정상으로 갱신됐고 nginx 도 reload 됐다.
내용을 열어 보면 nginx 가 types_hash 를 최적 크기로 만들지 못했다는 경고를 stderr 로 내보낸 것뿐이다. 같은 출력 안에 설정 검사가 통과했다는 test is successful 과 reload 신호가 전달됐다는 signal process started 가 함께 있다. 로그에서 error 를 grep 하는 감시를 걸면 성공한 이 훅이 실패로 집계된다.
그래서 훅이 실제로 일을 했는지는 로그 문구가 아니라 nginx 프로세스로 확인했다. reload 는 마스터를 유지한 채 워커만 새로 띄우므로 마스터 PID 가 그대로이고 워커 PID 만 바뀌면 reload 가 된 것이다. 같은 호스트에서 systemctl status 를 읽으면 cgroup 블록에 그대로 나온다.
CGroup: /system.slice/nginx.service
├─ 585 "nginx: master process /usr/bin/nginx"
└─37252 "nginx: worker process"
사람이 건 reload 는 진행 중이던 요청을 끊지 않았다
여기 실린 수치는 앞선 실험에서 사람이 손으로 건 reload 를 잰 것이다. 그때는 훅이 없었고 그것이 앞선 실험의 진단이었다. 새 연결 8856건이 전부 200 이었고, 응답 시간도 reload 직전 p95 205.7ms 에서 직후 204.3ms 로 움직이지 않았다.
진행 중이던 요청 쪽이 더 분명하다. 845KB 짜리 응답을 20k/s 로 느리게 받던 요청 하나가 전송 12초째에 reload 를 맞았는데, 845361바이트를 온전히 받고 끝났고 연결은 한 번뿐이었다. reload 신호를 받은 마스터는 새 워커를 띄우고 옛 워커에게는 들고 있던 요청을 끝내고 물러나라고 하므로, 이 요청은 처음부터 끝까지 옛 워커가 책임졌다.
확인하지 않은 것
훅이 거는 reload 가 무중단인지는 재지 않았다. 위의 8856건과 845361바이트는 사람이 건 reload 를 잰 값이다. 이 실험이 잰 것은 갱신에서 서빙까지의 공백이 1~2초로 줄었다는 것이고, 워커가 갈린 것은 PID 로 확인했다 — 사람이 걸었을 때 28829, 훅이 걸었을 때 37252 다.
실제 갱신 주기에서 훅이 도는 것은 확인하지 않았다. 강제 갱신으로만 검증했다.
nginx 가 종료될 때 진행 중이던 요청이 어떻게 되는지도 재지 않았다. systemd 유닛은 nginx 에 KillSignal=SIGQUIT 과 KillMode=mixed 를 쓰고 SIGQUIT 은 nginx 에서 진행 중 요청을 끝내고 종료하라는 뜻이라 reload 와 같은 성질이 걸려 있는데, 종료 쪽은 이번에 걸어 보지 않았다.