---
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` 복구로 함께 해소된다.