Pod, Deployment, StatefulSet, Job
저번 포스팅에서는, 쿠버네티스의 아키텍처와 Pod 하나가 뜨기까지의 흐름을 공부하였습니다. 이번에는 실제로 매일 다루게 되는 워크로드 리소스를 정리해보려 합니다. Pod 의 내부가 어떻게 생겼는지부터 시작해, Deployment · StatefulSet · DaemonSet · Job 이 각각 어떤 문제를 풀기 위해 존재하는지, 그리고 무중단 배포가 실제로 어떻게 이루어지는지까지 다루겠습니다.
1. Pod
1.1. 왜 컨테이너가 아니라 Pod 인가
쿠버네티스의 최소 배포 단위는 컨테이너가 아니라 Pod 입니다. Pod 하나에 컨테이너가 여러 개 들어갈 수 있습니다. 왜 한 겹을 더 두었을까요. 함께 떠야 하고, 같은 자원을 공유해야 하는 프로세스들이 실제로 존재하기 때문입니다. 예를 들어 애플리케이션과 그 로그를 수집해 보내는 사이드카는 같은 디스크를 봐야 하고, 같은 노드에 있어야 하며, 함께 죽고 함께 살아야 합니다.
이런 것을 컨테이너 하나에 우겨넣으면 컨테이너 이미지가 무거워지고 수명 주기가 섞입니다. 그렇다고 완전히 분리하면 같은 노드에 배치되는 것을 보장할 수 없습니다. Pod 는 그 중간을 메우는 장치입니다.
1.2. Pod 안에서 공유되는 것
같은 Pod 안의 컨테이너들은 다음을 공유합니다.
- 네트워크 네임스페이스 :
IP 가 하나입니다. 컨테이너끼리는
localhost로 통신하고 포트를 서로 겹쳐 쓸 수 없습니다. - IPC 네임스페이스 : 프로세스 간 통신 수단을 공유합니다.
- 볼륨 : Pod 에 정의한 볼륨을 여러 컨테이너가 함께 마운트할 수 있습니다.
반대로 공유하지 않는 것도 있습니다. 파일 시스템은 각자 따로입니다. 볼륨으로 명시적으로 연결한 경로만 함께 봅니다. PID 네임스페이스도 기본은 분리이고, 옵션으로 공유할 수 있습니다.
1.3. pause 컨테이너
Pod 의 네트워크 네임스페이스는 pause 컨테이너라는 아주 작은 컨테이너가 들고 있습니다. 이 컨테이너는 아무 일도 하지 않고 그냥 잠들어 있습니다. 존재 이유는 네임스페이스를 붙들고 있는 것 하나입니다. 본 컨테이너가 죽었다 다시 떠도 pause 가 살아 있으면 Pod 의 IP 가 유지됩니다. 만약 본 컨테이너가 네임스페이스를 들고 있었다면, 그것이 재시작될 때마다 IP 가 바뀌었을 것입니다.
1.4. initContainer
본 컨테이너가 뜨기 전에 순서대로, 완료될 때까지 실행되는 컨테이너입니다. 하나가 끝나야 다음이 시작되고 전부 끝나야 본 컨테이너가 뜹니다. 쓰임새는 이렇습니다.
- 의존하는 서비스가 준비될 때까지 기다리기
- 설정 파일을 내려받아 공유 볼륨에 두기
- 데이터베이스 마이그레이션 실행하기
- 권한이 필요한 초기화를 하고 본 컨테이너는 권한 없이 띄우기
마지막 항목이 보안 면에서 특히 유용합니다. 초기화에만 필요한 권한을 본 컨테이너에 주지 않아도 됩니다.
1.5. 사이드카 컨테이너
본 컨테이너와 함께 계속 도는 보조 컨테이너입니다. 로그 수집기, 서비스 메시의 프록시, 설정 리로더 등이 여기에 해당합니다. 원래는 그냥 컨테이너를 여러 개 적는 방식이었는데 이 방식에는 수명 주기 문제가 있었습니다. Job 에서 본 컨테이너가 끝나도 사이드카가 계속 살아 있어 Job 이 완료되지 않는 문제가 대표적입니다.
그래서 restartPolicy: Always 를 가진 initContainer 라는 형태로 정식 사이드카가 도입되었습니다(1.29 베타, 1.33 정식). 이렇게 선언하면 본 컨테이너보다 먼저 시작되고 본 컨테이너가 끝나면 함께 종료됩니다. 시작 순서와 종료 순서를 모두 보장받는 것이 핵심입니다.
1.6. Pod 의 생명주기
Pod 의 status.phase 는 다섯 가지입니다.
| 단계 | 뜻 |
|---|---|
| Pending | 아직 노드에 배정되지 않았거나, 이미지를 받는 중 |
| Running | 노드에 배정되었고 컨테이너가 최소 하나 돌고 있음 |
| Succeeded | 모든 컨테이너가 정상 종료, 재시작하지 않음 |
| Failed | 모든 컨테이너가 종료되었고 하나 이상이 비정상 종료 |
| Unknown | 노드와 통신이 되지 않아 상태를 알 수 없음 |
여기서 자주 헷갈리는 것이 Running 과 Ready 의 차이입니다. Running 은 프로세스가 떠 있다는 뜻일 뿐이고 트래픽을 받을 준비가 되었는지는 Ready 조건이 따로 판단합니다. Ready 는 readinessProbe 가 결정합니다.
1.7. Pod 는 지워지면 돌아오지 않는다
Pod 자체는 자기 치유 능력이 없습니다. Pod 를 직접 만들면(bare Pod) 그 Pod 가 죽었을 때 아무도 되살리지 않습니다. 노드가 죽으면 그대로 사라집니다. 그래서 실무에서는 Pod 를 직접 만들지 않고 컨트롤러로 만듭니다. 다음 장부터가 그 컨트롤러들입니다.
2. Deployment
가장 많이 쓰는 워크로드입니다. 상태가 없는(stateless) 애플리케이션을 원하는 개수만큼 유지하고, 새 버전으로 교체하는 일을 맡습니다.
2.1. 세 겹 구조
2편에서 본 대로 Deployment 는 Pod 를 직접 만들지 않습니다.
Deployment → ReplicaSet → Pod
(버전 관리) (개수 유지) (실제 실행)
역할이 이렇게 갈립니다.
- ReplicaSet : 주어진 개수를 유지하는 것만 합니다. 버전 개념이 없습니다.
- Deployment : 여러 ReplicaSet 을 두고 그 사이에서 Pod 수를 옮겨가며 버전을 교체합니다.
이 구조 덕분에 롤백이 쉽습니다. 새 버전으로 바꿔도 이전 ReplicaSet 이 replicas 0 인 상태로 남아 있어서, 되돌릴 때 그쪽 개수를 다시 올리기만 하면 됩니다. 몇 개를 남길지는 revisionHistoryLimit 으로 정합니다.
2.2. 롤링 업데이트
기본 배포 전략입니다. Pod 를 조금씩 새 버전으로 갈아끼웁니다. 속도를 정하는 값이 두 개입니다.
- maxSurge : 원하는 개수보다 몇 개까지 더 띄울 수 있는가. 기본 25%.
- maxUnavailable : 원하는 개수보다 몇 개까지 모자라도 되는가. 기본 25%.
이 둘의 조합이 배포 성격을 결정합니다.
maxSurge: 1, maxUnavailable: 0용량을 절대 줄이지 않습니다. 새것을 먼저 띄우고 준비되면 옛것을 내립니다. 가장 안전하지만 자원이 조금 더 필요하고 느립니다.maxSurge: 0, maxUnavailable: 1자원을 더 쓰지 않습니다. 하나를 내리고 그 자리에 새것을 띄웁니다. 배포 중 용량이 줄어듭니다.
여기서 가장 중요한 것은 "준비되면" 의 판단을 readinessProbe 가 한다는 점입니다. readinessProbe 를 걸지 않으면 컨테이너 프로세스가 뜨자마자 Ready 로 간주되어, 아직 초기화 중인 Pod 로 트래픽이 들어가고 다음 Pod 가 교체됩니다. 무중단 배포가 무너지는 가장 흔한 원인입니다.
2.3. 롤백과 배포 멈춤
kubectl rollout status deployment/web # 진행 상황 보기
kubectl rollout undo deployment/web # 직전 버전으로 되돌리기
kubectl rollout pause deployment/web # 진행 중인 배포 멈추기
pause 는 카나리 배포를 손으로 할 때 쓸 수 있습니다. 일부만 새 버전으로 바뀐 상태에서 멈춰두고 지표를 본 뒤, 괜찮으면 resume 하고 아니면 undo 합니다. 배포가 진행되지 않을 때를 위해 progressDeadlineSeconds 도 있습니다. 이 시간 안에 진척이 없으면 Deployment 의 상태에 실패가 기록됩니다. 다만 자동으로 롤백되지는 않습니다.
2.4. 다른 배포 전략
strategy 에서 고를 수 있는 것은 RollingUpdate 와 Recreate 둘뿐입니다. Recreate 는 전부 내리고 전부 새로 띄웁니다. 다운타임이 생기지만 구버전과 신버전이 공존하면 안 되는 경우(스키마가 바뀌는 등)에 씁니다.
블루-그린이나 카나리는 쿠버네티스 기본 기능이 아닙니다. Service 의 셀렉터를 바꿔 트래픽을 통째로 옮기거나(블루-그린), Deployment 를 두 개 두고 개수 비율로 트래픽을 나누는(카나리) 식으로 직접 구성하거나, Argo Rollouts 같은 도구를 씁니다.
3. StatefulSet
상태를 가진 애플리케이션을 위한 워크로드입니다. 데이터베이스, Kafka, ZooKeeper 처럼 각 인스턴스가 고유한 정체성과 자기 데이터를 갖는 경우에 씁니다.
3.1. Deployment 로는 안 되는 이유
Deployment 가 만든 Pod 는 서로 구별되지 않습니다. 이름이 web-7d4b8c9f-x8k2p 처럼 무작위이고 죽으면 다른 이름으로 다시 뜹니다. 어느 것이 죽어도 상관없다는 전제입니다. 그런데 데이터베이스 클러스터에서는 이 전제가 깨집니다.
- 어느 것이 리더인지 구별되어야 합니다.
- 재시작해도 자기 데이터를 다시 붙잡아야 합니다.
- 다른 노드가 고정된 주소로 찾아올 수 있어야 합니다.
- 시작과 종료에 순서가 필요할 때가 있습니다.
3.2. StatefulSet 이 보장하는 것
- 고정된 이름 :
web-0,web-1,web-2처럼 순번이 붙습니다.web-1이 죽으면 다시web-1이라는 이름으로 뜹니다. - 고정된 스토리지 :
volumeClaimTemplates로 Pod 마다 별도의 PVC 가 만들어집니다.web-1은 항상data-web-1을 다시 붙잡습니다. - 고정된 네트워크 주소 :
헤드리스 서비스와 함께 쓰면
web-1.web.default.svc.cluster.local같은 개별 DNS 이름이 생깁니다. - 순서 보장 : 생성은 0 → 1 → 2 순이고 앞의 것이 Ready 가 되어야 다음이 시작됩니다. 삭제는 역순입니다.
3.3. 운영에서 알아둘 점
- PVC 는 자동으로 지워지지 않는다 :
StatefulSet 을 지워도 PVC 는 남습니다. 데이터를 실수로 날리지 않기 위한 기본값입니다. 최근 버전에는 이를 제어하는
persistentVolumeClaimRetentionPolicy가 있습니다. - 스케일 다운은 역순이다 :
3개에서 2개로 줄이면
web-2가 사라집니다. - 순서 보장이 발목을 잡을 수 있다 :
web-0이 Ready 가 되지 않으면 뒤가 영원히 뜨지 않습니다. 이를 피하려면podManagementPolicy: Parallel로 바꿀 수 있습니다. - StatefulSet 이 데이터베이스를 운영해주지는 않는다 : 정체성과 스토리지를 붙여줄 뿐, 리더 선출이나 복제 설정은 애플리케이션이나 오퍼레이터의 몫입니다.
4. DaemonSet
모든 노드에 Pod 를 하나씩 배치합니다. 노드가 새로 추가되면 거기에도 자동으로 하나가 뜨고 노드가 빠지면 함께 사라집니다.
쓰임새는 대체로 노드 단위로 동작해야 하는 것들입니다.
- 로그 수집기 : 그 노드의 컨테이너 로그를 읽어 보냅니다.
- 모니터링 에이전트 : 노드의 자원 사용량을 수집합니다.
- CNI 플러그인, kube-proxy : 네트워크 설정을 노드마다 넣습니다.
- 스토리지 드라이버 : 노드에 볼륨을 붙입니다.
특정 노드에만 두고 싶으면 nodeSelector 나 affinity 를 겁니다. 그리고 컨트롤 플레인 노드처럼 taint 가 걸린 노드에도 배치하려면 toleration 이 필요합니다. 모니터링 에이전트처럼 정말 모든 노드에 있어야 하는 것은 toleration 을 넓게 겁니다. 이 부분은 6편에서 다루겠습니다.
5. Job 과 CronJob
지금까지의 워크로드는 계속 도는 것이 전제였습니다. Job 은 끝나는 것을 다룹니다.
5.1. Job
정해진 횟수만큼 성공적으로 완료되는 것을 목표로 합니다.
- completions : 총 몇 번 성공해야 하는가
- parallelism : 동시에 몇 개까지 돌릴 것인가
- backoffLimit : 실패했을 때 몇 번까지 재시도할 것인가
- activeDeadlineSeconds : 전체 제한 시간
Pod 의 restartPolicy 도 여기서는 의미가 달라집니다. Job 에서는 Never 또는 OnFailure 만 쓸 수 있습니다. Never 면 실패한 Pod 는 남겨두고 새 Pod 를 만들어 재시도하고 OnFailure 면 같은 Pod 안에서 컨테이너를 다시 띄웁니다. 실패 원인을 보려면 Never 가 유리합니다.
끝난 Job 의 Pod 는 기본적으로 남습니다. 로그를 보기 위해서인데 쌓이면 부담이 되므로 ttlSecondsAfterFinished 로 자동 정리를 걸 수 있습니다.
5.2. CronJob
Job 을 주기적으로 만들어 줍니다. 리눅스 cron 과 같은 표현식을 씁니다. 운영에서 꼭 챙겨야 하는 것이 concurrencyPolicy 입니다.
- Allow (기본) : 앞의 실행이 안 끝났어도 새로 시작합니다. 겹쳐 돌아도 되는 작업만 이래야 합니다.
- Forbid : 앞의 것이 돌고 있으면 이번 회차를 건너뜁니다.
- Replace : 앞의 것을 죽이고 새로 시작합니다.
배치 작업이 예상보다 오래 걸려 다음 회차와 겹치면서 데이터가 중복 처리되는 사고가 흔합니다. 기본값이 Allow 라는 점을 모르고 지나가면 그렇게 됩니다. 그리고 startingDeadlineSeconds 도 알아둘 만합니다. 컨트롤러가 잠시 멈춰 회차를 놓쳤을 때, 이 시간 안이면 뒤늦게라도 실행합니다. 이 값을 두지 않으면 놓친 회차가 한꺼번에 실행되는 상황이 생길 수 있습니다.
6. 종료는 어떻게 이루어지는가
무중단 배포를 이야기할 때 시작보다 종료가 더 자주 문제가 됩니다. 면접에서도 자주 파고드는 지점입니다.
6.1. 종료 절차
Pod 를 지우라는 요청이 들어오면 두 가지 일이 동시에 시작됩니다.
- 엔드포인트에서 제거 : 이 Pod 를 가리키던 Service 의 엔드포인트 목록에서 빠집니다. 새 트래픽이 오지 않게 됩니다.
- 컨테이너 종료 : preStop 훅이 있으면 실행하고 그다음 프로세스에 SIGTERM 을 보냅니다.
terminationGracePeriodSeconds(기본 30초) 안에 프로세스가 스스로 끝나지 않으면 SIGKILL 로 강제 종료됩니다.
6.2. 동시에 일어나는 것이 문제다
위 두 가지가 동시에 진행된다는 점이 핵심입니다. 순서가 보장되지 않습니다. 엔드포인트에서 빠지는 것은 여러 노드의 kube-proxy 가 각자 규칙을 갱신해야 완료됩니다. 그 전파에 시간이 걸립니다. 그런데 컨테이너는 SIGTERM 을 받자마자 종료를 시작합니다.
그 사이에 들어온 요청은 이미 종료를 시작한 Pod 로 향합니다. 배포할 때 간헐적으로 5xx 가 뜨는 전형적인 원인입니다.
6.3. 그래서 어떻게 하는가
- preStop 훅으로 잠깐 기다린다 : 몇 초간 sleep 을 넣어, 엔드포인트 제거가 퍼질 시간을 법니다. 투박하지만 널리 쓰이는 방법입니다.
- 애플리케이션이 SIGTERM 을 제대로 처리한다 : 새 요청은 받지 않되 처리 중인 요청은 끝내고 종료하는 코드(graceful shutdown)가 있어야 합니다.
- graceful period 를 충분히 둔다 : 처리에 오래 걸리는 작업이 있다면 30초로는 부족할 수 있습니다.
여기서 SIGTERM 이 컨테이너의 PID 1 에게 간다는 점도 알아둘 필요가 있습니다. 셸 스크립트로 애플리케이션을 실행하면 PID 1 이 셸이 되어, 신호가 애플리케이션까지 전달되지 않을 수 있습니다. exec 를 쓰거나 tini 같은 init 을 두는 이유입니다.
6.4. PodDisruptionBudget
노드를 점검하려고 비울 때(kubectl drain), 그 위의 Pod 가 한꺼번에 쫓겨나면 서비스가 끊깁니다. PDB 는 "이 라벨을 가진 Pod 는 최소 몇 개는 항상 살아 있어야 한다" 를 선언합니다. minAvailable 또는 maxUnavailable 로 적습니다.
주의할 것은 PDB 가 자발적 중단(voluntary disruption)에만 적용된다는 점입니다. 노드 비우기나 클러스터 오토스케일러의 축소는 막아주지만 노드가 갑자기 죽는 것은 막지 못합니다. 그것은 애초에 막을 수 있는 성질의 일이 아닙니다.
7. 조금 더 들어가보기 : 신호는 누구에게 가는가
앞에서 종료 절차를 보면서 SIGTERM 이 PID 1 에게 간다고 짚었습니다. 이 부분은 리눅스의 프로세스 동작을 알아야 제대로 이해되고 graceful shutdown 이 코드상으로는 분명히 구현되어 있는데 동작하지 않는 상황의 원인이기도 합니다.
7.1. PID 1 은 특별 대우를 받는다
리눅스에서 PID 1 은 원래 init 프로세스입니다. 커널이 부팅 직후 띄우는 첫 프로세스이고 이것이 죽으면 시스템이 멈춥니다. 그래서 커널은 PID 1 을 보호합니다. PID 1 에게는 기본 신호 처리가 적용되지 않습니다.
보통의 프로세스는 SIGTERM 을 받으면 핸들러를 등록하지 않았을 때 기본 동작으로 종료됩니다. 그런데 PID 1 은 다릅니다. 핸들러를 등록하지 않았다면 그 신호는 그냥 무시됩니다. 컨테이너 안에서는 PID 네임스페이스가 따로이므로, 그 안의 첫 프로세스가 PID 1 이 됩니다. 즉 우리가 띄운 애플리케이션이 이 특별 대우를 받게 됩니다.
여기서 첫 번째 함정이 나옵니다. SIGTERM 을 처리하지 않는 애플리케이션은 그 신호를 무시합니다. 그러고는 유예 시간이 끝난 뒤 SIGKILL 로 강제 종료됩니다. 배포할 때마다 매번 30초씩 걸리고 처리 중이던 요청이 끊기는데 로그에는 아무 단서가 없습니다.
7.2. 셸로 감싸면 신호가 도달하지 않는다
더 흔한 경우입니다. 이미지의 진입점을 이렇게 두는 경우가 많습니다.
CMD ["sh", "-c", "java -jar app.jar"]
이렇게 하면 PID 1 은 sh 이고, 자바 프로세스는 그 자식이 됩니다. 쿠버네티스가 SIGTERM 을 보내면 sh 가 받습니다. 그런데 sh 는 받은 신호를 자식에게 전달하지 않습니다. 자바 애플리케이션에 graceful shutdown 이 아무리 잘 구현되어 있어도 신호 자체가 도달하지 않으니 아무 일도 일어나지 않습니다.
해결 방법은 두 가지입니다.
exec로 프로세스를 교체합니다.dockerfileCMD ["sh", "-c", "exec java -jar app.jar"]exec는 새 프로세스를 만드는 대신 현재 프로세스를 덮어씁니다. 그래서 자바가 PID 1 이 되고 신호를 직접 받습니다.- 배열 형태로 직접 실행합니다.
셸을 거치지 않으므로 애초에 문제가 생기지 않습니다. 다만 이 형태에서는 셸 변수 확장이나 파이프를 쓸 수 없습니다.dockerfile
CMD ["java", "-jar", "app.jar"]
배포할 때마다 종료가 정확히 유예 시간만큼 걸린다면 이것을 의심해야 합니다.
7.3. 좀비 프로세스와 init 의 또 다른 역할
PID 1 에게는 신호 처리 말고도 책임이 하나 더 있습니다. 고아가 된 프로세스를 거두는 일입니다. 리눅스에서 자식 프로세스가 끝나면 곧바로 사라지지 않습니다. 종료 코드를 부모가 읽어갈 때까지 좀비 상태로 프로세스 테이블에 남습니다. 부모가 먼저 죽어버리면 그 자식은 PID 1 에게 입양되고 PID 1 이 대신 거둬줍니다.
그런데 우리 애플리케이션이 PID 1 인 컨테이너에서는, 애플리케이션이 이 일을 해주지 않습니다. 그럴 이유가 없으니 구현되어 있지 않습니다. 자식 프로세스를 자주 만드는 컨테이너라면 좀비가 계속 쌓이고 결국 프로세스 테이블이 차서 새 프로세스를 만들 수 없게 됩니다.
그래서 tini 같은 아주 작은 init 을 PID 1 로 두는 방식을 씁니다. tini 가 신호를 자식에게 전달해주고 좀비도 거둬줍니다. 컨테이너 런타임에도 이 기능이 옵션으로 들어 있습니다.
7.4. SIGKILL 은 잡을 수 없다
유예 시간이 지나면 SIGKILL 이 갑니다. 이 신호는 프로세스가 가로챌 수 없고 무시할 수도 없습니다. 커널이 프로세스를 그 자리에서 제거합니다. 그래서 SIGKILL 이 오면 정리할 기회가 없습니다. 열어둔 파일을 닫지도, 처리 중이던 요청을 마치지도, 연결을 정상적으로 끊지도 못합니다.
유예 시간을 얼마나 줄 것인가는 "가장 오래 걸리는 요청이 끝나는 데 필요한 시간" 을 기준으로 정해야 합니다. 기본값 30초는 짧은 API 서버에는 넉넉하지만 배치성 처리를 하는 워커에는 턱없이 부족할 수 있습니다.
결론
워크로드를 정리하면서 가장 크게 배운 것은, 각 리소스가 "무엇을 보장하는가" 로 갈린다는 점이었습니다. Deployment 는 개수를, StatefulSet 은 정체성과 순서를, DaemonSet 은 노드당 하나를, Job 은 완료를 보장합니다. 처음에는 종류가 많아 외워야 할 것으로 보였는데 "이 워크로드는 무엇을 지켜주는가" 를 물으면 어느 것을 써야 할지 자연히 정해졌습니다.
두 번째로 인상 깊었던 것은 readinessProbe 의 무게였습니다. 단순한 헬스 체크 설정인 줄 알았는데 롤링 업데이트가 다음 Pod 로 넘어가는 시점을 정하고 트래픽을 받을지를 정하는 기준이었습니다. 이것을 빠뜨리면 무중단 배포라고 부르던 것이 사실은 무중단이 아니게 됩니다.
세 번째는 종료 절차입니다. 엔드포인트 제거와 프로세스 종료가 동시에 일어난다는 것, 그래서 원리상 틈이 생길 수밖에 없다는 것을 알고 나니, preStop 에 sleep 을 넣는 관행이 왜 그렇게 흔한지 이해가 되었습니다. 처음에는 지저분한 임시방편처럼 보였는데 분산 시스템에서 두 가지 변화가 서로 다른 속도로 퍼진다는 사실을 받아들인 결과였습니다.
공부 참고 자료
📑 2. Kubernetes 공식 문서 - Pod Lifecycle
📑 3. Kubernetes 공식 문서 - Init Containers
📑 4. Kubernetes 공식 문서 - Sidecar Containers
📑 5. Kubernetes 공식 문서 - Deployments
📑 6. Kubernetes 공식 문서 - StatefulSets
📑 7. Kubernetes 공식 문서 - DaemonSet