Files
document-haness/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a3-database-crash.md
T
DongHyeonkaandClaude Opus 5 024362d096 fix(setup): 실험대를 새로 세워 setup 35편을 밟고 어긋난 명령과 결과를 고친다
기반 가이드 7단계로 실험대를 철거하고 다시 세운 뒤 virtualization setup 9편과
keycloak-session-store 26편을 순서대로 밟았다. 24편은 끝까지, 11편은 되는 데까지
밟았고 밟은 범위를 편마다 적었다.

명령이 못 도는 것을 고쳤다.

- kubectl 을 `kc-lab-1` 에서 치라고 적었는데 그 기계에 kubeconfig 가 없다.
  라벨 639개와 각 편의 「어디서 치는가」를 `[lab host]` 로 옮겼다
- `-o custom-columns=…[0]…` 이 zsh 에서 글로브로 읽혀 안 돈다. 28곳에 따옴표
- busybox `sed` 가 끝 개행을 안 붙여 A-3 의 측정이 언제나 0 이었다
- `--token-file ~/node-token` 뒤에 그 파일을 지우면 k3s agent 가 재부팅을
  못 견딘다. `/etc/rancher/node-token` 으로 옮기는 처방을 재서 넣었다
- 게스트에 없는 도구를 전제로 한 명령 넷 — `conntrack`·`dig`·`strings`·`nginx -v`
- `echo` 와 JWT 헤더가 `"이름" : [ 값 ]` 으로 찍는데 문서는 공백 없이 옮겨 적어
  그 실측으로 만든 grep·sed 가 한 줄도 못 잡는다
- B-0 이 `directAccessGrantsEnabled` 와 계정 완성을 빠뜨려 B-3 이 못 돈다
- D-4·D-4a 가 `test-server` 와 `certbot-renew.*` 를 가리키는데 실제로는
  `kc-lab-edge` 의 `certbot.service` 다
- `virsh setmaxmem --config` 를 `dominfo` 로 판정하면 틀린다. `--inactive` 로
- `LIBVIRT_DEFAULT_URI` 를 rc 에만 넣으면 `ssh host '명령'` 에서 안 먹는다

결과가 조건부인 것을 갈랐다.

- readiness 는 즉시 안 뒤집힌다. A-1·A-2 의 60초 창을 적었다
- 03 의 층 ②③ `301` 은 04 이후의 값이고 그 단계에서는 `404` 다
- A-0 의 로그 필터를 요청 직후에 치면 정반대 결론이 나온다
- A-5 의 한 방향 차단은 잠깐 `1` 이었다 `2` 로 돌아온다

증거는 두 프로젝트의 `evidence/raw/` 에 99벌을 README 와 함께 남겼다. 비밀은
길이만 적었고 화면에 찍힌 토큰은 가렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:59:42 +09:00

48 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
id kind slug title topic topicName project status studio pinnedVersions source sourceRevision
91ce17ad-758d-4a63-be5b-489786609557 SETUP reproduce-a3-database-crash PostgreSQL 을 진짜로 크래시시키고 잃은 로그인을 센다 losing-a-node-or-the-store PostgreSQL 을 내리고 노드 전원을 뽑았을 때 keycloak-session-store 게시 전 https://hyeonworks.com/studio/documents/91ce17ad-758d-4a63-be5b-489786609557/edit
name version
Keycloak 26.7.0
name version
curlimages/curl 8.11.1
final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-3
cdac9b8178391311d8eca1ebc6cac15bb62d79af

PostgreSQL 을 진짜로 크래시시키고 잃은 로그인을 센다

PostgreSQL 을 진짜로 죽여 커밋됐다고 응답받은 로그인 중 몇 건이 사라지는지 세는 절차다. 절차의 절반은 죽이는 데 실패하는 두 가지 방법이고, 그래서 결과를 세기 전에 주입 성공 신호부터 본다. 전 구간 약 40분이고 터미널 두 개가 필요하다.

관계

  • 200 과 토큰을 받은 로그인 네 건이 데이터베이스에 없었다 이 절차가 낸 결론을 담은 기록이다. 여기에는 치는 순서만 있다.
  • 주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다 이 편의 시도 ① 과 ② 가 그 아홉 건에 들어간다. 둘 다 화면에는 성공으로 보였다.
  • 산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다 파드 안에서 ( ... ) & 로 띄운 루프가 exec 세션과 함께 죽어 0건을 모은 것을 다룬다.
  • 예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다 주입 성공 신호를 미리 정해 두는 규칙을 편 기록이다. 이 편이 그 규칙이 없었으면 정반대 결론을 낼 뻔했다.
  • PostgreSQL 을 정상 종료시키고 네 경로를 잰다 먼저 해 둬야 하는 편이다. 정상 종료가 아무것도 잃지 않는다는 그쪽 결과가 이 편의 대조군이다.

본문

읽기 전에 — 어디서 치는가

명령은 전부 [lab host] 에서 kubectlpsql 로 친다. 노드 자체를 건드리는 명령이 없어서 게스트에 들어갈 일이 없다. kubectlsudo 를 붙이지 않는다 — root 홈에는 ~/.kube/config 가 없어 localhost:8080 으로 붙으려다 connection refused 로 끝난다.

원 가이드는 이 명령들을 kc-lab-1 에서 치라고 적었다. 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 sudo 없는 kubectlpermission denied 로 막힌다 — kubeconfig 는 lab host 의 ~/.kube/config 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 kubectl 블록의 기계 이름을 [lab host] 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.

터미널은 둘이 반드시 필요하다. 터미널 ① 은 로그인 루프가 도는 동안 통째로 붙잡히고, 터미널 ② 에서 그 사이에 데이터베이스를 죽인다. 코드블록마다 어느 터미널인지 붙여 두었다.

무엇
네임스페이스 keycloak-lab · 관측 스택은 observability
주입 수단 셋을 순서대로 — --grace-period=0 --force · kill -9 1 · 백엔드 SIGKILL
주입 성공 신호 database system was not properly shut downredo starts·redo done
탐침 파드 a3-probecurlimages/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 에서 이 한 줄을 잡았다.

SET LOCAL synchronous_commit TO OFF

COMMIT 직전, 같은 트랜잭션 안에서 나온다.

   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 downredo 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 이고 RESTARTS0 이어야 한다. 그 값은 주입 판정에 쓰이므로 적어 둔다.
  • A-0 이 켰던 문장 로깅이 꺼져 있어야 한다. 켜진 채로 루프를 돌리면 크래시 타이밍이 달라진다.

이건 데이터를 잃는 실험이다. PostgreSQL 을 강제로 죽이고 세션 테이블을 두 번 비운다. 실제로 커밋됐다고 응답한 데이터가 사라진다. 실험대에서만 한다.

지운 세션은 돌아오지 않는다. 되돌릴 수 있는 것은 문장 로깅 하나이고, 켜기 전에 끄는 명령을 먼저 읽어 둔다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "alter system reset log_statement" -c "select pg_reload_conf()"

주입 전에 같은 명령으로 먼저 본다

측정 설계가 성립하는지부터 본다. 여기서 하나라도 어긋나면 뒤의 숫자는 아무 의미가 없다.

1. 시각 컬럼의 눈금으로 무엇을 잴 수 있는가

무엇을 보는가 — 세션 테이블의 컬럼 타입.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "\d offline_user_session"

어디를 보나 — 실측은 이렇다(observed, 01-crash-injection.txt).

  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초라 보일 리가 없고, 「세션 갱신 시각이 되감기는지」 보려던 설계는 버렸다. 대신 행 존재 여부로 잰다.

   로그인 1회  =  OFFLINE_USER_SESSION 행 1개
   클라이언트가 sid 를 받았다  =  서버가 COMMIT 했다고 응답했다
   크래시 후 그 sid 가 없다    =  잃은 것

있거나 없거나이므로 눈금 문제가 없다. 이 실험이 로그인 수백 건을 도는 까닭이 여기 있다 — 이진 판정을 여러 번 해서 비율로 만든다.

2. 로그인 트랜잭션도 비동기 커밋인지 확인한다

무엇을 보는가 — A-0 에서 잡은 것은 refresh 트랜잭션이었고 로그인(INSERT)도 그런지는 확인하지 않았다. 아니라면 로그인은 안 사라지고 이 측정 설계 자체가 성립하지 않는다.

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 systempostgresql.auto.conf 에 쓸 뿐이고 reload 를 해야 적용된다.

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).

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}"'
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"'
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).

=== [설계 확인] 로그인 트랜잭션도 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

이 값이 뜻하는 것BEGINCOMMIT 사이에 insert into OFFLINE_USER_SESSIONSET LOCAL synchronous_commit TO OFF 가 같이 들어 있다. 앞의 BEGIN/COMMIT(2·5줄)은 다른 트랜잭션이다. 설계가 확인됐고 함의가 refresh 보다 훨씬 무겁다 — refresh 갱신 시각을 잃으면 세션 수명이 조금 짧아질 뿐이고 사용자는 모르지만, 로그인 자체를 잃으면 토큰은 손에 있는데 세션이 없고 다음 요청부터 실패한다.

곧바로 끈다.

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 설정을 재기 전에 잰다

무엇을 보는가 — 나중에 손실 창과 견줄 값.

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).

=== 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_commiton 이다. 전역 설정만 보면 「우리는 동기 커밋」이라고 믿게 되는데 Keycloak 이 자기 트랜잭션에만 SET LOCAL 로 뒤집는다. 서버 설정만 보고 판단하면 틀린다. 원래 실험은 결과를 먼저 쓰고 「wal_writer_delay 기본값(200ms)과 맞는다」고 주장했는데 그 시점에 이 값을 조회한 적이 없었다. 나중에 재서 맞기는 했지만 그때는 추정이었다. 가정한 값은 재기 전에 재 둔다.

4. 세션 테이블을 비우고 0 인지 센다

무엇을 보는가 — 출발값.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "delete from offline_user_session"
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.txtDELETE 375남은 세션: 0 이다(observed).

이 값이 뜻하는 것 — 크래시 뒤에 「DB 전체 세션 수」와 「내가 만든 세션 수」를 나란히 놓고 볼 것이므로 시작이 0 이어야 그 둘이 읽힌다. 캐시는 안 비워도 된다 — 이 실험의 판정은 DB 행의 존재 여부이고 캐시는 판정에 안 들어간다.

5. 파드 셋의 재시작 횟수를 적어 둔다

kubectl -n keycloak-lab get pods -o wide

keycloak-0 keycloak-1 postgres 가 전부 1/1 Running 이고 RESTARTS0 이어야 한다. RESTARTS 값을 적어 둔다 — 주입 판정의 일부다.

주입

세 번 시도한다. 순서대로 따라가면 죽이는 데 실패하는 두 가지 방법을 직접 보게 되고, 건너뛰고 세 번째만 하면 왜 그것이 유일한 방법인지 모른다.

시도 셋을 연달아 치지 않는다. 하나를 주입할 때마다 아래 「주입 검증」의 같은 번호 절을 치고 다음 시도로 넘어간다. 셋이 같은 /tmp/sids 와 같은 DB 를 보기 때문에 몰아서 치면 wc -l /tmp/sids 가 세 시도의 합을 내고, 시도 ① 이 0건을 잃었다는 것을 더는 보일 수 없다. 치는 순서는 이렇다.

   시도 ①  ─▶  주입 검증 §1  ─▶  시도 ②  ─▶  주입 검증 §2  ─▶  시도 ③  ─▶  주입 검증 §3

1. 로그인 루프를 파일로 써서 파드에 넣는다

목적 — 클라이언트가 200 과 토큰을 실제로 받은 로그인의 목록을 파드 안 파일에 쌓는다.

루프는 한 줄로 칠 물건이 아니다. 원래 실행은 이걸 kubectl exec ... sh -c "..." 한 줄에 욱여넣었고 인용이 세 겹이 되어 두 번 깨졌다. 실측은 이렇다(observed, 01-crash-injection.txt).

=== [1] 빠른 연속 로그인을 백그라운드로 시작 ===
  루프 시작
  6초 경과 — 지금까지 성공한 로그인: 0
...
  클라이언트가 200 을 받은 로그인 수: 0

0건이다. 파드 안에서 ( ... ) & 로 띄운 루프가 exec 세션이 끝날 때 같이 죽었고 측정 자체가 없었다. 그래서 편집기로 파일을 연다.

vim /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
    SID=$(echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
          | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
    [ -n "$SID" ] && echo "$SID" >> /tmp/sids
  fi
  i=$((i + 1))
done
echo "루프 종료: $(wc -l < /tmp/sids) 건"

sed 의 출력을 파일로 바로 흘리지 않는다. 변수에 받아 echo 로 다시 쓴다. 탐침 이미지의 셸이 busybox 이고 그 sed입력에 끝 개행이 없으면 출력에도 안 붙인다. base64 -d 가 내놓는 JSON 에는 끝 개행이 없으므로, 바로 >> 로 흘리면 sid 가 줄바꿈 없이 이어 붙어 파일 한 줄이 된다. 2026-09-17 에 그대로 겪었다(observed) — 로그인 217건이 DB 에 들어갔는데 파일은 이랬다.

0 /tmp/sids
9384 /tmp/sids

wc -l0 이고 wc -c 는 9384 다. 데이터는 다 있는데 줄이 하나도 없다. 이 파일을 wc -l·sort -u·comm 으로 세는 것이 이 실험의 측정 전부이므로, 이 상태로는 손실 건수가 언제나 0 으로 나온다. echo 로 감싼 형태로 열 번 돌려 10 줄 / 250 바이트 를 받아 고쳤다(observed).

busybox 의 sed 가 그렇게 동작하는 것을 따로 확인했다(observed).

printf "%s" '{"sid":"ABC"}' | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p' | od -c
0000000   A   B   C
0000003

/tmp/sids 에는 클라이언트가 200 과 토큰을 실제로 받은 것만 쌓인다. AT 가 비면 아무것도 안 적으므로 이 파일이 「서버가 COMMIT 했다고 응답한 것」의 목록이고, 그게 이 실험의 시험군이다.

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

예상 결과 — 줄 수가 19 /tmp/a3-login-loop.sh 로 나오면 들어갔다. 원래 실행의 파일은 17 줄이었고, 위에서 sid 를 변수로 받는 두 줄로 바꾸면서 늘었다.

왜 필요한가kubectl cp 도 되지만 컨테이너에 tar 가 있어야 한다. 이 실험대의 curlimages/curl:8.11.1tar 가 있는지는 재지 않았다(unknown). 그래서 가이드는 cat > 로 밀어 넣는 쪽이 어디서나 통한다고 적고 그쪽을 골랐다.

문제가 생기면 — 줄 수가 0 이면 로컬 파일이 비었거나 경로가 틀린 것이므로 wc -l /tmp/a3-login-loop.sh 를 로컬에서 먼저 친다.

2. 터미널 하나를 루프에 통째로 쓴다

목적 — 초당 십몇 건의 로그인을 계속 보내면서 크래시 순간을 감싼다.

kubectl -n keycloak-lab exec a3-probe -- sh /tmp/a3-login-loop.sh
kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/sids

예상 결과 — 실측은 06-backend-kill-crash.txt8초 후: 112 건 이다(observed). 8초에 112건이면 초당 약 14건이고, 이 속도를 적어 둔다 — 손실 건수를 시간으로 환산할 때 쓴다.

왜 필요한가& 로 배경에 보내지 않는다. 그게 원래 실행이 실패한 까닭이고, 터미널을 하나 통째로 이 루프에 쓴다. 이 앞으로 두고 돌리는 형태는 미검증이다(unknown) — 원래 실행은 호스트에서 배경 exec 로 했다.

문제가 생기면 — 0건이면 루프가 안 도는 것이므로 터미널 ① 을 본다.

3. 시도 ① — --grace-period=0 --force

목적 — 「강제 삭제」라는 이름이 붙은 방법이 크래시인지 확인한다.

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).

=== [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 을 보내 본다.

date '+%H:%M:%S.%3N SIGKILL'
kubectl -n keycloak-lab exec deploy/postgres -- kill -9 1

예상 결과 — 명령은 조용히 끝난다. 무슨 일이 일어났는지는 주입 검증에서 본다.

왜 필요한가 — 이것도 건너뛰면 세 번째 방법이 왜 유일한지 모른다.

문제가 생기면 — 명령이 오류를 내면 파드 이름과 네임스페이스를 먼저 본다.

다음 — 여기서 멈추고 「주입 검증」 §2 를 친 뒤 시도 ③ 으로 넘어온다.

5. 시도 ③ — 백엔드 프로세스를 죽인다

목적 — postmaster 가 공유 메모리 오염을 보고 전체를 재초기화하게 만든다. PostgreSQL 은 postmaster(부모) + 연결마다 백엔드(자식) 구조이고, 자식 하나가 비정상 종료하면 postmaster 가 전체를 재초기화하며 그것이 곧 crash recovery 다.

kubectl -n keycloak-lab exec deploy/postgres -- ps -ef | head -20

값은 환경마다 다르고 모양은 이렇다.

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 를 읽어서 넣는다. 예시 숫자를 그대로 치면 그 파드의 엉뚱한 프로세스를 죽인다.

date '+%H:%M:%S.%3N SIGKILL'
kubectl -n keycloak-lab exec deploy/postgres -- kill -9 <PID>

이 두 줄 형태는 이 실험대에서 치지 않았다(unknown). 실제로 친 것은 이름으로 고르는 쪽이다 — 백엔드가 여럿이면 이쪽이 한 번에 전부 끊는다.

kubectl exec deploy/postgres -- pkill -9 -f 'postgres: keycloak'

이 실험대는 한 줄에 원격 셸과 명령 치환을 겹쳐서 쳤다(observed).

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).

=== 백엔드 프로세스에 SIGKILL → postmaster 가 재초기화한다 ===
  시각: 12:04:22.063
  최종 성공 로그인: 153 건

터미널 ① 의 루프를 Ctrl-C 로 멈춘다.

왜 필요한가 — 컨테이너 밖이 아니라 안에서 보내는 시그널이라 PID 1 로는 통하지 않는다. 자식 프로세스라야 SIGKILL 이 전달된다.

문제가 생기면pgrep 이 아무것도 못 찾으면 Keycloak 이 아직 연결을 안 만든 것이므로 ps -ef 로 먼저 본다.

다음 — 「주입 검증」 §3 을 친다.

주입 검증

세 절은 이어서 치는 것이 아니다. 각각 같은 번호의 시도 직후에 치고 「주입」으로 돌아간다.

결과를 세기 전에 주입 성공 신호를 본다. 이 실험은 그 신호를 미리 정해 뒀다.

   PostgreSQL 이 정상 종료했다  →  pg_control 에 "깨끗하게 종료됨" 표시
                                    →  다음 기동에 아무 말 없이 뜬다

   PostgreSQL 이 즉사했다       →  표시가 없다
                                    →  "database system was not properly shut down"
                                    →  "redo starts at ..." / "redo done at ..."

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).

=== 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 downredo 도 없으므로 crash recovery 가 돌지 않았고 깨끗하게 내려갔다.

그런데도 손실을 세어 보면 이렇게 나온다.

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).

=== [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. 시도 ② 의 검증 — 아무 일도 일어나지 않았다

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).

=== [재주입] 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 도 예외가 아니다.

   같은 네임스페이스 안에서  →  PID 1 은 등록하지 않은 시그널을 무시한다
   조상 네임스페이스에서     →  전달된다 (노드에서 kill -9 하면 죽는다)

부팅 초기에 init 을 실수로 죽여 시스템이 멈추는 것을 막기 위한 장치인데, 컨테이너에서는 「안에서는 PID 1 을 못 죽인다」로 나타난다. 그래서 크래시 재현은 두 갈래다 — (a) 자식 프로세스를 죽이거나 (b) 노드에서 ssh kc-lab-2 'sudo kill -9 <호스트 PID>' 로 죽인다. 컨테이너 밖은 조상 네임스페이스이므로 SIGKILL 이 통한다. 이 실험은 (a) 로 했고 (b) 는 치지 않았다(unknown).

3. 시도 ③ 의 검증 — 이번엔 걸렸다

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).

  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 startsredo done 이 재생된 WAL 구간, checkpoint complete 가 재생 결과를 디스크에 고정한 것, 그리고 ready to accept connections 의 시각이 새로 찍혔다.

kubectl -n keycloak-lab get pods -l app=postgres -o "custom-columns=NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount"

RESTARTS0 이다. 컨테이너의 PID 1 인 postmaster 는 살아 있고 자식만 갈아치웠다. 쿠버네티스 관점에서는 아무 일도 없었지만 데이터 관점에서는 전원이 나간 것과 같다.

관찰

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). 눈으로 한 번 보는 까닭은 빈 줄이 섞여 있으면 유실 건수가 부풀려지기 때문이다.

CQUfg9HLH29xvhiu6pVlfWOo
5gLP4fqmpZBbjhH_d-0TPMMr
hkcOv1QskUFmYveMLB6Hljra

DB 쪽은 먼저 총계를 보고 그다음 목록으로 뽑는다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select count(*) from offline_user_session where offline_flag='0'"
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).

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).

=== 크래시 전후 대조 ===
  클라이언트가 200 과 토큰을 받은 로그인 : 153 건
  그중 DB 에 실제로 존재                 : 149 건
  ★ 유실                                 : 4 건

=== 유실된 sid 목록 ===
    ★ CQUfg9HLH29xvhiu6pVlfWOo  ← 토큰은 발급됐는데 세션이 없다
    ★ 5gLP4fqmpZBbjhH_d-0TPMMr  ← 토큰은 발급됐는데 세션이 없다
    ★ hkcOv1QskUFmYveMLB6Hljra  ← 토큰은 발급됐는데 세션이 없다
    ★ p5XybeQIYmAs818gO4Vl_5ea  ← 토큰은 발급됐는데 세션이 없다

로그인이 성공했다고 응답받았는데 세션이 존재하지 않는다. 153건 중 4건, 약 2.6% 다.

2026-09-17 에 새 실험대에서 다시 재니 395건 중 3건이었다(observed). 세 시도를 순서대로 밟았고 앞의 둘은 그때도 0건이었다.

시도 ① --grace-period=0 --force   유실 0건 — 새 파드 로그가 `database system was shut down at …`
시도 ② kill -9 1                  유실 0건 — RESTARTS 0, 파드 이름도 그대로
시도 ③ 백엔드 PID 39 에 SIGKILL   유실 3건 — 클라이언트 395 · DB 392

시도 ③ 의 로그가 남긴 것도 같았다.

server process (PID 39) was terminated by signal 9: Killed
all server processes terminated; reinitializing
database system was not properly shut down; automatic recovery in progress
redo starts at 0/1F80A00

건수는 실행마다 다르다. 크래시 순간에 아직 디스크로 못 내려간 트랜잭션만 사라지므로 로그인 속도와 wal_writer_delay 와 크래시 타이밍이 그 수를 정한다. 판정에 쓰는 것은 숫자가 아니라 0 이 아니라는 것이다.

comm -23 /tmp/a.txt /tmp/b.txt | wc -l

사라지지 않은 것도 하나 본다. sid 는 첫 줄 tail -1 이 화면에 찍은 그 값을 옮겨 넣는다 — 로그인 루프가 만든 값이라 실행마다 다르다.

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).

=== 그 토큰이 지금 실제로 쓰이는가 (마지막 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 플러시 주기와 같은 자릿수다.

사용자에게는 이렇게 보인다.

   로그인 성공  →  access token + refresh token 을 받음
        │
        │  (크래시)
        ▼
   다음 요청     →  access token 은 60초간 통한다
        │            (서명만 보는 경로라면)
        ▼
   60초 후 refresh  →  "Session not active"  →  다시 로그인

즉시 드러나지 않는다. access token 수명 동안은 정상으로 보이다가 갱신 시점에 끊기므로 장애와 증상 사이에 최대 60초의 시차가 있다. 그래서 모니터링은 갱신 실패율을 본다 — 로그인 성공률만 보면 이 장애는 안 보인다. 로그인은 200 을 줬기 때문이다.

이 손실이 허용된 까닭은 세션 쓰기가 매우 잦고(로그인마다, refresh 마다), 잃어도 사용자가 다시 로그인하면 되며, 동기 커밋의 비용은 모든 요청에 붙는데 크래시는 드물기 때문이다. 드문 사고의 비용을 상시 지연으로 지불하지 않겠다는 선택이고, 합리적이지만 선택했다는 사실을 알고 있어야 한다.

바꿀 수 있는지도 답이 있다.

-- 세션 트랜잭션까지 동기 커밋으로 강제하려면 (지연 대가를 치른다)
ALTER DATABASE keycloak SET synchronous_commit = on;

SET LOCAL 이 우선하므로 이것으로는 못 막는다. Keycloak 설정이나 소스 수준의 문제이고, RPO 0 이 필요하면 복제(streaming replication)로 푸는 쪽이 맞다.

복구와 원상복구 확인표

1. 문장 로깅이 꺼져 있는지 본다

목적 — 다음 실험의 측정값이 로그 폭주 때문에 달라지지 않게 한다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "show log_statement" -c "show log_line_prefix"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "alter system reset log_statement" -c "select pg_reload_conf()"

예상 결과log_statementnone 이다.

왜 필요한가 — 이 편은 설계 확인 단계에서 문장 로깅을 한 번 켰다. 끄지 않고 넘어가면 다음 실험의 로그가 폭주하고, 로그인 루프를 도는 편에서는 디스크 I/O 가 늘어 크래시 타이밍 자체가 달라진다.

문제가 생기면pg_reload_conf() 를 다시 친다. alter system 만으로는 적용되지 않는다.

2. 세션을 정리하고 파드를 재시작하고 탐침을 지운다

목적 — DB 행과 캐시 엔트리를 함께 비우고 실험 도구를 치운다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "delete from offline_user_session"
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
kubectl -n keycloak-lab delete pod a3-probe --ignore-not-found

예상 결과 — 두 Keycloak 파드가 새로 뜨고 a3-probeNotFound 가 된다.

왜 필요한가 — 재시작을 빼면 DB 만 지워지고 캐시 엔트리가 남아 캐시 합계와 DB 총계가 어긋난다. A-0 이 겪은 함정이고 다음 실험의 출발값을 망친다. 탐침 파드는 sleep 7200 이 끝나면 Completed 로 남고 자동으로 사라지지 않는다.

문제가 생기면a3-probe 를 다시 못 만들면 옛 파드가 남아 있는 것이므로 --ignore-not-found 를 붙여 다시 지운다.

3. 데이터베이스가 건강한지 본다

목적 — 크래시가 데이터 일부 손실인지 DB 파손인지 가른다.

kubectl -n keycloak-lab get pods -l app=postgres
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 목록을 읽지 않게 한다.

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건 이다 죽인 적이 없다. 대개 --forcekill -9 1 을 썼다 not properly shut down 이 로그에 있나
ready to accept connections 가 있으니 크래시인 줄 알았다 아까 뜰 때 찍힌 줄이다 줄의 존재가 아니라 시각을 본다
kill -9 1 을 했는데 아무 일도 없다 컨테이너 안에서 PID 1 은 SIGKILL 을 무시한다 백엔드 프로세스를 죽인다
루프가 0건 을 모았다 파드 안에서 & 로 띄우면 exec 종료와 같이 죽는다 터미널 하나를 루프에 통째로 쓴다
로그가 폭주하고 크래시 타이밍이 이상하다 log_statement='all' 을 켠 채로 루프를 돌렸다 show log_statementnone 인지
멀쩡한 sid 가 「없음」으로 잡힌다 comm 두 파일의 정렬 순서가 다르다 LC_ALL=C sort 를 양쪽에
유실 건수가 부풀려진다 /tmp/sids 에 빈 줄이 섞였다 head -3 으로 눈으로 본다
kubectl exec keycloak-0 -- curlexit 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:5812: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 sortcomm -23 세 줄, 루프를 앞으로 두고 돌리는 형태(원래 실행은 호스트에서 배경 exec 로 했다), kubectl exec ... kill -9 40 으로 PID 를 옮겨 적는 형태, 노드에서 호스트 PID 를 죽이는 (b) 갈래, curlimages/curl:8.11.1tar 가 있는지.
  • 이 실험이 두 번 틀렸다가 고친 것 — 「--force 로 죽였다」와 「kill -9 1 로 죽였다」가 둘 다 유실 0건이라는 깨끗한 결과를 냈다. 주입 성공 신호를 미리 정해 두지 않았다면 결론은 「Keycloak 은 DB 크래시에도 데이터를 잃지 않는다」가 됐을 것이다.
  • 추정이었다가 나중에 잰 값 — wal_writer_delay 200ms. 원래 문서는 결과를 먼저 쓰고 그 값과 맞는다고 주장했는데 그때는 조회한 적이 없었고, 로그인 속도도 초당 19건으로 잘못 적었다가 14건으로 정정했다.