# 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` 는 종료 신호를 보내지 않는다. **전원을 뽑는 것과 같다.** --- ## 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 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 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 | 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`** — 재시작마다 로그인 세션이 사라진다 |