The audit found ~80 placeholders, and the damaging ones were where the measuring apparatus itself was prose rather than a command: a6 "( curl ... ) & 를 20개 띄우고 wait" — the 22.2s headline came from this a3 "<로그인 반복, sid 를 /tmp/sids 에>" — the whole RPO measurement a3 "<sid 목록>" — the control it is compared against a5 "<수신 파드IP>" — the injection a8 writes /tmp/tok, reads /tmp/rt — self-inconsistent, sent an empty token b3 $KC / $RT / $NEW never assigned c2 bare kcadm.sh with no kubectl exec a1 conntrack tuples written by hand, though the direction flips per restart Each is now a shell-expandable form: pod IPs from jsonpath, the admin password from the secret, ids from kcadm --format csv, conntrack tuples derived from "conntrack -L" with awk rather than transcribed. Then the rewritten commands were executed against the live cluster, and one of them failed — the 20-way load generator, written as "kubectl run --rm -i", lost its output stream twice in a row. That is a trap this series already hit once, and the rewrite reintroduced it. A-6 now uses a resident probe pod that collects into a file and is cat-ed once; verified 20/20 lines. Evidence: docs/evidence/followup/05-command-reproducibility.txt Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
344 lines
12 KiB
Markdown
344 lines
12 KiB
Markdown
# A-2 — PostgreSQL 이 죽으면 어떻게 되는가
|
||
|
||
브랜치 `feature/keycloak-a2-database-loss` ·
|
||
증거 [`docs/evidence/a2-database-loss/`](evidence/a2-database-loss/) ·
|
||
2026-09-04 11:56–11:58 KST · Keycloak 26.7.0
|
||
|
||
선행: [`A-0`](experiment-00-session-replication.md) ·
|
||
[`A-1`](experiment-a1-jgroups-transport-block.md)
|
||
|
||
---
|
||
|
||
## 구조
|
||
|
||

|
||
|
||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||
|
||
---
|
||
|
||
## 0. 결론부터
|
||
|
||
| 예측 | 결과 |
|
||
|---|---|
|
||
| 즉시 전면 장애 | **맞다.** 외부 진입점 **503**, 양쪽 노드 NotReady |
|
||
| 캐시에 있어도 못 쓴다 | **맞다.** 캐시를 가진 노드도 `500` |
|
||
| — | **`up = 1` 인 채로 전면 장애가 났다** (관측의 함정) |
|
||
| — | **DB 복귀 15초 만에 재시작 없이 자동 회복** |
|
||
|
||
**A-1 과 정반대다.** A-1 은 한쪽만 빠지고 서비스가 계속됐지만,
|
||
A-2 는 **살아남는 노드가 없다.**
|
||
|
||
---
|
||
|
||
## 1. 설계 — 네 경로를 구분해서 본다
|
||
|
||
A-1 에서 **"룩어사이드 캐시는 읽을 때 DB 와 대조하지 않는다"** 를 확인했다.
|
||
그렇다면 캐시를 가진 노드는 DB 없이도 버틸지 모른다. 그 가설을 가른다.
|
||
|
||
| # | 경로 | 무엇을 보는가 |
|
||
|---|---|---|
|
||
| ① | **캐시를 가진 노드**에서 refresh | 캐시가 DB 를 대신할 수 있는가 |
|
||
| ② | 캐시가 없는 노드에서 refresh | 완전한 DB 의존 |
|
||
| ③ | 새 로그인 | 쓰기 경로 |
|
||
| ④ | 이미 발급된 토큰으로 조회 | 서명만으로 되는 경로 |
|
||
|
||
**access token 수명이 60초**이므로, 토큰 발급 → DB 정지 → 시험을 그 안에
|
||
끝내야 한다.
|
||
|
||
### 계측 도구를 바꿨다
|
||
|
||
A-1 에서 임시 curl 파드가 형편없는 계측 도구임을 확인했다. 여기서는
|
||
**상주 탐침 파드**를 하나 띄우고 `exec` 로 단계를 이어간다. 토큰을 파드 안
|
||
파일에 남겨 **DB 정지 전후로 같은 토큰**을 쓸 수 있다.
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab run a2-probe --image=curlimages/curl:8.11.1 \
|
||
--restart=Never --command -- sleep 7200
|
||
kubectl -n keycloak-lab wait --for=condition=Ready pod/a2-probe --timeout=120s
|
||
```
|
||
|
||
---
|
||
|
||
## 2. 기준선
|
||
|
||
```
|
||
keycloak-0 ready=true 10.42.1.67 kc-lab-2
|
||
keycloak-1 ready=true 10.42.0.35 kc-lab-1
|
||
postgres ready=true 10.42.1.24 kc-lab-2
|
||
|
||
cluster_size keycloak-0 = 2
|
||
cluster_size keycloak-1 = 2
|
||
```
|
||
|
||
세션을 양쪽에 하나씩 만들고, A-0 대로 **각자 자기 노드에만 캐시**되는 것을
|
||
확인했다.
|
||
|
||
```
|
||
keycloak-0 에서 로그인 sid=EAXV5HcG2J1BZ3vnwONf64AQ
|
||
keycloak-1 에서 로그인 sid=McyTj5lj3n_JqApCXeuAHExc
|
||
→ 캐시 keycloak-0 = 1 건 / keycloak-1 = 0 건 (스크레이프 지연)
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 주입
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
||
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
|
||
```
|
||
|
||
```
|
||
정지 시각: 11:56:04
|
||
삭제 완료: 11:56:04 ← 즉시
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 결과 — 네 경로
|
||
|
||
```
|
||
① 캐시를 가진 노드(keycloak-0)에서 refresh HTTP 500
|
||
② 캐시가 없는 노드(keycloak-1)에서 refresh HTTP 500
|
||
③ 새 로그인 HTTP 500
|
||
④ 관리 API (세션 조회 필요) HTTP 500 ← 5절의 재측정값
|
||
|
||
--- 오류 본문 ---
|
||
{"error":"unknown_error","error_description":"For more on this error consult the server log."}
|
||
```
|
||
|
||
> **④ 의 첫 측정은 오염됐다** —
|
||
> [`03-four-paths.txt`](evidence/a2-database-loss/03-four-paths.txt) 에는
|
||
> `HTTP 000000{"error":"HTTP 401 Unauthorized"}401` 이 남아 있다.
|
||
> `curl -w %{http_code}` 출력에 본문이 섞인 것이고, 재시도가 `000` 을 세 번
|
||
> 찍은 뒤 `401` 이 왔다. **위 표의 `500` 은 5절에서 다시 잰 값**이며,
|
||
> 첫 측정을 그대로 쓰지 않았다.
|
||
|
||
### ① 이 500 인 것이 중요하다
|
||
|
||
**캐시에 세션을 들고 있어도 refresh 는 실패한다.**
|
||
|
||
A-1 에서는 로그아웃된 세션을 캐시로 `200` 을 줬다. 왜 여기서는 안 되는가.
|
||
|
||
```
|
||
refresh 처리
|
||
├── 세션이 존재하는가 → 캐시로 답할 수 있다
|
||
└── LAST_SESSION_REFRESH 갱신 → DB 쓰기가 필요하다 ← 여기서 죽는다
|
||
```
|
||
|
||
A-0 에서 잡은 SQL 그대로다.
|
||
|
||
```sql
|
||
update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1, VERSION=$2 where ...
|
||
```
|
||
|
||
> **캐시는 읽기를 대신할 뿐, 쓰기를 대신하지 못한다.**
|
||
> refresh 는 이름과 달리 **쓰기 연산**이다.
|
||
|
||
### 로그가 말하는 원인
|
||
|
||
```
|
||
Caused by: java.net.ConnectException: Connection refused
|
||
at org.postgresql.core.v3.ConnectionFactoryImpl.tryConnect
|
||
at io.agroal.pool.ConnectionPool$CreateConnectionTask.call
|
||
```
|
||
|
||
`agroal` 은 Quarkus 의 커넥션 풀이다. 풀이 새 커넥션을 만들지 못한다.
|
||
|
||
---
|
||
|
||
## 5. 살아남은 것 — 상태가 필요 없는 경로
|
||
|
||
```
|
||
JWKS 엔드포인트(realm 공개키) HTTP 200
|
||
realm 메타데이터(.well-known) HTTP 200
|
||
관리 API (세션 조회 필요) HTTP 500
|
||
```
|
||
|
||
**realm 공개키와 메타데이터는 메모리에 있으므로 DB 없이도 응답한다.**
|
||
|
||
이론적으로는 **이미 JWKS 를 캐시한 리소스 서버는 토큰 검증을 계속할 수 있다**는
|
||
뜻이다. 다만 이 실험대에는 독립 리소스 서버가 아직 없으므로 **여기까지가
|
||
말할 수 있는 범위**다 — B층에서 확인한다.
|
||
|
||
> **그런데 정문으로는 이것도 못 쓴다.** 아래 6절 때문이다.
|
||
|
||
---
|
||
|
||
## 6. 전면 장애 — 살아남는 노드가 없다
|
||
|
||
```
|
||
=== 파드 Ready ===
|
||
keycloak-0 false restarts=0
|
||
keycloak-1 false restarts=0
|
||
|
||
=== Service 엔드포인트 ===
|
||
ready : [] ← 비었다
|
||
notReady: [10.42.0.35 10.42.1.67]
|
||
|
||
=== 외부 진입점 ===
|
||
https://auth.hyeonworks.com/realms/master HTTP 503
|
||
```
|
||
|
||
```json
|
||
{ "status": "DOWN",
|
||
"checks": [
|
||
{ "name": "Keycloak cluster health check", "status": "UP" },
|
||
{ "name": "Keycloak database connections async health check", "status": "DOWN" },
|
||
{ "name": "Keycloak Initialized", "status": "UP" } ] }
|
||
```
|
||
|
||
**`cluster health` 는 UP 인데 `database connections` 가 DOWN 이라 전체가 DOWN 이다.**
|
||
헬스체크는 **모든 항목이 UP 이어야 UP** 이다.
|
||
|
||
### A-1 과의 대비가 이 실험의 핵심이다
|
||
|
||
| | A-1 (7800 차단) | **A-2 (DB 정지)** |
|
||
|---|---|---|
|
||
| Ready 인 파드 | keycloak-1 **1개 생존** | **0개** |
|
||
| Service `ready` | `[10.42.0.35]` | **`[]`** |
|
||
| 외부 응답 | **200** | **503** |
|
||
| 성격 | 용량 저하 | **전면 장애** |
|
||
|
||
**노드를 몇 대로 늘려도 DB 가 죽으면 전부 같이 죽는다.**
|
||
Keycloak 의 대수는 DB 장애에 아무 도움이 되지 않는다.
|
||
|
||
> 원래 질문 *"Redis 또는 DB가 뒤질 경우 어떻게 복구를 해야 되는지"* 에 대한
|
||
> 첫 번째 답 — **복구 이전에, DB 이중화가 Keycloak 대수보다 우선한다.**
|
||
|
||
---
|
||
|
||
## 7. 관측의 함정 — `up = 1` 인 채로 전면 장애
|
||
|
||
```
|
||
up{pod=keycloak-1} = 1
|
||
up{pod=keycloak-0} = 1 ← 서비스는 503 인데
|
||
```
|
||
|
||

|
||
|
||
**전 구간 평평하다.** (11:44 의 짧은 골은 A-1 에서 파드를 교체한 자국이다.)
|
||
|
||
`up` 은 **Prometheus 가 `/metrics` 를 긁는 데 성공했는가**만 말한다.
|
||
프로세스는 멀쩡히 살아 메트릭을 내놓고 있었다. **기능은 전멸했는데.**
|
||
|
||
| 지표 | 이 장애에서 |
|
||
|---|---|
|
||
| `up` | **1 — 아무것도 알려주지 않는다** |
|
||
| 파드 `Ready` | **false — 여기서 드러난다** |
|
||
| 외부 HTTP 코드 | **503 — 사용자가 겪는 것** |
|
||
|
||
> **A-0 에서 나는 `up` 을 "가장 중요한 합성 지표"라고 썼다.**
|
||
> 절반만 맞다. `up` 은 **대상이 사라진 것**을 잡지만 **대상이 살아서 못 쓰는 것**은
|
||
> 못 잡는다. 후자가 운영에서 훨씬 흔하다.
|
||
>
|
||
> **알림은 `up` 이 아니라 readiness 와 외부 응답 코드에 걸어야 한다.**
|
||
|
||
이 실험대에는 아직 `kube-state-metrics` 가 없어 파드 readiness 가 지표로
|
||
남지 않는다. **관측 스택에 빠진 것을 이 실험이 찾아냈다** — 보완 항목이다.
|
||
|
||
---
|
||
|
||
## 8. 복구 — 자동이었다
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||
```
|
||
|
||
```
|
||
재기동 시각: 11:57:09
|
||
+15초 keycloak-0 true keycloak-1 true | 외부 HTTP 200
|
||
→ 서비스 복귀
|
||
|
||
재시작 횟수: keycloak-0 = 0, keycloak-1 = 0
|
||
정지 전 세션: online 세션 5 건 살아남음
|
||
```
|
||
|
||
| | |
|
||
|---|---|
|
||
| 회복 시간 | **약 15초** (DB Ready 이후) |
|
||
| 사람 개입 | **없음** |
|
||
| Keycloak 재시작 | **불필요** — `restarts=0` |
|
||
| 세션 | **살아남음** — DB 에 있으므로 |
|
||
|
||
**커넥션 풀이 스스로 재연결하고 readiness 가 다시 UP 이 되면서 Service 에
|
||
복귀했다.** `readiness` 를 쓴 설계의 이득이 여기서 나온다 — `liveness` 였다면
|
||
파드가 재시작되어 캐시까지 날아갔을 것이다.
|
||
|
||
### 개념 — readiness 와 liveness 를 가르는 기준
|
||
|
||
| | 실패하면 | 언제 쓰나 |
|
||
|---|---|---|
|
||
| **liveness** | **재시작** | 재시작하면 나아지는 문제 (교착, 메모리 누수) |
|
||
| **readiness** | **트래픽에서 격리** | 재시작해도 안 나아지는 문제 (**의존 대상이 죽음**) |
|
||
|
||
**DB 장애에 liveness 를 걸면 재앙이다.** 모든 파드가 무한 재시작하고,
|
||
DB 가 돌아와도 CrashLoopBackOff 의 백오프 때문에 회복이 늦어진다.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 증거 파일
|
||
|
||
**증거 수집 시각: 2026-09-04 11:53 – 11:56 KST** (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)
|
||
|
||
| 파일 | 종류 |
|
||
|---|---|
|
||
| [`01-baseline.txt`](evidence/a2-database-loss/01-baseline.txt) | 터미널 원문 |
|
||
| [`02-setup-sessions.txt`](evidence/a2-database-loss/02-setup-sessions.txt) | 터미널 원문 |
|
||
| [`03-four-paths.txt`](evidence/a2-database-loss/03-four-paths.txt) | 터미널 원문 |
|
||
| [`04-health-and-service.txt`](evidence/a2-database-loss/04-health-and-service.txt) | 터미널 원문 |
|
||
| [`05-recovery.txt`](evidence/a2-database-loss/05-recovery.txt) | 터미널 원문 |
|
||
| [`a2-up-stayed-1-during-outage.png`](evidence/a2-database-loss/a2-up-stayed-1-during-outage.png) | 스크린샷 |
|
||
|
||
파일별 상세는 [`evidence/a2-database-loss/README.md`](evidence/a2-database-loss/README.md).
|
||
|
||
## 9. 재현 절차 (명령어)
|
||
|
||
```bash
|
||
# 0. 상주 탐침 (임시 파드는 계측에 부적합 — A-1 참조)
|
||
kubectl -n keycloak-lab run a2-probe --image=curlimages/curl:8.11.1 \
|
||
--restart=Never --command -- sleep 7200
|
||
kubectl -n keycloak-lab wait --for=condition=Ready pod/a2-probe --timeout=120s
|
||
|
||
# 1. 토큰 발급 (access 60초 안에 시험을 끝내야 한다)
|
||
kubectl -n keycloak-lab exec a2-probe -- sh -c \
|
||
'curl -s -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"' > /tmp/tok.json'
|
||
# PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||
# -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)
|
||
|
||
# 2. DB 정지
|
||
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
||
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
|
||
|
||
# 3. 네 경로
|
||
kubectl -n keycloak-lab exec a2-probe -- curl -s -o /dev/null -w '%{http_code}\n' ...
|
||
|
||
# 4. 영향 범위
|
||
kubectl -n keycloak-lab get endpoints keycloak \
|
||
-o jsonpath='{.subsets[*].addresses[*].ip}' # 비어 있으면 전면 장애
|
||
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||
|
||
# 5. up 이 거짓말하는 것을 확인
|
||
curl -s "http://localhost:19090/api/v1/query?query=up%7Bjob=%22keycloak%22%7D"
|
||
|
||
# 6. 복구
|
||
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||
```
|
||
|
||
---
|
||
|
||
## 10. 다음 실험에 남기는 것
|
||
|
||
| 실험 | 이 실험이 준 것 |
|
||
|---|---|
|
||
| **A-3** DB 강제 종료 | 정상 정지는 데이터를 안 잃었다. **강제 종료는?** (`synchronous_commit OFF`) |
|
||
| **A-4** 노드 상실 | postgres 가 kc-lab-2 에 있으므로 그 노드를 죽이면 **A-2 가 함께 일어난다** |
|
||
| **D-1** 백업·복구 | 여기서는 DB 가 되살아났다. **데이터가 사라졌다면?** |
|
||
| 관측 스택 | **`kube-state-metrics` 가 없어 파드 readiness 가 지표로 안 남는다** — 보완 필요 |
|