docs: A-2 — losing the database takes every node down while up stays 1

Both pods go NotReady, the Service endpoint list empties and the front door returns 503, so adding Keycloak replicas buys nothing against database loss. The node holding the session in cache fails too, because a refresh writes LAST_SESSION_REFRESH. Recovery was automatic in about fifteen seconds with no restart, which is what readiness rather than liveness buys.

The observability finding matters as much: up stayed at 1 through a total outage, so alerting on it would have caught nothing. kube-state-metrics is missing and pod readiness is therefore not recorded as a metric.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-04 11:57:55 +09:00
co-authored by Claude Opus 5
parent 2a98ef1090
commit 4177fb6a48
11 changed files with 598 additions and 0 deletions
@@ -0,0 +1,14 @@
=== A-2 기준선 — 클러스터가 정상으로 돌아왔는가 ===
keycloak-0 true 10.42.1.67 kc-lab-2
keycloak-1 true 10.42.0.35 kc-lab-1
postgres-7b474b88c8-sn9ff true 10.42.1.24 kc-lab-2
cluster_size keycloak-1 = 2
cluster_size keycloak-0 = 2
=== 노드별 세션 캐시 (실험 설계에 필요) ===
keycloak-1 kc-lab-1 = 0 건
keycloak-0 kc-lab-2 = 0 건
=== DB 온라인 세션 ===
2
@@ -0,0 +1,10 @@
pod/a2-probe condition met
keycloak-0=10.42.1.67 keycloak-1=10.42.0.35
=== [준비] 양쪽 노드에 세션을 하나씩 만든다 ===
keycloak-0 에서 로그인 sid=EAXV5HcG2J1BZ3vnwONf64AQ 토큰길이=613
keycloak-1 에서 로그인 sid=McyTj5lj3n_JqApCXeuAHExc 토큰길이=613
=== [확인] 세션이 각자 노드에만 캐시되었는가 ===
keycloak-1 = 0 건
keycloak-0 = 1 건
@@ -0,0 +1,16 @@
=== [1] 토큰을 새로 발급 (access 수명 60초) ===
발급 완료 sid=RKXQGAgkuLtouFMVPTFmp_0_
=== [2] PostgreSQL 정지 ===
정지 시각: 11:56:04
deployment.apps/postgres scaled
pod/postgres-7b474b88c8-sn9ff condition met
삭제 완료: 11:56:04
=== [3] 네 경로를 즉시 시험 ===
④ 이미 발급된 access token 으로 관리 API HTTP 000000{"error":"HTTP 401 Unauthorized"}401
① 캐시를 가진 노드(keycloak-0)에서 refresh HTTP 500
② 캐시가 없는 노드(keycloak-1)에서 refresh HTTP 500
③ 새 로그인 HTTP 500
--- 오류 본문 (새 로그인) ---
{"error":"unknown_error","error_description":"For more on this error consult the server log."}
@@ -0,0 +1,29 @@
=== 파드 Ready 상태 — DB 가 없으면 어떻게 되는가 ===
keycloak-0 false 0
keycloak-1 false 0
=== Service 엔드포인트 ===
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
notReady: [10.42.0.35 10.42.1.67]
=== health/ready 상세 ===
전체: DOWN
Graceful Shutdown UP
Keycloak cluster health check UP
Keycloak database connections async health check DOWN
Keycloak Initialized UP
=== ④ 다시 — 서명 검증만 필요한 경로는 살아 있는가 ===
JWKS 엔드포인트(realm 공개키) HTTP 200
realm 메타데이터(.well-known) HTTP 200
관리 API(세션 조회 필요) HTTP 500
=== 외부 진입점 ===
https://auth.hyeonworks.com/realms/master HTTP 503
=== Keycloak 로그 — 실제 오류 ===
at io.agroal.pool.ConnectionPool$CreateConnectionTask.call(ConnectionPool.java:664)
at io.agroal.pool.ConnectionPool$CreateConnectionTask.call(ConnectionPool.java:645)
Caused by: java.net.ConnectException: Connection refused
at org.postgresql.core.v3.ConnectionFactoryImpl.tryConnect(ConnectionFactoryImpl.java:219)
at org.postgresql.core.v3.ConnectionFactoryImpl.openConnectionImpl(ConnectionFactoryImpl.java:365)
@@ -0,0 +1,21 @@
=== ★ up 지표는 무엇을 말하는가 (프로세스는 살아 있다) ===
up{pod=keycloak-1} = 1 ← 1 인데 서비스는 503 이다
up{pod=keycloak-0} = 1 ← 1 인데 서비스는 503 이다
=== 복구 — PostgreSQL 재기동 ===
재기동 시각: 11:57:09
deployment.apps/postgres scaled
Waiting for deployment "postgres" rollout to finish: 0 out of 1 new replicas have been updated...
Waiting for deployment "postgres" rollout to finish: 0 of 1 updated replicas are available...
deployment "postgres" successfully rolled out
=== Keycloak 이 스스로 회복하는가 (재시작 없이) ===
+15초 keycloak-0 true keycloak-1 true | 외부 HTTP 200
→ 서비스 복귀
=== 재시작 횟수 — 파드가 죽었다 살아난 것인가, 그대로 회복한 것인가 ===
keycloak-0 0
keycloak-1 0
=== 정지 전 세션이 살아남았는가 ===
online 세션 5
+19
View File
@@ -0,0 +1,19 @@
# A-2 — PostgreSQL 정지 증거
2026-09-04 11:5611:58 KST · Keycloak 26.7.0
해설: [`docs/experiment-a2-database-loss.md`](../../experiment-a2-database-loss.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-baseline.txt` | 정지 전 — 양쪽 Ready, `cluster_size=2` |
| `02-setup-sessions.txt` | 양쪽 노드에 세션 하나씩. 캐시는 각자 노드에만 |
| `03-four-paths.txt` | **네 경로 전부 `500`.** 캐시를 가진 노드도 실패 — refresh 는 쓰기다 |
| `04-health-and-service.txt` | **전면 장애 증거** — Ready 파드 0개, `ready 주소=[]`, 외부 **503**, `database connections: DOWN`. JWKS·.well-known 은 `200` |
| `05-recovery.txt` | **`up=1` 인 채로 503.** DB 복귀 15초 후 재시작 0회로 자동 회복, 세션 5건 생존 |
| `a2-up-stayed-1-during-outage.png` | Grafana — `up{job="keycloak"}` 이 전면 장애 내내 **1에 평평** |
## 핵심 세 줄
1. **DB 는 단일 장애점이다.** Keycloak 을 몇 대로 늘려도 같이 죽는다 — Ready 파드 0개, 외부 503.
2. **캐시는 읽기를 대신할 뿐 쓰기를 못 한다.** refresh 는 `UPDATE LAST_SESSION_REFRESH` 를 하므로 캐시가 있어도 실패한다.
3. **`up` 은 이 장애를 못 잡는다.** 알림은 readiness 와 외부 응답 코드에 걸어야 한다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+308
View File
@@ -0,0 +1,308 @@
# 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)
---
## 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
--- 오류 본문 ---
{"error":"unknown_error","error_description":"For more on this error consult the server log."}
```
### ① 이 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 의 백오프 때문에 회복이 늦어진다.
---
## 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 가 지표로 안 남는다** — 보완 필요 |