Files
keycloak-pattern/docs/guides/experiments/d1-backup-restore.md
DongHyeonkaandClaude Opus 5 6f6ab86345 docs(guides): reproduction guides for all 26 experiments
Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.

Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.

The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:

  A-0   emptying the session table without a restart leaves cache entries
        that get counted as replication arriving
  A-2   dropping -o /dev/null fuses body and status into one string
  A-3   presence of "ready to accept connections" instead of its timestamp
  B-2   row count alone reads an UPDATE as nothing having happened
  B-4   tr ',' '\n' splits ["admin","editor"] so only admin is seen
  B-7   no login screen means the cookie died and SSO re-authenticated
  C-1   counting sessions without joining realm counts your own kcadm one
  D-1   kubectl exec without -i restores nothing and still exits 0
  D-4a  "ran with error output" is what success looks like

Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.

Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.

Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:00 +09:00

832 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# D-1 재현 가이드 — 스키마를 통째로 지우고 백업이 진짜 백업인지 직접 본다
해설 문서: [`docs/experiment-d1-backup-restore.md`](../../experiment-d1-backup-restore.md) ·
증거 원문: [`docs/evidence/d1-backup-restore/`](../../evidence/d1-backup-restore/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 덤프 파일 안에 세션 행이 실제로 들어 있는 것 | `grep` |
| 데이터베이스를 통째로 비웠는데 정문이 `200` 인 것 | 밖에서 `curl` |
| 파드가 `1/1 Running` 인 채로 테이블이 0개인 것 | `get pods` · `psql` |
| `certs` 200 · `well-known` 500 · 토큰 400 으로 **부분만** 깨지는 것 | `curl` 세 번 |
| 복구가 1초 만에 오류 0건으로 끝나는 것 | `psql < 덤프` |
| 세션까지 되살아나는 것 | `offline_user_session` |
| **덤프가 DB 와 같은 기계 위에 놓여 있는 것** | `ls -l` |
## 전제
- [`A-2`](a2-database-loss.md) 를 먼저 하면 좋다. 「DB 프로세스가 죽었을 때」의
모양을 봐 둬야 이 실험의 `200` 이 얼마나 이상한지 안다.
- [`A-3`](a3-database-crash.md) 도 먼저다. RPO 의 두 번째 겹이 거기서 나온다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다
(kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다).
- 네임스페이스는 `keycloak-lab` 이다.
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
- **덤프를 다른 기계로 옮기는 단계(5-6)만 호스트(`test-server`)가 필요하고,
호스트의 `sudo` 는 비밀번호를 묻는다.** 그 부분은 사람이 직접 친다.
## 주의 — 이건 데이터베이스를 비우는 실험이다
`DROP SCHEMA public CASCADE`**realm·client·user·세션을 전부 지운다.**
되돌리는 수단은 당신이 방금 뜬 덤프 파일 **하나뿐**이다. 그래서 이 가이드는
**덤프를 검증하기 전에는 2절로 넘어가지 않는다.** 전 구간 약 20분이고,
파괴 구간 자체는 1분 안쪽으로 잡는다. 중간에 그만두려면
[5-1. 되돌린다](#5-1-되돌린다) 의 명령 하나면 된다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 14:5715:00 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
파드 이름·세션 id·바이트 수는 **당신 환경에서 다르다.** 이 문서는
자리표시자(`<...>`)를 쓰지 않는 대신 그 값을 뽑는 명령을 먼저 적는다.
예시로 실린 값은 전부 위 실행 기록의 실제 값이다.
---
# 0. 왜 이 실험을 하는가
「백업이 있다」와 「복구해 봤다」는 다른 문장이다. 백업 스크립트가 매일 도는
것과, 그 파일로 실제로 서비스를 되살릴 수 있는 것 사이에는 시험되지 않은
가정이 여러 개 있다.
이 실험은 그중 둘을 판정한다.
| # | 질문 | 어떻게 가르나 |
|---|---|---|
| ① | 덤프에 **필요한 것이 다 들어가는가** | 특히 **세션**. 안 들어가면 복구 후 전원 재로그인이다 |
| ② | **복구 절차가 실제로 도는가** | 오류 없이 끝나고 데이터가 일치하는가 |
그리고 부수 질문이 하나 붙는다 — **DB 가 비면 무엇이 깨지는가.**
이게 A-2 와 대비되는 지점이고, 실제로 이 실험에서 가장 놀라운 결과가 나왔다.
```
A-2 DB 프로세스 정지 → 커넥션 실패 → readiness DOWN → 파드가 Service 에서 빠짐
D-1 스키마만 삭제 → 커넥션 정상 → readiness UP → ?
```
**커넥션은 되는데 테이블이 없는 상태**는 단일 장애 주입으로는 잘 안 만들어진다.
그래서 이 실험이 필요하다.
---
# 1. 기준선 — 지우기 전에
**시험군만 재는 측정은 측정이 아니다.** 파괴 후에 볼 것을 파괴 전에 **똑같은
명령으로** 먼저 봐 둔다. 복구가 「완전 일치」인지 판정하려면 일치시킬 상대가
있어야 한다.
넓은 것부터 좁혀 간다.
```
파드 → 데이터 개수 → 세션 → 밖에서 본 상태 → 덤프 → ★ 덤프 검증 → 덤프의 위치
```
## 1-1. 파드가 다 떠 있나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt) 의
파괴 직후 목록이지만, 파괴 **전후가 같다**는 것이 이 실험의 결과이므로 기준선으로도 읽는다
```
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`** — 뒤에서 이 값이 오르면 파괴가 엉뚱한 것을 건드린 것이다
- `postgres` 파드가 있는지, 어느 노드에 있는지
**이 결과가 의미하는 것** — 지금은 전부 정상이다. 이 표의 값을 적어 둔다.
**복구 판정에서 「재시작 없이 돌아왔는가」를 볼 때 `RESTARTS` 를 비교한다.**
## 1-2. 데이터가 얼마나 있나
**처음 한 번은 읽는 형태로 친다.** 값만 뽑는 형태부터 배우면 psql 이 무엇을
돌려주는지 모르게 된다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from realm"
```
**형태**
```
count
-------
2
(1 row)
```
**어디를 봐야 하는가** — 숫자 하나와 `(1 row)`. 여기서 오류가 나면 뒤의 모든
단계가 무의미하다. `psql: error: connection to server ... failed` 면 DB 가 아직
안 붙은 것이고, `relation "realm" does not exist` 면 **이미 스키마가 없는 것**이다.
이제 다섯 개를 한 줄로 모은다. **비교할 값이 필요할 때만** 이 형태를 쓴다.
**확인**
```bash
sudo 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')"
```
**실측** — [`01-backup.txt`](../../evidence/d1-backup-restore/01-backup.txt)
```
realms|clients|users|sessions|authclients = 2|15|2|3|1
```
> **실측 줄에는 값이 다섯이고 위 명령은 넷을 뽑는다.** 원래 실행 스크립트는
> 「인가된 클라이언트(authclients)」를 하나 더 셌는데, 해설 문서의 재현 절차에는
> 그 쿼리가 남아 있지 않다. **없는 컬럼을 지어내지 않고 넷으로 둔다** — 판정에는
> 넷으로 충분하고, 다섯째가 필요하면 당신이 세는 쿼리를 정해서 **양쪽에 같이**
> 쓰면 된다.
>
> 손으로 치면 이름표 없이 `2|15|2|3` 만 나온다. `-tAc` 는 **헤더 없이(`-t`)
> 정렬 없이(`-A`) 한 줄만**이라는 뜻이고, 여러 값을 나란히 비교할 때 이 형태가
> 편하다.
**어디를 봐야 하는가** — 숫자 넷. **이 줄을 그대로 복사해 둔다.**
복구 후에 같은 명령을 쳐서 **문자 단위로 같은지** 본다.
**이 결과가 의미하는 것** — 이 값들이 「복구가 성공했다」의 판정 조건이다.
하나라도 다르면 복구가 부분적으로만 된 것이다.
## 1-3. 세션이 DB 에 있나 — 이게 덤프에 들어갈지가 관건이다
**확인**
```bash
sudo 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 가 어떻게 생겼는지만 본다
```
user_session_id | offline_flag | realm_id
--------------------------+--------------+--------------------------------------
E1q5xI7tt4U_WhZpW7rEPIF2 | 0 | 7845f394-723a-4d07-b530-c7416b2e1d31
...
```
**어디를 봐야 하는가****행이 0개면 안 된다.** 0개면 이 실험의 ①(세션이
덤프에 들어가는가)을 판정할 수 없다. 관리 콘솔에 한 번 로그인해서 세션을
만들고 다시 본다.
> **`offline_flag` 를 눈여겨본다.** 1-2 의 개수 쿼리는 `offline_flag='0'` 만
> 셌고, 이 쿼리는 전부 나열한다. **세는 쿼리와 나열하는 쿼리가 다른 것을
> 세고 있다** — 실제로 원래 실행에서도 개수는 `3`, 나열은 `4 rows` 였다
> ([`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)).
> 두 숫자가 다르다고 놀라지 말고, **복구 전후에 같은 쿼리끼리** 비교한다.
**이 결과가 의미하는 것** — 세션이 DB 테이블에 있다는 것은
`persistent-user-sessions` 가 켜져 있다는 뜻이다(A-0). **그래서 세션이 백업
대상이 된다.** volatile 이었다면 세션은 애초에 DB 에 없고, 복구해도 전원
재로그인이다 — 백업의 가치가 달라진다.
## 1-4. 밖에서 정상인가
**처음 한 번은 응답을 읽는다.**
**확인**
```bash
curl -I https://auth.hyeonworks.com/realms/master
```
헤더가 통째로 나온다. `HTTP/2 200`, `content-type: application/json` 을 본다.
같은 것을 반복해서 재고 비교할 때만 코드만 뽑는다.
**확인**
```bash
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/
```
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt)
(이것도 **파괴 직후** 값이다 — 그게 결과다)
```
https://auth.hyeonworks.com/realms/master HTTP 200
https://app1.hyeonworks.com/ HTTP 200
```
**어디를 봐야 하는가** — 둘 다 `200`.
**이 결과가 의미하는 것** — 지금은 당연히 200 이다. **문제는 3절에서도 이
값이 200 이라는 것**이고, 그래서 이 두 줄은 「정상 판정에 쓸 수 없는 지표」의
예시로 남는다.
## 1-5. 백업을 뜬다
**하기**
```bash
date '+%H:%M:%S 백업 시작'
sudo kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
--clean --if-exists > /tmp/keycloak-backup.sql
date '+%H:%M:%S 백업 완료'
```
**실측** — [`01-backup.txt`](../../evidence/d1-backup-restore/01-backup.txt)
```
시작: 14:59:30
완료: 14:59:30
크기: 394945 bytes (6956 줄)
```
**어디를 봐야 하는가** — 시각 두 줄과 파일 크기. 이 규모에서는 **1초 미만**이다.
### 개념 — `--clean --if-exists` 가 없으면 복구가 실패한다
| 옵션 | 무엇을 하나 | 없으면 |
|---|---|---|
| `--clean` | 복구 시 기존 객체를 **DROP 하고** 다시 만든다 | `already exists` 오류가 쏟아진다 |
| `--if-exists` | 없는 객체를 DROP 할 때 오류를 안 낸다 | 깨끗한 DB 에 복구할 때 오류가 쏟아진다 |
**둘은 짝이다.** `--clean` 만 주면 「빈 DB 에 복구」가 깨지고, `--if-exists`
만 주면 아무 효과가 없다(DROP 문 자체가 안 만들어진다).
> **왜 이 실험에서는 어차피 빈 DB 인데 필요한가.** 이 실험은 `DROP SCHEMA` 로
> 완전히 비우고 복구하지만, **실제 사고는 대개 그렇지 않다.** 반쯤 남은 DB 에
> 덤프를 밀어 넣는 상황이 훨씬 흔하고, 그때 이 두 옵션이 있고 없고가 갈린다.
**되돌리기** — 이 단계는 읽기만 한다. 파일이 마음에 안 들면 지우고 다시 뜬다.
```bash
rm -f /tmp/keycloak-backup.sql
```
## 1-6. ★ 덤프를 검증한다 — 여기를 건너뛰면 2절은 자살행위다
**「파일이 생겼다」는 「복구할 수 있다」가 아니다.** `pg_dump` 가 중간에
실패해도 파일은 남고, 크기도 0 이 아니다.
**확인 ①** 파일이 실제로 있고 크기가 말이 되는가
```bash
ls -l /tmp/keycloak-backup.sql
wc -l /tmp/keycloak-backup.sql
```
**실측**
```
크기: 394945 bytes (6956 줄)
```
**확인 ②** 테이블 정의가 다 들어갔는가
```bash
grep -c '^CREATE TABLE' /tmp/keycloak-backup.sql
```
**실측**
```
포함된 테이블 수: 101
```
**어디를 봐야 하는가** — 101 이라는 **절대값이 중요한 게 아니라**, 1-2 에서
본 DB 와 자릿수가 맞는지가 중요하다. 두 자리로 떨어지면 덤프가 잘린 것이다.
**확인 ③** 마지막 줄이 정상 종료인가
```bash
tail -3 /tmp/keycloak-backup.sql
```
**형태**
```
--
-- PostgreSQL database dump complete
--
```
**어디를 봐야 하는가**`dump complete`. **이 줄이 없으면 덤프가 중간에
끊긴 것이고, 그 파일로는 복구가 안 된다.** 이 한 줄이 「파일이 생겼다」와
「덤프가 끝났다」를 가른다.
**확인 ④** ★ 세션이 들어 있는가 — 이 실험의 질문 ①
```bash
grep -c 'offline_user_session' /tmp/keycloak-backup.sql
grep -A3 'COPY public.offline_user_session' /tmp/keycloak-backup.sql | cut -c1-110
```
**실측** — [`01-backup.txt`](../../evidence/d1-backup-restore/01-backup.txt)
```
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` 블록이다. 5-4 에서 이 id 들이 되살아나는
것을 확인한다.
## 1-7. ★ 덤프가 지금 어디에 있는가
**확인**
```bash
ls -l /tmp/keycloak-backup.sql
df -h /tmp
```
**어디를 봐야 하는가** — 경로. `/tmp` 다. **이 파일은 지금 `kubectl` 을 친
그 기계의 디스크에 있다.**
**이 결과가 의미하는 것** — A-4 에서 **`local-path` PVC 가 노드에 못박혀
있는 것**을 봤다. 그 노드가 안 돌아오면 DB 볼륨도 안 돌아온다. 그때 유일한
길이 덤프인데, **덤프도 같은 기계에 있으면 같이 사라진다.**
> **같은 장애 도메인에 있는 백업은 백업이 아니다.**
> 원래 실행에서도 덤프는 `test-server:/tmp` 에 있었고, 해설 문서는 그것을
> **「가장 중요한 미검증 항목」**으로 기록했다. 옮기는 절차는 5-6 에 있다 —
> **파괴 전에 읽어만 두고, 실제 이동은 복구가 끝난 뒤에 한다.**
---
# 2. 주입 — 스키마를 통째로 지운다
여기부터 데이터가 사라진다. **되돌리는 명령을 먼저 읽어 둔다.**
**되돌리기** (5절에서 자세히 한다)
```bash
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak \
< /tmp/keycloak-backup.sql
```
**이 명령이 유일한 되돌리기 수단이다.** 1-6 의 확인 ①~④ 를 통과하지 않았으면
지금 돌아가서 한다.
**하기**
```bash
date '+%H:%M:%S 파괴'
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
```
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt)
```
=== ★ 파괴 — 스키마를 통째로 지운다 ===
시각: 14:59:47
DROP SCHEMA
CREATE SCHEMA
```
**어디를 봐야 하는가**`DROP SCHEMA``CREATE SCHEMA` 두 줄. `NOTICE:
drop cascades to 101 other objects` 같은 줄이 함께 나오는 것이 정상이다.
**시각을 반드시 적어 둔다.** 5-5 의 RTO 는 이 시각에서 시작한다.
> **왜 `CREATE SCHEMA public` 을 붙이나.** `public` 스키마 자체를 지우면
> 복구 스크립트가 들어갈 자리가 없다. 지우는 것은 **안의 객체**이고,
> 빈 스키마는 남겨 둬야 `pg_dump` 출력이 그대로 들어간다.
---
# 3. 주입이 실제로 걸렸는지 확인한다
**결과를 해석하기 전에, 의도한 것만 지워졌는지 먼저 본다.**
## 3-1. 테이블이 0개인가
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from pg_tables where schemaname='public'"
```
**미검증** — 원래 실행은 스크립트로 셌다. 그 결과는 이렇다.
**실측**
```
남은 테이블: 0
```
**어디를 봐야 하는가**`0`. 여기서 101 이 그대로 나오면 `DROP` 이 다른
데이터베이스에 걸린 것이다(`-d` 인자를 본다).
**확인** — 애플리케이션 테이블이 정말 없는지 직접 물어본다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from realm"
```
**형태**
```
ERROR: relation "realm" does not exist
LINE 1: select count(*) from realm
^
```
**이 결과가 의미하는 것** — **커넥션은 성립하고 SQL 도 파싱된다. 테이블만
없다.** 이 구별이 이 실험의 전부다. A-2 에서는 여기가
`connection to server ... failed` 였다.
## 3-2. ★ 그런데 밖은 멀쩡하다
**확인**
```bash
sudo 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/
```
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt)
```
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 이다.**
여기서 「파괴가 실패했다」고 읽으면 틀린다. 3-1 에서 테이블이 0개인 것을
이미 봤다. 파괴는 성공했고, **관측 지점이 그것을 못 보는 것**이다.
Keycloak 이 realm 정보를 **Infinispan `realms` 캐시**에서 서빙하기 때문이다
(A-0 에서 그 캐시에 57개 엔트리가 있는 것을 봤다). 캐시는 읽을 때 DB 와
대조하지 않는다 — A-1 에서 로그아웃한 세션이 반대편에서 `200` 을 받았던 것과
**같은 성질**이다.
## 3-3. 엉뚱한 것을 죽이지 않았나
**확인**
```bash
sudo 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 에서는 여기가 빈 목록이었다. readiness 프로브가 통과하고 있다는 뜻이고,
그 이유는 4-3 에서 본다.
> `kubectl get endpoints` 는 v1.33+ 에서 deprecated 다. 실제로 이 실험대에서
> 그 경고를 봤다 — 4절 「막히면」 표에 있다.
---
# 4. 효과를 관찰한다
## 4-1. 무엇이 깨지고 무엇이 안 깨지나
**전부 깨지지 않는다.** 세 경로를 나눠서 친다.
**확인**
```bash
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
```
**실측** — [`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)
```
/.well-known/openid-configuration HTTP 500
/protocol/openid-connect/certs HTTP 200
토큰 발급 (DB 쓰기 필요) HTTP 400
```
토큰 발급은 값이 필요하므로 따로 친다. **미검증** — 원래 실행은 스크립트였다
```bash
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=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
> 셸 히스토리에도 남지 않는다. 길이만 확인하려면:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
**어디를 봐야 하는가** — 세 값이 **다 다르다**는 것.
| 경로 | 코드 | 왜 |
|---|---|---|
| `certs` (JWKS) | **200** | realm 키가 캐시에 있다. DB 를 안 본다 |
| `.well-known` | **500** | 이 응답을 만들려면 DB 를 본다 |
| 토큰 발급 | **400** | 세션을 **써야** 한다 |
**이 결과가 의미하는 것****부분적으로만 깨진다.** 헬스체크는 통과하고,
일부 엔드포인트는 정상이며, **로그인만 안 된다.**
운영에서 이 모양이 왜 고약한가 — 「사이트가 떴는가」를 재는 감시(정문 200,
JWKS 200)는 전부 초록이고, **사용자만 못 들어온다.** 이 실험의 감시 항목은
`/realms/master` 가 아니라 **토큰 발급**이어야 한다.
## 4-2. 로그가 이유를 말한다
**확인**
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 --tail=50
```
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt)
```
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 이라
대시보드의 에러 카운터에 안 잡힐 수 있다.** 3-2 의 `200`, 4-1 의 부분 정상,
여기의 `WARN`**세 관측이 전부 「괜찮다」 쪽으로 기운다.**
## 4-3. 개념 — 「DB 가 살아 있다」와 「데이터가 있다」는 다르다
```
A-2 DB 프로세스 정지 → 커넥션 실패 → readiness DOWN → 파드가 Service 에서 빠진다
D-1 스키마만 삭제 → 커넥션 정상 → readiness UP → ★ 파드가 그대로 트래픽을 받는다
```
**헬스체크는 커넥션만 본다.** 그래서 빈 데이터베이스를 통과시킨다.
이건 Keycloak 의 버그가 아니다. 「DB 에 붙을 수 있는가」는 프로브가 답할 수
있는 질문이고, 「데이터가 온전한가」는 프로브가 답할 수 없는 질문이다.
후자를 재려면 **업무 트랜잭션 하나를 실제로 돌리는 감시**(예: 토큰 발급)가
따로 있어야 한다.
| 재는 것 | 이 사고에서 |
|---|---|
| 파드 `Ready` | 초록 |
| 정문 `200` | 초록 |
| JWKS `200` | 초록 |
| **토큰 발급** | **400** ← 유일하게 정직한 지표 |
---
# 5. 복구
## 5-1. 되돌린다
**하기**
```bash
date '+%H:%M:%S 복구 시작'
sudo 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 복구 완료'
```
**실측** — [`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)
```
시작: 15:00:12
완료: 15:00:13
오류 줄: 0
```
### ★ `-i` 를 빠뜨리면 아무 일도 안 일어난다 — 그리고 오류도 안 난다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql ... < dump.sql # ✘
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- psql ... < dump.sql # ✔
```
`-i` 는 **표준입력을 파드 안으로 연결하라**는 뜻이다. 없으면 파드 안의 psql 은
빈 입력을 받고 **정상 종료한다.** 셸은 오류를 내지 않고, 종료 코드도 0 이며,
`date` 두 줄은 「1초 만에 끝났다」고 찍힌다. **복구된 것과 구별되지 않는다.**
구별하는 유일한 방법은 5-2 의 데이터 대조다. **그래서 대조는 선택이 아니다.**
**확인** — 오류 줄을 센다
```bash
grep -ci '^ERROR' /tmp/restore.log
tail -5 /tmp/restore.log
```
**어디를 봐야 하는가**`0`. 0 이 아니면 어떤 줄이 실패했는지 본다.
`--clean --if-exists` 로 뜬 덤프를 빈 DB 에 넣으면 오류가 0 인 것이 정상이다.
## 5-2. 데이터를 대조한다 — 여기가 진짜 판정이다
**확인** — 1-2 와 **똑같은 명령**을 친다
```bash
sudo 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')"
```
**실측** — [`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)
```
복구 후: realms|clients|users|sessions|authclients = 2|15|2|3|1
백업 시: realms|clients|users|sessions|authclients = 2|15|2|3|1
```
**어디를 봐야 하는가****두 줄이 문자 단위로 같은가.**
**이 결과가 의미하는 것** — 완전 일치. 질문 ②(복구 절차가 도는가)의 답이
「돈다」인 근거가 이 두 줄이다. **여기가 다르면 그 앞의 모든 「성공」 표시는
무의미하다** — 5-1 의 `-i` 를 빠뜨렸는지 먼저 의심한다.
## 5-3. 서비스가 재시작 없이 돌아오는가
**손대지 않고 기다린다.** 여기서 파드를 재시작하면 「자가 회복하는가」라는
질문 자체가 사라진다.
**확인** — 15초쯤 뒤
```bash
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration
sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak
```
**실측** — [`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)
```
+15초 well-known=200 토큰발급=200
→ 재시작 없이 회복
keycloak-0 restarts=0
keycloak-1 restarts=0
```
**어디를 봐야 하는가** — 500 이던 `well-known``200` 이 된 것, 그리고
**`RESTARTS` 가 여전히 0** 인 것.
**이 결과가 의미하는 것** — **커넥션 풀이 이미 붙어 있었으므로 테이블이
돌아오자마자 동작했다.** A-2 에서 본 것과 같은 자가 회복이다. 파드를 만질
필요가 없다 — 만졌다면 「복구 절차에 파드 재시작이 필요하다」는 잘못된 절차가
문서에 남았을 것이다.
## 5-4. 세션이 살아났나
**확인** — 1-3 과 같은 쿼리
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select user_session_id, offline_flag from offline_user_session"
```
**실측** — 원래 실행은 realm 이름을 함께 뽑았다
```
user_session_id | realm
--------------------------+-------------------
E1q5xI7tt4U_WhZpW7rEPIF2 | master
2ap3DyRiBF8OdMiqCodsJ0mp | master
Zsk4QcgXf_qgyMKzde5AG-Fz | master
vsDgCVo12-qX0CC63ZmYzbYF | keycloak-patterns
(4 rows)
```
**어디를 봐야 하는가** — **1-6 확인 ④ 의 덤프 안에서 봤던 id 가 그대로
있는가.** `E1q5xI7tt4U_WhZpW7rEPIF2` 가 덤프의 `COPY` 블록에도, 복구된
테이블에도 있다 — **파일에서 DB 로 실제로 넘어온 것을 눈으로 잇는다.**
**이 결과가 의미하는 것** — 세션이 백업에서 복원된다. 로그인 상태가 유지된다.
## 5-5. RTO 와 RPO 를 계산한다
**확인** — 적어 둔 시각 셋을 나란히 놓는다
```
14:59:47 파괴
15:00:12 복구 시작
15:00:13 복구 완료
~15:00:28 서비스 정상 확인
RTO = 41초
```
**어디를 봐야 하는가** — 41초 중 **복구 명령 자체는 1초**다. 나머지는
「파괴를 알아채고 무엇을 할지 정하는 시간」이며, 이 실험에서는 이미 알고
있었으므로 25초였다. **실제 사고에서는 이 부분이 대부분을 차지한다.**
### RPO 는 두 겹이다
```
① 마지막 덤프 이후의 모든 변경 ← 백업 주기가 정한다
② A-3 에서 측정한 synchronous_commit 손실 ← 수백 ms
실제 RPO = ① + ②
```
A-3 은 **클라이언트가 200 을 받은 로그인 153건 중 4건이 DB 에 없었다**는 것을
측정했다. **백업 주기만 보고 RPO 를 말하면 ②를 빠뜨린다.**
### 이 실험대의 규모는 현실적이지 않다
| | 이 실험대 | 운영 |
|---|---|---|
| 덤프 크기 | 395KB | GB~TB |
| 복구 시간 | 1초 | 분~시간 |
| 세션 수 | 3~4 | 수만 |
**복구가 1초인 것은 데이터가 작기 때문**이고, 이 실험이 확인한 것은
**절차가 맞다는 것**뿐이다. 시간은 규모에 따라 완전히 달라진다.
## 5-6. ★ 덤프를 다른 장애 도메인으로 옮긴다 — 사람이 쳐야 하는 부분
**여기가 이 실험이 「못 했다」로 남긴 항목이다.** 덤프는 아직 DB 와 같은
기계에 있다.
### 무엇을 사람이 쳐야 하나
| 하는 일 | 어디서 | sudo |
|---|---|---|
| 덤프 뜨기 · 복구 | `kc-lab-1` | 게스트는 **무암호** — 스크립트로도 된다 |
| 덤프를 호스트의 사용자 홈에 두기 | `test-server` | 필요 없다 |
| **덤프를 root 소유 경로(`/var/backups` 등)에 두기** | `test-server` | **비밀번호를 묻는다 — 사람이 친다** |
**호스트의 `sudo` 는 비대화 실행이 반드시 실패한다.** 실제로 그 벽에 부딪힌
기록이 있다.
**실측** — [`d4-certificate-renewal/01-certificate-state.txt`](../../evidence/d4-certificate-renewal/01-certificate-state.txt)
```
$ sudo -n -l
sudo: a password is required
```
`-n` 은 「비밀번호를 물어보지 말라」는 뜻이고, 호스트에서는 그게 곧 실패다.
**그러므로 백업을 호스트의 보호된 경로에 두는 단계는 자동화할 수 없다.**
`ssh -t` 로 붙어 사람이 비밀번호를 쳐야 한다(`-t` 가 없으면 sudo 가
비밀번호를 읽을 tty 가 없다).
**하기****미검증**. 이 실험대는 여기까지 하지 않았다. 호스트 이름과 경로는
당신 배치에 맞춘다
```bash
# ① 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
wc -c /tmp/keycloak-backup.sql
ssh test-server 'wc -c ~/keycloak-backup-2026-09-04.sql'
```
**어디를 봐야 하는가** — 두 숫자가 같은가. 다르면 전송이 잘린 것이다.
**이 결과가 의미하는 것** — 이것으로도 **부족하다.** 호스트는 VM 두 대를
품고 있는 기계이므로, 호스트가 죽으면 게스트도 덤프도 같이 간다.
**진짜 요건은 「다른 기계」가 아니라 「다른 장애 도메인」이다.**
> **이 실험이 확인하지 않은 것** — 백업 자동화, 보존 주기, 복구 리허설의
> 정기 실행. 이번엔 손으로 한 번 떴고, 한 번 되돌렸다. 그것만 참이다.
## 5-7. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 테이블 | `psql -c "select count(*) from pg_tables where schemaname='public'"` | 101 |
| 데이터 | 5-2 의 `-tAc` 한 줄 | 백업 시점과 **문자 단위로 동일** |
| 세션 | `select count(*) from offline_user_session` | 파괴 전과 같은 수 |
| 파드 | `sudo 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` |
| **로그인** | 4-1 의 토큰 발급 | **`200`** ← 이것이 진짜 판정 |
| 덤프 | `ls -l /tmp/keycloak-backup.sql` | 남겨 둔다. 다음 실험(D-2)의 전제다 |
**덤프는 지우지 않는다.** [D-2](d2-version-upgrade.md) 가 이 파일을 전제로 한다.
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이거나, 이 절차에서 실제로 갈리는
지점이다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 복구가 1초 만에 끝났는데 데이터가 없다 | **`exec``-i` 가 없다.** 오류도 안 난다 | 5-2 의 대조. `-i` 를 붙여 다시 |
| 복구에서 `already exists` 가 쏟아진다 | 덤프를 `--clean --if-exists` 없이 떴다 | `grep -c '^DROP TABLE' /tmp/keycloak-backup.sql` — 0 이면 그것이다 |
| 덤프 파일은 있는데 복구가 중간에 멈춘다 | 덤프가 잘렸다 | `tail -3``dump complete` 가 있는가 — 1-6 확인 ③ |
| 파괴했는데 정문이 계속 `200` | **정상이다.** realm 캐시가 서빙한다 | 토큰 발급으로 판정 — 4-1 |
| `psql: relation "realm" does not exist` | 파괴가 걸린 것이다 | 그게 3-1 의 기대 출력이다 |
| `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'` 필터가 있다 | **같은 쿼리끼리** 비교 — 1-3 |
| 백업이 0바이트다 | `pg_dump` 가 인증에서 막혔다 | `-U keycloak -d keycloak` 를 확인. 파일을 지우고 다시 뜬다 |
| 호스트에서 `sudo` 가 안 먹는다 | **호스트 sudo 는 비밀번호를 요구한다** | `ssh -t` 로 붙어 사람이 친다 — 5-6 |
---
# 이 가이드에 스크립트가 없는 이유
원래 실행은 백업·파괴·복구를 스크립트 하나로 돌렸다. 그래서 증거 파일의
줄이 `realms|clients|users|sessions|authclients = 2|15|2|3|1` 처럼 이름표가
붙은 형태다.
**그 형태는 사람이 치는 형태가 아니다.** 그리고 이 실험에서는 스크립트가
특히 위험하다 — **`DROP SCHEMA` 와 복구가 한 파일에 있으면 중간에서 멈췄을 때
무엇이 실행됐는지 알 수 없다.** 파괴는 손으로 치고, 그 직후에 눈으로 확인하고,
복구도 손으로 친다. 각 단계 사이에 사람이 서 있어야 한다.
---
# 다음
| 실험 | D-1 이 남긴 것 |
|---|---|
| [D-2](d2-version-upgrade.md) 버전 업그레이드 | **백업이 전제다.** 스키마가 바뀐 뒤에는 태그를 되돌려도 안 뜬다 |
| [D-3](d3-secret-management.md) 비밀 관리 | **덤프 안에 무엇이 들어 있는지** 생각한다. 백업을 잘 챙길수록 비밀도 잘 복사된다 |
| [A-4](a4-node-loss.md) 노드 상실 | PVC 가 노드에 못박혀 있다. **덤프가 같은 노드에 있으면 둘 다 잃는다** |
| 관측 | **「DB 가 살아 있다」만 보는 헬스체크는 빈 DB 를 통과시킨다.** 업무 트랜잭션을 재는 감시가 따로 필요하다 |
| 전부 | **복구해 보지 않은 백업은 백업이 아니다.** 그리고 복구 판정은 `200` 이 아니라 데이터 대조로 한다 |