Files
keycloak-pattern/docs/experiment-a4-node-loss.md
T
DongHyeonkaandClaude Opus 5 cdac9b8178 docs: give the twelve experiments that had no architecture diagram one
An audit against the standard the series set — concepts, procedure,
commands, architecture diagram, evidence table, terminal output — found the
three new experiments met it while twelve of the original ones had no
diagram at all: A-0, A-1, A-3, A-4, A-5, A-6, A-8, B-0, B-2, B-7, C-2, D-2.

Each now has one drawn from what that experiment actually found, not filler:
A-0 shows sharing going through PostgreSQL rather than between the caches;
A-3 the gap between the 200 and the WAL flush, with both failed injections;
A-5 the three silent injection failures; A-6 the two places latency is
multiplied; B-0 the repository keyed by principal with no session id; B-2
the primary key that causes the overwrite; D-2 why the rolling update
stopped the accident halfway.

Also corrected the index's stale claim of 11 experiments without a
screenshot — it is 14, and the reason is recorded: those experiments were
measured from terminals, the database and logs, and the observability stack
does not scrape Redis, the BFF or PostgreSQL, so there is no console to
photograph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:44:36 +09:00

436 lines
16 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-4 — 노드가 통째로 죽으면 어떻게 되는가
브랜치 `feature/keycloak-a4-node-loss` ·
증거 [`docs/evidence/a4-node-loss/`](evidence/a4-node-loss/) ·
2026-09-04 12:0712:24 KST
선행: [`A-2`](experiment-a2-database-loss.md) · [`A-3`](experiment-a3-database-crash.md)
**두 판본으로 나눠 실행했다.**
| | 죽인 노드 | 성격 |
|---|---|---|
| **4a** | `kc-lab-2` (워커) | Keycloak + **DB 동시 상실** |
| **4b** | `kc-lab-1` (k3s 서버) | **컨트롤 플레인 + 진입점 상실** |
`virsh destroy` 는 종료 신호를 보내지 않는다. **전원을 뽑는 것과 같다.**
---
## 구조
![A-4 — 두 가지 노드 상실과 서로 다른 이유](diagrams/a4-node-loss.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
---
## 0. 결론부터
| | 4a 워커 상실 | 4b 컨트롤 플레인 상실 |
|---|---|---|
| 외부 응답 | **503** | **000** (연결 자체가 안 됨) |
| `kubectl` | 정상 | **불통** |
| 살아 있는 워크로드 | keycloak-1 (하지만 DB 없음) | **keycloak-0 은 계속 돌고 있다** |
| **`virsh start` 이후** 복구 | **60초** | **60초** |
**둘 다 전면 장애**지만 이유가 다르다. 4a 는 **DB 가 같이 죽어서**, 4b 는
**들어갈 길이 없어서**다.
그리고 예상하지 못한 것 셋 —
**죽은 파드가 산 파드보다 건강해 보이고**, **StatefulSet 은 대체 파드를 만들지
않으며**, **PVC 때문에 재배치가 불가능하다.**
---
## 4a. 워커 노드 상실 (`kc-lab-2`)
### 기준선
```
kc-lab-1 Ready control-plane=true
kc-lab-2 Ready <none>
keycloak-0 ready=true kc-lab-2
keycloak-1 ready=true kc-lab-1
postgres ready=true kc-lab-2
PVC postgres-data → kc-lab-2 ← 재배치 가능성을 여기서 이미 알 수 있다
외부 HTTP 200
```
### 주입
```bash
virsh destroy kc-lab-2
```
### 관찰
```
+15초 node=Ready | keycloak-0=Running postgres=Running | 외부 HTTP 000
+30초 node=Ready | keycloak-0=Running postgres=Running | 외부 HTTP 000
+45초 node=NotReady | keycloak-0=Running postgres=Running | 외부 HTTP 503
...
+180초 node=NotReady | keycloak-0=Running postgres=Running | 외부 HTTP 503
```
### 발견 ① — 쿠버네티스가 알아채는 데 **40초** 걸린다
`+15초`, `+30초` 에도 노드는 여전히 `Ready` 다. **기계는 이미 없는데.**
kube-controller-manager 의 `node-monitor-grace-period` (기본 40초) 동안
kubelet 의 하트비트가 없어야 `NotReady` 로 바꾼다.
> **그 40초 동안 쿠버네티스는 거짓말을 한다.** 사용자는 이미 장애를 겪고 있다
> (`000`). **노드 상태를 알림 근거로 삼으면 항상 늦는다.**
### 발견 ② — **죽은 파드가 산 파드보다 건강해 보인다**
```
POD PHASE READY NODE
keycloak-0 Running true kc-lab-2 ← 기계가 꺼져 있다
keycloak-1 Running false kc-lab-1 ← 살아 있다
```
**`keycloak-0``ready=true`, `keycloak-1``ready=false`.**
| 왜 | |
|---|---|
| keycloak-0 | kubelet 이 없어 **상태를 갱신할 수 없다.** 마지막으로 보고한 값이 그대로 얼어 있다 |
| keycloak-1 | 살아서 **정직하게 보고한다** — DB 가 없으니 readiness 실패 |
> **파드 상태는 "지금 어떤가"가 아니라 "마지막으로 그렇게 들었다"이다.**
> 노드가 죽으면 그 노드 파드의 상태는 **화석**이 된다.
Prometheus 는 정확했다.
```
up{job=keycloak pod=keycloak-1} = 1
up{job=keycloak pod=keycloak-0} = 0 ← 긁기에 실패했다 = 사실
up{job=kubelet } = 0
up{job=node-exporter node=kc-lab-2 } = 0
```
**A-2 와 정반대다.** A-2 에서는 `up=1` 인데 서비스가 죽었고, 여기서는 `up=0`
이 정확했다.
| | `up` 이 잡는가 |
|---|---|
| **대상이 사라짐** (노드 상실) | **잡는다** |
| **대상이 살아서 못 씀** (DB 상실) | **못 잡는다** |
![up 이 노드별로 0 으로 떨어졌다](evidence/a4-node-loss/a4-up-dropped-per-node.png)
### 발견 ③ — 축출은 **5분** 뒤에 시작된다
쿠버네티스가 노드에 taint 를 붙인다.
```
node.kubernetes.io/unreachable=:NoSchedule
node.kubernetes.io/unreachable=:NoExecute
```
그런데 모든 파드에는 기본 관용이 붙어 있다.
```
node.kubernetes.io/not-ready NoExecute tolerationSeconds=300
node.kubernetes.io/unreachable NoExecute tolerationSeconds=300
```
```
+240초 전부 Running
+270초 a2-probe:Terminating keycloak-0:Terminating postgres:Terminating
postgres-...-9cmsv:Pending ← 새 파드가 생겼다
```
**노드가 죽고 약 5분이 지나야 파드가 축출된다.** 잠깐 끊긴 노드가 돌아올 수도
있으니 성급하게 옮기지 않겠다는 설계다. 대신 **그동안은 아무 일도 일어나지
않는다.**
### 발견 ④ — 축출돼도 **갈 곳이 없다**
```
Warning FailedScheduling default-scheduler
0/2 nodes are available:
1 node(s) didn't match PersistentVolume's node affinity,
1 node(s) had untolerated taint(s).
```
```
kc-lab-2 → taint 때문에 못 간다 (죽은 노드)
kc-lab-1 → PVC 가 kc-lab-2 에 묶여 있어 못 간다
```
**`local-path` PVC 는 그 노드의 로컬 디스크 디렉터리다.** 노드가 죽으면
볼륨도 죽는다. 새 파드는 **영원히 Pending** 이다.
> 결함이 아니라 **조건**이다. 이 실험대는 그걸 알고 `local-path` 를 골랐다.
> 운영이라면 네트워크 스토리지나 DB 복제가 이 자리를 메워야 한다.
### 발견 ⑤ — StatefulSet 은 **대체 파드를 만들지 않는다**
```
NAME DESIRED READY CURRENT
keycloak 2 <none> 1
keycloak-0 1/1 Terminating 30m ← 30분째
keycloak-1 0/1 Running
```
**`keycloak-0``Terminating` 에서 영원히 멈춰 있고, 대체 파드가 안 생긴다.**
| 왜 | |
|---|---|
| StatefulSet 의 계약 | **같은 이름의 파드는 클러스터에 하나뿐**이어야 한다 |
| 컨트롤 플레인이 아는 것 | 노드가 안 보인다 = **파드가 죽었는지 확신할 수 없다** |
| 그래서 | 옛 파드를 확실히 지우기 전엔 새 `keycloak-0` 을 못 만든다 |
**Deployment 였다면 즉시 새 파드를 만든다.** 이름이 아무래도 되기 때문이다.
StatefulSet 의 "안정된 이름"이라는 이득의 **반대편 비용**이 여기다.
강제로 진행시키려면 사람이 개입해야 한다.
```bash
kubectl -n keycloak-lab delete pod keycloak-0 --grace-period=0 --force
```
**위험한 명령이다.** 노드가 사실은 살아 있고 네트워크만 끊긴 것이라면
**같은 이름의 파드 둘이 동시에 존재**하게 된다 (split brain).
### 복구
```bash
virsh start kc-lab-2
```
```
+30초 node=Ready | Running 파드 3 개 | 외부 HTTP 503
+60초 node=Ready | Running 파드 3 개 | 외부 HTTP 200
→ 서비스 복귀
```
**60초.** 사람 개입 없이 전부 제자리로 돌아왔다.
> **이 60초는 MTTR 이 아니다.** `virsh start` 를 친 뒤의 시간이며,
> 실제 장애 구간은 **12:07:43(차단) → 12:17:31(서비스 복귀) ≈ 10분**이다.
> 그 대부분은 내가 관찰하며 보낸 시간이고, **사람이 알아채고 결정하는 시간이
> 복구 시간의 대부분**이라는 점이 오히려 현실적이다.
>
> 그리고 본문의 `40초`(node-monitor-grace-period)와 `5분`(tolerationSeconds)은
> **쿠버네티스 기본값을 인용한 것**이며, 관측된 전이 시점(+45초, +270초)이
> 그 값과 모순되지 않는다는 것까지가 이 실험이 말할 수 있는 범위다.
---
## 4b. 컨트롤 플레인 노드 상실 (`kc-lab-1`)
### 이 노드에 무엇이 있었나 — 그게 곧 영향 범위다
```
keycloak-lab keycloak-1
kube-system coredns
kube-system local-path-provisioner
kube-system metrics-server
kube-system svclb-traefik
kube-system traefik ← replicas=1
observability grafana
observability prometheus
observability node-exporter
```
```
NAME REPLICAS READY
traefik 1 1 ← 진입점이 한 개다
```
### 주입과 관찰
```bash
virsh destroy kc-lab-1
```
```
+20초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server
+60초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server
+120초 외부 auth=000 grafana=502 | kubectl: Unable to connect to the server
+160초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server
```
### 발견 ⑥ — 워크로드는 살아 있는데 **길이 없다**
살아남은 노드에서 직접 확인했다.
```bash
ssh kc-lab-2 'sudo crictl ps --name keycloak'
```
```
CONTAINER STATE NAME POD NAMESPACE
e5f777900b76 Running keycloak keycloak-0 keycloak-lab
```
**`keycloak-0` 은 멀쩡히 돌고 있다.** 컨테이너 런타임(containerd)은 API 서버
없이도 이미 떠 있는 파드를 계속 굴린다.
```
죽은 것: API 서버 · 스케줄러 · coredns · Traefik · Prometheus · Grafana
산 것: keycloak-0 · postgres · containerd
문제: 들어갈 문(Traefik)이 없다
```
> **컨트롤 플레인 상실은 워크로드 상실이 아니다.**
> 이미 떠 있는 것은 계속 돈다. **새로 뜨거나 옮기거나 고치는 것이 안 될 뿐.**
### 발견 ⑦ — `000` 과 `503` 의 차이
| 코드 | 뜻 |
|---|---|
| **`503`** (4a) | nginx·Traefik 은 살아 있고 **뒤에 보낼 파드가 없다** |
| **`000`** (4b) | curl 이 응답 자체를 못 받았다 — **Traefik 이 없어 nginx 가 죽은 주소를 기다리다 타임아웃** |
`grafana=502` 가 한 번 찍힌 것이 증거다 — nginx 는 살아서 502 를 만들 수
있었지만, 대부분은 타임아웃(`--max-time 8`)에 걸렸다.
**두 코드가 다른 층의 고장을 가리킨다.**
### 발견 ⑧ — 관측 시스템이 같이 죽으면 **0 이 아니라 구멍이 남는다**
Prometheus 가 `kc-lab-1` 에 있었다. 그 노드가 죽은 12:18–12:23 구간은
그래프에 **0 이 아니라 데이터 없음**으로 남는다.
```
대상이 죽음 → up = 0 → "언제 죽었는지" 알 수 있다
관측자가 죽음 → 데이터 없음 → "그때 무슨 일이 있었는지" 모른다
```
**계획 단계에서 "관측 스택은 죽이지 않는 노드에 둔다"고 정했던 이유**를
실제로 확인한 셈이다. 다만 노드가 둘뿐이라 완전히 피할 수는 없다.
### 복구
```bash
virsh start kc-lab-1
```
```
+30초 외부=502 | kc-lab-1=Ready kc-lab-2=Ready
+60초 외부=200 | kc-lab-1=Ready kc-lab-2=Ready
→ 서비스 복귀 (총 60초)
```
**Grafana 는 로그인 세션을 잃었다** — 데이터가 `emptyDir` 이라 파드 재시작에
사라진다. Prometheus 는 PVC 라 지표가 남았다. **의도한 설계대로 동작했다.**
---
## 5. 개념
### `node-monitor-grace-period` 와 `tolerationSeconds`
```
기계 정지
│ 40초 node-monitor-grace-period → 노드 NotReady
│ +300초 tolerationSeconds (NoExecute) → 파드 축출 시작
총 약 5분 40초 동안 쿠버네티스는 아무것도 하지 않는다
```
**빠른 장애 대응이 필요하면 이 값을 줄여야 하지만**, 짧게 하면 일시적
네트워크 끊김에도 파드를 옮기게 되어 **불안정해진다.** 맞바꿈이다.
### `Terminating` 이 안 끝나는 이유
```
파드 삭제 요청
└─ kubelet 이 컨테이너를 멈추고 "지웠다"고 보고해야 끝난다
└─ kubelet 이 없다 → 보고가 없다 → 영원히 Terminating
```
`--grace-period=0 --force` 는 **보고를 기다리지 않고 API 에서 지우는 것**이다.
컨테이너가 실제로 죽었는지는 **모른 채** 진행한다.
### StatefulSet vs Deployment — 노드 상실에서
| | Deployment | StatefulSet |
|---|---|---|
| 대체 파드 | **즉시 만든다** (이름 무관) | **안 만든다** (이름이 유일해야) |
| 노드 상실 시 | 자동 회복 | **사람이 강제 삭제해야** |
| 위험 | 없음 | 강제 삭제 시 **중복 실행 가능** |
**A-0 에서 "로그 대조가 쉬워서" StatefulSet 을 골랐는데, 그 대가가 여기 있다.**
### `local-path` PVC 와 재배치
```
PVC → PV → nodeAffinity: kubernetes.io/hostname = kc-lab-2
└─ 그 노드의 /var/lib/rancher/k3s/storage/... 디렉터리
```
**볼륨이 노드에 못박히면 파드도 못박힌다.** 노드가 죽으면 데이터도 함께
접근 불가가 된다.
---
---
## 증거 파일
**증거 수집 시각: 2026-09-04 12:05 12:23 KST** (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)
| 파일 | 종류 |
|---|---|
| [`01-baseline.txt`](evidence/a4-node-loss/01-baseline.txt) | 터미널 원문 |
| [`02-worker-node-killed.txt`](evidence/a4-node-loss/02-worker-node-killed.txt) | 터미널 원문 |
| [`03-state-during-loss.txt`](evidence/a4-node-loss/03-state-during-loss.txt) | 터미널 원문 |
| [`04-eviction-timing.txt`](evidence/a4-node-loss/04-eviction-timing.txt) | 터미널 원문 |
| [`05-recovery.txt`](evidence/a4-node-loss/05-recovery.txt) | 터미널 원문 |
| [`06-control-plane-inventory.txt`](evidence/a4-node-loss/06-control-plane-inventory.txt) | 터미널 원문 |
| [`07-control-plane-loss.txt`](evidence/a4-node-loss/07-control-plane-loss.txt) | 터미널 원문 |
| [`08-control-plane-recovery.txt`](evidence/a4-node-loss/08-control-plane-recovery.txt) | 터미널 원문 |
| [`a4-up-dropped-per-node.png`](evidence/a4-node-loss/a4-up-dropped-per-node.png) | 스크린샷 |
파일별 상세는 [`evidence/a4-node-loss/README.md`](evidence/a4-node-loss/README.md).
## 6. 재현 절차 (명령어)
```bash
export LIBVIRT_DEFAULT_URI=qemu:///system
# 사전 — PVC 가 어디 묶여 있는지 먼저 본다 (재배치 가능성이 여기서 정해진다)
kubectl -n keycloak-lab get pvc -o name | while read p; do
V=$(kubectl -n keycloak-lab get $p -o jsonpath='{.spec.volumeName}')
kubectl get pv $V -o jsonpath='{.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]}'
done
# 4a 워커 상실
virsh destroy kc-lab-2
kubectl get node kc-lab-2 # 40초 뒤 NotReady
kubectl -n keycloak-lab get pods -o wide # Running 인 채로 얼어 있다
kubectl get node kc-lab-2 -o jsonpath='{.spec.taints}'
# 5분 뒤 Terminating + 새 파드 Pending
kubectl -n keycloak-lab describe pod $(kubectl -n keycloak-lab get pods -l app=postgres --field-selector=status.phase=Pending -o jsonpath='{.items[0].metadata.name}') | grep -A4 Events
# 4b 컨트롤 플레인 상실 — kubectl 이 죽으므로 노드에서 직접 본다
virsh destroy kc-lab-1
ssh kc-lab-2 'sudo crictl ps' # 워크로드는 살아 있다
# 복구
virsh start kc-lab-2 && virsh start kc-lab-1
```
---
## 7. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **A-5** 비대칭 파티션 | 여기서는 노드가 **완전히** 사라졌다. 부분 단절은 더 고약하다 |
| **D-1** 백업·복구 | **PVC 가 노드에 묶여 있다** — 노드가 영영 안 돌아오면 백업이 유일한 길 |
| 구성 개선 | **Traefik `replicas=1` 은 진입점 단일 장애점** — 2로 늘리거나 DaemonSet 으로 |
| 구성 개선 | **Grafana 가 `emptyDir`** — 재시작마다 로그인 세션이 사라진다 |