Table of contents
- 왜 하필 파드인가
- 파드의 내부 풍경
- 가장 단순한 파드 띄워보기
- 파드의 라이프사이클
- 헬스 체크: Liveness, Readiness, Startup
- 멀티 컨테이너 패턴: 사이드카
- Init Container: 먼저 실행되고 끝나는 컨테이너
- 리소스 요청과 제한
- 파드는 일회용이다
왜 하필 파드인가
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, 같은 포트 공간. 서로 localhost로 통신
- 스토리지: 파드 레벨의 볼륨을 여러 컨테이너가 마운트
- 라이프사이클: 파드가 뜨고 죽을 때 함께 동작
파드 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
STATUS가 Running으로 바뀌면 컨테이너가 정상적으로 떠 있는 것이다. 파드 내부로 들어가서 curl로 자기 자신을 호출해볼 수도 있다.
kubectl exec -it nginx -- curl localhost:80
Welcome to nginx 페이지가 출력되면 성공이다. 다만 실무에서 이렇게 파드를 직접 만드는 일은 드물다. 파드 단독으로는 자가 치유가 안 되기 때문이다. 죽으면 그냥 사라진다. 그래서 4편에서 다룰 Deployment 같은 컨트롤러를 통해 파드를 관리한다.
파드의 라이프사이클
파드가 태어나서 죽을 때까지 어떤 단계를 거치는지 알아두면 디버깅할 때 큰 도움이 된다.
- Pending: 파드가 클러스터에 받아들여졌지만, 아직 컨테이너가 실행되기 전. 이미지 다운로드 중이거나, 배치될 노드를 고르는 중일 수 있다
- Running: 파드가 노드에 배정되었고, 컨테이너가 하나 이상 실행 중
- Succeeded: 모든 컨테이너가 성공적으로 종료되었고 재시작되지 않음(Job 같은 일회성 워크로드에서 주로 본다)
- Failed: 컨테이너가 비정상 종료되었고 재시작되지 않음
- Unknown: 노드와의 통신이 끊겨서 상태를 알 수 없음
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
각각의 역할은 이렇다.
- Liveness: “이 컨테이너가 살아 있나?” 실패하면 kubelet이 컨테이너를 재시작한다. 데드락 감지에 주로 쓴다
- Readiness: “이 컨테이너가 트래픽을 받을 준비가 되었나?” 실패하면 Service에서 이 파드를 제외한다(5편에서 연결된다). 부팅이 오래 걸리는 앱에 필수다
- Startup: “이 컨테이너가 부팅을 끝냈나?” 성공할 때까지 liveness/readiness를 보류한다. 부팅이 정말 오래 걸리는 레거시 앱을 위한 것이다
Readiness를 제대로 설정하지 않으면 방금 뜬 파드가 아직 준비도 안 된 상태에서 트래픽을 받고 500 에러를 내뱉는다. 배포 실패의 흔한 원인이다.
멀티 컨테이너 패턴: 사이드카
한 파드에 여러 컨테이너를 넣는 건 특수한 경우다. 가장 흔한 패턴이 사이드카(Sidecar)다. 메인 컨테이너 옆에 보조 역할 컨테이너를 붙이는 구조다.
대표적인 사이드카 용도는 이런 것들이다.
- 로그 수집: 메인 앱이 쓰는 로그 파일을 읽어서 중앙 로그 시스템으로 전송
- 프록시: Istio의 Envoy처럼 모든 트래픽을 가로채서 암호화/관측성 추가
- 설정 리로더: ConfigMap 변경을 감지해서 메인 앱에게 SIGHUP 신호 전달
파일 기반 로그를 공유하는 사이드카 예제를 보자.
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
이 파드는 다음 순서로 동작한다.
wait-for-db가 DB에 접속 가능해질 때까지 기다린다run-migration이 DB 스키마를 마이그레이션한다- 메인 컨테이너
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.requests와 resources.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편: 컨트롤러




Loading comments...