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 -->
@@ -0,0 +1,60 @@
---
kind: PROJECT_DECISION
slug: put-the-reload-in-a-deploy-hook
title: reload 를 사람이 아니라 deploy 훅이 부르게 한다
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
decisionStatus: ADOPTED
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
evidence:
- ../../../final/evidence/raw/d4-certificate-renewal__13-verdict.txt
- ../../../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
---
# reload 를 사람이 아니라 deploy 훅이 부르게 한다
인증서 갱신 뒤의 nginx reload 를 사람이 아니라 certbot deploy 훅이 부르게 정했다. 사람이 치던 절차로 두었을 때 갱신에서 서빙까지 2305초가 비었고, 훅을 넣은 뒤에는 1~2초였다.
## 근거
- **새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다**
사람이 치는 절차로 뒀을 때의 공백 2305초를 잰 기록이다. 이 결정이 고르지 않은 쪽을 실제로 재 본 값이 거기 있다.
- **deploy 훅 하나가 그 공백을 1~2초로 줄였다**
이 결정대로 훅을 설치하고 갱신에서 서빙까지를 다시 잰 기록이다.
- **적용됐는지는 로그 문구가 아니라 상태로 판정한다**
훅이 실제로 reload 를 걸었는지를 무엇으로 가릴지, 이 결정이 따르는 기준을 적은 기록이다.
- **갱신 타이머가 실제 갱신에서도 도는가**
이 결정을 강제 갱신에서만 확인했다는 것을 열린 질문으로 남긴 기록이다.
## 결정문
인증서를 갱신한 뒤의 nginx reload 는 /etc/letsencrypt/renewal-hooks/deploy/ 에 설치한 훅 파일이 부른다. 사람이 손으로 reload 를 치는 절차로 두지 않는다.
훅의 내용은 두 줄이고 nginx -t && nginx -s reload 다.
## 판단 이유
nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있는데, certbot 은 인증서 파일의 경로가 아니라 live/ 심볼릭 링크가 가리키는 대상을 갈아끼운다. 그래서 갱신이 끝나도 nginx 설정은 멀쩡해 보이고 옛 인증서가 계속 나간다. 고칠 것은 설정이 아니라 reload 를 부르는 경로인데, 이 실험대에서는 그 경로가 셋 다 비어 있었다. certbot-renew.service 에 ExecStartPost 가 없었고 certbot 에 nginx 플러그인도 설치돼 있지 않았다. renewal-hooks 아래 pre 와 deploy 와 post 세 디렉터리도 모두 비어 있었다.
사람이 치는 절차로 두는 대안은 반사실이 아니라 D-4 에서 실제로 관측했다. 새 인증서가 디스크에 기록되고 서빙이 바뀌기까지 2305초, 38분 25초가 비었고 그 reload 를 부른 것은 자동화가 아니라 사람이었다. 그 38분은 우연히 짧았을 뿐이고, 아무도 치지 않았다면 다음 nginx 재시작까지 옛 인증서가 나갔을 것이다.
훅을 놓을 디렉터리는 셋 중 하나였다. pre 는 갱신을 시도하기 전에 돌아서 새 인증서가 나오기 전이고, post 는 갱신 여부와 무관하게 매번 돈다. 타이머가 하루 두 번 도니 post 에 넣으면 갱신이 없는 날에도 nginx 를 하루 두 번 reload 하게 된다. deploy 는 certbot 이 RENEWED_LINEAGE 를 넘겨줄 때, 즉 실제로 갱신했을 때만 돈다.
두 줄 중 앞의 nginx -t 도 같은 종류의 안전장치다. 설정이 깨진 상태에서 nginx -s reload 를 보내면 마스터가 새 워커를 못 띄우는데, -t 로 먼저 검사해 통과할 때만 reload 하면 실패했을 때 옛 워커가 그대로 서비스를 계속한다. 인증서는 안 바뀌지만 서비스는 죽지 않는다. 이 순서 하나가 「인증서가 안 바뀐다」와 「사이트가 내려간다」를 가른다.
훅을 설치하고 강제 갱신을 다시 돌리자 갱신에서 서빙까지가 1~2초로 줄었다. 워커 PID(process ID, 프로세스 번호)는 사람이 걸었을 때의 28829 에서 37252 로 바뀌었다.
## 영향
- 갱신에서 서빙까지는 훅이 없던 D-4 에서 2305초, 훅을 넣은 D-4a 에서 1~2초였다. 두 값은 회차마다 한 번씩 잰 것이라 같은 조건에서 되풀이해 잰 값이 아니다.
- 이 훅은 인증서를 세우는 구축 절차에 들어간다. 원 가이드가 「이 훅은 구축 절차에 들어가야 한다. 사후에 붙이는 것이 아니다」라고 적었다. D-4 가 잰 2305초의 공백은 훅이 없어서 생긴 것이라, 그 훅은 인증서를 처음 세울 때 같이 놓였어야 했다.
- 되돌리기는 훅 파일을 지우는 한 줄이지만 지우지 않는다. 훅은 결함을 고치는 파일이라 지우면 D-4 의 상태로 돌아가고, 그 결함은 다음 실제 갱신(약 59일 뒤)에, 증상은 그 뒤 인증서 만료로 나타난다.
- certbot 은 훅이 성공해도 Hook 'deploy-hook' ran with error output 이라고 찍는다. 훅의 실제 출력은 test is successful 과 signal process started 이고, 그 문구가 붙은 까닭은 nginx 의 types_hash 경고가 stderr 로 나갔기 때문이다. certbot 은 stderr 에 무엇이든 있으면 이 문구를 붙이고, 그 경고 자체는 types_hash_max_size 기본값에서 오는 것이라 갱신과 무관하다. 로그에서 error 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.
- 확인은 --force-renewal 로 한 강제 갱신에서만 했다. certbot-renew.timer 가 스스로 갱신하는 경로에서도 같은 훅이 도는지는 아직 재지 않았다.
- 훅이 거는 reload 가 진행 중이던 요청을 끊지 않는지는 재지 않았다. 무중단을 확인한 측정은 D-4 에서 사람이 손으로 건 reload 를 잰 값이다.
@@ -0,0 +1,103 @@
---
kind: QUESTION
slug: does-the-renewal-timer-actually-renew
title: 갱신 타이머가 실제 갱신에서도 도는가
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
questionStatus: OPEN
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-재보지-않은-것
- final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a
evidence:
- ../../../final/evidence/raw/d4-certificate-renewal__07-renewal-hook-missing.txt
- ../../../final/evidence/raw/d4-certificate-renewal__12-certbot-state.txt
- ../../../final/evidence/raw/d4-certificate-renewal__13-verdict.txt
- ../../../final/evidence/raw/d4a-deploy-hook__01-hook-verified.txt
- ../../../final/evidence/raw/d4a-deploy-hook__03-after-state.txt
---
# 갱신 타이머가 실제 갱신에서도 도는가
deploy 훅은 강제 갱신으로만 검증했고, 타이머가 스스로 갱신하는 날도 그 훅이 도는지는 재지 않았다.
만료 30일 전에야 조건이 성립해 약 59일 뒤에나 시험할 수 있다. 그때까지 타이머는 매번 SUCCESS 를 찍는다.
## 관계
- **새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다**
이 질문이 나온 결함이다. reload 를 부르는 경로가 셋 다 비어 있었다.
- **deploy 훅 하나가 그 공백을 1~2초로 줄였다**
그 훅을 강제 갱신으로만 시험했고, 타이머 경로는 남겨 두었다.
## 사실
- certbot-renew.timer 는 하루 두 번 도는 일정으로 잡혀 있다.
RandomizedDelaySec : 12h
Persistent : true
- 2026-09-04 에 그 타이머가 두 번 돌았고 둘 다 status=0/SUCCESS 로 끝났다.
- 같은 날 서빙 인증서는 만료까지 88일 남아 있었다. 두 번의 SUCCESS 는 갱신을 하지 않은 채 끝난 것이다.
- certbot 의 인증서 상태 출력이 남은 일수를 VALID: 89 days 로 찍었다. 89일은 만료까지 남은
일수다. 다만 그 출력은 D-4 의 강제 갱신이 만든 인증서를 본 것이고, D-4a 가 다시 받은
인증서에 대해서는 같은 출력이 증거에 없다. 두 장은 만료 시각이 다르다.
certbot 은 30일 남았을 때 갱신하므로 갱신 조건은 약 59일 뒤에 성립한다.
- D-4 에서 새 인증서가 디스크에 기록된 시각과 실제 서빙이 바뀐 시각 사이가 2305초, 38분 25초 비었다.
그 사이 428회 관측했다.
- 그때 reload 를 건 것은 사람이었다. certbot-renew.service 에 ExecStartPost 가 없었고
renewal-hooks 의 deploy · post · pre 세 디렉터리가 다 비어 있었으며 certbot 의 nginx 플러그인도 없었다.
- D-4a 에서 deploy 훅 하나를 넣고 강제 갱신하자 갱신에서 서빙까지가 1~2초로 줄었다.
- 누가 reload 를 걸었는지는 워커 프로세스 번호(PID)로 갈린다. 사람이 걸었을 때 28829,
훅이 걸었을 때 37252 였고 마스터 585 는 그대로였다.
- 훅이 돌 때 certbot 은 Hook 'deploy-hook' ran with error output 을 찍었다. 실패는 아니었고
nginx 의 types_hash 경고가 stderr 로 나간 것이며 내용은 test is successful · signal process started 였다.
## 가정
- 타이머가 스스로 갱신하는 경로도 같은 certbot renew 를 부르고 같은 deploy/ 훅을 실행한다.
이 실험대의 기록이 그렇게 판단했고, 그 판단대로라면 남은 미지수는 타이머가 뜨는가 하나이며
그것은 D-4 에서 확인됐다. 훅 실행까지 같다는 부분은 실행으로 확인하지 않았다.
- 갱신일까지 아무도 이 호스트의 타이머 유닛과 훅 파일을 건드리지 않는다.
## 미지수
- 타이머가 실제 갱신을 수행하는 날에도 deploy/ 훅이 도는가.
이 실험대가 훅을 확인한 경로는 --force-renewal 하나뿐이다.
- --dry-run 에서 Running deploy-hook command 줄이 나오는가.
강제 갱신으로 바로 검증하는 바람에 dry-run 경로 자체를 거치지 않았다.
- 훅이 실제로 실패하면 certbot 이 무엇을 찍는가. 성공한 훅의 출력만 봤다.
- 훅이 /tmp 에 로그를 남기도록 만들면 타이머가 돌렸을 때 그 파일을 밖에서 찾을 수 있는가.
certbot-renew.service 는 PrivateTmp=true 이고, 이 실험은 훅에 로그를 넣지 않았다.
## 제약
- 만료 30일 전에야 갱신 조건이 성립하므로 실제 갱신은 약 59일 뒤다. 다만 기다리는 것
말고도 이 실험대가 적어 둔 길이 하나 있다 — certbot renew --dry-run 은 인증서를 발급하지
않고 발급 한도도 깎지 않으면서 훅이 호출되는지까지는 보여 준다.
- 강제 갱신으로는 이 질문에 답할 수 없다. 그 경로는 D-4a 에서 이미 검증했고, 지금 묻는 것은
타이머가 스스로 도는 경로다.
- deploy 훅 파일은 지우지 않고 남긴다. 지우면 D-4 의 상태로 돌아간다.
- 발급 한도가 있다. 강제 갱신을 쓸 때는 이번 주에 몇 장 발급했는지 센다.
## 선택지
갱신일이 와야 끝까지 시험할 수 있지만 그 전에 고를 수 있는 것이 하나 있다.
certbot renew --dry-run 을 먼저 돌려 훅이 호출되는지만 본다 : 발급도 한도 소모도 없다.
다만 실제 갱신에서 훅이 무엇을 받는지까지는 답하지 않는다. 이 실험대는 이 줄을 미검증으로
표시했고 돌리지 않았다.
약 59일을 기다렸다가 실제 갱신을 본다 : 이 질문에 온전히 답하는 유일한 경로다.
## 다음 검증
약 59일 뒤, 만료 30일 전 조건이 성립해 타이머가 갱신을 수행한 날에 두 가지를 읽는다.
1. nginx 워커의 lstart 를 읽어 갱신 시각 근처인지 본다. 마스터는 그대로이고 워커만 새것이어야 한다.
2. 서빙 인증서의 serial 과 notAfter 를 읽어 notAfter 가 밀렸는지 본다.
판정은 이 둘로 한다. certbot 이 찍는 문구로는 갈리지 않는다 — D-4a 에서 ran with error output 은
실패가 아니었다.
닫는 조건 : 워커 PID 가 바뀌고 서빙 일련번호가 새 인증서와 같으면 닫는다. 그렇지 않으면 훅이 강제
갱신에서만 도는 것이므로 타이머 유닛 쪽에 훅을 다시 건다.
@@ -0,0 +1,84 @@
---
kind: REFERENCE
slug: judge-a-reload-by-the-worker-pid-not-the-log
title: 적용됐는지는 로그 문구가 아니라 상태로 판정한다
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-d4
- final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
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__13-verdict.txt
- ../../../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
---
# 적용됐는지는 로그 문구가 아니라 상태로 판정한다
설정이나 인증서를 다시 읽었는지는 로그 문구가 아니라 워커 PID 로 판정한다. D-4 에서 certbot 타이머는 매번 SUCCESS 였는데 밖에서 본 인증서는 2305초 동안 옛 것이었고, D-4a 에서는 훅 로그가 error 를 찍었는데 상태는 바뀌어 있었다.
## 관계
- **deploy 훅 하나가 그 공백을 1~2초로 줄였다**
훅이 부른 reload 뒤에 워커 번호가 37252 로 바뀐 것을 확인한 실험이다. 로그가 error 를 찍는데도 상태는 바뀌어 있던 쪽이 여기 있다.
- **되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다**
같은 주제의 다른 운영 절차다. 업그레이드를 되돌릴 수 있는지도 로그가 아니라 databasechangelog 의 행 수가 가른다.
## 목적
운영 절차는 성공이라고 적혀 있는데 실제로는 아무것도 바뀌지 않은 상태를 그대로 지나치지 않게 한다. 반대쪽도 같이 막는다 — 로그에 error 가 있다고 실패로 처리하면 성공한 절차를 되돌리게 된다.
D-4 에서 인증서 갱신은 성공했다. certbot-renew.timer 는 그날 두 번 돌았고 서비스는 두 번 다 status=0/SUCCESS 로 끝났으며, 새 인증서도 디스크에 기록됐다. 그런데 밖에서 5초마다 본 일련번호는 564표본 내내 옛 것이었고, 디스크에 기록된 때로부터 2305초, 38분 25초가 지나서야 바뀌었다.
nginx 쪽에서도 틀린 것을 찾을 수 없었다. 설정 파일은 그대로 쓸 수 있는 상태였고 오류도 없었는데, 워커는 마스터 585 가 기동 직후에 만든 첫 fork 인 586 이었고 마스터와 워커의 etimes 가 둘 다 80529초, 22.4시간이었다. 그동안 reload 가 한 번도 일어나지 않았다.
## 규칙
### 1. reload 가 됐는지는 마스터 PID 와 워커 PID 를 함께 읽어 판정한다
마스터 PID(프로세스 번호)는 유지되고 워커 PID 만 바뀌면 reload 된 것이다. D-4 와 D-4a 를 지나는 동안 마스터는 585 그대로였고 워커만 586 에서 28829 로, 다시 37252 로 바뀌었다. ps 로 두 줄을 함께 읽으면 lstart 와 etimes 가 같은 줄에 나오므로 워커가 언제 만들어졌는지까지 한 번에 확인할 수 있다.
### 2. 성공 로그는 절차가 오류 없이 끝났다는 것까지만 말한다
status=0/SUCCESS 는 certbot 이 오류 없이 종료했다는 뜻이고, 서빙되는 인증서가 바뀌었는지는 말하지 않는다. D-4 에서 이 결함은 88일 동안 드러나지 않는다. 만료 30일 전까지는 갱신 자체를 하지 않아 발현할 기회가 없기 때문이고, 발현하는 날의 증상은 인증서 만료이며 그날에도 로그에는 SUCCESS 라고 적혀 있을 것이다.
### 3. error 가 찍혔다고 실패로 판정하지 않는다
D-4a 에서 certbot 은 Hook 'deploy-hook' ran with error output 이라고 찍었다. 실패가 아니라 nginx 의 types_hash 경고가 stderr 로 나간 것이고, 같은 출력의 내용은 test is successful 과 signal process started 다. 그 실행 뒤 워커는 37252 로 바뀌어 있었으니 reload 는 실제로 됐다. 로그에서 error 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다. 반대 방향은 재지 않았다. 훅이 진짜로 실패했을 때 certbot 이 무엇을 찍는지는 이 실험대가 보지 못했고, 여기 실린 문구는 성공한 훅에서 나왔다.
### 4. 상태는 절차 밖에서 확인한다
디스크에 새 파일이 쓰인 것과 그 파일이 서빙되는 것은 다른 시각에 일어났다. 그 두 시각 사이 428회 동안 밖에서 본 일련번호는 옛 것이었고, 그동안 certbot 의 출력도 nginx 의 설정 파일도 이상을 말하지 않았다. 확인할 값은 클라이언트가 실제로 받는 인증서의 일련번호다.
### 5. 다른 절차에서는 무엇이 상태인지 먼저 정한다
이 판정법을 확인한 데몬은 nginx 하나다. 다시 읽은 것이 어디에 남는지를 절차마다 먼저 찾고, 그 값을 절차 밖에서 확인한다.
## 적용 조건
- 설정이나 인증서를 다시 읽게 하는 절차를 확인할 때. reload · rotate · reconcile 이 여기 해당한다
- 타이머나 훅이 그 절차를 부르고 사람은 로그만 보는 구성
- 로그 문구로 감시 규칙을 만들 때. error 를 찾는 규칙과 SUCCESS 를 세는 규칙 둘 다
- 절차를 고친 뒤 효과를 잴 때. 훅을 넣은 D-4a 도 워커 번호로 갈렸다
## 예외
- 절차가 프로세스를 완전히 교체하면 PID 비교가 판정이 되지 않는다. 그때는 적재한 값 자체를 확인한다
- 워커 PID 로 판정하는 것은 nginx 의 마스터-워커 모델에서만 확인했다. 다른 데몬에서 같은 비교가 성립하는지는 이 실험대가 보지 않았다
- reload 가 무중단인지는 PID 로 알 수 없다. 사람이 건 reload 는 새 연결 8856건이 전부 200 이었지만, 훅이 거는 reload 를 같은 방식으로 재지는 않았다
## 예시
- 마스터 585 와 워커 586 의 lstart 가 같고 etimes 도 80529초로 같았다. 22.4시간 동안 reload 가 없었다
- 사람이 nginx -s reload 를 친 뒤 마스터는 585 그대로였고 워커는 28829 였다
- 훅이 부른 reload 뒤 워커는 37252 였다. 마스터는 585 에서 바뀌지 않았다
- cgroup.procs 로 읽어도 같은 두 번호 585 와 37252 가 나왔다. 같은 사실을 다른 도구로 다시 본 것이다
- 새 인증서가 디스크에 기록된 때와 실제 서빙이 시작된 때의 공백은 2305초 = 38분 25초였다
- certbot-renew.service 는 그날 두 번 돌았고 두 번 다 status=0/SUCCESS 였다
- 훅을 넣은 뒤 certbot 출력은 Hook 'deploy-hook' ran with error output 인데 내용은 test is successful 과 signal process started 였다
@@ -0,0 +1,745 @@
---
id: 7dc48b91-e31b-455c-9a9d-c766f95ff491
kind: SETUP
slug: reproduce-d1-backup-restore
title: 스키마를 통째로 지우고 덤프 하나로 되살아나는지 본다
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/7dc48b91-e31b-455c-9a9d-c766f95ff491/edit"
pinnedVersions:
- name: Keycloak
version: 26.7.0
source:
- final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-1
- final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 스키마를 통째로 지우고 덤프 하나로 되살아나는지 본다
덤프를 뜨고 검증 넷을 통과한 뒤 스키마를 통째로 지우고 같은 명령으로 복구를 대조하는 절차다. 되돌리는 수단이 방금 뜬 파일 하나뿐이라 검증이 파괴보다 먼저 온다.
## 관계
- **되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다**
이 절차가 남긴 덤프 위에 서는 판올림 실험의 결론이다. 스키마가 움직인 뒤에는 덤프만이 되돌리는 수단이 된다.
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
`-i` 를 빠뜨린 복구가 그 아홉 건과 같은 모양으로 끝난다. 왜 따로 확인해야 하는지를 그쪽이 적는다.
- **up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다**
데이터베이스가 통째로 비었는데 정문이 `200` 인 상태를 지표가 어떻게 놓치는지 다룬다.
- **이미지 태그를 올렸다 내리며 롤백이 언제 막히는지 가른다**
이 절차 다음에 오는 편이고, 여기서 뜬 덤프를 전제로 시작한다. 덤프를 지우지 않는 까닭이 그쪽에 있다.
- **PostgreSQL 을 정상 종료시키고 네 경로를 잰다**
프로세스가 죽었을 때의 모양이다. 먼저 봐 둬야 여기의 `200` 이 얼마나 이상한지 안다.
- **PostgreSQL 을 진짜로 크래시시키고 잃은 로그인을 센다**
실제 복구 지점 목표의 두 번째 겹이 그 편에서 나온다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 `kc-lab-1` 에서 친다. `kubectl``sudo` 를 붙이지 않는다 — 가이드가 「kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다」고 스스로 괄호를 달아 두었다. 마지막 한 단계만 호스트(`test-server`)로 넘어가고, 거기서는 사람이 비밀번호를 친다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` |
| 주입 수단 | `DROP SCHEMA public CASCADE; CREATE SCHEMA public;` |
| 되돌리는 수단 | 방금 뜬 덤프 파일 하나 — `/tmp/keycloak-backup.sql` |
| 전 구간 | 약 20분. 파괴 구간 자체는 1분 안쪽 |
| 잃는 것 | realm · client · user · 세션 전부 |
| `jq` | 이 실험대 어디에도 없다. 이 절차도 쓰지 않는다 |
**이 절차에는 스크립트가 없다.** 원래 실행은 백업·파괴·복구를 스크립트 하나로 돌렸고, 그래서 증거 파일의 줄에는 `realms|clients|users|sessions|authclients = 2|15|2|3|1` 처럼 이름표가 붙어 있다. 사람이 치는 형태가 아니다. 그리고 이 실험에서 스크립트는 특히 위험하다 — `DROP SCHEMA` 와 복구가 한 파일에 있으면 중간에서 멈췄을 때 무엇이 실행됐는지 알 수 없다. 파괴를 손으로 치고, 눈으로 확인하고, 복구도 손으로 친다.
## 이 실험이 가르는 것
「백업이 있다」와 「복구해 봤다」는 다른 문장이다. 백업 스크립트가 매일 도는 것과 그 파일로 실제로 서비스를 되살리는 것 사이에는 시험되지 않은 가정이 여러 개 있고, 이 절차는 그중 둘을 판정한다.
| # | 질문 | 어떻게 가르나 |
|---|---|---|
| ① | 덤프에 필요한 것이 다 들어가는가 | 특히 세션. 안 들어가면 복구 후 전원 재로그인이다 |
| ② | 복구 절차가 실제로 도는가 | 오류 없이 끝나고 데이터가 일치하는가 |
부수 질문이 하나 붙는다 — 데이터베이스가 비면 무엇이 깨지는가. 프로세스를 내린 A-2 와 여기가 갈라진다.
```text
A-2 DB 프로세스 정지 → 커넥션 실패 → readiness DOWN → 파드가 Service 에서 빠짐
D-1 스키마만 삭제 → 커넥션 정상 → readiness UP → ?
```
커넥션은 되는데 테이블이 없는 상태는 단일 장애 주입으로 잘 안 만들어진다. 그래서 이 절차가 따로 있다.
가이드는 끝났을 때 확인되는 것을 일곱으로 적는다. 덤프 파일 안에 세션 행이 실제로 들어 있는 것, 데이터베이스를 통째로 비웠는데 정문이 `200` 인 것, 파드가 `1/1 Running` 인 채로 테이블이 0개인 것, `certs` 200 · `well-known` 500 · 토큰 400 으로 부분만 깨지는 것, 복구가 1초 만에 오류 0건으로 끝나는 것, 세션까지 되살아나는 것, 그리고 덤프가 데이터베이스와 같은 기계 위에 놓여 있는 것.
**복구가 이 편에서는 관찰의 일부다.** 질문 ②의 답이 복구 절에서 나오므로 아래 「복구와 원상복구 확인표」는 원상복구만이 아니라 판정을 함께 싣는다.
## 전제와 되돌리기
- A-2 를 먼저 하면 좋다. 데이터베이스 프로세스가 죽었을 때의 모양을 봐 둬야 이 실험의 `200` 이 얼마나 이상한지 안다.
- A-3 도 먼저다. 실제 복구 지점 목표의 두 번째 겹이 거기서 나온다.
- 네임스페이스는 `keycloak-lab` 이다.
- 덤프를 다른 기계로 옮기는 마지막 단계만 호스트(`test-server`)가 필요하고, 호스트의 `sudo` 는 비밀번호를 묻는다. 그 부분은 사람이 직접 친다.
**이 실험은 데이터베이스를 비운다.** `DROP SCHEMA public CASCADE` 는 realm·client·user·세션을 전부 지운다. 되돌리는 수단은 방금 뜬 덤프 파일 하나뿐이고, 그래서 덤프를 검증하기 전에는 주입 절로 넘어가지 않는다.
되돌리기는 한 줄이고, 파괴하기 전에 읽어 둔다.
```bash label="[kc-lab-1] 파괴하기 전에 읽어 두는 되돌리기 한 줄"
kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak \
< /tmp/keycloak-backup.sql
```
**`-i` 가 이 명령의 전부다.** 빠뜨리면 아무 일도 안 일어나고 오류도 안 난다. 왜 그런지는 주입 검증 절의 마지막 단계에서 본다.
## 주입 전에 같은 명령으로 먼저 본다
시험군만 재는 측정은 측정이 아니다. 파괴 후에 볼 것을 파괴 전에 똑같은 명령으로 먼저 봐 둔다. 복구가 완전 일치인지 판정하려면 일치시킬 상대가 있어야 하는데, `DROP SCHEMA` 를 친 뒤에는 그 상대를 만들 방법이 없다. 넓은 것부터 좁혀 가고, 마지막 세 칸은 덤프 자체를 향한다.
```text
파드 → 데이터 개수 → 세션 → 밖에서 본 상태 → 덤프 → ★ 덤프 검증 → 덤프의 위치
```
### 1. 파드가 어디에 몇 개 있는가
**무엇을 보는가** — 파드 넷의 상태와 배치.
```bash label="[kc-lab-1] 파드 배치를 본다"
kubectl -n keycloak-lab get pods -o wide
```
**어디를 보나** — 실측은 이렇다(observed, `02-destruction.txt`). 파괴 직후 목록인데 파괴 전후가 같다는 것이 이 실험의 결과이므로 파괴 전 값으로도 읽는다. 증거에 옮겨진 네 줄에는 `-o wide` 가 덧붙이는 `NODE` 열이 없고 `postgres` 행도 빠져 있다. 아래에 없다고 해서 그 파드가 없지는 않다 — 노드 이름과 `postgres` 행은 자기 화면에서 읽는다.
```text
bff-555df79c97-6j86w 1/1 Running 0 49m
bff-555df79c97-vgg6g 1/1 Running 0 49m
keycloak-0 1/1 Running 0 4m15s
keycloak-1 1/1 Running 0 4m38s
```
**이 값이 뜻하는 것** — `READY` 가 전부 `1/1` 이고 `RESTARTS` 가 `0` 이다. 자기 화면의 `NODE` 열에서 `postgres` 파드가 어느 노드에 떠 있는지 읽고 적어 둔다 — 덤프가 그 노드와 같은 디스크에 놓였는지를 §7 에서 그 이름으로 가른다. 뒤에서 `RESTARTS` 가 오르면 파괴가 엉뚱한 데를 건드렸다는 신호다.
### 2. 데이터가 몇 건 있는가
**무엇을 보는가** — realm·client·user·세션의 개수. 처음 한 번은 읽는 형태로 친다. 값만 뽑는 형태부터 배우면 `psql` 이 무엇을 돌려주는지 모르게 된다.
```bash label="[kc-lab-1] ① psql 이 무엇을 돌려주는지 한 번 본다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from realm"
```
**어디를 보나** — 모양은 이렇다(observed).
```text
count
-------
2
(1 row)
```
숫자 하나와 `(1 row)` 를 본다. 여기서 오류가 나면 뒤의 모든 단계가 무의미하다. `psql: error: connection to server ... failed` 면 데이터베이스가 아직 안 붙은 것이고, `relation "realm" does not exist` 면 스키마가 이미 없다.
이제 넷을 한 줄로 모은다. 비교할 값이 필요할 때만 이 형태를 쓴다.
```bash label="[kc-lab-1] ② 대조할 한 줄을 뽑는다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select (select count(*) from realm), (select count(*) from client),
(select count(*) from user_entity),
(select count(*) from offline_user_session where offline_flag='0')"
```
**어디를 보나** — 실측은 이렇다(observed, `01-backup.txt`).
```text
realms|clients|users|sessions|authclients = 2|15|2|3|1
```
**이 값이 뜻하는 것** — 원래 실행은 스크립트로 돌렸고 인가된 클라이언트를 하나 더 셌다. 그래서 증거 줄에는 값이 다섯이고 이름표가 붙어 있다. 위 명령으로 넷을 뽑으면 이름표 없이 `2|15|2|3` 만 나온다. 다섯째 쿼리는 해설 문서의 재현 절차에 남아 있지 않아 가이드가 넷으로 뒀다 — 없는 컬럼을 지어내지 않고, 다섯째가 필요하면 세는 쿼리를 정해서 양쪽에 같이 쓴다. `-tAc` 는 헤더 없이(`-t`) 정렬 없이(`-A`) 한 줄만이라는 뜻이다.
**이 줄을 그대로 복사해 둔다.** 복구 후에 같은 명령을 쳐서 문자 단위로 같은지 본다. 하나라도 다르면 복구가 부분적으로만 됐다.
### 3. 세션이 데이터베이스 안에 있는가
**무엇을 보는가** — 질문 ①의 재료. 세션 행이 실제로 테이블에 있어야 덤프에 들어갈 것이 있다.
```bash label="[kc-lab-1] 세션 행을 나열한다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select user_session_id, offline_flag, realm_id from offline_user_session"
```
**어디를 보나** — 행이 몇 개 있고 id 가 어떻게 생겼는지만 본다(observed).
```text
user_session_id | offline_flag | realm_id
--------------------------+--------------+--------------------------------------
E1q5xI7tt4U_WhZpW7rEPIF2 | 0 | 7845f394-723a-4d07-b530-c7416b2e1d31
...
```
**이 값이 뜻하는 것** — 행이 0개면 질문 ①을 판정할 수 없다. 그때는 관리 콘솔에 한 번 로그인해서 세션을 만들고 다시 본다. 세션이 데이터베이스 테이블에 있다는 것은 `persistent-user-sessions` 가 켜져 있다는 뜻이고(A-0), 그래서 세션이 백업 대상이 된다. volatile 이었다면 세션은 애초에 데이터베이스에 없고 복구해도 전원 재로그인이라 백업의 값어치가 달라진다.
**두 쿼리가 다른 것을 센다.** 앞의 개수 쿼리는 `offline_flag='0'` 만 셌고 이 쿼리는 전부 나열한다. 원래 실행에서도 개수는 `3`, 나열은 `4 rows` 였다(`03-restore.txt`). 두 숫자가 다른 것을 이상하게 여기지 말고 복구 전후에 같은 쿼리끼리 비교한다.
**문제가 생기면** — 나열은 되는데 개수가 0이면 `offline_flag` 필터를 의심한다.
### 4. 밖에서는 무엇이 보이는가
**무엇을 보는가** — 정문과 app1 의 응답. 처음 한 번은 응답을 읽는다.
```bash label="[kc-lab-1] ① 헤더를 통째로 본다"
curl -I https://auth.hyeonworks.com/realms/master
```
헤더가 통째로 나온다. `HTTP/2 200`, `content-type: application/json` 을 본다. 같은 것을 반복해서 재고 비교할 때만 코드만 뽑는다.
```bash label="[kc-lab-1] ② 코드만 뽑아 둘을 잰다"
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/
```
**어디를 보나** — 실측은 이렇다(observed, `02-destruction.txt`). 이것도 파괴 직후 값이고, 그게 결과다.
```text
https://auth.hyeonworks.com/realms/master HTTP 200
https://app1.hyeonworks.com/ HTTP 200
```
**이 값이 뜻하는 것** — 지금은 당연히 `200` 이다. 파괴 뒤에도 같은 값이 나오므로 이 두 줄은 정상 판정에 쓸 수 없는 지표의 예로 남는다.
### 5. 덤프를 뜬다
**목적** — 되돌리는 수단을 만든다. 이 파일 없이는 다음 절로 못 간다.
① 시각을 남기고 덤프를 뜬다.
```bash label="[kc-lab-1] ① 시각과 함께 덤프를 뜬다"
date '+%H:%M:%S 백업 시작'
kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
--clean --if-exists > /tmp/keycloak-backup.sql
date '+%H:%M:%S 백업 완료'
```
**예상 결과** — 실측은 이렇다(observed, `01-backup.txt`).
```text
시작: 14:59:30
완료: 14:59:30
크기: 394945 bytes (6956 줄)
```
시각 두 줄과 파일 크기를 본다. 이 규모에서는 1초 미만이다.
**왜 필요한가** — 두 옵션은 짝이다.
| 옵션 | 무엇을 하나 | 없으면 |
|---|---|---|
| `--clean` | 복구 시 기존 객체를 DROP 하고 다시 만든다 | `already exists` 오류가 쏟아진다 |
| `--if-exists` | 없는 객체를 DROP 할 때 오류를 안 낸다 | 깨끗한 데이터베이스에 복구할 때 오류가 쏟아진다 |
`--clean` 만 주면 빈 데이터베이스에 넣는 복구가 깨지고, `--if-exists` 만 주면 아무 효과가 없다 — `DROP` 문 자체가 안 만들어진다. 이 실험은 어차피 빈 데이터베이스에 복구하는데도 두 옵션이 필요하다. 실제 사고는 대개 그렇지 않고, 반쯤 남은 데이터베이스에 덤프를 밀어 넣는 상황이 훨씬 흔하며 그때 이 둘이 있고 없고가 갈린다.
**문제가 생기면** — 이 단계는 읽기만 하므로 파일이 마음에 안 들면 지우고 다시 뜬다.
```bash label="[kc-lab-1] 덤프가 마음에 안 들면 지우고 다시 뜬다"
rm -f /tmp/keycloak-backup.sql
```
### 6. 덤프를 검증한다 — 넷을 통과해야 다음 절로 간다
**목적** — 「파일이 생겼다」와 「복구할 수 있다」를 가른다. `pg_dump` 가 중간에 실패해도 파일은 남고 크기도 0 이 아니다. 이 단계를 건너뛰면 주입 절은 자살행위라고 가이드는 적는다.
① 크기와 줄 수.
```bash label="[kc-lab-1] ① 크기와 줄 수"
ls -l /tmp/keycloak-backup.sql
wc -l /tmp/keycloak-backup.sql
```
**예상 결과** — 실측은 이렇다(observed).
```text
크기: 394945 bytes (6956 줄)
```
② 테이블 수.
```bash label="[kc-lab-1] ② 덤프 안의 테이블 수"
grep -c '^CREATE TABLE' /tmp/keycloak-backup.sql
```
**예상 결과** — 실측은 이렇다(observed).
```text
포함된 테이블 수: 101
```
101 이라는 절대값이 아니라 앞에서 본 데이터베이스와 자릿수가 맞는지가 중요하다. 두 자리로 떨어지면 덤프가 잘렸다.
③ 끝까지 쓰였는가.
```bash label="[kc-lab-1] ③ 마지막 세 줄"
tail -3 /tmp/keycloak-backup.sql
```
**예상 결과** — 모양은 이렇다(observed).
```text
--
-- PostgreSQL database dump complete
--
```
`dump complete` 를 본다. 이 줄이 없으면 덤프가 중간에 끊긴 것이고 그 파일로는 복구가 안 된다. 한 줄이 「파일이 생겼다」와 「덤프가 끝났다」를 가른다.
④ 세션이 들어갔는가. 이것이 질문 ① 자체다.
```bash label="[kc-lab-1] ④ COPY 블록에 세션 행이 붙어 있는가"
grep -c 'offline_user_session' /tmp/keycloak-backup.sql
grep -A3 'COPY public.offline_user_session' /tmp/keycloak-backup.sql | cut -c1-110
```
**예상 결과** — 실측은 이렇다(observed, `01-backup.txt`).
```text
offline_user_session 언급: 13
COPY public.offline_user_session (user_session_id, user_id, realm_id, created_on, offline_flag, data, last_session_refre
E1q5xI7tt4U_WhZpW7rEPIF2 48b37d33-8419-49aa-9b5b-7731975be50c 7845f394-723a-4d07-b530-c7416b2e1d31 1788500836 0 {"ipAddr
2ap3DyRiBF8OdMiqCodsJ0mp 48b37d33-8419-49aa-9b5b-7731975be50c 7845f394-723a-4d07-b530-c7416b2e1d31 1788501263 0 {"ipAddr
```
**왜 필요한가** — `COPY` 줄 다음에 실제 데이터 행이 붙어 있는가를 본다. `COPY ... FROM stdin;` 바로 뒤에 `\.` 만 있으면 테이블 정의만 들어가고 행은 비어 있는 것이고, 그건 세션을 백업하지 못한 덤프다. `cut -c1-110` 은 `data` 열의 JSON 이 화면을 뒤덮는 것을 막으려는 것이라, 처음 한 번은 `cut` 없이 쳐서 한 행이 얼마나 긴지 봐 둔다.
세션은 덤프에 들어간다. 질문 ①의 답은 「들어간다」이고 근거가 이 `COPY` 블록이다. 복구 절에서 같은 id 들이 되살아나는 것을 확인한다.
**문제가 생기면** — 넷 중 하나라도 어긋나면 덤프를 지우고 다시 뜬다. 다음 절로 넘어가지 않는다.
### 7. 덤프가 지금 어디에 있는가
**무엇을 보는가** — 파일의 경로와 그 파일이 올라앉은 디스크.
```bash label="[kc-lab-1] 덤프의 경로와 디스크를 본다"
ls -l /tmp/keycloak-backup.sql
df -h /tmp
```
**이 값이 뜻하는 것** — 경로가 `/tmp` 다. 이 파일은 지금 `kubectl` 을 친 그 기계의 디스크에 있다. A-4 에서 `local-path` PVC 가 노드에 못박혀 있는 것을 봤고, 그 노드가 안 돌아오면 데이터베이스 볼륨도 안 돌아온다. 그때 유일한 길이 덤프인데 덤프도 같은 기계에 있으면 같이 사라진다. **같은 장애 도메인에 있는 백업은 백업이 아니다.** 원래 실행에서도 덤프는 `test-server:/tmp` 에 있었고, 해설 문서는 그것을 가장 중요한 미검증 항목으로 기록했다. 옮기는 절차는 복구 절에 있고, 파괴 전에는 읽어만 두고 실제 이동은 복구가 끝난 뒤에 한다.
## 주입
여기부터 데이터가 사라진다. 되돌리는 명령은 전제 절에 있고, 덤프 검증 넷을 통과하지 않았으면 지금 돌아가서 한다.
### 1. 스키마를 통째로 지운다
**목적** — 커넥션은 살아 있는데 테이블만 없는 상태를 만든다.
① 시각을 남기고 친다.
```bash label="[kc-lab-1] ① 시각을 남기고 스키마를 지운다"
date '+%H:%M:%S 파괴'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
```
**예상 결과** — 실측은 이렇다(observed, `02-destruction.txt`).
```text
=== ★ 파괴 — 스키마를 통째로 지운다 ===
시각: 14:59:47
DROP SCHEMA
CREATE SCHEMA
```
`DROP SCHEMA` 와 `CREATE SCHEMA` 두 줄을 본다. `NOTICE: drop cascades to 101 other objects` 같은 줄이 함께 나오는 것이 정상이다.
**왜 필요한가** — 시각을 반드시 적어 둔다. 복구 절의 복구 시간 목표가 이 시각에서 시작한다. `CREATE SCHEMA public` 을 붙이는 까닭은 `public` 스키마 자체를 지우면 복구 스크립트가 들어갈 곳이 없기 때문이다. 지우는 것은 안의 객체이고, 빈 스키마는 남겨 둬야 `pg_dump` 출력이 그대로 들어간다.
**문제가 생기면** — `-d` 인자를 본다. 다른 데이터베이스에 걸렸으면 다음 절의 테이블 수가 101 그대로 나온다.
## 주입 검증
결과를 해석하기 전에, 의도한 것만 지워졌는지 먼저 본다.
```bash label="[kc-lab-1] ① 남은 테이블을 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from pg_tables where schemaname='public'"
```
이 실험대는 스크립트로 셌고(observed), 위 형태는 가이드가 미검증으로 표시한 줄이다(unknown). 결과는 이렇다(observed).
```text
남은 테이블: 0
```
`0` 이어야 한다. 여기서 101 이 그대로 나오면 `DROP` 이 다른 데이터베이스에 걸린 것이고 `-d` 인자를 본다.
애플리케이션 테이블이 정말 없는지 직접 물어본다.
```bash label="[kc-lab-1] ② 테이블에 직접 물어본다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from realm"
```
모양은 이렇다(observed).
```text
ERROR: relation "realm" does not exist
LINE 1: select count(*) from realm
^
```
**커넥션은 성립하고 SQL 도 파싱된다. 테이블만 없다.** 이 구별이 이 실험의 전부다. A-2 에서는 여기가 `connection to server ... failed` 였다.
**그런데 밖은 멀쩡하다.**
```bash label="[kc-lab-1] ③ 파드와 밖에서 본 상태를 다시 잰다"
kubectl -n keycloak-lab get pods -o wide
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/
```
실측은 이렇다(observed, `02-destruction.txt`).
```text
https://auth.hyeonworks.com/realms/master HTTP 200
https://app1.hyeonworks.com/ HTTP 200
keycloak-0 1/1 Running 0 4m15s
keycloak-1 1/1 Running 0 4m38s
```
`1/1`, `RESTARTS 0`, 그리고 `200` 이다. 데이터베이스가 통째로 비었는데 정문이 200 이다. 여기서 파괴가 실패했다고 읽으면 틀린다 — 테이블이 0개인 것을 바로 앞에서 봤다. 파괴는 성공했고 관측 지점이 그것을 못 본다. Keycloak 이 realm 정보를 Infinispan `realms` 캐시에서 서빙하기 때문이고(A-0 에서 그 캐시에 57개 엔트리가 있는 것을 봤다), 캐시는 읽을 때 데이터베이스와 대조하지 않는다. A-1 에서 로그아웃한 세션이 반대편에서 `200` 을 받았던 것과 같은 성질이다.
엉뚱한 것을 죽이지 않았는지도 본다.
```bash label="[kc-lab-1] ④ Service 에서 빠진 파드가 있는가"
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
```
ready 주소가 여전히 둘이다. 아무 파드도 Service 에서 빠지지 않았다. A-2 에서는 여기가 빈 목록이었다. `kubectl get endpoints` 는 v1.33+ 에서 deprecated 이고, 이 실험대에서 실제로 그 경고를 봤다.
### 이 층의 조용한 실패는 복구 쪽에서 온다
여기까지의 주입 검증은 쉽게 통과한다. 어려운 확인은 반대편에 있다. 복구 명령에서 `-i` 를 빠뜨리면 파드 안의 `psql` 이 빈 입력을 받고 정상 종료하고, 셸은 오류를 내지 않고, 종료 코드도 0 이며, `date` 두 줄은 「1초 만에 끝났다」로 찍힌다. 복구된 것과 구별되지 않는다.
```bash label="[kc-lab-1] 치지 않는다 — -i 가 있는 줄과 없는 줄을 눈으로 견준다"
kubectl -n keycloak-lab exec deploy/postgres -- psql ... < dump.sql # ✘
kubectl -n keycloak-lab exec -i deploy/postgres -- psql ... < dump.sql # ✔
```
위 두 줄은 치는 명령이 아니다. `...` 와 `dump.sql` 은 두 형태를 나란히 놓으려고 줄여 쓴 것이고, 실제로 치는 복구 명령은 전제 절과 아래 복구 §1 에 온전한 형태로 있다. `-i` 는 표준입력을 파드 안으로 연결하라는 뜻이다. 구별하는 유일한 방법이 복구 뒤의 데이터 대조이고, 그래서 대조는 선택이 아니다.
## 관찰
**전부 깨지지는 않는다.** 세 경로를 나눠서 친다.
```bash label="[kc-lab-1] ① 두 경로를 잰다"
curl -s -o /dev/null -w 'certs %{http_code}\n' \
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration
```
실측은 이렇다(observed, `03-restore.txt`).
```text
/.well-known/openid-configuration HTTP 500
/protocol/openid-connect/certs HTTP 200
토큰 발급 (DB 쓰기 필요) HTTP 400
```
토큰 발급은 값이 필요하므로 따로 친다. 이 실험대는 스크립트로 돌렸고(observed), 아래는 가이드가 미검증으로 표시한 형태다(unknown).
```bash label="[kc-lab-1] ② 토큰 발급을 잰다"
curl -s -o /dev/null -w '토큰 %{http_code}\n' -X POST \
https://auth.hyeonworks.com/realms/master/protocol/openid-connect/token \
-d grant_type=password -d client_id=admin-cli -d username=admin \
-d "password=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
**비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도 셸 히스토리에도 남지 않는다. 길이만 확인하려면 한 줄을 더 친다.
```bash label="[kc-lab-1] ③ 값이 아니라 길이만 잰다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
```
세 값이 다 다르다.
| 경로 | 코드 | 왜 |
|---|---|---|
| `certs` (JWKS) | 200 | realm 키가 캐시에 있다. 데이터베이스를 안 본다 |
| `.well-known` | 500 | 이 응답을 만들려면 데이터베이스를 본다 |
| 토큰 발급 | 400 | 세션을 써야 한다 |
**부분적으로만 깨진다.** 헬스체크는 통과하고, 일부 엔드포인트는 정상이며, 로그인만 안 된다. 운영에서 이 모양이 고약한 까닭은 「사이트가 떴는가」를 재는 감시가 전부 초록인데 사용자만 못 들어오기 때문이다. 이 사고의 감시 항목은 `/realms/master` 가 아니라 토큰 발급이어야 한다.
로그가 이유를 말한다.
```bash label="[kc-lab-1] ④ Keycloak 로그를 읽는다"
kubectl -n keycloak-lab logs keycloak-0 --tail=50
```
실측은 이렇다(observed, `02-destruction.txt`).
```text
2026-09-04 05:58:02,598 WARN [org.keycloak.jgroups.protocol.KEYCLOAK_JDBC_PING2] (blocking-thread--p3-t2) Failed to fetch the cluster members from the database.: org.postgresql.ut
at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2904)
```
`WARN` 이지 `ERROR` 가 아니다. 내용은 클러스터 멤버를 못 가져온다는 것이고, `JGROUPS_PING` 테이블도 같이 지워졌기 때문이다(A-1 에서 그 테이블을 봤다). 디스커버리가 깨졌는데도 로그 레벨이 `WARN` 이라 대시보드의 에러 카운터에 안 잡힐 수 있다. 정문의 `200`, 부분 정상, 여기의 `WARN` — 세 관측이 전부 「괜찮다」 쪽으로 기운다.
「데이터베이스가 살아 있다」와 「데이터가 있다」는 다르고, 그 차이가 이 실험의 모양을 만든다.
```text
A-2 DB 프로세스 정지 → 커넥션 실패 → readiness DOWN → 파드가 Service 에서 빠진다
D-1 스키마만 삭제 → 커넥션 정상 → readiness UP → ★ 파드가 그대로 트래픽을 받는다
```
헬스체크는 커넥션만 본다. 그래서 빈 데이터베이스를 통과시킨다. Keycloak 의 버그가 아니다 — 「데이터베이스에 붙을 수 있는가」는 프로브가 답할 수 있는 물음이고 「데이터가 온전한가」는 프로브가 답할 수 없는 물음이다. 뒤엣것을 재려면 업무 트랜잭션 하나를 실제로 돌리는 감시가 따로 있어야 한다.
| 재는 것 | 이 사고에서 |
|---|---|
| 파드 `Ready` | 초록 |
| 정문 `200` | 초록 |
| JWKS `200` | 초록 |
| 토큰 발급 | 400 ← 유일하게 정직한 지표 |
## 복구와 원상복구 확인표
### 1. 덤프를 되돌린다
**목적** — 파괴 전의 데이터로 되돌리고, 질문 ②의 답을 만든다.
① 시각을 남기고 복구한다. `-i` 가 있는지 치기 전에 눈으로 확인한다.
```bash label="[kc-lab-1] ① 시각과 함께 덤프를 되돌린다"
date '+%H:%M:%S 복구 시작'
kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak \
< /tmp/keycloak-backup.sql > /tmp/restore.log 2>&1
date '+%H:%M:%S 복구 완료'
```
**예상 결과** — 실측은 이렇다(observed, `03-restore.txt`).
```text
시작: 15:00:12
완료: 15:00:13
오류 줄: 0
```
② 로그의 오류를 센다.
```bash label="[kc-lab-1] ② 복구 로그의 오류를 센다"
grep -ci '^ERROR' /tmp/restore.log
tail -5 /tmp/restore.log
```
**예상 결과** — `0` 이어야 한다. 0 이 아니면 어떤 줄이 실패했는지 본다. `--clean --if-exists` 로 뜬 덤프를 빈 데이터베이스에 넣으면 오류가 0 인 것이 정상이다.
**왜 필요한가** — 시각 두 줄과 오류 0건은 `-i` 를 빠뜨렸을 때도 똑같이 나온다. 그래서 이 둘로는 복구를 판정하지 않는다.
**문제가 생기면** — 1초 만에 끝났는데 다음 단계의 대조가 어긋나면 `-i` 를 의심한다.
### 2. 진짜 판정 — 주입 전과 문자 단위로 견준다
**목적** — 복구가 완전 일치인지 가른다.
① 주입 전에 친 것과 똑같은 명령을 친다.
```bash label="[kc-lab-1] ① 대조할 한 줄을 다시 뽑는다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select (select count(*) from realm), (select count(*) from client),
(select count(*) from user_entity),
(select count(*) from offline_user_session where offline_flag='0')"
```
**예상 결과** — 실측은 이렇다(observed, `03-restore.txt`). 원래 실행은 스크립트였으므로 이름표가 붙은 두 줄이고 값이 다섯이다. 위 명령을 손으로 치면 이름표 없이 `2|15|2|3` 한 줄만 나온다. 주입 전 §2 에서 복사해 둔 줄과 그 한 줄을 견준다.
```text
복구 후: realms|clients|users|sessions|authclients = 2|15|2|3|1
백업 시: realms|clients|users|sessions|authclients = 2|15|2|3|1
```
**왜 필요한가** — 두 줄이 문자 단위로 같은가를 본다. 완전 일치이고, 질문 ②의 답이 「돈다」인 근거가 이 두 줄이다. 여기가 다르면 그 앞의 모든 성공 표시는 무의미하다.
**문제가 생기면** — `-i` 를 빠뜨렸는지 먼저 의심하고, 붙여서 다시 친다.
### 3. 손대지 않고 기다린다
**목적** — 스스로 회복하는지 본다. 여기서 파드를 재시작하면 그 물음 자체가 사라진다.
① 15초쯤 뒤에 본다.
```bash label="[kc-lab-1] ① 15초 뒤에 다시 잰다"
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration
kubectl -n keycloak-lab get pods -o wide | grep keycloak
```
**예상 결과** — 실측은 이렇다(observed, `03-restore.txt`).
```text
+15초 well-known=200 토큰발급=200
→ 재시작 없이 회복
keycloak-0 restarts=0
keycloak-1 restarts=0
```
**왜 필요한가** — 500 이던 `well-known` 이 `200` 이 된 것과 `RESTARTS` 가 여전히 0 인 것을 같이 본다. **`200` 을 본 순간의 시각을 손으로 적어 둔다** — 아래 §5 의 복구 시간 목표가 끝나는 지점이 그 시각인데, 이 단계에는 그것을 남기는 `date` 줄이 가이드에 없다. 커넥션 풀이 이미 붙어 있었으므로 테이블이 돌아오자마자 동작했다. 파드를 만졌다면 「복구 절차에 파드 재시작이 필요하다」는 잘못된 절차가 문서에 남았을 것이다.
### 4. 세션이 살아났는지 본다
**목적** — 덤프의 `COPY` 블록에서 본 id 가 테이블로 넘어왔는지 눈으로 잇는다.
① 세션 행을 다시 나열한다. **주입 전 §3 에서 친 것과 열이 하나 다르다** — 거기는 `realm_id` 까지 셋을 뽑고 여기는 `user_session_id` 와 `offline_flag` 둘만 뽑는다. 가이드 원문이 그렇게 갈려 있어 그대로 싣는다. 열이 다르므로 행 수와 `user_session_id` 값으로 견준다.
```bash label="[kc-lab-1] ① 세션 행을 다시 나열한다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select user_session_id, offline_flag from offline_user_session"
```
**예상 결과** — 실측은 이렇다(observed). 원래 실행은 realm 이름을 함께 뽑았으므로 아래 둘째 열이 `realm` 이다. 위 명령을 그대로 치면 둘째 열에 `offline_flag` 가 오고 값은 `0` 으로 찍힌다. 열 이름이 다른 것을 복구가 덜 됐다는 신호로 읽지 않는다 — 견줄 것은 `user_session_id` 네 값과 `(4 rows)` 다.
```text
user_session_id | realm
--------------------------+-------------------
E1q5xI7tt4U_WhZpW7rEPIF2 | master
2ap3DyRiBF8OdMiqCodsJ0mp | master
Zsk4QcgXf_qgyMKzde5AG-Fz | master
vsDgCVo12-qX0CC63ZmYzbYF | keycloak-patterns
(4 rows)
```
**왜 필요한가** — `E1q5xI7tt4U_WhZpW7rEPIF2` 가 덤프의 `COPY` 블록에도 복구된 테이블에도 있다. 파일에서 데이터베이스로 실제로 넘어온 것을 눈으로 잇는다. 세션이 백업에서 복원되고 로그인 상태가 유지된다.
### 5. 적어 둔 시각 셋을 나란히 놓는다
```text
14:59:47 파괴
15:00:12 복구 시작
15:00:13 복구 완료
~15:00:28 서비스 정상 확인
RTO = 41초
```
**값은 넷인데 `date` 가 남기는 것은 셋이다.** 파괴·복구 시작·복구 완료 세 줄만 명령이 찍고, 넷째 `~15:00:28 서비스 정상 확인` 은 §3 에서 사람이 읽어 적은 시각이다. `RTO = 41초` 는 첫째와 넷째의 차이므로, 그 시각을 안 적어 뒀으면 여기서 복구 시간 목표를 못 만든다.
41초 중 복구 명령 자체는 1초다. 나머지는 파괴를 알아채고 무엇을 할지 정하는 시간이며, 이 실험에서는 이미 알고 있었으므로 25초였다. 실제 사고에서는 이 부분이 대부분을 차지한다.
복구 지점 목표는 두 겹이다.
```text
① 마지막 덤프 이후의 모든 변경 ← 백업 주기가 정한다
② A-3 에서 측정한 synchronous_commit 손실 ← 수백 ms
실제 RPO = ① + ②
```
A-3 은 클라이언트가 200 을 받은 로그인 153건 중 4건이 데이터베이스에 없었다는 것을 측정했다. 백업 주기만 보고 복구 지점 목표를 말하면 ②를 빠뜨린다.
그리고 이 실험대의 규모는 현실적이지 않다.
| | 이 실험대 | 운영 |
|---|---|---|
| 덤프 크기 | 395KB | GB~TB |
| 복구 시간 | 1초 | 분~시간 |
| 세션 수 | 3~4 | 수만 |
복구가 1초인 것은 데이터가 작기 때문이고, 이 실험이 확인한 것은 절차가 맞다는 것까지다. 시간은 규모에 따라 완전히 달라진다.
### 6. 덤프를 다른 기계로 옮긴다 — 이 실험이 「못 했다」로 남긴 단계
**목적** — 덤프를 데이터베이스와 다른 장애 도메인에 둔다.
사람이 쳐야 하는 부분이 여기서 갈린다.
| 하는 일 | 어디서 | sudo |
|---|---|---|
| 덤프 뜨기 · 복구 | `kc-lab-1` | 게스트는 무암호 — 스크립트로도 된다 |
| 덤프를 호스트의 사용자 홈에 두기 | `test-server` | 필요 없다 |
| 덤프를 root 소유 경로(`/var/backups` 등)에 두기 | `test-server` | 비밀번호를 묻는다 — 사람이 친다 |
호스트에서 비대화 `sudo` 는 반드시 실패한다. 그 벽에 부딪힌 기록이 D-4 의 증거에 남아 있다(observed, `d4-certificate-renewal/01-certificate-state.txt`).
```text
$ sudo -n -l
sudo: a password is required
```
`-n` 은 비밀번호를 물어보지 말라는 뜻이고 호스트에서는 그게 곧 실패다. 그러므로 백업을 호스트의 보호된 경로에 두는 단계는 자동화할 수 없다. `ssh -t` 로 붙어 사람이 비밀번호를 쳐야 하고, `-t` 가 없으면 sudo 가 비밀번호를 읽을 tty 가 없다.
① 두 줄을 차례로 친다. 이 실험대는 여기까지 하지 않았다(unknown). 가이드가 미검증으로 표시한 줄이고, 호스트 이름과 경로는 따라 하는 사람의 배치에 맞춘다.
```bash label="[kc-lab-1 → test-server] ① 호스트의 사용자 홈으로 옮긴다"
# ① kc-lab-1 에서 호스트로 — sudo 없이 사용자 홈에
scp /tmp/keycloak-backup.sql test-server:~/keycloak-backup-2026-09-04.sql
# ② 보호된 경로로 옮기는 것은 호스트에서 사람이 친다 (비밀번호 프롬프트)
ssh -t test-server 'sudo install -m600 -o root -g root \
~/keycloak-backup-2026-09-04.sql /var/backups/keycloak-backup-2026-09-04.sql'
```
② 옮긴 파일이 온전한지는 크기를 양쪽에서 세서 비교한다.
```bash label="[kc-lab-1] ② 양쪽에서 크기를 센다"
wc -c /tmp/keycloak-backup.sql
ssh test-server 'wc -c ~/keycloak-backup-2026-09-04.sql'
```
**예상 결과** — 두 숫자가 같다. 다르면 전송이 잘린 것이다.
**왜 필요한가** — 이것으로도 부족하다. 호스트는 VM 두 대를 품고 있는 기계이므로 호스트가 죽으면 게스트도 덤프도 같이 간다. 진짜 요건은 「다른 기계」가 아니라 「다른 장애 도메인」이다.
**이 두 줄은 호스트에 평문 덤프를 두 벌 남긴다.** `install` 은 옮기기가 아니라 복사라 사용자 홈의 `keycloak-backup-2026-09-04.sql` 이 `/var/backups` 의 사본과 함께 그대로 있다. 두 파일 다 realm·client·user·세션을 통째로 담고 있는데, 지우는 절차는 가이드에 없고(unknown) 아래 여덟 항목에도 없다. 실험대 밖에서 이 단계를 밟았다면 두 파일을 어떻게 할지는 치는 사람이 정한다.
### 7. 여덟 항목을 대조한다
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 테이블 | `psql -c "select count(*) from pg_tables where schemaname='public'"` | 101 |
| 데이터 | 복구 대조의 `-tAc` 한 줄 | 백업 시점과 문자 단위로 동일 |
| 세션 | `select count(*) from offline_user_session` | 파괴 전과 같은 수 |
| 파드 | `kubectl -n keycloak-lab get pods -o wide` | `1/1 Running`, `RESTARTS 0` |
| Service | `get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 둘 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
| 로그인 | 관찰 절의 토큰 발급 | `200` ← 이것이 진짜 판정 |
| 덤프 | `ls -l /tmp/keycloak-backup.sql` | 남겨 둔다. D-2 의 전제다 |
**덤프를 지우지 않는다.** D-2 가 이 파일을 전제로 한다.
## 막히면
가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이거나 이 절차에서 실제로 갈리는 곳이라고 적는다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 복구가 1초 만에 끝났는데 데이터가 없다 | `exec` 에 `-i` 가 없다. 오류도 안 난다 | 데이터 대조. `-i` 를 붙여 다시 |
| 복구에서 `already exists` 가 쏟아진다 | 덤프를 `--clean --if-exists` 없이 떴다 | `grep -c '^DROP TABLE' /tmp/keycloak-backup.sql` — 0 이면 그것 때문이다 |
| 덤프 파일은 있는데 복구가 중간에 멈춘다 | 덤프가 잘렸다 | `tail -3` 에 `dump complete` 가 있는가 |
| 파괴했는데 정문이 계속 `200` | 정상이다. realm 캐시가 서빙한다 | 토큰 발급으로 판정 |
| `psql: relation "realm" does not exist` | 파괴가 걸린 것이다 | 그게 주입 검증의 기대 출력이다 |
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33+ 에서 deprecated | `get endpointslice -l kubernetes.io/service-name=...` |
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 밖에서 `curl` 로 친다 |
| 세션 개수가 나열한 행 수와 다르다 | 개수 쿼리에 `offline_flag='0'` 필터가 있다 | 같은 쿼리끼리 비교 |
| 백업이 0바이트다 | `pg_dump` 가 인증에서 막혔다 | `-U keycloak -d keycloak` 를 확인. 파일을 지우고 다시 뜬다 |
| 호스트에서 `sudo` 가 안 먹는다 | 호스트 sudo 는 비밀번호를 요구한다 | `ssh -t` 로 붙어 사람이 친다 |
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 14:5715:00 KST` 에 돈 한 번의 실행에서 나왔다(observed).
- (observed) 파괴 직후 파드 네 줄과 `RESTARTS 0`, 백업의 `시작: 14:59:30` · `완료: 14:59:30` · `크기: 394945 bytes (6956 줄)`, 테이블 수 `101`, `offline_user_session 언급: 13` 과 `COPY` 블록에 붙은 세션 행, 파괴 시각 `14:59:47` 과 `DROP SCHEMA` · `CREATE SCHEMA`, 남은 테이블 `0`, 파괴 뒤에도 정문과 app1 이 전부 `HTTP 200` 인 것, `certs` 200 · `.well-known` 500 · 토큰 발급 400, `KEYCLOAK_JDBC_PING2` 의 `WARN` 두 줄, 복구의 `시작: 15:00:12` · `완료: 15:00:13` · `오류 줄: 0`, 복구 전후 대조 두 줄이 같은 것, `+15초 well-known=200 토큰발급=200` 과 `restarts=0`, 복구된 세션 네 행, `RTO = 41초`.
- (observed) A-3 이 잰 로그인 153건 중 4건 소실은 그 실험의 값이고, 여기서는 실제 복구 지점 목표의 두 번째 겹으로 인용만 한다.
- (unknown) 남은 테이블을 세는 `pg_tables` 쿼리와 토큰 발급 `curl` 한 줄. 가이드가 미검증으로 표시했고 원래 실행은 스크립트로 돌렸다. 덤프를 호스트로 옮기는 두 줄도 미검증이고, 이 실험대는 그 단계를 하지 않았다 — 덤프는 데이터베이스와 같은 기계에 놓인 채 실험이 끝났다.
- 다섯째 컬럼은 지어내지 않았다. 증거 줄에는 `authclients` 까지 다섯 값이 있는데 해설 문서의 재현 절차에 그 쿼리가 없어서, 가이드도 이 절차도 넷만 센다.
- 비밀은 옮기지 않았다 — 관리자 비밀번호는 명령 치환으로만 넘어가고, 길이를 재는 줄만 따로 있다. 세션 id 와 realm UUID 는 식별자라 그대로 적었다. 덤프 파일 자체가 realm·client·user·세션을 통째로 담고 있고, 그 파일을 어디에 두는가가 이 실험의 마지막 물음이다.
- (unknown) 검증 넷을 통과한 덤프로 복구했는데 그 복구가 실패했을 때 갈 길은 가이드에 없다. 「막히면」 표는 원인을 가리키는 데까지만 적고, 지우고 다시 시작하는 절차를 주지 않는다. 만들어 넣지 않았다.
- 이 실험이 확인하지 않은 것 — 백업 자동화, 보존 주기, 복구 리허설의 정기 실행. 이번엔 손으로 한 번 떴고 한 번 되돌렸다. 그것만 참이다.
<!-- body:end -->
@@ -0,0 +1,706 @@
---
id: e53c5947-e1df-400a-ad79-e9d55b1da452
kind: SETUP
slug: reproduce-d2-version-upgrade
title: 이미지 태그를 올렸다 내리며 롤백이 언제 막히는지 가른다
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/e53c5947-e1df-400a-ad79-e9d55b1da452/edit"
pinnedVersions:
- name: Keycloak (시작·복귀 태그)
version: 26.7.0
- name: Keycloak (정방향)
version: 26.7.3
- name: Keycloak (역방향 대조)
version: "26.0"
- name: Infinispan (26.7.3 에 실린 판)
version: 16.0.14
source:
- final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-2
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 이미지 태그를 올렸다 내리며 롤백이 언제 막히는지 가른다
databasechangelog 행 수를 먼저 세고 이미지 태그를 정방향·롤백·역방향으로 세 번 바꾸는 절차다. 마지막 한 번은 파드를 CrashLoopBackOff 로 만들므로 백업 없이 시작하지 않는다.
## 관계
- **되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다**
이 절차가 만드는 상태에서 나온 판정이다. 여기는 순서만 적고 결론은 그쪽이 적는다.
- **롤링 재시작은 세션을 남기고 캐시만 지웠다**
태그를 바꾸면 파드가 하나씩 갈린다. 그때 세션과 캐시가 어떻게 갈리는지를 그쪽이 다룬다.
- **readiness 가 깨진 노드를 시야에서 먼저 치운다**
`Running` 인데 `0/1` 인 파드가 왜 트래픽을 안 받는지, 그것이 왜 사고를 절반에서 멈추는지 다룬다.
- **스키마를 통째로 지우고 덤프 하나로 되살아나는지 본다**
먼저 해 둬야 하는 편이다. 여기서 쓰는 되돌리기 수단이 태그가 아니라 그 편이 남긴 덤프일 수 있다.
- **롤링 재시작을 걸고 재시작 전 토큰이 통하는지 본다**
롤링 재시작이 무중단이라는 것이 이 절차의 전제다. 그 전제를 그 편에서 쟀다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 `kc-lab-1` 에서 친다. `kubectl``sudo` 를 붙이지 않는다. 호스트로 넘어가는 단계가 없어 전 구간을 게스트 안에서 끝낸다. 터미널을 두 개 열어 두면 편하다 — 하나는 가용성 폴링용, 하나는 관찰용이다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` |
| 주입 수단 | `set image statefulset/keycloak` — 태그를 세 번 바꾼다 |
| 되돌리는 수단 | 태그 한 줄. 단, 행 수가 안 바뀌었을 때만 |
| 전 구간 | 약 20분 |
| 대조군 | 1초 간격 150회 폴링. `--max-time 3` |
| `jq` | 이 실험대 어디에도 없다. 이 절차도 쓰지 않는다 |
**실측이 두 실행에서 나온다**(observed). 처음 실행은 `15:0015:10` 에 역방향 `26.0` 을 쳤고, 후속 실행은 `15:2215:26``26.7.3` 정방향과 롤백을 쳤다. 아래에서도 어느 쪽인지 매번 적는다.
## 이 실험이 가르는 것
「문제가 생기면 이미지 태그를 되돌린다」는 거의 모든 배포 계획서에 적혀 있다. 그 계획이 언제 동작하고 언제 동작하지 않는지를 가른다.
Keycloak 은 Liquibase 로 스키마를 관리한다. 적용한 변경 하나하나가 `databasechangelog` 테이블에 행으로 쌓이고, 각 행에는 그 변경 정의의 체크섬(`md5sum`)이 들어 있다.
```text
컨테이너가 뜬다
└─▶ Liquibase 가 databasechangelog 를 읽는다
└─▶ 자기가 아는 changeset 의 체크섬과 대조한다
├─ 같다 → 기동
└─ 다르다 → ValidationFailedException. 기동 거부
```
「모르는 변경이 있다」가 아니라 「아는 변경인데 정의가 다르다」이며, 더 엄격한 실패다. 그래서 판정 기준이 이렇게 바뀐다.
| 이렇게 묻지 말고 | 이렇게 묻는다 |
|---|---|
| 26.7.3 에서 26.7.0 으로 내려도 되나 | `databasechangelog` 의 행 수가 바뀌었나 |
**이 절차는 정정된 결론을 따른다.** 해설 문서는 처음에 「롤백은 안 된다」고 단정했다가 후속 실험에서 정정했다.
| 버전 차 | `databasechangelog` | 롤백 |
|---|---|---|
| 26.7.0 → 26.0 | 체크섬 불일치 | 불가 |
| 26.7.0 ↔ 26.7.3 | 210 → 210, 변화 없음 | 가능 |
판단 기준은 버전 번호가 아니라 행 수의 변화다. 그래서 그 숫자를 재는 법부터 배운다.
가이드는 끝났을 때 확인되는 것을 일곱으로 적는다. 업그레이드 전후로 `databasechangelog` 행 수가 그대로인 것, 파드가 하나씩 갈리는 동안 정문이 계속 `200` 인 것, 같은 스키마에서는 롤백이 되는 것, 전환 순간의 `000` 이 서버 오류가 아닌 것, 스키마가 바뀐 방향에서 `ValidationFailedException` 으로 기동이 거부되는 것, 그때도 서비스가 살아 있는 것, 실패한 기동이 스키마를 안 건드린 것.
## 전제와 되돌리기
- D-1 이 끝나 있고 덤프가 손에 있다. 이 실험의 되돌리기 수단은 태그가 아니라 그 파일일 수 있다.
- A-8 — 롤링 재시작이 무중단이라는 것이 전제다.
- 네임스페이스는 `keycloak-lab` 이다.
- 터미널 두 개를 열어 둔다.
**이 실험은 실제로 버전을 바꾼다.** 이미지 태그를 세 번 바꾸고(정방향 → 롤백 → 그리고 선택적으로 실패하는 방향) 마지막 것은 파드를 `CrashLoopBackOff` 로 만든다. 전 구간 약 20분이다. 그리고 이 실험은 백업 없이 시작하지 않는다 — 스키마가 움직이는 방향으로 가면 태그로는 못 돌아온다.
되돌리기는 전부 태그 한 줄이고, 각 단계 앞에서 먼저 읽는다.
```bash label="[kc-lab-1] 단계마다 먼저 읽어 두는 되돌리기 한 줄"
kubectl -n keycloak-lab set image statefulset/keycloak \
keycloak=quay.io/keycloak/keycloak:26.7.0
```
**이 되돌리기가 유효한 것은 `databasechangelog` 가 안 바뀌었을 때뿐이다.** 바뀌었으면 되돌리기는 「덤프 복구 + 태그 되돌리기」가 된다.
## 주입 전에 같은 명령으로 먼저 본다
**여기서 안 재면 나중에 다시 못 재는 값이 하나 있다** — 업그레이드 전의 `databasechangelog` 행 수다. 올린 뒤에는 그 값이 지워지기 때문에 「롤백해도 되는가」를 판정할 근거가 사라진다. 넓은 것부터 좁혀 간다.
```text
백업 → 현재 태그 → ★ 마이그레이션 수 → 세션 → 클러스터 뷰 → 가용성 대조군
```
### 1. 백업이 먼저다
**목적** — 태그로 못 돌아오는 경우의 되돌리기 수단을 손에 쥔다. D-1 의 절차 그대로다.
① 덤프를 뜨고 끝까지 쓰였는지 본다.
```bash label="[kc-lab-1] ① 덤프를 뜨고 마지막 세 줄을 본다"
kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
--clean --if-exists > /tmp/pre-upgrade.sql
ls -l /tmp/pre-upgrade.sql
tail -3 /tmp/pre-upgrade.sql
```
**예상 결과** — 실측은 이렇다(observed, `01-pre-upgrade.txt` 와 `followup/01-d2-forward-upgrade.txt`).
```text
백업: 396333 bytes
백업: 395375 bytes
```
크기와 `tail` 의 `dump complete` 를 본다. 두 값은 두 실행의 것이라 서로 다르다.
**왜 필요한가** — 이 파일이 없으면 이 실험을 하지 않는다.
**문제가 생기면** — `dump complete` 가 안 보이면 덤프가 잘린 것이고, D-1 의 덤프 검증 넷으로 돌아간다.
### 2. 지금 어떤 태그로 돌고 있는가
**무엇을 보는가** — StatefulSet 에 적힌 태그.
```bash label="[kc-lab-1] ① StatefulSet 의 태그를 본다"
kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].image}'; echo
```
**어디를 보나** — 실측은 이렇다(observed, `01-pre-upgrade.txt`).
```text
quay.io/keycloak/keycloak:26.7.0
```
**이 값이 뜻하는 것** — `latest` 로 되어 있으면 무엇에서 무엇으로 가는지 말할 수 없기 때문에 이 실험이 성립하지 않는다. 그리고 StatefulSet 에 적힌 것과 파드가 실제로 돌리고 있는 것은 다를 수 있다 — 적용 중이거나 롤아웃이 멈춰 있으면 그렇다.
```bash label="[kc-lab-1] ② 파드가 실제로 돌리는 이미지를 본다"
kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,IMAGE:.spec.containers[0].image,READY:.status.containerStatuses[0].ready,\
RESTARTS:.status.containerStatuses[0].restartCount | grep keycloak
```
두 파드의 `IMAGE` 가 서로 같고 StatefulSet 과도 같은지, `READY` 가 둘 다 `true`, `RESTARTS` 가 `0` 인지를 본다.
### 3. 마이그레이션 수 — 이 실험의 전부다
**무엇을 보는가** — `databasechangelog` 의 행 수. 처음 한 번은 읽는 형태로 친다.
```bash label="[kc-lab-1] ① psql 이 무엇을 돌려주는지 한 번 본다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from databasechangelog"
```
**어디를 보나** — 모양은 이렇다(observed).
```text
count
-------
210
(1 row)
```
비교용으로 값만 뽑는 형태도 익혀 둔다.
```bash label="[kc-lab-1] ② 값만 뽑는 형태"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from databasechangelog"
```
**어디를 보나** — 두 실행 모두 같은 값이 나왔다(observed).
```text
총 마이그레이션 수: 210
```
**이 값을 화면 밖에 적어 둔다.** 무엇이 마지막으로 적용됐는지도 한 번 본다. 나중에 「스키마가 언제 움직였나」를 물을 때 여기를 본다. 이 실험대는 개수만 셌고(observed), 아래는 가이드가 미검증으로 표시한 형태다(unknown).
```bash label="[kc-lab-1] ③ 마지막 다섯 줄을 본다 (미검증)"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select id, author, orderexecuted, dateexecuted from databasechangelog
order by orderexecuted desc limit 5"
```
`dateexecuted` 의 가장 최근 값이 이 데이터베이스의 스키마가 마지막으로 움직인 시각이다. 210 은 「이 데이터베이스는 여기까지 올라갔다」는 기록이고, 업그레이드 후에 211 이상이 되면 스키마가 움직였으며 그 순간부터 태그만으로는 못 돌아온다.
### 4. 세션도 센다
**무엇을 보는가** — 판올림이 세션을 건드리는지 판정할 재료.
```bash label="[kc-lab-1] 세션 수를 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session"
```
**어디를 보나** — 실측은 이렇다(observed).
```text
현재 세션: 4 (첫 실행)
세션 전: 3 (후속 실행)
```
**이 값이 뜻하는 것** — 0 이면 관리 콘솔에 한 번 로그인해서 만든다. 0인 채로 업그레이드하면 「세션이 유지되는가」를 판정할 수 없다.
### 5. 클러스터 뷰와 Infinispan 판
**무엇을 보는가** — 멤버 수와 괄호 안의 판 번호.
```bash label="[kc-lab-1] 클러스터 뷰 마지막 줄을 본다"
kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
```
**어디를 보나** — 실측은 이렇다(observed, `followup/01-d2-forward-upgrade.txt` 의 업그레이드 후 값).
```text
cluster: [keycloak-1-11418(v=16.0.14)|47] (2) [keycloak-1-11418(v=16.0.14), keycloak-0-58996(v=16.0.14)]
```
**이 값이 뜻하는 것** — 괄호 안의 판과 멤버 수 `(2)` 를 본다. Keycloak 태그를 바꾸면 함께 실린 Infinispan 판도 같이 바뀐다 — 후속 실행에서 `16.0.12` 에서 `16.0.14` 로 올라갔다. 클러스터 프로토콜 호환성 문제가 있다면 여기서 드러나므로, 업그레이드 후에 이 줄이 멤버 2로 다시 서는지 보는 것이 판정 항목 하나다.
### 6. 새 태그가 레지스트리에 있는지 본다
**무엇을 보는가** — 올라갈 곳이 실제로 있는가. 처음 한 번은 응답을 그대로 본다.
```bash label="[kc-lab-1] ① 응답을 통째로 본다"
curl -s "https://quay.io/api/v1/repository/keycloak/keycloak/tag/?limit=40&onlyActiveTags=true"
```
한 줄짜리 JSON 이 통째로 나온다. 어떤 필드가 있는지 보고 나서 자른다. 이 실험대는 `jq` 가 없어 이렇게 읽었고(observed), 가이드가 그 줄을 미검증으로 표시했다(unknown).
```bash label="[kc-lab-1] ② 이름만 잘라 본다 (미검증)"
curl -s "https://quay.io/api/v1/repository/keycloak/keycloak/tag/?limit=40&onlyActiveTags=true" \
| tr ',' '\n' | grep '"name"'
```
**이 값이 뜻하는 것** — `26.7.1` · `26.7.2` · `26.7.3` 이 있는지 본다. 처음 D-2 를 할 때 이걸 안 해서 정방향을 시험하지 못했다. 「26.7.0 보다 새 이미지가 없다」고 적었지만 실제로는 셋이나 있었다.
### 7. 가용성 대조군을 먼저 띄운다
**목적** — 주입 중에 나오는 비200 을 귀속할 수 있게 평시 오류율을 잡는다.
① 1초 간격으로 150회, 뒤에서 돌린다.
```bash label="[kc-lab-1] ① 폴링을 뒤에서 돌린다"
( for i in $(seq 1 150); do
printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
https://auth.hyeonworks.com/realms/master)"
sleep 1
done > /tmp/d2-avail.txt ) &
```
그만 재려면 `kill %1` 이다. **`&` 로 붙인 작업은 그것을 띄운 창의 것이라 `kill %1` 도 그 창에서만 듣는다.** 그래서 이 한 줄은 폴링용 창에서 치고, 아래 ② 부터 관찰 절까지는 다른 창에서 친다. 두 창 다 `kc-lab-1` 이다. 30초쯤 두고 먼저 평시를 센다.
```bash label="[kc-lab-1] ② 평시 응답 코드를 센다"
tr ' ' '\n' < /tmp/d2-avail.txt | grep -c 200
tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c
```
**예상 결과** — `uniq -c` 의 줄이 하나다.
**왜 필요한가** — 줄이 하나면 전부 같은 코드였다는 뜻이고, 두 줄 이상이면 평시에 이미 오류가 있기 때문에 그 상태로 주입하면 주입 중의 오류를 귀속할 수 없다. `--max-time 3` 을 기억해 둔다 — 관찰 절에서 나오는 `000` 이 이 값 때문이다.
**문제가 생기면** — 파일이 비어 있으면 백그라운드 작업이 죽은 것이고 `jobs` 로 본다.
## 주입
### 1. 태그를 26.7.3 으로 올린다
**목적** — 패치 릴리스로 한 칸 올리고 스키마가 움직이는지 본다.
① 시각을 남기고 태그를 바꾼다.
```bash label="[kc-lab-1] ① 시각을 남기고 태그를 바꾼다"
date '+%H:%M:%S 태그 변경'
kubectl -n keycloak-lab set image statefulset/keycloak \
keycloak=quay.io/keycloak/keycloak:26.7.3
```
**예상 결과** — 실측은 이렇다(observed, `followup/01-d2-forward-upgrade.txt`).
```text
시작: 15:22:59
statefulset.apps/keycloak image updated
```
`image updated` 한 줄을 본다. 이건 「적용됐다」가 아니라 「접수됐다」다. 실제 교체는 지금부터 일어난다.
② 롤아웃을 기다린다.
```bash label="[kc-lab-1] ② 롤아웃을 기다린다"
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
date '+%H:%M:%S 롤아웃 완료'
```
**예상 결과** — 실측은 이렇다(observed).
```text
partitioned roll out complete: 2 new pods have been updated...
완료: 15:24:26
```
87초 걸렸다.
**왜 필요한가** — `2 new pods have been updated` 가 나와야 교체가 끝난다.
**문제가 생기면** — `rollout status` 가 안 끝나고 매달려 있으면 그게 신호다. StatefulSet 은 파드 하나가 Ready 가 되기 전에는 다음 파드를 안 건드리기 때문에, 매달림은 곧 첫 파드가 안 뜬다는 뜻이다. 다른 터미널에서 `get pods -w` 로 본다.
## 주입 검증
결과를 해석하기 전에, 주입이 의도한 것을 정확히 했는지 먼저 본다.
```bash label="[kc-lab-1] ① 파드가 새 이미지를 돌리는가"
kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,IMAGE:.spec.containers[0].image,READY:.status.containerStatuses[0].ready,\
RESTARTS:.status.containerStatuses[0].restartCount | grep keycloak
```
실측은 이렇다(observed, `followup/01-d2-forward-upgrade.txt`).
```text
quay.io/keycloak/keycloak:26.7.3
keycloak-0 1/1 Running restarts=0
keycloak-1 1/1 Running restarts=0
```
`RESTARTS` 가 `0` 인 것이 중요하다. 교체는 새 파드를 만드는 것이지 같은 파드를 재시작하는 것이 아니다. `RESTARTS` 가 올라가 있으면 새 파드가 기동에 실패해서 재시작을 반복하고 있다.
실제로 새 파드인지는 나이로 본다.
```bash label="[kc-lab-1] ② 나이로 새 파드인지 본다"
kubectl -n keycloak-lab get pods -o wide | grep keycloak
```
실측은 첫 실행의 롤포워드 직후 값이다(observed).
```text
keycloak-0 1/1 Running 0 10m
keycloak-1 1/1 Running 0 28s
```
`AGE` 를 본다. 하나씩 갈리므로 나이가 다르다. 둘 다 방금 생긴 나이면 동시에 갈린 것이고, 그건 무중단이 아니다.
버전은 파드가 자기 입으로 말하게 한다.
```bash label="[kc-lab-1] ③ 로그가 말하는 판을 본다"
kubectl -n keycloak-lab logs keycloak-0 | grep -i 'Keycloak 26' | tail -1
```
실측은 이렇다(observed).
```text
Keycloak 26.7.3
```
이미지 태그와 다르면 태그가 재사용됐다 — 같은 태그가 다른 내용을 가리킨다.
## 관찰
### 1. 정방향은 무중단이었는가
```bash label="[kc-lab-1] 폴링 결과를 센다"
tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c
```
실측은 이렇다(observed, `followup/01-d2-forward-upgrade.txt`).
```text
200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
...
200 응답: 87 회
비200 : 0
```
줄이 하나뿐이고 그 값이 `200` 인지 본다. **`87` 을 `150` 과 견주지 않는다** — 루프는 150회지만 여기서 세는 것은 그 시점까지 파일에 쌓인 만큼이고, 판정은 「줄이 하나인가」로 선다. 이 숫자는 §3 을 시작하기 전에 적어 둔다. 정방향 업그레이드는 무중단이었다. 87회 요청이 전부 200 이고, 파드가 하나씩 갈리는 동안 남은 파드가 받았다. 다만 「무중단」은 관측 해상도에 달려 있다 — 이건 1초 간격·3초 타임아웃으로 잰 결과이고, 더 촘촘히 보면 더 보일 수 있다. D-4 에서 0.2초 간격으로 재니 다른 것이 보였다.
그림으로도 남아 있다 — 증거의 `d2-upgrade-window.png` 다. `cluster_size` 가 2 → 1 → 2 를 두 번 반복하고 파드별 `up` 시계열이 끝나고 새 시계열이 시작된다. 두 번인 것을 본다. 파드가 둘이므로 교체도 두 번이고 그때마다 클러스터가 잠시 한 명이 된다. 한 번만 보이면 두 파드가 동시에 갈렸다.
### 2. 스키마가 움직였는가 — 이 실험의 판정
주입 전에 친 것과 똑같은 명령이다.
```bash label="[kc-lab-1] 마이그레이션 수를 다시 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from databasechangelog"
```
실측은 이렇다(observed, `followup/01-d2-forward-upgrade.txt`).
```text
마이그레이션 후: 210 (전: 210)
세션 후: 3 (전: 3)
```
전과 후가 같은가를 본다.
| 결과 | 뜻 | 되돌리는 법 |
|---|---|---|
| 행 수가 그대로 | 스키마가 안 움직였다 | 태그만 되돌리면 된다 |
| 행 수가 늘었다 | 새 changeset 이 적용됐다 | 덤프 복구 + 태그 되돌리기 |
26.7.0 에서 26.7.3 은 패치 릴리스이므로 스키마가 그대로였다. 그래서 롤백이 가능하다는 가설이 섰고, 바로 시험한다.
### 3. 롤백을 시험한다
**목적** — 같은 스키마에서 태그만 되돌렸을 때 파드가 뜨는지 본다.
**폴링을 다시 띄우고 시작한다.** 주입 전 §7 의 루프는 1초 간격 150회라 150초면 끝난다. 정방향 롤아웃 하나가 87초였으니 여기까지 오는 동안 그 루프는 이미 끝나 있고, 다시 띄우지 않으면 §4 가 세는 것은 롤백 구간이 아니라 정방향 구간이다. **그러면 롤백에서 나온 `000` 한 건이 안 잡히고 화면은 「비200 0」으로 나온다** — 주입이 조용히 안 잡히는 모양이다.
⓪ §1 에서 센 `200 응답: 87 회 / 비200: 0` 을 먼저 손으로 적어 둔다. 루프가 `>` 로 파일을 잘라 쓰기 때문에 다시 띄우면 그 값은 화면에서 사라진다. 폴링 창에서 `jobs` 를 쳐 아직 돌고 있으면 `kill %1` 로 멈춘 뒤, 같은 창에서 친다.
```bash label="[kc-lab-1] ⓪ 폴링 창에서 다시 띄운다"
( for i in $(seq 1 150); do
printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
https://auth.hyeonworks.com/realms/master)"
sleep 1
done > /tmp/d2-avail.txt ) &
```
① 시각을 남기고 태그를 내린다.
```bash label="[kc-lab-1] ① 태그를 26.7.0 으로 되돌린다"
date '+%H:%M:%S 롤백'
kubectl -n keycloak-lab set image statefulset/keycloak \
keycloak=quay.io/keycloak/keycloak:26.7.0
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
```
**예상 결과** — 실측은 이렇다(observed, `followup/02-d2-rollback-same-schema.txt`).
```text
시작: 15:25:08
partitioned roll out complete: 2 new pods have been updated...
완료: 15:25:53
keycloak-0 1/1 Running restarts=0
keycloak-1 1/1 Running restarts=0
Keycloak 26.7.0
마이그레이션: 210
세션: 3
```
**왜 필요한가** — 파드가 뜬다. 이게 가설의 답이고, 스키마가 안 바뀌었으면 태그를 되돌리는 것으로 충분하다. 마이그레이션 210 그대로, 세션 3 그대로, 재시작 0 이다.
### 4. 전환 순간의 000 을 읽는다
```bash label="[kc-lab-1] 비200 이 어디에 있는지 본다"
tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c
grep -n '000' /tmp/d2-avail.txt
```
판정은 첫 줄이 한다. 둘째 줄의 `grep -n` 은 「있다/없다」까지만이다 — 루프가 `printf '%s '` 로 쓰기 때문에 이 파일은 개행이 없는 한 줄이고, 그래서 `1:` 하나에 전부 붙어 나온다. 몇 번째 요청이었는지는 이 명령으로 안 나온다.
실측은 이렇다(observed, `followup/02-d2-rollback-same-schema.txt`). 아래는 줄바꿈을 넣어 읽기 좋게 옮긴 것이고 화면은 한 줄이다.
```text
200 응답: 43 회 / 비200: 1
200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
200 200 200 200 000 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
200 200 200 200
비200 값: 000
=== 대조: 정방향 업그레이드 때는 ===
200: 87 / 비200: 0
```
`000` 이다. `500` 도 `502` 도 `503` 도 아니다. `000` 은 curl 이 HTTP 상태 코드를 하나도 못 받았다는 뜻이며, 여기서는 `--max-time 3` 을 넘겼다. 서버가 오류를 돌려준 것이 아니라 3초 안에 응답이 안 왔다. 파드 전환 순간 요청 하나가 3초를 넘겼고, 정방향에서 0회 역방향에서 1회다. 끊긴 것과 느린 것은 다르고, 그 구별은 코드가 아니라 `--max-time` 을 알고 있어야 선다.
### 5. 일부러 실패시킨다 — 선택 단계
**목적** — 행 수가 바뀌었을 때가 실제로 어떤 모양인지 본다. 위까지로 판정은 끝났으므로 가이드가 이 단계를 선택으로 둔다. 되돌리기는 태그 한 줄이고 먼저 읽는다.
**건너뛸 거면 §6·§7·§8 도 같이 건너뛰고 복구 절로 간다.** 그 셋은 전부 여기서 만든 실패한 기동을 읽는다 — §6 은 그 파드의 로그, §7 은 ready 주소가 하나로 줄어든 상태, §8 은 실패한 기동이 스키마를 건드렸는지다. §5 를 안 치면 §6 은 빈 출력이고 §7 의 ready 주소는 둘이며, 그 화면은 「아무 문제 없음」이 아니라 「볼 것이 없음」이다.
① 시각을 남기고 26.0 으로 내린다.
```bash label="[kc-lab-1] ① 26.0 으로 내린다"
date '+%H:%M:%S 26.0 으로 내린다'
kubectl -n keycloak-lab set image statefulset/keycloak \
keycloak=quay.io/keycloak/keycloak:26.0
```
② 이번에는 `rollout status` 로 기다리지 말고 눈으로 본다.
```bash label="[kc-lab-1] ② 파드 상태를 눈으로 따라간다"
kubectl -n keycloak-lab get pods -w
```
**예상 결과** — 실측은 이렇다(observed, `02-rollback-attempt.txt`).
```text
시각: 15:02:20
statefulset.apps/keycloak image updated
+20초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
+40초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
+60초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
+80초 keycloak-0:Running(1/1) keycloak-1:Error(0/1)
+100초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
+120초 keycloak-0:Running(1/1) keycloak-1:Error(0/1)
+140초 keycloak-0:Running(1/1) keycloak-1:CrashLoopBackOff(0/1)
+160초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
```
두 가지를 본다. `keycloak-1` 이 `Running(0/1)` 과 `Error` 와 `CrashLoopBackOff` 를 오가는 것과, `keycloak-0` 이 내내 `1/1` 인 것이다. `Running` 인데 `0/1` 인 상태를 「떴다」로 읽지 않는다 — 컨테이너 프로세스는 살아 있지만 readiness 를 통과하지 못했고 곧 죽는다. `Ctrl-C` 로 빠져나온다.
**왜 필요한가** — 이 모양을 봐 두면 롤아웃이 멈춘 것과 느린 것을 구별할 수 있다.
**문제가 생기면** — 파드가 이미 죽어 로그가 안 나오면 직전 컨테이너의 로그를 본다.
### 6. 왜 실패했는지 물어본다
```bash label="[kc-lab-1] ① Liquibase 관련 줄만 뽑는다"
kubectl -n keycloak-lab logs keycloak-1 | grep -iE 'liquibase|changeset|validation'
```
실측은 이렇다(observed, `03-roll-forward.txt`).
```text
2026-09-04 06:03:25,877 ERROR [org.keycloak.quarkus.runtime.cli.ExecutionExceptionHandler] (main) ERROR: liquibase.exception.ValidationFailedException: Validation Failed:
1 changesets check sum
2026-09-04 06:03:25,877 ERROR [org.keycloak.quarkus.runtime.cli.ExecutionExceptionHandler] (main) ERROR: Validation Failed:
1 changesets check sum
```
`1 changesets check sum` 이고 개수가 1이다. 26.7.0 이 적용한 changeset 하나를 26.0 도 알고 있는데 정의가 다르다. 같은 changeset 이 버전 사이에 수정됐고, Liquibase 는 스키마를 반쯤 아는 상태로 서비스하느니 기동 자체를 거부한다.
```bash label="[kc-lab-1] ② 파드가 이미 죽었으면 직전 로그를 본다"
kubectl -n keycloak-lab logs keycloak-1 --previous
```
### 7. 그런데 서비스는 살아 있다
```bash label="[kc-lab-1] 밖과 Service 와 StatefulSet 을 함께 본다"
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
kubectl -n keycloak-lab get statefulset keycloak
```
실측은 이렇다(observed, `03-roll-forward.txt`).
```text
https://auth.hyeonworks.com/realms/master HTTP 200
ready 주소: [10.42.1.140] ← 한 파드만
statefulset desired/ready/updated: 2 / 1 / 1
```
ready 주소가 하나, `desired/ready/updated` 가 `2 / 1 / 1` 이다. StatefulSet 의 롤링 업데이트가 사고를 절반에서 멈춰 줬다.
```text
keycloak-1 을 26.0 으로 → 기동 실패 → Ready 가 안 됨
└─ StatefulSet 은 keycloak-0 을 건드리지 않는다
└─ keycloak-0 (26.7.0) 이 계속 서비스한다
```
| replica 1 이었다면 | |
|---|---|
| 유일한 파드가 CrashLoopBackOff | 전면 장애 |
| 되돌리려면 사람이 개입 | 그동안 계속 다운 |
A-8 에서 「무중단은 replica ≥ 2 와 readiness 의 조합」이라고 썼는데, 여기서는 그 조합이 잘못된 배포를 절반에서 멈춰 줬다.
### 8. 실패한 기동이 스키마를 건드렸는지 센다
같은 명령을 세 번째로 친다.
```bash label="[kc-lab-1] 마이그레이션 수를 세 번째로 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from databasechangelog"
```
실측은 이렇다(observed). 네 칸짜리 이 줄은 원래 실행이 돌린 집계 스크립트의 출력이고, 위 명령은 그중 `migrations` 자리의 `210` 하나만 낸다. 나머지 셋을 한 줄로 내는 형태는 원본 가이드에 없다(unknown).
```text
realms|clients|migrations|sessions = 2|15|210|4
```
210 그대로다. Liquibase 가 검증 단계에서 멈췄으므로 스키마를 건드리지 못했고, 그래서 이 사고는 「태그만 되돌리면 되는」 쪽에 남았다. 여기가 두 경우를 가른다.
```text
✔ Liquibase 가 검증에서 멈췄다 → 이미지만 되돌리면 끝
✘ 이미 적용한 뒤였다 → DB 복구(D-1)까지 해야 한다
```
그래서 업그레이드 계획을 어떻게 쓰는가가 이 실험의 산출물이 된다.
```text
✘ "문제가 생기면 이미지 태그를 되돌린다"
└─ 스키마가 이미 바뀌었으면 옛 버전이 안 뜬다
✔ "업그레이드 전에 databasechangelog 를 세어 두고,
바뀌었으면 백업에서 DB 를 되돌린 뒤 태그를 되돌린다"
```
| 단계 | |
|---|---|
| 1 | 백업(D-1). 스키마가 움직인 뒤에는 이것만이 되돌리기 수단이다 |
| 2 | `databasechangelog` 행 수를 적어 둔다 — 나중에는 못 잰다 |
| 3 | 태그 변경 |
| 4 | 첫 파드만 관찰 — StatefulSet 이 멈춰 준다 |
| 5 | 행 수를 다시 센다. 그대로면 태그만 되돌려도 된다 |
| 6 | 늘었으면 DB 복구 + 태그 되돌리기 |
## 복구와 원상복구 확인표
### 1. 시작할 때의 태그로 되돌린다
**목적** — 실험대를 26.7.0 으로 돌려놓는다.
① 시각을 남기고 태그를 되돌린 뒤 롤아웃을 기다린다.
```bash label="[kc-lab-1] ① 태그를 되돌리고 롤아웃을 기다린다"
date '+%H:%M:%S 복귀'
kubectl -n keycloak-lab set image statefulset/keycloak \
keycloak=quay.io/keycloak/keycloak:26.7.0
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
```
**예상 결과** — 실측은 이렇다(observed, `03-roll-forward.txt`).
```text
statefulset.apps/keycloak image updated
partitioned roll out complete: 2 new pods have been updated...
keycloak-0 1/1 Running 0 10m
keycloak-1 1/1 Running 0 28s
realms|clients|migrations|sessions = 2|15|210|4
외부 진입점 HTTP 200
```
**왜 필요한가** — `kubectl rollout undo statefulset/keycloak` 도 있다. 이 실험은 쓰지 않았고(unknown), 쓰더라도 되돌아가는 것은 이미지뿐이다 — 스키마가 움직였다면 undo 도 같은 벽에 부딪힌다.
**문제가 생기면** — 폴링이 아직 돌고 있으면 `jobs` 로 보고 `kill %1` 로 멈춘다.
### 2. 아홉 항목을 대조한다
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 태그 | `get statefulset keycloak -o jsonpath='{.spec.template.spec.containers[0].image}'` | 시작할 때의 태그 |
| 파드 | `get pods -o wide \| grep keycloak` | 둘 다 `1/1 Running`, `RESTARTS 0` |
| 클러스터 | `logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)` |
| 마이그레이션 | `psql -tAc "select count(*) from databasechangelog"` | 210 — 시작할 때와 같다 |
| 세션 | `psql -tAc "select count(*) from offline_user_session"` | 시작할 때와 같다 |
| Service | `get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 둘 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
| 폴링 | `jobs` | 남아 있으면 `kill %1` |
| 덤프 | `ls -l /tmp/pre-upgrade.sql` | 남겨 둔다 |
`/tmp/d2-avail.txt` 는 이 표에 없다. 처분을 정하는 줄이 원본 가이드에 없어서(unknown) 여기에도 없다. 다음 런이 같은 루프를 띄우면 `>` 가 덮어쓰지만, 그때까지는 이 파일을 손으로 치우는 사람이 없다.
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| `rollout status` 가 안 끝난다 | 첫 파드가 안 뜬다. StatefulSet 이 기다린다 | 다른 터미널에서 `get pods -w` |
| 파드가 `Running` 인데 `0/1` | 프로세스는 살아 있고 readiness 미통과 | `logs` 를 본다. 「떴다」로 읽지 않는다 |
| 로그가 안 나온다 | 파드가 이미 죽었다 | `logs keycloak-1 --previous` |
| 업그레이드 전 행 수를 안 적었다 | 그 값은 이제 데이터베이스에 없다 | 덤프에서 복원한다 — 아래 |
| 비200 이 `000` 이다 | 서버 오류가 아니라 `--max-time` 타임아웃 | `--max-time` 값을 늘려 다시 재 본다 |
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33+ 에서 deprecated. 실측으로 이 경고를 봤다 | `get endpointslice -l kubernetes.io/service-name=...` |
| 두 파드가 동시에 갈렸다 | `podManagementPolicy: Parallel` | `get statefulset keycloak -o yaml \| grep podManagement` |
| 새 태그를 못 찾는다 | 레지스트리에서 확인 안 했다 | 주입 전 절차의 태그 목록 |
**업그레이드 전 행 수를 안 적었을 때**는 덤프 안에 그 테이블이 통째로 들어 있다. 가이드가 미검증으로 표시한 줄이다(unknown).
```bash label="[kc-lab-1] 덤프에서 행 수를 되찾는다 (미검증)"
sed -n '/^COPY public.databasechangelog /,/^\\\.$/p' /tmp/pre-upgrade.sql | wc -l
```
나온 수에서 2를 뺀다 — `COPY` 줄과 `\.` 줄이다. 이게 백업 시점의 행 수이고, D-1 의 덤프가 여기서 한 번 더 값을 한다.
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 15:0015:26 KST` 에 돈 두 번의 실행에서 나왔다(observed).
- (observed) 두 실행의 백업 크기 `396333 bytes` · `395375 bytes`, 시작 태그 `quay.io/keycloak/keycloak:26.7.0`, 마이그레이션 `210`, 세션 `4`(첫 실행)와 `3`(후속 실행), 업그레이드 후의 `ISPN000094` 줄과 판 `16.0.14`, 태그 변경 시각 `15:22:59` 와 롤아웃 완료 `15:24:26`, 새 파드의 `restarts=0` 과 나이 `10m`·`28s`, 로그의 `Keycloak 26.7.3`, 정방향 폴링 `200 응답: 87 회 / 비200 0`, 업그레이드 후 `마이그레이션 후: 210 (전: 210)`, 롤백의 `15:25:08``15:25:53` 과 `Keycloak 26.7.0` · 마이그레이션 210 · 세션 3, 롤백 폴링 `43 회 / 비200: 1` 과 그 1이 `000` 인 것, 역방향 시각 `15:02:20` 과 20초 간격 상태 여덟 줄, `1 changesets check sum` 두 줄, `ready 주소: [10.42.1.140]` 과 `desired/ready/updated: 2 / 1 / 1`, 실패한 기동 뒤에도 `210` 인 것.
- (observed) Grafana 화면 `d2-upgrade-window.png` 에 `cluster_size` 가 2 → 1 → 2 를 두 번 반복한 것.
- (unknown) `databasechangelog` 의 마지막 다섯 줄을 뽑는 쿼리, 레지스트리 태그 목록을 `tr`·`grep` 으로 자르는 줄, 덤프에서 행 수를 되찾는 `sed` 줄. 가이드가 전부 미검증으로 표시했다. `kubectl rollout undo` 도 이 실험은 쓰지 않았다.
- 비밀은 이 편에 나오지 않는다 — 이 실험이 다루는 값은 이미지 태그와 행 수라 옮길 비밀이 없다. 파드 이름·엔드포인트 주소·클러스터 멤버 이름은 식별자라 그대로 적었다.
- 가장 중요한 미검증이 첫 줄이다. 「행 수가 늘면 태그로 못 돌아온다」는 역방향(26.0)에서 관측한 실패를 근거로 한 추론이며(inferred), 실제로 행 수가 늘어난 뒤 되돌려 본 적은 없다. 26.7.x 사이에는 스키마 변경이 없어 이 실험대에서는 재현하지 못했다(unknown). 메이저 업그레이드를 할 때 이 실험을 다시 한다고 가이드는 적는다.
- 이 실험이 재지 않은 것 — 마이그레이션 도중에 죽으면 어떻게 되는지, 대규모 마이그레이션에 걸리는 시간. 데이터가 작아 순식간이라 잴 것이 없었다.
<!-- body:end -->
@@ -0,0 +1,541 @@
---
id: 186443e8-a32a-4a94-8609-845a4247d120
kind: SETUP
slug: reproduce-d3-secret-exposure
title: 카나리아 Secret 을 심고 네 경로에서 평문이 어디까지 나오는지 본다
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/186443e8-a32a-4a94-8609-845a4247d120/edit"
pinnedVersions:
- name: k3s 저장소 암호화
version: Disabled
- name: 판 번호
version: SSOT D-3 절에 한 줄도 없다
source:
- final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-3
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 카나리아 Secret 을 심고 네 경로에서 평문이 어디까지 나오는지 본다
지워도 되는 카나리아 Secret 하나를 심고 API·노드 디스크·파드 안·접근 제어 네 경로에서 평문이 어디까지 나오는지 재는 절차다. 파괴적인 단계가 없고, 복구는 그 Secret 을 지우는 한 줄이다.
## 관계
- **볼륨 없는 영속화와 유예 없는 키 회전**
이 절차가 판정하는 것은 값이 아니라 경로다. 그 값들을 어떻게 갈아 끼우는지는 그쪽이 다룬다.
- **up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다**
화면이 가려 준다고 감춰진 것은 아니라는 구별을, 지표 쪽에서 같은 모양으로 다룬다.
- **스키마를 통째로 지우고 덤프 하나로 되살아나는지 본다**
거기서는 덤프를 어디에 두느냐를 물었고, 여기서는 노드 디스크 하나가 모든 비밀이라는 답이 나온다.
- **서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다**
비밀이 샜을 때 실제로 해야 하는 일이 삭제가 아니라 회전인 까닭을 그쪽이 다룬다.
- **cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다**
같은 결론이 이 실험대의 cookie secret 에도 그대로 적용된다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 `kc-lab-1` 에서 친다. k3s 서버의 저장 파일도 이 노드에 있어서 디스크를 보는 경로를 여기서 칠 수 있다. 게스트(`kc-lab-1`/`kc-lab-2`)의 `sudo` 는 무암호이고, 호스트와 다르다.
가이드의 전제는 「`kubectl``sudo` 로 쓴다」인데 본문의 `kubectl` 줄에는 `sudo` 가 없고 `k3s`·`ls`·`grep` 에만 붙어 있다. 아래는 본문의 형태를 그대로 옮긴다. **먼저 `sudo` 없이 치고, 권한 때문에 막히면 그때 앞에 `sudo` 를 붙인다.** 어느 쪽인지는 kubeconfig 를 어디에 뒀나가 가른다 — D-1 은 같은 전제를 옮기면서 「kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다」를 괄호로 달아 두었고, D-3 은 그 괄호가 없다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` |
| 주입 수단 | 카나리아 Secret 하나 — `d3-canary` |
| 되돌리는 수단 | `delete secret d3-canary` 한 줄 |
| 전 구간 | 약 15분 |
| 파괴적인 단계 | 없다. 다섯 편 중 유일하다 |
| `jq` | 이 실험대 어디에도 없다. 이 절차도 쓰지 않는다 |
**이건 비밀을 화면에 띄우는 실험이다.** 몇 개의 명령은 비밀번호를 터미널에 그대로 찍는다. 그게 결론이라 피할 수 없지만, 그 값은 스크롤백·화면 공유·터미널 로그에 남는다. 가이드는 그래서 셋을 정해 두고 시작한다.
- 남의 진짜 비밀은 길이(`wc -c`)와 키 이름까지만 본다.
- 값을 찍어 봐야 하는 곳은 이 실험용으로 직접 만든 카나리아 Secret 을 쓴다. 지워도 되는 값이므로 찍어도 된다.
- 실측으로 실린 값들은 이 저장소의 매니페스트와 문서에 이미 적혀 있는 실험대 전용 값이고, 그래서 값 이름에 `change-me` 가 들어 있다.
**이 절차는 그 셋을 한 겹 더 지킨다.** 아래에서 API 로 뽑힌 네 값과 파드 안 환경변수 두 값은 키 이름과 길이까지만 적는다. 그리고 가이드가 `grep` 인자에 클라이언트 비밀 평문을 적어 둔 두 줄은 **카나리아 문자열로 바꿔** 실었다 — 명령의 모양은 같고 옮기면 안 되는 값만 빠졌다.
## 이 실험이 가르는 것
「비밀번호를 Secret 으로 옮겼습니다」는 리뷰에서 통과 도장을 받는 문장이라고 가이드는 적는다. 이 절차는 그 문장이 실제로 무엇을 막아 주는지를 네 경로로 나눠 판정한다. 가이드는 예측 칸을 넷 다 물음표로 비워 두고 시작한다.
| # | 경로 | 누가 쓰나 |
|---|---|---|
| ① | 쿠버네티스 API (`get secret`) | 클러스터에 접근하는 사람 |
| ② | 노드 디스크의 저장 파일 | 디스크·백업·스냅샷을 얻은 사람 |
| ③ | 파드 안의 프로세스 | `exec` 권한이 있는 사람, 크래시 덤프 |
| ④ | RBAC | 권한이 없는 주체 |
판정에 앞서 개념 둘을 가른다.
| | 목적 | 되돌리기 |
|---|---|---|
| 인코딩 (base64) | 바이너리를 텍스트로 안전하게 옮기기 | 키 없이 누구나 |
| 암호화 | 키 없이는 못 읽게 하기 | 키가 있어야 |
Secret 이 base64 를 쓰는 까닭은 감추려는 것이 아니라 YAML 에 임의 바이트를 담기 위해서다. 그런데 `kubectl describe` 가 값을 가려서 보여 주므로 「가려져 있구나」라는 인상이 남는다. 이 절차는 그 인상과 사실 사이의 거리를 잰다.
가이드는 끝났을 때 확인되는 것을 일곱으로 적는다. `describe``14 bytes` 만 보여 주는 것, 같은 값이 한 줄로 평문이 되는 것, 저장소 암호화가 꺼져 있는 것, 노드 디스크의 저장 파일 안에 평문이 있는 것, 그 `grep``0` 을 돌려주는데도 안전하지 않은 것, 파드 안에서는 그냥 환경변수인 것, 접근 제어는 실제로 막는 것.
**다섯째가 이 편의 요점이다.** 같은 파일에 같은 명령을 걸었는데 키에 따라 `2``0` 이 나왔고, `0` 을 「없다」로 읽으면 틀린다는 것을 가이드가 따로 한 절로 적는다.
## 전제와 되돌리기
- 명령은 `kc-lab-1` 에서 친다. k3s 서버의 저장 파일도 이 노드에 있고, 그래서 ②를 여기서 칠 수 있다.
- 게스트의 `sudo` 는 무암호다. 호스트와 다르다.
- 네임스페이스는 `keycloak-lab` 이다.
- B-6(key 회전)와 B-7(쿠키 비밀 회전)을 이미 했다면 이 실험의 결론이 그 key 들에도 그대로 적용된다는 것을 알고 있을 것이라고 가이드는 적는다.
**파괴적인 단계가 없는 편이다.** 만드는 것은 카나리아 Secret 하나뿐이고 복구 절에서 지운다. 전 구간 약 15분. 되돌리기는 한 줄이다.
```bash label="[kc-lab-1] 되돌리기 한 줄"
kubectl -n keycloak-lab delete secret d3-canary
```
## 주입 전에 같은 명령으로 먼저 본다
**무엇이 있는지부터 본다.** 카나리아를 심기 전에 목록과 `describe` 화면을 봐 둬야, 심은 뒤의 `describe` 가 같은 화면이라는 것이 보인다.
```text
Secret 목록 → describe 가 감추는 화면 → 키 이름만 → 길이만
```
### 1. Secret 이 몇 개 있는가
**무엇을 보는가** — 이름과 키 개수.
```bash label="[kc-lab-1] Secret 목록을 본다"
kubectl -n keycloak-lab get secret
```
**어디를 보나** — 실측은 이렇다(observed, `01-base64-not-encryption.txt`).
```text
bff-secrets Opaque keys=1
keycloak-lab-secrets Opaque keys=2
oauth2-proxy-secrets Opaque keys=3
```
**이 값이 뜻하는 것** — 이 실험대는 스크립트로 정리해 찍었다(observed). 위 명령을 그대로 치면 `NAME` · `TYPE` · `DATA` · `AGE` 네 칸이 나오고, `DATA` 열이 실측 줄의 `keys=` 에 해당한다. `TYPE` 이 `Opaque` 인 것도 본다 — 「불투명」이라는 이름이지만 그건 쿠버네티스가 내용 구조를 모른다는 뜻이지 감춘다는 뜻이 아니다.
### 2. describe 가 무엇을 감추는가
**무엇을 보는가** — 키 이름과 바이트 수.
```bash label="[kc-lab-1] describe 화면을 본다"
kubectl -n keycloak-lab describe secret bff-secrets
```
**어디를 보나** — 실측은 이렇다(observed, `01-base64-not-encryption.txt`).
```text
Type: Opaque
Data
====
KEYCLOAK_CLIENT_SECRET: 14 bytes
```
**이 값이 뜻하는 것** — 키 이름과 바이트 수만 나오고 값이 없다. 이 화면이 「Secret 은 감춰진다」는 인상의 출처다. `describe` 는 일부러 값을 안 찍는데, 그건 `describe` 라는 명령의 동작이지 저장이나 전송의 성질이 아니다. 이 구별이 이 편 전체의 축이다.
### 3. 남의 비밀은 키 이름과 길이까지만 본다
**무엇을 보는가** — 어떤 키가 들어 있는가. 값은 보지 않는다.
```bash label="[kc-lab-1] ① 키 이름만 뽑는다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets -o jsonpath='{.data}' \
| tr ',' '\n' | grep -o '"[A-Z_]*"'
```
**어디를 보나** — 모양은 이렇다(observed).
```text
"KC_BOOTSTRAP_ADMIN_PASSWORD"
"POSTGRES_PASSWORD"
```
`jq` 가 없어서 `tr` 과 `grep` 으로 자른다. D-2 가 레지스트리 태그 목록을 자를 때 쓴 것과 같은 수법이고, `jq` 가 없다는 전제가 여기서도 형태를 정한다.
```bash label="[kc-lab-1] ② 값 대신 길이를 잰다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c
```
**어디를 보나** — 모양은 이렇다(observed).
```text
22
```
**이 값이 뜻하는 것** — 숫자 하나가 나오고 값은 화면에 없다. 「Secret 이 제대로 들어갔는가」를 확인하는 데는 길이면 충분한 경우가 대부분이다. 배포가 안 될 때 진짜로 궁금한 것은 대개 「비었는가 아닌가」이지 값 자체가 아니다.
**문제가 생기면** — `wc -c` 는 개행까지 세므로 `base64 -d` 결과에 개행이 없으면 실제 길이와 같다. 값이 비었으면 `0` 이 나오고, `0` 은 「Secret 은 있는데 그 키가 비었다」는 뜻이며 배포 실패의 흔한 원인이라고 가이드는 적는다.
## 주입
여기부터 상태가 바뀐다. 바뀌는 것은 Secret 하나다.
### 1. 카나리아 Secret 을 심는다
**목적** — 저장 파일 안을 `grep` 할 때 쓸, 찾아도 아무 피해가 없는 문자열을 하나 만든다.
**왜 카나리아를 쓰는지가 먼저다.** 관찰 절에서 저장 파일 안을 `grep` 해야 하는데, 그러려면 찾을 문자열을 알고 있어야 한다. 진짜 비밀번호를 `grep` 인자로 쓰면 그 값이 셸 히스토리와 프로세스 목록(`ps` 로 다른 사용자에게도 보인다)에 남는다. 실험 대상이 값 자체가 아니라 경로이므로 이렇게 해도 결론은 같다.
① 카나리아를 만든다.
```bash label="[kc-lab-1] ① 카나리아 Secret 을 만든다"
kubectl -n keycloak-lab create secret generic d3-canary \
--from-literal=CANARY=d3-canary-zq7v-do-not-use
```
**예상 결과** — 모양은 이렇다(observed).
```text
secret/d3-canary created
```
**왜 필요한가** — 이 값은 아무 데도 쓰이지 않는다. 어떤 파드도 참조하지 않으므로 지워도 아무것도 안 깨진다. 값에 `do-not-use` 를 넣어 둔 까닭은 나중에 저장 파일 어딘가에서 이 문자열을 다시 만났을 때 무엇인지 알아보기 위해서다.
**문제가 생기면** — 이미 있으면 `AlreadyExists` 가 나온다. 지우고 다시 만든다.
## 주입 검증
```bash label="[kc-lab-1] 카나리아가 심겼는지 본다"
kubectl -n keycloak-lab get secret d3-canary
kubectl -n keycloak-lab describe secret d3-canary
```
모양은 이렇다(observed).
```text
Data
====
CANARY: 25 bytes
```
**이 화면은 이 실험대가 본 적이 없다**(unknown) — 카나리아를 심지 않고 실제 값으로 쟀기 때문이다. `--from-literal` 이 개행을 붙이지 않으므로 `25` 가 `d3-canary-zq7v-do-not-use` 의 글자 수와 그대로 맞는다. 가이드에 `26` 으로 적혀 있던 것을 고쳤다. 가이드 자신이 적어 둔 `--from-literal` 값의 글자 수와도 맞지 않는 수였다.
**여기서도 `describe` 는 바이트 수만 준다. 주입 전에 본 화면과 같다.** 값을 아는 것은 당신뿐이고, 그래서 다음 절의 비교가 성립한다 — 저장 파일에서 이 문자열을 찾았을 때 그것이 무엇인지 아는 사람이 당신 하나이기 때문이다.
## 관찰
네 경로를 하나씩 연다. ①은 API, ②는 노드 디스크, ③은 파드 안, ④는 RBAC(Role-Based Access Control, 역할 기반 접근 제어)다.
### 1. ① API — 한 줄로 읽힌다
값을 아는 카나리아로 먼저 해 본다.
```bash label="[kc-lab-1] ① 카나리아를 API 로 뽑아 본다"
kubectl -n keycloak-lab get secret d3-canary \
-o jsonpath='{.data.CANARY}' | base64 -d; echo
```
모양은 이렇다(observed).
```text
d3-canary-zq7v-do-not-use
```
주입 절에서 심은 값이 그대로 나온다. 같은 명령이 실제 비밀에도 그대로 듣고, 원래 실행이 네 개를 뽑은 결과가 증거 파일에 있다(observed, `01-base64-not-encryption.txt`). **값은 옮기지 않는다** — 네 줄 전부 `<Secret>/<키> = <평문>` 꼴로 나왔고, 값 자리에 있던 것은 이름에 `change-me` 가 들어간 실험대 전용 문자열이다. 원문은 증거 파일에 둔다.
```text
keycloak-lab-secrets/POSTGRES_PASSWORD = <평문 22자>
keycloak-lab-secrets/KC_BOOTSTRAP_ADMIN_PASSWORD = <평문>
bff-secrets/KEYCLOAK_CLIENT_SECRET = <평문 14자>
oauth2-proxy-secrets/COOKIE_SECRET_A = <평문>
```
실험대의 모든 비밀이 명령 네 줄로 나온다. `describe` 가 `14 bytes` 라고 했던 그 키의 값이 정확히 14자다 — 같은 값을 명령 둘이 다르게 보여 주고 있었고, 감춘 쪽은 `describe` 뿐이다.
**①은 막지 않는다.** base64 는 인코딩이고 `base64 -d` 는 누구나 칠 수 있다. 여기서 실질적인 방어선은 누가 이 명령을 칠 수 있는가이며, 그건 ④로 넘어가는 물음이다.
이 네 줄을 당신 환경에서 그대로 재현할 필요는 없다고 가이드는 적는다. 카나리아로 한 번 확인했으면 기제는 같고, 진짜 비밀은 앞에서 한 길이 확인으로 충분하다.
### 2. ② 저장소 — 노드 디스크에 평문이 있다
암호화 설정부터 본다.
```bash label="[kc-lab-1] ① 저장소 암호화 상태를 본다"
sudo k3s secrets-encrypt status
```
실측은 이렇다(observed, `02-at-rest.txt`).
```text
Encryption Status: Disabled, no configuration file found
```
`Disabled`, 그리고 `no configuration file found` 를 본다. 설정 파일이 아예 없다 — 껐다기보다 켠 적이 없다는 뜻이고, 이게 기본값이다.
```bash label="[kc-lab-1] ② 저장 파일 셋을 본다"
sudo ls -l /var/lib/rancher/k3s/server/db/
```
실측은 이렇다(observed, `02-at-rest.txt`).
```text
total 23336
drwx------ 2 root root 4096 Sep 2 09:12 .
drwx------ 8 root root 4096 Sep 4 03:23 ..
-rw-r--r-- 1 root root 13078528 Sep 4 06:05 state.db
-rw-r--r-- 1 root root 32768 Sep 4 06:06 state.db-shm
-rw-r--r-- 1 root root 10769712 Sep 4 06:06 state.db-wal
```
파일이 셋이다.
| 파일 | 무엇인가 |
|---|---|
| `state.db` | 본체 |
| `state.db-wal` | 아직 본체에 합쳐지지 않은 최근 쓰기 |
| `state.db-shm` | 공유 메모리 인덱스 |
k3s 는 etcd 대신 SQLite 를 쓴다. 「저장소(at rest)」가 놓이는 곳은 같다 — etcd 를 쓰는 클러스터라면 여기가 etcd 의 데이터 디렉터리다. `-wal` 이 10MB 나 되는 것을 봐 둔다 — 방금 만든 카나리아는 아직 본체에 없을 가능성이 높고, 그게 바로 아래에서 함정이 된다.
**파일 안을 찾아본다.** 카나리아부터다.
```bash label="[kc-lab-1] ③ 저장 파일에서 카나리아를 찾는다"
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db-wal
```
이 실험대는 카나리아 대신 실제 값으로 쟀다(observed). 위의 카나리아 형태는 가이드가 미검증으로 표시했다(unknown). 실제로 나온 결과가 이것이다(observed, `02-at-rest.txt`).
```text
=== ★ 저장 파일에서 비밀번호가 그대로 보이는가 ===
state.db 안의 평문 일치: 2
=== 평문이 저장 파일에 있다는 것을 눈으로 ===
client secret 평문 등장 횟수: 0
```
**두 줄의 값이 다르다. `2` 와 `0` 이다.** 같은 파일, 같은 명령, 다른 키인데 하나는 두 번 나오고 하나는 안 나온다. 가이드가 이 절에서 제일 중요하다고 적은 문장이 그다음에 온다.
> `grep` 이 `0` 을 돌려준 것은 「평문이 없다」가 아니라 「이 파일의 이 시점에 이 형태로는 못 찾았다」이다.
`2` 가 나온 순간 ②의 답은 이미 정해졌다 — 저장 파일에 평문이 있다. `0` 이 나온 키를 두고 「그건 안전한가 보다」라고 읽으면, 같은 파일에 평문이 들어 있는 것을 이미 본 뒤에 그러는 셈이다.
`0` 이 나왔을 때 다음에 볼 곳을 가이드가 적어 두긴 했는데, 이 실험은 원인을 가리지 않았다(unknown). 아래 두 줄과 표가 전부 미검증이다.
**카나리아로 쳐서 두 줄 다 `0` 이 나와도 여기서 멈추지 않는다.** 방금 만든 값이라 아직 `-wal` 에만 있거나 둘 다에 안 내려갔을 수 있고, 어느 쪽인지 가르는 절차를 이 실험이 밟지 않았다(unknown). ②의 판정은 당신 화면의 숫자가 아니라 위 실측의 `2` 가 이미 냈다. 아래 표로 한 번 더 보고, 숫자가 무엇이든 ③으로 넘어간다.
**가이드는 이 두 줄의 인자에 클라이언트 비밀 평문을 적어 두었다. 값은 옮기지 않는다** — 그리고 가이드 자신이 주입 절에서 「진짜 비밀번호를 `grep` 인자로 쓰면 셸 히스토리와 `ps` 에 남는다」고 적었으므로, 아래에는 카나리아 문자열을 넣었다.
```bash label="[kc-lab-1] ④ -wal 과 strings 로 한 번 더 본다 (미검증)"
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db-wal
sudo strings /var/lib/rancher/k3s/server/db/state.db | grep -c 'd3-canary-zq7v-do-not-use'
```
| 왜 안 나올 수 있나 | 확인 |
|---|---|
| 아직 `-wal` 에만 있다 | `-wal` 을 같이 `grep` |
| 값이 페이지 경계를 넘어 잘렸다 | `strings` 로 한 번 더 |
| 그 키가 그 시점에 없었다 | `get secret` 으로 존재 확인 |
`grep -c` 는 바이너리 파일에도 듣는다. 평소의 `grep` 은 바이너리를 만나면 `Binary file ... matches` 한 줄만 찍고 내용을 안 보여 주는데, `-c` 는 개수만 세므로 그대로 숫자가 나온다. 값 자체를 화면에 안 띄운다는 점에서도 이 형태가 맞다 — 여기서 궁금한 것은 「있는가」이지 「무엇인가」가 아니다.
그래서 무엇이 위험한지를 가이드가 넷으로 적는다.
| | |
|---|---|
| 노드 디스크를 얻으면 | 전 클러스터의 비밀 |
| 노드 백업/스냅샷 | 같은 것을 복사한다 |
| A-4 에서 본 `local-path` PVC | 같은 디스크에 있다 |
| D-1 의 덤프 | 같은 기계에 뒀다면 거기도 같이 |
D-1 에서 「덤프를 같은 장애 도메인에 두면 백업이 아니다」라고 했는데, 여기서는 노드 디스크 하나가 모든 비밀이다. 백업을 잘 챙길수록 비밀도 잘 복사된다.
k3s 는 `--secrets-encryption` 플래그로 켤 수 있다. 지금은 안 켜져 있고 이 절차는 켜지 않는다 — 켜는 것은 서버 재시작과 기존 Secret 재암호화를 수반하고, 이 실험대에서 시험하지 않았다(unknown).
### 3. ③ 파드 안 — 평범한 환경변수다
어느 파드를 볼지 먼저 정한다.
```bash label="[kc-lab-1] ① BFF 파드를 본다"
kubectl -n keycloak-lab get pods -l app=bff
```
이 실험대는 파드 이름을 직접 지정했다(observed). 아래 형태는 가이드가 미검증으로 표시한 줄이다(unknown).
**치기 전에 — 이 줄은 값을 화면에 찍는다.** 나오는 것은 카나리아가 아니라 `KEYCLOAK_CLIENT_SECRET` 과 `BFF_DB_PASSWORD` 의 평문이다. 바로 아래 실측을 `<평문 14자>` 로 가린 것은 이 문서이지 당신의 터미널이 아니다. 찍힌 값은 스크롤백과 셸 히스토리에 남고, 화면을 공유 중이면 보는 사람 모두에게 간다. 이 편이 「읽기 전에」에서 세운 「남의 진짜 비밀은 길이와 키 이름까지만 본다」를 이 줄 하나가 벗어나는데, 같은 결론을 값 없이 내는 명령은 가이드에 없다(unknown). 운영 클러스터에서는 치지 않는다. 실험대에서 쳤으면 복구 절의 `history` 확인까지 마치고, 운영 값을 찍었으면 회전(B-6·B-7)으로 이어 간다.
```bash label="[kc-lab-1] ② 파드 안의 환경변수를 본다 (미검증 · 평문이 화면에 찍힌다)"
kubectl -n keycloak-lab exec deploy/bff -- sh -c 'env | grep -iE "secret|password"'
```
실측은 이렇다(observed, `02-at-rest.txt`). **값은 옮기지 않는다** — 두 줄 다 `<환경변수>=<평문>` 꼴이고, 오른쪽에 있던 것이 ①에서 API 로 뽑은 바로 그 값이다.
```text
KEYCLOAK_CLIENT_SECRET=<평문 14자>
BFF_DB_PASSWORD=<평문 22자>
```
`env` 한 번이면 나온다. 그리고 클라이언트 비밀은 ①에서 API 로 뽑은 값과 같다 — 두 경로가 같은 평문에 닿는다.
`exec` 이 `deploy/bff` 로 안 되면(파드가 종료 중이거나 여럿이면) 이름을 골라 친다.
```bash label="[kc-lab-1] ③ Running 인 파드 이름을 고른다"
kubectl -n keycloak-lab get pod -l app=bff \
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}'; echo
```
**이 줄은 이름을 찍기만 한다.** 화면에 나온 `bff-...` 를 ②의 `deploy/bff` 자리에 그대로 넣어 다시 친다 — `exec` 다음의 대상만 바뀌고 `-- sh -c '...'` 부터는 같다. 가이드는 그 이름을 받아 치는 줄까지는 적지 않았다.
같은 파드 안의 다른 프로세스도 본다. 이게 환경변수의 진짜 성질이다. 아래도 가이드가 미검증으로 표시한 형태다(unknown).
**이 줄도 값을 화면에 찍는다.** ②에서 본 것과 같은 평문이 같은 자국을 남긴다. 여기서 확인하려는 것은 「같은 값이 또 나오는가」뿐이므로, 화면을 공유 중이거나 운영 클러스터에 붙어 있으면 치지 않고 ②의 결과로 판정한다.
```bash label="[kc-lab-1] ④ 다른 프로세스의 환경변수를 읽는다 (미검증 · 평문이 화면에 찍힌다)"
kubectl -n keycloak-lab exec deploy/bff -- \
sh -c 'tr "\0" "\n" < /proc/1/environ | grep -i secret'
```
같은 값이 나오는가를 본다. `/proc/<pid>/environ` 은 그 프로세스의 환경변수를 그대로 담고 있고, 같은 사용자 id 로 도는 아무 프로세스나 읽는다.
| 새는 경로 | |
|---|---|
| `kubectl exec` 권한이 있는 사람 | 바로 본다 |
| 같은 파드의 다른 프로세스 | `/proc/<pid>/environ` |
| 크래시 덤프 · 오류 리포트 | 환경변수를 함께 담는 도구가 많다 |
| 자식 프로세스 | 상속된다 |
볼륨으로 마운트하면 이 중 몇 가지가 줄어든다 — 파일 권한으로 제한할 수 있고, 환경변수 덤프에 안 들어간다.
```yaml
volumeMounts:
- name: secrets
mountPath: /etc/secrets
readOnly: true
```
줄어드는 것이지 없어지지는 않는다. `exec` 권한이 있으면 파일도 읽는다.
### 4. ④ RBAC — 유일하게 막는다
```bash label="[kc-lab-1] ① 기본 서비스계정이 Secret 을 읽을 수 있는지 묻는다"
kubectl auth can-i get secrets -n keycloak-lab \
--as=system:serviceaccount:keycloak-lab:default
```
실측은 이렇다(observed, `02-at-rest.txt`).
```text
default SA: no
```
`no` 한 단어다. 기본 서비스계정은 Secret 을 못 읽는데, 명시적으로 거부해서가 아니라 아무 권한도 주지 않았기 때문이다. RBAC 은 기본이 거부이고 Role 을 붙여야 할 수 있게 된다.
어떤 권한이 있는지 통째로 보는 형태도 가이드에 있고, 미검증이다(unknown).
```bash label="[kc-lab-1] ② 권한 목록과 Role 을 본다 (미검증)"
kubectl auth can-i --list -n keycloak-lab \
--as=system:serviceaccount:keycloak-lab:default
kubectl -n keycloak-lab get role,rolebinding
```
네 가지 중 유일하게 제 역할을 하는 것이 RBAC 다. 그러므로 실질적인 방어선은 「누가 `get secrets` 를 할 수 있는가」이며, 관리자 권한을 가진 사람에게는 아무 방어가 없다. A-0 의 관측 스택에서 `nodes/proxy` 서브리소스를 따로 줘야 했던 것처럼 Secret 접근도 리소스 단위로 나눌 수 있다고 가이드는 덧붙인다.
### 5. 네 경로를 한 표로 모은다
| # | 경로 | 감춰지는가 | 무엇이 뚫나 |
|---|---|---|---|
| ① | `get -o jsonpath \| base64 -d` | 아니다 | 클러스터 접근 권한 |
| — | `describe secret` | 값을 숨긴다 | 그래서 안전하다고 착각한다 |
| ② | 저장 파일(`state.db`) | 아니다. 암호화 꺼짐 | 노드 디스크·백업·스냅샷 |
| ③ | 파드 안 | 아니다. 평범한 환경변수 | `exec` · `/proc` · 크래시 덤프 |
| ④ | RBAC | 막는다 | 관리자 권한 |
「Secret 이니까 안전하다」는 네 가지 중 하나만 맞다. 그리고 ②·③ 은 쿠버네티스 API 를 한 번도 거치지 않고 평문에 닿는다.
무엇을 해야 하는가를 가이드가 다섯 단계로 적고, 이 실험대는 그중 아무것도 하고 있지 않다고 같은 표에 적는다.
| 단계 | 얻는 것 | 이 실험대 |
|---|---|---|
| ① 매니페스트에서 값을 빼고 `.example` 만 커밋 | git 유출을 막는다 | 안 함 |
| ② k3s `--secrets-encryption` 활성화 | 노드 디스크 유출을 막는다 | 안 함 (unknown) |
| ③ 환경변수 대신 볼륨 마운트 | 프로세스·덤프 유출을 줄인다 | 안 함 |
| ④ SealedSecret / 외부 KMS | 매니페스트에 암호문만 남는다 | 안 함 |
| ⑤ RBAC 최소화 | 유일하게 이미 동작하는 방어선을 좁힌다 | 기본값 그대로 |
실험 목적으로는 의도적이지만 그 사실을 기록해 두지 않으면 그대로 운영에 옮겨간다고 가이드는 적는다. 값 이름에 `change-me` 를 넣어 둔 것이 그 최소한의 표시다.
## 복구와 원상복구 확인표
### 1. 카나리아를 지운다
**목적** — 주입 전에 본 목록으로 되돌린다.
① 지우고 목록을 다시 본다.
```bash label="[kc-lab-1] ① 카나리아를 지우고 목록을 본다"
kubectl -n keycloak-lab delete secret d3-canary
kubectl -n keycloak-lab get secret
```
**예상 결과** — 모양은 이렇다(observed).
```text
secret "d3-canary" deleted
```
목록이 세 개로 돌아왔는가를 본다.
**왜 필요한가** — 어떤 파드도 이 Secret 을 참조하지 않으므로 지워도 아무것도 안 깨진다.
**문제가 생기면** — `NotFound` 가 나오면 이미 지워졌다.
### 2. 지웠다고 파일에서 없어지지는 않는다
아래는 미검증이고, 이 실험은 삭제 후를 재지 않았다(unknown).
```bash label="[kc-lab-1] 삭제 뒤에 저장 파일을 다시 본다 (미검증)"
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db-wal
```
`0` 이 나오면 「이 시점에 이 형태로는 안 보인다」이고, `0` 이 아니면 지운 Secret 의 평문이 아직 파일에 있는 것이다. 어느 쪽이든 관찰 절의 결론은 안 바뀐다 — 판정은 이미 `2` 에서 났다.
데이터베이스 파일은 지운 행의 공간을 즉시 0으로 덮어쓰지 않는다. 「Secret 을 지웠다」와 「그 값이 디스크에서 사라졌다」는 다른 사건이고, 비밀이 유출됐을 때 실제로 해야 하는 일이 삭제가 아니라 회전(rotation)인 까닭이 여기 있다 — B-6·B-7 의 주제다.
### 3. 네 항목을 대조한다
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 카나리아 | `kubectl -n keycloak-lab get secret d3-canary` | `NotFound` |
| Secret 목록 | `kubectl -n keycloak-lab get secret` | 세 개 |
| 파드 | `kubectl -n keycloak-lab get pods` | 전부 `Running` (아무것도 안 건드렸다) |
| 터미널 | `history \| tail -40` | 비밀번호가 찍힌 줄이 어디까지 남았는지 본다 |
**이 실험의 진짜 뒷정리는 스크롤백이다.** ①을 실제 비밀로 쳤다면 그 값이 터미널 버퍼와 셸 히스토리에 있다. 실험대 값이라 지금은 상관없지만, 같은 절차를 운영에서 하면 그게 유출 경로가 된다.
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| `grep` 이 `0` 인데 안전하다고 읽힌다 | `0` 은 「이 파일의 이 시점에 이 형태로는 못 찾았다」 | `-wal` 과 `strings` 로 한 번 더 |
| `grep` 이 `Binary file matches` 만 찍는다 | 바이너리 파일이다 | `-c` 를 쓴다(개수만). 값을 안 띄우는 이점도 있다 |
| `k3s secrets-encrypt` 가 없다 | 서버 노드가 아니다 | `kc-lab-1`(control-plane)에서 친다 |
| `state.db` 가 `Permission denied` | root 전용 디렉터리 | 게스트 sudo 는 무암호다. `sudo` 를 붙인다 |
| `exec deploy/bff` 가 실패한다 | 파드가 종료 중이거나 여럿이다 | `--field-selector=status.phase=Running` 으로 이름을 고른다 |
| `auth can-i` 가 `yes` 라고 한다 | 그 서비스계정에 Role 이 붙어 있다 | `get rolebinding -o wide` 로 누가 줬는지 본다 |
| 값이 `0 bytes` 로 나온다 | Secret 은 있는데 키가 비었다 | `describe` 의 바이트 수를 본다 — 배포 실패의 흔한 원인 |
| 비밀번호를 화면에 찍어 버렸다 | ①을 실제 값으로 쳤다 | 스크롤백·히스토리를 지우고, 운영이면 회전한다 |
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 15:0515:06 KST` 에 돈 한 번의 실행에서 나왔다(observed).
- (observed) Secret 세 개의 목록과 `keys=1` · `keys=2` · `keys=3`, `KEYCLOAK_CLIENT_SECRET: 14 bytes`, 카나리아의 `CANARY: 25 bytes`, `Encryption Status: Disabled, no configuration file found`, `db/` 의 파일 셋과 크기(`13078528` · `32768` · `10769712`), 저장 파일에서 평문 일치 `2` 와 같은 명령의 다른 키 `0`, 파드 안 `env` 두 줄, `default SA: no`.
- **판 번호가 이 편에 한 줄도 없다.** SSOT D층 머리말에는 B층 같은 버전 표가 없고, D-3 을 친 시각 `15:0515:06` 이 D-2 의 첫 실행(`15:0015:10`, 역방향 `26.0` 으로 `keycloak-1` 이 CrashLoop)과 겹쳐 그때 어느 판이 돌고 있었는지가 정해지지 않는다. 그래서 다른 편의 값을 끌어오지 않았다. 고정한 버전에 적은 것은 이 절차가 성립한 구성인 「저장소 암호화 꺼짐」 하나다.
- **비밀은 길이·존재·키 이름만 적는다** — API 로 뽑힌 네 값과 파드 안 환경변수 두 값은 평문이 그대로 찍힌 줄이라 이 절차로 옮기지 않았다. 키 이름과 `14 bytes` · `22` 라는 길이, 그리고 값 이름에 `change-me` 가 들어 있다는 모양까지가 옮긴 전부다. 원문은 증거 파일에 그대로 있다.
- **카나리아 값은 그대로 적었다** — `d3-canary-zq7v-do-not-use` 는 이 실험이 `grep` 인자로 쓰려고 직접 만든 문자열이고 복구 절에서 지운다. 값을 알아야 명령이 성립하므로 명령과 함께 남겼다. 찾아도 아무 피해가 없다는 것이 이 값의 목적이다. 그리고 가이드가 `0` 이 나온 키를 다시 찾을 때 쓴 두 줄에는 클라이언트 비밀 평문이 인자로 적혀 있었는데, 그 인자를 카나리아 문자열로 바꿔 적었다 — 명령의 모양은 같고 옮기면 안 되는 값만 빠졌다.
- (unknown) 카나리아를 저장 파일에서 찾는 두 줄, `0` 이 나왔을 때 `-wal`·`strings` 로 다시 보는 두 줄, `exec deploy/bff` 형태, `/proc/1/environ` 을 읽는 줄, `auth can-i --list`, 삭제 뒤에 다시 `grep` 하는 두 줄. 가이드가 전부 미검증으로 표시했다. 이 실험대는 카나리아 대신 실제 값으로 쟀고, 삭제 후는 재지 않았다.
- 이 실험이 확인하지 않은 것 — k3s `--secrets-encryption` 을 켠 뒤의 상태. 켜는 것은 서버 재시작과 기존 Secret 재암호화를 수반하고, 여기서는 시험하지 않았다. `0` 이 나온 키의 원인도 가리지 않았다. 볼륨 마운트·SealedSecret·외부 KMS 도 전부 안 했다.
<!-- body:end -->
@@ -0,0 +1,632 @@
---
id: 4f32b469-185a-4dea-8eba-599864a3b476
kind: SETUP
slug: reproduce-d4a-deploy-hook
title: deploy 훅 파일 하나를 넣고 nginx 워커가 저절로 갈리는지 확인한다
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/4f32b469-185a-4dea-8eba-599864a3b476/edit"
pinnedVersions:
- name: certbot
version: 5.7.0
source:
- final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-4a
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# deploy 훅 파일 하나를 넣고 nginx 워커가 저절로 갈리는지 확인한다
certbot 의 `deploy/` 훅에 두 줄짜리 파일 하나를 넣고, 갱신 뒤 nginx 워커가 사람 손 없이 갈리는지 확인하는 절차다. D-4 가 끝나 있어야 성립하고 인증서를 한 장 더 쓴다.
## 관계
- **deploy 훅 하나가 그 공백을 1~2초로 줄였다**
이 절차가 만드는 워커 교체와 1~2초를 그 기록이 결론으로 적는다. 결론이 필요하면 그쪽을 읽는다.
- **새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다**
이 절차가 고치는 결함을 그 기록이 잰다. 훅이 없을 때의 값이 거기 있다.
- **reload 를 사람이 아니라 deploy 훅이 부르게 한다**
이 절차가 넣는 파일이 그 결정의 내용이고, 여기서 나온 두 값이 그 결정의 근거다.
- **적용됐는지는 로그 문구가 아니라 상태로 판정한다**
certbot 이 성공한 훅에도 `ran with error output` 을 찍는 것을 여기서 만난다. 판정은 워커 PID 로 한다.
- **두 시계에서 온 값을 빼지 않는다**
여기서는 106초를 보정하지 않으면 뺀 값이 참값보다 약 106초 어긋나고, 보정을 반대로 걸면 음수 지연이 나온다.
- **갱신 타이머가 실제 갱신에서도 도는가**
이 절차는 강제 갱신으로만 훅을 시험한다. 타이머가 스스로 갱신하는 경로는 약 59일 뒤에야 조건이 성립한다.
- **인증서를 강제로 갱신하고 밖에서 보이는 일련번호가 언제 바뀌는지 잰다**
먼저 해 둬야 하는 편이다. 판정 기준과 시계 왜곡 값을 거기서 재 두고, 이 절차는 그 값을 그대로 쓴다. 주입 방향도 되돌리기도 반대라 절차를 겹쳐 적지 않았다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
기계가 둘이다. 밖에서 보는 `openssl``[dev]` 에서 치고, 주입은 전부 `[test-server]` 쪽이라 사람이 비밀번호를 친다.
| 무엇 | 값 |
|---|---|
| 관찰하는 기계 | 개발 머신 `dev`. 시계가 외부 기준과 맞는다 |
| 주입하는 기계 | 호스트 `test-server`. 시계가 **106초** 빠르다 |
| 바꾸는 것 | 파일 하나, 두 줄. `/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh` |
| certbot | `5.7.0`. **nginx 플러그인은 없다** |
| 인증서 | `--force-renewal` 을 또 한 번 친다. 주당 중복 인증서 5장 한도를 두 장 쓴 셈이 된다 |
| 원래 실행 | 2026-09-04 `12:27` UTC(실제) |
**시각 표기 규약은 D-4 와 같고 여기서는 훨씬 무겁다.** 이 절차는 1~2초를 재고, 106초 어긋난 시계를 섞으면 결과가 뒤집힌다.
| 표기 | 뜻 |
|---|---|
| `12:27:49 (실제)` | 보정한 값. 외부 기준과 일치 |
| `21:29:36 KST (ts)` | test-server 시계. 106초 빠르다 |
| `12:29:05 (dev)` | 개발 머신 시계. 보정 불필요 |
## 이 실험이 가르는 것
D-4 는 결함을 찾고 처방을 적어 두고 검증하지 않았다.
| D-4 가 남긴 항목 | 상태 |
|---|---|
| deploy 훅을 넣으면 자동 반영되는가 | 미측정. 훅은 아직 넣지 않았다 |
처방이 듣는지 모르는 채 「이렇게 고치면 된다」고 쓰는 것은 이 실험대가 스물세 번 경계해 온 실수라고 가이드는 적는다. 그래서 별도 실험으로 분리했다.
판정할 것은 셋이다.
| # | 질문 | 무엇으로 가르나 |
|---|---|---|
| ① | 훅이 실행되는가 | certbot 출력 |
| ② | nginx 가 정말 reload 되는가 | 워커 PID (문구가 아니라) |
| ③ | 얼마나 빠른가 | SCT ↔ 보정한 훅 시각 |
②가 이 편의 방법이고 ③이 이 편에서 가장 까다롭다. 판정을 문구로 하면 certbot 이 찍는 `ran with error output` 에 걸려 성공을 실패로 읽고, 시각을 보정하지 않으면 훅이 발급보다 먼저 돈 것이 되어 물리적으로 불가능한 값이 나온다.
절차를 끝까지 밟으면 훅 디렉터리가 비어 있는 데서 파일 하나를 넣는 것, certbot 이 `ran with error output` 이라고 찍는데 실패가 아닌 것, 마스터는 그대로고 워커만 자동으로 갈리는 것, 서빙 인증서가 곧바로 바뀌는 것, 발급에서 서빙까지 1~2초인 것, 보정하지 않으면 훅이 발급보다 `107초` 뒤에 돈 것으로 나오는 것, `notBefore` 가 발급 시각이 아닌 것을 자기 화면에서 보게 된다.
**무중단인지는 이 편이 재지 않는다.** 폴링과 전송 중 요청 감시는 D-4 에 있고, 거기서 잰 `8856건``845361` 바이트는 사람이 친 `nginx -s reload` 를 잰 값이다. 이 편이 재는 것은 훅이 거는 reload 가 실제로 일어나는가와 그 속도다.
## 전제와 되돌리기
- **D-4 를 먼저 한다.** 특히 두 가지가 없으면 이 절차는 성립하지 않는다 — 「reload 판정은 워커 PID 로 한다」는 기준, 그리고 두 기계 시계의 왜곡을 미리 재 둔 값.
- 관찰은 dev 에서, 주입은 `test-server` 에서 사람이 친다.
- 호스트의 `sudo` 는 비밀번호를 요구한다. 이 절차의 주입은 전부 그쪽이다.
- 이 호스트의 certbot 은 `5.7.0` 이고 nginx 플러그인은 없다.
**★ 인증서를 한 장 더 쓴다.** `certbot renew --force-renewal` 을 또 한 번 치므로, D-4 에서 한 번 썼다면 이번이 두 번째이고 Let's Encrypt 의 주당 중복 인증서 5장 한도를 두 장 쓴 셈이 된다. 세어 두고, 절차만 확인하려면 `--dry-run` 을 먼저 쓴다.
**되돌리기는 한 줄인데, 되돌리지 않는 편이 낫다.** 훅은 결함을 고치는 파일이라 지우면 D-4 의 상태로 돌아가고, 그 결함은 다음 실제 갱신(약 59일 뒤)에, 증상은 그 뒤 인증서 만료로 나타난다.
```bash label="[test-server] 훅을 지운다 — 지우면 D-4 의 상태로 돌아간다"
ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
```
## 주입 전에 같은 명령으로 먼저 본다
네 칸이고, 마지막 칸이 이 편의 답을 지킨다. D-4 의 여덟 칸을 다시 밟지 않는다 — 체인·이름 셋·타이머는 그쪽에서 이미 봤다.
```text
워커 PID → 서빙 인증서와 SCT → 훅 디렉터리가 비었나 → ★ 시계 왜곡
```
### 1. 출발점 워커 PID 를 적어 둔다
**무엇을 보는가** — 마스터 PID(Process ID, 프로세스 번호)와 워커 PID 두 숫자, 그리고 워커의 `lstart`.
```bash label="[test-server] nginx 프로세스 두 줄을 본다"
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
```
**어디를 보나** — 실측은 이렇다(observed, `01-hook-verified.txt`).
```text
585 1 ... Thu Sep 3 19:00:39 nginx: master process
28829 585 ... Fri Sep 4 18:00:35 nginx: worker process ← D-4 에서 사람이 reload 한 것
```
**이 값이 뜻하는 것** — 이 세 값을 적어 둔다. 관찰 절의 판정이 이 값과의 비교다.
워커 `28829` 는 D-4 에서 사람이 `nginx -s reload` 를 쳐서 생겼다. 마스터는 여전히 `585`, 어제 `19:00:39` 에 뜬 그대로다. 마스터가 유지되고 워커만 바뀌는 것이 reload 의 서명이라는 것을 D-4 에서 확인했고, 이 절차는 그 기준을 그대로 쓴다. **그러니까 출발점 자체가 사람이 건 reload 의 결과이고, 이 절차가 재려는 것은 훅이 거는 reload 다.**
### 2. 서빙 인증서와 SCT 를 적어 둔다
**무엇을 보는가** — 지금 밖으로 나가는 인증서의 일련번호와, 발급 시각의 외부 기준.
```bash label="[dev] ① 일련번호와 유효기간을 본다"
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -serial -dates
```
```bash label="[dev] ② 인증서 안의 SCT 를 본다"
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -ext ct_precert_scts | grep Timestamp
```
**어디를 보나** — `serial` 을 적어 둔다. 관찰 절에서 이 값이 바뀐다. `Timestamp` 두 줄은 CT(Certificate Transparency, 발급 사실을 공개 로그에 남기는 구조) 로그가 자기 시계로 서명한 시각이다.
**이 값이 뜻하는 것** — SCT(Signed Certificate Timestamp, CT 로그가 인증서에 박아 주는 서명된 시각)는 이 실험대의 두 기계와 무관한 제3의 기준이라, 관찰 절에서 시계 보정의 심판이 된다.
### 3. 훅 디렉터리가 비어 있는지 본다
**무엇을 보는가** — `deploy/` 안에 무엇이 있는지. root 전용이라 `sudo` 가 필요하다.
```bash label="[test-server] deploy 디렉터리를 본다"
ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/'
```
**어디를 보나** — 실측은 이렇다(observed, `d4-certificate-renewal/12-certbot-state.txt`).
```text
/etc/letsencrypt/renewal-hooks/deploy/:
total 8
drwxr-xr-x 2 root root 4096 2026-09-03 10:46:54.658474560 +0900 .
drwxr-xr-x 5 root root 4096 2026-09-03 10:46:54.658520760 +0900 ..
```
**이 값이 뜻하는 것** — `total 8` 과 `.` `..` 만 나온다. `sudo` 없이 치면 `Permission denied` 이고, 그 빈 출력을 「비어 있다」로 읽는 것이 D-4 에서 실제로 걸렸던 함정이다.
### 4. 시계 왜곡을 지금 잰다
**목적** — 이 절차의 답은 1~2초인데 시계가 106초 어긋나 있으면 그 답이 통째로 사라진다. 왜곡은 사후에 되짚을 수 없다.
**1.** SSH(Secure Shell, 원격 셸 접속) 왕복 직전·직후의 시각과 저쪽 시각을 세 번 찍는다.
```bash label="[dev] ① 세 번 잰다"
for i in 1 2 3; do
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
echo "A=$A B=$B C=$C"
done
```
세 줄 각각에서 `B` 와 `(A+C)/2` 의 차이를 눈으로 뺀다. 그리고 세 번의 값이 서로 비슷한가를 본다 — 흔들리면 네트워크 지연이 섞였고, 안정적이면 진짜 왜곡이다.
**2.** 어느 쪽이 맞는지는 외부 기준으로 가른다.
```bash label="[dev] ② 이쪽 시각"
date -u
```
```bash label="[dev] ③ 외부 기준 둘"
curl -sI https://www.google.com | grep -i '^date:'
curl -sI https://acme-v02.api.letsencrypt.org/directory | grep -i '^date:'
```
```bash label="[test-server] ④ 저쪽 시각과 NTP 동기 여부"
ssh test-server 'date -u; timedatectl show -p NTP -p NTPSynchronized'
```
**예상 결과** — 실측은 이렇다(observed, `01-hook-verified.txt`).
```text
dev → Google 차이 +0초
dev → Let's Encrypt ACME 차이 +0초
test-server → Google 차이 -105초 (즉 test-server 가 105초 빠르다)
ssh 왕복 왜곡 3회 측정: +106.1 / +106.1 / +106.1초 (안정적)
```
`NTPSynchronized` 를 본다. 이 호스트는 `no` 다. 세 번 다 `+106.1` 로 흔들리지 않았다는 것도 같이 본다.
```text
실제 시각 = test-server 시계 106초
```
**왜 필요한가** — Let's Encrypt 의 `Date:` 까지 보는 까닭은, 이 절차가 재는 사건의 한쪽 끝이 그쪽의 발급이기 때문이다. 그 기준과 dev 가 일치한다는 것을 확인해 두면 관찰 절의 비교가 같은 시간축 위에서 성립한다.
**문제가 생기면** — 세 번의 값이 흔들리면 회선이 조용할 때 다시 잰다.
## 주입
바꾸는 것은 파일 하나, 두 줄이다. 어느 디렉터리에 넣는가가 먼저 정해져야 한다.
| 디렉터리 | 언제 실행되나 |
|---|---|
| `pre/` | 갱신 시도 전 |
| `deploy/` | 실제로 갱신된 인증서가 있을 때만 |
| `post/` | 갱신 여부와 무관하게 매번 |
**왜 `deploy/` 인가.** 타이머는 하루 두 번 돈다. `post/` 에 넣으면 갱신이 없는 날에도 하루 두 번 nginx 를 reload 하게 된다 — 아무 이득 없이 워커만 갈아치운다. `deploy/` 는 certbot 이 `RENEWED_LINEAGE` 를 넘겨줄 때, 즉 실제로 갱신했을 때만 돈다. 없거나 틀리면 D-4 가 측정한 그대로 갱신은 성공하고 서빙은 안 바뀌며, 그 상태로 타이머는 `SUCCESS` 를 찍는다.
**`nginx -t &&` 를 앞에 두는 까닭**도 같은 종류의 안전장치다.
```sh
nginx -t && nginx -s reload
```
설정이 깨진 상태에서 `nginx -s reload` 를 보내면 마스터가 새 워커를 못 띄운다. `-t` 로 먼저 검사하고 통과할 때만 reload 한다. 실패하면 옛 워커가 서비스를 계속하므로 인증서는 안 바뀌지만 서비스는 죽지 않는다. 이 순서 하나가 「인증서가 안 바뀐다」와 「사이트가 내려간다」를 가른다.
`restart` 를 쓰지 않는 까닭도 같다. 실측(호스트)로 확인한 `nginx.service` 의 유효 설정은 `Restart=on-failure` · `RestartUSec=100ms` · `StartLimitBurst=5` · `StartLimitIntervalUSec=10s` 다(observed). 설정이 깨진 채 `restart` 를 걸면 10초 안에 5번 실패하고 systemd 가 포기한다 — nginx 가 내려간 채로 멈춘다.
### 5. 훅 파일을 만든다
**목적** — 사람이 비밀번호를 치며 실행할 명령을 짧게 만들려고, 파일 내용은 `sudo` 가 필요 없는 곳에서 미리 만들어 둔다.
**1.** 호스트에 붙는다.
```bash label="[test-server] ① 호스트 셸로 들어간다"
ssh test-server
```
**2.** 호스트의 셸에서 편집기로 연다.
```bash label="[test-server] ② 편집기로 연다"
nano /tmp/reload-nginx.sh
```
```sh
#!/bin/sh
nginx -t && nginx -s reload
```
**예상 결과** — 두 줄이 맞게 들어갔는가를 본다. `#!/bin/sh` 가 첫 줄이어야 한다.
```text
#!/bin/sh
nginx -t && nginx -s reload
```
**왜 필요한가** — 훅은 읽고 고칠 파일이지 한 번 찍고 마는 출력이 아니다. 파일을 열면 이미 무엇이 있는지 보이고, 같은 절차를 두 번 밟았을 때 `>>` 로 잘못 쳐서 줄이 두 번 들어가는 사고도 안 난다.
`/tmp` 를 여기서 쓰는 것은 괜찮은데, 이건 당신의 대화형 셸이 쓰는 `/tmp` 이기 때문이다. 다만 `certbot-renew.service` 는 `PrivateTmp=true`(실측(호스트), observed)라 그 서비스가 보는 `/tmp` 은 다른 곳이다. 훅이 나중에 `/tmp` 에 로그를 남기도록 만들면 타이머가 돌렸을 때 그 파일을 밖에서 찾을 수 없다(unknown — 이 실험은 훅에 로그를 넣지 않았다). 훅의 로그는 `logger` 로 저널에 보내거나 `/var/log` 아래에 쓴다.
**3.** 호스트 셸에서 나온다. **다음 절이 다시 `ssh` 로 들어가므로 여기서 나오지 않으면 test-server 안에서 test-server 로 또 붙게 된다.**
```bash label="[test-server] ③ 호스트 셸에서 나온다"
exit
```
**이 실험대는 셸로 파일을 만들었다**(observed). 편집기로 여는 형태는 이 형태로 실행하지 않았다(unknown). **아래 두 줄은 호스트 셸이 아니라 dev 머신에서 친 것이다** — 위 ①로 들어갔다면 이 형태는 쓰지 않는다.
```bash label="[dev 머신] 이 실험대가 실제로 친 형태 (observed)"
ssh test-server "printf '#!/bin/sh\nnginx -t && nginx -s reload\n' > /tmp/reload-nginx.sh"
ssh test-server 'cat /tmp/reload-nginx.sh'
```
**문제가 생기면** — 첫 줄이 `#!/bin/sh` 가 아니면 certbot 이 훅을 실행하지 못한다. 파일을 다시 연다.
### 6. 훅을 설치한다
**목적** — `deploy/` 에 실행 권한과 함께 넣는다. 여기부터 사람이 비밀번호를 친다.
**1.** tty 를 붙여 호스트에 붙는다.
```bash label="[test-server] ① tty 를 붙여 들어간다"
ssh -t test-server
```
**2.** 호스트의 셸에서 설치한다.
```bash label="[test-server] ② 실행 권한과 함께 설치한다"
sudo install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/
```
**예상 결과** — 아무것도 안 나오면 성공이다.
**왜 필요한가** — `install -m755` 가 복사와 권한 설정을 한 번에 한다. `x` 비트가 없으면 certbot 이 훅을 그냥 건너뛴다.
**3.** 호스트 셸에서 나온다. 바로 아래 「이 실험대가 실제로 친 형태」가 dev 머신에서 치는 줄이라 한 번 나와야 한다. 그다음 §7 은 다시 호스트 셸 안에서 친다.
```bash label="[test-server] ③ 호스트 셸에서 나온다"
exit
```
**이 실험대는 설치와 강제 갱신을 한 줄로 쳤다**(observed). 사람이 비밀번호를 한 번만 치게 하려고 그렇게 쳤다. **아래는 호스트 셸이 아니라 dev 머신에서 친 것이다** — 위 ①로 들어갔다면 이 형태는 쓰지 않는다.
```bash label="[dev 머신] 이 실험대가 실제로 친 형태 (observed)"
ssh -t test-server 'sudo sh -c "install -m755 /tmp/reload-nginx.sh \
/etc/letsencrypt/renewal-hooks/deploy/ && certbot renew --force-renewal \
> /tmp/d4a-renew.txt 2>&1; chmod 644 /tmp/d4a-renew.txt; tail -25 /tmp/d4a-renew.txt"'
```
읽기는 어렵다고 가이드가 스스로 적는다. 처음 할 때는 한 줄씩 치고 익숙해지면 합친다. 한 줄로 합치면 설치와 강제 갱신이 한 명령 안에 들어가서, 중간에서 멈췄을 때 훅이 깔린 상태인지 아닌지를 따로 봐야 한다.
**문제가 생기면** — `sudo` 가 조용히 빈 결과를 주면 tty 가 붙지 않았다. `ssh -t` 로 다시 붙는다.
## 주입 검증
갱신을 걸기 전에 훅이 제자리에, 실행 가능한 상태로 있는지 본다. 한 번뿐인 강제 갱신을 오타 때문에 날리지 않기 위해서다.
**§7 부터 §9 까지는 호스트 셸 안에서 친다.** §6 ③에서 나왔으므로 §6 ①의 `ssh -t test-server` 로 다시 들어간 뒤 아래를 친다 — `sudo` 가 비밀번호를 물으니 `-t` 가 붙은 쪽으로 들어간다. 이 세 절의 명령에는 앞에 `ssh` 가 없는데, 호스트 셸 안에 있다는 전제이기 때문이다. dev 머신에서 그대로 치면 `/etc/letsencrypt/` 가 없어 엉뚱한 결과를 보게 된다.
### 7. 훅이 제자리에 있는지 본다
**무엇을 보는가** — 파일의 권한·위치·소유자.
```bash label="[test-server] deploy 디렉터리를 다시 본다"
sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/
```
**어디를 보나** — 형태는 이렇다(모양은 observed).
```text
total 4
-rwxr-xr-x 1 root root 40 Sep 4 21:2x reload-nginx.sh
```
세 가지를 본다. `x` 비트(`-rwxr-xr-x`)가 있는가 — 없으면 certbot 이 그냥 건너뛴다. 디렉터리가 `deploy/` 인가 — `post/` 에 들어가면 매번 돈다. 소유자가 `root` 인가.
### 8. 훅을 손으로 한 번 돌린다
**목적** — 가장 확실한 사전 점검이다. 훅 스크립트가 실제로 도는지 본다.
**1.** 훅을 직접 실행한다.
```bash label="[test-server] 훅을 손으로 실행한다"
sudo /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
**예상 결과** — 형태는 이렇다(모양은 observed).
```text
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
```
**왜 필요한가** — `test is successful` 을 본다. 이때 워커 PID 도 바뀌는데, 이 스크립트는 실제로 reload 하기 때문이다. 그러므로 §1 의 `ps` 줄을 여기서 한 번 더 쳐서 새 워커 PID 로 바꿔 적는다 — 지금은 호스트 셸 안이므로 그 줄에서 `ssh test-server` 를 떼고 큰따옴표 안쪽만 친다. 가이드는 이 재측정을 지시만 하고 명령을 다시 싣지 않았다. 건너뛰면 §10 의 「워커가 바뀌었다」가 훅이 한 것인지 여기서 손으로 돌린 것이 한 것인지 갈리지 않는다.
**문제가 생기면** — `nginx -t` 가 실패하면 `&&` 뒤가 안 돌고 워커도 안 바뀐다. `nginx.conf` 를 고친 뒤 다시 친다.
certbot 이 훅을 부르는지 먼저 보는 형태도 있는데, 이 실험대는 곧바로 강제 갱신을 했다(observed). 아래는 가이드가 미검증으로 표시한 줄이다(unknown).
```bash label="[test-server] dry-run 으로 훅 호출만 본다 (unknown)"
sudo certbot renew --dry-run
```
출력에 `Running deploy-hook command` 계열의 줄이 나오는가, 그리고 `simulated renewals` 요약을 본다. dry-run 은 인증서를 발급하지 않고 한도도 안 깎는다. 훅이 호출되는지까지만 말해 주고, 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다. 그래서 관찰 절이 필요하다.
## 관찰
### 9. 강제 갱신을 친다
**목적** — 인증서 한 장을 실제로 발급하고, 훅이 거기에 붙어 도는지 본다. 되돌릴 수 없다.
**1.** 시작 시각을 남긴다. 호스트에서 찍은 것은 `(ts)` 이고 106초 빠르다.
```bash label="[test-server] ① 시작 시각을 ts 시계로 남긴다"
date -u '+%H:%M:%S 갱신 시작 (ts 시계)'
```
**2.** 강제 갱신을 건다.
```bash label="[test-server] ② 강제 갱신"
sudo certbot renew --force-renewal
```
**예상 결과** — 실측은 이렇다(observed, `02-certbot-with-hook.txt`).
```text
Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Renewing an existing certificate for auth.hyeonworks.com and 2 more
Hook 'deploy-hook' ran with error output:
2026/09/04 21:29:36 [warn] 37250#37250: could not build optimal types_hash, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
2026/09/04 21:29:37 [warn] 37251#37251: could not build optimal types_hash, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size
2026/09/04 21:29:37 [notice] 37251#37251: signal process started
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Congratulations, all renewals succeeded:
/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success)
```
**왜 필요한가** — 다섯 줄을 하나씩 읽는다.
| 줄 | 실제 의미 |
|---|---|
| `Hook 'deploy-hook' ran with error output:` | 훅이 실행됐고, stderr 에 뭔가 있었다 |
| `[warn] could not build optimal types_hash` | nginx 의 일반 경고. 갱신과 무관 |
| `nginx: … test is successful` | `nginx -t` 통과 |
| `[notice] … signal process started` | `nginx -s reload` 가 신호를 보냈다 |
| `Congratulations, all renewals succeeded` | 갱신 성공 |
`ran with error output` 은 실패가 아니다. certbot 은 훅이 stderr 에 무엇이라도 쓰면 이 문구를 붙이는데, 종료 코드를 말하지 않는다. 여기서 stderr 로 나간 것은 nginx 의 `types_hash` 경고뿐이고 내용은 전부 성공이다.
로그에서 `error` 를 grep 하는 감시를 걸어두면 성공한 훅을 실패로 오독한다. 반대 방향도 위험한데, 이 실험은 훅이 진짜로 실패했을 때 certbot 이 무엇을 찍는지 재지 않았다(unknown). 그래서 판정은 문구가 아니라 워커 PID 로 한다.
**문제가 생기면** — 발급 한도에 걸렸으면 이번 주에 중복 인증서 5장을 이미 썼다는 뜻이다. 다음 주까지 기다린다.
**여기서 호스트 셸을 나온다.** §10 부터의 명령은 앞에 `ssh test-server` 가 붙어 있거나 `[dev]` 라벨이 달려 있고, 둘 다 dev 머신에서 친다. `[dev]` 가 붙은 `openssl s_client` 세 줄은 호스트 안에서 쳐도 그대로 돌아가므로 잘못 친 것이 화면에 드러나지 않는다. 그런데 §12 의 보정은 「dev 에서 본 시각은 그대로 쓰고 호스트에서 본 시각에서만 106초를 뺀다」 위에 서 있어서, 어느 기계에서 쟀는지를 섞으면 뺄 값이 어긋나고 1~2초짜리 답이 통째로 없어진다.
### 10. 워커 PID 로 판정한다
**무엇을 보는가** — 1번과 8번에서 적어 둔 값과 지금의 값.
```bash label="[test-server] nginx 프로세스 두 줄을 본다"
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
```
**어디를 보나** — 실측은 이렇다(observed, `03-after-state.txt`).
```text
585 1 95412 Thu Sep 3 19:00:39 2026 nginx: master process /usr/bin/nginx
37252 585 74 Fri Sep 4 21:29:36 2026 nginx: worker process
```
| 무엇 | 전 | 후 | 판정 |
|---|---|---|---|
| 마스터 | `585` | `585` | 그대로 |
| 워커 | `28829` | `37252` | 바뀌었다 |
| 워커 `lstart` | `Fri Sep 4 18:00:35 (ts)` | `Fri Sep 4 21:29:36 (ts)` | 방금 떴다 |
| 워커 `etimes` | — | `74` | 74초 전 |
**이 값이 뜻하는 것** — 마스터 PID 는 유지되고 워커만 바뀌었다. D-4 에서 「reload 되었는가」를 판정하려고 세운 방법이 그대로 작동한다. 그리고 이번에는 사람이 아니라 훅이 했다 — 왼쪽 칸의 워커 `28829` 는 D-4 에서 사람이 친 `nginx -s reload` 가 만들었고, 오른쪽 칸의 `37252` 는 `deploy/` 훅이 만들었다.
`etimes 74` 를 같이 보는 까닭은 PID 가 우연히 재사용될 수 있기 때문이다. `lstart` 와 `etimes` 가 「방금」을 가리켜야 진짜 새 워커다.
### 11. 서빙 인증서가 바뀌었는지 본다
**무엇을 보는가** — 밖으로 나가는 인증서의 일련번호와 SAN(Subject Alternative Name, 한 인증서가 담는 이름 목록).
```bash label="[dev] 일련번호와 이름 셋을 본다"
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -serial -dates -ext subjectAltName
```
**어디를 보나** — 실측은 이렇다(observed, `03-after-state.txt`).
```text
serial=06F3E0EF4D1BB03DE58130EAAD1176101373
notBefore=Sep 4 11:29:18 2026 GMT
notAfter=Dec 3 11:29:17 2026 GMT
X509v3 Subject Alternative Name:
DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com
```
**이 값이 뜻하는 것** — `serial` 이 2번에서 적어 둔 값과 다르다. D-4 의 인증서(`06C7CB…EA1D`)에서 바뀌었고 SAN 은 세 이름 그대로다. 훅 하나로 ①②가 끝났고 남은 것은 「얼마나 빨랐나」다.
### 12. 시계를 보정해 발급과 서빙 사이를 잰다
**무엇을 보는가** — 가진 시각은 셋이고 두 개는 다른 시계에서 왔다.
| 사건 | 원래 값 | 어느 시계 |
|---|---|---|
| 인증서 발급 | SCT `Sep 4 12:27:49.054 GMT` | CT 로그 (독립) |
| 훅의 `nginx -t` | 로그 `2026/09/04 21:29:36` | (ts) |
| 새 워커 기동 | `lstart Fri Sep 4 21:29:36` | (ts) |
| 훅의 `nginx -s reload` | 로그 `2026/09/04 21:29:37` | (ts) |
```bash label="[dev] 새 인증서의 SCT 를 본다"
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -ext ct_precert_scts | grep Timestamp
```
**어디를 보나** — 실측은 이렇다(observed, `01-hook-verified.txt`).
```text
Signed Certificate Timestamp: Sep 4 12:27:49.054 2026 GMT
Signed Certificate Timestamp: Sep 4 12:27:49.048 2026 GMT
```
`(ts)` 값에서 106초를 뺀다.
```text
12:27:49.05 인증서 발급 ← SCT (외부 권위 기준)
12:27:50 훅 nginx -t ← 로그 21:29:36 KST(ts) 106초
12:27:50 새 워커 37252 기동 ← lstart 21:29:36 KST(ts) 106초
12:27:51 훅 nginx -s reload ← 로그 21:29:37 KST(ts) 106초
```
**이 값이 뜻하는 것** — 발급에서 서빙까지 1~2초다.
독립 시계인 SCT 가 보정한 훅 시각의 1초 앞에 놓이므로, 보정이 자기 검증된다.
보정하지 않으면 어떻게 되는지는 두 갈래다. 원본 가이드는 이 대목을 한 문장에 붙여 놓았으므로 갈라 적는다.
| 어떻게 계산하나 | 나오는 값 | 무엇이 틀렸나 |
|---|---|---|
| 그냥 뺀다 (`12:29:36 12:27:49`) | `+107초` | 훅이 발급보다 107초 뒤로 보인다. 참값 1~2초보다 약 106초 크다 |
| 106초를 반대쪽에 건다 | 훅이 발급보다 앞 | 음수 지연이다. 훅은 갱신이 끝나야 도니 성립하지 않는다 |
원본은 앞 칸의 수치(`+107초`)에 뒷 칸의 결론(「104초 먼저」)을 이어 붙였다. `+107초` 는 「뒤」이므로 거기서 「먼저」가 나오지 않고, `104` 라는 수가 어느 계산에서 나왔는지도 그 문서에 남아 있지 않다(unknown). 고쳐 쓰지 않고 어긋난 채로 적어 둔다. 어느 계산으로 가든 두 시계에서 온 값을 그대로 빼면 안 된다는 것은 같기 때문이다.
음수 지연이 나오면 계산이 아니라 시계를 의심한다. 그 의심을 가르는 것은 제3의 시계다 — 여기서는 CT 로그의 SCT 였다.
### 13. `notBefore` 를 발급 시각으로 쓰지 않는다
**무엇을 보는가** — 인증서에 적힌 `notBefore` 와 SCT 의 차이.
인증서에는 `notBefore=Sep 4 11:29:18` 이라고 적혀 있지만 이건 발급 시각이 아니다. Let's Encrypt 는 `notBefore` 를 정확히 한 시간 백데이트한다 — 클라이언트 시계가 조금 빨라도 「아직 유효하지 않은 인증서」가 되지 않게 하려고 그렇게 적는다. 그리고 한 시간을 더한 값(`12:29:18`)을 발급 시각으로 그대로 쓰지도 않는다. 이 실험대의 두 인증서에서 SCT 는 그보다 일관되게 약 89초 앞섰다.
| 인증서 | `notBefore` | `notBefore` + 1시간 | SCT | 차이 |
|---|---|---|---|---|
| D-4 이전 것 | `Sep 3 00:47:23` | `01:47:23` | `01:45:53.18` | 약 89.8초 |
| D-4a 새것 | `Sep 4 11:29:18` | `12:29:18` | `12:27:49.05` | 약 88.9초 |
**이 값이 뜻하는 것** — 이 차이의 원인은 이 실험이 규명하지 않았다(unknown). 다만 시각의 기준으로는 SCT 를 쓴다. 그것이 보정을 자기 검증한 값이기 때문이다. `notBefore` 를 그대로 발급 시각으로 쓰면 한 시간을 잃는다.
### 14. D-4 와 나란히 놓는다
| 무엇 | 훅 없음 (D-4) | 훅 있음 (D-4a) |
|---|---|---|
| 갱신 → 서빙 | `2305초` = `38분 25초` | 1~2초 |
| 무엇이 reload 했나 | 사람이 친 `nginx -s reload` | certbot deploy 훅 |
| 아무도 안 했다면 | 다음 nginx 재시작까지 = 사실상 무기한 | 해당 없음 |
| 차이 | | 약 1150배 |
바뀐 것은 파일 하나, 두 줄이다.
**부수 정정이 하나 딸려 나왔다 — D-4 의 `2199초` 는 틀렸다.** 이 실험이 시계를 재는 바람에 앞 실험의 숫자가 정정됐다. D-4 에서 적은 `2199초`(`36분 39초`)는 `archive/cert2.pem` 의 mtime(test-server 시계)과 일련번호 관측(dev 시계)을 그대로 뺀 값이었다.
| 사건 | 시각 (실제 UTC) |
|---|---|
| 새 인증서 디스크 기록 | `08:20:27` ← mtime `17:22:13 KST (ts)` 106초 |
| 실제 서빙 시작 | `08:58:52` ← dev 관측, 보정 불필요 |
| 갱신과 서빙 사이 | `2305초` = `38분 25초` |
두 시계에서 온 값을 빼면서 그 사실을 적지 않으면 자릿수가 아니라 방향까지 틀릴 수 있다. D-4 에서는 오차가 106초여서 결론이 안 바뀌었지만 1~2초를 재는 여기서는 결과를 완전히 뒤집었다.
## 복구와 원상복구 확인표
**이 주입은 고장이 아니라 고침이라 남긴다.** 지우면 D-4 의 상태로 돌아가고, 그 결함은 다음 실제 갱신(약 59일 뒤)에, 증상은 그 뒤 인증서 만료로 나타난다. 정말 지워야 한다면 두 줄이다.
```bash label="[test-server] ① 훅을 지운다"
ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
```
```bash label="[test-server] ② 다시 비었는지 본다"
ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/'
```
다시 `total 8` 인가를 본다.
**시간이 지나야 시험할 수 있는 항목이 하나 있다.**
| 항목 | 상태 |
|---|---|
| `certbot-renew.timer` 가 실제 갱신을 하는가 | 미측정. 만료 30일 전에야 조건이 성립한다 — 증거의 `VALID: 89 days` 는 만료까지이므로 갱신은 약 59일 뒤다 |
훅은 `--force-renewal` 로 검증했다. 타이머가 스스로 갱신하는 경로도 같은 `certbot renew` 를 부르고 같은 `deploy/` 훅을 실행하므로 미지수는 「타이머가 뜨는가」 하나이고, 그것은 D-4 에서 이미 확인했다(오늘 두 번 `status=0/SUCCESS`).
그날이 오면 두 줄이면 된다.
```bash label="[test-server] ① 워커가 갱신 시각 근처에 떴는가"
ssh test-server "ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep"
```
```bash label="[dev] ② 서빙 인증서의 만료가 밀렸는가"
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -serial -enddate
```
워커 `lstart` 가 갱신 시각 근처인가, 그리고 `notAfter` 가 밀렸는가를 본다. 문구가 아니라 이 둘로 판정한다.
| 항목 | 명령 | 이렇게 되어 있어야 한다 |
|---|---|---|
| 훅 | `sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/` | `-rwxr-xr-x … reload-nginx.sh` (남긴다) |
| nginx | `ps -eo pid,ppid,etimes,lstart,args \| grep nginx:` | 마스터 그대로, 워커 새것 |
| 서빙 인증서 | `openssl … -serial -dates` | 관찰 절의 새 일련번호 |
| 체인 | D-4 의 체인 확인 한 줄 | 4단계, `Verify return code: 0` |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
| 임시 파일 | `ssh test-server 'ls -l /tmp/reload-nginx.sh /tmp/d4a-renew.txt'` | 지워도 된다. 훅은 `/etc` 에 설치됐다 |
| 발급 한도 | — | 이번 주에 몇 장 썼는지 세어 둔다 |
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| `ran with error output` 을 보고 실패로 판단했다 | stderr 에 뭔가 있으면 무조건 붙는 문구다 | 워커 PID |
| 훅이 아예 안 불렸다 | `x` 비트가 없거나 `deploy/` 가 아니다 | `sudo ls -l …/deploy/` |
| 훅은 돌았는데 워커가 안 바뀐다 | `nginx -t` 가 실패해 `&&` 뒤가 안 돌았다 | 훅을 손으로 실행 |
| 워커도 마스터도 바뀌었다 | reload 가 아니라 재시작됐다 | `lstart` 두 줄을 본다 |
| 지연이 음수로 나온다 | 두 시계를 그대로 뺐다 | 시계 재는 절차로 돌아간다 |
| 발급 시각이 한 시간 어긋난다 | `notBefore` 를 발급 시각으로 읽었다 | SCT 를 본다 |
| 시계 왜곡을 지금 재려는데 값이 흔들린다 | 네트워크 지연이 섞였다 | 3회 이상 재서 안정적인지 본다 |
| 호스트 명령이 조용히 빈 결과 | sudo 가 비밀번호를 못 물었다 | `ssh -t` 로 다시 |
| 훅 로그를 `/tmp` 에 썼는데 안 보인다 | `certbot-renew.service` 는 `PrivateTmp=true` | `logger` 로 저널에 보내거나 `/var/log` 아래에 쓴다(unknown) |
| nginx 경고가 계속 거슬린다 | `types_hash_max_size` 기본값 | 갱신과 무관하다. 고치려면 `nginx.conf` 를 손본다 |
이 편이 남기는 한 문장은 「처방을 적었으면 시험한다」이다. D-4 는 원인을 정확히 셋으로 특정하고 고치는 법까지 적었고, 그 처방이 듣는지 확인하는 데 든 비용은 파일 하나와 명령 두 줄이었다. 확인하지 않은 채로 문서에 남았다면 「고치는 법」 항목은 다음 갱신일까지 아무도 시험하지 않은 문장으로 남았을 텐데, 그날이 바로 시험할 수 없는 날이다.
## 무엇이 관측이고 무엇이 아닌가
- (observed) 주입 전 워커 두 줄(`585` 와 `28829`, `lstart Fri Sep 4 18:00:35`), 훅 디렉터리의 `total 8`, 시계 측정 네 줄과 `+106.1` 세 번, certbot 출력 전문(`ran with error output` · `types_hash` 경고 두 줄 · `test is successful` · `signal process started` · `Congratulations, all renewals succeeded` · `fullchain.pem (success)`), 주입 뒤 워커 두 줄(`585` · `37252` · `etimes 74` · `lstart Fri Sep 4 21:29:36 2026`), 새 인증서의 `serial=06F3E0EF4D1BB03DE58130EAAD1176101373` · `notBefore=Sep 4 11:29:18 2026 GMT` · `notAfter=Dec 3 11:29:17 2026 GMT` 와 SAN 세 이름, SCT 두 줄(`Sep 4 12:27:49.054` · `Sep 4 12:27:49.048`), `notBefore` ↔ SCT 표의 `약 89.8초` · `약 88.9초`.
- (observed, 호스트 확인) `nginx.service` 의 `Restart=on-failure` · `RestartUSec=100ms` · `StartLimitBurst=5` · `StartLimitIntervalUSec=10s`, `certbot-renew.service` 의 `PrivateTmp=true`. 증거 파일이 아니라 이 호스트에서 확인한 값이라 가이드가 실측(호스트)으로 따로 표시했다.
- **이 편이 잰 reload 는 훅이 걸었다** — 워커 `37252` 를 만든 것은 `deploy/` 훅이고, 주입 전 워커 `28829` 는 D-4 에서 사람이 친 `nginx -s reload` 가 만들었다. 10번 표의 「전 / 후」 두 칸이 사람과 훅이다. 1~2초는 훅이 건 reload 를 잰 값이고, D-4 의 `2305초` 는 사람이 건 reload 까지의 간격이다. **무중단 판정의 `8856건` 과 `845361` 바이트는 이 편의 값이 아니다** — D-4 가 사람이 건 reload 에서 쟀고, 이 편은 폴링도 전송 중 요청 감시도 돌리지 않았다.
- **`2199` → `2305` 는 이 실험이 앞 실험을 정정했다** — D-4 가 `archive/cert2.pem` 의 mtime(test-server 시계)과 일련번호 관측(dev 시계)을 그대로 빼서 `2199초` 로 적었고, 여기서 시계 왜곡 106초를 재고 나서 `2305초` 로 고쳤다. D-4 에서는 106초가 결론을 안 바꿨지만 여기서는 보정하지 않으면 뺀 값이 참값보다 약 106초 어긋나고, 보정을 반대로 걸면 음수 지연이 나와 성립하지 않는다. 정정한 값과 정정 전 값을 둘 다 남겨 둔 까닭이 그것이다.
- (unknown) `certbot renew --dry-run` 에서 `Running deploy-hook command` 줄이 나오는지 — 이 실험대는 곧바로 강제 갱신을 했다. 훅이 진짜로 실패했을 때 certbot 이 무엇을 찍는지, 훅이 `/tmp` 에 남긴 로그가 `PrivateTmp` 때문에 안 보이는지, `notBefore`+1시간과 SCT 사이 약 89초 차이의 원인. 가이드가 전부 미검증으로 표시했다.
- **두 형태로 적은 곳이 둘이다** — 훅 파일을 만드는 것과 설치·갱신을 한 줄로 합치는 것. 이 실험대는 `printf … > /tmp/reload-nginx.sh` 로 만들고 설치와 강제 갱신을 한 줄로 쳤다(observed). 편집기로 여는 형태와 한 줄씩 치는 형태는 이 형태로 실행하지 않았다(unknown). 실제로 친 줄을 지우지 않고 나란히 적었다.
- **비밀은 이 편에 나오지 않는다** — 다루는 값이 훅 파일 두 줄과 PID 와 시각이라 옮길 비밀이 없다. 일련번호·PID·호스트명은 식별자라 그대로 적었다. 새 `privkey2.pem` 도 D-3 의 문제를 그대로 안고 있지만 이 절차는 그 파일을 열지 않는다.
- **이 실험이 확인하지 않은 것** — 타이머가 스스로 갱신하는 경로. 만료 30일 전에야 조건이 성립하고(증거의 `VALID: 89 days` 는 만료까지이므로 갱신은 약 59일 뒤다), 그때 볼 두 줄만 적어 두었다.
- **가이드가 「다음」에 적은 한 줄이 이 편의 결론이기도 하다** — 「이 훅은 구축 절차에 들어가야 한다. 사후에 붙이는 것이 아니다」. D-4 가 잰 `38분 25초` 의 공백은 훅이 없어서 생긴 것이고, 그 훅은 인증서를 처음 세울 때 같이 놓였어야 했다.
<!-- body:end -->