쿠버네티스 파드는 어떻게 태어나고 사라지는가
kubectl apply부터 컨테이너 Running까지, 스케줄링 알고리즘과 containerd 실행, preStop Hook을 통한 무중단 종료까지 파드 생명주기 전체를 추적한다.
- 01 쿠버네티스는 왜 모든 것을 API Server를 통해서만 통신하는가
- 02 쿠버네티스 파드는 어떻게 태어나고 사라지는가
- 03 쿠버네티스 네트워킹은 어떻게 동작하는가
- 04 쿠버네티스 스토리지는 왜 이렇게 복잡한가
- 05 Kubernetes는 자원을 어떻게 나누고 지키는가
- 06 Kubernetes 배포는 왜 이렇게 설계됐는가
- 07 쿠버네티스는 어떻게 스스로를 운영하는가
쿠버네티스에서 파드 하나가 Running이 되기까지 다섯 개의 컴포넌트가 순서대로 깨어나고, 종료할 때도 같은 경로를 거꾸로 내려간다. 이 흐름을 모르면 Pending 상태에서 멈춘 파드 앞에서 막연히 재시도만 반복하게 된다. 왜 kubectl apply 직후 파드의 spec.nodeName이 비어있고, 롤링 업데이트 중 502가 간헐적으로 터지는가?
파드 생성 — 다섯 단계 파이프라인
kubectl apply는 API Server에 HTTP POST를 보내는 것에 불과하다. API Server는 인증, RBAC 인가, Admission Webhook을 순서대로 통과시킨 뒤 파드 오브젝트를 etcd에 저장한다. 이 시점에서 spec.nodeName은 비어 있다. 파드가 kubectl get pod에 나타나지만 STATUS는 Pending이다.
kubectl apply -f pod.yaml
│
▼ POST /api/v1/namespaces/default/pods
API Server → etcd 저장 (spec.nodeName = "")
│ Watch 이벤트 (ADDED)
▼
Scheduler → Filtering → Scoring → Bind
PATCH spec.nodeName = "worker-1"
│ Watch 이벤트 (MODIFIED)
▼
worker-1의 kubelet → 이미지 Pull → CNI → containerd
│
▼
containerd → runc → 컨테이너 프로세스 시작
│
▼
kubelet → PATCH pod/status (Running)
각 컴포넌트는 서로를 직접 호출하지 않는다. 모두 etcd 상태 변화를 Watch해 반응한다. Scheduler가 죽어도 API Server와 kubelet은 독립적으로 동작한다.
스케줄링 — Filtering과 Scoring 두 단계
Scheduler는 nodeName=""인 파드를 Watch하다가 감지하면 스케줄링 사이클을 시작한다.
Filtering 단계는 조건 미충족 노드를 제거한다. PodFitsResources는 노드의 Allocatable에서 기존 파드 Request 합계를 뺀 여유가 새 파드 Request보다 커야 통과시킨다. 중요한 점은 실제 사용량이 아니라 Request 선언값을 기준으로 판단한다는 것이다. TaintToleration은 노드의 Taint를 Tolerate하지 않는 파드를 제거하고, NodeAffinity required와 PodAntiAffinity required도 이 단계에서 검사한다.
Scoring 단계는 남은 노드에 0-100 점수를 부여한다. LeastAllocated는 리소스 여유가 많은 노드를 선호해 파드를 클러스터 전체에 고르게 분산시키고, ImageLocality는 이미지가 이미 캐시된 노드에 가산점을 준다.
Scoring에 분산 선호가 있지만 노드 간 리소스 차이가 없으면 결과가 결정적이지 않다. replicas=3이어도 세 파드가 같은 노드에 올라갈 수 있다. 강제 분산이 필요하다면 PodAntiAffinity required + topologyKey: kubernetes.io/hostname을 명시해야 한다.
Filtering을 통과한 노드가 없으면 파드는 FailedScheduling 이벤트와 함께 영구 Pending된다. kubectl describe pod의 Events 섹션에서 0/3 nodes are available: 2 Insufficient memory, 1 node(s) had untolerated taint 같은 메시지로 정확한 원인을 읽을 수 있다.
containerd와 OCI — 컨테이너가 실제로 실행되는 경로
Scheduler가 spec.nodeName = "worker-1"을 기록하면, worker-1의 kubelet이 MODIFIED 이벤트를 수신해 실행 파이프라인을 시작한다.
kubelet은 CRI gRPC API로 containerd와 통신한다. 가장 먼저 RunPodSandbox()를 호출해 pause 컨테이너를 생성한다. pause 컨테이너는 파드의 Network, IPC, UTS namespace를 보유하는 최소 프로세스다. 이후 메인 컨테이너들이 pause의 network namespace에 join하므로, 같은 파드 내 컨테이너끼리는 localhost로 통신할 수 있다.
containerd는 각 컨테이너마다 containerd-shim 프로세스를 생성한다. shim은 runc와 컨테이너 사이에 위치해 containerd가 재시작되더라도 실행 중인 컨테이너를 유지한다. runc는 OCI config.json에 정의된 대로 Linux namespace와 cgroups를 설정한 뒤 컨테이너 프로세스를 exec한다.
ContainerCreating에서 멈추는 장애는 kubelet 레이어에서 보이지 않는 경우가 많다. crictl ps -a와 journalctl -u containerd로 containerd 레이어를 직접 확인해야 원인이 드러난다.
파드 종료 — preStop과 iptables 전파 지연
kubectl delete pod는 파드를 즉시 종료하지 않는다. API Server가 DeletionTimestamp를 설정하면 두 가지 일이 동시에 시작된다.
- Endpoint Controller가 파드를 Endpoints에서 제거 → kube-proxy가 iptables 규칙 갱신 시작 (전파에 1-5초 소요)
- kubelet이
preStopHook 실행 후 SIGTERM 전달
롤링 업데이트 중 502가 발생하는 근본 원인은 이 시간차다. SIGTERM과 iptables 갱신이 동시에 일어나지 않으므로, 이미 SIGTERM을 받아 종료 중인 파드로 새 요청이 유입된다.
lifecycle:
preStop:
exec:
command: ["sleep", "5"] # iptables 전파 완료까지 대기
terminationGracePeriodSeconds: 60
preStop: sleep 5가 실행되는 5초 동안 파드는 정상 운영 상태다. 이 시간 안에 iptables 규칙 전파가 완료되면, SIGTERM이 전달될 때는 새 요청이 더 이상 이 파드로 오지 않는다. terminationGracePeriodSeconds는 preStop 시작부터 카운트되므로 preStop 시간 + 앱 종료 시간보다 크게 설정해야 한다.
SIGTERM을 받지 못하는 경우도 있다. CMD ["sh", "-c", "java -jar app.jar"]처럼 shell이 PID 1이 되면 SIGTERM이 자식 프로세스에 전달되지 않는다. CMD ["java", "-jar", "app.jar"]로 exec 형식을 사용해 애플리케이션이 직접 PID 1이 되어야 한다.
Init Container와 Sidecar — 파드 설계 패턴
Init Container는 메인 컨테이너 시작 전에 순서대로 실행된다. 각각이 exit 0으로 완료되어야 다음 단계로 진행한다. “DB가 준비된 후 앱을 시작” 같은 의존성을 sleep 30으로 처리하면 환경마다 불안정하다. Init Container로 실제 접속 가능 여부를 확인하면 kubelet이 순서를 보장한다.
initContainers:
- name: wait-for-db
image: busybox
command: ['sh', '-c', 'until nc -z postgres-svc 5432; do sleep 2; done']
Sidecar 컨테이너는 pause의 Network namespace를 공유하므로 메인 앱 코드 수정 없이 기능을 추가할 수 있다. Istio의 istio-init Init Container가 iptables 규칙을 설정해 모든 트래픽을 Envoy 포트로 리다이렉션하면, 이후 모든 인바운드/아웃바운드 트래픽이 Envoy를 통과한다. 메인 앱은 자신이 프록시 뒤에 있는지조차 모른다.
Sidecar는 파드당 추가 리소스를 소비한다. Istio Envoy는 파드당 약 50m CPU, 64Mi 메모리를 기본으로 사용한다. Kubernetes 1.29의 정식 Sidecar Container(initContainers에 restartPolicy: Always 추가)는 종료 순서를 보장한다 — 메인 컨테이너가 먼저 종료된 후 Sidecar가 종료된다. Job 파드에서 Sidecar가 영원히 살아있어 Job이 완료되지 않는 문제도 해결된다.
정리
- 파드 생성은 API Server → Scheduler → kubelet → containerd → runc의 5단계 파이프라인이다. 각 단계는 etcd Watch로 연결되며 직접 호출하지 않는다.
- Pending에서 멈추면
kubectl get pod -o wide로nodeName유무를 확인해 Scheduler 문제인지 kubelet 문제인지 먼저 구분한다. - 롤링 업데이트 중 502를 막으려면
preStop: sleep 5로 iptables 전파 지연을 흡수하고, 애플리케이션이 SIGTERM을 직접 받도록 exec 형식 CMD를 사용한다. - Init Container로 의존성 순서를 보장하고, Sidecar로 메인 앱 수정 없이 프록시와 로그 수집을 붙인다.
다음 글에서는 파드에 IP가 할당되는 CNI 플러그인의 동작 원리와, 서비스가 iptables/IPVS로 트래픽을 분산하는 메커니즘을 추적한다.