4.1 Pod
Kubernetes가 만들고, 스케줄하고, 지우는 최소 단위는 컨테이너가 아니라 Pod다. Pod는 하나 이상의 컨테이너를 묶어 항상 같은 노드에서 함께 실행하는 단위이고, 묶인 컨테이너들은 네트워크와 볼륨을 공유한다. 실전의 Pod 대부분은 컨테이너가 하나뿐이라 이 구분이 형식처럼 보이지만, 컨테이너를 한 겹 감싼 이 추상이 Pod 네트워크 모델과 sidecar 패턴의 토대가 된다.
컨테이너 묶음이 공유하는 것
Pod 안의 컨테이너들은 Linux namespace의 일부를 공유한다. 가장 중요한 것이 network namespace다. Pod는 IP 주소를 하나 받고, 안의 모든 컨테이너가 그 IP와 포트 공간을 같이 쓴다. 같은 Pod의 컨테이너끼리는 localhost로 통신하고, 대신 두 컨테이너가 같은 포트를 열 수 없다. 밖에서 보면 Pod 하나가 독립된 호스트 하나처럼 동작한다.
IPC namespace도 공유한다. SysV shared memory나 POSIX message queue 같은 표준 프로세스 간 통신이 컨테이너 경계를 넘어 동작한다. 여기에 Pod 수준에서 정의한 volume을 각 컨테이너가 원하는 경로에 마운트해 파일을 주고받는다. 반면 파일시스템 자체는 컨테이너마다 각자의 이미지에서 나오므로 분리되어 있고, PID namespace도 기본값은 분리다. shareProcessNamespace: true를 지정한 Pod에서만 서로의 프로세스가 보인다.
컨테이너 둘이 이 정도를 공유한다는 것은 사실상 같은 머신에 설치된 두 프로세스라는 뜻이다. Pod라는 단위가 보장하는 것이 정확히 그것이다. 함께 스케줄되고, 함께 살고, 함께 죽는다.
pause 컨테이너
namespace는 그것을 쓰는 프로세스가 하나라도 살아 있어야 유지된다. 애플리케이션 컨테이너가 namespace의 주인이라면, 그 컨테이너가 crash로 재시작될 때마다 Pod의 IP와 네트워크 상태가 통째로 사라지는 문제가 생긴다. 그래서 컨테이너 런타임은 Pod마다 pause라는 인프라 컨테이너를 먼저 만들고, network namespace와 IPC namespace의 소유권을 여기에 둔다. 애플리케이션 컨테이너들은 pause의 namespace에 참여하는 형태로 기동한다.
pause 컨테이너가 하는 일은 이름 그대로 거의 없다. 시그널을 기다리며 잠들어 있을 뿐이다. 그 존재 덕분에 애플리케이션 컨테이너가 몇 번을 재시작해도 Pod의 IP는 바뀌지 않는다. kubectl get pods 출력 어디에도 나타나지 않지만, 노드에서 컨테이너 런타임을 직접 조회하면 Pod 수만큼의 pause 컨테이너가 보인다. shareProcessNamespace를 켠 Pod에서는 pause가 PID 1이 되어 고아가 된 zombie 프로세스를 수거하는 역할도 맡는다.
컨테이너를 하나 넣을까 여럿 넣을까
기본 답은 하나다. Pod는 스케일의 단위이기도 해서, replica를 늘리면 Pod 전체가 통째로 복제된다. 웹 서버와 데이터베이스를 한 Pod에 넣으면 웹 서버를 3개로 늘릴 때 데이터베이스도 3개가 된다. 따로 스케일할 것들은 따로 Pod로 나누고 Service로 연결하는 것이 맞다.
여러 컨테이너를 한 Pod에 두는 판단은 세 가지 질문으로 정리된다. 두 프로세스가 반드시 같은 머신에 있어야 하는가, 생명주기를 같이하는가, 항상 1:1 비율인가. 셋 모두 그렇다일 때만 묶는다. 애플리케이션의 로그 파일을 읽어 외부로 전송하는 로그 수집기, 트래픽을 가로채는 서비스 메시 proxy가 전형적인 예다. 주 컨테이너를 보조한다는 의미로 이런 컨테이너를 sidecar라고 부른다.
init container와 sidecar
initContainers에 나열한 컨테이너는 애플리케이션 컨테이너보다 먼저, 나열한 순서대로 하나씩 실행된다. 각 init container가 성공(exit 0)으로 끝나야 다음 것이 시작되고, 전부 끝나야 애플리케이션 컨테이너가 기동한다. 의존 서비스가 준비될 때까지 대기, 설정 파일 생성, 권한 조정 같은 준비 작업이 들어갈 자리다. init container가 실패하면 kubelet은 Pod의 restartPolicy에 따라 재시도하고, Never라면 Pod 전체가 실패로 끝난다.
sidecar를 오랫동안 일반 컨테이너로 나란히 두던 관행에는 순서 문제가 있었다. 로그 수집기가 애플리케이션보다 늦게 기동하면 초기 로그가 유실되고, Job에서는 애플리케이션이 끝나도 sidecar가 살아 있어 Job이 완료되지 않았다. 이를 해결하는 sidecar 컨테이너 기능이 1.29에서 beta로 기본 활성화되었고 1.33에서 GA가 됐다. 문법은 init container의 변형이다. initContainers 항목에 restartPolicy: Always를 지정하면 그 컨테이너는 완료를 기다리는 대신 시작만 확인하고 다음으로 넘어가며, Pod가 사는 동안 계속 실행된다.
apiVersion: v1
kind: Pod
metadata:
name: app-with-sidecar
spec:
initContainers:
- name: wait-for-db
image: busybox:1.36
command: ["sh", "-c", "until nc -z db 5432; do sleep 2; done"]
- name: log-shipper # sidecar
image: fluent/fluent-bit:3.0
restartPolicy: Always
volumeMounts:
- name: logs
mountPath: /var/log/app
containers:
- name: app
image: nginx:1.27
volumeMounts:
- name: logs
mountPath: /var/log/nginx
volumes:
- name: logs
emptyDir: {}sidecar는 애플리케이션 컨테이너보다 먼저 시작되고, 종료할 때는 애플리케이션이 끝난 뒤 역순으로 종료된다. 일반 init container와 달리 liveness, readiness, startup probe도 부착 가능하다. 시작과 종료 순서를 도식으로 보면 다음과 같다.
flowchart TD
A["init: wait-for-db 완료"] --> B["sidecar: log-shipper 시작"]
B --> C["app 컨테이너 기동"]
C --> D["실행 중 (sidecar 동행)"]
D --> E["종료: app 먼저"]
E --> F["종료: sidecar 마지막"]
정리
- Pod는 network/IPC namespace와 volume을 공유하는 컨테이너 묶음이고, 그 namespace를 보유하는 주체가 pause 컨테이너다. 애플리케이션이 재시작해도 Pod IP가 유지되는 이유다.
- 한 Pod에 여러 컨테이너를 두는 기준은 같은 머신, 같은 생명주기, 1:1 비율이다. 따로 스케일할 것은 따로 Pod로 나눈다.
- init container는 순서대로 완료를 보장하는 준비 단계이고, 여기에
restartPolicy: Always를 붙인 것이 sidecar 컨테이너다(1.33 GA).