# 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:57–15: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` 이 아니라 데이터 대조로 한다 |