Files
keycloak-pattern/docs/guides/experiments/a2-database-loss.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

957 lines
40 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 을 내리고 살아남는 노드가 있는지 직접 본다
해설 문서: [`docs/experiment-a2-database-loss.md`](../../experiment-a2-database-loss.md) ·
증거 원문: [`docs/evidence/a2-database-loss/`](../../evidence/a2-database-loss/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 캐시에 세션을 가진 노드도 refresh 가 `500` 인 것 | 상주 탐침 파드 |
| JWKS 와 `.well-known``200` 으로 살아 있는 것 | 같은 파드 |
| Ready 파드가 **0개**, `ready` 주소가 **빈 목록**인 것 | `endpointslice` |
| 정문이 `503` 을 주는 것 | 밖에서 `curl` |
| `database connections` 만 DOWN 인 헬스 본문 | `health/ready` |
| **`up = 1` 인 채로 전면 장애가 나 있는 것** | Prometheus |
| 15초 만에 **재시작 0회**로 스스로 돌아오는 것 | `get pods` |
## 전제
- [`A-0`](a0-session-replication.md) 을 먼저 한다. 「세션은 DB 가 공유한다」를
손으로 확인해 두지 않으면 이 실험의 `500` 을 해석할 수 없다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- 네임스페이스는 `keycloak-lab`, Prometheus 는 `observability` 다.
- 터미널 **두 개**를 열어 두면 편하다. 하나는 탐침 파드용, 하나는 관찰용.
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
## 주의 — 이건 전면 장애를 만드는 실험이다
**정문(`https://auth.hyeonworks.com`)이 실제로 `503` 이 된다.** 이 실험대를 쓰는
다른 작업이 있으면 멈춘다. 정지 구간은 **1분 남짓**으로 짧게 잡는다. 되돌리는
명령은 하나뿐이고 [5. 복구](#5-복구) 에 있다. 중간에 그만두려면 그것만 치면 된다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 11:5311:58 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
IP·파드 이름·sid 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를 쓰지
않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행 기록의
실제 값이다.
---
# 0. 왜 이 실험을 하는가
[A-1](a1-jgroups-transport-block.md) 에서 **「룩어사이드 캐시는 읽을 때 DB 와
대조하지 않는다」**를 확인했다. 로그아웃되어 DB 행이 사라진 세션에 대해서도
캐시를 가진 노드가 `200` 을 줬다.
**그렇다면 캐시를 가진 노드는 DB 없이도 버틸지 모른다.** 그 가설을 가른다.
| | 예측 |
|---|---|
| 캐시가 DB 를 대신한다면 | 캐시를 가진 노드는 **살아남는다** — 부분 장애 |
| 대신하지 못한다면 | **전면 장애** |
그리고 A-1 과의 대비가 이 실험의 진짜 값이다.
```
A-1 7800 차단 → 한쪽만 빠지고 서비스는 계속됐다 (용량 저하)
A-2 DB 정지 → ? (여기서 판정)
```
**네 경로를 구분해서 본다.** 하나만 재면 무엇 때문에 죽었는지 모른다.
| # | 경로 | 무엇을 보는가 |
|---|---|---|
| ① | **캐시를 가진 노드**에서 refresh | 캐시가 DB 를 대신할 수 있는가 |
| ② | 캐시가 없는 노드에서 refresh | 완전한 DB 의존 |
| ③ | 새 로그인 | 쓰기 경로 |
| ④ | 이미 발급된 토큰으로 관리 API 조회 | 서명만으로 되는 경로가 있는가 |
---
# 1. 기준선 — DB 를 내리기 전에
**시험군만 재는 측정은 측정이 아니다.** 정지 후에 볼 것을 정지 전에 **똑같은
명령으로** 먼저 봐 둔다.
넓은 것부터 좁혀 간다.
```
파드 → 클러스터 크기 → 탐침 파드 → 양쪽에 세션 하나씩 → 노드별 캐시 → 대조군 시험
```
## 1-1. 파드와 노드
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**실측** — [`01-baseline.txt`](../../evidence/a2-database-loss/01-baseline.txt)
```
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
```
**어디를 봐야 하는가**
- `READY` 가 셋 다 `1/1`, `RESTARTS``0`
- **`postgres` 가 어느 노드에 있는가.** 원래 실행에서는 `kc-lab-2`, 즉
`keycloak-0` 과 **같은 노드**다
- IP 세 개를 적어 둔다
**이 결과가 의미하는 것**`postgres``keycloak-0` 이 같은 노드에 있다는
사실은 이 실험에서는 상관없지만, **A-4(노드 상실)에서는 결정적이다.**
그 노드를 죽이면 A-2 가 함께 일어난다.
IP 는 변수로 잡아 둔다.
```bash
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
**실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt)
```
keycloak-0=10.42.1.67 keycloak-1=10.42.0.35
```
## 1-2. 클러스터가 정상인가
**확인**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
한 줄짜리 JSON 이 통째로 나온다. **처음 한 번은 그대로 본다.** 어떤 라벨이
붙어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
읽기 좋게 자른다. **미검증**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
```
**실측** — [`01-baseline.txt`](../../evidence/a2-database-loss/01-baseline.txt)
```
cluster_size keycloak-1 = 2
cluster_size keycloak-0 = 2
```
**어디를 봐야 하는가** — 두 줄이고 값이 둘 다 `2`.
**이 결과가 의미하는 것** — A-1 의 분단이 완전히 회복된 상태에서 시작한다.
여기가 `1` 이면 A-1 의 잔재가 남은 것이고, 그 위에서 재면 두 실험이 섞인다.
## 1-3. 상주 탐침 파드를 띄운다 — 계측 도구를 바꾼다
**A-1 에서 임시 curl 파드가 형편없는 계측 도구임을 확인했다.** `--rm` 파드는
매번 만들고 지우므로 느리고 경합이 있고, **토큰을 단계 사이로 넘길 수 없다.**
이 실험은 **DB 정지 전에 발급한 토큰을 정지 후에 써야** 한다. 그래서 파드를
하나 띄워 두고 `exec` 로 단계를 이어간다.
**하기**
```bash
sudo kubectl -n keycloak-lab run a2-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" --env="K1=$K1" \
--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/a2-probe --timeout=120s
```
**실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt)
```
pod/a2-probe condition met
```
**되돌리기**
```bash
sudo kubectl -n keycloak-lab delete pod a2-probe --ignore-not-found
```
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
> 셸 히스토리에도 남지 않는다. 존재와 길이만 확인하고 싶으면:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
> **실측** — `19`
**확인** — 환경변수가 들어갔나
```bash
sudo kubectl -n keycloak-lab exec a2-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'
```
**형태**
```
K0=10.42.1.67 K1=10.42.0.35 PW길이=19
```
`PW길이=0` 이면 `--env` 가 빈 값을 넘긴 것이다. 파드를 지우고 다시 띄운다.
**이제부터는 이 파드 안에서 친다.** 셸에 들어가는 편이 편하다.
```bash
sudo kubectl -n keycloak-lab exec -it a2-probe -- sh
```
프롬프트가 `/ $` 로 바뀐다. 나올 때는 `exit`**파드는 안 지워진다**
(`--rm` 이 없다).
## 1-4. 양쪽 노드에 세션을 하나씩 만든다
**이 실험의 ① 과 ② 를 구분하려면 「캐시를 가진 노드」와 「없는 노드」가 있어야
한다.** A-0 에서 확인한 성질을 그대로 쓴다 — **각 노드는 자기가 로그인시킨
세션만 캐시한다.**
**하기** — 파드 안 셸에서
```sh
TOK=/realms/master/protocol/openid-connect/token
for H in "$K0" "$K1"; do
echo -n "$H : "
curl -s -X POST "http://$H:8080$TOK" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW" \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
| cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
| sed -n 's/.*"sid":"\([^"]*\)".*/\1/p'
done
```
**실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt)
```
=== [준비] 양쪽 노드에 세션을 하나씩 만든다 ===
keycloak-0 에서 로그인 sid=EAXV5HcG2J1BZ3vnwONf64AQ 토큰길이=613
keycloak-1 에서 로그인 sid=McyTj5lj3n_JqApCXeuAHExc 토큰길이=613
```
**어디를 봐야 하는가** — sid 두 개가 나온다. 빈 줄이 나오면 로그인이 실패했거나
base64 패딩 때문에 sid 를 못 뽑은 것이다. 응답 전체를 한 번 그대로 본다.
## 1-5. 세션이 각자 노드에만 캐시되었는가
**확인** — 밖에서. Keycloak 이미지에는 `curl` 이 없으므로 Prometheus 에 묻는다
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
| tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'
```
**실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt)
```
=== [확인] 세션이 각자 노드에만 캐시되었는가 ===
keycloak-1 = 0 건
keycloak-0 = 1 건
```
**어디를 봐야 하는가**`cache``sessions` 인 두 줄. 값이 서로 다르다.
**이 결과가 의미하는 것****`keycloak-0` 은 캐시를 가졌고 `keycloak-1` 은 없다.**
이제 ① 과 ② 를 구분해서 물을 수 있다.
> **`keycloak-1` 이 `0` 인 것은 스크레이프 지연 때문이다.** 방금 로그인했으므로
> 다음 15초 스크레이프에서 `1` 이 될 수 있다. 원래 실행 기록에도 그렇게 적혀
> 있다 — 「캐시 keycloak-0 = 1 건 / keycloak-1 = 0 건 (스크레이프 지연)」.
> **중요한 것은 「양쪽이 다르다」가 아니라 「`keycloak-0` 이 확실히 가지고
> 있다」다.** ① 의 해석에 필요한 것은 그것뿐이다.
**확인** — DB 에는 몇 건인가
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select offline_flag, count(*) from offline_user_session group by offline_flag"
```
**실측** — [`01-baseline.txt`](../../evidence/a2-database-loss/01-baseline.txt)
```
=== DB 온라인 세션 ===
2
```
**이 숫자를 적어 둔다.** 복구 후에 세션이 살아남았는지 볼 대조군이다.
## 1-6. 대조군 — DB 가 살아 있을 때 네 경로가 전부 되는 것을 먼저 본다
**이 절을 건너뛰면 뒤의 `500` 이 아무 의미가 없다.**
### ④ 에 쓸 클라이언트 id 를 지금 뽑아 둔다
**DB 가 죽은 뒤에는 이 조회 자체가 실패한다.** 미리 잡아 놔야 ④ 를 측정할 수
있다.
**확인** — 파드 안에서. 응답을 한 번 그대로 본다
```sh
AT=$(curl -s -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')
curl -s -H "Authorization: Bearer $AT" \
"http://$K0:8080/admin/realms/master/clients?clientId=admin-cli"
```
**형태** — 객체 하나짜리 배열. `"id"` 가 맨 앞에 있다
```json
[{"id":"131a9912-b578-4b9c-b16a-97518704077e","clientId":"admin-cli", ...}]
```
무엇을 자르는지 눈으로 본 다음 잘라낸다. **미검증**
```sh
CID=$(curl -s -H "Authorization: Bearer $AT" \
"http://$K0:8080/admin/realms/master/clients?clientId=admin-cli" \
| tr ',' '\n' | grep -m1 '"id"' | cut -d'"' -f4)
echo "CID=$CID"
```
> `sed -n 's/.*"id":"\([^"]*\)".*/\1/p'` 로 뽑으면 **뒤쪽의 다른 `id` 를 잡을 수
> 있다.** `.*` 가 탐욕적이라 줄에서 마지막 `"id":"` 를 고른다.
### 네 경로를 정상 상태에서 한 번 돌린다
**하기** — 파드 안에서
```sh
R0=$(curl -s -X POST "http://$K0:8080$TOK" \
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW")
RT0=$(echo "$R0" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
R1=$(curl -s -X POST "http://$K1:8080$TOK" \
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW")
RT1=$(echo "$R1" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
curl -s -o /dev/null -w '① %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT0"
curl -s -o /dev/null -w '② %{http_code}\n' --max-time 10 -X POST "http://$K1:8080$TOK" \
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT1"
curl -s -o /dev/null -w '③ %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"
curl -s -o /dev/null -w '④ %{http_code}\n' --max-time 10 -H "Authorization: Bearer $AT" \
"http://$K0:8080/admin/realms/master/clients/$CID/user-sessions?max=100"
```
**형태** — 정상 상태에서는
```
① 200
② 200
③ 200
④ 200
```
**`-o /dev/null` 을 빼면 안 된다.** 빼면 본문과 상태코드가 한 줄에 섞여
나온다. 원래 실행이 정확히 이걸 당했다 — [4-1](#4-1-네-경로) 을 본다.
**이 결과가 의미하는 것** — 네 경로가 전부 `200` 인 것이 기준선이다. 정지 후에
`500` 이면 「내가 깨뜨린 것」이고, 대조군 없이는 이 구별이 안 된다.
### ⑤ 상태가 필요 없는 경로도 미리 재 둔다
**하기**
```sh
curl -s -o /dev/null -w 'JWKS %{http_code}\n' \
"http://$K0:8080/realms/master/protocol/openid-connect/certs"
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
"http://$K0:8080/realms/master/.well-known/openid-configuration"
```
**형태**
```
JWKS 200
well-known 200
```
**확인** — 밖에서 정문도 재 둔다
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
**형태**
```
200
```
`exit` 으로 파드 셸에서 나온다. **파드는 그대로 둔다.**
---
# 2. 주입 — PostgreSQL 을 0대로 내린다
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
**되돌리기**
```bash
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
```
## 2-1. 왜 `scale --replicas=0` 인가
| 방법 | 무엇이 일어나나 |
|---|---|
| **`scale --replicas=0`** | 파드가 정상 종료되고 **다시 만들어지지 않는다** |
| `delete pod` | Deployment 가 **곧바로 새로 만든다** — 몇 초 만에 돌아온다 |
| 노드 정지 | Keycloak 도 같이 죽는다 — **두 장애가 섞인다** |
**`delete pod` 를 쓰면 이 실험이 성립하지 않는다.** DB 가 없는 구간을 원하는
만큼 유지할 수 있어야 네 경로를 다 재고 헬스와 엔드포인트까지 볼 수 있다.
> 이것은 **정상 종료**다. PostgreSQL 은 SIGTERM 을 받고 WAL 을 플러시한 뒤
> 내려간다. **데이터는 하나도 잃지 않는다.** 강제로 죽였을 때 무엇을 잃는지는
> [A-3](a3-database-crash.md) 이 잰다.
## 2-2. 적용
**하기**
```bash
date '+%H:%M:%S 정지'
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=0
sudo kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
date '+%H:%M:%S 삭제완료'
```
**실측** — [`03-four-paths.txt`](../../evidence/a2-database-loss/03-four-paths.txt)
```
=== [2] PostgreSQL 정지 ===
정지 시각: 11:56:04
deployment.apps/postgres scaled
pod/postgres-7b474b88c8-sn9ff condition met
삭제 완료: 11:56:04
```
**어디를 봐야 하는가****두 시각이 같다.** 즉시 사라진다.
**시각을 반드시 적어 둔다.** 뒤에서 「언제부터 변했나」를 볼 때 이 시각이 없으면
인과를 못 붙인다.
> **access token 수명이 60초다.** 1-6 에서 발급한 `AT` 로 ④ 를 재려면
> **발급 → 정지 → 시험을 60초 안에** 끝내야 한다. 60초를 넘기면 ④ 의 `401`
> 이 「DB 때문」인지 「토큰 만료」인지 구별되지 않는다. 시간이 지났으면
> 4-1 전에 토큰을 다시 받아 둔다 — 단, **그건 DB 가 있어야 되는 일**이므로
> 순서는 「토큰 발급 → 정지」다.
---
# 3. 주입이 실제로 걸렸는지 확인한다
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
## 3-1. postgres 파드가 정말 없나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**어디를 봐야 하는가**`postgres` 로 시작하는 줄이 **한 개도 없다.**
`Terminating` 으로 남아 있으면 아직 안 끝난 것이다. `wait` 가 통과했으면 없다.
**확인** — Deployment 쪽도 본다
```bash
sudo kubectl -n keycloak-lab get deploy postgres
```
**형태**
```
NAME READY UP-TO-DATE AVAILABLE AGE
postgres 0/0 0 0 5d
```
`0/0` 이어야 한다. `0/1` 이면 스케일이 안 먹고 파드가 못 뜨는 다른 문제다.
## 3-2. Keycloak 이 실제로 DB 에 못 붙고 있나
**확인** — 로그가 원인을 말한다
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 --tail=40 | grep -A3 -i 'connection'
```
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
```
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)
```
**어디를 봐야 하는가**`Connection refused``agroal`.
**이 결과가 의미하는 것**`agroal` 은 Quarkus 의 커넥션 풀이다. **풀이 새
커넥션을 만들지 못한다.** 이 줄이 없으면 Keycloak 은 아직 옛 커넥션으로 버티고
있거나, 애초에 DB 가 안 죽은 것이다.
> `Connection refused` 이지 `timed out` 이 아니다. Service 는 남아 있지만 뒤에
> 파드가 없어 **연결이 즉시 거부**된다. 네트워크를 막았다면 timeout 이 나왔을
> 것이고 증상이 훨씬 느리게 나타난다 — 그건 다른 실험이다.
## 3-3. 엉뚱한 것을 죽이지 않았나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount \
| grep keycloak
```
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
```
keycloak-0 false 0
keycloak-1 false 0
```
**어디를 봐야 하는가**`READY``false` 인데 **`RESTARTS` 가 여전히 `0`.**
**이 결과가 의미하는 것** — 파드는 **죽지 않았다.** 트래픽에서 빠졌을 뿐이다.
`RESTARTS` 가 오르고 있으면 liveness 가 실패하는 것이고, 그 상태에서 무엇을
재든 「DB 없는 Keycloak」이 아니라 「재시작 중인 Keycloak」을 재는 것이다.
**이 `restarts=0` 이 8절의 결론(자동 회복)을 가능하게 하는 조건이다.**
---
# 4. 효과를 관찰한다
## 4-1. 네 경로
**하기** — 탐침 파드 안에서. 1-6 과 **똑같은 명령**을 다시 친다
```bash
sudo kubectl -n keycloak-lab exec -it a2-probe -- sh
```
```sh
TOK=/realms/master/protocol/openid-connect/token
curl -s -o /dev/null -w '① %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT0"
curl -s -o /dev/null -w '② %{http_code}\n' --max-time 10 -X POST "http://$K1:8080$TOK" \
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT1"
curl -s -o /dev/null -w '③ %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"
curl -s -o /dev/null -w '④ %{http_code}\n' --max-time 10 -H "Authorization: Bearer $AT" \
"http://$K0:8080/admin/realms/master/clients/$CID/user-sessions?max=100"
```
> 파드 셸에서 나갔다 들어오면 `RT0` `RT1` `AT` `CID` 가 사라진다. **셸을
> 붙잡고 있는 편이 낫다.** 그래서 터미널 두 개를 열라고 한 것이다.
**실측** — [`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)
```
① 캐시를 가진 노드(keycloak-0)에서 refresh HTTP 500
② 캐시가 없는 노드(keycloak-1)에서 refresh HTTP 500
③ 새 로그인 HTTP 500
④ 관리 API (세션 조회 필요) HTTP 500
```
**하기** — 본문도 한 번 그대로 본다
```sh
curl -s -X POST "http://$K0:8080$TOK" \
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"
```
**실측**
```
{"error":"unknown_error","error_description":"For more on this error consult the server log."}
```
**어디를 봐야 하는가** — 네 줄 전부 `500`. 그리고 본문이 **아무것도 말해 주지
않는다.** 원인은 3-2 의 서버 로그에만 있다.
### ★ ④ 의 첫 측정은 오염됐다 — 이 함정에 걸리지 않는다
원래 실행의 증거 파일에는 이렇게 남아 있다.
**실측** — [`03-four-paths.txt`](../../evidence/a2-database-loss/03-four-paths.txt)
```
④ 이미 발급된 access token 으로 관리 API HTTP 000000{"error":"HTTP 401 Unauthorized"}401
```
**읽어 보면 세 가지가 한 줄에 뭉쳐 있다.**
```
HTTP 000000{"error":"HTTP 401 Unauthorized"}401
─┬──── ──────────┬─────────────────── ─┬─
│ │ └─ 마지막 시도의 상태코드
│ └─ 응답 본문이 그대로 섞였다
└─ 재시도가 세 번 "000" 을 찍었다 (연결 실패)
```
`curl -w '%{http_code}'` 를 쓰면서 **`-o /dev/null` 을 빼면** 본문이 표준출력으로
같이 나온다. 여기에 `--retry` 까지 걸려 있어 실패한 시도의 `000` 이 앞에 쌓였다.
> **위 표의 ④ `500` 은 5절에서 다시 잰 값이다.** 첫 측정은 그대로 쓰지 않았다.
> 오염된 측정은 **버리고 다시 잰다.** 「`401` 인가 `500` 인가」를 추측으로
> 메우면 안 된다.
**당신은 1-6 부터 `-o /dev/null` 을 쓰고 있으므로 이 함정을 지난다.**
## 4-2. ① 이 `500` 인 것이 이 실험의 핵심이다
**캐시에 세션을 들고 있어도 refresh 는 실패한다.**
A-1 에서는 로그아웃되어 DB 행이 사라진 세션에 대해 캐시를 가진 노드가 `200`
줬다. **왜 여기서는 안 되는가.**
```
refresh 처리
├── 세션이 존재하는가 → 캐시로 답할 수 있다
└── LAST_SESSION_REFRESH 갱신 → DB 쓰기가 필요하다 ← 여기서 죽는다
```
[A-0](a0-session-replication.md) 에서 잡은 SQL 그대로다.
```sql
update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1, VERSION=$2 where ...
```
> **캐시는 읽기를 대신할 뿐, 쓰기를 대신하지 못한다.**
> **refresh 는 이름과 달리 쓰기 연산이다.**
| | A-1 (7800 차단) | **A-2 (DB 정지)** |
|---|---|---|
| DB | 살아 있다 | **없다** |
| 캐시가 답할 수 있는 부분 | 세션 존재 확인 → `200` | 세션 존재 확인 → 거기까지 |
| DB 가 필요한 부분 | `UPDATE` 는 성공 | **`UPDATE` 실패 → `500`** |
## 4-3. 살아남은 것 — 상태가 필요 없는 경로
**하기** — 파드 안에서, 1-6 의 ⑤ 를 그대로
```sh
curl -s -o /dev/null -w 'JWKS %{http_code}\n' \
"http://$K0:8080/realms/master/protocol/openid-connect/certs"
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
"http://$K0:8080/realms/master/.well-known/openid-configuration"
```
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
```
=== ④ 다시 — 서명 검증만 필요한 경로는 살아 있는가 ===
JWKS 엔드포인트(realm 공개키) HTTP 200
realm 메타데이터(.well-known) HTTP 200
관리 API(세션 조회 필요) HTTP 500
```
**어디를 봐야 하는가** — 같은 파드, 같은 포트인데 **경로에 따라 `200``500`
이 갈린다.**
**이 결과가 의미하는 것** — **realm 공개키와 메타데이터는 메모리에 있으므로
DB 없이도 응답한다.** 이론적으로는 **이미 JWKS 를 캐시한 리소스 서버는 토큰
검증을 계속할 수 있다**는 뜻이다.
> 다만 이 실험대에는 독립 리소스 서버가 아직 없으므로 **여기까지가 말할 수
> 있는 범위**다. B층에서 확인한다.
>
> **그리고 정문으로는 이것도 못 쓴다.** 다음 절 때문이다.
## 4-4. 전면 장애 — 살아남는 노드가 없다
**확인** — Service 가 어느 파드를 잡고 있나
```bash
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
```
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
```
=== Service 엔드포인트 ===
ready : [] ← 비었다
notReady: [10.42.0.35 10.42.1.67]
```
**어디를 봐야 하는가****`ready` 가 빈 목록.** 두 IP 가 전부 `notReady` 다.
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 라 경고가
> 뜬다. 원래 실행 기록에도 그 경고가 두 줄 남아 있다.
>
> **실측**
> ```
> 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
> ```
> 사람이 눈으로 볼 때는 이쪽이 더 짧다.
> ```bash
> sudo kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints
> ```
**확인** — 밖에서
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
**실측**
```
=== 외부 진입점 ===
https://auth.hyeonworks.com/realms/master HTTP 503
```
한 번 눈으로 볼 때는 헤더까지 본다.
```bash
curl -I https://auth.hyeonworks.com/realms/master
```
**이 결과가 의미하는 것****`503` 은 Keycloak 이 준 것이 아니다.** Ready 인
백엔드가 하나도 없어서 그 앞의 프록시가 준 것이다. 4-3 에서 `200` 이던 JWKS 도
정문으로는 닿지 않는다 — **readiness 게이트가 문을 닫았다.**
### 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 대수보다 우선한다.**
## 4-5. 헬스 본문이 이유를 말한다
**확인** — Keycloak 이미지에는 `curl` 이 없으므로 파드 밖에서 묻는다
```bash
sudo kubectl -n keycloak-lab exec a2-probe -- \
curl -s "http://$K0:9000/health/ready"
```
**형태** — 한 줄 JSON 이 나온다. 한 번은 그대로 본다.
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
```
=== health/ready 상세 ===
전체: DOWN
Graceful Shutdown UP
Keycloak cluster health check UP
Keycloak database connections async health check DOWN
Keycloak Initialized UP
```
**어디를 봐야 하는가****네 항목 중 하나만 DOWN 인데 전체가 DOWN 이다.**
**이 결과가 의미하는 것****헬스체크는 모든 항목이 UP 이어야 UP 이다.**
그리고 **`cluster health` 는 UP** 이다 — 클러스터는 멀쩡하다. A-1 에서는 정확히
반대였다(cluster DOWN, database UP). **같은 `503` 이라도 어느 체크가 DOWN 인지가
장애를 구별한다.**
**확인**`describe` 로도 같은 것이 보인다
```bash
sudo kubectl -n keycloak-lab describe pod keycloak-0 | grep -A6 Conditions
```
## 4-6. 관측의 함정 — `up = 1` 인 채로 전면 장애
**확인**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=up' \
| tr ',' '\n' | grep -E '"job":|"pod":|^"[0-9]'
```
**실측** — [`05-recovery.txt`](../../evidence/a2-database-loss/05-recovery.txt)
```
=== ★ up 지표는 무엇을 말하는가 (프로세스는 살아 있다) ===
up{pod=keycloak-1} = 1 ← 1 인데 서비스는 503 이다
up{pod=keycloak-0} = 1 ← 1 인데 서비스는 503 이다
```
**어디를 봐야 하는가** — 둘 다 `1`. **서비스는 `503` 인데.**
Grafana Explore 에서 `up{job="keycloak"}` 을 그려 보면 **전 구간 평평하다.**
원래 실행의 그림이
[`a2-up-stayed-1-during-outage.png`](../../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 와 외부 응답 코드에 걸어야 한다.**
**확인** — 그럼 readiness 를 지표로 볼 수 있나
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=kube_pod_status_ready' \
| head -c 300; echo
```
**형태** — 결과가 비어 있다
```json
{"status":"success","data":{"resultType":"vector","result":[]}}
```
**이 결과가 의미하는 것** — 이 실험대에는 아직 `kube-state-metrics` 가 없어
**파드 readiness 가 지표로 남지 않는다.** 즉 지금 이 장애는 **Prometheus 만
보고 있으면 알 수 없다.** **관측 스택에 빠진 것을 이 실험이 찾아냈다.**
---
# 5. 복구
## 5-1. 되돌린다
**하기**
```bash
date '+%H:%M:%S 재기동'
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
```
**실측** — [`05-recovery.txt`](../../evidence/a2-database-loss/05-recovery.txt)
```
=== 복구 — 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
```
## 5-2. Keycloak 이 스스로 회복하는가 — 손대지 않고 본다
**★ 여기서 Keycloak 을 재시작하고 싶어진다. 참는다.** 재시작하면 이 실험이
답하려던 질문(「사람 개입이 필요한가」)이 사라진다.
**확인** — 15초 간격으로 몇 번 친다
```bash
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount \
| grep keycloak
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
**실측**
```
=== Keycloak 이 스스로 회복하는가 (재시작 없이) ===
+15초 keycloak-0 true keycloak-1 true | 외부 HTTP 200
→ 서비스 복귀
```
**어디를 봐야 하는가**`READY` 가 둘 다 `true`, 정문이 `200`.
## 5-3. 재시작 없이 회복한 것이 맞나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount | grep keycloak
```
**실측**
```
=== 재시작 횟수 — 파드가 죽었다 살아난 것인가, 그대로 회복한 것인가 ===
keycloak-0 0
keycloak-1 0
```
**어디를 봐야 하는가****`0`.** 3-3 에서 본 값 그대로다.
**이 결과가 의미하는 것** — **커넥션 풀이 스스로 재연결하고 readiness 가 다시
UP 이 되면서 Service 에 복귀했다.** 사람이 한 일은 DB 를 켠 것뿐이다.
| | |
|---|---|
| 회복 시간 | **약 15초** (DB Ready 이후) |
| 사람 개입 | **없음** |
| Keycloak 재시작 | **불필요**`restarts=0` |
### 개념 — readiness 와 liveness 를 가르는 기준
| | 실패하면 | 언제 쓰나 |
|---|---|---|
| **liveness** | **재시작** | 재시작하면 나아지는 문제 (교착, 메모리 누수) |
| **readiness** | **트래픽에서 격리** | 재시작해도 안 나아지는 문제 (**의존 대상이 죽음**) |
**DB 장애에 liveness 를 걸면 재앙이다.** 모든 파드가 무한 재시작하고, DB 가
돌아와도 CrashLoopBackOff 의 백오프 때문에 회복이 늦어진다. 게다가 재시작하면
**캐시까지 날아간다.**
> A-1 에서도 같은 결론이 나왔다. 분단된 노드가 **readiness 로** 빠졌기 때문에
> 재시작 없이 격리만 되었다. **Keycloak 은 두 종류의 장애를 다 readiness 로
> 신고한다.**
## 5-4. 세션이 살아남았나
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select offline_flag, count(*) from offline_user_session group by offline_flag"
```
**실측**
```
=== 정지 전 세션이 살아남았는가 ===
online 세션 5
```
**어디를 봐야 하는가** — 1-5 에서 적어 둔 값보다 크거나 같다. 실험 중에 로그인을
여러 번 했으므로 늘어나 있다.
**이 결과가 의미하는 것** — **세션은 DB 에 있으므로 DB 가 돌아오면 같이
돌아온다.** 정상 종료였기 때문에 하나도 잃지 않았다.
> **강제로 죽였다면 어떨까.** `SET LOCAL synchronous_commit TO OFF` 때문에
> 마지막 수백 밀리초의 쓰기가 사라져야 한다. **[A-3](a3-database-crash.md) 이
> 그 숫자를 잰다.**
## 5-5. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| DB | `sudo kubectl -n keycloak-lab get deploy postgres` | `1/1` |
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running`, `RESTARTS 0` |
| Service | `sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
| 헬스 | `exec a2-probe -- curl -s "http://$K0:9000/health/ready"` | 전체 `UP` |
| 클러스터 | `vendor_cluster_size` | 양쪽 `2` |
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a2-probe` | 지웠으면 `NotFound` |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
**하기** — 탐침 파드를 지운다. `--rm` 이 없으므로 **직접 지워야 한다**
```bash
sudo kubectl -n keycloak-lab delete pod a2-probe --ignore-not-found
```
`sleep 7200` 이 끝나면 파드는 `Completed` 로 남는다. **자동으로 사라지지
않는다.** 다음 실험에서 `a2-probe` 이름이 이미 있다고 거절당하는 원인이 이것이다.
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 출력이 `HTTP 000000{...}401` 처럼 뭉쳐 나온다 | **`-o /dev/null` 을 뺐다.** 본문과 코드가 섞였다 | 4-1 의 ★ 절. 오염된 측정은 버리고 다시 잰다 |
| `000` 이 앞에 붙어 나온다 | `--retry` 가 걸려 실패 시도의 코드까지 찍었다 | 재시도를 빼고 `--max-time` 만 쓴다 |
| DB 를 내렸는데 몇 초 만에 돌아온다 | **`delete pod` 를 썼다.** Deployment 가 새로 만든다 | `scale --replicas=0` — 2-1 |
| ④ 가 `401` 이다 | **access token 이 만료됐다** (수명 60초) | 토큰 발급 → 정지 → 시험을 60초 안에 — 2-2 |
| `kubectl exec keycloak-0 -- curl``exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드로 치거나 Prometheus 에 묻는다 |
| 파드 셸에 다시 들어갔더니 변수가 없다 | `exec` 세션이 끝나면 셸 변수는 사라진다 | 셸을 붙잡고 있는다. 터미널 두 개 — 4-1 |
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` — 4-4 |
| `up` 이 1 이라 정상인 줄 알았다 | **`up` 은 스크레이프 성공만 말한다** | readiness 와 외부 코드를 본다 — 4-6 |
| `kube_pod_status_ready` 결과가 비었다 | **`kube-state-metrics` 가 이 실험대에 없다** | 보완 항목이다. 지금은 `kubectl` 로 본다 — 4-6 |
| 복구했는데 계속 `503` | Keycloak 이 아직 재연결 중이다 | 15~30초 더 기다린다. **재시작하지 않는다** — 5-2 |
| `a2-probe` 를 다시 못 만든다 | 옛 파드가 `Completed` 로 남아 있다 | `delete pod a2-probe --ignore-not-found` — 5-5 |
| 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | `exec a2-probe -- sh -c 'echo ${#PW}'``0` 이면 `--env` 가 빈 값 |
---
# 왜 이 가이드는 임시 파드를 안 쓰나
A-1 은 관찰을 `kubectl run --rm` 임시 파드로 했고, **그 계측이 실패했다.**
매번 파드를 만들고 지우므로 느리고, 경합이 있고, 빈 출력이 섞였다.
이 실험은 거기에 더해 **토큰을 단계 사이로 넘겨야 한다.** 임시 파드로는 불가능
하다 — 파드가 사라지면 변수도 사라진다.
```
임시 파드 단계마다 새로 뜬다 → 토큰이 안 넘어간다 · 느리다 · 빈 출력
상주 파드 한 번 띄워 둔다 → exec 로 이어간다 · 파일에 남길 수 있다
```
**대신 지우는 것을 잊으면 안 된다.** `--rm` 이 없다는 것은 그런 뜻이다.
> **임시 파드는 계측 도구가 아니다.** 15초마다 이미 긁고 있는 Prometheus 가
> 그러라고 있는 것이고, 사람이 손으로 묻는 것은 상주 파드가 낫다.
---
# 다음
| 실험 | A-2 가 남긴 질문 |
|---|---|
| [A-3](a3-database-crash.md) DB 강제 종료 | **정상 정지는 하나도 안 잃었다. 강제 종료는?** `synchronous_commit OFF` 의 대가 |
| A-4 노드 상실 | `postgres``kc-lab-2` 에 있다 — **그 노드를 죽이면 A-2 가 함께 일어난다** |
| D-1 백업·복구 | 여기서는 DB 가 되살아났다. **데이터가 사라졌다면?** |
| 관측 스택 | **`kube-state-metrics` 가 없어 파드 readiness 가 지표로 안 남는다** — 보완 필요 |
| 전부 | **알림을 `up` 에 걸지 않는다.** readiness 와 외부 응답 코드에 건다 |