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>
436 lines
16 KiB
Markdown
436 lines
16 KiB
Markdown
# A-4 — 노드가 통째로 죽으면 어떻게 되는가
|
||
|
||
브랜치 `feature/keycloak-a4-node-loss` ·
|
||
증거 [`docs/evidence/a4-node-loss/`](evidence/a4-node-loss/) ·
|
||
2026-09-04 12:07–12: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` 는 종료 신호를 보내지 않는다. **전원을 뽑는 것과 같다.**
|
||
|
||
---
|
||
|
||
## 구조
|
||
|
||

|
||
|
||
> 다이어그램 규약은 [`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 상실) | **못 잡는다** |
|
||
|
||

|
||
|
||
### 발견 ③ — 축출은 **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`** — 재시작마다 로그인 세션이 사라진다 |
|