Files
keycloak-pattern/docs/guides/experiments/d1-backup-restore.md
T
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

36 KiB
Raw Blame History

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 에서 친다. kubectlsudo 로 쓴다 (kubeconfig 를 사용자 홈에 복사해 뒀다면 sudo 는 빼도 된다).
  • 네임스페이스는 keycloak-lab 이다.
  • jq 는 이 실험대 어디에도 없다. 이 가이드는 jq 를 쓰지 않는다.
  • 덤프를 다른 기계로 옮기는 단계(5-6)만 호스트(test-server)가 필요하고, 호스트의 sudo 는 비밀번호를 묻는다. 그 부분은 사람이 직접 친다.

주의 — 이건 데이터베이스를 비우는 실험이다

DROP SCHEMA public CASCADErealm·client·user·세션을 전부 지운다. 되돌리는 수단은 당신이 방금 뜬 덤프 파일 하나뿐이다. 그래서 이 가이드는 덤프를 검증하기 전에는 2절로 넘어가지 않는다. 전 구간 약 20분이고, 파괴 구간 자체는 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. 파드가 다 떠 있나

확인

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/1
  • RESTARTS0 — 뒤에서 이 값이 오르면 파괴가 엉뚱한 것을 건드린 것이다
  • 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-110data 열의 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 SCHEMACREATE 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-known200 이 된 것, 그리고 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 -3dump 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 -- curlexit 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 이 아니라 데이터 대조로 한다