🏠 목록 클러스터 control-plane 장애 — lb-apiserver.kubernetes.local 이름해석 소실 📄 MD 원본 🌓 테마
kubernetescontrol-planekubesprayrunbook

클러스터 control-plane 장애 — lb-apiserver.kubernetes.local 이름해석 소실

NOTE

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/hosts203.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가 멈췄는데도 클러스터가 멀쩡해 보이는 이유는 두 개의 독립된 캐싱 때문이다.

/etc/hosts: lb-apiserver gone cloud-init overwrote name resolution fails on all 3 nodes cm / scheduler / kubelet cannot reach apiserver leader lease renewTime frozen all control loops stop existing Pods keep running kubelet runs them locally nodes stay Ready node-lifecycle dead, nobody demotes them new workloads stuck Deploy→RS→Pod→bind frozen istiod 0/1, status {} LOOKS HEALTHY (착시)
그림 1. "멀쩡해 보이는" 장애의 인과. leader lease 동결로 모든 control loop가 멈춰도 기존 Pod와 노드는 kubelet/캐싱 덕에 그대로라 healthy처럼 보인다 — 진짜 증상은 새 워크로드가 전혀 안 뜨는 것(istiod 0/1, status {}).

반대로 새 워크로드는 전 단계가 막힌다: 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)이 일을 하는가"다.

istiod 0/1 status: {} · no ReplicaSet deploy reconcile? no RS, empty status controller-manager 의심 leader lease renewTime fresh? stale 9 days · leaseDuration 15s crictl logs (kubectl logs blocked) apiserver 연결? name resolution failed getent / grep /etc/hosts lb-apiserver entry missing root cause cloud-init manage_etc_hosts overwrote /etc/hosts
그림 1. 진단 cascade — istiod 0/1(빈 status·RS 없음) → controller-manager lease 9일 stale → crictl 로그상 apiserver 이름해석 실패 → /etc/hosts에서 lb-apiserver 항목 소실 = cloud-init이 덮어쓴 근본 원인.

(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 출력

복구 후 컴포넌트는 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

둘 다 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.1master-only 임시 stopgap일 뿐이고, kubespray가 다음 정적 pod 재생성에서 .211로 다시 덮는다.


6. 다음 작업

핵심 정리

  1. apiserver를 이름으로 찾는 구조가 SPOF다: control-plane 컴포넌트가 apiserver를 lb-apiserver.kubernetes.local로 바라보고, 이 클러스터에서 그 이름은 /etc/hosts203.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