Files
keycloak-pattern/docs/guides/experiments/a3-database-crash.md
T
DongHyeonkaandClaude Opus 5 6f6ab86345 docs(guides): reproduction guides for all 26 experiments
Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.

Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.

The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:

  A-0   emptying the session table without a restart leaves cache entries
        that get counted as replication arriving
  A-2   dropping -o /dev/null fuses body and status into one string
  A-3   presence of "ready to accept connections" instead of its timestamp
  B-2   row count alone reads an UPDATE as nothing having happened
  B-4   tr ',' '\n' splits ["admin","editor"] so only admin is seen
  B-7   no login screen means the cookie died and SSO re-authenticated
  C-1   counting sessions without joining realm counts your own kcadm one
  D-1   kubectl exec without -i restores nothing and still exits 0
  D-4a  "ran with error output" is what success looks like

Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.

Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.

Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:00 +09:00

994 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# A-3 재현 가이드 — DB 를 진짜로 죽여서 몇 건이 사라지는지 센다
해설 문서: [`docs/experiment-a3-database-crash.md`](../../experiment-a3-database-crash.md) ·
증거 원문: [`docs/evidence/a3-database-crash/`](../../evidence/a3-database-crash/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 로그인 트랜잭션에 붙은 `SET LOCAL synchronous_commit TO OFF` | PostgreSQL 문장 로그 |
| `--grace-period=0 --force`**크래시가 아니라는 것** | crash recovery 가 없는 재기동 로그 |
| 컨테이너 안에서 **PID 1 이 SIGKILL 을 무시하는 것** | 파드 재시작 0, 로그 시각 그대로 |
| `not properly shut down` / `redo starts` / `redo done` | 같은 로그 |
| **`200` 과 토큰을 받았는데 DB 에 없는 sid** | `comm` 으로 뽑은 차집합 |
| `wal_writer_delay = 200ms` 가 기본값이라는 것 | `pg_settings` |
## 전제
- [`A-0`](a0-session-replication.md) 과 [`A-2`](a2-database-loss.md) 를 먼저 한다.
A-0 이 `SET LOCAL synchronous_commit TO OFF` 를 발견했고, 이 실험은 **그
대가가 몇 건인지**를 잰다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- 네임스페이스는 `keycloak-lab`.
- 터미널 **두 개가 반드시 필요하다.** 하나는 로그인 루프를 돌리고(붙잡고 있어야
한다), 하나는 그 사이에 DB 를 죽인다.
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
## 주의 — 이건 데이터를 잃는 실험이다
**PostgreSQL 을 강제로 죽이고, 세션 테이블을 두 번 비운다.** 실제로 커밋됐다고
응답한 데이터가 사라진다. **실험대에서만 한다.** 전 구간 약 40분이고, 되돌리는
방법은 매 단계에 적어 두었다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 11:5812:05 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
sid 와 건수는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를 쓰지
않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행
기록의 실제 값이다.
---
# 0. 왜 이 실험을 하는가
[A-2](a2-database-loss.md) 는 DB 를 **정상 종료**시켰다. 세션은 하나도 안
없어졌다. 당연하다 — PostgreSQL 은 SIGTERM 을 받으면 WAL 을 플러시하고 내려간다.
**그런데 [A-0](a0-session-replication.md) 에서 이 한 줄을 잡았다.**
```sql
SET LOCAL synchronous_commit TO OFF
```
`COMMIT` 직전, **같은 트랜잭션 안에서** 나온다. 뜻은 이렇다.
```
COMMIT
├─ WAL 버퍼(메모리)에 기록 ← 항상 한다
├─ synchronous_commit = on : 디스크 플러시를 기다렸다가 응답
└─ synchronous_commit = off : 기다리지 않고 즉시 응답 ← Keycloak
└─ 크래시 시 이 구간이 사라진다
```
**「사라질 수 있다」와 「몇 건 사라졌다」는 다르다.** 이 실험은 뒤쪽이다.
RPO(Recovery Point Objective)를 숫자로 만든다.
**그리고 이 실험의 절반은 「죽이는 데 실패하는 이야기」다.** 세 번 시도해서
세 번째에 성공했고, 앞의 둘은 **「손실 0건」으로 보였지만 실제로는 죽인 적이
없었다.** A-1 이 남긴 교훈이 그대로 나온다 — **주입이 실제로 걸렸는지 먼저
확인하지 않으면 「아무 일도 없었다」를 결과로 착각한다.**
---
# 1. 설계 확인 — 재기 전에 세 가지를 확인한다
**측정 설계가 성립하는지부터 본다.** 여기서 하나라도 어긋나면 뒤의 숫자는
아무 의미가 없다.
## 1-1. 눈금이 맞는가 — `LAST_SESSION_REFRESH` 로는 못 잰다
처음 계획은 「세션 갱신 시각이 되감기는지」 보는 것이었다. 스키마를 보고 접었다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "\d offline_user_session"
```
**실측** — [`01-crash-injection.txt`](../../evidence/a3-database-crash/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 가 없다 = 잃은 것
```
**있거나 없거나**이므로 눈금 문제가 없다. **이 실험이 로그인 수백 건을 도는
이유가 이것이다** — 이진 판정을 여러 번 해서 비율로 만든다.
## 1-2. 로그인도 비동기 커밋인가 — **아니면 설계가 무너진다**
A-0 에서 잡은 것은 **refresh** 트랜잭션이었다. **로그인(INSERT)도 그런지는
확인하지 않았다.** 아니라면 로그인은 안 사라지고, 이 측정 설계 자체가 성립하지
않는다.
### 켠다 — 첫 번째 주입
**되돌리기** — 먼저 읽어 둔다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system reset log_statement" -c "select pg_reload_conf()"
```
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system set log_statement='all'" -c "select pg_reload_conf()"
```
**확인** — 실제로 켜졌나
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
**형태**
```
log_statement
---------------
all
```
`none` 이면 `pg_reload_conf()` 가 안 돈 것이다. `alter system`
`postgresql.auto.conf` 에 쓸 뿐이고 **reload 를 해야 적용된다.**
### 로그인 한 번을 보낸다
Keycloak 컨테이너에는 `curl``wget` 도 없다(`exit 127`). 탐침 파드를 띄운다.
**하기**
```bash
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
sudo kubectl -n keycloak-lab run a3-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" \
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a3-probe --timeout=120s
```
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
> 셸 히스토리에도 남지 않는다. 존재와 길이만 확인하고 싶으면:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
> **실측** — `19`
>
> **★ 명령줄에 비밀번호를 직접 쓰지 않는다.** 원래 실험의 재현 절차에는
> 평문 비밀번호가 그대로 적혀 있는데, **파드 안 `ps` 에도 셸 히스토리에도
> 남는다.** `--env` 로 넘긴 값은 그 파드 안에서만 산다.
**확인** — 환경변수가 들어갔나
```bash
sudo kubectl -n keycloak-lab exec a3-probe -- sh -c 'echo "K0=$K0 PW길이=${#PW}"'
```
**형태**
```
K0=10.42.1.67 PW길이=19
```
**하기** — 로그인 한 번
```bash
sudo 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"'
```
**형태**
```
200
```
### 로그에서 로그인 트랜잭션을 찾는다
**확인**
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --since=60s \
| grep -E 'BEGIN|insert into OFFLINE|synchronous_commit|COMMIT' | tail -20
```
**실측** — [`02-design-check.txt`](../../evidence/a3-database-crash/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
```
**어디를 봐야 하는가**`BEGIN``COMMIT` 사이에 **`insert into
OFFLINE_USER_SESSION``SET LOCAL synchronous_commit TO OFF` 가 같이 들어
있는 것.** 앞의 `BEGIN`/`COMMIT`(2·5줄)은 다른 트랜잭션이다.
**이 결과가 의미하는 것****확인됐고, 함의가 refresh 보다 훨씬 무겁다.**
| | 잃으면 |
|---|---|
| refresh 갱신 시각 | 세션 수명이 조금 짧아진다. **사용자는 모른다** |
| **로그인 자체** | **토큰은 손에 있는데 세션이 없다.** 다음 요청부터 실패 |
### ★ 곧바로 끈다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system reset log_statement" -c "select pg_reload_conf()"
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
**형태**
```
log_statement
---------------
none
```
**★ 켜 둔 채로 3절에 들어가면 안 된다.** 3절은 수백 건의 로그인을 최대한 빨리
돈다. `log_statement='all'` 이면 **로그인 하나에 SQL 열 몇 줄씩** 쌓인다.
로그가 폭주하고, 디스크 I/O 가 늘어 **크래시 타이밍 자체가 달라진다.**
## 1-3. WAL 설정을 지금 재 둔다
**확인**
```bash
sudo 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')"
```
**실측** — [`08-wal-settings.txt`](../../evidence/a3-database-crash/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_commit` 은 `on`.**
**이 결과가 의미하는 것** — **전역 설정만 보면 「우리는 동기 커밋」이라고 믿게
된다.** 그런데 1-2 에서 본 대로 **Keycloak 이 자기 트랜잭션에만 `SET LOCAL`
뒤집는다.** DBA 가 서버 설정만 보고 판단하면 틀린다.
> **★ 이 값을 지금 재 두는 것이 이 절의 요점이다.** 원래 실험은 결과를 먼저
> 쓰고 「`wal_writer_delay` 기본값(200ms)과 맞는다」고 주장했는데, **그 시점에
> 이 값을 조회한 적이 없었다.** 나중에 재서 맞기는 했지만 **그때는 추정이었다.**
> 해설 문서 5절이 그 정정 기록이다.
>
> **가정한 값은 재기 전에 재 둔다.** 결과를 본 뒤에 재면 「맞춰 보는」 것이 된다.
---
# 2. 기준선 — 세션 테이블을 비우고 센다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_user_session"
```
**실측** — [`05-true-crash.txt`](../../evidence/a3-database-crash/05-true-crash.txt)
```
=== [정리] 세션 테이블 비우고 루프 잔여 확인 ===
DELETE 375
남은 세션: 0
```
**되돌리기** — 되돌릴 수 없다. 지운 세션은 돌아오지 않는다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from offline_user_session where offline_flag='0'"
```
**형태**
```
count
-------
0
```
**왜 비우나** — 크래시 뒤에 「DB 전체 세션 수」와 「내가 만든 세션 수」를 나란히
놓고 볼 것이다. 시작이 0 이어야 그 둘이 읽힌다.
> **캐시는 안 비워도 된다.** 이 실험의 판정은 **DB 행의 존재 여부**이고, 캐시는
> 판정에 안 들어간다. A-0 처럼 파드를 재시작할 필요가 없다.
**확인** — 파드가 정상인지도 본다
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
`keycloak-0` `keycloak-1` `postgres` 가 전부 `1/1 Running` 이고 `RESTARTS`
`0` 이어야 한다. **`RESTARTS` 값을 적어 둔다** — 3절에서 이 값이 오르는지가
주입 판정의 일부다.
---
# 3. 주입 — 세 번 시도한다. 앞의 둘은 실패한다
**이 절이 이 실험의 본체다.** 순서대로 따라가면 「죽이는 데 실패하는 두 가지
방법」을 직접 보게 된다. 건너뛰고 3-6 만 하면 **왜 그게 유일한 방법인지** 모른다.
## 3-1. 로그인 루프 — 스크립트 파일로 만든다
루프는 한 줄로 칠 물건이 아니다. **파일로 만든다.**
### 왜 파일인가
원래 실행은 이걸 `kubectl exec ... sh -c "..."` 한 줄에 욱여넣었고, **인용이
세 겹이 되어 두 번 깨졌다.**
**실측** — [`01-crash-injection.txt`](../../evidence/a3-database-crash/01-crash-injection.txt)
```
=== [1] 빠른 연속 로그인을 백그라운드로 시작 ===
루프 시작
6초 경과 — 지금까지 성공한 로그인: 0
...
클라이언트가 200 을 받은 로그인 수: 0
```
**0건.** 파드 안에서 `( ... ) &` 로 띄운 루프가 **`exec` 세션이 끝날 때 같이
죽었다.** 측정 자체가 없었던 것이다.
**편집기로 파일을 연다.**
```bash
vim /tmp/a3-login-loop.sh
```
```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 했다고 응답한 것」의 목록**이고, 그게 이 실험의 시험군이다.
**하기** — 파드 안으로 넣는다. `tar` 가 필요 없는 방법이다
```bash
sudo kubectl -n keycloak-lab exec -i a3-probe -- sh -c 'cat > /tmp/a3-login-loop.sh' \
< /tmp/a3-login-loop.sh
sudo kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/a3-login-loop.sh
```
**형태**
```
17 /tmp/a3-login-loop.sh
```
> `kubectl cp` 도 되지만 컨테이너에 `tar` 가 있어야 한다. `cat >` 로 밀어 넣는
> 쪽이 어디서나 통한다.
**하기****터미널 ①** 에서 **앞으로 두고** 돌린다. 이 터미널은 붙잡힌다
```bash
sudo kubectl -n keycloak-lab exec a3-probe -- sh /tmp/a3-login-loop.sh
```
**`&` 로 배경에 보내지 않는다.** 그게 원래 실행이 실패한 이유다. 터미널을
하나 통째로 이 루프에 쓴다. **미검증** — 원래 실행은 호스트에서 배경 `exec`
로 했다.
**확인****터미널 ②** 에서, 얼마나 쌓였는지 본다
```bash
sudo kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/sids
```
**실측** — [`06-backend-kill-crash.txt`](../../evidence/a3-database-crash/06-backend-kill-crash.txt)
```
=== 로그인 루프 시작 ===
8초 후: 112 건
```
**어디를 봐야 하는가****8초에 112건이면 초당 약 14건.** 이 속도를 적어 둔다.
4-4 에서 손실 건수를 시간으로 환산할 때 쓴다.
**0건이면 루프가 안 도는 것이다.** 터미널 ① 을 본다. 거기 에러가 있다.
## 3-2. 시도 ① — `--grace-period=0 --force`
**「강제 삭제」라는 이름이 붙어 있으니 크래시일 것 같다.** 확인해 본다.
**하기** — 터미널 ② 에서
```bash
date '+%H:%M:%S.%3N 종료'
sudo kubectl -n keycloak-lab delete pod -l app=postgres --grace-period=0 --force
date '+%H:%M:%S.%3N 반환'
```
**실측** — [`01-crash-injection.txt`](../../evidence/a3-database-crash/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
```
터미널 ① 의 루프가 에러를 쏟기 시작한다. 그대로 두거나 `Ctrl-C` 로 멈춘다.
## 3-3. 주입 검증 ① — **crash recovery 가 돌았는가**
**★ 여기가 이 실험 전체에서 가장 중요한 절이다.** 결과를 세기 전에 **주입 성공
신호**를 본다. 이 실험은 그 신호를 미리 정해 뒀다.
```
PostgreSQL 이 정상 종료했다 → pg_control 에 "깨끗하게 종료됨" 표시
→ 다음 기동에 아무 말 없이 뜬다
PostgreSQL 이 즉사했다 → 표시가 없다
→ "database system was not properly shut down"
→ "redo starts at ..." / "redo done at ..."
```
**확인** — DB 가 다시 뜰 때까지 기다렸다가 로그를 본다
```bash
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
sudo kubectl -n keycloak-lab logs deploy/postgres | grep -E 'not properly shut down|redo|ready to accept'
```
**실측** — [`02-design-check.txt`](../../evidence/a3-database-crash/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 down``redo` 도 없다.
**이 결과가 의미하는 것****crash recovery 가 돌지 않았다 = 깨끗하게 내려갔다.**
**하기** — 그런데도 손실을 세어 보면 이렇게 나온다
```bash
sudo kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/sids
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from offline_user_session where offline_flag='0'"
```
**실측** — [`04-comparison.txt`](../../evidence/a3-database-crash/04-comparison.txt)
```
=== [5] 전체 대조 — 몇 건이나 사라졌는가 ===
클라이언트 성공: 291 건
DB 에 존재: 291 건
★ 유실: 0 건
```
**0건.** 그런데 이건 **「안 잃었다」가 아니라 「죽인 적이 없는 것」이다.**
### 왜 안 죽었나 — 시그널 세 가지
| 신호 | PostgreSQL 의 반응 |
|---|---|
| **SIGTERM** | **fast shutdown** — 진행 중 트랜잭션을 롤백하고 **WAL 을 플러시**한 뒤 종료 |
| SIGINT | smart shutdown — 연결이 끊기길 기다린다 |
| **SIGKILL** | **즉사** — 플러시 없음. 다음 기동에 crash recovery |
`--force --grace-period=0`**API 오브젝트를 즉시 지운다.** 그것뿐이다.
컨테이너 런타임은 여전히 정상 종료 절차를 밟고, **PostgreSQL 은 SIGTERM 을
받고 얌전히 플러시했다.**
> **운영에 주는 것 — 장애 훈련이 훈련이 안 될 수 있다.**
> 「강제 삭제로 DB 를 죽여 봤는데 아무 문제 없었다」는 결론은 **아무것도 죽이지
> 않은 것**일 수 있다. 훈련에는 **주입 성공 신호**가 있어야 한다.
## 3-4. 시도 ② — 컨테이너 안에서 `kill -9 1`
**postmaster 는 컨테이너의 PID 1 이다.** 직접 SIGKILL 을 보내면 될 것 같다.
**하기**
```bash
date '+%H:%M:%S.%3N SIGKILL'
sudo kubectl -n keycloak-lab exec deploy/postgres -- kill -9 1
```
## 3-5. 주입 검증 ② — **아무 일도 일어나지 않았다**
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -l app=postgres
sudo kubectl -n keycloak-lab logs deploy/postgres | grep -E 'not properly shut down|redo|ready to accept' | tail -3
```
**실측** — [`05-true-crash.txt`](../../evidence/a3-database-crash/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
```
**어디를 봐야 하는가****두 가지를 같이 본다.**
1. **`RESTARTS` 가 안 올랐다.** 파드는 재시작하지 않았다
2. **로그의 마지막 줄 시각이 `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) 로 했다.
## 3-6. 시도 ③ — 백엔드 프로세스를 죽인다
PostgreSQL 은 **postmaster(부모) + 연결마다 백엔드(자식)** 구조다. 자식 하나가
비정상 종료하면 **postmaster 는 공유 메모리가 오염됐다고 보고 전체를
재초기화한다.** 그게 곧 crash recovery 다.
**확인** — 먼저 무엇을 죽일지 눈으로 본다
```bash
sudo 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초쯤 쌓이면:
**하기** — 터미널 ② 에서
```bash
date '+%H:%M:%S.%3N SIGKILL'
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
sh -c 'kill -9 $(pgrep -f "postgres: keycloak keycloak" | head -1)'
```
**실측** — [`06-backend-kill-crash.txt`](../../evidence/a3-database-crash/06-backend-kill-crash.txt)
```
=== 백엔드 프로세스에 SIGKILL → postmaster 가 재초기화한다 ===
시각: 12:04:22.063
최종 성공 로그인: 153 건
```
터미널 ① 의 루프를 `Ctrl-C` 로 멈춘다.
## 3-7. 주입 검증 ③ — 이번엔 걸렸다
**확인**
```bash
sudo 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'
```
**실측** — [`06-backend-kill-crash.txt`](../../evidence/a3-database-crash/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 starts at 0/23CAB68``redo done at 0/2529E40` | 재생된 WAL 구간 |
| `checkpoint complete` | 재생 결과를 디스크에 고정했다 |
| `ready to accept connections` | **시각이 새로 찍혔다** — 3-5 와 대조한다 |
**확인** — 파드는 재시작하지 않았다
```bash
sudo kubectl -n keycloak-lab get pods -l app=postgres -o custom-columns=\
NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount
```
**형태**
```
NAME RESTARTS
postgres-7b474b88c8-xxxxx 0
```
**이 결과가 의미하는 것** — 컨테이너의 PID 1 인 postmaster 는 **살아 있고
자식만 갈아치웠다.** 쿠버네티스 관점에서는 아무 일도 없었지만, **데이터
관점에서는 전원이 나간 것과 같다.**
### crash recovery 를 한 줄로
```
기동 시 pg_control 을 읽는다
└─ "깨끗하게 종료됨" 표시가 없다
└─ "database system was not properly shut down"
└─ 마지막 체크포인트부터 WAL 을 재생(redo)
└─ 디스크에 안 내려간 커밋은 복구할 수 없다 ← 손실
```
**WAL 에 없는 것은 재생할 수도 없다.** `redo starts``redo done` 사이가
살아 돌아온 구간이고, **그 뒤에 있던 것이 사라진 것**이다.
---
# 4. 결과 — 몇 건이 사라졌나
## 4-1. 클라이언트가 받은 sid 목록을 꺼낸다
**하기**
```bash
sudo kubectl -n keycloak-lab exec a3-probe -- cat /tmp/sids > /tmp/client-sids.txt
wc -l /tmp/client-sids.txt
```
**실측** — [`07-loss-result.txt`](../../evidence/a3-database-crash/07-loss-result.txt)
```
클라이언트가 200 과 토큰을 받은 로그인 : 153 건
```
**어디를 봐야 하는가** — 파일 한 줄에 sid 하나. 몇 줄인지 적어 둔다.
**확인** — 눈으로 한 번 본다
```bash
head -3 /tmp/client-sids.txt
```
**형태**
```
CQUfg9HLH29xvhiu6pVlfWOo
5gLP4fqmpZBbjhH_d-0TPMMr
hkcOv1QskUFmYveMLB6Hljra
```
빈 줄이 섞여 있으면 sid 추출이 실패한 것이다. 그대로 세면 유실 건수가 부풀려진다.
## 4-2. DB 에 남아 있는 sid 목록을 꺼낸다
**확인** — 먼저 총계를 본다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from offline_user_session where offline_flag='0'"
```
**실측**
```
DB 전체 온라인 세션 : 150 건
```
**하기** — 목록으로 뽑는다. `-tAc` 는 헤더·정렬 없이 값만 준다
```bash
sudo 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
```
> **`psql` 의 두 얼굴.** `-c` 는 표를 그려서 사람이 읽기 좋고, `-tAc` 는 값만
> 줘서 파이프에 넣기 좋다. **한 번은 `-c` 로 눈으로 보고**, 셀 때만 `-tAc` 를
> 쓴다.
## 4-3. 차집합 — 무엇이 사라졌나
`comm` 은 **정렬된 두 파일의 차집합**을 낸다. 정렬부터 한다.
**하기** **미검증**
```bash
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` 은 2·3 을 감추므로 **왼쪽 전용만 남는다.**
> **`LC_ALL=C` 를 빼면 안 된다.** `comm` 은 두 파일이 **같은 정렬 순서**임을
> 전제한다. 로케일이 다르면 대소문자·기호 순서가 달라져 **멀쩡한 sid 가
> 「없는 것」으로 잡힌다.** sid 는 대소문자와 `-` `_` 가 섞인 base64url 이라
> 정확히 이 문제에 걸린다.
**실측** — [`07-loss-result.txt`](../../evidence/a3-database-crash/07-loss-result.txt)
```
=== 크래시 전후 대조 ===
클라이언트가 200 과 토큰을 받은 로그인 : 153 건
그중 DB 에 실제로 존재 : 149 건
★ 유실 : 4 건
=== 유실된 sid 목록 ===
★ CQUfg9HLH29xvhiu6pVlfWOo ← 토큰은 발급됐는데 세션이 없다
★ 5gLP4fqmpZBbjhH_d-0TPMMr ← 토큰은 발급됐는데 세션이 없다
★ hkcOv1QskUFmYveMLB6Hljra ← 토큰은 발급됐는데 세션이 없다
★ p5XybeQIYmAs818gO4Vl_5ea ← 토큰은 발급됐는데 세션이 없다
```
**확인** — 건수만
```bash
comm -23 /tmp/a.txt /tmp/b.txt | wc -l
```
**이 결과가 의미하는 것** — **로그인이 성공했다고 응답받았는데 세션이 존재하지
않는다.** 153건 중 4건, **약 2.6%.**
**확인** — 대조군. 사라지지 **않은** 것도 하나 본다
```bash
tail -1 /tmp/client-sids.txt
```
그 sid 로 DB 를 뒤진다.
```bash
sudo 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='8do0Bw6tkVLDVxgxotE7GosH'"
```
**실측**
```
=== 그 토큰이 지금 실제로 쓰이는가 (마지막 sid 로 확인) ===
마지막 sid: 8do0Bw6tkVLDVxgxotE7GosH
user_session_id | created_on | last_session_refresh
--------------------------+------------+----------------------
8do0Bw6tkVLDVxgxotE7GosH | 1788490958 | 1788490958
(1 row)
```
**대부분은 멀쩡하다.** 그래서 **손실이 잘 안 보인다.**
## 4-4. 숫자를 어떻게 읽나
**★ 여기서 성급하게 결론을 붙이지 않는다.** 원래 문서가 그렇게 했다가 정정했다.
원래 문서는 *「초당 19건 … `wal_writer_delay` 기본값(200ms)과 맞는다」*고 썼는데,
**그 시점에 `wal_writer_delay` 를 조회한 적이 없었다.** 그리고 로그인 속도도
틀렸다.
**증거를 다시 읽는다.** 3-1 에서 본 값이다.
```
8초에 112건 ≈ 초당 14건
4건 ≈ 약 0.29초 분량
```
**19건이 아니라 14건이고, 0.2초가 아니라 약 0.29초다.**
| | |
|---|---|
| 측정한 손실 | 4건 ≈ **약 0.29초 분량** |
| `wal_writer_delay` (1-3 에서 잰 값) | **200 ms** |
| 관계 | **같은 자릿수이되 정확히 일치하지는 않는다** |
**「같은 자릿수」까지가 이 실험이 말할 수 있는 것이다.** `wal_writer_delay`
하나가 손실 창을 정하는 것도 아니다 — `wal_writer_flush_after`(128 × 8kB)와
체크포인트 타이밍이 함께 작용한다.
> **재현하면 당신의 숫자는 다르다.** 로그인 속도, 디스크, 죽인 순간이 전부
> 다르기 때문이다. **중요한 것은 「4」가 아니라 「0 이 아니다」이고, 그 크기가
> WAL 플러시 주기와 같은 자릿수라는 것이다.**
## 4-5. 사용자에게 어떻게 보이는가
```
로그인 성공 → access token + refresh token 을 받음
│ (크래시)
다음 요청 → access token 은 60초간 통한다
│ (서명만 보는 경로라면)
60초 후 refresh → "Session not active" → 다시 로그인
```
**즉시 드러나지 않는다.** access token 수명 동안은 정상으로 보이다가 갱신
시점에 끊긴다. **장애와 증상 사이에 최대 60초의 시차가 있다.**
> **운영적 함의 — 모니터링은 갱신 실패율을 봐야 한다.** 로그인 성공률만 보면
> 이 장애는 안 보인다. 로그인은 `200` 을 줬기 때문이다.
## 4-6. 이 손실이 「허용된」 이유
Keycloak 의 판단은 이렇게 읽힌다.
| | |
|---|---|
| 세션 쓰기는 **매우 잦다** | 로그인마다, refresh 마다 |
| 잃어도 **회복 가능하다** | 사용자가 다시 로그인하면 된다 |
| 동기 커밋의 비용은 **모든 요청에 붙는다** | 크래시는 드물다 |
**드문 사고의 비용을 상시 지연으로 지불하지 않겠다는 선택**이다. 합리적이지만,
**선택했다는 사실을 알고 있어야 한다.**
### 바꿀 수 있는가 — 못 바꾼다
```sql
-- 세션 트랜잭션까지 동기 커밋으로 강제하려면 (지연 대가를 치른다)
ALTER DATABASE keycloak SET synchronous_commit = on;
```
**`SET LOCAL` 이 우선하므로 이것으로는 못 막는다.** Keycloak 설정이나 소스
수준의 문제다.
> **RPO 0 이 필요하면 복제(streaming replication)로 푸는 것이 맞다.** 동기
> 스탠바이가 있으면 `synchronous_commit` 의 의미가 달라진다.
---
# 5. 복구 · 정리
## 5-1. 문장 로깅이 꺼져 있는지 확인한다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement" -c "show log_line_prefix"
```
`none` 이 아니면 1-2 의 reset 을 다시 친다.
## 5-2. 실험이 만든 세션을 정리한다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_user_session"
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
**★ 재시작을 빼면 안 된다.** DB 만 지우면 **캐시 엔트리가 남아** 캐시 합계와
DB 총계가 어긋난다. A-0 이 겪은 함정이고, 다음 실험의 기준선을 망친다.
## 5-3. 탐침 파드를 지운다
**하기**
```bash
sudo kubectl -n keycloak-lab delete pod a3-probe --ignore-not-found
```
`--rm` 이 없으므로 **자동으로 사라지지 않는다.** `sleep 7200` 이 끝나면
`Completed` 로 남는다.
## 5-4. DB 가 건강한지 본다
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -l app=postgres
sudo 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 파손」이 아니다.**
## 5-5. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| DB | `sudo 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) |
| 파드 | `sudo 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` |
| 탐침 파드 | `sudo 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` |
로컬 임시 파일도 치운다.
```bash
rm -f /tmp/client-sids.txt /tmp/db-sids.txt /tmp/a.txt /tmp/b.txt /tmp/a3-login-loop.sh
```
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 유실이 `0건` 이다 | **죽인 적이 없다.** 대개 `--force``kill -9 1` 을 썼다 | `not properly shut down` 이 로그에 있나 — 3-3·3-7 |
| `ready to accept connections` 가 있으니 크래시인 줄 알았다 | **아까 뜰 때 찍힌 줄이다** | **줄의 존재가 아니라 시각**을 본다 — 3-5 |
| `kill -9 1` 을 했는데 아무 일도 없다 | **컨테이너 안에서 PID 1 은 SIGKILL 을 무시한다** | 백엔드 프로세스를 죽인다 — 3-6 |
| 루프가 `0건` 을 모았다 | **파드 안에서 `&` 로 띄우면 `exec` 종료와 같이 죽는다** | 터미널 하나를 루프에 통째로 쓴다 — 3-1 |
| 로그가 폭주하고 크래시 타이밍이 이상하다 | **`log_statement='all'` 을 켠 채로 루프를 돌렸다** | `show log_statement``none` 인지 — 1-2 |
| 멀쩡한 sid 가 「없음」으로 잡힌다 | **`comm` 두 파일의 정렬 순서가 다르다** | `LC_ALL=C sort` 를 양쪽에 — 4-3 |
| 유실 건수가 부풀려진다 | `/tmp/sids` 에 빈 줄이 섞였다 | `head -3` 으로 눈으로 본다 — 4-1 |
| `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` — 5-3 |
| `pgrep` 이 아무것도 못 찾는다 | Keycloak 이 아직 연결을 안 만들었다 | `ps -ef` 로 먼저 본다 — 3-6 |
| 손실 건수를 시간으로 환산했더니 문서와 다르다 | **원래 문서가 속도를 잘못 썼다가 정정했다** | 초당 14건이 실측이다 — 4-4 |
---
# 왜 이 가이드는 판정 기준을 먼저 적나
이 실험이 남긴 가장 큰 교훈은 손실 건수가 아니다.
> **주입 성공 신호를 미리 정한다.**
세 번의 시도 중 **두 번은 「유실 0건」이라는 깨끗한 결과를 냈다.** 신호를
정해 두지 않았다면 첫 번째 결과를 그대로 발표했을 것이고, 결론은
**「Keycloak 은 DB 크래시에도 데이터를 잃지 않는다」**가 됐을 것이다.
정반대의 결론이다.
| 실험 | 주입 성공 신호 |
|---|---|
| A-1 7800 차단 | conntrack 에 `SYN_SENT [UNREPLIED]`, `coord = t` 가 둘 |
| A-2 DB 정지 | Keycloak 로그의 `Connection refused` + agroal |
| **A-3 DB 크래시** | **`database system was not properly shut down` + `redo starts`** |
**신호가 없으면 결과를 해석하지 않는다.** 그게 이 절의 전부다.
---
# 다음
| 실험 | A-3 가 남긴 것 |
|---|---|
| D-1 백업·복구 | **진짜 RPO = 백업 주기 + 이 손실.** 둘을 더해야 한다 |
| B-6 Redis 영속화 | `appendfsync everysec`**같은 모양의 트레이드오프** |
| A-4 노드 상실 | `postgres``kc-lab-2` 에 있다 — **노드가 죽으면 이것도 함께 일어난다** |
| 모니터링 | **로그인 성공률이 아니라 갱신 실패율을 본다** — 4-5 |
| 전부 | **주입 성공 신호를 미리 정한다.** 여기서는 crash recovery 로그 |