클러스터 콜드부팅 복구 — "no route to host" 전체 노드가 항상 cloud-init 장애는 아니다
control-plane /etc/hosts 장애 런북과 증상이 닮았다 —
kubectl이 전 노드에 no route to host. 하지만 원인은 완전히 다르다: 그 문서는 게스트 OS 안의
이름해석이 죽은 것이고, 이번은 노드를 담은 KVM VM 자체가 꺼져 있던 것이다. 이 문서는 두 장애를
30초 안에 가르는 감별법과, "ping이 안 되면 죽은 것"이라는 흔한 오해가 이 환경에서는 아예 거짓인 이유
(ICMP 필터링)를 다룬다.
결론 한 문장: 계층을 먼저 갈라라 — 전원(KVM) → 게스트 네트워크(ARP) → 이름해석(lease). ping은 이
감별에서 쓸모가 없다.
Date: 2026-07-01
호스트: homelab (kubespray bare-metal 3노드, 전부 로컬 KVM VM 위에서 구동)
도메인: Kubernetes / KVM/libvirt
상태: ✅ 복구 완료 — 3노드 Ready, lease 실시간 갱신, 전 네임스페이스 pod 정상, 기존 cloud-init 영구수리 유지
대상독자: 홈랩처럼 "k8s 노드 = 로컬 VM"인 환경에서, 재부팅/방치 후 클러스터가 안 잡힐 때 원인을 계층별로 빠르게 좁히려는 사람
선행개념: KVM/libvirt VM 상태(virsh), ARP(L2)와 ICMP(L3)의 차이, leader election lease
1. 배경 — 이 클러스터의 노드는 "컴퓨터"가 아니라 "이 서버 위의 VM"이다
이 홈랩 kubespray 클러스터의 3노드(control-plane 1 + worker 2)와 apiserver 앞단 로드밸런서는 전부
이 워크스테이션 한 대 위에 떠 있는 KVM VM이다 — 별도의 물리 머신이 아니다. 즉 "클러스터가 안 잡힌다"는
증상 앞에는 물리 전원 문제가 있을 수 없는 대신, 더 흔한 실패 모드가 하나 추가된다: VM 자체가
shut off 상태인 것. 이건 리눅스 게스트 안에서 무슨 일이 있었는지와 무관하게, 호스트의 libvirt 레벨에서
결정되는 훨씬 앞단의 계층이다.
이 관점이 중요한 이유는, 예전에 이 클러스터에서 겪은 control-plane 장애와
겉보기 증상이 완전히 같기 때문이다(kubectl이 전 노드에 접속 실패). 하지만 그 문서의 원인(cloud-init이
게스트 안의 /etc/hosts를 지움)과 이번 원인(VM이 꺼짐)은 서로 다른 계층에 있다. 증상만 보고 지난번과
같은 수리(/etc/hosts 편집)를 시도했다면 시간을 낭비했을 것이다 — 계층을 먼저 가르는 게 이 문서의 핵심이다.
2. 핵심 — 계층을 가르는 감별법 (메커니즘)
멘탈모델 앵커: "no route to host"가 VIP 하나만이면 이름해석/라우팅 문제(게스트 OS 계층)를 의심하고, 클러스터의 모든 IP(VIP+전 노드)가 동시에 no-route면 그보다 훨씬 앞단 — VM 전원(libvirt 계층)을 먼저 의심한다. 그리고 이 환경에서는 ping(ICMP)이 거짓 정보를 준다 — 이 노드들은 ICMP을 필터링하므로, VM이 멀쩡히 켜져 있어도 ping은 실패한다. 판정은 반드시 서비스 포트(TCP)로 한다.
2.1 두 장애를 가르는 표
| 이번(2026-07-01, 콜드부팅) | 이전(2026-06-01 outage) | |
|---|---|---|
| 증상 | .211(VIP)~.214(전 노드) 전부 no-route |
노드는 응답, VIP 이름 해석만 실패 |
| 실패 계층 | libvirt(VM 전원) | 게스트 OS(/etc/hosts) |
| VM 자체 상태 | shut off |
running(게스트 안에서 문제) |
| 복구 | virsh start |
/etc/hosts 편집 + cloud-init 템플릿 |
2.2 ping이 이 환경에서 쓸모없는 이유
VM을 켠 직후 5분 가까이 4개 IP 모두 ping에 무응답이라 "부팅이 안 되나" 의심했지만, 실제로는 ARP는 이미
정상 응답 중이었다(ip neigh get으로 MAC 주소가 정확히 잡힘 = L2/게스트 네트워크 스택은 살아있다는 뜻).
즉 ping(ICMP, L3)만 막혀 있었을 뿐 실제 서비스 포트(TCP, apiserver :6443/sshd :22)는 열려 있었다.
ping 무응답을 "노드가 죽었다"로 오독하면 이미 복구된 클러스터를 붙잡고 계속 헤매게 된다 — 이게 이
문서에서 가장 시간을 아껴주는 교훈이다.
판정 순서:
virsh list --all -> VM 자체가 shut off 인가? (가장 앞단)
| running이면
v
ip neigh get <ip> -> ARP 응답(MAC 잡힘) = 게스트 네트워크 스택 살아있음 (ping 말고 이걸로 판정)
|
v
TCP :6443 / :22 -> 서비스 포트 자체 확인 (/dev/tcp 또는 timeout+bash)
|
v
kubectl / lease -> control-plane 실제 활성 여부 (renewTime ≈ now)
3. 증상과 추적 경로
3.1 1차 증상
$ kubectl --context homelab get nodes
Unable to connect to the server: dial tcp <VIP>:6443: connect: no route to host
VIP뿐 아니라 노드 3개 전부(ping -c1 -W2 <ip>) no-route. 이전 장애의
"이름만 실패, 노드는 살아있음" 패턴과 다르다는 첫 단서.
3.2 계층을 앞으로 — libvirt 확인
$ virsh list --all
Id Name State
------------------------------
- k8s-master1 shut off
- k8s-worker1 shut off
- k8s-worker2 shut off
- lb-haproxy shut off
4개 VM(3노드 + 로드밸런서) 전부 꺼져 있었다. 이게 진짜 원인 — 게스트 OS 안을 들여다볼 필요조차 없다.
3.3 기동 후에도 5분 무응답 — red herring 확인
for vm in lb-haproxy k8s-master1 k8s-worker1 k8s-worker2; do virsh start "$vm"; done
기동 직후 4개 IP에 ping -c1 -W2를 반복했지만 전부 무응답이 5분 가까이 지속됐다. 여기서 "부팅이 오래
걸리나" 의심하는 대신 §2.2의 판정 순서로 내려갔다:
$ ip neigh get <master1-ip> dev br0
<master1-ip> dev br0 lladdr 52:54:00:f6:c5:d3 DELAY # <- MAC이 잡힘 = 게스트 살아있음
$ timeout 3 bash -c 'cat </dev/null >/dev/tcp/<vip>/6443' && echo OPEN
<vip>:6443 OPEN # <- 서비스 포트 응답
$ kubectl --context homelab get --raw=/healthz
ok
ARP는 즉시 정상, TCP도 즉시 열려 있었다 — ping만 계속 무응답이었다(노드가 ICMP을 필터링하는 구성). 5분을 ping으로 허비할 필요가 없었다는 뜻.
4. 수리 절차 (재현 가능)
# (1) VM 상태 확인
virsh list --all
# (2) 기동 — 로드밸런서(VIP 제공)를 먼저
for vm in lb-haproxy k8s-master1 k8s-worker1 k8s-worker2; do virsh start "$vm"; done
# (3) 판정은 ping이 아니라 ARP + TCP로
ip neigh get <master-ip> dev br0
for hp in <vip>:6443 <master-ip>:22; do
ip=${hp%:*}; p=${hp#*:}
timeout 3 bash -c "cat </dev/null >/dev/tcp/$ip/$p" && echo "$hp OPEN"
done
# (4) 클러스터 헬스 — 핵심은 lease가 '지금'으로 갱신되는가
kubectl --context homelab get --raw=/healthz
kubectl --context homelab get nodes
date -u +%Y-%m-%dT%H:%M:%SZ
kubectl --context homelab -n kube-system get lease kube-controller-manager -o jsonpath='{.spec.renewTime}'
kubectl --context homelab -n kube-system get lease kube-scheduler -o jsonpath='{.spec.renewTime}'
kubectl --context homelab get pods -A | grep -vE 'Running|Completed' # 비정상 0이면 정상
⚠ VM 기동은 위험 작업 정책상 사용자 승인 후 진행. 4개 VM 전부를 대상으로 하는 이 조치는 클러스터 전체에 영향을 주므로 먼저 상태를 파악하고 승인을 받았다.
5. 검증 결과
healthz : ok
nodes : 3노드 모두 Ready (k8s v1.30.6)
lease : controller-manager/scheduler renewTime ≈ 현재 시각(지연 <1s) -> control loop 실제 활성
pods : 전 네임스페이스 비정상 0. istio-system istiod/ingress/egress 1/1 (restart 1, 이번 부팅분)
istio : istioctl 1.30.0 client/control/data plane 일치, 6 proxies
경로 : istioctl proxy-config / kubectl logs(kubelet webhook 인가) 정상 -> 이전 장애의 증상 없음
기존물 : 기존 실험 네임스페이스·리소스 무결
lease가 즉시 갱신된다는 것은 노드에서 lb-apiserver.kubernetes.local 이름해석이 살아있다는 뜻이기도
하다 — 즉 이전 장애의 영구수리(cloud-init 템플릿 항목)가
이번 콜드부팅을 견뎠다는 부수 확인이 됐다. 두 장애가 서로 다른 계층이라도, 복구 검증 명령(lease
renewTime)은 똑같이 유효하다는 점이 흥미롭다.
핵심 정리
- 증상(전 노드 no-route)이 같아도 원인 계층은 다를 수 있다: VM 전원(libvirt) vs 게스트 OS 이름해석.
감별은
virsh list --all이 가장 먼저다 — 게스트 안을 보기 전에 "VM이 켜져 있긴 한가"부터 확인. - ping은 이 환경에서 판정 도구로 쓰지 않는다: ICMP 필터링 때문에 살아있는 노드도 ping엔 응답하지
않는다. ARP(
ip neigh get)로 L2 생존을, TCP(/dev/tcp)로 서비스 포트를 확인한다. - 최종 판정은 항상 lease
renewTime:Ready나healthz: ok만으로는 부족하고, control loop가 "지금" 갱신되고 있는지가 진짜 활성 증거다(이전 장애 §2의 "살아있는 듯한 정지" 착시와 같은 원칙).
What you might be missing
- 영구수리가 재부팅을 "견뎠다"는 것이 곧 "다시는 안 터진다"는 뜻은 아니다. cloud-init 템플릿 수정은
이번 콜드부팅 1회를 통과했을 뿐, 근본 해결(kubespray inventory에서
manage_etc_hosts끄기)은 여전히 미완이다 — 다음 재부팅 후에도 반드시 lease부터 재확인할 것. - "VM이 꺼진 이유" 자체는 이번에 추적하지 않았다. 의도적 종료였는지, 호스트 재부팅에 VM autostart가
안 걸려 있었는지는 별도 확인이 필요하다 —
virsh dominfo <vm> | grep Autostart로 다음에 점검할 만하다. - 감별 절차(전원→ARP→이름해석→lease)는 이 문서가 처음 정리한 것이라, 다음에 비슷한 증상이 나오면 이 순서를 그대로 따르면 된다 — "ping이 안 되니 죽었다"는 결론으로 바로 뛰지 말 것.
참조
- control-plane
/etc/hosts장애 런북 — 겉보기 증상이 같은 이전 장애, 위험 작업 승인 정책도 여기 상속 - GSLB DNS resolution 재현 랩 — 이 복구 직후 이어서 진행한 작업