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:
co-authored by
Claude Opus 5
parent
d473609e0a
commit
2109f726fe
+150
@@ -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 신호를 보내며, 워커가 새로 뜬 뒤에야 새 인증서가 나간다. 훅이 없던 동안에는 두 번째 단계가 비어 있어 사슬이 이어지지 않았고, 같은 구간이 훅 없음 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 -->
|
||||
+182
@@ -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 를 부르는 경로다.
|
||||
|
||||

|
||||
|
||||
그림에서 `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 -->
|
||||
+134
@@ -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 검증` 으로만 이어지고, 거기서 두 갈래가 나간다. 한쪽은 `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 -->
|
||||
Reference in New Issue
Block a user