--- type: runbook tags: [kubernetes, control-plane, kubespray, runbook] created: 2026-06-01 --- # 클러스터 control-plane 장애 — `lb-apiserver.kubernetes.local` 이름해석 소실 > [!abstract] > Istio 재설치 중 istiod가 `0/1`로 안 뜨는 원인을 추적하다, 그게 Istio 문제가 아니라 **kubespray control-plane 전체가 ~9일째 마비**였음을 밝혀낸 장애 분석·수리 런북. 단일 실패점은 `/etc/hosts`의 단 한 줄, 범인은 cloud-init, 그리고 가장 위험한 함정은 **"노드는 Ready인데 control loop는 죽어있는" 착시**다. [Helm 재설치 런북](arch__runbook-helm-reinstall.html)의 하드 선행 작업 — 이 수리 없이는 데이터플레인이 뜨지 않는다. **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가 멈췄는데도 클러스터가 멀쩡해 보이는 이유는 **두 개의 독립된 캐싱** 때문이다. ```mermaid flowchart TD A["/etc/hosts: lb-apiserver line gone
(cloud-init overwrote)"] --> B["name resolution fails
on all 3 nodes"] B --> C["controller-manager / scheduler / kubelet
cannot reach apiserver"] C --> D["leader lease renewTime frozen
all control loops stop"] D --> E1["existing Pods keep running
(kubelet runs them locally)"] D --> E2["nodes stay Ready
(node-lifecycle controller is dead,
so nobody demotes them)"] E1 --> F["LOOKS HEALTHY"] E2 --> F D --> G["new workloads stuck:
Deploy→RS→Pod→bind all frozen
(istiod 0/1, status {})"] ``` - **기존 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)이 일을 하는가"다. ```mermaid flowchart TD A["istiod 0/1
status: {} · no ReplicaSet"] --> B{"deploy reconcile?"} B -- "no RS, empty status" --> C["controller-manager 의심"] C --> D{"leader lease
renewTime fresh?"} D -- "stale 9 days · leaseDuration 15s" --> E["crictl logs
(kubectl logs blocked)"] E --> F{"apiserver 연결?"} F -- "name resolution failed" --> G["getent / grep /etc/hosts"] G -- "lb-apiserver entry missing" --> H["root cause:
cloud-init manage_etc_hosts
overwrote /etc/hosts"] ``` **(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 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**를 박는다: ```bash # /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 루프로 자동 재연결되나, 빠른 회복을 위해: ```bash # 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 검증 ```bash 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 재설치 런북](arch__runbook-helm-reinstall.html)). - 프로덕션 적용 관점: 프로덕션 kubespray/kubeadm 노드에서 `manage_etc_hosts` 정책과 apiserver LB 경로(외부 VIP vs localhost-LB) 점검 항목으로 기록. ## 핵심 정리 1. **apiserver를 이름으로 찾는 구조가 SPOF다**: control-plane 컴포넌트가 apiserver를 `lb-apiserver.kubernetes.local`로 바라보고, 이 클러스터에서 그 이름은 `/etc/hosts`의 `203.0.113.211 lb-apiserver.kubernetes.local` 한 줄에만 의존한다(CoreDNS보다 먼저 와야 하므로 OS 수준 해석). 한 줄 = 단일 실패점. 2. **모드를 먼저 식별하라**: kubespray는 localhost-LB(`127.0.0.1`)와 외부 VIP(`203.0.113.211`) 두 토폴로지가 있고, 응급값이 다르다. 이 클러스터는 백업으로 확인한 **외부 VIP 모드**이므로 정답은 `.211`이다. `127.0.0.1`은 master에서만 통하는 임시 stopgap. 3. **cloud-init `manage_etc_hosts: True` × kubespray 충돌**: cloud-init이 부팅마다 템플릿으로 `/etc/hosts`를 덮어써 kubespray 항목을 날린다. bare-metal kubespray의 알려진 함정. 4. **"노드 Ready"는 control-plane 건강의 증거가 아니다**: 노드를 강등하는 node-lifecycle controller(controller-manager 소속)가 죽으면 `Ready`가 stale하게 박제된다. 실제 활성도는 lease `renewTime`의 실시간 갱신으로 본다. 5. **장애 격리 사고법**: 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-proxy` static pod가 control-plane로 forward해야 동작한다. 이 pod가 없어 `127.0.0.1:6443`이 죽어 있다는 사실 자체가 "이 클러스터는 외부 VIP 모드"라는 증거였다 — worker에선 반드시 **외부 VIP(.211)를 직접 지정**해야 한다. - **"노드 Ready"는 함정 신호다.** 노드 상태를 강등하는 node-lifecycle controller가 죽으면 `Ready`가 stale하게 박제되므로, 노드 상태가 아니라 lease `renewTime`의 실시간 갱신 여부로 control-plane 활성도를 판정해야 한다. - **`kubectl logs/exec`·`istioctl proxy-config` 오류도 같은 뿌리다.** 별개 권한 문제로 오해하기 쉽지만, kubelet의 Webhook SAR(SubjectAccessReview) 호출이 이름해석 실패로 막힌 결과다 → `/etc/hosts` 복구로 함께 해소된다.