Skip to content
ioob.dev
Go back

Kubernetes 입문 3편 — Pod

· 8분 읽기
Kubernetes 시리즈 (3/12)
  1. Kubernetes 입문 1편 — Kubernetes란
  2. Kubernetes 입문 2편 — 클러스터 구조
  3. Kubernetes 입문 3편 — Pod
  4. Kubernetes 입문 4편 — 컨트롤러
  5. Kubernetes 입문 5편 — 서비스와 네트워킹
  6. Kubernetes 입문 6편 — Ingress와 Gateway API
  7. Kubernetes 입문 7편 — ConfigMap과 Secret
  8. Kubernetes 입문 8편 — 스토리지: PV, PVC, StorageClass
  9. Kubernetes 입문 9편 — 리소스 관리와 오토스케일링
  10. Kubernetes 입문 10편 — RBAC과 보안: 최소 권한의 원칙
  11. Kubernetes 입문 11편 — 관측성: 로그, 메트릭, 추적
  12. Kubernetes 입문 12편 — Helm과 패키지 관리
Table of contents

Table of contents

왜 하필 파드인가

Kubernetes를 처음 만지면 kubectl run부터 해보게 된다. 그러면 pod/nginx created 같은 메시지가 뜬다. 분명 컨테이너를 띄우려고 했는데 왜 “pod”라고 말하는 걸까?

Kubernetes의 배포 단위는 컨테이너가 아니라 파드(Pod)다. 파드는 하나 이상의 컨테이너를 묶은 단위이고, 같은 파드 안의 컨테이너들은 네트워크와 스토리지를 공유한다. 다시 말해, 같은 파드에 속한 컨테이너들은 같은 IP를 쓰고 localhost로 서로를 부를 수 있다.

왜 이런 한 단계를 더 뒀을까? 처음에는 그냥 컨테이너가 배포 단위여도 충분해 보인다. 그런데 실제 운영을 해보면 “메인 프로세스 + 로그 수집기”, “메인 프로세스 + TLS 프록시”처럼 밀접하게 붙어 다녀야 하는 컨테이너 쌍들이 자주 등장한다. 이들은 같은 네트워크/스토리지를 공유해야 하고, 배포 단위도 함께 묶이는 편이 자연스럽다. 파드는 이런 “서로 떼어놓을 수 없는 한 쌍”을 묶기 위한 장치다.

파드의 내부 풍경

파드 안에는 재미있는 조연이 한 명 있다. Pause 컨테이너다. 사용자가 선언한 앱 컨테이너들이 뜨기 전에, Pause라는 아주 작은 컨테이너가 먼저 올라온다. 얘가 네트워크 네임스페이스를 붙들고 있어주는 역할을 한다. 앱 컨테이너가 재시작되더라도 네트워크 정보가 유지되는 건 Pause 덕분이다.

flowchart TB
    subgraph POD["Pod (IP: 10.244.1.5)"]
        PAUSE[Pause Container<br/>네트워크 네임스페이스 홀더]
        APP[App Container]
        SIDE[Sidecar Container]
        VOL[(Shared Volume)]
        APP -.-|localhost| SIDE
        APP --> VOL
        SIDE --> VOL
    end

이 구조 덕분에 파드 안의 컨테이너들은 다음을 공유한다.

파드 IP는 파드마다 고유하지만, 파드가 죽으면 그 IP는 사라진다. 새로 뜨는 파드는 다른 IP를 받는다. 이 불안정성 때문에 파드를 직접 가리키지 않고 Service로 추상화하는 것이다(5편에서 다룬다).

가장 단순한 파드 띄워보기

먼저 nginx 컨테이너 한 개짜리 파드를 YAML로 만들어보자.

# nginx-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  containers:
  - name: nginx
    image: nginx:1.25
    ports:
    - containerPort: 80

이 파일을 클러스터에 적용하고, 상태를 확인해보자.

kubectl apply -f nginx-pod.yaml
kubectl get pod nginx -o wide

STATUSRunning으로 바뀌면 컨테이너가 정상적으로 떠 있는 것이다. 파드 내부로 들어가서 curl로 자기 자신을 호출해볼 수도 있다.

kubectl exec -it nginx -- curl localhost:80

Welcome to nginx 페이지가 출력되면 성공이다. 다만 실무에서 이렇게 파드를 직접 만드는 일은 드물다. 파드 단독으로는 자가 치유가 안 되기 때문이다. 죽으면 그냥 사라진다. 그래서 4편에서 다룰 Deployment 같은 컨트롤러를 통해 파드를 관리한다.

파드의 라이프사이클

파드가 태어나서 죽을 때까지 어떤 단계를 거치는지 알아두면 디버깅할 때 큰 도움이 된다.

stateDiagram-v2
    [*] --> Pending
    Pending --> Running: 컨테이너 시작
    Pending --> Failed: 이미지 pull 실패
    Running --> Succeeded: 정상 종료 (RestartPolicy=Never)
    Running --> Failed: 비정상 종료 (재시작 불가)
    Running --> Running: 컨테이너 재시작
    Running --> [*]: 삭제됨
    Succeeded --> [*]: 삭제됨
    Failed --> [*]: 삭제됨

kubectl describe pod <name>로 확인하면 Events 섹션에서 라이프사이클 전환이 시간순으로 보인다. “ImagePullBackOff”, “CrashLoopBackOff” 같은 익숙한 에러 메시지가 여기서 나온다.

헬스 체크: Liveness, Readiness, Startup

파드가 Running이라고 해서 꼭 정상 작동 중인 건 아니다. 컨테이너가 살아 있지만 애플리케이션은 데드락에 빠져 있을 수 있고, 부팅 중이라 아직 트래픽을 받을 준비가 안 됐을 수도 있다. Kubernetes는 세 가지 프로브(probe)로 이런 상태를 구분한다.

각 probe가 언제, 어떤 결정을 내리는지 흐름으로 보자.

flowchart TB
    START["컨테이너 시작"] --> STP{"startupProbe 있나?"}
    STP -->|있음| SLOOP{"startupProbe 통과?"}
    SLOOP -->|"실패 (failureThreshold 미달)"| SLOOP
    SLOOP -->|통과| READY["liveness/readiness 활성화"]
    SLOOP -->|"임계치 초과"| KILL["컨테이너 재시작"]
    STP -->|없음| READY
    READY --> LP{"livenessProbe 실패?"}
    LP -->|예| KILL
    LP -->|아니오| RP{"readinessProbe 실패?"}
    RP -->|예| REMOVE["Service Endpoints에서 제외"]
    RP -->|아니오| SERVE["정상 트래픽 수신"]
apiVersion: v1
kind: Pod
metadata:
  name: web
spec:
  containers:
  - name: web
    image: myapp:1.0
    ports:
    - containerPort: 8080
    livenessProbe:
      httpGet:
        path: /healthz
        port: 8080
      initialDelaySeconds: 30
      periodSeconds: 10
    readinessProbe:
      httpGet:
        path: /ready
        port: 8080
      periodSeconds: 5
    startupProbe:
      httpGet:
        path: /healthz
        port: 8080
      failureThreshold: 30
      periodSeconds: 10

각각의 역할은 이렇다.

Readiness를 제대로 설정하지 않으면 방금 뜬 파드가 아직 준비도 안 된 상태에서 트래픽을 받고 500 에러를 내뱉는다. 배포 실패의 흔한 원인이다.

멀티 컨테이너 패턴: 사이드카

한 파드에 여러 컨테이너를 넣는 건 특수한 경우다. 가장 흔한 패턴이 사이드카(Sidecar)다. 메인 컨테이너 옆에 보조 역할 컨테이너를 붙이는 구조다.

대표적인 사이드카 용도는 이런 것들이다.

파일 기반 로그를 공유하는 사이드카 예제를 보자.

apiVersion: v1
kind: Pod
metadata:
  name: app-with-logger
spec:
  volumes:
  - name: logs
    emptyDir: {}
  containers:
  - name: app
    image: myapp:1.0
    volumeMounts:
    - name: logs
      mountPath: /var/log/app
  - name: log-forwarder
    image: fluent/fluent-bit:latest
    volumeMounts:
    - name: logs
      mountPath: /var/log/app
      readOnly: true

emptyDir 볼륨을 두 컨테이너가 함께 마운트한다. 메인 앱이 /var/log/app에 로그를 쌓으면, 로그 전송용 사이드카가 같은 경로를 읽어서 외부로 내보낸다. 같은 파드 안이니 네트워크 홉 없이 파일시스템만으로 소통한다.

Init Container: 먼저 실행되고 끝나는 컨테이너

메인 컨테이너가 실행되기 전에 반드시 마쳐야 하는 초기화 작업이 있다면 Init Container를 쓴다. Init Container는 순서대로 실행되고, 모두 성공하면 그제야 메인 컨테이너가 시작된다.

apiVersion: v1
kind: Pod
metadata:
  name: app-with-init
spec:
  initContainers:
  - name: wait-for-db
    image: busybox:1.36
    command: ['sh', '-c', 'until nc -z db 5432; do echo "waiting db"; sleep 2; done']
  - name: run-migration
    image: myapp:1.0
    command: ['./migrate.sh']
  containers:
  - name: app
    image: myapp:1.0

이 파드는 다음 순서로 동작한다.

  1. wait-for-db가 DB에 접속 가능해질 때까지 기다린다
  2. run-migration이 DB 스키마를 마이그레이션한다
  3. 메인 컨테이너 app이 시작된다

시간축으로 보면 이런 모양이다. 앞 단계가 성공해야 다음 단계로 넘어가는 순차적 실행이 핵심이다.

sequenceDiagram
    participant K as kubelet
    participant I1 as initContainer:wait-for-db
    participant I2 as initContainer:run-migration
    participant M as container:app

    K->>I1: 실행
    I1->>I1: DB 접속 폴링
    I1-->>K: exit 0 (성공)
    K->>I2: 실행
    I2->>I2: 마이그레이션
    I2-->>K: exit 0 (성공)
    K->>M: 실행 (메인)
    Note over M: Pod Running 상태

Init Container를 안 쓰고 메인 앱 안에 이런 로직을 넣을 수도 있다. 하지만 분리하면 책임이 명확해지고, 이미지도 작게 유지할 수 있다. 무엇보다 마이그레이션 실패 시 메인 앱이 아예 실행되지 않아 잘못된 상태로 트래픽을 받는 사고를 예방할 수 있다.

리소스 요청과 제한

파드를 띄울 때 “이 파드는 CPU를 얼마나 쓰고, 메모리는 최대 얼마까지만 쓸 수 있다”를 명시하는 게 좋다. 이걸 resources.requestsresources.limits로 설정한다.

spec:
  containers:
  - name: app
    image: myapp:1.0
    resources:
      requests:
        cpu: "200m"      # 0.2 CPU 코어
        memory: "256Mi"
      limits:
        cpu: "1"
        memory: "512Mi"

requests는 Scheduler가 파드를 배치할 때 참고하는 값이다. “이 노드에 200m 이상의 여유 CPU가 있어야 이 파드가 들어갈 수 있다”는 기준이 된다. limits는 런타임에서의 상한이다. 메모리 제한을 넘으면 컨테이너가 OOMKilled로 죽는다.

운영에서 가장 많이 사고가 나는 지점이 이 설정이다. requests가 너무 크면 리소스 낭비, 너무 작으면 다른 파드와 경합. limits가 너무 작으면 OOMKilled로 앱이 재시작 지옥에 빠진다. 실제 부하를 측정해보고 조정하는 과정이 필요하다.

파드는 일회용이다

이번 편을 마치며 가장 중요한 관점을 하나 남긴다. 파드는 일회용(ephemeral)이다. 죽었다 살아나면 다른 파드고, IP가 바뀌고, 로컬 디스크 내용이 사라진다.

이 특성은 불편한 것이 아니라 의도된 설계다. 파드가 쉽게 죽고 쉽게 태어날 수 있다는 전제 덕분에 자가 치유, 수평 확장, 롤링 업데이트가 가능해진다. 애플리케이션이 이 전제를 수용하지 못하면(예: 로컬 디스크에 중요한 상태를 쌓는다면) Kubernetes의 장점을 제대로 누리기 어렵다.

그래서 상태는 외부(DB, 오브젝트 스토리지, 퍼시스턴트 볼륨)로 밀어내고, 파드는 상태 없는(stateless) 처리 유닛으로 설계하는 게 Kubernetes 친화적인 접근이다.


다음 편에서는 파드를 직접 관리하지 않고, Deployment 같은 컨트롤러를 통해 여러 파드를 선언적으로 다루는 방법을 살펴본다. 롤링 업데이트와 롤백이 어떻게 안전하게 이뤄지는지도 함께 정리한다.

4편: 컨트롤러


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Kubernetes 입문 2편 — 클러스터 구조
Next Post
Kubernetes 입문 4편 — 컨트롤러