Files
keycloak-pattern/docs/guides/experiments/a4-node-loss.md
T

979 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-4 재현 가이드 — 기계 전원을 뽑고 쿠버네티스가 언제 알아채는지 직접 본다
해설 문서: [`docs/experiment-a4-node-loss.md`](../../experiment-a4-node-loss.md) ·
증거 원문: [`docs/evidence/a4-node-loss/`](../../evidence/a4-node-loss/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 기계는 없는데 쿠버네티스가 40초 동안 `Ready` 라고 말하는 것 | `kubectl get node` 와 외부 `curl` 을 나란히 |
| **죽은 파드가 `ready=true`, 산 파드가 `ready=false`** 인 것 | `get pods -o custom-columns` |
| 그 와중에 `up` 은 정확히 0 인 것 | Prometheus |
| 축출이 5분 뒤에야 시작되는 것 | `tolerationSeconds` 와 파드 상태 |
| 새 파드가 **영원히 `Pending`** 인 것 | `describe pod` 의 Events |
| StatefulSet 이 대체 파드를 **안 만드는** 것 | `get statefulset``CURRENT` |
| `kubectl` 이 죽어도 컨테이너는 도는 것 | `kc-lab-2` 에서 `crictl ps` |
| 관측자가 같이 죽으면 **0 이 아니라 구멍**이 남는 것 | Grafana |
## 전제
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
- **명령을 치는 곳이 세 군데다.** 이 실험은 그 구별이 곧 내용이다.
| 터미널 | 어디 | 무엇을 |
|---|---|---|
| **A** | `test-server` (VM 호스트) | `virsh` — 전원을 뽑고 다시 넣는다 |
| **B** | `kc-lab-1` | `sudo kubectl` — 관찰. **4b 에서는 이 터미널이 죽는다** |
| **C** | `test-server` | 밖에서 `curl`. 사용자 시점 |
- 터미널 A 에서 `virsh` 가 시스템 하이퍼바이저를 보고 있어야 한다.
```bash
export LIBVIRT_DEFAULT_URI=qemu:///system
virsh uri
```
`qemu:///system` 이 아니면 **VM 이 안 보인다.** [`00-lab-host`](../00-lab-host/) 5절.
- 4b 에서는 `kc-lab-2` 에도 붙는다. 터미널 A 와 같은 기계에서 `ssh kc-lab-2`.
이름이 안 풀리면 `ssh 192.168.122.12`.
## 주의 — 이건 기계를 끄는 실험이다
`virsh destroy` 는 **종료 신호를 보내지 않는다. 전원 코드를 뽑는 것과 같다.**
게스트 파일시스템이 더러운 채로 멈춘다. **실험대에서만 한다.**
전 구간 약 **40분**이다. 4a 에서 축출을 보려면 그것만 7분을 기다려야 한다.
어느 시점에서든 그만두려면 터미널 A 에서 한 줄이면 된다.
```bash
virsh start kc-lab-2 ; virsh start kc-lab-1
```
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 12:0512:23 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
파드 이름·IP·시각은 **당신 환경에서 다르다.** 자리표시자(`<...>`)를 쓰지 않는
대신 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행 기록의
실제 값이다.
---
# 0. 왜 이 실험을 하는가
A-1 과 A-5 는 **네트워크만** 끊었다. 파드는 살아 있었고, 쿠버네티스는 계속
정확한 상태를 알고 있었다. 여기서는 **기계 자체를 없앤다.** 그러면 상태를
보고할 주체가 사라진다.
두 판본으로 나눈다. **어느 노드를 죽이느냐가 전부**이기 때문이다.
| | 죽이는 노드 | 그 노드에 있는 것 | 묻는 것 |
|---|---|---|---|
| **4a** | `kc-lab-2` (워커) | keycloak-0 · **postgres** · postgres PVC | Keycloak 과 DB 를 **동시에** 잃으면 |
| **4b** | `kc-lab-1` (k3s 서버) | keycloak-1 · **Traefik** · 컨트롤 플레인 · 관측 스택 | **들어갈 문**을 잃으면 |
세 가지를 확인한다.
```
쿠버네티스는 언제 알아채는가 → 40초 (그동안 거짓말을 한다)
무엇을 스스로 고치는가 → 축출. 단 5분 뒤
무엇을 못 고치는가 → PVC 가 묶인 재배치, StatefulSet 이름
```
---
# 1. 기준선 — 전원을 뽑기 전에
**시험군만 재는 측정은 측정이 아니다.** 뽑은 뒤에 볼 것을 뽑기 전에 **똑같은
명령으로** 먼저 봐 둔다.
넓은 것부터 좁혀 간다.
```
VM → 노드 → 파드 배치 → 볼륨이 어디 묶여 있나 → 외부 응답 → 관측자가 어디 있나
```
## 1-1. VM 이 둘 다 살아 있나
**확인** — 터미널 A
```bash
virsh list --all
```
**실측** — [`01-baseline.txt`](../../evidence/a4-node-loss/01-baseline.txt)
```
--------------------------
1 kc-lab-1 running
2 kc-lab-2 running
```
**어디를 봐야 하는가** — 둘 다 `running`. 앞의 숫자는 **도메인 ID** 이며
VM 을 껐다 켜면 바뀐다. 이름으로 다룬다.
## 1-2. 노드와 파드 배치
**확인** — 터미널 B
```bash
sudo kubectl get nodes
sudo kubectl -n keycloak-lab get pods -o wide
```
**실측** — [`01-baseline.txt`](../../evidence/a4-node-loss/01-baseline.txt)
```
kc-lab-1 Ready true
kc-lab-2 Ready <none>
a2-probe true kc-lab-2
keycloak-0 true kc-lab-2
keycloak-1 true kc-lab-1
postgres-7b474b88c8-2gf27 true kc-lab-2
```
**어디를 봐야 하는가** — **`NODE` 열.** 이 실험은 배치가 전부다.
**이 결과가 의미하는 것** — `kc-lab-2` 에 **keycloak-0 과 postgres 가 함께**
있다. 그래서 4a 는 「Keycloak 한 대를 잃는 실험」이 아니라 **「Keycloak 한 대와
DB 를 동시에 잃는 실험」**이다. 배치가 다르면 결과도 다르다 — 먼저 확인한다.
> `a2-probe` 는 A-2 에서 띄워 두고 안 지운 상주 파드다. 당신 환경에는 없을 수
> 있다. 없어도 이 실험에는 지장이 없다.
## 1-3. ★ 볼륨이 어느 노드에 못박혀 있나
**이 한 줄이 뒤의 결과를 이미 결정한다.** 4a 에서 「새 파드가 왜 영원히
Pending 인가」의 답이 여기 있다.
**확인** — 어떤 PVC 가 있나
```bash
sudo kubectl -n keycloak-lab get pvc
```
**확인** — 그 PVC 뒤의 PV 가 어느 노드를 요구하나. 먼저 **읽는 형태**로 한 번 본다
```bash
sudo kubectl -n keycloak-lab get pvc postgres-data -o jsonpath='{.spec.volumeName}' ; echo
sudo kubectl describe pv $(sudo kubectl -n keycloak-lab get pvc postgres-data \
-o jsonpath='{.spec.volumeName}') | grep -A6 'Node Affinity'
```
**형태**
```
Node Affinity:
Required Terms:
Term 0: kubernetes.io/hostname in [kc-lab-2]
```
값만 필요하면 **뽑는 형태**로 줄인다.
```bash
sudo kubectl get pv $(sudo kubectl -n keycloak-lab get pvc postgres-data \
-o jsonpath='{.spec.volumeName}') \
-o jsonpath='{.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]}' ; echo
```
**실측** — [`01-baseline.txt`](../../evidence/a4-node-loss/01-baseline.txt)
```
=== PVC 가 어느 노드에 묶여 있는가 (재배치 가능성) ===
persistentvolumeclaim/postgres-data → kc-lab-2
```
**어디를 봐야 하는가** — 오른쪽의 노드 이름. **그것이 `kc-lab-2` 라면 4a 에서
postgres 는 갈 곳이 없다.**
**이 결과가 의미하는 것** — `local-path` PVC 는 **그 노드의 로컬 디렉터리**다
(`/var/lib/rancher/k3s/storage/...`). 노드가 죽으면 볼륨도 같이 죽는다.
스케줄러는 그 사실을 `nodeAffinity` 로 알고 있어서, 다른 노드에 파드를
**만들지 않는다.** 결함이 아니라 이 실험대의 **조건**이다.
## 1-4. 밖에서 보이는 상태
**확인** — 터미널 C. 눈으로 한 번 볼 때는 `-I` 로 충분하다
```bash
curl -I --max-time 8 https://auth.hyeonworks.com/realms/master
```
**형태**
```
HTTP/2 200
content-type: application/json
```
여러 번 재서 비교할 것이므로, 이제부터는 **코드만** 뽑는다.
```bash
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
```
**실측** — [`01-baseline.txt`](../../evidence/a4-node-loss/01-baseline.txt)
```
=== 서비스 정상 확인 ===
https://auth.hyeonworks.com/realms/master HTTP 200
```
**`--max-time` 을 반드시 준다.** 4b 에서 이 값이 없으면 curl 이 몇 분씩
매달린다. 그리고 **타임아웃이 곧 결과**다 — 뒤에서 `000` 이 나오는 이유가
그것이다.
## 1-5. 관측자가 어디 있나 — 미리 알아 둔다
**확인**
```bash
sudo kubectl -n observability get pods -o wide
```
**형태**
```
NAME READY STATUS NODE
grafana-845b5678cf-b6gvc 1/1 Running kc-lab-1
prometheus-6774f94f7c-pzr2t 1/1 Running kc-lab-1
```
**이 결과가 의미하는 것** — 관측 스택이 `kc-lab-1` 에 있다. **4a(`kc-lab-2`
살해)에서는 Prometheus 가 살아남아 관측이 정확하고, 4b 에서는 관측자가 같이
죽는다.** 그 차이를 발견 ⑧ 에서 본다. 지금 알아 두지 않으면 나중에 그래프의
빈 구간을 「값이 0」으로 잘못 읽는다.
---
# 2. 주입 4a — 워커 노드의 전원을 뽑는다
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
**되돌리기** — 터미널 A
```bash
virsh start kc-lab-2
```
## 2-1. `destroy` 와 `shutdown` 의 차이
| 명령 | 게스트에 무슨 일이 | 이 실험에 |
|---|---|---|
| `virsh shutdown` | ACPI 종료 신호 → kubelet 이 정상 종료 → 파드가 정리된다 | **쓰면 안 된다** |
| **`virsh destroy`** | **전원 차단.** 신호 없음. 마지막 상태가 그대로 얼어붙는다 | 이것이 「노드 상실」이다 |
`shutdown` 을 쓰면 쿠버네티스가 **정상적인 노드 이탈**로 처리해서
이 실험의 발견 ①·② 가 통째로 안 나온다.
## 2-2. 뽑는다
**하기** — 터미널 A
```bash
date '+%H:%M:%S 차단'
virsh destroy kc-lab-2
```
**실측** — [`02-worker-node-killed.txt`](../../evidence/a4-node-loss/02-worker-node-killed.txt)
```
차단 시각: 12:07:43
Domain 'kc-lab-2' destroyed
```
**시각을 반드시 적어 둔다.** 40초·5분 같은 숫자는 **이 시각에서 뺀 값**이다.
기준점이 없으면 뒤의 관찰은 그냥 나열이다.
---
# 3. 주입이 실제로 걸렸는지 확인한다
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
A-5·A-6 에서는 「규칙을 넣었는데 카운터가 0」이 실패였다. **이 실험의 검증
대상은 다르다.** 여기서 믿을 수 있는 것은 **하이퍼바이저**뿐이고, 쿠버네티스가
뭐라고 하든 그것은 결과이지 검증이 아니다.
## 3-1. VM 이 실제로 꺼졌나 — 이것이 유일한 주입 검증이다
**확인** — 터미널 A
```bash
virsh list --all
```
**형태**
```
1 kc-lab-1 running
- kc-lab-2 shut off
```
**어디를 봐야 하는가** — `shut off`. ID 가 `-` 로 바뀐 것도 같은 말이다.
**확인** — 정말 응답이 없나
```bash
ping -c 2 -W 2 192.168.122.12
```
**미검증** — 원 실행에는 이 확인이 없다. `0 received` 가 나오면 꺼진 것이다.
## 3-2. ★ 그런데 쿠버네티스는 아직 `Ready` 라고 말한다
**확인** — 터미널 B
```bash
sudo kubectl get node kc-lab-2
```
**형태**
```
NAME STATUS ROLES AGE VERSION
kc-lab-2 Ready <none> 12d v1.33.x+k3s1
```
**여기서 「주입이 안 걸렸다」고 결론 내리면 틀린다.** 기계는 3-1 에서 확인한
대로 꺼져 있다. 쿠버네티스가 아직 모를 뿐이다.
노드 상태와 사용자 경험을 **나란히** 봐야 이게 보인다. 터미널 B 에서:
```bash
sudo kubectl get node kc-lab-2
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
```
두 줄을 15초 간격으로 몇 번 친다. 손이 아프면 한 줄로 묶는다. **미검증**
```bash
while true; do
printf '%s node=%s 외부=%s\n' "$(date +%H:%M:%S)" \
"$(sudo kubectl get node kc-lab-2 --no-headers | awk '{print $2}')" \
"$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 \
https://auth.hyeonworks.com/realms/master)"
sleep 15
done
```
`Ctrl-C` 로 멈춘다.
**실측** — [`02-worker-node-killed.txt`](../../evidence/a4-node-loss/02-worker-node-killed.txt)
```
+15초 node=Ready | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 000
+30초 node=Ready | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 000
+45초 node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503
+60초 node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503
```
**어디를 봐야 하는가** — `+30초` 줄과 `+45초` 줄 사이. **노드 상태가 그때
넘어간다.**
**이 결과가 의미하는 것** — kube-controller-manager 는 kubelet 의 하트비트가
`node-monitor-grace-period`(이 실험대에서 **40초**) 동안 없어야 `NotReady` 로
바꾼다. 그 40초 동안 **쿠버네티스는 거짓말을 한다.** 그리고 사용자는 그
40초에도 이미 장애를 겪고 있다 — `000` 이 그 증거다.
> **노드 상태를 알림 근거로 삼으면 항상 늦는다.** 사용자가 먼저 안다.
## 3-3. 왜 처음 40초는 `503` 이 아니라 `000` 인가
```
000 curl 이 응답 자체를 못 받았다 = 타임아웃 또는 연결 실패
503 nginx·Traefik 은 살아 있고 뒤로 보낼 파드가 없다
```
엣지 nginx(`kc-lab-edge`) 의 upstream 에는 **두 노드가 다 들어 있다**
([`03-nginx`](../03-nginx/) 1절).
```
upstream k3s_traefik {
server 192.168.122.11:80;
server 192.168.122.12:80;
}
```
죽은 쪽으로 배분된 요청은 **응답도 거절도 못 받고** `--max-time 8` 에 걸린다.
> **★ 여기는 이 실험이 답을 못 남긴 자리다.** 증거 파일
> [`03-state-during-loss.txt`](../../evidence/a4-node-loss/03-state-during-loss.txt)
> 의 마지막 절 제목이 「진입점이 처음 40초간 000 이었던 이유 — nginx upstream」
> 인데 **그 아래가 비어 있다.** 명령이 아무것도 찍지 못했다.
> **당신은 지금 직접 볼 수 있다** — 터미널 C 에서. **미검증**
> ```bash
> sudo tail -f /var/log/nginx/error.log
> ```
> `upstream timed out` 이 `192.168.122.12` 에 대해 찍히면 그것이 답이다.
> nginx 에러 로그는 2048바이트에서 잘리므로, 잘려 보이면 access 로그를 본다.
---
# 4. 효과를 관찰한다 (4a)
## 4-1. ★ 죽은 파드가 산 파드보다 건강해 보인다
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,PHASE:.status.phase,READY:.status.containerStatuses[0].ready,NODE:.spec.nodeName
```
**실측** — [`03-state-during-loss.txt`](../../evidence/a4-node-loss/03-state-during-loss.txt)
```
a2-probe Running true kc-lab-2 <none>
keycloak-0 Running true kc-lab-2 <none>
keycloak-1 Running false kc-lab-1 <none>
postgres-7b474b88c8-2gf27 Running true kc-lab-2 <none>
```
**어디를 봐야 하는가** — `keycloak-0` 은 **꺼진 기계 위에서 `ready=true`**,
`keycloak-1` 은 **살아 있는데 `ready=false`.**
**이 결과가 의미하는 것**
| 파드 | 왜 |
|---|---|
| `keycloak-0` | kubelet 이 없어 **상태를 갱신할 수 없다.** 마지막으로 보고한 값이 얼어 있다 |
| `keycloak-1` | 살아서 **정직하게 보고한다** — DB 가 없으니 readiness 실패 |
> **파드 상태는 「지금 어떤가」가 아니라 「마지막으로 그렇게 들었다」이다.**
> 노드가 죽으면 그 노드 파드의 상태는 **화석**이 된다.
이유를 이벤트로 확인한다.
```bash
sudo kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20
```
**실측** — 같은 파일
```
10m Warning Unhealthy pod/keycloak-0 Readiness probe failed: Get "http://10.42.1.67:9000/health/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
3m15s Warning NodeNotReady pod/postgres-7b474b88c8-2gf27 Node is not ready
3m15s Warning NodeNotReady pod/keycloak-0 Node is not ready
3m15s Warning NodeNotReady pod/a2-probe Node is not ready
2m27s Warning Unhealthy pod/keycloak-1 Readiness probe failed: Get "http://10.42.0.35:9000/health/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
2s Warning Unhealthy pod/keycloak-1 Readiness probe failed: HTTP probe failed with statuscode: 503
```
**`keycloak-1` 의 실패가 두 종류다.** 처음에는 프로브 자체가 타임아웃되고
(`context deadline exceeded`), 나중에는 `503` 을 받는다. Keycloak 이 DB 없음을
스스로 판단해 답할 수 있게 된 것이다. **같은 「Unhealthy」라도 층이 다르다.**
**`Age` 를 반드시 같이 본다.** 노드를 뽑은 것은 `3m15s` 전인데 맨 위 줄은
`10m` 짜리다 — **주입보다 앞선 사건**이고, 앞 실험의 잔재다. 이벤트 목록은
시간대가 섞여 있으므로 **`Age` 로 먼저 걸러야** 내가 만든 일을 고를 수 있다.
그리고 주입 이후 `keycloak-0` 에 붙은 이벤트는 `NodeNotReady` **하나뿐**이다.
그것은 컨트롤러가 쓴 것이지 kubelet 이 쓴 것이 아니다. **kubelet 이 없으니
그 파드에 대해 말해 줄 주체가 없다** — 4-1 의 `ready=true` 가 화석인 이유다.
## 4-2. Prometheus 는 정확했다
**확인**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=up'
```
한 줄짜리 JSON 이 통째로 나온다. **처음 한 번은 그대로 본다.** 어떤 job 과
라벨이 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다. 읽기 좋게 자르려면
(`jq` 는 이 실험대에 없다) **미검증**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=up' \
| tr ',' '\n' | grep -E '"job":|"pod":|"node":|^"[0-9]'
```
**실측** — [`03-state-during-loss.txt`](../../evidence/a4-node-loss/03-state-during-loss.txt)
```
up{job=keycloak pod=keycloak-1 } = 1
up{job=keycloak pod=keycloak-0 } = 0
up{job=kubelet pod=- } = 1
up{job=kubelet pod=- } = 0
up{job=node-exporter pod=kc-lab-1 } = 1
up{job=node-exporter pod=kc-lab-2 } = 0
up{job=prometheus pod=- } = 1
```
**어디를 봐야 하는가** — `kc-lab-2` 쪽이 전부 `0`. **`kubelet` job 이 두 줄인
것**도 본다 — 노드마다 하나씩이라 하나는 1, 하나는 0 이다.
**이 결과가 의미하는 것** — `up` 은 **Prometheus 가 그 대상을 긁는 데
성공했는가**다. 대상이 사라졌으니 실패했고, 그 0 은 **사실**이다.
**A-2 와 정반대다.** A-2(DB 상실)에서는 `up=1` 인데 서비스가 죽어 있었다.
| | `up` 이 잡는가 |
|---|---|
| **대상이 사라짐** (노드 상실) | **잡는다** |
| **대상이 살아서 못 씀** (DB 상실) | **못 잡는다** |
Grafana 에서 같은 것을 그림으로 본다 —
[`a4-up-dropped-per-node.png`](../../evidence/a4-node-loss/a4-up-dropped-per-node.png).
## 4-3. 쿠버네티스가 노드에 무엇을 붙였나
**확인** — 읽는 형태
```bash
sudo kubectl describe node kc-lab-2 | grep -A3 Taints
```
값만 필요하면
```bash
sudo kubectl get node kc-lab-2 -o jsonpath='{.spec.taints}' ; echo
```
**실측** — [`03-state-during-loss.txt`](../../evidence/a4-node-loss/03-state-during-loss.txt)
```
=== 노드 taint — 쿠버네티스가 붙인 것 ===
node.kubernetes.io/unreachable=:NoSchedule
node.kubernetes.io/unreachable=:NoExecute
```
**어디를 봐야 하는가** — 두 종류가 붙어 있다.
| taint | 뜻 |
|---|---|
| `NoSchedule` | **새 파드를 여기 보내지 마라** |
| `NoExecute` | **이미 있는 파드도 쫓아내라** |
`NoExecute` 가 붙었는데 왜 아무 일도 안 일어나는가 — 다음 절.
## 4-4. 축출은 왜 5분 뒤인가
**확인** — 파드에 붙어 있는 관용을 본다
```bash
sudo kubectl -n keycloak-lab describe pod keycloak-1 | grep -A4 Tolerations
```
**실측** — [`04-eviction-timing.txt`](../../evidence/a4-node-loss/04-eviction-timing.txt)
```
=== NoExecute taint 의 tolerationSeconds — 언제 축출되는가 ===
node.kubernetes.io/not-ready NoExecute tolerationSeconds=300
node.kubernetes.io/unreachable NoExecute tolerationSeconds=300
```
**어디를 봐야 하는가** — `tolerationSeconds=300`. **당신이 쓴 적 없는 값**이다.
쿠버네티스가 모든 파드에 자동으로 붙인다.
```
기계 정지
│ 40초 node-monitor-grace-period → 노드 NotReady
│ +300초 tolerationSeconds (NoExecute) → 파드 축출 시작
총 약 5분 40초 동안 쿠버네티스는 아무것도 하지 않는다
```
## 4-5. 그 5분을 실제로 기다린다
**확인** — 30초 간격으로 본다. 손으로 치기 싫으면 `watch`
```bash
watch -n 30 'sudo kubectl -n keycloak-lab get pods -o wide'
```
**실측** — [`04-eviction-timing.txt`](../../evidence/a4-node-loss/04-eviction-timing.txt)
```
+240초 a2-probe:Running keycloak-0:Running keycloak-1:Running postgres-7b474b88c8-2gf27:Running
+270초 a2-probe:Terminating keycloak-0:Terminating keycloak-1:Running postgres-7b474b88c8-2gf27:Terminating postgres-7b474b88c8-9cmsv:Pending
+300초 a2-probe:Terminating keycloak-0:Terminating keycloak-1:Running postgres-7b474b88c8-2gf27:Terminating postgres-7b474b88c8-9cmsv:Pending
```
**어디를 봐야 하는가** — `+240초` 와 `+270초` 사이. 두 가지가 동시에 일어난다.
- `kc-lab-2` 의 파드들이 **`Terminating`** 으로 바뀐다
- **새 이름의 postgres 파드**(`...-9cmsv`)가 생기고 **`Pending`** 이다
**이 결과가 의미하는 것** — 축출이 시작됐다. 그런데 `Terminating` 이 안 끝나고,
새 파드는 뜨지 못한다. 두 문제는 원인이 다르다 — 4-6 과 4-7.
## 4-6. 새 파드는 왜 영원히 `Pending` 인가
**확인** — 파드에게 직접 물어본다
```bash
sudo kubectl -n keycloak-lab get pods --field-selector=status.phase=Pending
sudo kubectl -n keycloak-lab describe pod postgres-7b474b88c8-9cmsv | grep -A6 Events
```
이름은 매번 다르므로 위 `get` 으로 먼저 확인하고 옮겨 적는다.
**실측** — [`05-recovery.txt`](../../evidence/a4-node-loss/05-recovery.txt)
```
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 4m45s default-scheduler 0/2 nodes are available: 1 node(s) didn't match PersistentVolume's node affinity, 1 node(s) had untolerated taint(s). no new claims to deallocate, preemption: 0/2 nodes are available: 2 Preemption is not helpful for scheduling.
```
**어디를 봐야 하는가** — **`0/2 nodes are available` 뒤에 이유가 노드 수만큼
나열된다.** 이 줄 하나에 두 노드의 사연이 다 들어 있다.
```
kc-lab-2 → had untolerated taint(s) (죽은 노드)
kc-lab-1 → didn't match PersistentVolume's node affinity
```
**이 결과가 의미하는 것** — **1-3 에서 이미 알고 있던 것이 그대로 벌어졌다.**
볼륨이 `kc-lab-2` 에 못박혀 있어서 살아 있는 노드로 못 간다. 죽은 노드에는
taint 때문에 못 간다. **갈 곳이 없다.**
> 이건 결함이 아니라 **조건**이다. 이 실험대는 그걸 알고 `local-path` 를
> 골랐다. 운영이라면 네트워크 스토리지나 DB 복제가 이 자리를 메워야 한다.
> 노드가 영영 안 돌아오면 남는 길은 **백업 복원(D-1)** 뿐이다.
## 4-7. StatefulSet 은 대체 파드를 만들지 않는다
**확인**
```bash
sudo kubectl -n keycloak-lab get statefulset keycloak
sudo kubectl -n keycloak-lab get pods | grep keycloak
```
**실측** — [`05-recovery.txt`](../../evidence/a4-node-loss/05-recovery.txt)
```
keycloak 2 <none> 1
keycloak-0 1/1 Terminating 0 30m
keycloak-1 0/1 Running 0 143m
```
**어디를 봐야 하는가** — `DESIRED=2` 인데 `CURRENT=1`. 그리고 `keycloak-0` 이
**30분째 `Terminating`.**
**이 결과가 의미하는 것**
| | |
|---|---|
| StatefulSet 의 계약 | **같은 이름의 파드는 클러스터에 하나뿐**이어야 한다 |
| 컨트롤 플레인이 아는 것 | 노드가 안 보인다 = **파드가 죽었는지 확신할 수 없다** |
| 그래서 | 옛 파드를 확실히 지우기 전엔 새 `keycloak-0` 을 못 만든다 |
`Terminating` 이 안 끝나는 사슬은 이렇다.
```
파드 삭제 요청
└─ kubelet 이 컨테이너를 멈추고 "지웠다"고 보고해야 끝난다
└─ kubelet 이 없다 → 보고가 없다 → 영원히 Terminating
```
**Deployment 였다면 즉시 새 파드를 만든다.** 이름이 아무래도 되기 때문이다
(postgres 가 실제로 그랬다 — 4-5 에서 새 이름의 파드가 생겼다. 다만 갈 곳이
없었을 뿐이다). **StatefulSet 의 「안정된 이름」이라는 이득의 반대편 비용**이
여기다.
> **강제로 진행시키는 명령이 있지만, 이 가이드에서는 치지 않는다.**
> ```
> kubectl -n keycloak-lab delete pod keycloak-0 --grace-period=0 --force
> ```
> 이것은 **컨테이너가 실제로 죽었는지 모른 채 API 에서 지우는 것**이다.
> 노드가 사실은 살아 있고 네트워크만 끊긴 것이라면 **같은 이름의 파드 둘이
> 동시에 존재**하게 된다 — 그게 split brain 이고, 이 실험대에서는 5절의
> `virsh start` 가 훨씬 안전하고 빠르다.
---
# 5. 복구 (4a)
## 5-1. 전원을 다시 넣는다
**하기** — 터미널 A
```bash
date '+%H:%M:%S 재기동'
virsh start kc-lab-2
```
**실측** — [`05-recovery.txt`](../../evidence/a4-node-loss/05-recovery.txt)
```
재기동 시각: 12:16:31
Domain 'kc-lab-2' started
```
## 5-2. 얼마나 걸리나
**확인** — 30초 간격
```bash
sudo kubectl get nodes
sudo kubectl -n keycloak-lab get pods
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
```
**실측** — 같은 파일
```
+30초 node=Ready | Running 파드 3 개 | 외부 HTTP 503
+60초 node=Ready | Running 파드 3 개 | 외부 HTTP 200
→ 서비스 복귀
```
**60초. 사람 개입 없이 전부 제자리로 돌아왔다.** `Terminating` 이던 파드도,
`Pending` 이던 파드도 kubelet 이 돌아오자 정리됐다.
> **이 60초는 MTTR 이 아니다.** `virsh start` 를 친 **뒤**의 시간이다.
> 실제 장애 구간은 **12:07:43(차단) → 12:17:31(서비스 복귀) ≈ 10분**이고,
> 그 대부분은 사람이 관찰하고 결정하는 데 쓴 시간이다. **현실의 MTTR 도
> 대개 그렇다.**
>
> 그리고 본문의 `40초`와 `5분`은 **쿠버네티스 기본값을 인용한 것**이며,
> 관측된 전이 시점(+45초, +270초)이 그 값과 모순되지 않는다는 것까지가
> 이 실험이 말할 수 있는 범위다. 값 자체를 측정한 것은 아니다.
## 5-3. 4b 로 넘어가기 전 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| VM | `virsh list --all` | 둘 다 `running` |
| 노드 | `sudo kubectl get nodes` | 둘 다 `Ready` |
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 전부 `1/1 Running`, `Pending` 없음 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
**실측** — [`06-control-plane-inventory.txt`](../../evidence/a4-node-loss/06-control-plane-inventory.txt)
```
=== 복구 확인 ===
keycloak-0 1/1 Running 0 68s
keycloak-1 1/1 Running 0 144m
postgres-7b474b88c8-9cmsv 1/1 Running 0 4m20s
```
**`postgres` 의 이름이 바뀌어 있다** (`-2gf27` → `-9cmsv`). 4-5 에서 생겼다가
`Pending` 이던 그 파드가 노드가 살아나자 그대로 뜬 것이다. **`keycloak-0` 은
이름이 그대로** — StatefulSet 이라 그렇다. 두 컨트롤러의 차이가 이름에 남는다.
여기까지 안 돌아왔으면 **4b 로 넘어가지 않는다.** 두 고장이 겹치면 무엇이
원인인지 못 가린다.
---
# 6. 4b — 이번엔 컨트롤 플레인을 뽑는다
## 6-1. 먼저 인벤토리 — 그게 곧 영향 범위다
**확인**
```bash
sudo kubectl get pods -A -o wide --field-selector spec.nodeName=kc-lab-1
```
**실측** — [`06-control-plane-inventory.txt`](../../evidence/a4-node-loss/06-control-plane-inventory.txt)
```
keycloak-lab keycloak-1
kube-system coredns-54996dc9b4-8k8fj
kube-system helm-install-traefik-crd-q29b5
kube-system local-path-provisioner-77b9867795-g27z8
kube-system metrics-server-6dc596dfb8-7xxq4
kube-system svclb-traefik-5eb6a9a1-qwwk5
kube-system traefik-5d6fcf895-wpfhr
observability grafana-845b5678cf-b6gvc
observability node-exporter-9qk9w
observability prometheus-6774f94f7c-pzr2t
```
**어디를 봐야 하는가** — `traefik`. **진입점이 여기 있다.**
**확인** — 진입점이 몇 개인가
```bash
sudo kubectl -n kube-system get deploy traefik
```
**실측** — 같은 파일
```
traefik 1 1
```
**이 결과가 의미하는 것** — **`replicas=1`. 진입점이 단일 장애점이다.**
이 노드를 뽑으면 클러스터로 들어갈 문이 사라진다. 4a 와 결과가 다른 이유가
여기서 이미 예측된다.
## 6-2. 뽑는다
**되돌리기** — 터미널 A
```bash
virsh start kc-lab-1
```
**하기** — 터미널 A
```bash
date '+%H:%M:%S 차단'
virsh destroy kc-lab-1
```
**실측** — [`07-control-plane-loss.txt`](../../evidence/a4-node-loss/07-control-plane-loss.txt)
```
차단 시각: 12:18:08
Domain 'kc-lab-1' destroyed
```
**터미널 B 가 여기서 죽는다.** SSH 세션이 그대로 끊긴다. 놀랄 일이 아니다.
## 6-3. 주입 검증 — `kubectl` 이 죽은 것이 곧 증거다
**확인** — 터미널 A 나 C 에서
```bash
sudo kubectl get nodes
```
**실측** — 같은 파일
```
kubectl: Unable to connect to the server: dial tcp
```
**어디를 봐야 하는가** — `Unable to connect to the server`. API 서버가
`kc-lab-1:6443` 에 있었으므로 **당연한 결과**다.
**이 결과가 의미하는 것** — 4a 에서는 「쿠버네티스가 뭐라고 하는가」를 물을 수
있었다. **여기서는 물어볼 상대 자체가 없다.** 이 실험의 관찰 도구가 통째로
바뀐다.
## 6-4. 밖에서는 어떻게 보이나
**확인** — 터미널 C. 20초 간격으로 두 주소를 본다
```bash
curl -s -o /dev/null -w 'auth=%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w 'grafana=%{http_code}\n' --max-time 8 https://grafana.hyeonworks.com/
```
**실측** — [`07-control-plane-loss.txt`](../../evidence/a4-node-loss/07-control-plane-loss.txt)
```
+20초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
+60초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
+120초 외부 auth=000 grafana=502 | kubectl: Unable to connect to the server: dial tcp
+160초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
```
**어디를 봐야 하는가** — `+120초` 의 **`grafana=502` 한 줄.** 나머지는 전부
`000` 인데 여기만 다르다.
**이 결과가 의미하는 것** — **`000` 과 `503`/`502` 는 서로 다른 층의 고장을
가리킨다.**
| 코드 | 어디까지 살아 있는가 |
|---|---|
| **`503`** (4a) | nginx·Traefik 은 살아 있고 **뒤에 보낼 파드가 없다** |
| **`502`** (4b, 한 번) | nginx 가 **연결 실패를 제때 판정해** 자기 힘으로 502 를 만들었다 |
| **`000`** (4b, 대부분) | nginx 가 죽은 주소를 기다리다 **우리 `--max-time 8` 이 먼저 끝났다** |
`502` 가 한 번이라도 찍혔다는 것이 **nginx 는 살아 있었다**는 증거다.
같은 고장인데 코드가 흔들리는 이유는 **타임아웃 경주**다.
## 6-5. ★ 그런데 워크로드는 살아 있다
`kubectl` 이 없으니 **노드의 컨테이너 런타임에 직접 묻는다.**
**확인** — 터미널 A 에서 살아남은 노드로
```bash
ssh kc-lab-2 'sudo crictl ps --name keycloak'
```
**실측** — [`07-control-plane-loss.txt`](../../evidence/a4-node-loss/07-control-plane-loss.txt)
```
CONTAINER IMAGE CREATED STATE NAME ATTEMPT POD ID POD NAMESPACE
e5f777900b762 60e153026e8f5 4 minutes ago Running keycloak 0 640d4dafaefb3 keycloak-0 keycloak-lab
```
**어디를 봐야 하는가** — `STATE` 가 `Running`, `ATTEMPT` 가 `0`.
**API 서버가 없는데도 컨테이너는 돌고 있다.**
**이 결과가 의미하는 것**
```
죽은 것: API 서버 · 스케줄러 · coredns · Traefik · Prometheus · Grafana
산 것: keycloak-0 · postgres · containerd
문제: 들어갈 문(Traefik)이 없다
```
> **컨트롤 플레인 상실 ≠ 워크로드 상실.**
> 이미 떠 있는 것은 계속 돈다. **새로 뜨거나 옮기거나 고치는 것이 안 될 뿐.**
전체 목록도 본다.
```bash
ssh kc-lab-2 'sudo crictl ps'
```
`crictl` 이 소켓을 못 찾으면 k3s 의 것을 직접 준다. **미검증**
```bash
ssh kc-lab-2 'sudo crictl --runtime-endpoint unix:///run/k3s/containerd/containerd.sock ps'
```
## 6-6. 관측자가 같이 죽으면 0 이 아니라 구멍이 남는다
**지금은 확인할 수 없다.** Prometheus 도 Grafana 도 `kc-lab-1` 에 있었다.
**그것이 이 발견이다.**
```
대상이 죽음 → up = 0 → "언제 죽었는지" 알 수 있다
관측자가 죽음 → 데이터 없음 → "그때 무슨 일이 있었는지" 모른다
```
복구 뒤에 Grafana 에서 `up` 그래프를 다시 열어 **12:1812:23 구간이 0 이
아니라 빈칸**인 것을 확인한다. 6-8 에서 한다.
## 6-7. 복구
**하기** — 터미널 A
```bash
date '+%H:%M:%S 재기동'
virsh start kc-lab-1
```
**실측** — [`08-control-plane-recovery.txt`](../../evidence/a4-node-loss/08-control-plane-recovery.txt)
```
재기동: 12:23:39
Domain 'kc-lab-1' started
+30초 외부=502 | kc-lab-1=Ready kc-lab-2=Ready
+60초 외부=200 | kc-lab-1=Ready kc-lab-2=Ready
→ 서비스 복귀 (총 60초)
```
**여기서도 60초.** `+30초` 의 `502` 는 **nginx 가 먼저 살아나고 Traefik 이
아직 안 뜬** 중간 상태다. 4b 내내 보던 `000` 과 층이 다르다.
## 6-8. 복구 후에 확인할 것
**확인**
```bash
sudo kubectl -n keycloak-lab get pods
```
**실측** — 같은 파일
```
keycloak-0 1/1 Running 0 7m57s
keycloak-1 1/1 Running 1 (<invalid> ago) 151m
postgres-7b474b88c8-9cmsv 1/1 Running 0 11m
```
**어디를 봐야 하는가** — 세 가지가 한 줄에 있다.
- `keycloak-1` 의 `RESTARTS` 가 **1** — `kc-lab-1` 위에 있었으니 당연하다
- `AGE` 가 `151m` 인데 재시작은 방금 — **AGE 는 파드가 만들어진 시각**이지
컨테이너가 시작한 시각이 아니다
- **`(<invalid> ago)`** — 재시작 시각이 API 서버 시계보다 미래로 보일 때 나온다.
**원인은 이 실험이 확정하지 않았다.** 잠시 뒤 다시 치면 정상 값으로 바뀐다
**확인** — Grafana. 6-6 에서 예고한 구멍
```
브라우저로 Grafana 를 열어 up{job="keycloak"} 그래프를 12:1512:30 으로 본다
```
**실측** — [`a4-up-dropped-per-node.png`](../../evidence/a4-node-loss/a4-up-dropped-per-node.png)
그림에서 12:1812:23 은 **선이 0 으로 내려간 것이 아니라 아예 끊겨 있다.**
**Grafana 로그인이 풀려 있다.** Grafana 데이터가 `emptyDir` 이라 파드
재시작에 사라진다. Prometheus 는 PVC 라 지표가 남았다 — 다만 관측자가 죽어
있던 구간의 데이터는 애초에 수집되지 않았다. **의도한 설계대로 동작했고,
그 설계의 한계도 함께 드러났다.**
Prometheus 를 `port-forward` 로 보고 있었다면 **다시 연결해야 한다** (실측
기록의 마지막 줄이 그것이다).
## 6-9. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| VM | `virsh list --all` | 둘 다 `running` |
| 노드 | `sudo kubectl get nodes` | 둘 다 `Ready` |
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | 전부 `1/1 Running` |
| 진입점 | `sudo kubectl -n kube-system get deploy traefik` | `1/1` |
| Service | `sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
| 클러스터 뷰 | `sudo kubectl -n keycloak-lab logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)` |
| 관측 | Prometheus `up` | 전부 1 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
> **이 실험이 재지 않은 것** — 노드가 **영영 안 돌아오는** 경우는 재지 않았다.
> `local-path` PVC 가 그 노드와 함께 사라진 상태에서의 복구는 **D-1(백업·복원)**
> 의 주제다.
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `virsh` 가 도메인을 못 찾는다 | `qemu:///session` 을 보고 있다 | `virsh uri` — `system` 이어야 한다 |
| VM 을 껐는데 노드가 `Ready` | **정상.** `node-monitor-grace-period` 40초 | `virsh list --all` 로 전원을 먼저 본다 — 3-1 |
| 5분이 지나도 축출이 안 온다 | 40초 + `tolerationSeconds=300` = **5분 40초** | `describe pod \| grep -A4 Tolerations` |
| 새 파드가 계속 `Pending` | **PVC 가 죽은 노드에 못박혀 있다** | `describe pod` 의 `FailedScheduling` — 4-6 |
| `keycloak-0` 이 30분째 `Terminating` | StatefulSet + kubelet 없음. **정상이다** | `get statefulset` 의 `CURRENT` — 4-7 |
| `--force` 로 지우고 싶다 | 노드가 살아 있으면 **중복 실행**이 된다 | 치지 말고 `virsh start` — 4-7 |
| `kubectl` 이 전혀 안 된다 (4b) | **API 서버가 죽은 노드에 있었다.** 정상 | `ssh kc-lab-2 'sudo crictl ps'` — 6-5 |
| `crictl` 이 소켓을 못 찾는다 | k3s 는 자기 containerd 소켓을 쓴다 | `--runtime-endpoint unix:///run/k3s/containerd/containerd.sock` |
| `503` 을 기대했는데 `000` | 층이 다르다. nginx 가 죽은 주소를 기다린다 | `--max-time` 을 늘려 보면 `502` 가 나온다 — 6-4 |
| `curl` 이 몇 분씩 안 끝난다 | `--max-time` 을 안 줬다 | 모든 외부 확인에 `--max-time 8` |
| 그래프의 그 구간이 0 으로 보인다 | **0 이 아니라 데이터 없음이다** | 점 사이가 이어져 있는지 본다 — 6-6 |
| Grafana 로그인이 풀렸다 | 데이터가 `emptyDir` | 재시작마다 그렇다. PVC 로 바꾸면 남는다 |
| Prometheus 가 갑자기 안 보인다 | `port-forward` 가 끊겼다 | 다시 연다 |
| `RESTARTS` 가 `1 (<invalid> ago)` | 재시작 직후에 나온다. **원인 미확정** | 잠시 뒤 다시 친다 — 6-8 |
| 4b 결과가 4a 와 섞인다 | 4a 복구를 확인하지 않고 넘어갔다 | 5-3 확인표를 통과한 뒤 시작 |
---
# 이 실험이 남기는 구성 숙제
관찰만 하고 끝내면 아깝다. **두 가지는 지금 고칠 수 있다.**
| 발견 | 고치는 방향 |
|---|---|
| Traefik `replicas=1` 이라 진입점이 단일 장애점 | `replicas=2` 로 늘리거나 DaemonSet 으로 |
| Grafana 가 `emptyDir` 이라 재시작마다 세션이 사라짐 | PVC 를 붙인다 |
| 관측 스택이 실험 대상 노드에 함께 있음 | 노드가 둘뿐이라 완전히는 못 피한다. **아는 것이 먼저** |
---
# 다음
| 실험 | A-4 가 남긴 질문 |
|---|---|
| [A-5](../../experiment-a5-asymmetric-partition.md) 비대칭 파티션 | 여기서는 노드가 **완전히** 사라졌다. **부분 단절은 더 고약하다** |
| [D-1](../../experiment-d1-backup-restore.md) 백업·복구 | **PVC 가 노드에 묶여 있다.** 노드가 영영 안 돌아오면 백업이 유일한 길 |
| [A-6](../../experiment-a6-latency-injection.md) 지연 주입 | 여기서는 `up=0` 이 정확했다. **느려짐은 `up` 이 못 잡는다** |
| 전부 | **주입 검증의 기준을 먼저 정한다.** 여기서는 쿠버네티스가 아니라 하이퍼바이저가 기준이었다 |