Pod의 모든 것 — 생성부터 스케줄링까지

이 글은 K8s 입문 시리즈의 네 번째 글이다.

이 시리즈의 커리큘럼은 SK Devocean의 Kubernetes(쿠버네티스)를 처음 공부하려면 무엇을 공부해야 할까? (seungkyua) 글의 학습 로드맵을 바탕으로 구성했다.

03편에서 클러스터를 해부해 Control Plane과 Node의 컴포넌트를 하나씩 살펴봤다. 이제 그 무대 위에 올라가는 주인공, Pod를 다룰 차례다.

Docker에서는 docker run으로 컨테이너를 직접 띄웠다. 그런데 Kubernetes에는 “컨테이너를 만들어라”라는 명령이 없다. Kubernetes가 만들고, 지우고, 옮기는 최소 단위는 컨테이너가 아니라 Pod이기 때문이다. 앞으로 배울 Deployment, StatefulSet, DaemonSet 같은 워크로드 리소스도 결국 전부 Pod를 찍어내는 틀이다. Pod를 정확히 이해하지 못하면 그 위의 모든 개념이 흔들린다.

이번 글에서는 Pod가 무엇인지, 왜 컨테이너가 아니라 Pod인지(사이드카·init 컨테이너) 이해하고, 직접 nginx Pod를 띄워본 다음, kubectl apply 한 줄 뒤에서 03편의 컴포넌트들이 어떻게 협업해 Pod를 띄우는지 5단계로 추적한다. 후반부에서는 kubelet의 헬스체크인 probe, 스케줄링과 직결되는 리소스 requests/limits, Pod를 원하는 노드에 배치하는 스케줄링 제어(nodeSelector·affinity·taint), 그리고 Pod가 이상할 때 원인을 찾는 디버깅 루틴까지 다진다.


1. Pod란 무엇인가

공식 문서의 정의는 이렇다.

Pod는 Kubernetes에서 생성하고 관리할 수 있는 가장 작은 배포 단위다. Pod는 하나 이상의 컨테이너 그룹이며, 스토리지와 네트워크를 공유하고, 컨테이너를 어떻게 실행할지에 대한 명세를 담는다.

핵심은 세 가지다.

특징 의미
최소 배포 단위 Kubernetes는 컨테이너를 개별로 다루지 않는다. 스케줄링·생성·삭제 모두 Pod 단위다
컨테이너 그룹 하나의 Pod에 여러 컨테이너를 담을 수 있다 (대부분은 1개)
자원 공유 같은 Pod의 컨테이너들은 하나의 IP(네트워크 네임스페이스)볼륨을 공유한다

비유하면 Pod는 셰어하우스 한 집이다.

  • 주소(IP)는 집마다 하나다. 방(컨테이너)마다 주소가 따로 있지 않다.
  • 룸메이트(컨테이너)끼리는 집 안에서 바로 대화한다 — localhost 통신.
  • 거실(공유 볼륨)에 물건을 두면 모든 룸메이트가 쓸 수 있다.
  • 이사(스케줄링)를 가면 집 전체가 같이 움직인다. 룸메이트 한 명만 다른 동네(노드)로 갈 수 없다.

같은 Pod 안의 컨테이너와 다른 Pod의 컨테이너는 이렇게 다르다.

항목 같은 Pod 안의 컨테이너 다른 Pod의 컨테이너
IP 주소 하나를 공유 각자 다름
통신 방법 localhost + 포트 Pod IP 또는 Service(06편) 경유
볼륨 공유 가능 기본적으로 불가
배치(노드) 항상 같은 노드 서로 다른 노드일 수 있음
생명주기 함께 생성되고 함께 삭제됨 독립적

2. 왜 컨테이너가 아니라 Pod인가

“어차피 Pod 하나에 컨테이너 하나가 대부분이라면, 그냥 컨테이너를 최소 단위로 하면 되지 않나?”라는 의문이 든다. Pod라는 한 겹의 추상화가 존재하는 이유는 강하게 결합된 컨테이너 묶음을 표현하기 위해서다.

대표적인 예가 사이드카(sidecar) 패턴이다. 공식 문서는 사이드카를 이렇게 설명한다 — 주 애플리케이션 컨테이너의 코드를 고치지 않고, 로깅·모니터링·보안·데이터 동기화 같은 부가 기능을 옆에 붙은 보조 컨테이너로 확장하는 패턴이다.

로그 수집을 예로 들어보자. nginx는 로그를 파일로 남길 뿐, 중앙 로그 서버로 보내는 기능은 없다. 이때 nginx 이미지를 뜯어고치는 대신, 로그 수집기 컨테이너를 같은 Pod에 태우면 된다.

사이드카 패턴 — 같은 Pod 안의 앱 컨테이너(nginx)와 사이드카 컨테이너(로그 수집기)가 공유 볼륨(/var/log/nginx)과 localhost로 통신하고, 사이드카가 중앙 로그 서버로 로그를 전송하는 구조
  • 앱과 사이드카는 공유 볼륨으로 파일을 주고받는다. 네트워크 설정이 전혀 필요 없다.
  • 두 컨테이너는 항상 같은 노드에 함께 스케줄링된다. 로그 수집기만 다른 노드에 떨어지는 일이 없다.
  • 앱 컨테이너는 자기 일(웹 서빙)만 하고, 로그 전송은 사이드카가 맡는다 — 관심사의 분리.
  • 필요하면 localhost로도 통신할 수 있다. 같은 네트워크 네임스페이스라 IP가 같고 포트만 나눠 쓴다.

참고로 Kubernetes v1.29부터는 사이드카 컨테이너가 정식 기능으로 기본 활성화됐다. initContainersrestartPolicy: Always를 지정하면, Pod가 살아 있는 동안 계속 실행되는 사이드카가 된다. 지금은 “같은 Pod에 보조 컨테이너를 태우는 패턴”이라는 개념만 기억하면 충분하다.

정리하면 — Pod는 “반드시 함께 배포되고, 함께 스케일링되어야 하는 컨테이너들”의 경계선이다. 이 경계선이 있어야 스케줄러는 “이 묶음은 쪼갤 수 없다”는 것을 알고 통째로 배치할 수 있다.

2.1 사이드카 말고, 먼저 끝나는 컨테이너 — initContainers

사이드카가 앱과 나란히 도는 컨테이너였다면, init 컨테이너는 앱보다 먼저 도는 컨테이너다. 공식 문서의 정의는 이렇다 — init 컨테이너는 앱 컨테이너가 시작되기 전에 실행되며, 완료될 때까지 실행되고(run-to-completion), 여러 개면 정의된 순서대로 하나씩 돈다. 모든 init 컨테이너가 성공해야 비로소 앱 컨테이너가 뜬다.

연극에 비유하면 사이드카는 무대 옆에서 배우와 함께 공연 내내 조명을 담당하는 스태프이고, init 컨테이너는 막이 오르기 전에 무대 세팅을 끝내고 퇴장하는 준비팀이다. 준비가 끝나야 배우(앱)가 무대에 오른다. 대표 용도는 이렇다.

  • 앱이 의존하는 외부 서비스(DB 등)가 준비될 때까지 대기한 뒤 앱을 띄운다.
  • 설정 파일·소스를 미리 내려받아 공유 볼륨에 넣어 둔다.
  • 마이그레이션·권한 설정처럼 앱 시작 전 딱 한 번 해야 하는 준비 작업을 처리한다.
apiVersion: v1
kind: Pod
metadata:
  name: init-demo
spec:
  initContainers:
    - name: wait-for-db
      image: busybox:1.36
      command: ["sh", "-c", "until nslookup db.default.svc; do echo waiting for db; sleep 2; done"]
  containers:
    - name: app
      image: nginx:1.25
  • initContainerscontainers와 형제 필드다. 위 Pod는 wait-for-db가 성공(종료 코드 0)해야만 app(nginx)이 시작된다.
  • init 컨테이너가 실패하면 kubelet은 Pod의 restartPolicy에 따라 성공할 때까지 재시작한다(Always·OnFailure면 반복, Never면 Pod가 실패 처리).
  • 앱 컨테이너가 뜨기 전 단계라, kubectl get pods에서 Init:0/1처럼 몇 개의 init 컨테이너 중 몇 개가 끝났는지가 STATUS에 표시된다. 여기서 오래 멈춰 있으면 준비 조건(위 예시라면 db 서비스)이 아직 충족되지 않았다는 뜻이다.

init 컨테이너와 앱 컨테이너는 같은 Pod이므로 볼륨을 공유한다. init 단계에서 공유 볼륨에 파일을 받아두면 앱 컨테이너가 그대로 읽는 식인데, 이때 쓰는 Pod 수명 볼륨(emptyDir)의 정체는 07편에서 다룬다. 참고로 2절에서 본 사이드카는 사실 “restartPolicy: Always를 가진 특수한 init 컨테이너”로 구현된다 — 준비만 하고 끝나는 보통 init 컨테이너와 달리, 시작된 뒤 Pod가 사는 내내 함께 도는 것이다.


3. 실습 1: nginx Pod 띄워보기

02편에서 만든 kind 클러스터에 nginx Pod를 직접 띄워보자. 아래 YAML은 공식 문서의 예제 그대로다. nginx-pod.yaml로 저장한다.

apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  containers:
    - name: nginx
      image: nginx:1.14.2
      ports:
        - containerPort: 80
  • apiVersion: v1, kind: Pod — “Pod라는 종류의 리소스를 core API 그룹 v1 스펙으로 만든다”는 선언.
  • metadata.name — 이 Pod의 이름. 같은 네임스페이스 안에서 유일해야 한다.
  • spec.containers — 이 Pod에 담을 컨테이너 목록. 지금은 nginx 하나뿐이다.

3.1 생성 — kubectl apply

kubectl apply -f nginx-pod.yaml
pod/nginx created

3.2 확인 — get pods -o wide

kubectl get pods -o wide
NAME    READY   STATUS    RESTARTS   AGE   IP           NODE                 NOMINATED NODE   READINESS GATES
nginx   1/1     Running   0          30s   10.244.0.5   kind-control-plane   <none>           <none>

(IP·노드 이름·시간은 환경마다 다르다.)

  • READY 1/1 — 컨테이너 1개 중 1개가 준비됨.
  • STATUS Running — Pod가 노드에 배치됐고 컨테이너가 실행 중이다 (뒤의 생명주기 절에서 자세히).
  • -o wide를 붙이면 Pod의 IP어느 노드에 배치됐는지까지 보인다. 이 IP가 바로 셰어하우스의 “집 주소”다.

3.3 상세 조회 — describe

kubectl describe pod nginx

출력이 길어서 마지막의 Events 부분만 보면 이렇다.

Events:
  Type    Reason     Age   From               Message
  ----    ------     ----  ----               -------
  Normal  Scheduled  50s   default-scheduler  Successfully assigned default/nginx to kind-control-plane
  Normal  Pulling    49s   kubelet            Pulling image "nginx:1.14.2"
  Normal  Pulled     40s   kubelet            Successfully pulled image "nginx:1.14.2"
  Normal  Created    40s   kubelet            Created container nginx
  Normal  Started    40s   kubelet            Started container nginx

From 열을 보면 누가 무슨 일을 했는지가 그대로 드러난다. default-scheduler가 노드를 배정했고, 그 뒤는 전부 kubelet의 작업이다. 이 순서가 바로 다음 절에서 다룰 “Pod가 뜨는 5단계”의 흔적이다.

3.4 접속 — port-forward

Pod IP는 클러스터 내부 주소라서 노트북 브라우저로는 바로 접근할 수 없다. kubectl port-forward로 로컬 포트를 Pod 포트에 연결한다.

kubectl port-forward pod/nginx 8080:80
Forwarding from 127.0.0.1:8080 -> 80
Forwarding from [::1]:8080 -> 80

이 명령은 종료되지 않고 계속 떠 있는다. 다른 터미널에서 접속해 보자.

curl -s localhost:8080 | head -n 4
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>

nginx 환영 페이지가 나오면 성공이다. 확인이 끝나면 port-forward 터미널에서 Ctrl+C로 끊는다.

3.5 삭제 — delete

kubectl delete pod nginx
pod "nginx" deleted

여기서 중요한 사실 하나 — Pod는 스스로 부활하지 않는다. 지금처럼 직접 만든 Pod는 지우면 그걸로 끝이다. “죽으면 다시 살려라”는 05편의 워크로드 리소스가 하는 일이다.


4. Pod가 뜨는 5단계

kubectl apply를 치고 Running이 되기까지, 뒤에서는 무슨 일이 벌어질까? 03편에서 배운 컴포넌트들이 릴레이처럼 일을 넘겨받는다.

Pod가 뜨는 5단계 시퀀스 다이어그램 — 사용자가 kubectl apply를 실행하면 kubectl이 kube-apiserver로 Pod 스펙을 전송하고, api-server가 etcd에 저장한 뒤, kube-scheduler가 노드를 선택해 바인딩을 요청하고, kubelet이 컨테이너를 실행하는 흐름

각 단계를 03편의 컴포넌트와 연결해 뜯어보자.

1단계 — 사용자가 YAML을 작성한다. YAML은 “이런 Pod가 존재해야 한다”는 desired state의 선언이다. 명령(“실행해!”)이 아니라 상태(“존재해야 한다”)를 적는다는 점이 Kubernetes 철학의 핵심이다.

2단계 — kubectl이 kube-apiserver로 보낸다. kubectl은 kubeconfig(02편)에 적힌 api-server 주소로 REST 요청을 날리는 클라이언트일 뿐이다. 마법은 전부 서버 쪽에서 일어난다.

3단계 — kube-apiserver가 etcd에 저장한다. api-server는 인증·인가·어드미션 검증을 통과한 Pod 객체를 etcd에 기록한다(03편에서 봤듯 etcd에 접근하는 유일한 컴포넌트다). 이 시점의 Pod는 아직 어느 노드에서 돌지 정해지지 않은 레코드일 뿐이다. kubectl apply가 성공했다는 것은 “컨테이너가 떴다”가 아니라 “희망 상태가 저장됐다”는 뜻이다.

4단계 — kube-scheduler가 노드를 고른다. 스케줄러는 api-server를 watch하다가 노드가 배정되지 않은 새 Pod를 발견하면, 두 단계로 최적의 노드를 고른다(03편에서 예고한 그 2단계다).

단계 하는 일 비유
필터링(Filtering) 이 Pod를 돌릴 수 있는 노드만 남긴다 (예: 요청한 CPU/메모리가 충분한가) 서류 전형 — 자격 미달 탈락
스코어링(Scoring) 남은 노드에 점수를 매겨 가장 적합한 노드를 고른다 면접 — 최고 득점자 선발

최고 점수 노드가 여럿이면 그중 하나를 무작위로 고른다. 결정이 끝나면 스케줄러는 api-server에 “이 Pod는 이 노드로”라고 알리는데, 이를 바인딩(binding)이라 한다. 스케줄러가 직접 노드에 명령하는 것이 아니라는 점에 주목하자.

5단계 — kubelet이 컨테이너를 실행한다. 배정된 노드의 kubelet은 api-server를 통해 자기 노드 몫의 Pod를 발견하고, CRI를 통해 컨테이너 런타임에 실행을 위임한다. 이미지를 당겨오고(Pulling), 컨테이너를 만들고(Created), 시작한다(Started). kubelet은 이후에도 PodSpec대로 컨테이너가 건강하게 돌고 있는지 계속 책임진다.

이 흐름에서 관찰할 점:

  • 컴포넌트끼리 직접 명령하지 않는다. 모든 상호작용은 api-server를 거치고, 각 컴포넌트는 etcd에 저장된 상태를 watch하다가 자기 몫의 일을 알아서 한다. 덕분에 컴포넌트 하나가 잠시 죽어도 재시작 후 상태를 보고 이어서 일할 수 있다.
  • 비동기다. apply 성공(3단계)과 컨테이너 실행(5단계) 사이에는 시간차가 있다. STATUSPending에서 Running으로 바뀌는 데 시간이 걸리는 이유다.
  • 03편의 “desired state vs current state” 구도가 그대로 재현된다. YAML(희망 상태)을 저장하면, 나머지 컴포넌트들이 현실을 그 상태에 맞춘다.

5. 실습 2: 이벤트로 5단계 추적하기

방금 설명한 5단계는 이론이 아니라 이벤트(Event)로 직접 확인할 수 있다. Pod를 다시 만들고 이벤트를 시간순으로 정렬해 보자.

kubectl apply -f nginx-pod.yaml
kubectl get events --sort-by=.metadata.creationTimestamp
LAST SEEN   TYPE     REASON      OBJECT      MESSAGE
20s         Normal   Scheduled   pod/nginx   Successfully assigned default/nginx to kind-control-plane
19s         Normal   Pulling     pod/nginx   Pulling image "nginx:1.14.2"
12s         Normal   Pulled      pod/nginx   Successfully pulled image "nginx:1.14.2"
12s         Normal   Created     pod/nginx   Created container nginx
12s         Normal   Started     pod/nginx   Started container nginx

(메시지 형식과 소요 시간은 버전·환경에 따라 조금 다르다. 이미지가 이미 캐시되어 있으면 Pulling 없이 바로 Pulled가 나오기도 한다.)

이벤트와 5단계를 매핑하면 이렇다.

이벤트 Reason 발행 주체 5단계 중 의미
Scheduled default-scheduler 4단계 (바인딩) 노드 배정 완료
Pulling kubelet 5단계 컨테이너 이미지 다운로드 시작
Pulled kubelet 5단계 이미지 다운로드 완료
Created kubelet 5단계 컨테이너 생성
Started kubelet 5단계 컨테이너 시작

1~3단계(작성→전송→저장)는 이벤트로 남지 않는다. 이벤트는 “저장된 희망 상태를 현실로 만드는 과정”에서 발생하기 때문이다. 문제가 생겼을 때 kubectl describe pod <이름>의 Events 섹션을 가장 먼저 보라는 조언이 나오는 이유가 여기에 있다 — 어느 단계에서 멈췄는지가 그대로 보인다.

확인이 끝났으면 정리하자.

kubectl delete pod nginx

6. Pod 생명주기 — 5가지 phase

kubectl get pods의 STATUS 열 뒤에는 공식적으로 정의된 phase가 있다. Pod는 생애 동안 다음 다섯 phase 중 하나에 속한다.

Phase 의미 언제 보이나
Pending 클러스터가 Pod를 수락했지만, 컨테이너가 아직 하나도 준비되지 않음 스케줄링 대기 중이거나 이미지 다운로드 중 (4~5단계 사이)
Running 노드에 바인딩됐고 모든 컨테이너가 생성됨. 최소 1개가 실행 중이거나 시작·재시작 중 정상 동작 중인 대부분의 서비스 Pod
Succeeded 모든 컨테이너가 성공적으로 종료됐고 재시작되지 않음 배치 작업처럼 끝이 있는 Pod가 정상 완료됐을 때
Failed 모든 컨테이너가 종료됐고, 최소 1개가 실패로 종료됨(0이 아닌 종료 코드 등) 작업이 비정상 종료됐을 때
Unknown Pod 상태를 알 수 없음 주로 Pod가 있는 노드와의 통신 장애

phase와 별개로, Pod 안의 컨테이너Waiting(시작 준비 중 — 이미지 풀 등), Running(실행 중), Terminated(종료됨)의 세 가지 상태를 가진다.

한 가지 주의 — kubectl get pods의 STATUS 열에는 CrashLoopBackOff, Terminating 같은 값도 보이는데, 이것들은 phase가 아니다. STATUS는 사용자가 상황을 직관적으로 파악하도록 kubectl이 가공해 보여주는 필드이고, phase는 Pod API에 정의된 공식 값 5가지뿐이다.


7. Probes — kubelet의 헬스체크

STATUSRunning이라는 것은 “컨테이너 프로세스가 떠 있다”는 뜻이지, “서비스할 준비가 됐다”는 뜻이 아니다. 프로세스는 살아 있는데 데드락에 걸려 응답을 못 할 수도 있고, 이제 막 떠서 초기화가 안 끝났을 수도 있다. 그래서 kubelet은 probe라는 헬스체크로 컨테이너에게 주기적으로 질문을 던진다. probe를 정의하지 않으면 kubelet은 결과를 성공으로 간주한다 — “프로세스만 떠 있으면 건강하다”는 가장 느슨한 기준이 적용되는 셈이다.

probe는 세 종류가 있고, 핵심은 실패했을 때 벌어지는 일이 서로 다르다는 점이다.

  • livenessProbe — “살아 있나?” 실패가 누적되면 kubelet이 컨테이너를 죽이고 restartPolicy에 따라 재시작한다. 프로세스는 떠 있지만 응답하지 못하는 데드락 같은 상황을 재시작으로 복구하는 용도다.
  • readinessProbe — “지금 트래픽 받을 수 있나?” 실패하면 Pod가 unready로 표시되고 Service(06편)의 엔드포인트 목록(EndpointSlice)에서 Pod IP가 제외된다. 트래픽만 끊길 뿐 컨테이너는 재시작되지 않으며, 다시 성공하면 트래픽도 돌아온다. 워밍업·일시적 과부하·외부 의존성 대기처럼 “죽진 않았지만 지금은 곤란한” 상황에 쓴다.
  • startupProbe — “시작이 끝났나?” 이 probe가 성공할 때까지 Kubernetes는 liveness·readiness probe를 실행하지 않는다. 기동이 느린 앱이 초기화를 마치기도 전에 liveness에게 “죽었다”고 오판받아 재시작당하는 것을 막는다. 실패 한도를 넘기면 컨테이너는 죽고 restartPolicy를 따른다. 시작할 때만 동작하고, 한 번 성공하면 다시 실행되지 않는다.

식당에 비유하면 이렇다.

  • liveness = “주방장 쓰러졌어?” — 쓰러졌으면 새 주방장으로 교체한다(재시작).
  • readiness = “지금 주문 받을 수 있어?” — 재료 준비가 안 됐으면 ‘준비중’ 팻말만 건다(트래픽 차단). 주방장을 자르진 않는다.
  • startup = 개업 준비 기간 — 준비가 끝나기 전까지는 위의 두 질문 자체를 하지 않는다.

질문하는 방식은 probe마다 하나를 골라 지정한다.

방식 하는 일 성공 기준
httpGet 지정한 경로·포트로 HTTP GET 요청 상태 코드 200 이상 400 미만
tcpSocket 지정한 포트로 TCP 연결 시도 연결 성공(포트 열림)
exec 컨테이너 안에서 명령 실행 종료 코드 0

(gRPC 서버라면 grpc 방식도 쓸 수 있다.)

공식 문서의 예제를 컨테이너 spec 하나에 모으면 이런 모양이다.

containers:
  - name: web
    image: my-web:1.0
    ports:
      - containerPort: 8080
    startupProbe:
      httpGet:
        path: /healthz
        port: 8080
      failureThreshold: 30
      periodSeconds: 10
    livenessProbe:
      httpGet:
        path: /healthz
        port: 8080
      initialDelaySeconds: 10
      periodSeconds: 5
    readinessProbe:
      httpGet:
        path: /ready
        port: 8080
      periodSeconds: 5
  • startupProbe — 10초 간격(periodSeconds)으로 최대 30번(failureThreshold)까지 실패를 허용한다. 즉 최대 30 × 10 = 300초의 기동 시간을 벌어준다. 그 안에 /healthz가 한 번이라도 응답하면 그 즉시 liveness·readiness가 가동을 시작한다.
  • livenessProbe — 이후 5초마다 /healthz를 확인하고, 실패가 누적되면 컨테이너를 재시작한다. kubectl get podsRESTARTS가 올라가는 흔한 원인 중 하나다.
  • readinessProbe — 5초마다 /ready를 확인한다. liveness와 다른 경로를 보는 것에 주목하자. “살아 있는가”와 “서비스 가능한가”는 다른 질문이므로 엔드포인트도 나누는 것이 보통이다.

세 probe를 한 표로 비교하면 다음과 같다.

  livenessProbe readinessProbe startupProbe
질문 살아 있나? 트래픽 받을 준비 됐나? 시작이 끝났나?
실패하면 컨테이너 kill 후 재시작 Service 엔드포인트에서 제외 한도 초과 시 kill 후 재시작
재시작 유발 O X O
실행 시점 startup 성공 후 계속 주기적으로 startup 성공 후 계속 주기적으로 시작할 때만
주 용도 데드락 복구 트래픽 제어 느린 기동 보호

8. 리소스 requests와 limits

4절의 필터링 표에 “요청한 CPU/메모리가 충분한가”라고 썼다. 그 “요청”의 정체가 바로 컨테이너 spec의 resources.requests다. 공식 문서의 예제 수치를 그대로 옮기면 이렇다.

containers:
  - name: app
    image: my-app:1.0
    resources:
      requests:
        memory: "64Mi"
        cpu: "250m"
      limits:
        memory: "128Mi"
        cpu: "500m"
  • cpu: "250m" — CPU는 밀리코어(m) 단위다. 1000m이 코어 1개이므로 250m은 0.25코어.
  • memory: "64Mi" — 메모리는 바이트 단위이고, 보통 Mi·Gi 같은 이진 접두어로 적는다.

requests와 limits의 역할은 명확히 다르다.

  requests limits
의미 컨테이너에 보장해야 할 최소량 컨테이너가 쓸 수 있는 최대량
누가 사용하나 kube-scheduler — 노드 선택의 기준 kubelet·커널 — 초과 시 제재
그 이상 쓰면 노드에 여유가 있으면 더 쓸 수 있음 CPU는 스로틀링, 메모리는 OOM kill

requests는 스케줄링의 기준이다. 4단계에서 스케줄러는 노드의 실제 사용량이 아니라, 그 노드에 이미 배정된 Pod들의 requests 합계를 보고 자리가 있는지 판단한다. 그래서 노드가 실제로는 한가해도 requests 장부가 꽉 차 있으면 새 Pod는 못 들어가고, 반대로 requests를 만족하는 노드가 하나도 없으면 필터링에서 전부 탈락해 Pod는 Pending에 머문다 — 다음 절에서 볼 FailedScheduling의 단골 원인이다.

limits를 초과했을 때의 운명은 CPU와 메모리가 다르다.

자원 limit 초과 시 결과
CPU 커널이 스로틀링으로 사용량을 깎음 죽지 않는다. 대신 느려진다
메모리 커널이 OOM kill로 컨테이너를 종료 컨테이너가 죽는다 (OOMKilled)

CPU는 시간을 쪼개 나눠 쓰는 자원이라 초과분을 깎아서 줄 수 있지만, 이미 내준 메모리는 뺏을 방법이 없어서 프로세스를 죽이는 것 외에는 도리가 없기 때문이다.

비유하면 requests는 계약 전력, limits는 차단기 용량이다. 전력 회사(스케줄러)는 실제 사용량이 아니라 계약 전력의 합계를 보고 이 건물(노드)에 새 계약을 받을지 정한다. CPU limit 초과는 전력 제한이 걸려 기기가 느려지는 것이고, 메모리 limit 초과는 차단기가 내려가 통째로 꺼지는 것이다.

마지막으로, requests·limits를 어떻게 적었는지에 따라 Kubernetes는 Pod에 QoS(Quality of Service) 클래스를 자동으로 부여한다. 노드 자원이 부족해질 때 누구를 먼저 쫓아낼지(eviction)를 정하는 우선순위 기준이다.

QoS 클래스 조건 자원 부족 시
Guaranteed 모든 컨테이너의 CPU·메모리에 requests와 limits가 있고, 둘의 값이 같음 가장 나중에 축출
Burstable Guaranteed는 아니지만, 최소 한 컨테이너에 CPU 또는 메모리 requests/limits가 있음 중간
BestEffort 어느 컨테이너에도 CPU·메모리 requests/limits가 전혀 없음 가장 먼저 축출

축출당하면 곤란한 핵심 서비스의 requests와 limits를 같은 값으로 맞춰 Guaranteed로 만드는 이유가 이것이다.


9. 스케줄링을 제어하기 — nodeSelector, affinity, taint

4절에서 스케줄러는 필터링→스코어링으로 알아서 노드를 골랐고, 8절에서 그 필터링의 기준이 requests라는 것을 봤다. 대부분은 이렇게 맡겨두면 된다. 하지만 실무에서는 배치를 직접 통제해야 할 때가 온다.

  • “이 학습 Job은 GPU 노드에만 띄워야 한다.”
  • “웹 서버 복제본 3개가 한 노드에 몰리면 그 노드가 죽을 때 전부 죽는다 — 서로 다른 노드에 흩어라.”
  • “이 노드는 모니터링 전용이니 아무 Pod나 올라오면 안 된다.

Kubernetes는 이 요구를 두 방향의 도구로 푼다. Pod가 노드를 고르는(끌어당기는) 쪽과, 노드가 Pod를 밀어내는 쪽이다. 먼저 표로 지도를 그리자.

도구 방향 한 줄 요약
nodeSelector Pod → 노드 (끌림) 라벨이 맞는 노드에만 배치 (가장 단순)
Node affinity Pod → 노드 (끌림) nodeSelector의 확장 — 필수/선호, 다양한 연산자
Pod affinity/anti-affinity Pod → Pod (끌림/밀침) 다른 Pod와 같은/다른 노드에 배치
Taint & Toleration 노드 → Pod (밀침) 노드가 Pod를 밀어내고, 견디는 Pod만 허용
스케줄링 제어의 두 방향 — nodeSelector/affinity는 Pod가 조건에 맞는 노드로 끌려가고, taint는 노드가 toleration 없는 Pod를 밀어내며 toleration을 가진 Pod만 통과시키는 구조

9.1 nodeSelector — 라벨이 맞는 노드에만

가장 단순한 방법이다. 노드에 라벨을 붙이고, Pod에 “이 라벨을 가진 노드로 보내라”고 적으면 끝이다. 02편에서 만든 멀티노드 kind 클러스터로 실습해 보자. 먼저 worker 노드 하나에 라벨을 붙인다.

kubectl label node kind-worker disktype=ssd

Pod의 spec.nodeSelector에 그 라벨을 적는다.

apiVersion: v1
kind: Pod
metadata:
  name: nginx-ssd
spec:
  nodeSelector:
    disktype: ssd
  containers:
    - name: nginx
      image: nginx:1.25
kubectl apply -f nginx-ssd.yaml
kubectl get pod nginx-ssd -o wide   # NODE 열이 kind-worker인지 확인

disktype=ssd 라벨을 가진 노드가 kind-worker뿐이므로, 스케줄러의 필터링 단계에서 다른 노드는 전부 탈락하고 이 Pod는 반드시 kind-worker에 뜬다. 만약 그 라벨을 가진 노드가 하나도 없으면? Pod는 배치될 곳을 못 찾아 Pending에 머문다 — 다음 절에서 볼 FailedScheduling이다.

9.2 Node affinity — 더 유연한 노드 선택

nodeSelector는 “정확히 일치” 하나뿐이다. in (ssd, nvme)처럼 여러 값을 허용하거나, “있으면 좋지만 없어도 배치”하는 선호를 표현하려면 node affinity를 쓴다. 두 종류가 핵심이다.

종류 의미 조건 불만족 시
requiredDuringSchedulingIgnoredDuringExecution 필수(hard) — 반드시 만족하는 노드에만 배치 배치 안 됨 (Pending)
preferredDuringSchedulingIgnoredDuringExecution 선호(soft) — 만족하는 노드에 가점, 없어도 배치 아무 노드에나 배치
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
          - matchExpressions:
              - key: disktype
                operator: In
                values: ["ssd", "nvme"]
  • required...는 하드 제약이라 필터링 단계에서, preferred...는 소프트 선호라 스코어링 단계에서 반영된다. 4절의 “필터링 vs 스코어링” 2단계가 그대로 대응한다.
  • 이름이 긴 IgnoredDuringExecution은 “스케줄링 시점에만 본다”는 뜻이다. 이미 뜬 Pod는 나중에 노드 라벨이 바뀌어도 쫓겨나지 않는다.
  • operatorIn, NotIn, Exists 등을 쓸 수 있어 nodeSelector보다 표현력이 넓다.

9.3 Pod affinity/anti-affinity — 다른 Pod와의 관계로 배치

여기서 관점이 바뀐다. 노드의 라벨이 아니라 이미 떠 있는 다른 Pod를 기준으로 배치를 정한다.

  • Pod affinity: “캐시 Pod와 같은 노드에 붙여라” — 네트워크 지연을 줄이는 로컬리티 목적.
  • Pod anti-affinity: “같은 앱의 복제본은 서로 다른 노드에 흩어라” — 한 노드가 죽어도 전부 죽지 않게 하는 고가용성 목적.
spec:
  affinity:
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - labelSelector:
            matchLabels:
              app: web
          topologyKey: kubernetes.io/hostname

topologyKey가 “무엇을 기준으로 같고 다른가”를 정한다. kubernetes.io/hostname이면 노드 단위로 흩어지고, topology.kubernetes.io/zone이면 가용 영역(zone) 단위로 흩어진다. 위 예시는 “app=web Pod가 이미 있는 노드에는 새 app=web Pod를 두지 않는다”는 뜻이라, 복제본이 노드마다 하나씩 분산된다.

9.4 Taint와 Toleration — 노드가 Pod를 밀어낸다

지금까지는 전부 Pod가 노드를 끌어당기는 방향이었다. Taint는 정반대다. 노드에 “출입 금지” 딱지(taint)를 붙이면, 그 딱지를 견딜 수 있는(toleration을 가진) Pod만 그 노드에 들어올 수 있다. 클럽 입구의 문지기에 비유하면, taint는 “회원 전용” 팻말이고 toleration은 회원증이다. 회원증 없는 손님(toleration 없는 Pod)은 입장이 거부된다.

taint의 effect는 세 가지다.

effect 동작
NoSchedule toleration 없는 Pod는 새로 배치되지 않음 (이미 뜬 건 유지)
PreferNoSchedule 되도록 배치하지 않음 (소프트 — 다른 곳이 없으면 배치될 수도)
NoExecute toleration 없는 Pod는 배치도 안 되고, 이미 떠 있어도 쫓겨남

사실 우리는 taint를 이미 만나고 있었다. 실제 클러스터에서 control-plane 노드에는 node-role.kubernetes.io/control-plane:NoSchedule taint가 기본으로 걸려 있다. 일반 워크로드가 두뇌 역할을 하는 노드에 올라와 자원을 잡아먹지 못하게 막는 것이다. 직접 확인해 보자.

kubectl describe node kind-control-plane | grep -i taints

kind 단일 노드 클러스터라면 결과가 Taints: <none>으로 나온다. 노드가 하나뿐이라 워크로드를 올릴 곳이 거기밖에 없으니, kind가 이 taint를 일부러 제거해 둔 것이다. 3절 실습에서 nginx Pod가 아무 설정 없이 kind-control-plane에 떴던 것이 바로 이 덕분이다. 반면 실제 멀티노드 클러스터에서는 이 taint가 살아 있어 일반 Pod가 자동으로 worker로만 배치된다.

노드에 taint를 직접 걸고 toleration을 붙여 보자.

kubectl taint node kind-worker dedicated=monitoring:NoSchedule

이제 kind-worker에는 아래 toleration을 가진 Pod만 새로 배치될 수 있다.

spec:
  tolerations:
    - key: "dedicated"
      operator: "Equal"
      value: "monitoring"
      effect: "NoSchedule"

실습이 끝나면 taint와 라벨을 원래대로 되돌린다(키 뒤에 -를 붙이면 제거).

kubectl taint node kind-worker dedicated=monitoring:NoSchedule-
kubectl label node kind-worker disktype-

한 가지 자주 하는 오해 — toleration은 “허가증”일 뿐, “강제 배치”가 아니다. 위 toleration을 가진 Pod가 반드시 kind-worker로 가는 것은 아니다. 단지 그 taint를 견딜 수 있을 뿐, 배치는 여전히 스케줄러가 정한다. “반드시 그 노드로”를 원하면 taint/toleration에 더해서 nodeSelector나 affinity(끌어당기는 쪽)를 함께 써야 한다. 05편의 DaemonSet이 control-plane 노드에까지 뜨는 것, 06편에서 ingress-nginx가 control-plane에 뜰 수 있었던 것도 전부 해당 taint를 견디는 toleration이 걸려 있기 때문이다.

정리하면, affinity 계열은 Pod가 원하는 노드로 끌려가는 힘이고, taint는 노드가 원치 않는 Pod를 밀어내는 힘이다. 둘은 반대 방향이라 함께 조합해 “GPU 노드는 GPU 워크로드만(taint로 밀어내고) + GPU 워크로드는 GPU 노드로만(affinity로 끌어당기고)” 같은 전용 노드 풀을 만든다.


10. 흔한 에러와 디버깅 루틴

Pod를 띄우다 보면 반드시 만나게 될 상태 세 가지를 미리 맛보고, 원인을 찾아 들어가는 순서를 루틴으로 정리하자. 원인 진단의 시작은 언제나 kubectl describe pod <이름>의 Events다.

증상 대표 원인 확인 포인트
ImagePullBackOff 이미지 이름·태그 오타, 존재하지 않는 이미지, 프라이빗 레지스트리 인증 실패 — kubelet이 이미지 풀에 실패해 재시도 간격을 늘려가며(back-off) 대기 중 Events의 이미지 풀 실패 메시지, 이미지 이름 철자
Pending에서 안 넘어감 스케줄 불가 — 요청한 CPU/메모리를 만족하는 노드가 없거나, 조건에 맞는 노드가 없음 Events의 FailedScheduling 경고와 그 사유
CrashLoopBackOff 컨테이너가 뜨자마자 반복해서 죽는 중 — 앱 크래시, 잘못된 실행 명령, 성급한 livenessProbe 등. kubelet이 재시작 간격을 늘려가며(back-off) 다시 띄우는 상태 직전 컨테이너의 로그 — kubectl logs -p
  • ImagePullBackOff는 5단계 중 5단계(kubelet의 이미지 풀)에서 막힌 것이다. 컨테이너는 Waiting 상태로 남는다.
  • Pending 지속4단계(스케줄러의 노드 선택)에서 막힌 것이다. 필터링을 통과한 노드가 하나도 없으면 Pod는 배정되지 못하고 Pending에 머문다.
  • CrashLoopBackOff는 5단계를 모두 통과해 컨테이너가 뜨긴 했는데, 뜨자마자 죽기를 반복하는 상태다. Kubernetes는 할 일을 다 한 것이므로 원인은 대개 컨테이너 안(앱)에 있다.

에러가 어느 단계에서 났는지 알면 원인의 절반은 찾은 셈이다. 5단계 그림을 머릿속에 넣어두자.

10.1 디버깅 루틴 — 보는 순서는 언제나 같다

증상이 무엇이든, 원인을 찾아 들어가는 순서는 같다. 네 단계를 루틴으로 몸에 익혀두자.

순서 명령 보는 곳 답하는 질문
1 kubectl get pods STATUS·READY·RESTARTS 무엇이 어떤 상태로 이상한가
2 kubectl describe pod <이름> Events, 컨테이너 State와 Reason 5단계 중 어디서 막혔나
3 kubectl logs <이름> (직전 컨테이너는 -p) 앱 로그 앱이 무슨 말을 남기고 죽었나
4 kubectl exec -it <이름> -- /bin/sh 컨테이너 내부 설정·파일·네트워크가 기대와 같은가
  • describe의 Events는 Kubernetes 쪽 사정(스케줄링·이미지 풀·재시작)을, logs는 앱 쪽 사정을 말해준다. 둘은 보는 층이 다르다.
  • kubectl logs -p(--previous)는 직전에 죽은 컨테이너의 로그를 보여준다. CrashLoopBackOff처럼 재시작이 반복되는 상황에서는 방금 새로 뜬 컨테이너의 로그가 비어 있기 일쑤라서, 죽기 직전의 마지막 메시지는 -p로 봐야 한다.
  • kubectl exec -it <이름> -- /bin/sh--는 kubectl의 옵션과 컨테이너 안에서 실행할 명령을 구분한다. 쉘조차 없는 초경량 이미지라면 kubectl debug로 디버깅용 임시 컨테이너(ephemeral container)를 붙이는 방법도 있다.

이번 글의 에러들을 루틴에 매핑하면 이렇다.

증상 막힌 단계 원인이 보이는 곳
Pending 지속 4단계 — 스케줄링 2번 describe → Events의 FailedScheduling
ImagePullBackOff 5단계 — 이미지 풀 2번 describe → Events의 풀 실패 메시지
CrashLoopBackOff 5단계 이후 — 앱 실행 3번 logs -p → 죽기 직전 앱 로그

컨테이너가 아예 못 뜬 상태(Pending, ImagePullBackOff)에서는 읽을 로그 자체가 없다 — describe가 답이다. 반대로 컨테이너가 떴다가 죽는 상태(CrashLoopBackOff)는 Events에 “재시작했다”는 사실만 반복될 뿐 이유는 없다 — 앱 로그가 답이다.


11. 마무리

이번 글의 핵심을 표 하나로 요약한다.

질문
Pod란? Kubernetes의 최소 배포 단위. 하나의 IP와 볼륨을 공유하는 컨테이너 그룹
왜 컨테이너가 아니라 Pod? 사이드카·init 컨테이너처럼 “반드시 함께 움직여야 하는 컨테이너 묶음”의 경계를 표현하기 위해
Pod는 어떻게 뜨나? YAML 작성 → kubectl 전송 → api-server가 etcd에 저장 → scheduler가 노드 선택 → kubelet이 실행
상태는 어떻게 확인하나? get pods -o widedescribe pod(Events) → get events --sort-by=...
phase는? Pending / Running / Succeeded / Failed / Unknown 다섯 가지
Running이면 끝인가? 아니다. liveness(재시작)·readiness(트래픽 제외)·startup(기동 보호) 3종 probe가 건강을 판정한다
requests vs limits? requests는 스케줄러의 배치 기준, limits는 상한 — 초과 시 CPU는 스로틀링, 메모리는 OOMKilled
배치를 제어하려면? 끌어당기는 nodeSelector·affinity, 밀어내는 taint/toleration — 조합해 전용 노드 풀 구성
Pod가 이상하면? get podsdescribe(Events) → logs -pexec 순서로 본다

직접 만든 Pod는 지우면 끝이고, 죽어도 되살아나지 않는다는 것도 확인했다. 실제 서비스에서 Pod를 날것으로 쓰지 않는 이유다.

이전 글 03: Kubernetes 아키텍처 — Control Plane과 Node에서 소개한 컴포넌트들이 이번 글의 5단계에서 어떻게 협업하는지 확인했다. 다음 글 05: 워크로드 — ReplicaSet, Deployment, StatefulSet, DaemonSet에서는 Pod가 죽어도 자동으로 되살리고, 스케일링과 롤링 업데이트까지 해주는 워크로드 리소스를 다룬다.


참고 문헌




Enjoy Reading This Article?

Here are some more articles you might like to read next:

  • Kubernetes 관측성 — 로그·메트릭과 Prometheus/Grafana
  • Kubernetes 확장과 생태계 — Operator와 CNCF Projects
  • Kubernetes 권한 관리 — ServiceAccount와 RBAC
  • Kubernetes 스토리지와 설정 — PV/PVC, ConfigMap, Secret
  • Kubernetes 네트워킹 — Service와 Ingress