클러스터 control-plane 장애 — lb-apiserver.kubernetes.local 이름해석 소실
Istio 재설치 중 istiod가 0/1로 안 뜨는 원인을 추적하다, 그게 Istio 문제가 아니라 kubespray control-plane 전체가 ~9일째 마비였음을 밝혀낸 장애 분석·수리 런북. 단일 실패점은 /etc/hosts의 단 한 줄, 범인은 cloud-init, 그리고 가장 위험한 함정은 "노드는 Ready인데 control loop는 죽어있는" 착시다. Helm 재설치 런북의 하드 선행 작업 — 이 수리 없이는 데이터플레인이 뜨지 않는다.
Date: 2026-06-01
호스트: homelab (kubespray bare-metal, k8s v1.30.6, 3노드)
도메인: Kubernetes (control-plane / kubespray)
상태: ✅ 수리 완료 (2026-06-01 02:16Z) — 3노드 Ready, lease 실시간 갱신, 전 pod 정상
대상독자: kubespray/kubeadm bare-metal을 운영하며 "control plane이 살아있는지"를 lease 수준에서 판정하려는 DevOps/SRE.
선행개념: static pod, leader election lease, kubelet authorization(Webhook), /etc/hosts 이름해석.
1. 배경 — 왜 control plane은 이름 한 줄에 목숨이 걸려 있나
Kubernetes의 모든 control loop는 결국 하나의 진실원천(apiserver)에 쓰고 읽으면서 돈다. controller-manager가 Deployment를 보고 ReplicaSet을 만들고, scheduler가 Pod에 노드를 배정하고, kubelet이 노드 상태(lease)를 갱신하는 — 이 모든 reconcile은 "apiserver에 HTTP 연결이 된다"를 전제로 한다. 연결이 끊기면 loop는 에러도 거의 안 내고 조용히 멈춘다. retry를 영원히 돌 뿐이다.
여기서 핵심은 컴포넌트가 apiserver를 IP가 아니라 이름으로 찾는다는 점이다. HA control plane은 apiserver가 여러 대(또는 VIP 뒤)에 있을 수 있으므로, kubespray는 모든 kubeconfig(controller-manager.conf/scheduler.conf/kubelet.conf)의 server를 고정 IP가 아닌
server: https://lb-apiserver.kubernetes.local:6443
로 적어 둔다. 그래야 apiserver 한 대가 죽거나 VIP가 옮겨가도 kubeconfig를 안 고친다. 대신 그 이름을 어디서 어떻게 푸느냐가 클러스터마다 갈린다. kubespray에는 두 가지 LB 토폴로지가 있고, 이 차이를 모르면 장애 때 잘못된 응급조치를 한다.
| LB 모드 | lb-apiserver.kubernetes.local이 가리키는 곳 |
누가 forward? |
|---|---|---|
| localhost-LB | 127.0.0.1:6443 |
worker엔 nginx-proxy static pod가 control-plane로 forward, master엔 로컬 apiserver가 직접 listen |
| 외부 VIP | 203.0.113.211:6443 (실제 LB/VIP IP) |
외부 LB(또는 keepalived VIP)가 직접 받음, worker에 nginx-proxy 없음 |
두 모드 모두 이름해석은 /etc/hosts 단 한 줄에 의존한다(CoreDNS가 아니다 — control plane 부팅 자체가 CoreDNS보다 먼저 와야 하니 OS 수준에서 풀어야 한다). 그래서 그 한 줄이 SPOF다. 이 클러스터는 외부 VIP 모드다(§3에서 증명). 따라서 풀어야 할 이름은 203.0.113.211 lb-apiserver.kubernetes.local이지 127.0.0.1이 아니다. 이 구분이 이 문서 전체를 끌고 간다.
2. 핵심 — 한 줄이 사라지면 무슨 일이 벌어지나 (메커니즘)
멘탈모델 앵커: control-plane의 모든 reconcile은 컴포넌트가 apiserver를
lb-apiserver.kubernetes.local로 찾는 데서 시작하고, 이 클러스터에서 그 이름은/etc/hosts의203.0.113.211 lb-apiserver.kubernetes.local단 한 줄이 해석한다. 그 줄이 사라지면(cloud-init이/etc/hosts를 덮어씀) controller-manager·scheduler·kubelet이 일제히 apiserver에 닿지 못해 모든 control loop가 조용히 멈춘다. 그런데 기존 Pod와 노드Ready표시는 그대로 남아 멀쩡해 보인다 — 이 "살아있는 듯한 정지" 착시가 진단을 가장 어렵게 만든다.
2.1 왜 cloud-init이 그 줄을 지우나
cloud-init의 manage_etc_hosts: True는 부팅마다 /etc/cloud/templates/hosts.debian.tmpl 템플릿으로 /etc/hosts를 통째로 재생성한다. kubespray가 박아둔 lb-apiserver.kubernetes.local 항목은 그 템플릿에 없으므로 재생성될 때마다 날아간다. 평소엔 재부팅이 드물어 잠복하다가, 한 번 재부팅(여기선 2026-05-24)이 일어나는 순간 클러스터 전체가 무너진다. bare-metal kubespray의 알려진 함정이다.
2.2 왜 정지가 "조용한가" — 두 겹의 착시
control loop가 멈췄는데도 클러스터가 멀쩡해 보이는 이유는 두 개의 독립된 캐싱 때문이다.
- 기존 Pod가 살아있는 이유: Pod 실행은 노드 로컬 kubelet의 일이다. apiserver와 끊겨도 kubelet은 이미 받아둔 PodSpec을 계속 굴린다. 그래서
service-a·backend같은 기존 워크로드는 멀쩡히 돈다. - 노드가
Ready로 박제되는 이유: 노드를NotReady로 떨어뜨리는 주체는 controller-manager의 node-lifecycle controller다. 그 controller가 죽었으니, kubelet이 lease를 못 갱신해도 아무도 노드 상태를 강등하지 않는다.Ready는 마지막으로 기록된 값 그대로 stale하게 고정된다.
반대로 새 워크로드는 전 단계가 막힌다: Deployment→ReplicaSet(controller-manager), ReplicaSet→Pod(controller-manager), Pod→Node 바인딩(scheduler) — 이 사슬의 각 단계가 죽은 컨트롤러를 거쳐야 한다. 그래서 istiod Deployment는 RS도 못 만들고 status:{}로 남는다.
2.3 kubectl logs/exec·istioctl proxy-config까지 막히는 같은 뿌리
겉보기엔 별개의 권한 문제 같지만 뿌리는 동일하다. apiserver가 kubectl logs/exec/port-forward를 처리하려면 대상 노드 kubelet의 /proxy로 접속하고, kubelet은 --authorization-mode=Webhook이라 "이 요청을 허용할지"를 apiserver에 SubjectAccessReview(SAR)로 역질의해 판단한다. kubelet이 lb-apiserver.kubernetes.local을 못 푸니 이 SAR 호출이 실패 → 인가 불가 → 클라이언트엔 Authorization error (user=kube-apiserver-kubelet-client, ..., resource=nodes, subresource=proxy)로 표면화된다. 그래서 예전부터 있던 kubectl logs·istioctl 오류도 같은 원인이고, /etc/hosts 복구로 함께 해소된다.
3. 적용 — 표면(Istio)에서 바닥(/etc/hosts)까지 내려간 진단
진단의 사고법은 "각 계층이 책임지는 객체가 실제로 갱신되는가?" 를 한 겹씩 확인하며 내려가는 것이다. istiod가 안 뜨는 것은 증상일 뿐이고, 진짜 질문은 "그 위 계층(controller-manager)이 일을 하는가"다.
(1) 표면 증상 — istiod Deployment에 RS도 status도 없다
$ kubectl -n istio-system get deploy istiod
NAME READY UP-TO-DATE AVAILABLE AGE
istiod 0/1 0 0 59m
$ kubectl -n istio-system get rs # → No resources found (RS 자체가 없음)
$ kubectl -n istio-system get deploy istiod -o jsonpath='{.status}' # → {} (빈 status)
status가 통째로 비고 ReplicaSet이 없음 = deployment controller가 한 번도 reconcile 안 함 = controller-manager가 죽었다는 강한 신호.
(2) controller-manager lease가 9일째 멈춤 — "죽었다"의 결정적 증거
$ kubectl -n kube-system get lease kube-controller-manager \
-o jsonpath='{.spec.renewTime}'
2026-05-23T04:25:10Z # 현재 2026-06-01 → 약 9일간 갱신 없음 (leaseDuration=15s)
$ kubectl -n kube-system get pod kube-controller-manager-k8s-master1
... Terminating (50m), 컨테이너는 Running(42d) # mirror pod는 stuck, 프로세스는 떠있으나 일을 못함
renewTime이 9일 전이라는 건 프로세스는 떠있지만 apiserver에 lease를 갱신하지 못한다 = 연결이 끊겼다는 뜻. 노드 Ready가 아니라 이 값이 control-plane 활성도의 진짜 지표다.
(3) 노드에서 직접 컨테이너 로그 확인 — kubectl logs가 막혔으므로 crictl로
# master1: sudo crictl logs <kube-controller-manager>
E leaderelection.go:347] error retrieving resource lock kube-system/kube-controller-manager:
Get "https://lb-apiserver.kubernetes.local:6443/.../leases/kube-controller-manager?timeout=5s":
context deadline exceeded
# scheduler
... dial tcp: lookup lb-apiserver.kubernetes.local on 1.1.1.1:53: no such host
# kubelet (journalctl -u kubelet)
E kubelet_node_status.go:96] "Unable to register node with API server"
err="... lookup lb-apiserver.kubernetes.local: Temporary failure in name resolution"
세 컴포넌트가 같은 이름을 못 푼다. DNS(1.1.1.1)까지 질의가 새는 건 /etc/hosts에서 못 찾았기 때문.
(4) 이름해석 실패 확정 — /etc/hosts에 줄이 없다
# master1
$ getent hosts lb-apiserver.kubernetes.local # → (빈 결과)
$ grep lb-apiserver /etc/hosts # → (없음)
$ ls /etc/kubernetes/manifests/ # nginx-proxy 없음 (master노드라 정상)
# 포트 자체는 살아있음:
127.0.0.1:6443 REACHABLE (로컬 apiserver)
203.0.113.211:6443 REACHABLE
203.0.113.212:6443 REACHABLE
lb-apiserver.kubernetes.local:6443 UNREACHABLE ← 이름만 못 찾음
IP로는 다 닿는데 이름만 못 푼다 = 순수 이름해석 문제임을 격리.
(5) 범인 — cloud-init이 /etc/hosts를 덮어쓴 흔적
# master1 /etc/hosts (현재, mtime 2026-05-24 23:32)
# Your system has configured 'manage_etc_hosts' as True.
...
127.0.1.1 k8s-master1 k8s-master1
127.0.0.1 localhost
# → kubespray가 넣었던 'lb-apiserver.kubernetes.local' 줄이 사라짐
# 백업 흔적: Apr 19 버전은 770~860B (lb-apiserver 포함), 현재 May 24 버전은 549B (cloud-init 기본 템플릿)
-rw-r--r-- 1 root root 549 May 24 23:32 /etc/hosts
-rw-r--r-- 1 root root 860 Apr 19 08:21 /etc/hosts.29221...~
파일 mtime(2026-05-24 23:32)과 크기 축소(860B→549B)가 재부팅 시 cloud-init 재생성과 정확히 맞물린다.
(6) 모드 확정 + 3노드 전부 동일
# worker1(.213), worker2(.214)
grep lb-apiserver /etc/hosts → [MISSING]
getent hosts lb-apiserver... → (unresolvable)
그리고 백업(/etc/hosts.*~, Apr 19)의 원본 매핑이 203.0.113.211이었다 — worker엔 nginx-proxy도 없어 127.0.0.1:6443이 죽어 있다. 즉 이 클러스터는 localhost-LB가 아니라 외부 VIP 모드다. 이 사실이 §4의 응급 명령을 결정한다.
4. 수리 절차 (★사용자 승인 후 실행 — §4.1은 노드 변경)
4.1 즉시 복구 — 외부 VIP로 이름해석 되살리기
3노드 각각, kubespray 원본과 일치하는 외부 VIP를 박는다:
# /etc/hosts 에 항목 추가 (master/worker 공통 — 이 클러스터는 외부 VIP 모드)
echo '203.0.113.211 lb-apiserver.kubernetes.local' | sudo tee -a /etc/hosts
getent hosts lb-apiserver.kubernetes.local # 검증: 203.0.113.211 출력
- 이 클러스터는 외부 VIP 모드이므로 master·worker 모두
.211을 가리키면 된다. worker엔 nginx-proxy가 없어127.0.0.1:6443은 죽어 있다 — 절대 worker에127.0.0.1을 쓰지 말 것. - (master 한정 임시 stopgap) apiserver가 master 로컬에서 직접 listen하므로, 정말 급하면 master에서만
echo '127.0.0.1 lb-apiserver.kubernetes.local'로 1차 응급을 띄울 수 있다. 단 이건 master에서만 통하고(worker는 죽음), kubespray 원본과 불일치라 다음 static pod 재생성 때.211로 되돌려야 한다(§5 참조). 평시 정답은 처음부터.211이다.
복구 후 컴포넌트는 retry 루프로 자동 재연결되나, 빠른 회복을 위해:
# master1: static pod 재생성(매니페스트 out→in) + kubelet 재기동
sudo systemctl restart kubelet
sudo mv /etc/kubernetes/manifests/kube-controller-manager.yaml /tmp/ && sleep 5 \
&& sudo mv /tmp/kube-controller-manager.yaml /etc/kubernetes/manifests/
static pod를 왜 단순 restart가 아니라 out→in으로 재생성하나? → §5.
4.2 영구 고정 (재부팅에도 살아남기) — 둘 중 택1
- (A) 권장) cloud-init 템플릿에 항목 추가:
/etc/cloud/templates/hosts.debian.tmpl끝에203.0.113.211 lb-apiserver.kubernetes.local추가 → cloud-init이 재생성해도 유지. - (B)
/etc/cloud/cloud.cfg에서manage_etc_hosts: false(또는localhost)로 변경 → cloud-init이/etc/hosts를 더 이상 덮어쓰지 않음. (kubespray 권장 회피책; 다른 호스트 항목도 cloud-init이 관리 안 하게 되는 점 유의)
둘 다 3개 노드 모두 적용. 근본적으로는 kubespray inventory에서
manage_etc_hosts를 끄도록 관리해야 재발 방지.
4.3 검증
kubectl -n kube-system get lease kube-controller-manager -o jsonpath='{.spec.renewTime}' # 현재 시각 근처로 갱신
kubectl -n kube-system get lease kube-scheduler -o jsonpath='{.spec.holderIdentity}'
kubectl get nodes # Ready (이제 진짜로)
kubectl -n kube-system logs kube-controller-manager-k8s-master1 --tail=5 # nodes/proxy 오류 사라짐
kubectl -n kube-system get deploy coredns # AVAILABLE 정상화 (신규 reconcile 동작 증거)
5. 실제 수행한 수리와 결과 (2026-06-01)
매핑 값은 백업(/etc/hosts.*~, Apr 19)에서 확인 → kubespray는 3노드 모두 203.0.113.211(외부 VIP/LB) 사용, worker엔 nginx-proxy 없음 → 외부 VIP 모드로 확정하고 처음부터 .211로 복구했다.
[전 노드 공통] /etc/hosts 백업 후 추가 + cloud-init 템플릿에도 추가(영구):
203.0.113.211 lb-apiserver.kubernetes.local
→ /etc/cloud/templates/hosts.debian.tmpl 에도 동일 추가 (재부팅에도 유지)
[master1] static pod 재생성으로 컨테이너 /etc/hosts 갱신:
mv kube-controller-manager.yaml, kube-scheduler.yaml out→in (apiserver는 미접촉)
※ hostNetwork static pod는 sandbox 생성 시점의 /etc/hosts 스냅샷을 사용하므로
호스트 파일 수정만으로는 실행중 컨테이너에 반영 안 됨 → 재생성 필요
[worker1/2] kubelet은 호스트 프로세스라 호스트 /etc/hosts 즉시 반영:
systemctl restart kubelet
검증 결과:
kube-controller-manager / kube-scheduler lease: 현재 시각으로 실시간 갱신
nodes: master1/worker1/worker2 모두 Ready
istiod pod: Pending → 1/1 Running (control loop 정상 동작 증거)
kubectl logs/exec: nodes/proxy Authorization 오류 해소 (kubelet webhook 경로 복구)
전 네임스페이스 비정상 pod: 0
응급 시 master1에 잠깐
127.0.0.1로 1차 복구했다가 kubespray 원본과 일치시키려203.0.113.211로 정정했다. 실행중이던 pod는 캐시된 옛/etc/hosts로 계속 동작했고, 다음 static pod 재생성 때.211을 받았다 — 즉127.0.0.1은 master-only 임시 stopgap일 뿐이고, kubespray가 다음 정적 pod 재생성에서.211로 다시 덮는다.
6. 다음 작업
- (승인 시) §4 수리 → 검증 → 그 다음에 Istio 1.30.0 teardown + Helm 클린 재설치 진행(Helm 재설치 런북).
- 프로덕션 적용 관점: 프로덕션 kubespray/kubeadm 노드에서
manage_etc_hosts정책과 apiserver LB 경로(외부 VIP vs localhost-LB) 점검 항목으로 기록.
핵심 정리
- apiserver를 이름으로 찾는 구조가 SPOF다: control-plane 컴포넌트가 apiserver를
lb-apiserver.kubernetes.local로 바라보고, 이 클러스터에서 그 이름은/etc/hosts의203.0.113.211 lb-apiserver.kubernetes.local한 줄에만 의존한다(CoreDNS보다 먼저 와야 하므로 OS 수준 해석). 한 줄 = 단일 실패점. - 모드를 먼저 식별하라: kubespray는 localhost-LB(
127.0.0.1)와 외부 VIP(203.0.113.211) 두 토폴로지가 있고, 응급값이 다르다. 이 클러스터는 백업으로 확인한 외부 VIP 모드이므로 정답은.211이다.127.0.0.1은 master에서만 통하는 임시 stopgap. - cloud-init
manage_etc_hosts: True× kubespray 충돌: cloud-init이 부팅마다 템플릿으로/etc/hosts를 덮어써 kubespray 항목을 날린다. bare-metal kubespray의 알려진 함정. - "노드 Ready"는 control-plane 건강의 증거가 아니다: 노드를 강등하는 node-lifecycle controller(controller-manager 소속)가 죽으면
Ready가 stale하게 박제된다. 실제 활성도는 leaserenewTime의 실시간 갱신으로 본다. - 장애 격리 사고법: istiod 0/1 → "RS도 status도 없다" → controller-manager → lease → 컨테이너 로그 → 이름해석 →
/etc/hosts. 표면(앱)에서 바닥(노드 OS)으로 한 겹씩 내려가며 "이 계층이 책임지는 객체가 실제로 갱신되는가"를 확인.
What you might be missing
- localhost-LB(127.0.0.1) vs 외부 VIP(.211) 모드를 복구 전에 백업으로 식별하라. 모드를 잘못 잡으면 응급 복구가 일단 통하더라도 다음 static pod 재생성 시 kubespray 원본으로 뒤집힌다. 이 클러스터는 외부 VIP(
203.0.113.211)였으므로127.0.0.1로 박았다면 다음 재생성에 사라졌을 것이다. 원본 매핑은/etc/hosts.*~백업에 있다. - hostNetwork static pod는 sandbox 생성 시점의
/etc/hosts스냅샷을 박제한다. 호스트 파일만 고쳐도 실행 중인 controller-manager/scheduler 컨테이너엔 반영되지 않는다 → 매니페스트를 out→in 이동해 static pod를 재생성해야 새/etc/hosts가 들어간다. kubelet은 호스트 프로세스라systemctl restart kubelet만으로 즉시 반영되는 것과 대비된다. - worker의
nginx-proxy부재가 모드를 가른다. localhost-LB 모드라면 worker의127.0.0.1:6443을nginx-proxystatic pod가 control-plane로 forward해야 동작한다. 이 pod가 없어127.0.0.1:6443이 죽어 있다는 사실 자체가 "이 클러스터는 외부 VIP 모드"라는 증거였다 — worker에선 반드시 외부 VIP(.211)를 직접 지정해야 한다. - "노드 Ready"는 함정 신호다. 노드 상태를 강등하는 node-lifecycle controller가 죽으면
Ready가 stale하게 박제되므로, 노드 상태가 아니라 leaserenewTime의 실시간 갱신 여부로 control-plane 활성도를 판정해야 한다. kubectl logs/exec·istioctl proxy-config오류도 같은 뿌리다. 별개 권한 문제로 오해하기 쉽지만, kubelet의 Webhook SAR(SubjectAccessReview) 호출이 이름해석 실패로 막힌 결과다 →/etc/hosts복구로 함께 해소된다.