The audit found ~80 placeholders, and the damaging ones were where the measuring apparatus itself was prose rather than a command: a6 "( curl ... ) & 를 20개 띄우고 wait" — the 22.2s headline came from this a3 "<로그인 반복, sid 를 /tmp/sids 에>" — the whole RPO measurement a3 "<sid 목록>" — the control it is compared against a5 "<수신 파드IP>" — the injection a8 writes /tmp/tok, reads /tmp/rt — self-inconsistent, sent an empty token b3 $KC / $RT / $NEW never assigned c2 bare kcadm.sh with no kubectl exec a1 conntrack tuples written by hand, though the direction flips per restart Each is now a shell-expandable form: pod IPs from jsonpath, the admin password from the secret, ids from kcadm --format csv, conntrack tuples derived from "conntrack -L" with awk rather than transcribed. Then the rewritten commands were executed against the live cluster, and one of them failed — the 20-way load generator, written as "kubectl run --rm -i", lost its output stream twice in a row. That is a trap this series already hit once, and the rewrite reintroduced it. A-6 now uses a resident probe pod that collects into a file and is cat-ed once; verified 20/20 lines. Evidence: docs/evidence/followup/05-command-reproducibility.txt Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 KiB
A-4 — 노드가 통째로 죽으면 어떻게 되는가
브랜치 feature/keycloak-a4-node-loss ·
증거 docs/evidence/a4-node-loss/ ·
2026-09-04 12:07–12:24 KST
두 판본으로 나눠 실행했다.
| 죽인 노드 | 성격 | |
|---|---|---|
| 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 <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
주입
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 의 "안정된 이름"이라는 이득의 반대편 비용이 여기다.
강제로 진행시키려면 사람이 개입해야 한다.
kubectl -n keycloak-lab delete pod keycloak-0 --grace-period=0 --force
위험한 명령이다. 노드가 사실은 살아 있고 네트워크만 끊긴 것이라면 같은 이름의 파드 둘이 동시에 존재하게 된다 (split brain).
복구
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 ← 진입점이 한 개다
주입과 관찰
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
발견 ⑥ — 워크로드는 살아 있는데 길이 없다
살아남은 노드에서 직접 확인했다.
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 → "언제 죽었는지" 알 수 있다
관측자가 죽음 → 데이터 없음 → "그때 무슨 일이 있었는지" 모른다
계획 단계에서 "관측 스택은 죽이지 않는 노드에 둔다"고 정했던 이유를 실제로 확인한 셈이다. 다만 노드가 둘뿐이라 완전히 피할 수는 없다.
복구
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 |
터미널 원문 |
02-worker-node-killed.txt |
터미널 원문 |
03-state-during-loss.txt |
터미널 원문 |
04-eviction-timing.txt |
터미널 원문 |
05-recovery.txt |
터미널 원문 |
06-control-plane-inventory.txt |
터미널 원문 |
07-control-plane-loss.txt |
터미널 원문 |
08-control-plane-recovery.txt |
터미널 원문 |
a4-up-dropped-per-node.png |
스크린샷 |
파일별 상세는 evidence/a4-node-loss/README.md.
6. 재현 절차 (명령어)
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 — 재시작마다 로그인 세션이 사라진다 |
