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

40 KiB
Raw Blame History

A-4 재현 가이드 — 기계 전원을 뽑고 쿠버네티스가 언제 알아채는지 직접 본다

해설 문서: docs/experiment-a4-node-loss.md · 증거 원문: docs/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 statefulsetCURRENT
kubectl 이 죽어도 컨테이너는 도는 것 kc-lab-2 에서 crictl ps
관측자가 같이 죽으면 0 이 아니라 구멍이 남는 것 Grafana

전제

  • 05-keycloak · 06-observability 가 끝나 있다.
  • 명령을 치는 곳이 세 군데다. 이 실험은 그 구별이 곧 내용이다.
터미널 어디 무엇을
A test-server (VM 호스트) virsh — 전원을 뽑고 다시 넣는다
B kc-lab-1 sudo kubectl — 관찰. 4b 에서는 이 터미널이 죽는다
C test-server 밖에서 curl. 사용자 시점
  • 터미널 A 에서 virsh 가 시스템 하이퍼바이저를 보고 있어야 한다.
    export LIBVIRT_DEFAULT_URI=qemu:///system
    virsh uri
    
    qemu:///system 이 아니면 VM 이 안 보인다. 00-lab-host 5절.
  • 4b 에서는 kc-lab-2 에도 붙는다. 터미널 A 와 같은 기계에서 ssh kc-lab-2. 이름이 안 풀리면 ssh 192.168.122.12.

주의 — 이건 기계를 끄는 실험이다

virsh destroy종료 신호를 보내지 않는다. 전원 코드를 뽑는 것과 같다. 게스트 파일시스템이 더러운 채로 멈춘다. 실험대에서만 한다.

전 구간 약 40분이다. 4a 에서 축출을 보려면 그것만 7분을 기다려야 한다. 어느 시점에서든 그만두려면 터미널 A 에서 한 줄이면 된다.

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

virsh list --all

실측01-baseline.txt

--------------------------
 1    kc-lab-1   running
 2    kc-lab-2   running

어디를 봐야 하는가 — 둘 다 running. 앞의 숫자는 도메인 ID 이며 VM 을 껐다 켜면 바뀐다. 이름으로 다룬다.

1-2. 노드와 파드 배치

확인 — 터미널 B

sudo kubectl get nodes
sudo kubectl -n keycloak-lab get pods -o wide

실측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-2keycloak-0 과 postgres 가 함께 있다. 그래서 4a 는 「Keycloak 한 대를 잃는 실험」이 아니라 **「Keycloak 한 대와 DB 를 동시에 잃는 실험」**이다. 배치가 다르면 결과도 다르다 — 먼저 확인한다.

a2-probe 는 A-2 에서 띄워 두고 안 지운 상주 파드다. 당신 환경에는 없을 수 있다. 없어도 이 실험에는 지장이 없다.

1-3. ★ 볼륨이 어느 노드에 못박혀 있나

이 한 줄이 뒤의 결과를 이미 결정한다. 4a 에서 「새 파드가 왜 영원히 Pending 인가」의 답이 여기 있다.

확인 — 어떤 PVC 가 있나

sudo kubectl -n keycloak-lab get pvc

확인 — 그 PVC 뒤의 PV 가 어느 노드를 요구하나. 먼저 읽는 형태로 한 번 본다

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]

값만 필요하면 뽑는 형태로 줄인다.

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

=== 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 로 충분하다

curl -I --max-time 8 https://auth.hyeonworks.com/realms/master

형태

HTTP/2 200
content-type: application/json

여러 번 재서 비교할 것이므로, 이제부터는 코드만 뽑는다.

curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master

실측01-baseline.txt

=== 서비스 정상 확인 ===
  https://auth.hyeonworks.com/realms/master  HTTP 200

--max-time 을 반드시 준다. 4b 에서 이 값이 없으면 curl 이 몇 분씩 매달린다. 그리고 타임아웃이 곧 결과다 — 뒤에서 000 이 나오는 이유가 그것이다.

1-5. 관측자가 어디 있나 — 미리 알아 둔다

확인

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

virsh start kc-lab-2

2-1. destroyshutdown 의 차이

명령 게스트에 무슨 일이 이 실험에
virsh shutdown ACPI 종료 신호 → kubelet 이 정상 종료 → 파드가 정리된다 쓰면 안 된다
virsh destroy 전원 차단. 신호 없음. 마지막 상태가 그대로 얼어붙는다 이것이 「노드 상실」이다

shutdown 을 쓰면 쿠버네티스가 정상적인 노드 이탈로 처리해서 이 실험의 발견 ①·② 가 통째로 안 나온다.

2-2. 뽑는다

하기 — 터미널 A

date '+%H:%M:%S 차단'
virsh destroy kc-lab-2

실측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

virsh list --all

형태

 1    kc-lab-1   running
 -    kc-lab-2   shut off

어디를 봐야 하는가shut off. ID 가 - 로 바뀐 것도 같은 말이다.

확인 — 정말 응답이 없나

ping -c 2 -W 2 192.168.122.12

미검증 — 원 실행에는 이 확인이 없다. 0 received 가 나오면 꺼진 것이다.

3-2. ★ 그런데 쿠버네티스는 아직 Ready 라고 말한다

확인 — 터미널 B

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 에서:

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초 간격으로 몇 번 친다. 손이 아프면 한 줄로 묶는다. 미검증

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

  +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 1절).

upstream k3s_traefik {
	server 192.168.122.11:80;
	server 192.168.122.12:80;
}

죽은 쪽으로 배분된 요청은 응답도 거절도 못 받고 --max-time 8 에 걸린다.

★ 여기는 이 실험이 답을 못 남긴 자리다. 증거 파일 03-state-during-loss.txt 의 마지막 절 제목이 「진입점이 처음 40초간 000 이었던 이유 — nginx upstream」 인데 그 아래가 비어 있다. 명령이 아무것도 찍지 못했다. 당신은 지금 직접 볼 수 있다 — 터미널 C 에서. 미검증

sudo tail -f /var/log/nginx/error.log

upstream timed out192.168.122.12 에 대해 찍히면 그것이 답이다. nginx 에러 로그는 2048바이트에서 잘리므로, 잘려 보이면 access 로그를 본다.


4. 효과를 관찰한다 (4a)

4-1. ★ 죽은 파드가 산 파드보다 건강해 보인다

확인

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

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 실패

파드 상태는 「지금 어떤가」가 아니라 「마지막으로 그렇게 들었다」이다. 노드가 죽으면 그 노드 파드의 상태는 화석이 된다.

이유를 이벤트로 확인한다.

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 는 정확했다

확인

sudo kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=up'

한 줄짜리 JSON 이 통째로 나온다. 처음 한 번은 그대로 본다. 어떤 job 과 라벨이 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다. 읽기 좋게 자르려면 (jq 는 이 실험대에 없다) 미검증

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

  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 이다.

이 결과가 의미하는 것upPrometheus 가 그 대상을 긁는 데 성공했는가다. 대상이 사라졌으니 실패했고, 그 0 은 사실이다.

A-2 와 정반대다. A-2(DB 상실)에서는 up=1 인데 서비스가 죽어 있었다.

up 이 잡는가
대상이 사라짐 (노드 상실) 잡는다
대상이 살아서 못 씀 (DB 상실) 못 잡는다

Grafana 에서 같은 것을 그림으로 본다 — a4-up-dropped-per-node.png.

4-3. 쿠버네티스가 노드에 무엇을 붙였나

확인 — 읽는 형태

sudo kubectl describe node kc-lab-2 | grep -A3 Taints

값만 필요하면

sudo kubectl get node kc-lab-2 -o jsonpath='{.spec.taints}' ; echo

실측03-state-during-loss.txt

=== 노드 taint — 쿠버네티스가 붙인 것 ===
  node.kubernetes.io/unreachable=:NoSchedule
  node.kubernetes.io/unreachable=:NoExecute

어디를 봐야 하는가 — 두 종류가 붙어 있다.

taint
NoSchedule 새 파드를 여기 보내지 마라
NoExecute 이미 있는 파드도 쫓아내라

NoExecute 가 붙었는데 왜 아무 일도 안 일어나는가 — 다음 절.

4-4. 축출은 왜 5분 뒤인가

확인 — 파드에 붙어 있는 관용을 본다

sudo kubectl -n keycloak-lab describe pod keycloak-1 | grep -A4 Tolerations

실측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

watch -n 30 'sudo kubectl -n keycloak-lab get pods -o wide'

실측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 인가

확인 — 파드에게 직접 물어본다

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

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 은 대체 파드를 만들지 않는다

확인

sudo kubectl -n keycloak-lab get statefulset keycloak
sudo kubectl -n keycloak-lab get pods | grep keycloak

실측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-030분째 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

date '+%H:%M:%S 재기동'
virsh start kc-lab-2

실측05-recovery.txt

재기동 시각: 12:16:31
Domain 'kc-lab-2' started

5-2. 얼마나 걸리나

확인 — 30초 간격

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

=== 복구 확인 ===
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. 먼저 인벤토리 — 그게 곧 영향 범위다

확인

sudo kubectl get pods -A -o wide --field-selector spec.nodeName=kc-lab-1

실측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. 진입점이 여기 있다.

확인 — 진입점이 몇 개인가

sudo kubectl -n kube-system get deploy traefik

실측 — 같은 파일

traefik   1     1

이 결과가 의미하는 것replicas=1. 진입점이 단일 장애점이다. 이 노드를 뽑으면 클러스터로 들어갈 문이 사라진다. 4a 와 결과가 다른 이유가 여기서 이미 예측된다.

6-2. 뽑는다

되돌리기 — 터미널 A

virsh start kc-lab-1

하기 — 터미널 A

date '+%H:%M:%S 차단'
virsh destroy kc-lab-1

실측07-control-plane-loss.txt

차단 시각: 12:18:08
Domain 'kc-lab-1' destroyed

터미널 B 가 여기서 죽는다. SSH 세션이 그대로 끊긴다. 놀랄 일이 아니다.

6-3. 주입 검증 — kubectl 이 죽은 것이 곧 증거다

확인 — 터미널 A 나 C 에서

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초 간격으로 두 주소를 본다

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

  +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 인데 여기만 다르다.

이 결과가 의미하는 것000503/502 는 서로 다른 층의 고장을 가리킨다.

코드 어디까지 살아 있는가
503 (4a) nginx·Traefik 은 살아 있고 뒤에 보낼 파드가 없다
502 (4b, 한 번) nginx 가 연결 실패를 제때 판정해 자기 힘으로 502 를 만들었다
000 (4b, 대부분) nginx 가 죽은 주소를 기다리다 우리 --max-time 8 이 먼저 끝났다

502 가 한 번이라도 찍혔다는 것이 nginx 는 살아 있었다는 증거다. 같은 고장인데 코드가 흔들리는 이유는 타임아웃 경주다.

6-5. ★ 그런데 워크로드는 살아 있다

kubectl 이 없으니 노드의 컨테이너 런타임에 직접 묻는다.

확인 — 터미널 A 에서 살아남은 노드로

ssh kc-lab-2 'sudo crictl ps --name keycloak'

실측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

어디를 봐야 하는가STATERunning, ATTEMPT0. API 서버가 없는데도 컨테이너는 돌고 있다.

이 결과가 의미하는 것

   죽은 것:  API 서버 · 스케줄러 · coredns · Traefik · Prometheus · Grafana
   산 것:    keycloak-0 · postgres · containerd
   문제:     들어갈 문(Traefik)이 없다

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

전체 목록도 본다.

ssh kc-lab-2 'sudo crictl ps'

crictl 이 소켓을 못 찾으면 k3s 의 것을 직접 준다. 미검증

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

date '+%H:%M:%S 재기동'
virsh start kc-lab-1

실측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초502nginx 가 먼저 살아나고 Traefik 이 아직 안 뜬 중간 상태다. 4b 내내 보던 000 과 층이 다르다.

6-8. 복구 후에 확인할 것

확인

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-1RESTARTS1kc-lab-1 위에 있었으니 당연하다
  • AGE151m 인데 재시작은 방금 — AGE 는 파드가 만들어진 시각이지 컨테이너가 시작한 시각이 아니다
  • (<invalid> ago) — 재시작 시각이 API 서버 시계보다 미래로 보일 때 나온다. 원인은 이 실험이 확정하지 않았다. 잠시 뒤 다시 치면 정상 값으로 바뀐다

확인 — Grafana. 6-6 에서 예고한 구멍

브라우저로 Grafana 를 열어 up{job="keycloak"} 그래프를 12:1512:30 으로 본다

실측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 urisystem 이어야 한다
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 podFailedScheduling — 4-6
keycloak-0 이 30분째 Terminating StatefulSet + kubelet 없음. 정상이다 get statefulsetCURRENT — 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 가 끊겼다 다시 연다
RESTARTS1 (<invalid> ago) 재시작 직후에 나온다. 원인 미확정 잠시 뒤 다시 친다 — 6-8
4b 결과가 4a 와 섞인다 4a 복구를 확인하지 않고 넘어갔다 5-3 확인표를 통과한 뒤 시작

이 실험이 남기는 구성 숙제

관찰만 하고 끝내면 아깝다. 두 가지는 지금 고칠 수 있다.

발견 고치는 방향
Traefik replicas=1 이라 진입점이 단일 장애점 replicas=2 로 늘리거나 DaemonSet 으로
Grafana 가 emptyDir 이라 재시작마다 세션이 사라짐 PVC 를 붙인다
관측 스택이 실험 대상 노드에 함께 있음 노드가 둘뿐이라 완전히는 못 피한다. 아는 것이 먼저

다음

실험 A-4 가 남긴 질문
A-5 비대칭 파티션 여기서는 노드가 완전히 사라졌다. 부분 단절은 더 고약하다
D-1 백업·복구 PVC 가 노드에 묶여 있다. 노드가 영영 안 돌아오면 백업이 유일한 길
A-6 지연 주입 여기서는 up=0 이 정확했다. 느려짐은 up 이 못 잡는다
전부 주입 검증의 기준을 먼저 정한다. 여기서는 쿠버네티스가 아니라 하이퍼바이저가 기준이었다