쿠버네티스 내부 이해
- 11.1 아키텍처 개요 — Control Plane과 노드
- 11.2 컨트롤러들의 협업
- 11.3 실행 중인 Pod는 실제로 무엇인가
- 11.4 Pod 네트워킹
- 11.5 Service는 어떻게 구현되는가
- 11.6 고가용성 클러스터
11장은 한 마디로 "지금까지 써온 쿠버네티스가 내부적으로 어떻게 돌아가는가"를 보여주는 장이야. 1장에서 Control Plane 컴포넌트들을 간단히 소개만 했었는데, 이 장에선 각 컴포넌트의 책임, 서로 어떻게 협업하는지, 그리고 Pod·Service·네트워크가 실제로 어떻게 구현되는지를 파고들어. 운영 중에 뭔가 이상할 때 어디부터 봐야 할지를 알게 해주는 장이야.
먼저 클러스터는 두 종류 노드로 구성돼. **Control Plane(master)**에는 클러스터 전체 상태를 저장하는 분산 KV 스토어이자 유일한 영속 저장소인 etcd, 모든 컴포넌트가 통신하는 중앙 허브이자 REST API를 제공하고 검증·권한 확인·etcd 쓰기를 담당하는 API Server, 새 Pod를 어느 노드에 배치할지 결정하는 Scheduler, 여러 컨트롤러(ReplicaSet, Deployment, Node, Endpoints 등)를 실제로 돌리는 Controller Manager가 있어. Worker 노드에는 API 서버와 대화하며 자기 노드의 컨테이너 수명주기를 관리하는 Kubelet, Service 로드밸런싱을 담당(iptables 규칙 관리)하는 kube-proxy, 그리고 실제 컨테이너를 돌리는 Container Runtime(Docker, containerd, CRI-O 등)이 있고.
여기서 가장 중요한 원칙 — 모든 통신은 API Server를 통해서만 일어나. 컴포넌트들이 서로 직접 말하지 않아. Scheduler가 Kubelet에게 "이 Pod 띄워"라고 직접 말하지 않거든. 대신 Scheduler가 Pod 객체에 nodeName을 써서 API Server에 업데이트하면, 해당 노드의 Kubelet이 API Server를 watch하고 있다가 변화를 감지하고, 그 Pod를 자기 노드에 실행해. 이 허브-앤-스포크 구조 덕분에 컴포넌트를 독립적으로 교체·확장할 수 있고, 모든 상태 변화가 etcd에 기록되어 추적 가능해져.
etcd에 대해 좀 더 보면, Raft 합의 알고리즘을 쓰는 분산 KV 스토어야. 쿠버네티스는 etcd에 리소스를 JSON 또는 protobuf로 저장하고, 오직 API Server만 직접 etcd에 접근해. 왜 그러냐면 낙관적 동시성(optimistic concurrency) 제어를 한 곳에 모으기 위해서야 — 여러 컨트롤러가 동시에 같은 리소스를 수정하려 할 때 충돌을 감지해야 하거든. 검증과 권한 확인을 한 곳에서 할 수 있고, 나중에 etcd를 다른 저장소로 바꿔도 나머지 컴포넌트에 영향이 없어. etcd 클러스터는 **홀수 개(3, 5, 7)**로 구성해. Raft가 과반수 합의를 요구하기 때문이야. 2개면 하나 죽을 때 과반수가 안 나와서 전체가 멈춰. 그래서 "2대가 1대보다 나쁘다"는 말이 있어.
API Server는 들어오는 요청에 대해 인증(누구냐) → 권한 확인(이걸 할 수 있냐, RBAC 등) → Admission Control(기본값 채우기, 추가 검증, 변형) → 검증(스키마 맞냐) → etcd에 쓰기 + 응답 순서로 처리해. 그리고 핵심적인 메커니즘 하나가 watch야. 클라이언트가 API Server에 "리소스가 변하면 알려줘"라고 long-polling/streaming 연결을 열어둘 수 있거든. Scheduler, 각 컨트롤러, Kubelet 전부 watch로 동작해. 그래서 변화가 거의 실시간으로 퍼져 — polling이 아니야.
Scheduler는 새 Pod(nodeName이 비어있는)가 만들어지면 필터링(nodeSelector, 리소스 요청량, taint/toleration, affinity 등을 만족하는 노드만 남김), 우선순위 결정(남은 노드들에 점수를 매겨 최선의 노드 선택), 그리고 API Server에 nodeName을 써서 해당 노드에 배정하는 일을 해. 중요한 건 Scheduler가 Pod를 직접 실행하지 않는다는 거야. 그건 Kubelet 몫이고. Scheduler가 하는 일은 "nodeName 필드에 값 쓰기"뿐이야. 근데 그게 핵심이지.
Controller Manager 프로세스 안에는 여러 컨트롤러가 돌아가. ReplicaSet Controller는 RS와 Pod 수를 맞추고, Deployment Controller는 Deployment와 RS를 맞추고, Node Controller는 노드 상태를 모니터링해서 heartbeat 끊기면 NotReady로 마킹하고, Endpoints Controller는 Service의 selector와 매칭되는 Pod IP를 Endpoints 객체에 반영해. 이 외에도 ServiceAccount Controller, PV Controller, Namespace Controller 등등이 있어. 공통 패턴은 watch → 현재 상태와 desired 비교 → 차이만큼 작업 수행이야. 이게 reconciliation loop고, 전부 이 패턴을 따라.
Kubelet은 자기 노드에서 노드를 API Server에 등록하고, 자기 노드에 배정된 Pod를 watch하고 Container Runtime에게 실행을 지시하고, liveness·readiness probe를 실행하고, 상태를 API Server에 계속 보고해. Kubelet은 자기 노드만 봐. 다른 노드 일에는 관여하지 않아. kube-proxy는 Service로 들어오는 트래픽을 Pod로 분산하는데, 두 가지 모드가 있고 현대엔 거의 iptables 모드를 써 (이전엔 userspace 모드). kube-proxy는 Service와 Endpoints를 watch하다가 iptables 규칙을 생성·업데이트해. 실제 트래픽 처리는 커널의 iptables(또는 nftables, IPVS)가 해 — kube-proxy는 규칙 관리자일 뿐이야. 그래서 "서비스 IP로 커넥션이 가는 건 되는데 ping은 안 된다"는 말이 이제 이해되지. 서비스 IP는 실체가 없고, TCP/UDP 패킷에 대해 iptables DNAT 규칙으로 Pod IP로 바꾸는 것뿐이거든. ICMP에 대한 규칙이 없으니 ping은 응답이 없어.
이제 한 가지 명령의 여정을 따라가 보자. 사용자가 kubectl create -f deployment.yaml을 하면, 먼저 kubectl이 API Server에 POST를 보내고, API Server가 검증·인증·권한·admission을 처리한 뒤 etcd에 Deployment를 저장해. Deployment Controller가 새 Deployment를 watch로 감지해서 해당 ReplicaSet을 생성하고(API Server에 POST), ReplicaSet Controller가 새 RS를 감지해서 replicas 수만큼 Pod를 생성해(각각 API Server에 POST, nodeName은 비어있음). 그러면 Scheduler가 새 Pod(nodeName 없음)를 감지해서 노드를 선택한 뒤 nodeName을 업데이트하고, 해당 노드의 Kubelet이 자기 노드에 배정된 Pod를 감지해서 Docker에게 이미지 pull과 실행을 지시해. Kubelet이 Pod 상태를 API Server에 보고하고, Endpoints Controller가 Pod가 Ready 되면 Service의 Endpoints에 Pod IP를 추가하고, 마지막으로 kube-proxy가 Endpoints 변화를 감지해서 iptables 규칙을 갱신해. 놀라운 점은 이 컨트롤러들이 서로를 모른다는 거야. 각자 자기 관심 리소스만 watch하고 자기 상태만 맞추는데도 전체적으로 원하는 결과가 나와. 이게 controller-based architecture의 우아함이야. 디버깅할 땐 이 흐름을 역순으로 따라가면 어디서 막혔는지 알 수 있어. "Pod가 Pending이야" → Scheduler가 못 골랐나? → kubectl describe pod의 events 보기 → 노드 리소스 부족 / taint / 이미지 pull 실패 등.
Pod가 노드에서 실제로 돌 때, Docker는 Pod당 컨테이너를 N+1개 만들어. 사용자가 정의한 컨테이너들 + pause 컨테이너(infrastructure container) 하나야. pause 컨테이너는 뭘 하냐면 — Pod의 Linux 네임스페이스(network, IPC 등)를 들고 있는 껍데기야. 아무 일도 안 하고 pause 시스템 콜만 부르고 잠들어. 다른 컨테이너들은 이 pause 컨테이너의 네임스페이스를 공유하는 식으로 붙어. 왜 이런 식이냐면, 애플리케이션 컨테이너 하나가 크래시해서 재시작되어도 Pod의 네트워크 네임스페이스(즉 IP)는 pause 컨테이너가 들고 있으니 유지되거든. 애플리케이션 컨테이너가 다 죽었다 살아나도 Pod IP가 바뀌지 않는 이유야.
쿠버네티스의 네트워크 모델은 세 요구사항이야 — 모든 Pod는 서로 NAT 없이 통신 가능해야 하고, 노드와 Pod도 NAT 없이 통신 가능해야 하고, Pod가 보는 자기 IP와 다른 Pod가 보는 그 Pod의 IP가 같아야 해. 쿠버네티스 자체는 이걸 구현하지 않아. CNI 플러그인(Calico, Flannel, Cilium, Weave 같은 거)이 이 요구사항을 만족하도록 네트워크를 구성해. 같은 노드 안의 Pod들은 노드의 브리지(보통 cbr0)에 veth pair 한쪽 끝이 꽂히는 식으로 연결돼서 L2로 서로 봐. 노드 간 Pod 통신은 CNI 플러그인마다 다른데, Flannel의 overlay(VXLAN)는 패킷을 UDP로 감싸서 노드 간에 보내는 단순하지만 오버헤드가 있는 방식이고, Calico의 BGP는 각 노드를 라우터처럼 다루고 BGP로 라우팅 정보를 교환해서 오버레이 없이 원래 네트워크를 활용하고, Cilium의 eBPF는 커널 eBPF 프로그램으로 처리해. 네트워크 구현은 쿠버네티스 밖의 문제고, 쿠버네티스는 **CNI(Container Network Interface)**로 플러그인에게 위임해.
Service의 ClusterIP는 실제 네트워크 인터페이스에 붙어있는 IP가 아니야. 그래서 ping이 안 돼. 이 가상 IP가 의미를 가지는 건 각 노드의 iptables 규칙 덕분이야. 흐름은 사용자가 Service를 생성하면 → Endpoints Controller가 selector에 매칭되는 Pod IP들을 모아 Endpoints 객체를 만들고 → kube-proxy가 Service와 Endpoints를 watch하다가 노드의 iptables 규칙을 업데이트해 ("서비스 ClusterIP:port로 가는 패킷은 백엔드 Pod 중 하나로 DNAT", "소스 Pod가 이 서비스를 거쳐 나갈 땐 SNAT/마스커레이드 적절히") → Pod가 서비스 IP로 커넥션을 열면 커널 netfilter가 패킷을 가로채 Pod IP로 바꿔서 전달해. 모든 일이 커널에서 일어나. kube-proxy는 트래픽 경로에 없어. kube-proxy가 죽어도 기존 커넥션은 그대로 흘러 — 단지 새 서비스·Endpoints 변화가 iptables에 반영되지 않을 뿐이야. 최근 버전엔 IPVS 모드도 있어. iptables는 규칙 수가 많아지면 O(n) 탐색이라 수천 개 서비스 규모에서 느려지는데, IPVS는 해시 테이블 기반이라 더 빨라.
마지막으로 고가용성. 프로덕션에선 단일 장애점이 없어야 해. 두 측면이 있어. 앱 자체를 HA로 만들기 위해선 여러 replica로 실행(ReplicaSet/Deployment)하고, 여러 노드에 퍼뜨리고(Pod anti-affinity), 리더 선출이 필요한 경우(프로세스 하나만 활성이어야 하는 워크로드)엔 쿠버네티스 API의 리스 메커니즘을 활용해. Control Plane HA는 etcd를 3/5/7개 홀수 노드로 클러스터링하고, API Server를 여러 노드에 띄우고 앞에 로드밸런서를 둬 — stateless라서 가능해. Scheduler와 Controller Manager는 여러 인스턴스를 띄우되 리더 선출로 활성 하나만 작동해. 이들이 동시에 돌면 같은 Pod를 두 곳에 배치하려 드는 참사가 일어나거든. 리더가 죽으면 다른 인스턴스가 리더가 돼. kubelet과 kube-proxy는 노드마다 하나씩 자연스럽게 분산돼 있어서 별도 HA 고민이 없어.
정리
11장 읽고 기억할 거 세 가지:
- 모든 통신은 API Server를 거치고, 모든 상태는 etcd에 있다. 컴포넌트들은 서로 직접 말하지 않고 API Server의 watch로 변화를 감지하며 독립적으로 동작한다. 각 컨트롤러는 "내 리소스 watch → 현재 vs desired 비교 → 차이만큼 작업"이라는 reconciliation loop의 반복일 뿐이고, 이 단순한 패턴이 모여 복잡한 오케스트레이션이 이뤄진다.
- Pod는 pause 컨테이너를 중심으로 묶여있다. 네트워크·IPC 네임스페이스는 pause가 들고 있고, 앱 컨테이너들은 거기에 붙는다. 그래서 앱 컨테이너가 죽었다 살아나도 Pod IP가 유지된다. Pod 네트워킹 자체는 쿠버네티스가 아니라 CNI 플러그인의 영역(Calico, Flannel 등)이다.
- Service IP는 실체가 없는 가상 IP고, 각 노드의 iptables 규칙이 그 가상 IP를 실제 Pod IP로 DNAT한다. 이 규칙은 kube-proxy가 관리하지만 트래픽 경로 자체엔 kube-proxy가 없다 — 커널 netfilter가 처리한다. ping이 안 되는 이유, kube-proxy가 잠깐 멈춰도 기존 커넥션이 살아있는 이유가 여기서 나온다.