--- id: 7dc48b91-e31b-455c-9a9d-c766f95ff491 kind: SETUP slug: reproduce-d1-backup-restore title: 스키마를 통째로 지우고 덤프 하나로 되살아나는지 본다 topic: operations-that-report-success topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신 project: keycloak-session-store status: 게시 전 studio: "https://hyeonworks.com/studio/documents/7dc48b91-e31b-455c-9a9d-c766f95ff491/edit" pinnedVersions: - name: Keycloak version: 26.7.0 source: - final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-1 - final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서 sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af --- # 스키마를 통째로 지우고 덤프 하나로 되살아나는지 본다 덤프를 뜨고 검증 넷을 통과한 뒤 스키마를 통째로 지우고 같은 명령으로 복구를 대조하는 절차다. 되돌리는 수단이 방금 뜬 파일 하나뿐이라 검증이 파괴보다 먼저 온다. ## 관계 - **되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다** 이 절차가 남긴 덤프 위에 서는 판올림 실험의 결론이다. 스키마가 움직인 뒤에는 덤프만이 되돌리는 수단이 된다. - **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다** `-i` 를 빠뜨린 복구가 그 아홉 건과 같은 모양으로 끝난다. 왜 따로 확인해야 하는지를 그쪽이 적는다. - **up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다** 데이터베이스가 통째로 비었는데 정문이 `200` 인 상태를 지표가 어떻게 놓치는지 다룬다. - **이미지 태그를 올렸다 내리며 롤백이 언제 막히는지 가른다** 이 절차 다음에 오는 편이고, 여기서 뜬 덤프를 전제로 시작한다. 덤프를 지우지 않는 까닭이 그쪽에 있다. - **PostgreSQL 을 정상 종료시키고 네 경로를 잰다** 프로세스가 죽었을 때의 모양이다. 먼저 봐 둬야 여기의 `200` 이 얼마나 이상한지 안다. - **PostgreSQL 을 진짜로 크래시시키고 잃은 로그인을 센다** 실제 복구 지점 목표의 두 번째 겹이 그 편에서 나온다. ## 본문 ## 읽기 전에 — 어디서 치는가 명령은 `[lab host]` 에서 친다. `kubectl` 에 `sudo` 를 붙이지 않는다 — 가이드가 「kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다」고 스스로 괄호를 달아 두었다. 마지막 한 단계만 호스트(`test-server`)로 넘어가고, 거기서는 사람이 비밀번호를 친다. **`[kc-lab-1]` 라벨이 붙은 블록은 게스트 셸이다.** lab host 에서 `ssh kc-lab-1` 로 들어가서 치고, 끝나면 `exit` 로 나온다. `ssh kc-lab-2 '…'` 한 줄 형태는 lab host 에서 그대로 쳐도 되므로 `[lab host]` 로 두었다. **원 가이드는 이 명령들을 `kc-lab-1` 에서 치라고 적었다.** 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 `sudo` 없는 `kubectl` 이 `permission denied` 로 막힌다 — kubeconfig 는 lab host 의 `~/.kube/config` 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 `kubectl` 블록의 기계 이름을 `[lab host]` 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다. | 무엇 | 값 | |---|---| | 네임스페이스 | `keycloak-lab` | | 주입 수단 | `DROP SCHEMA public CASCADE; CREATE SCHEMA public;` | | 되돌리는 수단 | 방금 뜬 덤프 파일 하나 — `/tmp/keycloak-backup.sql` | | 전 구간 | 약 20분. 파괴 구간 자체는 1분 안쪽 | | 잃는 것 | realm · client · user · 세션 전부 | | `jq` | 이 실험대 어디에도 없다. 이 절차도 쓰지 않는다 | **이 절차에는 스크립트가 없다.** 원래 실행은 백업·파괴·복구를 스크립트 하나로 돌렸고, 그래서 증거 파일의 줄에는 `realms|clients|users|sessions|authclients = 2|15|2|3|1` 처럼 이름표가 붙어 있다. 사람이 치는 형태가 아니다. 그리고 이 실험에서 스크립트는 특히 위험하다 — `DROP SCHEMA` 와 복구가 한 파일에 있으면 중간에서 멈췄을 때 무엇이 실행됐는지 알 수 없다. 파괴를 손으로 치고, 눈으로 확인하고, 복구도 손으로 친다. **공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. ## 이 실험이 가르는 것 「백업이 있다」와 「복구해 봤다」는 다른 문장이다. 백업 스크립트가 매일 도는 것과 그 파일로 실제로 서비스를 되살리는 것 사이에는 시험되지 않은 가정이 여러 개 있고, 이 절차는 그중 둘을 판정한다. | # | 질문 | 어떻게 가르나 | |---|---|---| | ① | 덤프에 필요한 것이 다 들어가는가 | 특히 세션. 안 들어가면 복구 후 전원 재로그인이다 | | ② | 복구 절차가 실제로 도는가 | 오류 없이 끝나고 데이터가 일치하는가 | 부수 질문이 하나 붙는다 — 데이터베이스가 비면 무엇이 깨지는가. 프로세스를 내린 A-2 와 여기가 갈라진다. ```text A-2 DB 프로세스 정지 → 커넥션 실패 → readiness DOWN → 파드가 Service 에서 빠짐 D-1 스키마만 삭제 → 커넥션 정상 → readiness UP → ? ``` 커넥션은 되는데 테이블이 없는 상태는 단일 장애 주입으로 잘 안 만들어진다. 그래서 이 절차가 따로 있다. 가이드는 끝났을 때 확인되는 것을 일곱으로 적는다. 덤프 파일 안에 세션 행이 실제로 들어 있는 것, 데이터베이스를 통째로 비웠는데 정문이 `200` 인 것, 파드가 `1/1 Running` 인 채로 테이블이 0개인 것, `certs` 200 · `well-known` 500 · 토큰 400 으로 부분만 깨지는 것, 복구가 1초 만에 오류 0건으로 끝나는 것, 세션까지 되살아나는 것, 그리고 덤프가 데이터베이스와 같은 기계 위에 놓여 있는 것. **복구가 이 편에서는 관찰의 일부다.** 질문 ②의 답이 복구 절에서 나오므로 아래 「복구와 원상복구 확인표」는 원상복구만이 아니라 판정을 함께 싣는다. ## 전제와 되돌리기 - A-2 를 먼저 하면 좋다. 데이터베이스 프로세스가 죽었을 때의 모양을 봐 둬야 이 실험의 `200` 이 얼마나 이상한지 안다. - A-3 도 먼저다. 실제 복구 지점 목표의 두 번째 겹이 거기서 나온다. - 네임스페이스는 `keycloak-lab` 이다. - 덤프를 다른 기계로 옮기는 마지막 단계만 호스트(`test-server`)가 필요하고, 호스트의 `sudo` 는 비밀번호를 묻는다. 그 부분은 사람이 직접 친다. **이 실험은 데이터베이스를 비운다.** `DROP SCHEMA public CASCADE` 는 realm·client·user·세션을 전부 지운다. 되돌리는 수단은 방금 뜬 덤프 파일 하나뿐이고, 그래서 덤프를 검증하기 전에는 주입 절로 넘어가지 않는다. 되돌리기는 한 줄이고, 파괴하기 전에 읽어 둔다. ```bash label="[lab host] 파괴하기 전에 읽어 두는 되돌리기 한 줄" kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak \ < /tmp/keycloak-backup.sql ``` **`-i` 가 이 명령의 전부다.** 빠뜨리면 아무 일도 안 일어나고 오류도 안 난다. 왜 그런지는 주입 검증 절의 마지막 단계에서 본다. ## 주입 전에 같은 명령으로 먼저 본다 시험군만 재는 측정은 측정이 아니다. 파괴 후에 볼 것을 파괴 전에 똑같은 명령으로 먼저 봐 둔다. 복구가 완전 일치인지 판정하려면 일치시킬 상대가 있어야 하는데, `DROP SCHEMA` 를 친 뒤에는 그 상대를 만들 방법이 없다. 넓은 것부터 좁혀 가고, 마지막 세 칸은 덤프 자체를 향한다. ```text 파드 → 데이터 개수 → 세션 → 밖에서 본 상태 → 덤프 → ★ 덤프 검증 → 덤프의 위치 ``` ### 1. 파드가 어디에 몇 개 있는가 **무엇을 보는가** — 파드 넷의 상태와 배치. ```bash label="[lab host] 파드 배치를 본다" kubectl -n keycloak-lab get pods -o wide ``` **어디를 보나** — 실측은 이렇다(observed, `02-destruction.txt`). 파괴 직후 목록인데 파괴 전후가 같다는 것이 이 실험의 결과이므로 파괴 전 값으로도 읽는다. 증거에 옮겨진 네 줄에는 `-o wide` 가 덧붙이는 `NODE` 열이 없고 `postgres` 행도 빠져 있다. 아래에 없다고 해서 그 파드가 없지는 않다 — 노드 이름과 `postgres` 행은 자기 화면에서 읽는다. ```text bff-555df79c97-6j86w 1/1 Running 0 49m bff-555df79c97-vgg6g 1/1 Running 0 49m keycloak-0 1/1 Running 0 4m15s keycloak-1 1/1 Running 0 4m38s ``` **이 값이 뜻하는 것** — `READY` 가 전부 `1/1` 이고 `RESTARTS` 가 `0` 이다. 자기 화면의 `NODE` 열에서 `postgres` 파드가 어느 노드에 떠 있는지 읽고 적어 둔다 — 덤프가 그 노드와 같은 디스크에 놓였는지를 §7 에서 그 이름으로 가른다. 뒤에서 `RESTARTS` 가 오르면 파괴가 엉뚱한 데를 건드렸다는 신호다. ### 2. 데이터가 몇 건 있는가 **무엇을 보는가** — realm·client·user·세션의 개수. 처음 한 번은 읽는 형태로 친다. 값만 뽑는 형태부터 배우면 `psql` 이 무엇을 돌려주는지 모르게 된다. ```bash label="[lab host] ① psql 이 무엇을 돌려주는지 한 번 본다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "select count(*) from realm" ``` **어디를 보나** — 모양은 이렇다(observed). ```text count ------- 2 (1 row) ``` 숫자 하나와 `(1 row)` 를 본다. 여기서 오류가 나면 뒤의 모든 단계가 무의미하다. `psql: error: connection to server ... failed` 면 데이터베이스가 아직 안 붙은 것이고, `relation "realm" does not exist` 면 스키마가 이미 없다. 이제 넷을 한 줄로 모은다. 비교할 값이 필요할 때만 이 형태를 쓴다. ```bash label="[lab host] ② 대조할 한 줄을 뽑는다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \ "select (select count(*) from realm), (select count(*) from client), (select count(*) from user_entity), (select count(*) from offline_user_session where offline_flag='0')" ``` **어디를 보나** — 실측은 이렇다(observed, `01-backup.txt`). ```text realms|clients|users|sessions|authclients = 2|15|2|3|1 ``` **이 값이 뜻하는 것** — 원래 실행은 스크립트로 돌렸고 인가된 클라이언트를 하나 더 셌다. 그래서 증거 줄에는 값이 다섯이고 이름표가 붙어 있다. 위 명령으로 넷을 뽑으면 이름표 없이 `2|15|2|3` 만 나온다. 다섯째 쿼리는 해설 문서의 재현 절차에 남아 있지 않아 가이드가 넷으로 뒀다 — 없는 컬럼을 지어내지 않고, 다섯째가 필요하면 세는 쿼리를 정해서 양쪽에 같이 쓴다. `-tAc` 는 헤더 없이(`-t`) 정렬 없이(`-A`) 한 줄만이라는 뜻이다. **이 줄을 그대로 복사해 둔다.** 복구 후에 같은 명령을 쳐서 문자 단위로 같은지 본다. 하나라도 다르면 복구가 부분적으로만 됐다. ### 3. 세션이 데이터베이스 안에 있는가 **무엇을 보는가** — 질문 ①의 재료. 세션 행이 실제로 테이블에 있어야 덤프에 들어갈 것이 있다. ```bash label="[lab host] 세션 행을 나열한다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "select user_session_id, offline_flag, realm_id from offline_user_session" ``` **어디를 보나** — 행이 몇 개 있고 id 가 어떻게 생겼는지만 본다(observed). ```text user_session_id | offline_flag | realm_id --------------------------+--------------+-------------------------------------- E1q5xI7tt4U_WhZpW7rEPIF2 | 0 | 7845f394-723a-4d07-b530-c7416b2e1d31 ... ``` **이 값이 뜻하는 것** — 행이 0개면 질문 ①을 판정할 수 없다. 그때는 관리 콘솔에 한 번 로그인해서 세션을 만들고 다시 본다. 세션이 데이터베이스 테이블에 있다는 것은 `persistent-user-sessions` 가 켜져 있다는 뜻이고(A-0), 그래서 세션이 백업 대상이 된다. volatile 이었다면 세션은 애초에 데이터베이스에 없고 복구해도 전원 재로그인이라 백업의 값어치가 달라진다. **두 쿼리가 다른 것을 센다.** 앞의 개수 쿼리는 `offline_flag='0'` 만 셌고 이 쿼리는 전부 나열한다. 원래 실행에서도 개수는 `3`, 나열은 `4 rows` 였다(`03-restore.txt`). 두 숫자가 다른 것을 이상하게 여기지 말고 복구 전후에 같은 쿼리끼리 비교한다. **문제가 생기면** — 나열은 되는데 개수가 0이면 `offline_flag` 필터를 의심한다. ### 4. 밖에서는 무엇이 보이는가 **무엇을 보는가** — 정문과 app1 의 응답. 처음 한 번은 응답을 읽는다. ```bash label="[lab host] ① 헤더를 통째로 본다" curl -I --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master ``` 헤더가 통째로 나온다. `HTTP/2 200`, `content-type: application/json` 을 본다. 같은 것을 반복해서 재고 비교할 때만 코드만 뽑는다. ```bash label="[lab host] ② 코드만 뽑아 둘을 잰다" curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ https://auth.hyeonworks.com/realms/master curl -s -o /dev/null -w '%{http_code}\n' --resolve app1.hyeonworks.com:443:192.168.122.10 \ https://app1.hyeonworks.com/ ``` **어디를 보나** — 실측은 이렇다(observed, `02-destruction.txt`). 이것도 파괴 직후 값이고, 그게 결과다. ```text https://auth.hyeonworks.com/realms/master HTTP 200 https://app1.hyeonworks.com/ HTTP 200 ``` **이 값이 뜻하는 것** — 지금은 당연히 `200` 이다. 파괴 뒤에도 같은 값이 나오므로 이 두 줄은 정상 판정에 쓸 수 없는 지표의 예로 남는다. ### 5. 덤프를 뜬다 **목적** — 되돌리는 수단을 만든다. 이 파일 없이는 다음 절로 못 간다. ① 시각을 남기고 덤프를 뜬다. ```bash label="[lab host] ① 시각과 함께 덤프를 뜬다" date '+%H:%M:%S 백업 시작' kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \ --clean --if-exists > /tmp/keycloak-backup.sql date '+%H:%M:%S 백업 완료' ``` **예상 결과** — 실측은 이렇다(observed, `01-backup.txt`). ```text 시작: 14:59:30 완료: 14:59:30 크기: 394945 bytes (6956 줄) ``` 시각 두 줄과 파일 크기를 본다. 이 규모에서는 1초 미만이다. **왜 필요한가** — 두 옵션은 짝이다. | 옵션 | 무엇을 하나 | 없으면 | |---|---|---| | `--clean` | 복구 시 기존 객체를 DROP 하고 다시 만든다 | `already exists` 오류가 쏟아진다 | | `--if-exists` | 없는 객체를 DROP 할 때 오류를 안 낸다 | 깨끗한 데이터베이스에 복구할 때 오류가 쏟아진다 | `--clean` 만 주면 빈 데이터베이스에 넣는 복구가 깨지고, `--if-exists` 만 주면 아무 효과가 없다 — `DROP` 문 자체가 안 만들어진다. 이 실험은 어차피 빈 데이터베이스에 복구하는데도 두 옵션이 필요하다. 실제 사고는 대개 그렇지 않고, 반쯤 남은 데이터베이스에 덤프를 밀어 넣는 상황이 훨씬 흔하며 그때 이 둘이 있고 없고가 갈린다. **문제가 생기면** — 이 단계는 읽기만 하므로 파일이 마음에 안 들면 지우고 다시 뜬다. ```bash label="[lab host] 덤프가 마음에 안 들면 지우고 다시 뜬다" rm -f /tmp/keycloak-backup.sql ``` ### 6. 덤프를 검증한다 — 넷을 통과해야 다음 절로 간다 **목적** — 「파일이 생겼다」와 「복구할 수 있다」를 가른다. `pg_dump` 가 중간에 실패해도 파일은 남고 크기도 0 이 아니다. 이 단계를 건너뛰면 주입 절은 자살행위라고 가이드는 적는다. ① 크기와 줄 수. ```bash label="[lab host] ① 크기와 줄 수" ls -l /tmp/keycloak-backup.sql wc -l /tmp/keycloak-backup.sql ``` **예상 결과** — 실측은 이렇다(observed). ```text 크기: 394945 bytes (6956 줄) ``` ② 테이블 수. ```bash label="[lab host] ② 덤프 안의 테이블 수" grep -c '^CREATE TABLE' /tmp/keycloak-backup.sql ``` **예상 결과** — 실측은 이렇다(observed). ```text 포함된 테이블 수: 101 ``` 101 이라는 절대값이 아니라 앞에서 본 데이터베이스와 자릿수가 맞는지가 중요하다. 두 자리로 떨어지면 덤프가 잘렸다. ③ 끝까지 쓰였는가. ```bash label="[lab host] ③ 마지막 세 줄" tail -3 /tmp/keycloak-backup.sql ``` **예상 결과** — 모양은 이렇다(observed). ```text -- -- PostgreSQL database dump complete -- ``` `dump complete` 를 본다. 이 줄이 없으면 덤프가 중간에 끊긴 것이고 그 파일로는 복구가 안 된다. 한 줄이 「파일이 생겼다」와 「덤프가 끝났다」를 가른다. ④ 세션이 들어갔는가. 이것이 질문 ① 자체다. ```bash label="[lab host] ④ COPY 블록에 세션 행이 붙어 있는가" grep -c 'offline_user_session' /tmp/keycloak-backup.sql grep -A3 'COPY public.offline_user_session' /tmp/keycloak-backup.sql | cut -c1-110 ``` **예상 결과** — 실측은 이렇다(observed, `01-backup.txt`). ```text offline_user_session 언급: 13 COPY public.offline_user_session (user_session_id, user_id, realm_id, created_on, offline_flag, data, last_session_refre E1q5xI7tt4U_WhZpW7rEPIF2 48b37d33-8419-49aa-9b5b-7731975be50c 7845f394-723a-4d07-b530-c7416b2e1d31 1788500836 0 {"ipAddr 2ap3DyRiBF8OdMiqCodsJ0mp 48b37d33-8419-49aa-9b5b-7731975be50c 7845f394-723a-4d07-b530-c7416b2e1d31 1788501263 0 {"ipAddr ``` **왜 필요한가** — `COPY` 줄 다음에 실제 데이터 행이 붙어 있는가를 본다. `COPY ... FROM stdin;` 바로 뒤에 `\.` 만 있으면 테이블 정의만 들어가고 행은 비어 있는 것이고, 그건 세션을 백업하지 못한 덤프다. `cut -c1-110` 은 `data` 열의 JSON 이 화면을 뒤덮는 것을 막으려는 것이라, 처음 한 번은 `cut` 없이 쳐서 한 행이 얼마나 긴지 봐 둔다. 세션은 덤프에 들어간다. 질문 ①의 답은 「들어간다」이고 근거가 이 `COPY` 블록이다. 복구 절에서 같은 id 들이 되살아나는 것을 확인한다. **문제가 생기면** — 넷 중 하나라도 어긋나면 덤프를 지우고 다시 뜬다. 다음 절로 넘어가지 않는다. ### 7. 덤프가 지금 어디에 있는가 **무엇을 보는가** — 파일의 경로와 그 파일이 올라앉은 디스크. ```bash label="[lab host] 덤프의 경로와 디스크를 본다" ls -l /tmp/keycloak-backup.sql df -h /tmp ``` **이 값이 뜻하는 것** — 경로가 `/tmp` 다. 이 파일은 지금 `kubectl` 을 친 그 기계의 디스크에 있다. A-4 에서 `local-path` PVC 가 노드에 못박혀 있는 것을 봤고, 그 노드가 안 돌아오면 데이터베이스 볼륨도 안 돌아온다. 그때 유일한 길이 덤프인데 덤프도 같은 기계에 있으면 같이 사라진다. **같은 장애 도메인에 있는 백업은 백업이 아니다.** 원래 실행에서도 덤프는 `test-server:/tmp` 에 있었고, 해설 문서는 그것을 가장 중요한 미검증 항목으로 기록했다. 옮기는 절차는 복구 절에 있고, 파괴 전에는 읽어만 두고 실제 이동은 복구가 끝난 뒤에 한다. ## 주입 여기부터 데이터가 사라진다. 되돌리는 명령은 전제 절에 있고, 덤프 검증 넷을 통과하지 않았으면 지금 돌아가서 한다. ### 1. 스키마를 통째로 지운다 **목적** — 커넥션은 살아 있는데 테이블만 없는 상태를 만든다. ① 시각을 남기고 친다. ```bash label="[lab host] ① 시각을 남기고 스키마를 지운다" date '+%H:%M:%S 파괴' kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" ``` **예상 결과** — 실측은 이렇다(observed, `02-destruction.txt`). ```text === ★ 파괴 — 스키마를 통째로 지운다 === 시각: 14:59:47 DROP SCHEMA CREATE SCHEMA ``` `DROP SCHEMA` 와 `CREATE SCHEMA` 두 줄을 본다. `NOTICE: drop cascades to 101 other objects` 같은 줄이 함께 나오는 것이 정상이다. **왜 필요한가** — 시각을 반드시 적어 둔다. 복구 절의 복구 시간 목표가 이 시각에서 시작한다. `CREATE SCHEMA public` 을 붙이는 까닭은 `public` 스키마 자체를 지우면 복구 스크립트가 들어갈 곳이 없기 때문이다. 지우는 것은 안의 객체이고, 빈 스키마는 남겨 둬야 `pg_dump` 출력이 그대로 들어간다. **문제가 생기면** — `-d` 인자를 본다. 다른 데이터베이스에 걸렸으면 다음 절의 테이블 수가 101 그대로 나온다. ## 주입 검증 결과를 해석하기 전에, 의도한 것만 지워졌는지 먼저 본다. ```bash label="[lab host] ① 남은 테이블을 센다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "select count(*) from pg_tables where schemaname='public'" ``` 이 실험대는 스크립트로 셌고(observed), **위 형태도 2026-09-17 에 쳐서 `0` 을 받았다**(observed). 결과는 이렇다(observed). ```text 남은 테이블: 0 ``` `0` 이어야 한다. 여기서 101 이 그대로 나오면 `DROP` 이 다른 데이터베이스에 걸린 것이고 `-d` 인자를 본다. 애플리케이션 테이블이 정말 없는지 직접 물어본다. ```bash label="[lab host] ② 테이블에 직접 물어본다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "select count(*) from realm" ``` 모양은 이렇다(observed). ```text ERROR: relation "realm" does not exist LINE 1: select count(*) from realm ^ ``` **커넥션은 성립하고 SQL 도 파싱된다. 테이블만 없다.** 이 구별이 이 실험의 전부다. A-2 에서는 여기가 `connection to server ... failed` 였다. **그런데 밖은 멀쩡하다.** ```bash label="[lab host] ③ 파드와 밖에서 본 상태를 다시 잰다" kubectl -n keycloak-lab get pods -o wide curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ https://auth.hyeonworks.com/realms/master curl -s -o /dev/null -w '%{http_code}\n' --resolve app1.hyeonworks.com:443:192.168.122.10 \ https://app1.hyeonworks.com/ ``` 실측은 이렇다(observed, `02-destruction.txt`). ```text https://auth.hyeonworks.com/realms/master HTTP 200 https://app1.hyeonworks.com/ HTTP 200 keycloak-0 1/1 Running 0 4m15s keycloak-1 1/1 Running 0 4m38s ``` `1/1`, `RESTARTS 0`, 그리고 `200` 이다. 데이터베이스가 통째로 비었는데 정문이 200 이다. 여기서 파괴가 실패했다고 읽으면 틀린다 — 테이블이 0개인 것을 바로 앞에서 봤다. 파괴는 성공했고 관측 지점이 그것을 못 본다. Keycloak 이 realm 정보를 Infinispan `realms` 캐시에서 서빙하기 때문이고(A-0 에서 그 캐시에 57개 엔트리가 있는 것을 봤다), 캐시는 읽을 때 데이터베이스와 대조하지 않는다. A-1 에서 로그아웃한 세션이 반대편에서 `200` 을 받았던 것과 같은 성질이다. 엉뚱한 것을 죽이지 않았는지도 본다. ```bash label="[lab host] ④ Service 에서 빠진 파드가 있는가" kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \ -o "custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready" ``` ready 주소가 여전히 둘이다. 아무 파드도 Service 에서 빠지지 않았다. A-2 에서는 여기가 빈 목록이었다. `kubectl get endpoints` 는 v1.33+ 에서 deprecated 이고, 이 실험대에서 실제로 그 경고를 봤다. ### 이 층의 조용한 실패는 복구 쪽에서 온다 여기까지의 주입 검증은 쉽게 통과한다. 어려운 확인은 반대편에 있다. 복구 명령에서 `-i` 를 빠뜨리면 파드 안의 `psql` 이 빈 입력을 받고 정상 종료하고, 셸은 오류를 내지 않고, 종료 코드도 0 이며, `date` 두 줄은 「1초 만에 끝났다」로 찍힌다. 복구된 것과 구별되지 않는다. ```bash label="[lab host] 치지 않는다 — -i 가 있는 줄과 없는 줄을 눈으로 견준다" kubectl -n keycloak-lab exec deploy/postgres -- psql ... < dump.sql # ✘ kubectl -n keycloak-lab exec -i deploy/postgres -- psql ... < dump.sql # ✔ ``` 위 두 줄은 치는 명령이 아니다. `...` 와 `dump.sql` 은 두 형태를 나란히 놓으려고 줄여 쓴 것이고, 실제로 치는 복구 명령은 전제 절과 아래 복구 §1 에 온전한 형태로 있다. `-i` 는 표준입력을 파드 안으로 연결하라는 뜻이다. 구별하는 유일한 방법이 복구 뒤의 데이터 대조이고, 그래서 대조는 선택이 아니다. ## 관찰 **전부 깨지지는 않는다.** 세 경로를 나눠서 친다. ```bash label="[lab host] ① 두 경로를 잰다" curl -s -o /dev/null -w 'certs %{http_code}\n' \ --resolve auth.hyeonworks.com:443:192.168.122.10 \ https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs curl -s -o /dev/null -w 'well-known %{http_code}\n' \ --resolve auth.hyeonworks.com:443:192.168.122.10 \ https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration ``` 실측은 이렇다(observed, `03-restore.txt`). ```text /.well-known/openid-configuration HTTP 500 /protocol/openid-connect/certs HTTP 200 토큰 발급 (DB 쓰기 필요) HTTP 400 ``` 토큰 발급은 값이 필요하므로 따로 친다. 이 실험대는 스크립트로 돌렸고(observed), 아래는 가이드가 미검증으로 표시한 형태다(unknown). ```bash label="[lab host] ② 토큰 발급을 잰다" curl -s -o /dev/null -w '토큰 %{http_code}\n' -X POST \ --resolve auth.hyeonworks.com:443:192.168.122.10 \ https://auth.hyeonworks.com/realms/master/protocol/openid-connect/token \ -d grant_type=password -d client_id=admin-cli -d username=admin \ -d "password=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \ -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" ``` **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도 셸 히스토리에도 남지 않는다. 길이만 확인하려면 한 줄을 더 친다. ```bash label="[lab host] ③ 값이 아니라 길이만 잰다" kubectl -n keycloak-lab get secret keycloak-lab-secrets \ -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c ``` 세 값이 다 다르다. | 경로 | 코드 | 왜 | |---|---|---| | `certs` (JWKS) | 200 | realm 키가 캐시에 있다. 데이터베이스를 안 본다 | | `.well-known` | 500 | 이 응답을 만들려면 데이터베이스를 본다 | | 토큰 발급 | 400 | 세션을 써야 한다 | **부분적으로만 깨진다.** 헬스체크는 통과하고, 일부 엔드포인트는 정상이며, 로그인만 안 된다. 운영에서 이 모양이 고약한 까닭은 「사이트가 떴는가」를 재는 감시가 전부 초록인데 사용자만 못 들어오기 때문이다. 이 사고의 감시 항목은 `/realms/master` 가 아니라 토큰 발급이어야 한다. 로그가 이유를 말한다. ```bash label="[lab host] ④ Keycloak 로그를 읽는다" kubectl -n keycloak-lab logs keycloak-0 --tail=50 ``` 실측은 이렇다(observed, `02-destruction.txt`). ```text 2026-09-04 05:58:02,598 WARN [org.keycloak.jgroups.protocol.KEYCLOAK_JDBC_PING2] (blocking-thread--p3-t2) Failed to fetch the cluster members from the database.: org.postgresql.ut at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2904) ``` `WARN` 이지 `ERROR` 가 아니다. 내용은 클러스터 멤버를 못 가져온다는 것이고, `JGROUPS_PING` 테이블도 같이 지워졌기 때문이다(A-1 에서 그 테이블을 봤다). 디스커버리가 깨졌는데도 로그 레벨이 `WARN` 이라 대시보드의 에러 카운터에 안 잡힐 수 있다. 정문의 `200`, 부분 정상, 여기의 `WARN` — 세 관측이 전부 「괜찮다」 쪽으로 기운다. 「데이터베이스가 살아 있다」와 「데이터가 있다」는 다르고, 그 차이가 이 실험의 모양을 만든다. ```text A-2 DB 프로세스 정지 → 커넥션 실패 → readiness DOWN → 파드가 Service 에서 빠진다 D-1 스키마만 삭제 → 커넥션 정상 → readiness UP → ★ 파드가 그대로 트래픽을 받는다 ``` 헬스체크는 커넥션만 본다. 그래서 빈 데이터베이스를 통과시킨다. Keycloak 의 버그가 아니다 — 「데이터베이스에 붙을 수 있는가」는 프로브가 답할 수 있는 물음이고 「데이터가 온전한가」는 프로브가 답할 수 없는 물음이다. 뒤엣것을 재려면 업무 트랜잭션 하나를 실제로 돌리는 감시가 따로 있어야 한다. | 재는 것 | 이 사고에서 | |---|---| | 파드 `Ready` | 초록 | | 정문 `200` | 초록 | | JWKS `200` | 초록 | | 토큰 발급 | 400 ← 유일하게 정직한 지표 | ## 복구와 원상복구 확인표 ### 1. 덤프를 되돌린다 **목적** — 파괴 전의 데이터로 되돌리고, 질문 ②의 답을 만든다. ① 시각을 남기고 복구한다. `-i` 가 있는지 치기 전에 눈으로 확인한다. ```bash label="[lab host] ① 시각과 함께 덤프를 되돌린다" date '+%H:%M:%S 복구 시작' kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak \ < /tmp/keycloak-backup.sql > /tmp/restore.log 2>&1 date '+%H:%M:%S 복구 완료' ``` **예상 결과** — 실측은 이렇다(observed, `03-restore.txt`). ```text 시작: 15:00:12 완료: 15:00:13 오류 줄: 0 ``` ② 로그의 오류를 센다. ```bash label="[lab host] ② 복구 로그의 오류를 센다" grep -ci '^ERROR' /tmp/restore.log tail -5 /tmp/restore.log ``` **예상 결과** — `0` 이어야 한다. 0 이 아니면 어떤 줄이 실패했는지 본다. `--clean --if-exists` 로 뜬 덤프를 빈 데이터베이스에 넣으면 오류가 0 인 것이 정상이다. **왜 필요한가** — 시각 두 줄과 오류 0건은 `-i` 를 빠뜨렸을 때도 똑같이 나온다. 그래서 이 둘로는 복구를 판정하지 않는다. **2026-09-17 에 이 절차를 처음부터 끝까지 쳤다**(observed). ```text 덤프 101 테이블 · 976873 bytes · 8569 줄 파괴 15:31:02 DROP SCHEMA · CREATE SCHEMA → 남은 테이블 0 · select count(*) from realm 이 relation does not exist 그동안 밖에서는 200 ← 스키마가 통째로 없는데 정문은 멀쩡했다 복구 15:31:02 → 15:31:08 (6초) · 복구 로그 ERROR 0 복구 뒤 101 테이블 · 세션 6건 — 파괴 전과 같다 ``` **크기와 줄 수는 실험대마다 다르다.** 위 실측의 `394945 bytes · 6956 줄` 은 그 실험대의 값이고, BFF 쪽 테이블이 있는 이 실험대에서는 `976873 bytes · 8569 줄` 이었다. **같아야 하는 것은 테이블 수 `101` 과 복구 전후의 행 수**다. **스키마가 없는 동안에도 정문이 `200` 인 것이 이 편의 핵심이다.** Keycloak 은 이미 읽어 둔 것으로 답하므로, 밖에서 보는 코드만으로는 데이터베이스가 통째로 비었다는 것을 알 수 없다. **문제가 생기면** — 1초 만에 끝났는데 다음 단계의 대조가 어긋나면 `-i` 를 의심한다. ### 2. 진짜 판정 — 주입 전과 문자 단위로 견준다 **목적** — 복구가 완전 일치인지 가른다. ① 주입 전에 친 것과 똑같은 명령을 친다. ```bash label="[lab host] ① 대조할 한 줄을 다시 뽑는다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \ "select (select count(*) from realm), (select count(*) from client), (select count(*) from user_entity), (select count(*) from offline_user_session where offline_flag='0')" ``` **예상 결과** — 실측은 이렇다(observed, `03-restore.txt`). 원래 실행은 스크립트였으므로 이름표가 붙은 두 줄이고 값이 다섯이다. 위 명령을 손으로 치면 이름표 없이 `2|15|2|3` 한 줄만 나온다. 주입 전 §2 에서 복사해 둔 줄과 그 한 줄을 견준다. ```text 복구 후: realms|clients|users|sessions|authclients = 2|15|2|3|1 백업 시: realms|clients|users|sessions|authclients = 2|15|2|3|1 ``` **왜 필요한가** — 두 줄이 문자 단위로 같은가를 본다. 완전 일치이고, 질문 ②의 답이 「돈다」인 근거가 이 두 줄이다. 여기가 다르면 그 앞의 모든 성공 표시는 무의미하다. **문제가 생기면** — `-i` 를 빠뜨렸는지 먼저 의심하고, 붙여서 다시 친다. ### 3. 손대지 않고 기다린다 **목적** — 스스로 회복하는지 본다. 여기서 파드를 재시작하면 그 물음 자체가 사라진다. ① 15초쯤 뒤에 본다. ```bash label="[lab host] ① 15초 뒤에 다시 잰다" curl -s -o /dev/null -w 'well-known %{http_code}\n' \ --resolve auth.hyeonworks.com:443:192.168.122.10 \ https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration kubectl -n keycloak-lab get pods -o wide | grep keycloak ``` **예상 결과** — 실측은 이렇다(observed, `03-restore.txt`). ```text +15초 well-known=200 토큰발급=200 → 재시작 없이 회복 keycloak-0 restarts=0 keycloak-1 restarts=0 ``` **왜 필요한가** — 500 이던 `well-known` 이 `200` 이 된 것과 `RESTARTS` 가 여전히 0 인 것을 같이 본다. **`200` 을 본 순간의 시각을 손으로 적어 둔다** — 아래 §5 의 복구 시간 목표가 끝나는 지점이 그 시각인데, 이 단계에는 그것을 남기는 `date` 줄이 가이드에 없다. 커넥션 풀이 이미 붙어 있었으므로 테이블이 돌아오자마자 동작했다. 파드를 만졌다면 「복구 절차에 파드 재시작이 필요하다」는 잘못된 절차가 문서에 남았을 것이다. ### 4. 세션이 살아났는지 본다 **목적** — 덤프의 `COPY` 블록에서 본 id 가 테이블로 넘어왔는지 눈으로 잇는다. ① 세션 행을 다시 나열한다. **주입 전 §3 에서 친 것과 열이 하나 다르다** — 거기는 `realm_id` 까지 셋을 뽑고 여기는 `user_session_id` 와 `offline_flag` 둘만 뽑는다. 가이드 원문이 그렇게 갈려 있어 그대로 싣는다. 열이 다르므로 행 수와 `user_session_id` 값으로 견준다. ```bash label="[lab host] ① 세션 행을 다시 나열한다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "select user_session_id, offline_flag from offline_user_session" ``` **예상 결과** — 실측은 이렇다(observed). 원래 실행은 realm 이름을 함께 뽑았으므로 아래 둘째 열이 `realm` 이다. 위 명령을 그대로 치면 둘째 열에 `offline_flag` 가 오고 값은 `0` 으로 찍힌다. 열 이름이 다른 것을 복구가 덜 됐다는 신호로 읽지 않는다 — 견줄 것은 `user_session_id` 네 값과 `(4 rows)` 다. ```text user_session_id | realm --------------------------+------------------- E1q5xI7tt4U_WhZpW7rEPIF2 | master 2ap3DyRiBF8OdMiqCodsJ0mp | master Zsk4QcgXf_qgyMKzde5AG-Fz | master vsDgCVo12-qX0CC63ZmYzbYF | keycloak-patterns (4 rows) ``` **왜 필요한가** — `E1q5xI7tt4U_WhZpW7rEPIF2` 가 덤프의 `COPY` 블록에도 복구된 테이블에도 있다. 파일에서 데이터베이스로 실제로 넘어온 것을 눈으로 잇는다. 세션이 백업에서 복원되고 로그인 상태가 유지된다. ### 5. 적어 둔 시각 셋을 나란히 놓는다 ```text 14:59:47 파괴 15:00:12 복구 시작 15:00:13 복구 완료 ~15:00:28 서비스 정상 확인 RTO = 41초 ``` **값은 넷인데 `date` 가 남기는 것은 셋이다.** 파괴·복구 시작·복구 완료 세 줄만 명령이 찍고, 넷째 `~15:00:28 서비스 정상 확인` 은 §3 에서 사람이 읽어 적은 시각이다. `RTO = 41초` 는 첫째와 넷째의 차이므로, 그 시각을 안 적어 뒀으면 여기서 복구 시간 목표를 못 만든다. 41초 중 복구 명령 자체는 1초다. 나머지는 파괴를 알아채고 무엇을 할지 정하는 시간이며, 이 실험에서는 이미 알고 있었으므로 25초였다. 실제 사고에서는 이 부분이 대부분을 차지한다. 복구 지점 목표는 두 겹이다. ```text ① 마지막 덤프 이후의 모든 변경 ← 백업 주기가 정한다 ② A-3 에서 측정한 synchronous_commit 손실 ← 수백 ms 실제 RPO = ① + ② ``` A-3 은 클라이언트가 200 을 받은 로그인 153건 중 4건이 데이터베이스에 없었다는 것을 측정했다. 백업 주기만 보고 복구 지점 목표를 말하면 ②를 빠뜨린다. 그리고 이 실험대의 규모는 현실적이지 않다. | | 이 실험대 | 운영 | |---|---|---| | 덤프 크기 | 395KB | GB~TB | | 복구 시간 | 1초 | 분~시간 | | 세션 수 | 3~4 | 수만 | 복구가 1초인 것은 데이터가 작기 때문이고, 이 실험이 확인한 것은 절차가 맞다는 것까지다. 시간은 규모에 따라 완전히 달라진다. ### 6. 덤프를 다른 기계로 옮긴다 — 이 실험이 「못 했다」로 남긴 단계 **목적** — 덤프를 데이터베이스와 다른 장애 도메인에 둔다. 사람이 쳐야 하는 부분이 여기서 갈린다. | 하는 일 | 어디서 | sudo | |---|---|---| | 덤프 뜨기 · 복구 | `kc-lab-1` | 게스트는 무암호 — 스크립트로도 된다 | | 덤프를 호스트의 사용자 홈에 두기 | `test-server` | 필요 없다 | | 덤프를 root 소유 경로(`/var/backups` 등)에 두기 | `test-server` | 비밀번호를 묻는다 — 사람이 친다 | 호스트에서 비대화 `sudo` 는 반드시 실패한다. 그 벽에 부딪힌 기록이 D-4 의 증거에 남아 있다(observed, `d4-certificate-renewal/01-certificate-state.txt`). ```text $ sudo -n -l sudo: a password is required ``` `-n` 은 비밀번호를 물어보지 말라는 뜻이고 호스트에서는 그게 곧 실패다. 그러므로 백업을 호스트의 보호된 경로에 두는 단계는 자동화할 수 없다. `ssh -t` 로 붙어 사람이 비밀번호를 쳐야 하고, `-t` 가 없으면 sudo 가 비밀번호를 읽을 tty 가 없다. ① 두 줄을 차례로 친다. 이 실험대는 여기까지 하지 않았다(unknown). 가이드가 미검증으로 표시한 줄이고, 호스트 이름과 경로는 따라 하는 사람의 배치에 맞춘다. ```bash label="[kc-lab-1 → test-server] ① 호스트의 사용자 홈으로 옮긴다" # ① kc-lab-1 에서 호스트로 — sudo 없이 사용자 홈에 scp /tmp/keycloak-backup.sql test-server:~/keycloak-backup-2026-09-04.sql # ② 보호된 경로로 옮기는 것은 호스트에서 사람이 친다 (비밀번호 프롬프트) ssh -t test-server 'sudo install -m600 -o root -g root \ ~/keycloak-backup-2026-09-04.sql /var/backups/keycloak-backup-2026-09-04.sql' ``` ② 옮긴 파일이 온전한지는 크기를 양쪽에서 세서 비교한다. ```bash label="[kc-lab-1] ② 양쪽에서 크기를 센다" wc -c /tmp/keycloak-backup.sql ssh test-server 'wc -c ~/keycloak-backup-2026-09-04.sql' ``` **예상 결과** — 두 숫자가 같다. 다르면 전송이 잘린 것이다. **왜 필요한가** — 이것으로도 부족하다. 호스트는 VM 두 대를 품고 있는 기계이므로 호스트가 죽으면 게스트도 덤프도 같이 간다. 진짜 요건은 「다른 기계」가 아니라 「다른 장애 도메인」이다. **이 두 줄은 호스트에 평문 덤프를 두 벌 남긴다.** `install` 은 옮기기가 아니라 복사라 사용자 홈의 `keycloak-backup-2026-09-04.sql` 이 `/var/backups` 의 사본과 함께 그대로 있다. 두 파일 다 realm·client·user·세션을 통째로 담고 있는데, 지우는 절차는 가이드에 없고(unknown) 아래 여덟 항목에도 없다. 실험대 밖에서 이 단계를 밟았다면 두 파일을 어떻게 할지는 치는 사람이 정한다. ### 7. 여덟 항목을 대조한다 | 항목 | 명령 | 돌아왔을 때 | |---|---|---| | 테이블 | `psql -c "select count(*) from pg_tables where schemaname='public'"` | 101 | | 데이터 | 복구 대조의 `-tAc` 한 줄 | 백업 시점과 문자 단위로 동일 | | 세션 | `select count(*) from offline_user_session` | 파괴 전과 같은 수 | | 파드 | `kubectl -n keycloak-lab get pods -o wide` | `1/1 Running`, `RESTARTS 0` | | Service | `get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 둘 | | 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` | | 로그인 | 관찰 절의 토큰 발급 | `200` ← 이것이 진짜 판정 | | 덤프 | `ls -l /tmp/keycloak-backup.sql` | 남겨 둔다. D-2 의 전제다 | **덤프를 지우지 않는다.** D-2 가 이 파일을 전제로 한다. ## 막히면 가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이거나 이 절차에서 실제로 갈리는 곳이라고 적는다. | 증상 | 원인 | 확인 | |---|---|---| | 복구가 1초 만에 끝났는데 데이터가 없다 | `exec` 에 `-i` 가 없다. 오류도 안 난다 | 데이터 대조. `-i` 를 붙여 다시 | | 복구에서 `already exists` 가 쏟아진다 | 덤프를 `--clean --if-exists` 없이 떴다 | `grep -c '^DROP TABLE' /tmp/keycloak-backup.sql` — 0 이면 그것 때문이다 | | 덤프 파일은 있는데 복구가 중간에 멈춘다 | 덤프가 잘렸다 | `tail -3` 에 `dump complete` 가 있는가 | | 파괴했는데 정문이 계속 `200` | 정상이다. realm 캐시가 서빙한다 | 토큰 발급으로 판정 | | `psql: relation "realm" does not exist` | 파괴가 걸린 것이다 | 그게 주입 검증의 기대 출력이다 | | `kubectl get endpoints` 가 경고를 찍는다 | v1.33+ 에서 deprecated | `get endpointslice -l kubernetes.io/service-name=...` | | `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 밖에서 `curl` 로 친다 | | 세션 개수가 나열한 행 수와 다르다 | 개수 쿼리에 `offline_flag='0'` 필터가 있다 | 같은 쿼리끼리 비교 | | 백업이 0바이트다 | `pg_dump` 가 인증에서 막혔다 | `-U keycloak -d keycloak` 를 확인. 파일을 지우고 다시 뜬다 | | 호스트에서 `sudo` 가 안 먹는다 | 호스트 sudo 는 비밀번호를 요구한다 | `ssh -t` 로 붙어 사람이 친다 | ## 무엇이 관측이고 무엇이 아닌가 이 절차의 숫자는 `2026-09-04 14:57–15:00 KST` 에 돈 한 번의 실행에서 나왔다(observed). - (observed) 파괴 직후 파드 네 줄과 `RESTARTS 0`, 백업의 `시작: 14:59:30` · `완료: 14:59:30` · `크기: 394945 bytes (6956 줄)`, 테이블 수 `101`, `offline_user_session 언급: 13` 과 `COPY` 블록에 붙은 세션 행, 파괴 시각 `14:59:47` 과 `DROP SCHEMA` · `CREATE SCHEMA`, 남은 테이블 `0`, 파괴 뒤에도 정문과 app1 이 전부 `HTTP 200` 인 것, `certs` 200 · `.well-known` 500 · 토큰 발급 400, `KEYCLOAK_JDBC_PING2` 의 `WARN` 두 줄, 복구의 `시작: 15:00:12` · `완료: 15:00:13` · `오류 줄: 0`, 복구 전후 대조 두 줄이 같은 것, `+15초 well-known=200 토큰발급=200` 과 `restarts=0`, 복구된 세션 네 행, `RTO = 41초`. - (observed) A-3 이 잰 로그인 153건 중 4건 소실은 그 실험의 값이고, 여기서는 실제 복구 지점 목표의 두 번째 겹으로 인용만 한다. - (unknown) 남은 테이블을 세는 `pg_tables` 쿼리와 토큰 발급 `curl` 한 줄. 가이드가 미검증으로 표시했고 원래 실행은 스크립트로 돌렸다. 덤프를 호스트로 옮기는 두 줄도 미검증이고, 이 실험대는 그 단계를 하지 않았다 — 덤프는 데이터베이스와 같은 기계에 놓인 채 실험이 끝났다. - 다섯째 컬럼은 지어내지 않았다. 증거 줄에는 `authclients` 까지 다섯 값이 있는데 해설 문서의 재현 절차에 그 쿼리가 없어서, 가이드도 이 절차도 넷만 센다. - 비밀은 옮기지 않았다 — 관리자 비밀번호는 명령 치환으로만 넘어가고, 길이를 재는 줄만 따로 있다. 세션 id 와 realm UUID 는 식별자라 그대로 적었다. 덤프 파일 자체가 realm·client·user·세션을 통째로 담고 있고, 그 파일을 어디에 두는가가 이 실험의 마지막 물음이다. - (unknown) 검증 넷을 통과한 덤프로 복구했는데 그 복구가 실패했을 때 갈 길은 가이드에 없다. 「막히면」 표는 원인을 가리키는 데까지만 적고, 지우고 다시 시작하는 절차를 주지 않는다. 만들어 넣지 않았다. - 이 실험이 확인하지 않은 것 — 백업 자동화, 보존 주기, 복구 리허설의 정기 실행. 이번엔 손으로 한 번 떴고 한 번 되돌렸다. 그것만 참이다.