Files
keycloak-pattern/docs/experiment-a4-node-loss.md
T
DongHyeonkaandClaude Opus 5 74c9b3cea7 docs: replace prose placeholders in reproduction steps with executable commands
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>
2026-09-04 17:01:54 +09:00

15 KiB
Raw Blame History

A-4 — 노드가 통째로 죽으면 어떻게 되는가

브랜치 feature/keycloak-a4-node-loss · 증거 docs/evidence/a4-node-loss/ · 2026-09-04 12:0712:24 KST

선행: A-2 · A-3

두 판본으로 나눠 실행했다.

죽인 노드 성격
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-0ready=true, keycloak-1ready=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 으로 떨어졌다

발견 ③ — 축출은 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-0Terminating 에서 영원히 멈춰 있고, 대체 파드가 안 생긴다.

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)이 없다

컨트롤 플레인 상실은 워크로드 상실이 아니다. 이미 떠 있는 것은 계속 돈다. 새로 뜨거나 옮기거나 고치는 것이 안 될 뿐.

발견 ⑦ — 000503 의 차이

코드
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-periodtolerationSeconds

   기계 정지
      │
      │  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 — 재시작마다 로그인 세션이 사라진다