Files
document-haness/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a4-node-loss.md
T
DongHyeonkaandClaude Opus 5 2109f726fe feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만
있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문
종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다.

SSOT 결함 둘을 고쳤다.

- kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이
  「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽
  `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다.
- virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데
  원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다.

기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은
둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더
든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다).

계약을 셋 고쳤다.

- kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/**
  28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는
  것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온
  것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수
  대조했을 때 가장 가까운 커밋도 28개가 어긋났다).
- virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고
  재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다.
- kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고
  넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다.

style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져
(실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고,
engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고
있었다(Question 기록에서 11.94 → 3.86).

verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:01:55 +09:00

40 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
id kind slug title topic topicName project status studio pinnedVersions source sourceRevision
d845adc8-be2c-4471-aa1d-7e4db864c471 SETUP reproduce-a4-node-loss 기계 전원을 뽑고 쿠버네티스가 알아채는 시각을 잰다 losing-a-node-or-the-store PostgreSQL 을 내리고 노드 전원을 뽑았을 때 keycloak-session-store 게시 전 https://hyeonworks.com/studio/documents/d845adc8-be2c-4471-aa1d-7e4db864c471/edit
name version
Keycloak 26.7.0
final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-4
cdac9b8178391311d8eca1ebc6cac15bb62d79af

기계 전원을 뽑고 쿠버네티스가 알아채는 시각을 잰다

워커 노드와 k3s 서버를 차례로 virsh destroy 로 끄고 노드 이탈 인지와 축출 시작과 재배치 실패 시각을 재는 절차다. 주입이 하이퍼바이저 명령이라 되돌리기도 물리적이고 터미널을 세 개 쓴다. 전 구간 약 40분이고 실험대에서만 친다.

관계

  • 노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것 이 절차가 재현하는 사건이고, 4a 와 4b 가 왜 다른 고장인지는 그 기록이 결론으로 갖는다.
  • 장애 시간의 대부분은 알아채는 데 걸린다 40초와 5분 40초를 차단 시각에서 빼서 재는 순서가 그 기준이 선 근거다.
  • up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다 노드가 통째로 없어지는 이 절차에서는 up 이 정확히 0 을 낸다. DB 만 잃었을 때와 왜 다른지를 그 기록이 설명한다.
  • readiness 가 깨진 노드를 시야에서 먼저 치운다 꺼진 기계의 파드가 ready=true 로 얼어 있고 살아 있는 파드가 ready=false 인 상태를 이 절차가 만든다.

본문

읽기 전에 — 어디서 치는가

명령을 치는 곳이 세 군데이고, 그 구별이 이 절차의 내용이다. 코드블록마다 어느 터미널인지 붙여 두었다.

터미널 어디서 치나 무엇을 치나
A test-server (VM 호스트) virsh — 전원을 뽑고 다시 넣는다
B kc-lab-1 kubectl — 관찰. 4b 에서는 이 터미널이 죽는다
C test-server 밖에서 curl. 사용자 시점

4b 에서는 kc-lab-2 에도 붙는다. 터미널 A 와 같은 기계에서 ssh kc-lab-2 로 붙고, 이름이 안 풀리면 ssh 192.168.122.12 다.

무엇
네임스페이스 keycloak-lab · 관측 스택은 observability
대상 게스트 VM 둘 — kc-lab-2(워커) 와 kc-lab-1(k3s 서버)
주입 virsh destroy. virsh shutdown 을 쓰면 이 절차가 아니다
외부 확인 모든 curl--max-time 8
걸리는 시간 전 구간 약 40분. 4a 의 축출을 보는 데만 7분
되돌리기 virsh start. --grace-period=0 --force 로 파드를 지우지 않는다

이 실험이 가르는 것

A-1 과 A-5 는 네트워크만 끊는다. 파드는 살아 있고 쿠버네티스는 계속 정확한 상태를 안다. 여기서는 기계 자체를 없애므로 상태를 보고할 주체가 사라진다.

A층은 예측을 먼저 적어 두고 주입했다. A-0 의 예측표는 이 실험을 옛 로드맵 번호인 A-3 노드 상실 (kc-lab-2) 로 적고 「세션은 살아남는다. 죽은 노드의 캐시만 사라진다」를 룩어사이드 캐시에서 끌어냈다. 그 한 줄에 postgres 는 나오지 않는다.

판본은 둘이고, 어느 노드를 끄느냐가 결과를 전부 바꾼다.

판본 끄는 노드 그 노드에 있는 것 무엇을 묻나
4a kc-lab-2 (워커) keycloak-0 · postgres · postgres PVC Keycloak 과 DB 를 동시에 잃으면
4b kc-lab-1 (k3s 서버) keycloak-1 · Traefik · 컨트롤 플레인 · 관측 스택 들어갈 문을 잃으면

끝나면 셋을 말할 수 있다.

   쿠버네티스는 언제 알아채는가      →  40초 (그동안 거짓말을 한다)
   무엇을 스스로 고치는가            →  축출. 단 5분 뒤
   무엇을 못 고치는가                →  PVC 가 묶인 재배치, StatefulSet 이름

전제와 되돌리기

  • virsh 가 시스템 하이퍼바이저를 보고 있어야 한다. qemu:///system 이 아니면 VM 이 안 보인다.
  • 4b 에서는 kc-lab-2 에도 붙는다. 터미널 A 와 같은 기계에서 ssh kc-lab-2 로 붙고, 이름이 안 풀리면 ssh 192.168.122.12 다.
  • 4a 의 확인표를 통과하기 전에 4b 로 넘어가지 않는다. 두 고장이 겹치면 무엇이 원인인지 못 가린다.

1. virsh 가 시스템 하이퍼바이저를 보게 맞춘다

목적 — 터미널 A 의 virshqemu:///system 을 보게 한다.

export LIBVIRT_DEFAULT_URI=qemu:///system
virsh uri

예상 결과qemu:///system 한 줄.

왜 필요한가qemu:///sessionqemu:///system 은 서로 다른 libvirt 연결이다. VM 은 system 쪽에 있으므로 session 을 보고 있으면 목록 자체가 비어 나오고, 그것을 「VM 이 죽었다」로 읽게 된다.

문제가 생기면 — 다음 단계에서 virsh list --all 이 빈 목록을 내면 여기부터 다시 본다.

:::warning

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

:::

어느 단계에서든 그만두려면 터미널 A 에서 한 줄이면 된다.

virsh start kc-lab-2 ; virsh start kc-lab-1

주입 전에 같은 명령으로 먼저 본다

주입 뒤에 치는 명령과 같은 명령을 먼저 친다. 순서는 이렇다.

VM → 노드 → 파드 배치 → 볼륨이 어디 묶여 있나 → 외부 응답 → 관측자가 어디 있나

1. VM 전원과 노드와 파드 배치

무엇을 확인하는가 — 두 게스트가 켜져 있고 어느 파드가 어느 노드에 있는지.

virsh list --all
kubectl get nodes
kubectl -n keycloak-lab get pods -o wide

출력에서 답이 되는 것virsh list --all 의 상태 열과 get pods -o wideNODE 열이다. 실측은 이렇다.

--------------------------
 1    kc-lab-1   running
 2    kc-lab-2   running
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

이 결과가 뜻하는 것kc-lab-2 에 keycloak-0 과 postgres 가 함께 있으므로 4a 는 Keycloak 한 대를 잃는 절차가 아니라 Keycloak 한 대와 DB 를 동시에 잃는 절차다. virsh list --all 앞의 숫자는 도메인 ID 이고 VM 을 껐다 켜면 바뀌므로 이름으로 다룬다. a2-probe 는 A-2 에서 띄우고 안 지운 상주 파드라 없어도 지장이 없다.

2. 볼륨이 어느 노드에 못박혀 있나

무엇을 확인하는가 — postgres PVC(PersistentVolumeClaim, 영구 볼륨 요청) 가 요구하는 노드. 이 값이 뒤의 재배치 결과를 미리 정한다.

kubectl -n keycloak-lab get pvc
kubectl -n keycloak-lab get pvc postgres-data -o jsonpath='{.spec.volumeName}' ; echo
kubectl describe pv $(kubectl -n keycloak-lab get pvc postgres-data \
  -o jsonpath='{.spec.volumeName}') | grep -A6 'Node Affinity'

출력에서 답이 되는 것Required Terms 아래 호스트 이름이다. 값은 환경마다 다르다.

Node Affinity:
  Required Terms:
    Term 0:  kubernetes.io/hostname in [kc-lab-2]

값만 여러 번 비교할 때는 뽑는 형태로 줄인다.

kubectl get pv $(kubectl -n keycloak-lab get pvc postgres-data \
  -o jsonpath='{.spec.volumeName}') \
  -o jsonpath='{.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]}' ; echo

이 결과가 뜻하는 것 — 이 실험대에서 postgres-datakc-lab-2 를 요구했다. local-path PVC 는 그 노드의 로컬 디렉터리(/var/lib/rancher/k3s/storage/...)이므로 노드가 죽으면 볼륨도 같이 죽고, 스케줄러는 nodeAffinity 로 그것을 알고 있어 다른 노드에 파드를 만들지 않는다. 결함이 아니라 이 실험대의 조건이다.

3. 밖에서 보이는 상태와 관측자의 위치

무엇을 확인하는가 — 정문이 지금 무엇을 답하는지, 그리고 Prometheus 와 Grafana 가 어느 노드에 있는지.

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

출력에서 답이 되는 것 — ② 는 200, ③ 은 grafanaprometheusNODE 열이다. 이 실험대에서는 둘 다 kc-lab-1 에 있었다.

이 결과가 뜻하는 것 — 4a 는 kc-lab-2 를 끄므로 Prometheus 가 살아남아 관측이 정확하고, 4b 는 관측자가 같이 죽는다. 미리 알아 두지 않으면 나중에 그래프의 빈 구간을 값 0 으로 읽는다. --max-time 은 모든 외부 확인에 준다. 4b 에서 그 값이 없으면 curl 이 몇 분씩 매달리고, 타임아웃이 곧 결과다.

주입

주입은 둘이고 한 번에 하나씩만 건다. 4a 를 끝까지 밟고 복구 확인표를 통과한 뒤에 4b 를 건다. 두 노드가 동시에 꺼져 있으면 무엇이 무엇의 결과인지 가릴 수 없고, 4b 는 API 서버를 끄기 때문에 4a 를 관찰할 kubectl 자체가 없어진다. 치는 순서는 이렇다.

   4a 주입 ─▶ 주입 검증 §1·§2 ─▶ 관찰 §1~§7 ─▶ 복구 §1 (확인표 통과)
                                                          │
                        ┌─────────────────────────────────┘
                        ▼
   4b 주입 ─▶ 주입 검증 §3 ─▶ 관찰 §8·§9 ─▶ 복구 §2

1. 워커 노드의 전원을 뽑는다 (4a)

목적kc-lab-2 를 신호 없이 정지시켜 keycloak-0 과 postgres 를 동시에 잃는다.

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

예상 결과

차단 시각: 12:07:43
Domain 'kc-lab-2' destroyed

왜 필요한가virsh shutdown 은 ACPI(Advanced Configuration and Power Interface, 전원 관리 규격) 종료 신호를 보내 kubelet 이 정상 종료하고 파드가 정리되므로, 쿠버네티스가 정상적인 노드 이탈로 처리해 이 절차의 발견 둘이 통째로 안 나온다. virsh destroy 는 신호가 없어 마지막 상태가 그대로 얼어붙는다. 시각을 찍는 것도 같은 이유다 — 40초와 5분은 이 시각에서 뺀 값이고, 기준점이 없으면 뒤의 관찰은 나열로 끝난다.

문제가 생기면virsh 가 도메인을 못 찾으면 qemu:///session 을 보고 있는 것이므로 전제의 1번으로 돌아가 virsh uri 를 본다.

2. 컨트롤 플레인 노드의 전원을 뽑는다 (4b) — 4a 를 끝낸 뒤에 친다

언제 치는가 — 복구 §1 의 원상복구 확인표를 전부 통과한 뒤다. 4a 의 주입 검증·관찰·복구를 먼저 밟고 여기로 온다.

목적kc-lab-1 을 정지시켜 API 서버와 Traefik 과 관측 스택을 한꺼번에 잃는다.

kubectl get pods -A -o wide --field-selector spec.nodeName=kc-lab-1
kubectl -n kube-system get deploy traefik
date '+%H:%M:%S 차단'
virsh destroy kc-lab-1

예상 결과 — ① 의 실측은 이렇다.

  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

④ 뒤에는 차단 시각: 12:18:08Domain 'kc-lab-1' destroyed 가 나오고, 터미널 B 의 SSH 세션이 그대로 끊긴다.

왜 필요한가traefik 이 이 노드에 있고 replicas1 이라, 이 노드를 뽑으면 클러스터로 들어갈 문이 사라진다. 인벤토리를 먼저 뽑아 두지 않으면 4b 에서 무엇이 없어졌는지 사후에 못 센다.

문제가 생기면 — 4a 의 확인표를 통과하기 전에는 여기로 오지 않는다. 두 고장이 겹치면 무엇이 원인인지 못 가린다.

다음 — 아래 「주입 검증」 §3 으로 간다. §1 과 §2 는 4a 의 것이라 이미 쳤다.

주입 검증

A-5 와 A-6 에서는 규칙을 넣었는데 카운터가 0 인 것이 실패였다. 여기서 검증하는 대상은 다르다. 믿을 수 있는 것은 하이퍼바이저이고, 쿠버네티스가 뭐라고 하든 그것은 결과다.

1. 기계가 실제로 꺼졌나 (4a)

무엇을 확인하는가 — 게스트의 전원 상태.

virsh list --all
ping -c 2 -W 2 192.168.122.12

출력에서 답이 되는 것 — ① 에서 shut off 이면 꺼진 것이고, ID 가 - 로 바뀐 것도 같은 말이다. ② 에서 0 received 가 나오면 꺼져 있다.

이 결과가 뜻하는 것 — 여기까지가 주입 검증이다. 다음 단계의 kubectl 출력은 검증이 아니라 관측 대상이다.

2. 쿠버네티스는 아직 Ready 라고 말한다 (4a)

무엇을 확인하는가 — 노드 상태와 사용자가 겪는 응답을 같은 시각에 나란히 본다.

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

손이 아프면 한 줄로 묶는 형태가 있고, 그 루프는 미검증이다. Ctrl-C 로 멈춘다.

while true; do
  printf '%s node=%s 외부=%s\n' "$(date +%H:%M:%S)" \
    "$(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

출력에서 답이 되는 것 — 노드 상태가 넘어가는 줄과, 그 앞뒤의 외부 코드다. 실측은 이렇다.

  +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 이 그것을 말한다. 여기서 주입이 안 걸렸다고 결론 내리면 틀린다 — 기계는 꺼져 있고 쿠버네티스가 아직 모를 뿐이다.

처음 40초가 503 이 아니라 000 인 까닭은 층이 다르기 때문이다. 000curl 이 응답 자체를 못 받은 것(타임아웃 또는 연결 실패)이고, 503 은 nginx 와 Traefik 은 살아 있고 뒤로 보낼 파드가 없는 것이다. 엣지 nginx(kc-lab-edge)의 upstream 에는 두 노드가 다 들어 있다.

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

죽은 쪽으로 배분된 요청은 응답도 거절도 못 받고 --max-time 8 에 걸린다. 이 까닭을 nginx 로그로 확인하려던 절이 증거 파일에 제목만 있고 아래가 비어 있다. 직접 볼 수 있는 줄을 가이드가 미검증으로 표시했다. upstream timed out192.168.122.12 에 대해 찍히면 그것이 답이고, nginx 에러 로그는 2048바이트에서 잘리므로 잘려 보이면 access 로그를 본다.

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

3. 4b 의 검증은 kubectl 이 죽은 것 자체다

무엇을 확인하는가 — API 서버에 닿는지.

kubectl get nodes

출력에서 답이 되는 것kubectl: Unable to connect to the server: dial tcp.

이 결과가 뜻하는 것 — API 서버가 kc-lab-1:6443 에 있었으므로 당연한 결과다. 4a 에서는 쿠버네티스가 뭐라고 하는가를 물을 수 있었고 여기서는 물어볼 상대가 없어, 이 절차의 관찰 도구가 통째로 바뀐다.

관찰

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

kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,PHASE:.status.phase,READY:.status.containerStatuses[0].ready,NODE:.spec.nodeName
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 실패다. 파드 상태는 지금 어떤가가 아니라 마지막으로 그렇게 들었다이고, 노드가 죽으면 그 노드 파드의 상태는 갱신을 멈춘 값이 된다.

2. 이벤트는 Age 로 먼저 거른다

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

노드를 뽑은 것은 3m15s 전인데 맨 위 줄은 10m 짜리라 주입보다 앞선 사건이고 앞 실험이 남겼다. 이벤트 목록은 시간대가 섞여 있으므로 Age 로 먼저 걸러야 내가 만든 일을 고를 수 있다. 주입 이후 keycloak-0 에 붙은 이벤트는 NodeNotReady 하나뿐인데 그것은 컨트롤러가 쓴 것이고, kubelet 이 없으니 그 파드에 대해 말해 줄 주체가 없다. keycloak-1 의 실패는 두 종류다 — 처음에는 프로브 자체가 타임아웃되고(context deadline exceeded), 나중에는 503 을 받는다. Keycloak 이 DB 없음을 스스로 판단해 답할 수 있게 된 것이라 같은 Unhealthy 라도 층이 다르다.

3. Prometheus 는 정확했다

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=up'
kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=up' \
  | tr ',' '\n' | grep -E '"job":|"pod":|"node":|^"[0-9]'
  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 이 두 줄인 것은 노드마다 하나씩이기 때문이다. A-2 와 정반대로, 대상이 사라진 노드 상실은 up 이 잡고 대상이 살아서 못 쓰는 DB 상실은 못 잡는다.

4. taint 는 붙었는데 5분 동안 아무 일도 안 일어난다

kubectl describe node kc-lab-2 | grep -A3 Taints
kubectl get node kc-lab-2 -o jsonpath='{.spec.taints}' ; echo
kubectl -n keycloak-lab describe pod keycloak-1 | grep -A4 Tolerations

①② 는 node.kubernetes.io/unreachable=:NoSchedulenode.kubernetes.io/unreachable=:NoExecute 를 낸다. NoSchedule 은 새 파드를 여기 보내지 말라는 뜻이고 NoExecute 는 이미 있는 파드도 쫓아내라는 뜻인데, 그런데도 아무 일이 안 일어나는 까닭이 ③ 에 있다.

=== 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초 동안 쿠버네티스는 아무것도 하지 않는다

5. 5분을 실제로 기다린다

watch -n 30 'kubectl -n keycloak-lab get pods -o wide'
  +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 파드가 생기며 Pending 이다. 축출이 시작됐는데 Terminating 이 안 끝나고 새 파드는 뜨지 못하며, 두 문제는 원인이 다르다.

6. 새 파드가 갈 곳이 없다

kubectl -n keycloak-lab get pods --field-selector=status.phase=Pending
kubectl -n keycloak-lab describe pod postgres-7b474b88c8-9cmsv | grep -A6 Events
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

주입 전에 이미 알고 있던 것이 그대로 벌어졌다. 볼륨이 kc-lab-2 에 못박혀 있어 살아 있는 노드로 못 가고, 죽은 노드에는 taint 때문에 못 간다. 갈 곳이 없다. 이 실험대의 조건이고, 운영이라면 네트워크 스토리지나 DB 복제가 그 몫을 맡아야 한다. 노드가 영영 안 돌아오면 백업 복원(D-1)으로 간다.

7. StatefulSet 은 대체 파드를 안 만든다

kubectl -n keycloak-lab get statefulset keycloak
kubectl -n keycloak-lab get pods | grep keycloak
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 을 못 만든다.

   파드 삭제 요청
     └─ kubelet 이 컨테이너를 멈추고 "지웠다"고 보고해야 끝난다
          └─ kubelet 이 없다 → 보고가 없다 → 영원히 Terminating

Deployment 였다면 즉시 새 파드를 만든다. 이름이 아무래도 되기 때문이고, postgres 가 실제로 그랬다. 강제로 진행시키는 명령이 있지만 이 절차에서는 치지 않는다.

kubectl -n keycloak-lab delete pod keycloak-0 --grace-period=0 --force

그것은 컨테이너가 실제로 죽었는지 모른 채 API 에서 지우는 것이라, 노드가 사실은 살아 있고 네트워크만 끊긴 것이라면 같은 이름의 파드 둘이 동시에 존재하게 된다. 이 실험대에서는 virsh start 가 훨씬 안전하고 빠르다.

8. 4b — kubectl 이 없으면 컨테이너 런타임에 직접 묻는다

이 실험대는 한 줄로 쳤다.

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

따라 하는 사람은 붙고 나서 원격 셸에서 친다. 한 줄에 SSH 접속과 원격 셸의 인용을 겹쳐 놓지 않는다. 이 두 단계 형태는 이 실험대에서 치지 않았다.

ssh kc-lab-2
sudo crictl ps --name keycloak
  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)이 없다

컨트롤 플레인 상실은 워크로드 상실과 다르다. 이미 떠 있는 것은 계속 돌고, 새로 뜨거나 옮기거나 고치는 것이 안 된다. 전체 목록은 sudo crictl ps 로 본다.

③ 원격 셸에서 나온다. 뒤의 단계는 다시 터미널 A 에서 치므로, 나오지 않으면 kc-lab-2 안에서 kc-lab-2 로 또 붙게 된다.

exit

crictl 이 소켓을 못 찾으면 k3s 의 것을 직접 주는데, 그 줄은 미검증이다. 아래는 원격 셸이 아니라 터미널 A 에서 치는 형태다.

ssh kc-lab-2 'sudo crictl --runtime-endpoint unix:///run/k3s/containerd/containerd.sock ps'

9. 4b — 밖에서는 두 주소를 함께 본다

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/
  +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 한 줄만 다르다. 503(4a)은 nginx 와 Traefik 이 살아 있고 뒤에 보낼 파드가 없는 것, 502(4b, 한 번)는 nginx 가 연결 실패를 제때 판정해 자기 힘으로 만든 것, 000(4b, 대부분)은 nginx 가 죽은 주소를 기다리다 --max-time 8 에 먼저 걸렸다. 502 가 한 번이라도 찍혔으므로 nginx 는 살아 있었고, 같은 고장인데 코드가 흔들리는 까닭은 타임아웃 경주다.

4b 구간의 지표는 지금 확인할 수 없다. Prometheus 도 Grafana 도 kc-lab-1 에 있었다.

   대상이 죽음        →  up = 0        →  "언제 죽었는지" 알 수 있다
   관측자가 죽음      →  데이터 없음    →  "그때 무슨 일이 있었는지" 모른다

복구와 원상복구 확인표

1. 워커 노드를 다시 켠다 (4a)

목적kc-lab-2 를 되살려 파드와 볼륨과 정문을 제자리로 돌린다.

date '+%H:%M:%S 재기동'
virsh start kc-lab-2
kubectl get nodes
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(Mean Time To Recovery, 평균 복구 시간)이 아니다. virsh start 를 친 뒤의 시간이고, 실제 장애 구간은 12:07:43(차단)에서 12:17:31(서비스 복귀)까지 약 10분이며 그 대부분은 사람이 관찰하고 결정하는 데 썼다.

문제가 생기면postgres 파드 이름이 바뀌어 있으면 정상이다. 축출 때 생겼다가 Pending 이던 파드가 노드가 살아나자 그대로 뜬 것이고, keycloak-0 은 StatefulSet 이라 이름이 그대로다.

=== 복구 확인 ===
keycloak-0                  1/1   Running   0     68s
keycloak-1                  1/1   Running   0     144m
postgres-7b474b88c8-9cmsv   1/1   Running   0     4m20s
항목 명령 돌아왔을 때
VM virsh list --all 둘 다 running
노드 kubectl get nodes 둘 다 Ready
파드 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

이 표를 통과하기 전에는 4b 로 넘어가지 않는다.

2. 컨트롤 플레인 노드를 다시 켠다 (4b)

목적kc-lab-1 을 되살려 API 서버와 진입점과 관측 스택을 돌린다.

date '+%H:%M:%S 재기동'
virsh start kc-lab-1
kubectl -n keycloak-lab get pods

예상 결과

재기동: 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초)

+30초502 는 nginx 가 먼저 살아나고 Traefik 이 아직 안 뜬 중간 상태이고, 4b 내내 보던 000 과 층이 다르다.

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-1RESTARTS 가 1 인 것은 kc-lab-1 위에 있었으니 당연하고, AGE151m 인데 재시작은 방금인 것은 AGE 가 파드가 만들어진 시각이지 컨테이너가 시작한 시각이 아니기 때문이며, (<invalid> ago) 는 재시작 시각이 API 서버 시계보다 미래로 보일 때 나온다. 그 원인은 이 절차가 확정하지 못했고, 잠시 뒤 다시 치면 정상 값으로 바뀐다.

문제가 생기면 — 복구 뒤에 Grafana 에서 up{job="keycloak"} 그래프를 12:1512:30 으로 열어 12:1812:23 구간이 0 이 아니라 빈칸인 것을 확인한다. 그 구간은 선이 0 으로 내려간 것이 아니라 아예 끊겨 있다. Grafana 로그인도 풀려 있는데 데이터가 emptyDir 이라 파드 재시작에 사라지기 때문이고, Prometheus 는 PVC 라 지표가 남았지만 관측자가 죽어 있던 구간의 데이터는 애초에 수집되지 않았다. Prometheus 를 port-forward 로 보고 있었다면 다시 연결한다.

항목 명령 돌아왔을 때
VM virsh list --all 둘 다 running
노드 kubectl get nodes 둘 다 Ready
파드 kubectl -n keycloak-lab get pods -o wide 전부 1/1 Running
진입점 kubectl -n kube-system get deploy traefik 1/1
Service kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak ready 주소 둘
클러스터 뷰 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

막히면

증상 원인 확인
virsh 가 도메인을 못 찾는다 qemu:///session 을 보고 있다 virsh urisystem 이어야 한다
VM 을 껐는데 노드가 Ready 정상이다. node-monitor-grace-period 40초 virsh list --all 로 전원을 먼저 본다
5분이 지나도 축출이 안 온다 40초 + tolerationSeconds=300 = 5분 40초 describe pod | grep -A4 Tolerations
새 파드가 계속 Pending PVC 가 죽은 노드에 못박혀 있다 describe podFailedScheduling
keycloak-0 이 30분째 Terminating StatefulSet + kubelet 없음. 정상이다 get statefulsetCURRENT
--force 로 지우고 싶다 노드가 살아 있으면 중복 실행이 된다 치지 말고 virsh start
kubectl 이 전혀 안 된다 (4b) API 서버가 죽은 노드에 있었다. 정상 ssh kc-lab-2 'sudo crictl ps'
crictl 이 소켓을 못 찾는다 k3s 는 자기 containerd 소켓을 쓴다 --runtime-endpoint unix:///run/k3s/containerd/containerd.sock
503 을 기대했는데 000 층이 다르다. nginx 가 죽은 주소를 기다린다 --max-time 을 늘려 보면 502 가 나온다
curl 이 몇 분씩 안 끝난다 --max-time 을 안 줬다 모든 외부 확인에 --max-time 8
그래프의 그 구간이 0 으로 보인다 0 이 아니라 데이터 없음이다 점 사이가 이어져 있는지 본다
Grafana 로그인이 풀렸다 데이터가 emptyDir 재시작마다 그렇다. PVC 로 바꾸면 남는다
Prometheus 가 갑자기 안 보인다 port-forward 가 끊겼다 다시 연다
RESTARTS1 (<invalid> ago) 재시작 직후에 나온다. 원인 미확정 잠시 뒤 다시 친다
4b 결과가 4a 와 섞인다 4a 복구를 확인하지 않고 넘어갔다 4a 확인표를 통과한 뒤 시작

무엇이 관측이고 무엇이 아닌가

  • (observed) 차단 12:07:43 · 재기동 12:16:31 · 2차 차단 12:18:08 · 2차 재기동 12:23:39, +30초 까지 Ready 이고 +45초NotReady, 그 동안 외부가 000 에서 503 으로, 죽은 노드 파드의 ready=true, up 일곱 줄, taint 두 종류, tolerationSeconds=300, +270초 의 축출과 Pending, 0/2 nodes are available 줄, DESIRED=2 / CURRENT=1 과 30분째 Terminating, crictl 출력 한 줄, grafana=502 한 번, 양쪽 복구 60초, postgres 이름이 -2gf27 에서 -9cmsv 로 바뀐 것.
  • 인용한 값이고 잰 값이 아닌 것 — 본문의 40초와 5분은 쿠버네티스 기본값을 인용했다. 관측된 전이 시점(+45초, +270초)이 그 값과 모순되지 않는다는 것까지가 이 절차가 말할 수 있는 범위이고, 값 자체를 측정하지는 않았다.
  • (unknown) ping -c 2 -W 2 192.168.122.12(원 실행에 없다), 노드 상태와 외부 코드를 한 줄로 묶는 while 루프, tr ',' '\n' | grep -E 로 자른 up 출력, sudo tail -f /var/log/nginx/error.log, crictl 에 소켓을 직접 주는 줄, ssh kc-lab-2 로 들어가서 crictl ps 를 따로 치는 두 단계 형태.
  • 이 절차가 답을 못 남긴 곳 — 진입점이 처음 40초간 000 이었던 까닭을 nginx 로그로 확인하려던 절이 증거 파일에서 제목만 있고 아래가 비어 있다. 명령이 아무것도 찍지 못했다.
  • 원인을 확정하지 않은 것 — RESTARTS(<invalid> ago).
  • 재지 않은 것 — 노드가 영영 안 돌아오는 경우. local-path PVC 가 그 노드와 함께 없어진 상태에서의 복구는 D-1(백업·복원)의 주제다.
  • 이 절차가 남기는 구성 숙제 셋 — Traefik replicas=1 이라 진입점이 단일 장애점인 것(replicas=2 로 늘리거나 DaemonSet 으로), Grafana 가 emptyDir 이라 재시작마다 세션이 사라지는 것(PVC 를 붙인다), 관측 스택이 실험 대상 노드에 함께 있는 것(노드가 둘뿐이라 완전히는 못 피한다).