2.5 kubelet과 컨테이너 런타임
control plane이 어떤 결정을 내리든, 컨테이너가 실제로 시작되는 곳은 노드다. kubelet은 모든 노드에 하나씩 상주하며 그 노드의 Pod 실행을 책임지는 에이전트다. apiserver를 감시하다가 자기 노드에 배정된 Pod를 발견하면 컨테이너 런타임을 통해 실행하고, 결과를 다시 apiserver에 보고한다. kubeadm으로 만든 클러스터에서 kubelet은 컨테이너가 아니라 systemd 서비스로 실행되는 유일한 Kubernetes 컴포넌트인데, 왜 그래야 하는지는 static Pod 절에서 드러난다.
PodSpec을 컨테이너로
kubelet의 입력은 PodSpec이다. apiserver watch에서 spec.nodeName이 자기 노드로 채워진 Pod를 받아 선언된 내용을 실행 상태로 만든다. 이미지를 pull하고, 볼륨을 마운트하고, 컨테이너를 생성해 시작하고, liveness와 readiness probe를 주기적으로 실행하고, 컨테이너가 죽으면 restartPolicy에 따라 재시작한다. 진행 상황은 Pod의 status 필드로 apiserver에 기록되어, kubectl get pods가 표시하는 Running이나 CrashLoopBackOff 같은 상태가 된다. probe와 Pod 생명주기의 상세는 Part IV. 워크로드에서 다룬다.
관리 범위는 명확히 한정된다. kubelet은 PodSpec으로 선언된 컨테이너만 관리한다. 같은 노드에서 누군가 런타임 CLI로 직접 시작한 컨테이너는 kubelet의 관심 밖이고, 반대로 자신이 관리하는 컨테이너가 선언과 달라지면 되돌린다. 2.4에서 본 reconciliation loop가 노드 안에서도 그대로 반복되는 것이다.
CRI: 런타임과 맺은 gRPC 계약
kubelet은 컨테이너를 직접 만들지 않는다. 컨테이너의 생성과 실행은 containerd나 CRI-O 같은 컨테이너 런타임의 일이고, kubelet은 CRI(Container Runtime Interface)라는 gRPC 인터페이스로 런타임을 호출한다. CRI는 두 서비스로 구성된다. 이미지를 pull하고 조회하고 삭제하는 ImageService, 그리고 Pod 샌드박스와 컨테이너의 생명주기를 다루는 RuntimeService다.
flowchart TD
API["kube-apiserver"] -->|"watch: 배정 Pod"| KL["kubelet"]
KL -->|"CRI (gRPC)"| CTR["containerd"]
CTR -->|"OCI"| RUNC["runc"]
RUNC --> C["컨테이너 프로세스"]
KL -->|"status 보고"| API
containerd는 받은 요청을 저수준 런타임 runc에 전달하고, runc가 리눅스 네임스페이스와 cgroup을 설정해 실제 컨테이너 프로세스를 만든다. 계층이 나뉜 덕에 kubelet은 어떤 런타임이 오든 같은 gRPC 인터페이스만 호출하면 되고, 런타임 교체가 kubelet 코드와 무관해진다. Docker Engine을 특별 취급하던 내장 어댑터 dockershim이 1.24에서 제거되면서 CRI는 kubelet과 런타임 사이의 유일한 접점이 되었다. 노드에서 런타임 수준을 직접 조사할 때는 CRI 클라이언트인 crictl을 실행한다.
# 노드에서 CRI 레벨의 컨테이너 목록을 조회
crictl psstatic Pod: apiserver 없이 뜨는 Pod
kubelet이 PodSpec을 받는 경로가 하나 더 있다. kubelet은 설정된 디렉터리(kubeadm 기준 /etc/kubernetes/manifests)를 감시하다가, 거기에 놓인 매니페스트 파일을 apiserver 개입 없이 곧바로 실행한다. 이렇게 뜬 Pod가 static Pod다.
이 경로가 존재하는 이유는 control plane 자신에게 있다. kube-apiserver도 결국 하나의 컨테이너로 실행되는데, apiserver를 통해 apiserver를 시작할 수는 없다. kubeadm 클러스터에서 kube-apiserver, etcd, kube-scheduler, kube-controller-manager는 전부 control plane 노드의 static Pod로 기동된다. systemd가 kubelet을 시작하고, kubelet이 매니페스트 디렉터리를 읽어 control plane을 시작하는 부트스트랩 순서다. kubelet만 systemd 서비스여야 하는 이유가 이것이다.
static Pod는 apiserver를 거치지 않았으므로 kubectl에 보이지 않아야 할 것 같지만 실제로는 보인다. kubelet이 static Pod마다 mirror Pod라는 읽기 전용 사본을 apiserver에 등록하기 때문이다. mirror Pod는 관측용이라 kubectl delete로 삭제해도 kubelet이 곧 다시 등록하며, 원본은 노드의 파일이므로 static Pod를 없애는 방법은 매니페스트 파일을 지우는 것뿐이다.
PLEG, cAdvisor, 그리고 heartbeat
kubelet 내부에는 트러블슈팅에서 이름이 자주 나오는 하위 컴포넌트가 있다. PLEG(Pod Lifecycle Event Generator)는 CRI로 런타임의 컨테이너 상태를 주기적으로 다시 조회(relist)해서, 직전 조회와 달라진 점을 이벤트로 만들어 kubelet의 동기화 루프에 공급한다. 컨테이너는 kubelet의 지시 없이도 크래시나 OOM으로 죽으므로, 런타임 쪽에서 일어난 변화를 감지하는 역방향 통로가 필요하다. 런타임 응답이 느리거나 노드의 컨테이너가 지나치게 많으면 relist가 제때 끝나지 못하는데, 그때 나타나는 것이 노드를 NotReady로 만드는 “PLEG is not healthy” 오류다.
cAdvisor는 kubelet에 내장된 리소스 사용량 수집기다. 노드와 컨테이너별 CPU, 메모리, 파일시스템 사용량을 수집해 kubelet의 Summary API로 노출한다. 이 수치가 metrics-server를 거쳐 kubectl top과 HPA의 입력이 된다.
노드의 생존 보고는 두 채널로 나뉜다. Node 오브젝트의 status에는 노드의 조건(Ready, MemoryPressure 등)과 용량 같은 무거운 정보가 담기는데, kubelet은 이것을 내용이 바뀔 때 또는 기본 5분 주기로만 갱신한다. 생존 신호 자체는 가벼운 채널이 담당한다. kubelet은 kube-node-lease 네임스페이스에 있는 자기 Lease 오브젝트를 기본 10초 간격으로 갱신하고, controller-manager의 node controller는 이 갱신이 유예 시간(기본 40초)을 넘겨 끊긴 노드의 Ready 조건을 Unknown으로 바꾼다. 무거운 status와 가벼운 heartbeat를 분리해 대규모 클러스터에서 apiserver와 etcd가 받는 쓰기 부하를 줄인 설계다. Unknown이 된 노드에서 Pod를 다른 노드로 옮기는 eviction 절차는 Part VIII. 스케줄링과 자원 관리에서 다룬다.
정리
- kubelet은 노드마다 하나 있는 에이전트로, 자기 노드에 배정된 PodSpec을 컨테이너로 만들고 결과를 status로 보고한다.
- 런타임과는 CRI gRPC로만 대화한다. containerd가 runc를 호출해 실제 프로세스를 만들고, dockershim 제거(1.24) 이후 CRI가 유일한 접점이다.
- static Pod는 매니페스트 파일에서 apiserver 없이 뜨는 Pod로, control plane 자신을 기동하는 부트스트랩 수단이다.
- 생존 보고는 무거운 Node status(기본 5분)와 가벼운 Lease 갱신(기본 10초)으로 분리되어 있다.