feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다

기록 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>
This commit is contained in:
DongHyeonka
2026-09-17 11:01:55 +09:00
co-authored by Claude Opus 5
parent d473609e0a
commit 2109f726fe
574 changed files with 159654 additions and 1551 deletions
@@ -0,0 +1,150 @@
---
kind: CASE
slug: a-deploy-hook-closed-the-gap-to-two-seconds
title: deploy 훅 하나가 그 공백을 1~2초로 줄였다
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
lastVerifiedOn: 2026-09-04
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a
assets:
- key: d4a-hook-effect
file: ../../../final/assets/d4a-hook-effect/d4a-hook-effect.svg
evidence:
- ../../../final/evidence/raw/d4a-deploy-hook__01-hook-verified.txt
- ../../../final/evidence/raw/d4a-deploy-hook__02-certbot-with-hook.txt
- ../../../final/evidence/raw/d4a-deploy-hook__03-after-state.txt
---
# deploy 훅 하나가 그 공백을 1~2초로 줄였다
훅을 넣자 갱신에서 서빙까지가 1~2초로 줄었고, reload 를 부른 것도 사람이 아니라 certbot 의 deploy 훅이었다. 훅이 없던 같은 구간은 38분 25초였다. 그 1~2초를 재려면 시계 왜곡 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 하는 감시를 걸면 성공한 훅을 실패로 읽는다.
1~2초를 재려면 시계 보정이 먼저다. test-server 는 NTP 가 꺼져 있어 106초 빨랐고, 보정하지 않고 그냥 빼면 훅이 발급보다 107초 뒤로 보인다 — 참값 1~2초보다 약 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
## 재현 조건
1. certbot 의 renewal-hooks 가운데 deploy 디렉터리에 nginx 설정을 검사하고 reload 하는 스크립트를 넣는다.
2. 강제 갱신을 걸기 전에 nginx 의 마스터와 워커 PID 를 읽어 둔다.
3. 새 연결을 0.2초 간격으로 보내는 폴링과, 845KB 짜리 응답을 20k/s 로 느리게 받는 요청 하나를 함께 띄운다.
4. 그 요청이 전송 중일 때 강제 갱신을 건다.
5. 갱신이 끝나면 마스터와 워커 PID 를 다시 읽어 마스터가 유지되고 워커만 바뀌었는지 본다.
6. certbot 출력에서 훅 실행 줄을 찾아 error 라는 낱말이 실패를 뜻하는지 내용을 열어 확인한다.
7. 새 인증서의 notBefore 를 발급 시각으로 쓰지 않는다. 1시간 백데이트를 되돌리고, 두 시계의 왜곡을 재서 보정한 뒤 SCT 와 견준다.
8. 느리게 받던 요청이 몇 바이트를 받고 끝났는지, 연결을 몇 번 맺었는지 확인한다.
## 본문
<!-- body:start -->
## 훅을 어느 디렉터리에 넣나
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 하는 경로와, 그 훅이 없어 사람이 개입해야 하는 경로가 갈리는 구성.](../../../final/assets/d4a-hook-effect/d4a-hook-effect.svg)
그림은 `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 블록에 그대로 나온다.
```text label="마스터는 9월 3일 그대로이고 워커만 바뀌어 있다"
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 와 같은 성질이 걸려 있는데, 종료 쪽은 이번에 걸어 보지 않았다.
<!-- body:end -->
@@ -0,0 +1,182 @@
---
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 건수를 세고,
전송 중이던 요청이 받은 바이트와 연결 수를 본다.
## 본문
<!-- body:start -->
## 대조군을 먼저 잡았다
계획서의 물음은 「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 에서도 같은지는 여기서 재지 않았고, 훅을 넣고 다시 돌린 실험이 그것을 이어받았다.
<!-- body:end -->
@@ -0,0 +1,134 @@
---
kind: CASE
slug: the-upgrade-that-would-not-roll-back
title: 되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
lastVerifiedOn: 2026-09-04
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-d1-d2
assets:
- key: d2-upgrade-direction
file: ../../../final/assets/d2-upgrade-direction/d2-upgrade-direction.svg
evidence:
- ../../../final/evidence/raw/d2-version-upgrade__02-rollback-attempt.txt
- ../../../final/evidence/raw/d2-version-upgrade__03-roll-forward.txt
---
# 되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다
되돌리기가 옛 버전 파드의 기동 단계에서 막혔다. 새 버전이 스키마에 남긴 체크섬을 Liquibase 가 거부했기 때문이다. 그동안 외부 서비스는 살아 있었는데, 롤링 업데이트가 첫 파드에서 멈춰 남은 파드가 계속 응답했다. 되돌릴 수 있는지는 databasechangelog 의 행 수가 가른다.
## 관계
- **적용됐는지는 로그 문구가 아니라 상태로 판정한다**
되돌릴 수 있는지도 계획서에 적힌 문장이 아니라 databasechangelog 의 행 수로 갈랐다.
- **reload 를 사람이 아니라 deploy 훅이 부르게 한다**
운영 절차의 결과를 사람의 기억이 아니라 실행 가능한 검사에 맡긴다는 물음이 여기에도 같이 걸려 있다.
## 문제
버전을 올리는 절차에는 되돌리기가 따라붙는다. 이 실험대에는 그 롤백 계획이 적혀 있지 않았다.
되돌리기를 실제로 걸어 본 적도 없었다. 이미지 태그를 옛 버전으로 되돌리면 파드가 뜨는지, 되돌리는 동안 밖에서 보는 서비스가 어떻게 되는지는 재지 않은 상태였다.
## 결론
되돌리기는 기동 단계에서 막혔고 외부 서비스는 그동안 살아 있었다.
정방향 26.7.0 에서 26.7.3 : 무중단. 요청 87회 전부 200
되돌리기 : 옛 버전 파드가 기동 단계에서 막혔다
막은 것 : Liquibase 의 체크섬 검증
그때 외부 서비스 : 살아 있었다. StatefulSet 롤링 업데이트가 첫 파드에서 멈췄다
되돌릴 수 있는지 가르는 기준 : databasechangelog 의 행 수가 업그레이드 전후로 같은가
스키마 변경 없는 되돌리기 26.7.3 에서 26.7.0 : 성공. 전환 순간 000 이 1회
「롤백 불가」는 조건부다. 새 버전이 changeset 을 추가하지 않았으면 옛 버전으로 되돌아간다. 추가했으면 옛 버전이 그 행의 체크섬을 거부하고 기동에서 멈춘다.
롤백 계획을 적어 두지 않았더라도 이 사고는 전면화되지 않았다. 롤링 업데이트가 첫 파드에서 멈추면서 나머지 파드를 그대로 두었기 때문이다.
## 검증 환경
Keycloak : 26.7.0 에서 26.7.3 으로 올리고 다시 되돌렸다
워크로드 : StatefulSet 2파드
스키마 관리 : Liquibase. 적용한 changeset 을 databasechangelog 테이블에 기록한다
데이터베이스 : PostgreSQL
가용성 측정 : 업데이트가 도는 동안 밖에서 요청을 반복해 상태 코드를 셌다
측정일 : 2026-09-04. 되돌린 파드가 남긴 로그 줄의 날짜다
## 재현 조건
1. 업그레이드 전에 데이터베이스를 백업한다.
2. databasechangelog 의 행 수를 세어 둔다.
3. 이미지 태그를 새 패치 버전으로 바꿔 StatefulSet 롤링 업데이트를 건다.
4. 업데이트가 도는 동안 밖에서 요청을 반복해 200 과 비200 을 센다.
5. databasechangelog 의 행 수를 다시 세어 업그레이드 전과 같은지 본다.
6. 이미지 태그를 옛 버전으로 되돌리고, 첫 파드가 기동하는지와 그 파드의 로그에 무엇이 찍히는지 본다.
7. 되돌리는 동안에도 밖에서 요청을 반복해 남은 파드가 응답하는지 확인한다.
## 본문
<!-- body:start -->
## 올리는 방향은 아무것도 끊지 않았다
Keycloak 두 파드를 StatefulSet 으로 띄워 두고 이미지 태그를 26.7.0 에서 26.7.3 으로 바꿨다. 업데이트가 도는 동안 밖에서 요청을 계속 보냈고 87회가 전부 200 이었다.
이 방향에서는 파드가 새 이미지로 다시 뜨는 것 말고 걸리는 단계가 없었다. 되돌리기를 시도한 것은 이 상태에서다.
## 되돌리기는 기동 단계에서 막혔다
이미지 태그를 옛 버전으로 되돌리자 새로 뜬 파드가 기동하지 못했다. 로그에 찍힌 것은 애플리케이션 오류가 아니라 스키마 검증이었다.
```text label="옛 버전 파드가 기동하면서 남긴 것"
liquibase ValidationFailedException: 1 changesets check sum
```
Liquibase 는 스키마 변경을 changeset 단위로 적용하고 적용한 것을 `databasechangelog` 테이블에 한 행씩 기록하는데, 각 행에는 그 changeset 내용의 체크섬이 함께 들어간다. 기동할 때 자기가 들고 있는 changeset 파일의 체크섬과 테이블에 적힌 체크섬을 대조하고, 다르면 거기서 멈춘다. 새 버전이 남긴 행을 옛 버전이 자기 파일과 대조했더니 맞지 않았고, 그래서 데이터베이스에 손을 대기 전에 기동을 포기했다.
막은 것은 애플리케이션 코드도 이미지도 아니라 데이터베이스에 이미 적힌 한 행이다.
되돌리기를 걸고 20초 간격으로 여덟 번 파드 상태를 읽었는데 그중 다섯 번은 `Running` 이었고 `Error` 가 두 번, `CrashLoopBackOff` 가 한 번이었다. 여덟 번 모두 준비된 컨테이너는 0/1 이었다. 상태 칸만 보면 `Running` 인 때도 있었으므로, 기동하지 못했다는 것은 그 0/1 과 파드 로그를 보고 판단했다.
## 되돌리기가 막힌 동안에도 서비스는 살아 있었다
기동하지 못한 파드는 첫 번째 파드였다. StatefulSet 의 롤링 업데이트는 파드를 하나씩 교체하고 앞의 파드가 준비 상태가 되어야 다음으로 넘어가므로, 첫 파드가 기동하지 못한 시점에 업데이트가 거기서 멈추고 나머지 파드는 옛 이미지 그대로 남았다. 밖에서는 남은 파드가 계속 응답해 외부 진입점이 `HTTP 200` 이었고, Service 의 준비된 엔드포인트에는 주소가 하나 남아 있었다.
![앞으로 가는 경로는 무중단이고 뒤로 가는 경로는 Liquibase 검증에서 막히는 구성. 롤링 업데이트가 그 사고를 절반에서 멈춘다.](../../../final/assets/d2-upgrade-direction/d2-upgrade-direction.svg)
그림에서 `옛 버전 롤백` 은 `Liquibase 검증` 으로만 이어지고, 거기서 두 갈래가 나간다. 한쪽은 `databasechangelog 행 수` 이고 판단 기준이라는 이름이 붙어 있으며, 다른 쪽은 `StatefulSet 롤링 업데이트` 이고 중단 지점이라는 이름이 붙어 있다. 그 중단 지점에서 `외부 서비스` 로 가는 화살표에는 잔여 파드 응답이 붙는다. 되돌리기를 막은 단계와 사고를 절반에서 멈춘 단계가 같은 검증에서 갈라져 나온다.
롤백 계획을 적어 두지 않은 상태에서 되돌리기를 시도했는데도 전면 중단으로 가지 않았다. 그것은 계획이 좋아서가 아니라 StatefulSet 의 롤링 업데이트가 실패한 파드에서 교체를 멈췄기 때문이다.
## 롤백 불가는 조건부였다
이 실험을 처음 적을 때는 「롤백은 안 된다」고 단정했고, 후속 실험에서 정정했다. 실제로 막은 것은 버전 번호가 아니라 새 버전이 스키마에 행을 더했다는 사실이라, 새 버전이 changeset 을 하나도 더하지 않았으면 옛 버전은 대조에서 걸릴 것이 없다.
그래서 판단 기준이 한 줄로 정해진다.
```sql label="되돌릴 수 있는지 가르는 한 줄"
select count(*) from databasechangelog
```
업그레이드 전후로 이 수가 같으면 되돌아가고, 늘었으면 옛 버전이 기동에서 멈춘다. 이 기준으로 다시 걸어 본 26.7.3 에서 26.7.0 으로의 되돌리기는 성공했고, 전환 순간에 `000` 이 한 번 나왔다. 그 `000` 은 서버가 오류를 돌려준 것이 아니라 `--max-time 3` 을 넘긴 것이다. 끊긴 것과 느린 것은 다르고, 그 구별은 상태 코드가 아니라 타임아웃 값을 알고 있어야 선다.
이 수는 실제로 세었다. 되돌리기가 막힌 뒤 다시 앞으로 올려 놓고 확인했더니 마이그레이션 210 과 세션 4 가 업그레이드 전에 세어 둔 값 그대로였다.
업그레이드를 걸기 전에 이 수를 세어 두면 되돌릴 수 있는지를 사고가 나기 전에 안다. 계획서에 「롤백 가능」이라고 적어 두는 것과 이 쿼리를 전후로 돌려 보는 것은 다른 일이다.
다만 이 기준의 두 갈래를 같은 만큼 확인하지는 않았다. 「같으면 되돌아간다」는 26.7.3 에서 26.7.0 으로 실제로 걸어 본 결과이고, 「늘었으면 멈춘다」는 옛 버전에서 본 기동 실패를 근거로 한 추론이다. 26.7.x 사이에는 스키마 변경이 없어서 행이 늘어난 뒤 태그를 되돌리는 경우는 이 실험대에서 만들지 못했다.
## 확인하지 않은 것
스키마가 크게 바뀌는 메이저 업그레이드에서는 재지 않았다. 두 패치 버전 사이만 확인했다.
되돌리기가 막혔을 때 남은 파드가 얼마나 오래 버티는지도 재지 않았다. 관측한 것은 첫 파드가 기동하지 못하는 동안 밖에서 응답이 계속 왔다는 것까지이고, 그 상태를 길게 두었을 때 무엇이 먼저 깨지는지는 걸어 보지 않았다.
<!-- body:end -->