Certified Kubernetes Administrator (CKA) #3 클러스터 아키텍처 2: Node (kubelet/kube-proxy/CRI), Pod 네트워킹 모델
#2 클러스터 아키텍처 1에서는 control plane의 네 컴포넌트가 어떻게 클러스터의 결정을 내리는지 살펴봤습니다. apiserver가 통신의 관문이 되고, etcd가 상태를 저장하고, scheduler가 Pod의 배치를 정하고, controller-manager가 reconciliation loop를 돌립니다. 그런데 이 모든 결정은 결정일 뿐입니다. 실제로 컨테이너를 띄우고, 트래픽을 흘려보내고, 디스크와 네트워크를 붙이는 일은 전부 워커 노드에서 일어납니다.
이번 글은 그 노드를 들여다봅니다. 노드 위에서 도는 세 컴포넌트(kubelet, kube-proxy, 컨테이너 런타임)가 각각 무슨 일을 하는지, kubelet과 런타임을 잇는 CRI라는 표준이 무엇인지, 그리고 모든 Pod가 서로를 직접 부를 수 있게 만드는 Pod 네트워킹 모델과 그것을 실제로 구현하는 CNI 플러그인까지 운영 관점에서 정리하겠습니다.
노드는 control plane이 내린 결정을 실행한다 #
쿠버네티스를 두 층으로 나눠 보면 이해가 빠릅니다. control plane은 “무엇을 어디에 띄울지"를 정하는 두뇌이고, 워커 노드는 그 결정을 받아 “실제로 컨테이너를 띄우는” 손발입니다. scheduler가 “이 Pod를 node01에 배치한다"고 결정하면, 그 결정은 etcd에 저장될 뿐 아직 아무 컨테이너도 뜨지 않았습니다. node01의 kubelet이 그 결정을 읽고, 컨테이너 런타임에게 컨테이너를 띄우라고 지시하는 순간 비로소 워크로드가 실행됩니다.
그래서 노드 컴포넌트를 이해하면 트러블슈팅의 절반이 풀립니다. Pod가 Pending에서 멈췄다면 scheduler 쪽 문제일 수 있지만, ContainerCreating에서 멈췄다면 kubelet이나 런타임, 혹은 CNI 쪽을 봐야 합니다. 노드가 NotReady가 되면 그 노드에 있던 Pod 전체가 영향을 받습니다. 노드의 세 컴포넌트는 다음과 같습니다.
| 컴포넌트 | 역할 | 어디서 도는가 |
|---|---|---|
| kubelet | 노드 에이전트. Pod를 실제로 실행하고 상태를 보고 | 모든 노드(control plane 노드 포함) |
| kube-proxy | Service의 가상 IP를 노드의 라우팅 규칙으로 구현 | 모든 노드(보통 DaemonSet) |
| 컨테이너 런타임 | 컨테이너 이미지를 받아 실제 컨테이너를 띄움 | 모든 노드 |
control plane 노드도 사실은 워커 노드입니다. control plane 컴포넌트 자체가 static Pod로 떠 있기 때문에, control plane 노드 위에서도 kubelet과 런타임이 돕니다. #2에서 본 apiserver와 etcd가 static Pod로 뜨는 것도 결국 그 노드의 kubelet이 띄워 주는 것입니다.
kubelet: 노드의 에이전트 #
kubelet은 모든 노드에서 도는 단 하나의 에이전트이며, 노드 컴포넌트 중 가장 핵심입니다. 다른 컴포넌트가 Pod로 떠 있는 것과 달리, kubelet은 노드의 systemd 서비스로 직접 돕니다. kubelet이 Pod가 아니라 서비스인 이유는 분명합니다. kubelet이 Pod를 띄우는 주체인데, 그 자신이 Pod라면 닭이 먼저냐 달걀이 먼저냐의 문제에 빠지기 때문입니다.
kubelet이 하는 일을 정리하면 다음과 같습니다.
- Pod 실행. apiserver를 watch하다가 자기 노드에 배치된 Pod가 생기면, 컨테이너 런타임에게 컨테이너를 띄우라고 지시합니다. PodSpec에 적힌 이미지, 볼륨, 환경 변수, probe 설정을 모두 런타임에 전달합니다.
- 상태 보고. 자기 노드의 상태(Ready 여부, 리소스 가용량)와 각 Pod의 상태를 주기적으로 apiserver에 보고합니다.
k get nodes와k get pods에 보이는 상태는 결국 kubelet이 올린 보고입니다. - probe 실행. livenessProbe, readinessProbe, startupProbe를 실제로 실행하는 주체가 kubelet입니다. liveness가 실패하면 kubelet이 직접 컨테이너를 재시작합니다.
- static Pod 관리. kubelet은 apiserver 없이도
/etc/kubernetes/manifests에 놓인 매니페스트를 읽어 Pod를 띄웁니다. 이것이 static Pod이며, control plane 컴포넌트가 바로 이 방식으로 부트스트랩됩니다.
static Pod는 CKA에서 특히 중요합니다. apiserver가 죽어 있어도 kubelet은 이 디렉터리만 보고 Pod를 띄울 수 있으므로, control plane이 “스스로를 띄우는” 부트스트랩이 가능합니다. apiserver의 매니페스트를 잘못 고쳐서 apiserver가 안 뜨는 상황은 #24에서 다루는 대표적인 트러블슈팅 시나리오입니다.
# kubelet은 systemd 서비스로 돈다
systemctl status kubelet
# kubelet 로그 (NotReady 원인 추적의 1순위)
journalctl -u kubelet -f
# static Pod 매니페스트 디렉터리
ls /etc/kubernetes/manifests/kubelet이 죽으면 그 노드는 더 이상 apiserver에 상태를 보고하지 못하므로, 잠시 뒤 노드가 NotReady로 바뀝니다. 다만 이미 떠 있던 Pod는 kubelet이 죽었다고 곧바로 사라지지 않습니다. kubelet이 다시 살아나면 그 Pod들을 다시 관리하기 시작합니다. NotReady가 되는 구체적 원인과 복구 절차는 #23에서 본격적으로 다루겠습니다.
kube-proxy: Service를 노드 위에서 구현한다 #
kube-proxy는 Service의 가상 IP를 실제 라우팅으로 바꿔 주는 컴포넌트입니다. Service가 무엇인지는 #18에서 자세히 다루겠지만, 여기서 핵심만 짚으면 이렇습니다. ClusterIP Service는 10.96.0.10 같은 가상 IP를 가지는데, 이 IP를 가진 네트워크 인터페이스는 클러스터 어디에도 없습니다. 그저 약속된 주소일 뿐입니다.
그렇다면 그 가상 IP로 보낸 트래픽은 어떻게 실제 Pod에 도착할까요. 바로 kube-proxy가 각 노드에 라우팅 규칙을 깔아 두기 때문입니다. kube-proxy는 apiserver를 watch하다가 Service와 그 뒤의 Pod(endpoints)가 바뀌면, 노드의 커널 규칙을 갱신합니다. 결과적으로 “이 가상 IP로 가는 패킷은 실제 Pod IP 중 하나로 보낸다"는 규칙이 모든 노드에 깔립니다.
kube-proxy는 보통 DaemonSet으로 배포되어 모든 노드에 한 개씩 돕니다. 어느 노드에서 보낸 트래픽이든 그 노드의 규칙을 거쳐 목적지 Pod로 가야 하므로, 규칙은 모든 노드에 있어야 합니다.
iptables 모드와 IPVS 모드 #
kube-proxy가 규칙을 까는 방식에는 크게 두 가지가 있습니다.
| 모드 | 동작 | 특징 |
|---|---|---|
| iptables | 커널의 iptables 규칙으로 DNAT 처리 | 기본값. 안정적. Service가 많아지면 규칙 평가가 선형으로 늘어 성능이 떨어짐 |
| IPVS | 커널의 IPVS(해시 테이블)로 처리 | Service 수가 많은 대규모 클러스터에 유리. 다양한 로드밸런싱 알고리즘 지원 |
기본은 iptables 모드입니다. Service가 수천 개 규모로 늘어나는 대규모 클러스터에서는 iptables 규칙을 순차로 평가하는 비용이 커지므로, 해시 기반의 IPVS 모드가 더 나은 성능을 냅니다. CKA에서는 두 모드의 차이와 어느 쪽이 대규모에 유리한지 정도를 알아 두면 충분합니다. 어느 모드인지는 kube-proxy의 ConfigMap이나 로그에서 확인할 수 있습니다.
컨테이너 런타임과 CRI #
kubelet은 컨테이너를 직접 띄우지 않습니다. kubelet은 “이 이미지로 컨테이너를 하나 띄워라"라고 지시할 뿐, 실제로 이미지를 받아 네임스페이스와 cgroup을 만들고 프로세스를 띄우는 일은 컨테이너 런타임이 합니다. 오늘날 가장 널리 쓰이는 런타임은 containerd이며, CRI-O도 많이 쓰입니다.
CRI: kubelet과 런타임 사이의 표준 인터페이스 #
kubelet이 어떤 런타임과도 같은 방식으로 대화할 수 있는 이유는 **CRI(Container Runtime Interface)**라는 표준이 있기 때문입니다. CRI는 kubelet과 런타임 사이에 놓인 gRPC 기반 표준 인터페이스입니다. kubelet은 CRI 규약대로 “컨테이너를 띄워라”, “이미지를 받아라” 같은 요청을 보내고, 그 규약을 구현한 런타임이라면 무엇이든 그 요청을 처리합니다.
과거에는 kubelet 안에 Docker를 직접 호출하는 dockershim이라는 어댑터가 들어 있었습니다. 그런데 Docker는 CRI를 직접 구현하지 않아 별도 어댑터가 필요했고, 이 dockershim은 쿠버네티스 1.24에서 제거되었습니다. 그 결과 지금의 표준 경로는 kubelet → CRI → containerd(또는 CRI-O) → 컨테이너입니다. Docker로 만든 이미지(OCI 이미지)는 그대로 쓸 수 있으니 이미지 호환성은 문제가 없습니다. 단지 노드에서 컨테이너를 띄우는 주체가 Docker 데몬이 아니라 containerd가 되었을 뿐입니다.
crictl: CRI 레벨에서 컨테이너를 들여다본다 #
런타임이 containerd로 바뀌면서, 노드에서 컨테이너를 직접 확인할 때는 docker 대신 **crictl**을 씁니다. crictl은 CRI를 통해 런타임과 대화하는 디버깅 도구입니다.
# 노드에서 도는 컨테이너 확인 (docker ps의 CRI 버전)
crictl ps
# 컨테이너 이미지 목록
crictl imageskubelet이 보고하는 Pod와 crictl이 보여 주는 컨테이너가 어긋날 때(예: kubelet은 살아 있다는데 컨테이너가 안 뜸) 런타임 레벨의 문제를 의심하게 됩니다. 트러블슈팅에서 노드 안으로 한 단계 더 내려가는 도구입니다.
노드 등록과 상태 확인 #
노드가 클러스터에 합류하면(이 조인 과정은 #4에서 kubeadm으로 직접 해 봅니다) 그 노드의 kubelet이 apiserver에 자신을 등록하고, 이후 주기적으로 상태를 보고합니다. 관리자가 가장 먼저 보는 명령은 k get nodes입니다.
# 노드 목록과 상태
k get nodes
# 노드의 IP, OS, 커널, 컨테이너 런타임까지 한눈에
k get nodes -o wide-o wide를 붙이면 내부 IP, 운영체제, 커널 버전, 그리고 컨테이너 런타임 버전(예: containerd://1.7.x)까지 보입니다. 어느 노드가 어떤 런타임을 쓰는지 한 번에 확인할 수 있습니다.
노드의 STATUS는 보통 Ready이지만, 다음 같은 이유로 NotReady가 될 수 있습니다.
- kubelet이 죽었거나 시작하지 못함. 가장 흔한 원인.
systemctl status kubelet과journalctl -u kubelet이 첫 확인 지점 - 컨테이너 런타임이 죽음. kubelet이 CRI로 런타임에 닿지 못하면 노드를 정상으로 보고할 수 없음
- CNI가 준비되지 않음. 네트워크 플러그인이 아직 안 깔렸거나 망가지면 노드가 NotReady에 머묾
- 리소스 압박. disk pressure, memory pressure 같은 condition이 붙어 정상 스케줄링을 막음
여기서는 “NotReady가 보이면 어디부터 보는가"의 큰 그림만 잡고, 구체적인 원인별 복구는 #23에서 단계별로 다루겠습니다.
Pod 네트워킹 모델 #
쿠버네티스 네트워킹의 출발점은 단 하나의 약속입니다. 모든 Pod는 NAT 없이 서로의 IP로 직접 통신할 수 있어야 한다는 것입니다. 이 모델을 풀어 쓰면 다음과 같습니다.
- 모든 Pod는 고유한 IP를 가진다. 같은 노드에 있든 다른 노드에 있든 마찬가지다.
- 한 Pod는 다른 Pod의 IP로 NAT 없이 직접 패킷을 보낼 수 있다.
- 노드 위의 에이전트(kubelet 등)도 그 노드의 Pod와 직접 통신할 수 있다.
이 약속 덕분에 개발자는 Pod가 어느 노드에 있는지 신경 쓰지 않고 IP만으로 통신을 설계할 수 있습니다. 전통적인 가상화 환경에서 흔히 보던 포트 매핑이나 NAT가 Pod 사이에는 없습니다.
Pod CIDR과 노드별 분할 #
이 모델을 구현하려면 IP가 겹치면 안 됩니다. 그래서 클러스터에는 전체 Pod에 쓸 큰 IP 대역인 Pod CIDR(예: 10.244.0.0/16)을 정해 두고, 이 대역을 노드별로 잘게 나눠 줍니다. 예를 들어 node01은 10.244.1.0/24, node02는 10.244.2.0/24를 받는 식입니다. 각 노드는 자기에게 할당된 서브넷 안에서만 Pod에 IP를 나눠 주므로, 클러스터 전체에서 Pod IP가 겹치지 않습니다.
그러면 node01의 Pod가 node02의 Pod에게 보낸 패킷은 어떻게 노드 경계를 넘어갈까요. 이것을 노드 간 라우팅으로 풀어 주는 것이 다음에 볼 CNI 플러그인입니다.
pause 컨테이너 #
Pod 안의 여러 컨테이너가 같은 IP와 같은 네트워크 네임스페이스를 공유하는 이유는, Pod마다 pause 컨테이너라는 보이지 않는 컨테이너가 먼저 뜨기 때문입니다. pause 컨테이너가 네트워크 네임스페이스를 잡아 두고, 같은 Pod의 다른 컨테이너들이 그 네임스페이스에 합류하는 구조입니다. 그래서 한 Pod 안의 컨테이너끼리는 localhost로 서로를 부를 수 있습니다.
CNI 플러그인이 실제 네트워크를 구현한다 #
쿠버네티스 자체는 “Pod 네트워킹 모델"이라는 규칙만 정하고, 그 규칙을 실제 네트워크로 구현하는 일은 CNI(Container Network Interface) 플러그인에 맡깁니다. kubelet은 Pod를 띄울 때 CNI 플러그인을 호출해 그 Pod에 IP를 붙이고 네트워크에 연결합니다. CNI가 깔리지 않은 노드는 Pod에 네트워크를 줄 수 없으므로 NotReady에 머뭅니다.
대표적인 CNI 플러그인은 다음과 같습니다.
| 플러그인 | 특징 |
|---|---|
| Calico | BGP 기반 라우팅, 풍부한 NetworkPolicy 지원으로 널리 쓰임 |
| Cilium | eBPF 기반. 고성능과 세밀한 보안 정책 |
| Flannel | 단순한 overlay 네트워크. 설정이 쉬워 학습과 소규모에 적합 |
CNI 플러그인의 구조와 NetworkPolicy의 동작 원리를 더 깊이 보려면 쿠버네티스 심화 시리즈의 CNI 편을 함께 읽어 보길 권합니다. CKA 범위에서는 “CNI가 Pod 네트워킹 모델을 구현하며, 이것이 없으면 노드가 NotReady가 된다"는 인과를 잡는 것이 우선입니다.
노드를 확인하는 핵심 명령 #
이번 글에서 다룬 컴포넌트를 실제로 확인하는 명령을 한곳에 모으면 다음과 같습니다.
# 노드 상태와 런타임 한눈에
k get nodes -o wide
# kubelet 상태 (NotReady의 1순위 확인)
systemctl status kubelet
journalctl -u kubelet -f
# 노드에서 도는 컨테이너 (CRI 레벨)
crictl ps
# kube-proxy와 CNI는 보통 kube-system 네임스페이스의 Pod로 확인
k get pods -n kube-system -o wide마지막 명령으로 kube-proxy와 CNI 플러그인(예: calico-node)이 각 노드에 정상적으로 떠 있는지 확인할 수 있습니다. 노드 하나만 NotReady라면 그 노드의 kube-proxy나 CNI Pod가 떠 있는지 보는 것이 단서가 됩니다.
시험 포인트 #
- kubelet은 systemd 서비스로 돈다. Pod가 아니다. NotReady 추적의 1순위는
systemctl status kubelet과journalctl -u kubelet이다. - static Pod는 kubelet이
/etc/kubernetes/manifests를 보고 apiserver 없이 띄운다. control plane 컴포넌트가 이 방식으로 부트스트랩된다. - kube-proxy는 Service의 가상 IP를 노드의 라우팅 규칙으로 구현한다. iptables(기본)와 IPVS(대규모 유리) 두 모드를 구분한다.
- CRI는 kubelet과 런타임 사이의 표준 인터페이스다. dockershim이 1.24에서 제거된 뒤 containerd/CRI-O가 표준이며, 노드에서는
docker가 아니라crictl ps로 확인한다. - Pod 네트워킹 모델은 “모든 Pod가 NAT 없이 직접 통신"이다. Pod CIDR을 노드별로 분할하고, 실제 구현은 CNI 플러그인(Calico/Cilium/Flannel)이 한다. CNI가 없으면 노드가 NotReady에 머문다.
k get nodes -o wide로 노드별 컨테이너 런타임 버전까지 확인할 수 있다.
정리 #
이번 글에서 잡은 것:
- 노드는 control plane의 결정을 실행하는 손발. scheduler가 정한 배치를 kubelet이 받아 런타임에게 컨테이너를 띄우게 한다.
- kubelet. 노드 에이전트. Pod 실행, 상태 보고, probe 실행, static Pod 관리. systemd 서비스로 돈다.
- kube-proxy. Service가상 IP를 iptables/IPVS 규칙으로 구현. 보통 DaemonSet.
- 컨테이너 런타임과 CRI. kubelet은 CRI를 통해 containerd/CRI-O와 대화한다. dockershim은 1.24에서 제거. 확인은
crictl. - Pod 네트워킹 모델. NAT 없는 Pod 간 통신, 노드별 Pod CIDR, pause 컨테이너, CNI 플러그인이 실제 구현.
다음: kubeadm 클러스터 설치 #
아키텍처를 control plane(#2)과 노드(이번 글) 두 층으로 모두 훑었습니다. 이제 이 컴포넌트들을 직접 손으로 세울 차례입니다.
#4 kubeadm 클러스터 설치에서는 빈 리눅스 머신에 컨테이너 런타임과 kubeadm을 깔고, kubeadm init으로 단일 control plane을 부트스트랩한 뒤, CNI 플러그인을 적용하고, 워커 노드를 kubeadm join으로 합류시키는 과정을 처음부터 끝까지 따라가겠습니다. 이번 글에서 본 kubelet, CRI, CNI가 어떻게 한 자리에서 맞물리는지 손으로 확인하는 글입니다.