Files
keycloak-pattern/docs/experiment-a2-database-loss.md
T
DongHyeonkaandClaude Opus 5 e0d27d47ce docs: correct the places where documents contradicted their own evidence
An independent audit found ten documents printing values their evidence files do not contain. C-1 printed a session count of 0 where the evidence says 4, C-2 printed a success readback for a command that exited 1, and A-1 credited the conntrack flush with a split that the timestamps attribute to a pod restart four seconds earlier.

Also measured wal_writer_delay, which A-3 had asserted as matching without ever querying it, relabelled the A-6 control that moved 41 percent, noted A-8's nine-sample resolution, corrected D-1's RTO to the 41 seconds its own timeline shows, and added a correction banner to D-2. Every experiment document now links its evidence files with their real collection times, and the duplicate screenshots are documented as duplicates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:35:49 +09:00

342 lines
12 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-2 — PostgreSQL 이 죽으면 어떻게 되는가
브랜치 `feature/keycloak-a2-database-loss` ·
증거 [`docs/evidence/a2-database-loss/`](evidence/a2-database-loss/) ·
2026-09-04 11:5611:58 KST · Keycloak 26.7.0
선행: [`A-0`](experiment-00-session-replication.md) ·
[`A-1`](experiment-a1-jgroups-transport-block.md)
---
## 구조
![A-2 구조 — DB 정지 시 살아남는 노드가 없다](diagrams/a2-database-loss.svg)
> 다이어그램 규약은 [`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 인데
```
![up 은 움직이지 않았다](evidence/a2-database-loss/a2-up-stayed-1-during-outage.png)
**전 구간 평평하다.** (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'
# 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 가 지표로 안 남는다** — 보완 필요 |