--- id: 91ce17ad-758d-4a63-be5b-489786609557 kind: SETUP slug: reproduce-a3-database-crash title: PostgreSQL 을 진짜로 크래시시키고 잃은 로그인을 센다 topic: losing-a-node-or-the-store topicName: PostgreSQL 을 내리고 노드 전원을 뽑았을 때 project: keycloak-session-store status: 게시 전 studio: "https://hyeonworks.com/studio/documents/91ce17ad-758d-4a63-be5b-489786609557/edit" pinnedVersions: - name: Keycloak version: 26.7.0 - name: curlimages/curl version: 8.11.1 source: - final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-3 sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af --- # PostgreSQL 을 진짜로 크래시시키고 잃은 로그인을 센다 PostgreSQL 을 진짜로 죽여 커밋됐다고 응답받은 로그인 중 몇 건이 사라지는지 세는 절차다. 절차의 절반은 죽이는 데 실패하는 두 가지 방법이고, 그래서 결과를 세기 전에 주입 성공 신호부터 본다. 전 구간 약 40분이고 터미널 두 개가 필요하다. ## 관계 - **200 과 토큰을 받은 로그인 네 건이 데이터베이스에 없었다** 이 절차가 낸 결론을 담은 기록이다. 여기에는 치는 순서만 있다. - **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다** 이 편의 시도 ① 과 ② 가 그 아홉 건에 들어간다. 둘 다 화면에는 성공으로 보였다. - **산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다** 파드 안에서 `( ... ) &` 로 띄운 루프가 `exec` 세션과 함께 죽어 0건을 모은 것을 다룬다. - **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다** 주입 성공 신호를 미리 정해 두는 규칙을 편 기록이다. 이 편이 그 규칙이 없었으면 정반대 결론을 낼 뻔했다. - **PostgreSQL 을 정상 종료시키고 네 경로를 잰다** 먼저 해 둬야 하는 편이다. 정상 종료가 아무것도 잃지 않는다는 그쪽 결과가 이 편의 대조군이다. ## 본문 ## 읽기 전에 — 어디서 치는가 명령은 전부 `[kc-lab-1]` 에서 `kubectl` 과 `psql` 로 친다. 노드 자체를 건드리는 명령이 없어서 `kc-lab-2` 로 들어갈 일이 없다. `kubectl` 에 `sudo` 를 붙이지 않는다 — root 홈에는 `~/.kube/config` 가 없어 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다. 터미널은 둘이 반드시 필요하다. 터미널 ① 은 로그인 루프가 도는 동안 통째로 붙잡히고, 터미널 ② 에서 그 사이에 데이터베이스를 죽인다. 코드블록마다 어느 터미널인지 붙여 두었다. | 무엇 | 값 | |---|---| | 네임스페이스 | `keycloak-lab` · 관측 스택은 `observability` | | 주입 수단 | 셋을 순서대로 — `--grace-period=0 --force` · `kill -9 1` · 백엔드 SIGKILL | | 주입 성공 신호 | `database system was not properly shut down` 과 `redo starts`·`redo done` | | 탐침 파드 | `a3-probe` — `curlimages/curl:8.11.1`, `sleep 7200`, `--restart=Never` | | 손으로 쓰는 파일 | `/tmp/a3-login-loop.sh` — 400회 로그인 루프. 편집기로 쓴다 | | 로그 시각 | PostgreSQL 로그 줄은 UTC 로 찍힌다. 친 명령의 시각은 KST 다 | | 걸리는 시간 | 전 구간 약 40분 | | 도구 | `jq` 가 이 실험대에 없다 | ## 이 실험이 가르는 것 A-2 는 데이터베이스를 정상 종료시켰고 세션은 하나도 안 없어졌다. PostgreSQL 은 SIGTERM 을 받으면 WAL 을 플러시하고 내려가기 때문이다. 그런데 A-0 에서 이 한 줄을 잡았다. ```sql label="A-0 의 문장 로그에서 COMMIT 직전에 나온 줄" SET LOCAL synchronous_commit TO OFF ``` `COMMIT` 직전, 같은 트랜잭션 안에서 나온다. ```text COMMIT │ ├─ WAL 버퍼(메모리)에 기록 ← 항상 한다 │ ├─ synchronous_commit = on : 디스크 플러시를 기다렸다가 응답 └─ synchronous_commit = off : 기다리지 않고 즉시 응답 ← Keycloak │ └─ 크래시 시 이 구간이 사라진다 ``` 「사라질 수 있다」와 「몇 건 사라졌다」는 다르다. 이 실험은 뒤쪽이고 RPO(Recovery Point Objective, 복구 시점 목표)를 숫자로 만든다. A-0 을 끝내고 아직 아무것도 주입하기 전에 쓴 예측표에 이 실험이 「DB 강제 종료」로 올라 있다. 예측 칸은 「직전 수백 ms 의 세션 갱신이 사라진다」이고 근거 칸은 `synchronous_commit OFF` 다. 그 예측이 가리킨 것은 갱신 트랜잭션인데 이 절차가 세는 것은 로그인이라, 설계 확인 2번이 로그인 트랜잭션도 같은 설정을 거는지부터 본다. 그리고 이 실험의 절반은 죽이는 데 실패하는 이야기다. 세 번 시도해서 세 번째에 성공했고, 앞의 둘은 「손실 0건」으로 보였지만 실제로는 죽인 적이 없었다. 절차를 끝까지 밟으면 로그인 트랜잭션에 붙은 `SET LOCAL synchronous_commit TO OFF`, `--grace-period=0 --force` 가 크래시가 아니라는 것, 컨테이너 안에서 PID 1 이 SIGKILL 을 무시하는 것, `not properly shut down` 과 `redo starts`·`redo done`, `200` 과 토큰을 받았는데 DB 에 없는 sid, `wal_writer_delay = 200ms` 가 기본값이라는 것을 자기 화면에서 보게 된다. ## 전제와 되돌리기 - **A-0 과 A-2 를 먼저 한다.** A-0 이 `SET LOCAL synchronous_commit TO OFF` 를 발견했고, 이 실험은 그 대가가 몇 건인지를 잰다. - 세 파드가 전부 `1/1 Running` 이고 `RESTARTS` 가 `0` 이어야 한다. 그 값은 주입 판정에 쓰이므로 적어 둔다. - A-0 이 켰던 문장 로깅이 꺼져 있어야 한다. 켜진 채로 루프를 돌리면 크래시 타이밍이 달라진다. **이건 데이터를 잃는 실험이다.** PostgreSQL 을 강제로 죽이고 세션 테이블을 두 번 비운다. 실제로 커밋됐다고 응답한 데이터가 사라진다. **실험대에서만 한다.** 지운 세션은 돌아오지 않는다. 되돌릴 수 있는 것은 문장 로깅 하나이고, 켜기 전에 끄는 명령을 먼저 읽어 둔다. ```bash label="[kc-lab-1] 중간에 그만둘 때 치는 한 묶음" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "alter system reset log_statement" -c "select pg_reload_conf()" ``` ## 주입 전에 같은 명령으로 먼저 본다 측정 설계가 성립하는지부터 본다. 여기서 하나라도 어긋나면 뒤의 숫자는 아무 의미가 없다. ### 1. 시각 컬럼의 눈금으로 무엇을 잴 수 있는가 **무엇을 보는가** — 세션 테이블의 컬럼 타입. ```bash label="[kc-lab-1] 세션 테이블의 정의를 본다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "\d offline_user_session" ``` **어디를 보나** — 실측은 이렇다(observed, `01-crash-injection.txt`). ```text LAST_SESSION_REFRESH 는 integer(초) — 200ms 손실은 보이지 않는다 created_on | integer | | not null | last_session_refresh | integer | | not null | 0 "idx_user_session_expiration_created" btree (realm_id, offline_flag, remember_me, created_on, user_session_id, user_id) "idx_user_session_expiration_last_refresh" btree (realm_id, offline_flag, remember_me, last_session_refresh, user_session_id, user_id) ``` **이 값이 뜻하는 것** — 두 시각 컬럼의 타입이 `integer` 다. 손실 창은 수백 밀리초인데 눈금이 1초라 보일 리가 없고, 「세션 갱신 시각이 되감기는지」 보려던 설계는 버렸다. 대신 행 존재 여부로 잰다. ```text 로그인 1회 = OFFLINE_USER_SESSION 행 1개 클라이언트가 sid 를 받았다 = 서버가 COMMIT 했다고 응답했다 크래시 후 그 sid 가 없다 = 잃은 것 ``` 있거나 없거나이므로 눈금 문제가 없다. 이 실험이 로그인 수백 건을 도는 까닭이 여기 있다 — 이진 판정을 여러 번 해서 비율로 만든다. ### 2. 로그인 트랜잭션도 비동기 커밋인지 확인한다 **무엇을 보는가** — A-0 에서 잡은 것은 refresh 트랜잭션이었고 로그인(INSERT)도 그런지는 확인하지 않았다. 아니라면 로그인은 안 사라지고 이 측정 설계 자체가 성립하지 않는다. ```bash label="[kc-lab-1] ① 문장 로깅을 잠깐 켠다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "alter system set log_statement='all'" -c "select pg_reload_conf()" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "show log_statement" ``` `none` 이면 `pg_reload_conf()` 가 안 돈 상태다. `alter system` 은 `postgresql.auto.conf` 에 쓸 뿐이고 reload 를 해야 적용된다. ```bash label="[kc-lab-1] ② 탐침 파드를 띄우고 Ready 까지 기다린다" K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}') kubectl -n keycloak-lab run a3-probe --image=curlimages/curl:8.11.1 \ --restart=Never \ --env="K0=$K0" \ --env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \ -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \ --command -- sleep 7200 kubectl -n keycloak-lab wait --for=condition=Ready pod/a3-probe --timeout=120s ``` 명령줄에 비밀번호를 직접 쓰지 않는다. 원래 실험의 재현 절차에는 평문 비밀번호가 그대로 적혀 있는데 파드 안 `ps` 에도 셸 히스토리에도 남는다. `--env` 로 넘긴 값은 그 파드 안에서만 산다. 존재와 길이만 확인한다 — 실측은 `19` 다(observed). ```bash label="[kc-lab-1] ③ 비밀번호의 길이만 두 곳에서 센다" kubectl -n keycloak-lab get secret keycloak-lab-secrets \ -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c kubectl -n keycloak-lab exec a3-probe -- sh -c 'echo "K0=$K0 PW길이=${#PW}"' ``` ```bash label="[kc-lab-1] ④ 로그인 한 번을 보낸다" kubectl -n keycloak-lab exec a3-probe -- sh -c \ 'curl -s -o /dev/null -w "%{http_code}\n" -X POST \ "http://$K0:8080/realms/master/protocol/openid-connect/token" \ -d grant_type=password -d client_id=admin-cli \ -d username=admin -d "password=$PW"' ``` ```bash label="[kc-lab-1] ⑤ 그 로그인의 트랜잭션을 로그에서 본다" kubectl -n keycloak-lab logs deploy/postgres --since=60s \ | grep -E 'BEGIN|insert into OFFLINE|synchronous_commit|COMMIT' | tail -20 ``` **어디를 보나** — 실측은 이렇다(observed, `02-design-check.txt`). ```text === [설계 확인] 로그인 트랜잭션도 synchronous_commit 을 끄는가 === --- 로그인 트랜잭션 (INSERT 가 있는 것) --- 2:BEGIN 5:COMMIT 6:BEGIN 9:insert into OFFLINE_USER_SESSION (BROKER_SESSION_ID,CREATED_ON,DATA,LAST_SESSION_REFRESH,REALM_ID,REMEMBER_ME,USER_ID,VERSION,OFFLINE_FLAG,USER_SESSION_ID) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) 10:insert into OFFLINE_CLIENT_SESSION (DATA,REALM_ID,TIMESTAMP,VERSION,CLIENT_ID,CLIENT_STORAGE_PROVIDER,EXTERNAL_CLIENT_ID,OFFLINE_FLAG,USER_SESSION_ID) values ($1,$2,$3,$4,$5,$6,$7,$8,$9) 11:SET LOCAL synchronous_commit TO OFF 12:COMMIT ``` **이 값이 뜻하는 것** — `BEGIN` 과 `COMMIT` 사이에 `insert into OFFLINE_USER_SESSION` 과 `SET LOCAL synchronous_commit TO OFF` 가 같이 들어 있다. 앞의 `BEGIN`/`COMMIT`(2·5줄)은 다른 트랜잭션이다. 설계가 확인됐고 함의가 refresh 보다 훨씬 무겁다 — refresh 갱신 시각을 잃으면 세션 수명이 조금 짧아질 뿐이고 사용자는 모르지만, 로그인 자체를 잃으면 토큰은 손에 있는데 세션이 없고 다음 요청부터 실패한다. 곧바로 끈다. ```bash label="[kc-lab-1] ⑥ 문장 로깅을 끄고 꺼졌는지 읽는다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "alter system reset log_statement" -c "select pg_reload_conf()" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "show log_statement" ``` **켜 둔 채로 주입에 들어가면 안 된다.** 주입 단계는 수백 건의 로그인을 최대한 빨리 도는데, `log_statement='all'` 이면 로그인 하나에 SQL 열 몇 줄씩 쌓이고 로그가 폭주하며 디스크 I/O 가 늘어 크래시 타이밍 자체가 달라진다. ### 3. WAL 설정을 재기 전에 잰다 **무엇을 보는가** — 나중에 손실 창과 견줄 값. ```bash label="[kc-lab-1] 커밋과 WAL 관련 설정 네 줄" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "select name, setting, unit, source from pg_settings where name in ('commit_delay','synchronous_commit','wal_writer_delay','wal_writer_flush_after')" ``` **어디를 보나** — 실측은 이렇다(observed, `08-wal-settings.txt`). ```text === A-3 이 가정만 하고 재지 않은 값 === name | setting | unit | source ------------------------+---------+------+--------- commit_delay | 0 | | default synchronous_commit | on | | default wal_writer_delay | 200 | ms | default wal_writer_flush_after | 128 | 8kB | default (4 rows) ``` **이 값이 뜻하는 것** — `source` 열이 전부 `default` 이고 전역 `synchronous_commit` 은 `on` 이다. 전역 설정만 보면 「우리는 동기 커밋」이라고 믿게 되는데 Keycloak 이 자기 트랜잭션에만 `SET LOCAL` 로 뒤집는다. 서버 설정만 보고 판단하면 틀린다. 원래 실험은 결과를 먼저 쓰고 「`wal_writer_delay` 기본값(200ms)과 맞는다」고 주장했는데 그 시점에 이 값을 조회한 적이 없었다. 나중에 재서 맞기는 했지만 그때는 추정이었다. 가정한 값은 재기 전에 재 둔다. ### 4. 세션 테이블을 비우고 0 인지 센다 **무엇을 보는가** — 출발값. ```bash label="[kc-lab-1] ① 세션 행을 지운다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "delete from offline_user_session" ``` ```bash label="[kc-lab-1] ② 남은 행을 센다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "select count(*) from offline_user_session where offline_flag='0'" ``` **어디를 보나** — 실측은 `05-true-crash.txt` 의 `DELETE 375` 와 `남은 세션: 0` 이다(observed). **이 값이 뜻하는 것** — 크래시 뒤에 「DB 전체 세션 수」와 「내가 만든 세션 수」를 나란히 놓고 볼 것이므로 시작이 0 이어야 그 둘이 읽힌다. 캐시는 안 비워도 된다 — 이 실험의 판정은 DB 행의 존재 여부이고 캐시는 판정에 안 들어간다. ### 5. 파드 셋의 재시작 횟수를 적어 둔다 ```bash label="[kc-lab-1] 파드 셋의 상태" kubectl -n keycloak-lab get pods -o wide ``` `keycloak-0` `keycloak-1` `postgres` 가 전부 `1/1 Running` 이고 `RESTARTS` 가 `0` 이어야 한다. `RESTARTS` 값을 적어 둔다 — 주입 판정의 일부다. ## 주입 세 번 시도한다. 순서대로 따라가면 죽이는 데 실패하는 두 가지 방법을 직접 보게 되고, 건너뛰고 세 번째만 하면 왜 그것이 유일한 방법인지 모른다. **시도 셋을 연달아 치지 않는다.** 하나를 주입할 때마다 아래 「주입 검증」의 같은 번호 절을 치고 다음 시도로 넘어간다. 셋이 같은 `/tmp/sids` 와 같은 DB 를 보기 때문에 몰아서 치면 `wc -l /tmp/sids` 가 세 시도의 합을 내고, 시도 ① 이 0건을 잃었다는 것을 더는 보일 수 없다. 치는 순서는 이렇다. ```text 시도 ① ─▶ 주입 검증 §1 ─▶ 시도 ② ─▶ 주입 검증 §2 ─▶ 시도 ③ ─▶ 주입 검증 §3 ``` ### 1. 로그인 루프를 파일로 써서 파드에 넣는다 **목적** — 클라이언트가 `200` 과 토큰을 실제로 받은 로그인의 목록을 파드 안 파일에 쌓는다. 루프는 한 줄로 칠 물건이 아니다. 원래 실행은 이걸 `kubectl exec ... sh -c "..."` 한 줄에 욱여넣었고 인용이 세 겹이 되어 두 번 깨졌다. 실측은 이렇다(observed, `01-crash-injection.txt`). ```text === [1] 빠른 연속 로그인을 백그라운드로 시작 === 루프 시작 6초 경과 — 지금까지 성공한 로그인: 0 ... 클라이언트가 200 을 받은 로그인 수: 0 ``` 0건이다. 파드 안에서 `( ... ) &` 로 띄운 루프가 `exec` 세션이 끝날 때 같이 죽었고 측정 자체가 없었다. 그래서 편집기로 파일을 연다. ```bash label="[kc-lab-1] ① 편집기로 루프 파일을 연다" vim /tmp/a3-login-loop.sh ``` ```sh label="/tmp/a3-login-loop.sh — 이 내용을 적는다" # file: /tmp/a3-login-loop.sh — 탐침 파드 안에서 돈다 #!/bin/sh # K0 · PW 는 파드 환경변수에서 온다. 여기에 비밀번호를 적지 않는다. TOK=/realms/master/protocol/openid-connect/token : > /tmp/sids i=0 while [ "$i" -lt 400 ]; do AT=$(curl -s --max-time 5 -X POST "http://$K0:8080$TOK" \ -d grant_type=password -d client_id=admin-cli \ -d username=admin -d "password=$PW" \ | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') if [ -n "$AT" ]; then echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \ | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p' >> /tmp/sids fi i=$((i + 1)) done echo "루프 종료: $(wc -l < /tmp/sids) 건" ``` `/tmp/sids` 에는 클라이언트가 `200` 과 토큰을 실제로 받은 것만 쌓인다. `AT` 가 비면 아무것도 안 적으므로 이 파일이 「서버가 COMMIT 했다고 응답한 것」의 목록이고, 그게 이 실험의 시험군이다. ```bash label="[kc-lab-1] ② 파일을 파드 안으로 밀어 넣고 줄 수를 센다" kubectl -n keycloak-lab exec -i a3-probe -- sh -c 'cat > /tmp/a3-login-loop.sh' \ < /tmp/a3-login-loop.sh kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/a3-login-loop.sh ``` **예상 결과** — 줄 수가 `17 /tmp/a3-login-loop.sh` 로 나오면 들어갔다. **왜 필요한가** — `kubectl cp` 도 되지만 컨테이너에 `tar` 가 있어야 한다. 이 실험대의 `curlimages/curl:8.11.1` 에 `tar` 가 있는지는 재지 않았다(unknown). 그래서 가이드는 `cat >` 로 밀어 넣는 쪽이 어디서나 통한다고 적고 그쪽을 골랐다. **문제가 생기면** — 줄 수가 0 이면 로컬 파일이 비었거나 경로가 틀린 것이므로 `wc -l /tmp/a3-login-loop.sh` 를 로컬에서 먼저 친다. ### 2. 터미널 하나를 루프에 통째로 쓴다 **목적** — 초당 십몇 건의 로그인을 계속 보내면서 크래시 순간을 감싼다. ```bash label="[터미널 ①] 앞으로 두고 돌린다. 이 터미널은 붙잡힌다" kubectl -n keycloak-lab exec a3-probe -- sh /tmp/a3-login-loop.sh ``` ```bash label="[터미널 ②] 얼마나 쌓였는지 본다" kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/sids ``` **예상 결과** — 실측은 `06-backend-kill-crash.txt` 의 `8초 후: 112 건` 이다(observed). 8초에 112건이면 초당 약 14건이고, 이 속도를 적어 둔다 — 손실 건수를 시간으로 환산할 때 쓴다. **왜 필요한가** — `&` 로 배경에 보내지 않는다. 그게 원래 실행이 실패한 까닭이고, 터미널을 하나 통째로 이 루프에 쓴다. 이 앞으로 두고 돌리는 형태는 미검증이다(unknown) — 원래 실행은 호스트에서 배경 `exec` 로 했다. **문제가 생기면** — 0건이면 루프가 안 도는 것이므로 터미널 ① 을 본다. ### 3. 시도 ① — `--grace-period=0 --force` **목적** — 「강제 삭제」라는 이름이 붙은 방법이 크래시인지 확인한다. ```bash label="[터미널 ②] 시각을 남기고 강제 삭제한다" date '+%H:%M:%S.%3N 종료' kubectl -n keycloak-lab delete pod -l app=postgres --grace-period=0 --force date '+%H:%M:%S.%3N 반환' ``` **예상 결과** — 실측은 이렇다(observed, `01-crash-injection.txt`). ```text === [2] PostgreSQL 강제 종료 (SIGKILL) === 종료 시각: 12:00:26.511 pod "postgres-7b474b88c8-xc2vt" force deleted from keycloak-lab namespace 삭제 반환: 12:00:26.586 ``` **왜 필요한가** — 이 시도를 건너뛰면 다음 절의 검증이 무엇을 가르는지 알 수 없다. 결과는 주입 검증에서 본다. **문제가 생기면** — 파드가 새로 안 뜨면 `rollout status` 로 기다린다. **다음** — 여기서 멈추고 「주입 검증」 §1 을 친 뒤 시도 ② 로 넘어온다. ### 4. 시도 ② — 컨테이너 안에서 `kill -9 1` **목적** — postmaster 가 컨테이너의 PID 1 이므로 직접 SIGKILL 을 보내 본다. ```bash label="[터미널 ②] PID 1 에 SIGKILL 을 보낸다" date '+%H:%M:%S.%3N SIGKILL' kubectl -n keycloak-lab exec deploy/postgres -- kill -9 1 ``` **예상 결과** — 명령은 조용히 끝난다. 무슨 일이 일어났는지는 주입 검증에서 본다. **왜 필요한가** — 이것도 건너뛰면 세 번째 방법이 왜 유일한지 모른다. **문제가 생기면** — 명령이 오류를 내면 파드 이름과 네임스페이스를 먼저 본다. **다음** — 여기서 멈추고 「주입 검증」 §2 를 친 뒤 시도 ③ 으로 넘어온다. ### 5. 시도 ③ — 백엔드 프로세스를 죽인다 **목적** — postmaster 가 공유 메모리 오염을 보고 전체를 재초기화하게 만든다. PostgreSQL 은 postmaster(부모) + 연결마다 백엔드(자식) 구조이고, 자식 하나가 비정상 종료하면 postmaster 가 전체를 재초기화하며 그것이 곧 crash recovery 다. ```bash label="[터미널 ②] ① 무엇을 죽일지 눈으로 먼저 본다" kubectl -n keycloak-lab exec deploy/postgres -- ps -ef | head -20 ``` 값은 환경마다 다르고 모양은 이렇다. ```text UID PID PPID C STIME TTY TIME CMD postgres 1 0 0 02:59 ? 00:00:00 postgres postgres 40 1 0 02:59 ? 00:00:00 postgres: keycloak keycloak 10.42.1.67(41234) idle postgres 41 1 0 02:59 ? 00:00:00 postgres: keycloak keycloak 10.42.0.35(52118) idle ... ``` `PID 1` 이 postmaster 이고 `postgres: keycloak keycloak ...` 이 Keycloak 이 붙어 있는 백엔드다. 터미널 ① 에서 루프를 다시 돌려 8초쯤 쌓이면 터미널 ② 에서 죽인다. **위 출력의 `40`·`41` 은 이 실험대에서 나온 값이다. 당신 화면의 숫자는 다르다** — 방금 친 `ps -ef` 가 보여 준 PID 를 읽어서 넣는다. 예시 숫자를 그대로 치면 그 파드의 엉뚱한 프로세스를 죽인다. ```bash label="[터미널 ②] ② 방금 본 PID 를 넣어 죽인다 — 를 바꿔서 친다" date '+%H:%M:%S.%3N SIGKILL' kubectl -n keycloak-lab exec deploy/postgres -- kill -9 ``` 이 두 줄 형태는 이 실험대에서 치지 않았다(unknown). **실제로 친 것은 이름으로 고르는 쪽이다** — 백엔드가 여럿이면 이쪽이 한 번에 전부 끊는다. ```bash label="[터미널 ②] 이 실험대가 실제로 친 형태 (observed)" kubectl exec deploy/postgres -- pkill -9 -f 'postgres: keycloak' ``` 이 실험대는 한 줄에 원격 셸과 명령 치환을 겹쳐서 쳤다(observed). ```bash label="[터미널 ②] 원래 실행이 친 형태" date '+%H:%M:%S.%3N SIGKILL' kubectl -n keycloak-lab exec deploy/postgres -- \ sh -c 'kill -9 $(pgrep -f "postgres: keycloak keycloak" | head -1)' ``` **예상 결과** — 실측은 이렇다(observed, `06-backend-kill-crash.txt`). ```text === 백엔드 프로세스에 SIGKILL → postmaster 가 재초기화한다 === 시각: 12:04:22.063 최종 성공 로그인: 153 건 ``` 터미널 ① 의 루프를 `Ctrl-C` 로 멈춘다. **왜 필요한가** — 컨테이너 밖이 아니라 안에서 보내는 시그널이라 PID 1 로는 통하지 않는다. 자식 프로세스라야 SIGKILL 이 전달된다. **문제가 생기면** — `pgrep` 이 아무것도 못 찾으면 Keycloak 이 아직 연결을 안 만든 것이므로 `ps -ef` 로 먼저 본다. **다음** — 「주입 검증」 §3 을 친다. ## 주입 검증 **세 절은 이어서 치는 것이 아니다.** 각각 같은 번호의 시도 직후에 치고 「주입」으로 돌아간다. 결과를 세기 전에 주입 성공 신호를 본다. 이 실험은 그 신호를 미리 정해 뒀다. ```text PostgreSQL 이 정상 종료했다 → pg_control 에 "깨끗하게 종료됨" 표시 → 다음 기동에 아무 말 없이 뜬다 PostgreSQL 이 즉사했다 → 표시가 없다 → "database system was not properly shut down" → "redo starts at ..." / "redo done at ..." ``` ### 1. 시도 ① 의 검증 — 죽인 적이 없다 ```bash label="[kc-lab-1] 새 파드를 기다리고 기동 로그를 본다" kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s kubectl -n keycloak-lab logs deploy/postgres | grep -E 'not properly shut down|redo|ready to accept' ``` 실측은 이렇다(observed, `02-design-check.txt`). ```text === crash recovery 가 실행되었는가 (강제 종료의 흔적) === 2026-09-04 02:58:41.036 UTC [1] LOG: database system is ready to accept connections ``` `ready to accept connections` 한 줄만 나온다. `not properly shut down` 도 `redo` 도 없으므로 crash recovery 가 돌지 않았고 깨끗하게 내려갔다. 그런데도 손실을 세어 보면 이렇게 나온다. ```bash label="[kc-lab-1] 클라이언트가 받은 건수와 DB 건수를 나란히 본다" kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/sids kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "select count(*) from offline_user_session where offline_flag='0'" ``` 실측은 이렇다(observed, `04-comparison.txt`). ```text === [5] 전체 대조 — 몇 건이나 사라졌는가 === 클라이언트 성공: 291 건 DB 에 존재: 291 건 ★ 유실: 0 건 ``` 0건이다. 그런데 이건 안 잃은 것이 아니라 죽인 적이 없다. 시그널 셋이 다르게 동작한다 — SIGTERM 은 fast shutdown 으로 진행 중 트랜잭션을 롤백하고 WAL 을 플러시한 뒤 종료하고, SIGINT 는 smart shutdown 으로 연결이 끊기길 기다리며, SIGKILL 은 즉사라 플러시가 없고 다음 기동에 crash recovery 가 돈다. `--force --grace-period=0` 는 API 오브젝트를 즉시 지우고 거기서 끝난다. 컨테이너 런타임은 여전히 정상 종료 절차를 밟고 PostgreSQL 은 SIGTERM 을 받고 얌전히 플러시했다. 운영 함의가 여기 붙는다 — 장애 훈련이 훈련이 안 될 수 있다. 「강제 삭제로 DB 를 죽여 봤는데 아무 문제 없었다」는 결론은 아무것도 죽이지 않은 것일 수 있고, 훈련에는 주입 성공 신호가 있어야 한다. ### 2. 시도 ② 의 검증 — 아무 일도 일어나지 않았다 ```bash label="[kc-lab-1] 파드와 기동 로그의 마지막 세 줄" kubectl -n keycloak-lab get pods -l app=postgres kubectl -n keycloak-lab logs deploy/postgres | grep -E 'not properly shut down|redo|ready to accept' | tail -3 ``` 실측은 이렇다(observed, `05-true-crash.txt`). ```text === [재주입] postmaster(PID 1)에 SIGKILL — 진짜 크래시 === 8초 후 성공 로그인: 110 건 SIGKILL: 12:03:21.441 최종 성공 로그인: 139 건 === [검증] 이번엔 crash recovery 가 돌았는가 === 2026-09-04 02:59:48.427 UTC [1] LOG: database system is ready to accept connections ``` 두 가지를 같이 본다. `RESTARTS` 가 안 올랐으니 파드는 재시작하지 않았고, 로그의 마지막 줄 시각이 `02:59:48` 인데 시도 ① 때 뜬 그 시각 그대로다. **줄의 존재가 아니라 시각을 본다** — `ready to accept connections` 줄이 있다는 것으로 판정하면 안 된다. 까닭은 PID 1 의 시그널 보호다. 리눅스 커널은 PID 1 을 특별 취급해서 자기 PID 네임스페이스 안에서 온 시그널은 핸들러가 등록된 것만 전달하고 SIGKILL 도 예외가 아니다. ```text 같은 네임스페이스 안에서 → PID 1 은 등록하지 않은 시그널을 무시한다 조상 네임스페이스에서 → 전달된다 (노드에서 kill -9 하면 죽는다) ``` 부팅 초기에 init 을 실수로 죽여 시스템이 멈추는 것을 막기 위한 장치인데, 컨테이너에서는 「안에서는 PID 1 을 못 죽인다」로 나타난다. 그래서 크래시 재현은 두 갈래다 — (a) 자식 프로세스를 죽이거나 (b) 노드에서 `ssh kc-lab-2 'sudo kill -9 <호스트 PID>'` 로 죽인다. 컨테이너 밖은 조상 네임스페이스이므로 SIGKILL 이 통한다. 이 실험은 (a) 로 했고 (b) 는 치지 않았다(unknown). ### 3. 시도 ③ 의 검증 — 이번엔 걸렸다 ```bash label="[kc-lab-1] 크래시 흔적 여섯 줄을 한 번에 본다" kubectl -n keycloak-lab logs deploy/postgres --since=5m \ | grep -E 'terminated by signal|reinitializing|not properly shut down|redo|checkpoint complete|ready to accept' ``` 실측은 이렇다(observed, `06-backend-kill-crash.txt`). ```text 2026-09-04 03:02:35.807 UTC [1] LOG: server process (PID 40) was terminated by signal 9: Killed 2026-09-04 03:02:35.807 UTC [1] LOG: terminating any other active server processes 2026-09-04 03:02:35.814 UTC [1] LOG: all server processes terminated; reinitializing 2026-09-04 03:02:35.896 UTC [2585] LOG: database system was not properly shut down; automatic recovery in progress 2026-09-04 03:02:35.899 UTC [2585] LOG: redo starts at 0/23CAB68 2026-09-04 03:02:35.904 UTC [2585] LOG: redo done at 0/2529E40 system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s 2026-09-04 03:02:35.923 UTC [2586] LOG: checkpoint complete: wrote 113 buffers (0.7%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.004 s, sync=0.004 s, total=0.015 s; sync files=27, longest=0.003 s, average=0.001 s; distance=1405 kB, estimate=1405 kB; lsn=0/252A048, redo lsn=0/252A048 2026-09-04 03:02:35.926 UTC [1] LOG: database system is ready to accept connections ``` 여섯 줄이 순서대로 나온다. `terminated by signal 9` 는 내가 죽인 그 백엔드이고, `all server processes terminated; reinitializing` 은 postmaster 가 전체를 갈아엎기로 한 것이며, **`not properly shut down` 이 주입 성공 신호다 — 이게 없으면 결과를 해석하지 않는다.** `redo starts` 와 `redo done` 이 재생된 WAL 구간, `checkpoint complete` 가 재생 결과를 디스크에 고정한 것, 그리고 `ready to accept connections` 의 시각이 새로 찍혔다. ```bash label="[kc-lab-1] 파드 재시작 횟수를 센다" kubectl -n keycloak-lab get pods -l app=postgres -o custom-columns=\ NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount ``` `RESTARTS` 는 `0` 이다. 컨테이너의 PID 1 인 postmaster 는 살아 있고 자식만 갈아치웠다. 쿠버네티스 관점에서는 아무 일도 없었지만 데이터 관점에서는 전원이 나간 것과 같다. ## 관찰 ```bash label="[kc-lab-1] ① 클라이언트가 받은 sid 목록을 꺼낸다" kubectl -n keycloak-lab exec a3-probe -- cat /tmp/sids > /tmp/client-sids.txt wc -l /tmp/client-sids.txt head -3 /tmp/client-sids.txt ``` 실측은 `07-loss-result.txt` 의 `클라이언트가 200 과 토큰을 받은 로그인 : 153 건` 이다(observed). 눈으로 한 번 보는 까닭은 빈 줄이 섞여 있으면 유실 건수가 부풀려지기 때문이다. ```text CQUfg9HLH29xvhiu6pVlfWOo 5gLP4fqmpZBbjhH_d-0TPMMr hkcOv1QskUFmYveMLB6Hljra ``` DB 쪽은 먼저 총계를 보고 그다음 목록으로 뽑는다. ```bash label="[kc-lab-1] ② 총계를 표로 본다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "select count(*) from offline_user_session where offline_flag='0'" ``` ```bash label="[kc-lab-1] ③ 값만 뽑아 파일로 내린다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \ "select user_session_id from offline_user_session where offline_flag='0'" \ > /tmp/db-sids.txt wc -l /tmp/db-sids.txt ``` 실측은 `DB 전체 온라인 세션 : 150 건` 이다(observed). `psql` 의 두 얼굴을 가른다 — `-c` 는 표를 그려서 사람이 읽기 좋고 `-tAc` 는 값만 줘서 파이프에 넣기 좋으므로, 한 번은 `-c` 로 눈으로 보고 셀 때만 `-tAc` 를 쓴다. 차집합은 `comm` 으로 낸다. 정렬부터 한다 — 이 세 줄은 미검증이다(unknown). ```bash label="[kc-lab-1] ④ 같은 정렬 순서로 맞춘 뒤 왼쪽 전용만 낸다" LC_ALL=C sort -u /tmp/client-sids.txt > /tmp/a.txt LC_ALL=C sort -u /tmp/db-sids.txt > /tmp/b.txt comm -23 /tmp/a.txt /tmp/b.txt ``` `comm -23` 은 왼쪽 파일에만 있는 줄을 내므로 클라이언트는 받았는데 DB 에는 없는 sid 다. `-1` 은 왼쪽 전용을, `-2` 는 오른쪽 전용을, `-3` 은 양쪽에 다 있는 줄을 감추므로 `-23` 은 왼쪽 전용만 남긴다. **`LC_ALL=C` 를 빼면 안 된다** — `comm` 은 두 파일이 같은 정렬 순서임을 전제하는데 로케일이 다르면 대소문자와 기호 순서가 달라져 멀쩡한 sid 가 「없는 것」으로 잡힌다. sid 는 대소문자와 `-` `_` 가 섞인 base64url 이라 정확히 그 문제에 걸린다. 실측은 이렇다(observed, `07-loss-result.txt`). ```text === 크래시 전후 대조 === 클라이언트가 200 과 토큰을 받은 로그인 : 153 건 그중 DB 에 실제로 존재 : 149 건 ★ 유실 : 4 건 === 유실된 sid 목록 === ★ CQUfg9HLH29xvhiu6pVlfWOo ← 토큰은 발급됐는데 세션이 없다 ★ 5gLP4fqmpZBbjhH_d-0TPMMr ← 토큰은 발급됐는데 세션이 없다 ★ hkcOv1QskUFmYveMLB6Hljra ← 토큰은 발급됐는데 세션이 없다 ★ p5XybeQIYmAs818gO4Vl_5ea ← 토큰은 발급됐는데 세션이 없다 ``` 로그인이 성공했다고 응답받았는데 세션이 존재하지 않는다. 153건 중 4건, 약 2.6% 다. ```bash label="[kc-lab-1] ⑤ 유실 건수를 센다" comm -23 /tmp/a.txt /tmp/b.txt | wc -l ``` 사라지지 않은 것도 하나 본다. sid 는 첫 줄 `tail -1` 이 화면에 찍은 그 값을 옮겨 넣는다 — 로그인 루프가 만든 값이라 실행마다 다르다. ```bash label="[kc-lab-1] ⑥ 마지막 sid 가 DB 에 있는지 본다" tail -1 /tmp/client-sids.txt kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "select user_session_id, created_on, last_session_refresh from offline_user_session where user_session_id='{{SID}}'" ``` 실측은 이렇다(observed). ```text === 그 토큰이 지금 실제로 쓰이는가 (마지막 sid 로 확인) === 마지막 sid: 8do0Bw6tkVLDVxgxotE7GosH user_session_id | created_on | last_session_refresh --------------------------+------------+---------------------- 8do0Bw6tkVLDVxgxotE7GosH | 1788490958 | 1788490958 (1 row) ``` 대부분은 멀쩡하다. 그래서 손실이 잘 안 보인다. 숫자를 읽을 때 성급하게 결론을 붙이지 않는다. 원래 문서는 「초당 19건 … `wal_writer_delay` 기본값(200ms)과 맞는다」고 썼는데 그 시점에 `wal_writer_delay` 를 조회한 적이 없었고 로그인 속도도 틀렸다. 증거를 다시 읽으면 8초에 112건 ≈ 초당 14건이고 4건 ≈ 약 0.29초 분량이다. | | | |---|---| | 측정한 손실 | 4건 ≈ **약 0.29초 분량** | | `wal_writer_delay` (주입 전에 잰 값) | **200 ms** | | 관계 | **같은 자릿수이되 정확히 일치하지는 않는다** | 「같은 자릿수」까지가 이 실험이 말할 수 있는 범위다. `wal_writer_delay` 하나가 손실 창을 정하는 것도 아니고 `wal_writer_flush_after`(128 × 8kB)와 체크포인트 타이밍이 함께 작용한다. 재현하면 로그인 속도와 디스크와 죽인 순간이 전부 다르므로 중요한 값은 「4」가 아니라 「0 이 아니다」이고, 그 크기가 WAL 플러시 주기와 같은 자릿수다. 사용자에게는 이렇게 보인다. ```text 로그인 성공 → access token + refresh token 을 받음 │ │ (크래시) ▼ 다음 요청 → access token 은 60초간 통한다 │ (서명만 보는 경로라면) ▼ 60초 후 refresh → "Session not active" → 다시 로그인 ``` 즉시 드러나지 않는다. access token 수명 동안은 정상으로 보이다가 갱신 시점에 끊기므로 장애와 증상 사이에 최대 60초의 시차가 있다. 그래서 모니터링은 갱신 실패율을 본다 — 로그인 성공률만 보면 이 장애는 안 보인다. 로그인은 `200` 을 줬기 때문이다. 이 손실이 허용된 까닭은 세션 쓰기가 매우 잦고(로그인마다, refresh 마다), 잃어도 사용자가 다시 로그인하면 되며, 동기 커밋의 비용은 모든 요청에 붙는데 크래시는 드물기 때문이다. 드문 사고의 비용을 상시 지연으로 지불하지 않겠다는 선택이고, 합리적이지만 선택했다는 사실을 알고 있어야 한다. 바꿀 수 있는지도 답이 있다. ```sql label="세션 트랜잭션까지 동기 커밋으로 강제하려는 시도 — 이것으로는 못 막는다" -- 세션 트랜잭션까지 동기 커밋으로 강제하려면 (지연 대가를 치른다) ALTER DATABASE keycloak SET synchronous_commit = on; ``` `SET LOCAL` 이 우선하므로 이것으로는 못 막는다. Keycloak 설정이나 소스 수준의 문제이고, RPO 0 이 필요하면 복제(streaming replication)로 푸는 쪽이 맞다. ## 복구와 원상복구 확인표 ### 1. 문장 로깅이 꺼져 있는지 본다 **목적** — 다음 실험의 측정값이 로그 폭주 때문에 달라지지 않게 한다. ```bash label="[kc-lab-1] ① 두 설정을 읽는다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "show log_statement" -c "show log_line_prefix" ``` ```bash label="[kc-lab-1] ② none 이 아니면 되돌리고 reload 한다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "alter system reset log_statement" -c "select pg_reload_conf()" ``` **예상 결과** — `log_statement` 가 `none` 이다. **왜 필요한가** — 이 편은 설계 확인 단계에서 문장 로깅을 한 번 켰다. 끄지 않고 넘어가면 다음 실험의 로그가 폭주하고, 로그인 루프를 도는 편에서는 디스크 I/O 가 늘어 크래시 타이밍 자체가 달라진다. **문제가 생기면** — `pg_reload_conf()` 를 다시 친다. `alter system` 만으로는 적용되지 않는다. ### 2. 세션을 정리하고 파드를 재시작하고 탐침을 지운다 **목적** — DB 행과 캐시 엔트리를 함께 비우고 실험 도구를 치운다. ```bash label="[kc-lab-1] ① 세션 행을 지운다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "delete from offline_user_session" ``` ```bash label="[kc-lab-1] ② 파드를 갈아 끼워 캐시를 비운다" kubectl -n keycloak-lab rollout restart statefulset/keycloak kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s ``` ```bash label="[kc-lab-1] ③ 탐침 파드를 지운다" kubectl -n keycloak-lab delete pod a3-probe --ignore-not-found ``` **예상 결과** — 두 Keycloak 파드가 새로 뜨고 `a3-probe` 가 `NotFound` 가 된다. **왜 필요한가** — 재시작을 빼면 DB 만 지워지고 캐시 엔트리가 남아 캐시 합계와 DB 총계가 어긋난다. A-0 이 겪은 함정이고 다음 실험의 출발값을 망친다. 탐침 파드는 `sleep 7200` 이 끝나면 `Completed` 로 남고 자동으로 사라지지 않는다. **문제가 생기면** — `a3-probe` 를 다시 못 만들면 옛 파드가 남아 있는 것이므로 `--ignore-not-found` 를 붙여 다시 지운다. ### 3. 데이터베이스가 건강한지 본다 **목적** — 크래시가 데이터 일부 손실인지 DB 파손인지 가른다. ```bash label="[kc-lab-1] ① 파드 상태" kubectl -n keycloak-lab get pods -l app=postgres ``` ```bash label="[kc-lab-1] ② 마이그레이션 이력 세 줄" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "select id, dateexecuted from databasechangelog order by dateexecuted desc limit 3" ``` **예상 결과** — 질의가 그냥 되고 마이그레이션 이력 세 줄이 나오면 된다. 건수는 Keycloak 버전마다 다르므로 숫자를 외울 필요가 없다. **왜 필요한가** — crash recovery 는 커밋되지 않은 것만 버리므로 스키마와 마이그레이션 이력은 멀쩡하다. 이 실험이 만든 것은 데이터 일부 손실이지 DB 파손이 아니다. **문제가 생기면** — 질의가 실패하면 파드 로그에서 기동 실패 원인을 본다. ### 4. 로컬 임시 파일을 치운다 **목적** — 다음 실행이 옛 sid 목록을 읽지 않게 한다. ```bash label="[kc-lab-1] 이 실험이 만든 로컬 파일 다섯 개" rm -f /tmp/client-sids.txt /tmp/db-sids.txt /tmp/a.txt /tmp/b.txt /tmp/a3-login-loop.sh ``` **예상 결과** — 아무것도 출력되지 않는다. **왜 필요한가** — `comm` 이 읽는 두 파일이 옛 실행의 값이면 유실 건수가 통째로 틀린다. **문제가 생기면** — 지워지지 않았으면 `ls -l /tmp` 로 경로를 다시 본다. | 항목 | 명령 | 돌아왔을 때 | |---|---|---| | DB | `kubectl -n keycloak-lab get pods -l app=postgres` | `1/1 Running` | | 문장 로깅 | `psql -c "show log_statement"` | `none` | | WAL 설정 | `psql -c "show synchronous_commit"` | `on` (전역은 원래 on) | | 파드 | `kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` | | 클러스터 | `vendor_cluster_size` | 양쪽 `2` | | DB 세션 | `psql -c "select count(*) from offline_user_session"` | `0` | | 탐침 파드 | `kubectl -n keycloak-lab get pod a3-probe` | `NotFound` | | 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` | ## 막히면 | 증상 | 원인 | 확인 | |---|---|---| | 유실이 `0건` 이다 | **죽인 적이 없다.** 대개 `--force` 나 `kill -9 1` 을 썼다 | `not properly shut down` 이 로그에 있나 | | `ready to accept connections` 가 있으니 크래시인 줄 알았다 | **아까 뜰 때 찍힌 줄이다** | **줄의 존재가 아니라 시각**을 본다 | | `kill -9 1` 을 했는데 아무 일도 없다 | **컨테이너 안에서 PID 1 은 SIGKILL 을 무시한다** | 백엔드 프로세스를 죽인다 | | 루프가 `0건` 을 모았다 | **파드 안에서 `&` 로 띄우면 `exec` 종료와 같이 죽는다** | 터미널 하나를 루프에 통째로 쓴다 | | 로그가 폭주하고 크래시 타이밍이 이상하다 | **`log_statement='all'` 을 켠 채로 루프를 돌렸다** | `show log_statement` 가 `none` 인지 | | 멀쩡한 sid 가 「없음」으로 잡힌다 | **`comm` 두 파일의 정렬 순서가 다르다** | `LC_ALL=C sort` 를 양쪽에 | | 유실 건수가 부풀려진다 | `/tmp/sids` 에 빈 줄이 섞였다 | `head -3` 으로 눈으로 본다 | | `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드로 친다 | | 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | `exec a3-probe -- sh -c 'echo ${#PW}'` — `0` 이면 `--env` 가 빈 값 | | `a3-probe` 를 다시 못 만든다 | 옛 파드가 `Completed` 로 남아 있다 | `delete pod a3-probe --ignore-not-found` | | `pgrep` 이 아무것도 못 찾는다 | Keycloak 이 아직 연결을 안 만들었다 | `ps -ef` 로 먼저 본다 | | 손실 건수를 시간으로 환산했더니 문서와 다르다 | **원래 문서가 속도를 잘못 썼다가 정정했다** | 초당 14건이 실측이다 | ## 무엇이 관측이고 무엇이 아닌가 이 절차의 숫자는 `2026-09-04 11:58–12:05 KST` 에 돈 한 번의 실행에서 나왔다(observed). - (observed) `offline_user_session` 의 시각 컬럼이 `integer`, 로그인 트랜잭션에도 붙은 `SET LOCAL synchronous_commit TO OFF`, WAL 설정 네 줄(`wal_writer_delay 200 ms default` 포함), 시도 ①·②·③ 의 시각 `12:00:26.511`·`12:03:21.441`·`12:04:22.063`, crash recovery 로그 여섯 줄, `153 / 149 / 4`, 유실 sid 네 개, 초당 14건, `DELETE 375`, 비밀번호 길이 `19`. - (unknown) `LC_ALL=C sort` 와 `comm -23` 세 줄, 루프를 앞으로 두고 돌리는 형태(원래 실행은 호스트에서 배경 `exec` 로 했다), `kubectl exec ... kill -9 40` 으로 PID 를 옮겨 적는 형태, 노드에서 호스트 PID 를 죽이는 (b) 갈래, `curlimages/curl:8.11.1` 에 `tar` 가 있는지. - 이 실험이 두 번 틀렸다가 고친 것 — 「`--force` 로 죽였다」와 「`kill -9 1` 로 죽였다」가 둘 다 유실 0건이라는 깨끗한 결과를 냈다. 주입 성공 신호를 미리 정해 두지 않았다면 결론은 「Keycloak 은 DB 크래시에도 데이터를 잃지 않는다」가 됐을 것이다. - 추정이었다가 나중에 잰 값 — `wal_writer_delay` 200ms. 원래 문서는 결과를 먼저 쓰고 그 값과 맞는다고 주장했는데 그때는 조회한 적이 없었고, 로그인 속도도 초당 19건으로 잘못 적었다가 14건으로 정정했다.