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

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

|
||||
|
||||
그림에서 `nginx 워커` 로 들어오는 화살표는 둘이다. `live/fullchain.pem` 에서 오는 쪽에는 「reload 필요」가 붙어 있고, 그 reload 를 부르는 신호는 `renewal-hooks/deploy` 에서 온다. 이 실험대에서는 그 디렉터리가 비어 있어 신호를 보낼 것이 없었고, 그래서 새 인증서가 기록된 뒤에도 워커는 옛 인증서를 들고 있었다.
|
||||
|
||||
## reload 가 있었는지는 워커 PID 로 가른다
|
||||
|
||||
nginx 의 reload 는 마스터를 유지한 채 워커만 새로 띄운다. 그래서 마스터 PID(Process ID, 프로세스 번호)가 그대로이고 워커 PID 만 바뀌었으면 reload 가 된 것이고, 둘 다 그대로이면 없었던 것이다.
|
||||
|
||||
```text label="갱신 직후 nginx 의 마스터와 워커"
|
||||
585 1 80529 Thu Sep 3 19:00:39 nginx: master process
|
||||
586 585 80529 Thu Sep 3 19:00:39 nginx: worker process
|
||||
```
|
||||
|
||||
워커 586 은 마스터 585 가 기동한 직후의 첫 fork 이고 기동 시각도 경과 시간도 마스터와 같다. 22.4시간 동안 워커가 한 번도 교체되지 않았으므로 reload 도 한 번도 없었다. 로그에 무엇이 적혔는지를 보지 않고 지금 떠 있는 프로세스만으로 갈린다.
|
||||
|
||||
## 88일 동안 드러나지 않는다
|
||||
|
||||
certbot 타이머는 정상이고 실행은 매번 SUCCESS 로 끝난다. certbot 은 만료 30일 전이 되어야 갱신을 시도하므로, 그때까지는 갱신 자체가 없어서 「갱신해도 반영되지 않는다」는 결함이 나타날 기회가 없다. 이 호스트에서는 그 구간이 88일이다.
|
||||
|
||||
발현하는 날의 증상은 인증서 만료이고, 그날에도 타이머 로그에는 SUCCESS 라고 적혀 있다. 강제 갱신을 걸어 일련번호를 밖에서 폴링하지 않았다면 이 실험대에서도 그날까지 보이지 않았다.
|
||||
|
||||
## reload 자체는 무중단이었다
|
||||
|
||||
원래 물음이었던 「reload 중 진행 중이던 요청은 어떻게 되는가」는 답이 나왔다. 다만 잰 것은 자동화가 부른 reload 가 아니라 `08:58:52` 에 사람이 친 reload 다.
|
||||
|
||||
```text label="사람이 친 reload 전후"
|
||||
새 연결 8856건 전부 200 · p95 205.7 → 204.3ms
|
||||
진행 중 요청 전송 12초째에 reload · 845361바이트 전량 · 연결수 1
|
||||
```
|
||||
|
||||
845KB 짜리 응답을 20k/s 로 느리게 받아 42초 동안 살려 둔 요청이 있었고, 그 전송 한가운데에서 reload 가 걸렸다. 받은 바이트가 전량이고 연결 수가 1이므로 중간에 끊겨 다시 연결한 것이 아니다. 옛 워커가 그 요청을 끝까지 책임졌다.
|
||||
|
||||
새 연결 쪽도 같다. p95 가 205.7 밀리초에서 204.3 밀리초로 사실상 그대로이고 비200 은 한 건도 없었다.
|
||||
|
||||
## 확인하지 않은 것
|
||||
|
||||
실제 만료가 임박한 상태를 만들지 않았다. 이 결함이 만료로 드러나는 경로는 재지 않았다.
|
||||
|
||||
무중단을 확인한 reload 는 사람이 건 것이다. certbot 의 deploy 훅이 부르는 reload 에서도 같은지는 여기서 재지 않았고, 훅을 넣고 다시 돌린 실험이 그것을 이어받았다.
|
||||
<!-- body:end -->
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: the-upgrade-that-would-not-roll-back
|
||||
title: 되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다
|
||||
topic: operations-that-report-success
|
||||
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
source:
|
||||
- final/document.md#선택이-코드와-흐름에-반영되는-방식-d1-d2
|
||||
assets:
|
||||
- key: d2-upgrade-direction
|
||||
file: ../../../final/assets/d2-upgrade-direction/d2-upgrade-direction.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/d2-version-upgrade__02-rollback-attempt.txt
|
||||
- ../../../final/evidence/raw/d2-version-upgrade__03-roll-forward.txt
|
||||
---
|
||||
|
||||
# 되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다
|
||||
|
||||
되돌리기가 옛 버전 파드의 기동 단계에서 막혔다. 새 버전이 스키마에 남긴 체크섬을 Liquibase 가 거부했기 때문이다. 그동안 외부 서비스는 살아 있었는데, 롤링 업데이트가 첫 파드에서 멈춰 남은 파드가 계속 응답했다. 되돌릴 수 있는지는 databasechangelog 의 행 수가 가른다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **적용됐는지는 로그 문구가 아니라 상태로 판정한다**
|
||||
되돌릴 수 있는지도 계획서에 적힌 문장이 아니라 databasechangelog 의 행 수로 갈랐다.
|
||||
- **reload 를 사람이 아니라 deploy 훅이 부르게 한다**
|
||||
운영 절차의 결과를 사람의 기억이 아니라 실행 가능한 검사에 맡긴다는 물음이 여기에도 같이 걸려 있다.
|
||||
|
||||
## 문제
|
||||
|
||||
버전을 올리는 절차에는 되돌리기가 따라붙는다. 이 실험대에는 그 롤백 계획이 적혀 있지 않았다.
|
||||
|
||||
되돌리기를 실제로 걸어 본 적도 없었다. 이미지 태그를 옛 버전으로 되돌리면 파드가 뜨는지, 되돌리는 동안 밖에서 보는 서비스가 어떻게 되는지는 재지 않은 상태였다.
|
||||
|
||||
## 결론
|
||||
|
||||
되돌리기는 기동 단계에서 막혔고 외부 서비스는 그동안 살아 있었다.
|
||||
|
||||
정방향 26.7.0 에서 26.7.3 : 무중단. 요청 87회 전부 200
|
||||
되돌리기 : 옛 버전 파드가 기동 단계에서 막혔다
|
||||
막은 것 : Liquibase 의 체크섬 검증
|
||||
그때 외부 서비스 : 살아 있었다. StatefulSet 롤링 업데이트가 첫 파드에서 멈췄다
|
||||
되돌릴 수 있는지 가르는 기준 : databasechangelog 의 행 수가 업그레이드 전후로 같은가
|
||||
스키마 변경 없는 되돌리기 26.7.3 에서 26.7.0 : 성공. 전환 순간 000 이 1회
|
||||
|
||||
「롤백 불가」는 조건부다. 새 버전이 changeset 을 추가하지 않았으면 옛 버전으로 되돌아간다. 추가했으면 옛 버전이 그 행의 체크섬을 거부하고 기동에서 멈춘다.
|
||||
|
||||
롤백 계획을 적어 두지 않았더라도 이 사고는 전면화되지 않았다. 롤링 업데이트가 첫 파드에서 멈추면서 나머지 파드를 그대로 두었기 때문이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
Keycloak : 26.7.0 에서 26.7.3 으로 올리고 다시 되돌렸다
|
||||
워크로드 : StatefulSet 2파드
|
||||
스키마 관리 : Liquibase. 적용한 changeset 을 databasechangelog 테이블에 기록한다
|
||||
데이터베이스 : PostgreSQL
|
||||
가용성 측정 : 업데이트가 도는 동안 밖에서 요청을 반복해 상태 코드를 셌다
|
||||
측정일 : 2026-09-04. 되돌린 파드가 남긴 로그 줄의 날짜다
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 업그레이드 전에 데이터베이스를 백업한다.
|
||||
|
||||
2. databasechangelog 의 행 수를 세어 둔다.
|
||||
|
||||
3. 이미지 태그를 새 패치 버전으로 바꿔 StatefulSet 롤링 업데이트를 건다.
|
||||
|
||||
4. 업데이트가 도는 동안 밖에서 요청을 반복해 200 과 비200 을 센다.
|
||||
|
||||
5. databasechangelog 의 행 수를 다시 세어 업그레이드 전과 같은지 본다.
|
||||
|
||||
6. 이미지 태그를 옛 버전으로 되돌리고, 첫 파드가 기동하는지와 그 파드의 로그에 무엇이 찍히는지 본다.
|
||||
|
||||
7. 되돌리는 동안에도 밖에서 요청을 반복해 남은 파드가 응답하는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
## 올리는 방향은 아무것도 끊지 않았다
|
||||
|
||||
Keycloak 두 파드를 StatefulSet 으로 띄워 두고 이미지 태그를 26.7.0 에서 26.7.3 으로 바꿨다. 업데이트가 도는 동안 밖에서 요청을 계속 보냈고 87회가 전부 200 이었다.
|
||||
|
||||
이 방향에서는 파드가 새 이미지로 다시 뜨는 것 말고 걸리는 단계가 없었다. 되돌리기를 시도한 것은 이 상태에서다.
|
||||
|
||||
## 되돌리기는 기동 단계에서 막혔다
|
||||
|
||||
이미지 태그를 옛 버전으로 되돌리자 새로 뜬 파드가 기동하지 못했다. 로그에 찍힌 것은 애플리케이션 오류가 아니라 스키마 검증이었다.
|
||||
|
||||
```text label="옛 버전 파드가 기동하면서 남긴 것"
|
||||
liquibase ValidationFailedException: 1 changesets check sum
|
||||
```
|
||||
|
||||
Liquibase 는 스키마 변경을 changeset 단위로 적용하고 적용한 것을 `databasechangelog` 테이블에 한 행씩 기록하는데, 각 행에는 그 changeset 내용의 체크섬이 함께 들어간다. 기동할 때 자기가 들고 있는 changeset 파일의 체크섬과 테이블에 적힌 체크섬을 대조하고, 다르면 거기서 멈춘다. 새 버전이 남긴 행을 옛 버전이 자기 파일과 대조했더니 맞지 않았고, 그래서 데이터베이스에 손을 대기 전에 기동을 포기했다.
|
||||
|
||||
막은 것은 애플리케이션 코드도 이미지도 아니라 데이터베이스에 이미 적힌 한 행이다.
|
||||
|
||||
되돌리기를 걸고 20초 간격으로 여덟 번 파드 상태를 읽었는데 그중 다섯 번은 `Running` 이었고 `Error` 가 두 번, `CrashLoopBackOff` 가 한 번이었다. 여덟 번 모두 준비된 컨테이너는 0/1 이었다. 상태 칸만 보면 `Running` 인 때도 있었으므로, 기동하지 못했다는 것은 그 0/1 과 파드 로그를 보고 판단했다.
|
||||
|
||||
## 되돌리기가 막힌 동안에도 서비스는 살아 있었다
|
||||
|
||||
기동하지 못한 파드는 첫 번째 파드였다. StatefulSet 의 롤링 업데이트는 파드를 하나씩 교체하고 앞의 파드가 준비 상태가 되어야 다음으로 넘어가므로, 첫 파드가 기동하지 못한 시점에 업데이트가 거기서 멈추고 나머지 파드는 옛 이미지 그대로 남았다. 밖에서는 남은 파드가 계속 응답해 외부 진입점이 `HTTP 200` 이었고, Service 의 준비된 엔드포인트에는 주소가 하나 남아 있었다.
|
||||
|
||||

|
||||
|
||||
그림에서 `옛 버전 롤백` 은 `Liquibase 검증` 으로만 이어지고, 거기서 두 갈래가 나간다. 한쪽은 `databasechangelog 행 수` 이고 판단 기준이라는 이름이 붙어 있으며, 다른 쪽은 `StatefulSet 롤링 업데이트` 이고 중단 지점이라는 이름이 붙어 있다. 그 중단 지점에서 `외부 서비스` 로 가는 화살표에는 잔여 파드 응답이 붙는다. 되돌리기를 막은 단계와 사고를 절반에서 멈춘 단계가 같은 검증에서 갈라져 나온다.
|
||||
|
||||
롤백 계획을 적어 두지 않은 상태에서 되돌리기를 시도했는데도 전면 중단으로 가지 않았다. 그것은 계획이 좋아서가 아니라 StatefulSet 의 롤링 업데이트가 실패한 파드에서 교체를 멈췄기 때문이다.
|
||||
|
||||
## 롤백 불가는 조건부였다
|
||||
|
||||
이 실험을 처음 적을 때는 「롤백은 안 된다」고 단정했고, 후속 실험에서 정정했다. 실제로 막은 것은 버전 번호가 아니라 새 버전이 스키마에 행을 더했다는 사실이라, 새 버전이 changeset 을 하나도 더하지 않았으면 옛 버전은 대조에서 걸릴 것이 없다.
|
||||
|
||||
그래서 판단 기준이 한 줄로 정해진다.
|
||||
|
||||
```sql label="되돌릴 수 있는지 가르는 한 줄"
|
||||
select count(*) from databasechangelog
|
||||
```
|
||||
|
||||
업그레이드 전후로 이 수가 같으면 되돌아가고, 늘었으면 옛 버전이 기동에서 멈춘다. 이 기준으로 다시 걸어 본 26.7.3 에서 26.7.0 으로의 되돌리기는 성공했고, 전환 순간에 `000` 이 한 번 나왔다. 그 `000` 은 서버가 오류를 돌려준 것이 아니라 `--max-time 3` 을 넘긴 것이다. 끊긴 것과 느린 것은 다르고, 그 구별은 상태 코드가 아니라 타임아웃 값을 알고 있어야 선다.
|
||||
|
||||
이 수는 실제로 세었다. 되돌리기가 막힌 뒤 다시 앞으로 올려 놓고 확인했더니 마이그레이션 210 과 세션 4 가 업그레이드 전에 세어 둔 값 그대로였다.
|
||||
|
||||
업그레이드를 걸기 전에 이 수를 세어 두면 되돌릴 수 있는지를 사고가 나기 전에 안다. 계획서에 「롤백 가능」이라고 적어 두는 것과 이 쿼리를 전후로 돌려 보는 것은 다른 일이다.
|
||||
|
||||
다만 이 기준의 두 갈래를 같은 만큼 확인하지는 않았다. 「같으면 되돌아간다」는 26.7.3 에서 26.7.0 으로 실제로 걸어 본 결과이고, 「늘었으면 멈춘다」는 옛 버전에서 본 기동 실패를 근거로 한 추론이다. 26.7.x 사이에는 스키마 변경이 없어서 행이 늘어난 뒤 태그를 되돌리는 경우는 이 실험대에서 만들지 못했다.
|
||||
|
||||
## 확인하지 않은 것
|
||||
|
||||
스키마가 크게 바뀌는 메이저 업그레이드에서는 재지 않았다. 두 패치 버전 사이만 확인했다.
|
||||
|
||||
되돌리기가 막혔을 때 남은 파드가 얼마나 오래 버티는지도 재지 않았다. 관측한 것은 첫 파드가 기동하지 못하는 동안 밖에서 응답이 계속 왔다는 것까지이고, 그 상태를 길게 두었을 때 무엇이 먼저 깨지는지는 걸어 보지 않았다.
|
||||
<!-- body:end -->
|
||||
+60
@@ -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 를 잰 값이다.
|
||||
+103
@@ -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 가 바뀌고 서빙 일련번호가 새 인증서와 같으면 닫는다. 그렇지 않으면 훅이 강제
|
||||
갱신에서만 도는 것이므로 타이머 유닛 쪽에 훅을 다시 건다.
|
||||
+84
@@ -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 였다
|
||||
+745
@@ -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:57–15: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 -->
|
||||
+706
@@ -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:00–15:10` 에 역방향 `26.0` 을 쳤고, 후속 실행은 `15:22–15: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:00–15: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 -->
|
||||
+541
@@ -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:05–15: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:05–15:06` 이 D-2 의 첫 실행(`15:00–15: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 -->
|
||||
+1085
File diff suppressed because it is too large
Load Diff
+632
@@ -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 -->
|
||||
Reference in New Issue
Block a user