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>
36 KiB
D-1 재현 가이드 — 스키마를 통째로 지우고 백업이 진짜 백업인지 직접 본다
해설 문서: docs/experiment-d1-backup-restore.md ·
증거 원문: docs/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를 먼저 하면 좋다. 「DB 프로세스가 죽었을 때」의 모양을 봐 둬야 이 실험의200이 얼마나 이상한지 안다.A-3도 먼저다. 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. 되돌린다 의 명령 하나면 된다.
표시 규약
| 표시 | 뜻 |
|---|---|
| 실측 | 2026-09-04 14:57–15:00 KST 실행 기록의 출력 원문. 증거 파일에 그대로 있다 |
| 형태 | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| 미검증 | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
파드 이름·세션 id·바이트 수는 당신 환경에서 다르다. 이 문서는
자리표시자(<...>)를 쓰지 않는 대신 그 값을 뽑는 명령을 먼저 적는다.
예시로 실린 값은 전부 위 실행 기록의 실제 값이다.
0. 왜 이 실험을 하는가
「백업이 있다」와 「복구해 봤다」는 다른 문장이다. 백업 스크립트가 매일 도는 것과, 그 파일로 실제로 서비스를 되살릴 수 있는 것 사이에는 시험되지 않은 가정이 여러 개 있다.
이 실험은 그중 둘을 판정한다.
| # | 질문 | 어떻게 가르나 |
|---|---|---|
| ① | 덤프에 필요한 것이 다 들어가는가 | 특히 세션. 안 들어가면 복구 후 전원 재로그인이다 |
| ② | 복구 절차가 실제로 도는가 | 오류 없이 끝나고 데이터가 일치하는가 |
그리고 부수 질문이 하나 붙는다 — DB 가 비면 무엇이 깨지는가. 이게 A-2 와 대비되는 지점이고, 실제로 이 실험에서 가장 놀라운 결과가 나왔다.
A-2 DB 프로세스 정지 → 커넥션 실패 → readiness DOWN → 파드가 Service 에서 빠짐
D-1 스키마만 삭제 → 커넥션 정상 → readiness UP → ?
커넥션은 되는데 테이블이 없는 상태는 단일 장애 주입으로는 잘 안 만들어진다. 그래서 이 실험이 필요하다.
1. 기준선 — 지우기 전에
시험군만 재는 측정은 측정이 아니다. 파괴 후에 볼 것을 파괴 전에 똑같은 명령으로 먼저 봐 둔다. 복구가 「완전 일치」인지 판정하려면 일치시킬 상대가 있어야 한다.
넓은 것부터 좁혀 간다.
파드 → 데이터 개수 → 세션 → 밖에서 본 상태 → 덤프 → ★ 덤프 검증 → 덤프의 위치
1-1. 파드가 다 떠 있나
확인
sudo kubectl -n keycloak-lab get pods -o wide
실측 — 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/1RESTARTS가0— 뒤에서 이 값이 오르면 파괴가 엉뚱한 것을 건드린 것이다postgres파드가 있는지, 어느 노드에 있는지
이 결과가 의미하는 것 — 지금은 전부 정상이다. 이 표의 값을 적어 둔다.
복구 판정에서 「재시작 없이 돌아왔는가」를 볼 때 RESTARTS 를 비교한다.
1-2. 데이터가 얼마나 있나
처음 한 번은 읽는 형태로 친다. 값만 뽑는 형태부터 배우면 psql 이 무엇을 돌려주는지 모르게 된다.
확인
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 면 이미 스키마가 없는 것이다.
이제 다섯 개를 한 줄로 모은다. 비교할 값이 필요할 때만 이 형태를 쓴다.
확인
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
realms|clients|users|sessions|authclients = 2|15|2|3|1
실측 줄에는 값이 다섯이고 위 명령은 넷을 뽑는다. 원래 실행 스크립트는 「인가된 클라이언트(authclients)」를 하나 더 셌는데, 해설 문서의 재현 절차에는 그 쿼리가 남아 있지 않다. 없는 컬럼을 지어내지 않고 넷으로 둔다 — 판정에는 넷으로 충분하고, 다섯째가 필요하면 당신이 세는 쿼리를 정해서 양쪽에 같이 쓰면 된다.
손으로 치면 이름표 없이
2|15|2|3만 나온다.-tAc는 헤더 없이(-t) 정렬 없이(-A) 한 줄만이라는 뜻이고, 여러 값을 나란히 비교할 때 이 형태가 편하다.
어디를 봐야 하는가 — 숫자 넷. 이 줄을 그대로 복사해 둔다. 복구 후에 같은 명령을 쳐서 문자 단위로 같은지 본다.
이 결과가 의미하는 것 — 이 값들이 「복구가 성공했다」의 판정 조건이다. 하나라도 다르면 복구가 부분적으로만 된 것이다.
1-3. 세션이 DB 에 있나 — 이게 덤프에 들어갈지가 관건이다
확인
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). 두 숫자가 다르다고 놀라지 말고, 복구 전후에 같은 쿼리끼리 비교한다.
이 결과가 의미하는 것 — 세션이 DB 테이블에 있다는 것은
persistent-user-sessions 가 켜져 있다는 뜻이다(A-0). 그래서 세션이 백업
대상이 된다. volatile 이었다면 세션은 애초에 DB 에 없고, 복구해도 전원
재로그인이다 — 백업의 가치가 달라진다.
1-4. 밖에서 정상인가
처음 한 번은 응답을 읽는다.
확인
curl -I https://auth.hyeonworks.com/realms/master
헤더가 통째로 나온다. HTTP/2 200, content-type: application/json 을 본다.
같은 것을 반복해서 재고 비교할 때만 코드만 뽑는다.
확인
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
(이것도 파괴 직후 값이다 — 그게 결과다)
https://auth.hyeonworks.com/realms/master HTTP 200
https://app1.hyeonworks.com/ HTTP 200
어디를 봐야 하는가 — 둘 다 200.
이 결과가 의미하는 것 — 지금은 당연히 200 이다. 문제는 3절에서도 이 값이 200 이라는 것이고, 그래서 이 두 줄은 「정상 판정에 쓸 수 없는 지표」의 예시로 남는다.
1-5. 백업을 뜬다
하기
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
시작: 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 에 덤프를 밀어 넣는 상황이 훨씬 흔하고, 그때 이 두 옵션이 있고 없고가 갈린다.
되돌리기 — 이 단계는 읽기만 한다. 파일이 마음에 안 들면 지우고 다시 뜬다.
rm -f /tmp/keycloak-backup.sql
1-6. ★ 덤프를 검증한다 — 여기를 건너뛰면 2절은 자살행위다
「파일이 생겼다」는 「복구할 수 있다」가 아니다. pg_dump 가 중간에
실패해도 파일은 남고, 크기도 0 이 아니다.
확인 ① 파일이 실제로 있고 크기가 말이 되는가
ls -l /tmp/keycloak-backup.sql
wc -l /tmp/keycloak-backup.sql
실측
크기: 394945 bytes (6956 줄)
확인 ② 테이블 정의가 다 들어갔는가
grep -c '^CREATE TABLE' /tmp/keycloak-backup.sql
실측
포함된 테이블 수: 101
어디를 봐야 하는가 — 101 이라는 절대값이 중요한 게 아니라, 1-2 에서 본 DB 와 자릿수가 맞는지가 중요하다. 두 자리로 떨어지면 덤프가 잘린 것이다.
확인 ③ 마지막 줄이 정상 종료인가
tail -3 /tmp/keycloak-backup.sql
형태
--
-- PostgreSQL database dump complete
--
어디를 봐야 하는가 — dump complete. 이 줄이 없으면 덤프가 중간에
끊긴 것이고, 그 파일로는 복구가 안 된다. 이 한 줄이 「파일이 생겼다」와
「덤프가 끝났다」를 가른다.
확인 ④ ★ 세션이 들어 있는가 — 이 실험의 질문 ①
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
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. ★ 덤프가 지금 어디에 있는가
확인
ls -l /tmp/keycloak-backup.sql
df -h /tmp
어디를 봐야 하는가 — 경로. /tmp 다. 이 파일은 지금 kubectl 을 친
그 기계의 디스크에 있다.
이 결과가 의미하는 것 — A-4 에서 local-path PVC 가 노드에 못박혀
있는 것을 봤다. 그 노드가 안 돌아오면 DB 볼륨도 안 돌아온다. 그때 유일한
길이 덤프인데, 덤프도 같은 기계에 있으면 같이 사라진다.
같은 장애 도메인에 있는 백업은 백업이 아니다. 원래 실행에서도 덤프는
test-server:/tmp에 있었고, 해설 문서는 그것을 **「가장 중요한 미검증 항목」**으로 기록했다. 옮기는 절차는 5-6 에 있다 — 파괴 전에 읽어만 두고, 실제 이동은 복구가 끝난 뒤에 한다.
2. 주입 — 스키마를 통째로 지운다
여기부터 데이터가 사라진다. 되돌리는 명령을 먼저 읽어 둔다.
되돌리기 (5절에서 자세히 한다)
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak \
< /tmp/keycloak-backup.sql
이 명령이 유일한 되돌리기 수단이다. 1-6 의 확인 ①~④ 를 통과하지 않았으면 지금 돌아가서 한다.
하기
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
=== ★ 파괴 — 스키마를 통째로 지운다 ===
시각: 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개인가
확인
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 인자를 본다).
확인 — 애플리케이션 테이블이 정말 없는지 직접 물어본다
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. ★ 그런데 밖은 멀쩡하다
확인
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
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. 엉뚱한 것을 죽이지 않았나
확인
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. 무엇이 깨지고 무엇이 안 깨지나
전부 깨지지 않는다. 세 경로를 나눠서 친다.
확인
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
/.well-known/openid-configuration HTTP 500
/protocol/openid-connect/certs HTTP 200
토큰 발급 (DB 쓰기 필요) HTTP 400
토큰 발급은 값이 필요하므로 따로 친다. 미검증 — 원래 실행은 스크립트였다
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)"
비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 값은 터미널에도 셸 히스토리에도 남지 않는다. 길이만 확인하려면:
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. 로그가 이유를 말한다
확인
sudo kubectl -n keycloak-lab logs keycloak-0 --tail=50
실측 — 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. 되돌린다
하기
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
시작: 15:00:12
완료: 15:00:13
오류 줄: 0
★ -i 를 빠뜨리면 아무 일도 안 일어난다 — 그리고 오류도 안 난다
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 의 데이터 대조다. 그래서 대조는 선택이 아니다.
확인 — 오류 줄을 센다
grep -ci '^ERROR' /tmp/restore.log
tail -5 /tmp/restore.log
어디를 봐야 하는가 — 0. 0 이 아니면 어떤 줄이 실패했는지 본다.
--clean --if-exists 로 뜬 덤프를 빈 DB 에 넣으면 오류가 0 인 것이 정상이다.
5-2. 데이터를 대조한다 — 여기가 진짜 판정이다
확인 — 1-2 와 똑같은 명령을 친다
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
복구 후: 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초쯤 뒤
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
+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 과 같은 쿼리
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
$ sudo -n -l
sudo: a password is required
-n 은 「비밀번호를 물어보지 말라」는 뜻이고, 호스트에서는 그게 곧 실패다.
그러므로 백업을 호스트의 보호된 경로에 두는 단계는 자동화할 수 없다.
ssh -t 로 붙어 사람이 비밀번호를 쳐야 한다(-t 가 없으면 sudo 가
비밀번호를 읽을 tty 가 없다).
하기 — 미검증. 이 실험대는 여기까지 하지 않았다. 호스트 이름과 경로는 당신 배치에 맞춘다
# ① 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'
확인 — 옮긴 파일이 온전한가. 크기를 양쪽에서 세서 비교한다
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 가 이 파일을 전제로 한다.
막히면
전부 이 실험대가 실제로 겪은 증상이거나, 이 절차에서 실제로 갈리는 지점이다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 복구가 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 버전 업그레이드 | 백업이 전제다. 스키마가 바뀐 뒤에는 태그를 되돌려도 안 뜬다 |
| D-3 비밀 관리 | 덤프 안에 무엇이 들어 있는지 생각한다. 백업을 잘 챙길수록 비밀도 잘 복사된다 |
| A-4 노드 상실 | PVC 가 노드에 못박혀 있다. 덤프가 같은 노드에 있으면 둘 다 잃는다 |
| 관측 | 「DB 가 살아 있다」만 보는 헬스체크는 빈 DB 를 통과시킨다. 업무 트랜잭션을 재는 감시가 따로 필요하다 |
| 전부 | 복구해 보지 않은 백업은 백업이 아니다. 그리고 복구 판정은 200 이 아니라 데이터 대조로 한다 |