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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8062cc9a19
commit
6f6ab86345
@@ -0,0 +1,993 @@
|
||||
# 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:58–12: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 로그 |
|
||||
Reference in New Issue
Block a user