Kubernetes 워크로드 — ReplicaSet, Deployment, StatefulSet, DaemonSet
이 글은 K8s 입문 시리즈의 다섯 번째 글이다.
- 01: Kubernetes의 탄생 — Google Borg에서 CNCF까지
- 02: 내 노트북에 클러스터 만들기 — kind와 kubectl
- 03: Kubernetes 아키텍처 — Control Plane과 Node
- 04: Pod의 모든 것 — 생성부터 스케줄링까지
- 05: 워크로드 — ReplicaSet, Deployment, StatefulSet, DaemonSet ← 현재 글
- 06: 네트워킹 — Service와 Ingress
- 07: 스토리지와 설정 — PV/PVC, ConfigMap, Secret
- 08: 권한 관리 — ServiceAccount와 RBAC
- 09: 확장과 생태계 — Operator와 CNCF Projects
이 시리즈의 커리큘럼은 SK Devocean의 Kubernetes(쿠버네티스)를 처음 공부하려면 무엇을 공부해야 할까? (seungkyua) 글의 학습 로드맵을 바탕으로 구성했다.
1. 왜 Pod를 직접 만들지 않을까
04편에서 nginx Pod를 직접 띄워봤다. 그런데 그 Pod에는 치명적인 약점이 있다. 혼자 만든 Pod는 죽으면 그걸로 끝이다. 컨테이너가 크래시나면 kubelet이 재시작해주긴 하지만, 노드 자체가 죽으면 그 위의 Pod는 함께 사라지고 아무도 다시 만들어주지 않는다. 트래픽이 늘어서 Pod를 3개로 늘리고 싶으면? YAML을 세 번 복사해서 이름만 바꿔 적용해야 한다. 업데이트는 더 끔찍하다. 하나씩 지우고 새로 만들면서 서비스가 끊기지 않기를 기도해야 한다.
일상 비유로 보면 이렇다. Pod를 직접 만드는 건 사장이 직원 한 명 한 명을 직접 고용하고, 누가 그만두면 직접 알아차려서 다시 뽑는 방식이다. 워크로드 리소스는 인력 관리 회사와 “우리 매장에 항상 3명이 근무하게 해달라”는 계약을 맺는 것이다. 누가 그만두든, 몇 명이 필요하든, 관리 회사(컨트롤러)가 알아서 채워 넣는다.
이게 바로 03편에서 본 reconcile 루프의 실체다. 사용자는 desired state(“nginx 3개”)만 선언하고, 컨트롤러가 current state를 끊임없이 비교하며 차이를 메꾼다. 이 글에서 다룰 워크로드 리소스 4종을 미리 훑어보면 다음과 같다.
| 리소스 | 한 줄 요약 | 대표 용도 |
|---|---|---|
| ReplicaSet | 동일한 Pod를 항상 N개 유지 | 직접 쓸 일은 드물다 |
| Deployment | ReplicaSet + 롤링 업데이트/롤백 | 무상태 웹/API 서버 |
| StatefulSet | 이름과 디스크가 유지되는 Pod 집합 | DB, 메시지 브로커 |
| DaemonSet | 모든(또는 일부) 노드에 Pod 1개씩 | 로그 수집기, 모니터링 에이전트 |
글 후반부에서는 여기에 더해, 리소스가 사는 공간을 구획하는 Namespace와 “끝나는 것이 정상”인 작업을 위한 Job/CronJob도 다룬다. 실습은 02편에서 만든 kind 클러스터를 그대로 사용한다.
2. Label과 Selector — 모든 워크로드의 연결 고리
본론에 들어가기 전에 반드시 짚어야 할 개념이 있다. 워크로드 컨트롤러는 자기가 관리할 Pod를 이름이 아니라 Label로 찾는다. Label은 리소스에 붙이는 key/value 메타데이터이고, Selector는 그 Label을 조건으로 리소스 집합을 골라내는 문법이다.
비유하면 Label은 사원증에 적힌 부서명이다. “김 아무개를 찾아줘”(이름)가 아니라 “개발팀 소속 전원 회의실로”(조건)라고 부르는 방식이다. Pod는 언제든 죽고 새로 태어나며 그때마다 이름이 바뀌기 때문에, 이름 대신 조건으로 묶는 이 방식이 Kubernetes 전체를 관통하는 그룹핑 원리다.
Selector 문법은 두 가지가 있다.
| 방식 | 연산자 | 예시 | 의미 |
|---|---|---|---|
| equality-based | =, ==, != | env=prod, tier!=frontend | 값이 같다 / 다르다 |
| set-based | in, notin, 키 존재 여부 | env in (dev, prod), !partition | 값이 집합에 속한다 / 안 속한다 / 키가 있다 |
직접 확인해보자. Label을 다르게 붙인 Pod 3개를 만든다.
kubectl run web-1 --image=nginx:1.25 --labels="app=nginx,env=dev"
kubectl run web-2 --image=nginx:1.25 --labels="app=nginx,env=prod"
kubectl run db-1 --image=redis:7 --labels="app=redis,env=dev"
-l 플래그로 조건에 맞는 Pod만 골라낸다.
kubectl get pods -l app=nginx
예상 출력:
NAME READY STATUS RESTARTS AGE
web-1 1/1 Running 0 30s
web-2 1/1 Running 0 25s
set-based 문법은 괄호가 셸에 해석되지 않도록 따옴표로 감싼다. 아래 명령은 env가 dev인 web-1과 db-1을 돌려준다.
kubectl get pods -l 'env in (dev)'
kubectl get pods --show-labels를 실행하면 각 Pod에 붙은 Label 전체를 볼 수 있다. 확인이 끝나면 정리한다.
kubectl delete pod web-1 web-2 db-1
앞으로 나올 모든 워크로드 리소스의 YAML에는 selector 필드가 있고, 그 값이 Pod template의 labels와 일치해야 한다. 이 연결 고리를 기억해두자.
3. Namespace — 리소스를 나누는 가상의 벽
Label을 배웠으니 자연스럽게 이어지는 질문이 있다. 조건으로 골라내는 것 말고, 아예 리소스가 사는 공간을 나누는 방법은 없을까? 그 답이 Namespace다. 하나의 물리 클러스터 안에서 리소스 그룹을 격리하는 메커니즘으로, 클러스터를 여러 개의 가상 클러스터처럼 나눠 쓰게 해준다. 사원증 비유를 이어가면, Label이 사원증에 적힌 부서명이라면 Namespace는 건물의 층이다. 부서명은 어느 층에 있든 “개발팀 전원”을 불러 모으는 조건이고, 층은 아예 분리된 공간이라 3층과 5층에 같은 이름의 회의실이 있어도 충돌하지 않는다.
격리의 실체는 두 가지다.
- 이름 충돌 방지 — 리소스 이름은 같은 Namespace 안에서만 유일하면 된다.
dev,staging,prod네임스페이스에 같은 이름의 Deploymentapi-server를 동시에 띄울 수 있다. 같은 YAML을 네임스페이스만 바꿔 적용하면 환경별 사본이 생기는 셈이다. - 정책의 경계 — 팀별 CPU/메모리 한도(ResourceQuota)나 접근 권한(08편에서 다룰 RBAC)을 네임스페이스 단위로 걸 수 있다.
클러스터는 처음부터 네임스페이스 4개를 갖고 시작한다.
kubectl get namespace
예상 출력:
NAME STATUS AGE
default Active 3d
kube-node-lease Active 3d
kube-public Active 3d
kube-system Active 3d
| Namespace | 용도 |
|---|---|
default | 네임스페이스를 지정하지 않은 리소스가 들어가는 곳. 지금까지 만든 Pod가 전부 여기 있었다 |
kube-system | Kubernetes 시스템이 만든 오브젝트용. 03편에서 본 시스템 컴포넌트들이 여기서 돈다 |
kube-public | 인증 없이도 모든 클라이언트가 읽을 수 있는 공간. 주로 클러스터 내부 용도로 예약되어 있다 |
kube-node-lease | 노드별 Lease 오브젝트 보관. kubelet이 하트비트를 보내 노드 생존을 알리는 데 쓴다 |
직접 하나 만들어 써보자.
kubectl create namespace dev
kubectl run nginx --image=nginx:1.25 -n dev
kubectl get pods -n dev
예상 출력:
NAME READY STATUS RESTARTS AGE
nginx 1/1 Running 0 10s
-n dev를 빼고 kubectl get pods를 치면 이 Pod는 보이지 않는다. 네임스페이스를 지정하지 않은 명령은 전부 default를 향하기 때문이다. 매번 -n을 붙이기 귀찮다면 현재 컨텍스트의 기본 네임스페이스 자체를 바꿀 수 있다.
kubectl config set-context --current --namespace=dev
한 가지 주의할 점 — 모든 리소스가 네임스페이스에 속하는 것은 아니다. Pod, Deployment, Service처럼 네임스페이스 안에 사는 리소스(namespaced)가 있고, Node나 PersistentVolume처럼 클러스터 전체에 걸치는 리소스(cluster-scoped)가 있다. “이 노드는 dev팀 것”이라고 가를 수 없으니 당연한 구분이다. 어떤 리소스가 어느 쪽인지는 다음 명령으로 확인한다.
kubectl api-resources --namespaced=true # 네임스페이스에 속하는 리소스
kubectl api-resources --namespaced=false # 클러스터 전역 리소스
실습이 끝나면 정리한다. 네임스페이스를 지우면 그 안의 리소스가 전부 함께 삭제되므로 운영 클러스터에서는 각별히 조심해야 하는 명령이다.
kubectl config set-context --current --namespace=default
kubectl delete namespace dev
Label과의 역할 차이를 한 줄로 정리하면 — Namespace는 격리(공간을 나눈다), Label은 그룹핑(조건으로 골라낸다)이다. 공식 문서도 같은 앱의 버전 구분처럼 살짝 다른 리소스는 네임스페이스가 아니라 Label로 구분하라고 안내한다. 참고로 Service의 DNS 주소(<service>.<namespace>.svc.cluster.local)에도 네임스페이스가 들어가는데, 이는 06편에서 다룬다.
이제 본론인 워크로드 컨트롤러로 들어가자.
4. ReplicaSet — “항상 N개”의 보장
ReplicaSet의 목적은 단순하다. 동일한 Pod가 항상 지정한 개수(replicas)만큼 떠 있도록 보장한다. YAML은 세 부분으로 구성된다. 몇 개를 원하는지(replicas), 어떤 Pod가 자기 소유인지(selector), 부족할 때 무엇을 만들지(template).
# nginx-rs.yaml
apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: nginx-rs
spec:
replicas: 3
selector:
matchLabels:
app: nginx-rs
template:
metadata:
labels:
app: nginx-rs
spec:
containers:
- name: nginx
image: nginx:1.25
적용하면 nginx-rs- 뒤에 랜덤 접미사가 붙은 Pod 3개가 뜬다. 그중 하나를 일부러 지워보자.
kubectl apply -f nginx-rs.yaml
kubectl delete pod nginx-rs-b2zdv # 실제 Pod 이름은 kubectl get pods로 확인
kubectl get pods -l app=nginx-rs
예상 출력:
NAME READY STATUS RESTARTS AGE
nginx-rs-8x4kp 1/1 Running 0 3s
nginx-rs-vcmts 1/1 Running 0 1m
nginx-rs-wtsmm 1/1 Running 0 1m
지운 자리에 새 Pod가 즉시 생겨났다. 반대 실험도 재밌다. ReplicaSet의 selector와 같은 Label을 단 Pod를 손으로 하나 더 만들면, ReplicaSet은 그 Pod를 자기 소유로 흡수한 뒤 “원하는 개수는 3개인데 4개가 있네”라며 곧바로 종료시킨다. 컨트롤러 입장에서는 desired state 3개를 지키는 것이 전부이기 때문이다. 소유 관계는 Pod의 metadata.ownerReferences 필드에 기록된다.
그런데 공식 문서조차 이렇게 말한다. ReplicaSet을 직접 쓰지 말고 Deployment를 쓰라. 왜일까? ReplicaSet은 개수만 보장할 뿐 업데이트 개념이 없다. template의 이미지를 바꿔서 다시 적용해도 이미 떠 있는 Pod는 그대로다. 새 버전으로 갈아타려면 Pod를 손으로 지워가며 교체해야 한다. 이 업데이트 오케스트레이션을 얹은 상위 리소스가 바로 Deployment다.
kubectl delete rs nginx-rs
5. Deployment — 롤링 업데이트와 롤백
Deployment는 ReplicaSet을 직접 만들고 관리하는 상위 컨트롤러다. 계층 구조는 Deployment → ReplicaSet → Pod이고, 사용자는 맨 위의 Deployment만 만지면 된다. 핵심 가치는 선언적 업데이트다. 이미지 버전을 바꾸면 Deployment가 새 ReplicaSet을 만들고, 새것을 점진적으로 키우면서 옛것을 점진적으로 줄여 무중단으로 교체한다.
# nginx-deploy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deploy
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.25
kubectl apply -f nginx-deploy.yaml
kubectl get deploy,rs,pods
예상 출력:
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/nginx-deploy 3/3 3 3 25s
NAME DESIRED CURRENT READY AGE
replicaset.apps/nginx-deploy-7c5d6b8f9d 3 3 3 25s
NAME READY STATUS RESTARTS AGE
pod/nginx-deploy-7c5d6b8f9d-8k2mp 1/1 Running 0 25s
pod/nginx-deploy-7c5d6b8f9d-p4xvz 1/1 Running 0 25s
pod/nginx-deploy-7c5d6b8f9d-tj6q8 1/1 Running 0 25s
이름을 뜯어보면 계층이 보인다. Deployment 이름(nginx-deploy) 뒤에 ReplicaSet의 해시(7c5d6b8f9d)가 붙고, Pod는 거기에 랜덤 접미사가 하나 더 붙는다. Deployment는 자신이 만든 ReplicaSet의 selector에 pod-template-hash Label을 자동으로 추가해서, 리비전이 달라도 서로의 Pod가 섞이지 않게 만든다.
5.1 스케일링
replicas 변경은 명령 한 줄이다.
kubectl scale deployment/nginx-deploy --replicas=5
예상 출력:
deployment.apps/nginx-deploy scaled
물론 YAML의 replicas를 고쳐서 kubectl apply 해도 결과는 같다. 실무에서는 YAML을 Git으로 관리하므로 후자가 기본이고, scale 명령은 급할 때 쓰는 응급 처치에 가깝다.
5.2 롤링 업데이트
이제 하이라이트다. 이미지를 1.25에서 1.26으로 올려보자.
kubectl set image deployment/nginx-deploy nginx=nginx:1.26
kubectl rollout status deployment/nginx-deploy
예상 출력:
deployment.apps/nginx-deploy image updated
Waiting for deployment "nginx-deploy" rollout to finish: 2 out of 5 new replicas have been updated...
Waiting for deployment "nginx-deploy" rollout to finish: 1 old replicas are pending termination...
deployment "nginx-deploy" successfully rolled out
내부에서 벌어진 일을 그림으로 보면 다음과 같다.
- Deployment는 Pod를 직접 갈아치우지 않는다. 새 ReplicaSet을 만들어 키우고, 구 ReplicaSet을 줄이는 방식으로 교체한다.
- 교체 속도는
maxSurge(desired보다 몇 개까지 초과 생성 가능한지)와maxUnavailable(몇 개까지 동시에 내려가도 되는지)로 제어하며, 기본값은 둘 다 25%다. - 업데이트 전략(
strategy.type)의 기본값은RollingUpdate다.Recreate로 바꾸면 전부 내리고 전부 올리는 방식이 된다. - 구 ReplicaSet은 replicas 0으로 남는다. 이것이 롤백의 발판이다.
실제로 ReplicaSet 목록을 보면 구 리비전이 0으로 남아 있는 것을 확인할 수 있다.
kubectl get rs
예상 출력:
NAME DESIRED CURRENT READY AGE
nginx-deploy-6f9c4b7d5b 5 5 5 50s
nginx-deploy-7c5d6b8f9d 0 0 0 6m
5.3 롤아웃 이력과 롤백
배포 이력은 rollout history로 조회한다.
kubectl rollout history deployment/nginx-deploy
예상 출력:
deployment.apps/nginx-deploy
REVISION CHANGE-CAUSE
1 <none>
2 <none>
CHANGE-CAUSE가 비어 있는 이유는 이 컬럼이 kubernetes.io/change-cause 어노테이션 값을 보여주는 것이고, 그 어노테이션은 자동으로 붙지 않기 때문이다. 배포 사유를 남기고 싶으면 직접 기록한다.
kubectl annotate deployment/nginx-deploy kubernetes.io/change-cause="nginx 1.26으로 업데이트"
특정 리비전의 상세 내용(Pod template, 이미지 등)은 kubectl rollout history deployment/nginx-deploy --revision=2처럼 --revision 플래그로 본다.
새 버전에 문제가 있다면? 구 ReplicaSet이 남아 있으므로 되돌리는 것도 한 줄이다.
kubectl rollout undo deployment/nginx-deploy
예상 출력:
deployment.apps/nginx-deploy rolled back
특정 리비전으로 지정해서 돌아갈 수도 있다.
kubectl rollout undo deployment/nginx-deploy --to-revision=2
롤백도 결국 “구 ReplicaSet을 다시 키우는” 하나의 새 롤아웃이라서, 되돌린 설정은 새 리비전 번호로 다시 기록된다. 참고로 rollout 명령에는 status, history, undo 외에 pause/resume(롤아웃 일시 정지/재개), restart(이미지 변경 없이 Pod 전체를 롤링 방식으로 재기동)도 있고, Deployment뿐 아니라 DaemonSet과 StatefulSet에도 쓸 수 있다.
kubectl delete deployment nginx-deploy
6. StatefulSet — 이름과 디스크를 기억하는 워크로드
Deployment의 Pod는 서로 완전히 교체 가능(interchangeable)하다. 이름은 랜덤 해시고, 누가 죽든 똑같은 놈이 새로 생기면 그만이다. 편의점 아르바이트처럼 누가 근무해도 같은 일을 하면 된다. 무상태(stateless) 웹 서버에는 이게 완벽하다.
하지만 데이터베이스는 다르다. MySQL primary와 replica는 역할이 다르고, 각자 자기 데이터 디스크를 가지며, 클러스터 멤버끼리 서로를 고정된 주소로 알아봐야 한다. 병원의 담당 주치의처럼 “아무나”가 아니라 “그 사람”이어야 하는 것이다. 이런 요구를 위한 워크로드가 StatefulSet이고, 공식 문서 기준으로 다음을 보장한다.
- 안정적인 네트워크 ID — Pod 이름이
<StatefulSet 이름>-<서수>형식으로 고정된다(web-0,web-1,web-2). Pod가 재스케줄링되어도 이 정체성은 유지되며, headless Service와 결합하면web-0.nginx.default.svc.cluster.local처럼 Pod 개별 DNS 주소가 생긴다. - 안정적인 스토리지 —
volumeClaimTemplates로 Pod마다 개별 PVC(예:www-web-0)가 만들어진다. StatefulSet을 삭제하거나 스케일 다운해도 PVC는 삭제되지 않는다. 데이터 안전을 위한 의도적 설계다. - 순차적 기동과 종료 — 기본 정책(
OrderedReady)에서는 0번부터 순서대로 뜨고, 앞 Pod가 Running & Ready 상태가 되어야 다음 Pod가 생성된다. 종료는 역순이다. 순서가 필요 없으면podManagementPolicy: Parallel로 바꿀 수 있다.
- 기동은 0 → 1 → 2 순서, 종료는 2 → 1 → 0 역순이다. DB 클러스터에서 primary가 먼저 떠야 replica가 붙을 수 있는 상황과 맞아떨어진다.
- Deployment라면 세 Pod가 동시에 뜬다. 순서 보장이 없는 대신 빠르다.
YAML의 골격은 이렇다. Deployment와 달리 serviceName(반드시 headless Service를 가리켜야 하고, StatefulSet이 자동으로 만들어주지 않는다)과 volumeClaimTemplates가 있다.
apiVersion: v1
kind: Service
metadata:
name: nginx
spec:
clusterIP: None # headless Service — 06편에서 자세히 다룬다
selector:
app: nginx
ports:
- port: 80
name: web
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: web
spec:
serviceName: "nginx"
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.25
ports:
- containerPort: 80
name: web
volumeClaimTemplates:
- metadata:
name: www
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
PVC와 headless Service를 아직 안 배웠으니 실습은 각각 07편과 06편에서 이어간다. 지금은 Deployment와의 차이를 표로 정리해두자.
| 항목 | Deployment | StatefulSet |
|---|---|---|
| Pod 이름 | 랜덤 해시 (nginx-deploy-7c5d…-8k2mp) | 고정 서수 (web-0, web-1) |
| Pod 정체성 | 서로 교체 가능 | 재스케줄링에도 고유 ID 유지 |
| 스토리지 | Pod별 개별 볼륨 개념 없음 | volumeClaimTemplates로 Pod마다 개별 PVC |
| 기동/종료 순서 | 보장 없음 (동시) | 순차 기동, 역순 종료 (기본 OrderedReady) |
| 네트워크 접근 | Service로 집합 전체에 접근 | headless Service로 Pod별 DNS 주소 |
| 어울리는 앱 | 무상태 웹/API 서버 | DB, 메시지 브로커 등 상태 저장 앱 |
한 가지 균형 잡힌 시각도 필요하다. 안정적 ID나 순차 기동이 필요 없다면 StatefulSet을 쓸 이유가 없다. 공식 문서도 무상태 앱에는 Deployment를 쓰라고 명시한다. 운영 난이도는 StatefulSet 쪽이 확실히 높다.
7. DaemonSet — 노드마다 하나씩
마지막 워크로드는 발상이 다르다. “몇 개”가 아니라 “모든(또는 일부) 노드에 하나씩”이다. DaemonSet에는 replicas 필드가 없다. 노드 수가 곧 Pod 수다. 노드가 클러스터에 추가되면 그 노드에 Pod가 자동으로 생기고, 노드가 제거되면 해당 Pod도 정리된다.
건물의 층마다 소화기를 하나씩 비치하는 것과 같다. 소화기 개수를 따로 세지 않는다. 층이 늘어나면 소화기도 늘어난다. 공식 문서가 드는 대표 용도는 다음 세 가지다.
- 모든 노드에서 도는 클러스터 스토리지 데몬
- 모든 노드에서 도는 로그 수집 데몬
- 모든 노드에서 도는 노드 모니터링 데몬
사실 우리는 이미 DaemonSet을 쓰고 있다. kind 클러스터의 kube-system 네임스페이스를 보자.
kubectl get ds -n kube-system
예상 출력 (노드 1개짜리 기본 kind 클러스터 기준, 세부 값은 환경에 따라 다르다):
NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE
kindnet 1 1 1 1 1 <none> 3d
kube-proxy 1 1 1 1 1 kubernetes.io/os=linux 3d
- 03편에서 본 kube-proxy(Service 트래픽 라우팅)와 kindnet(kind의 CNI 플러그인)이 둘 다 DaemonSet이다. “노드마다 반드시 하나”가 필요한 시스템 컴포넌트의 표준 배포 방식인 셈이다.
- 02편에서 멀티노드 config로 클러스터를 만들었다면 DESIRED/CURRENT 숫자가 노드 수만큼 나온다.
-
nodeSelector나 node affinity를 지정하면 “GPU 노드에만”, “Linux 노드에만”처럼 일부 노드로 배치를 제한할 수 있다. 위 출력에서 kube-proxy의 NODE SELECTOR가 그 예다.
8. Job과 CronJob — 끝이 있는 워크로드
지금까지 본 워크로드 4종에는 공통 전제가 있다. Pod가 계속 떠 있어야 한다는 것이다. Deployment의 Pod가 종료되면 컨트롤러는 장애로 간주하고 즉시 되살린다. 그런데 세상에는 끝나는 것이 정상인 작업도 있다. 배치 데이터 전처리, 모델 평가, DB 마이그레이션 같은 작업이 그렇다. 이런 작업을 Deployment로 돌리면 정상 종료한 Pod를 컨트롤러가 끝없이 재시작하는 이상한 상황이 벌어진다. 한 줄로 비교하면 — Deployment는 “계속 떠 있음”이 성공이고, Job은 “성공적으로 끝남”이 성공이다.
8.1 Job — 완료를 보장하는 일회성 실행
Job은 지정한 개수의 Pod가 성공적으로 종료할 때까지 Pod 생성과 재시도를 책임지는 컨트롤러다. 핵심 필드는 세 개다.
| 필드 | 의미 | 기본값 |
|---|---|---|
completions | 성공해야 하는 Pod 개수 | 1 |
parallelism | 동시에 실행할 최대 Pod 개수 | 1 |
backoffLimit | Job을 실패로 마킹하기 전까지 허용하는 재시도 횟수 | 6 |
ML 파이프라인에서 흔한 예로, 새 모델 체크포인트가 나올 때마다 평가 스위트를 Job으로 돌리는 경우를 보자.
# model-eval-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: model-eval
spec:
completions: 1
parallelism: 1
backoffLimit: 4
template:
spec:
containers:
- name: eval
image: python:3.11-slim
command: ["python", "-c", "print('accuracy: 0.87')"]
restartPolicy: Never
눈여겨볼 부분은 restartPolicy: Never다. Job의 Pod template에는 restartPolicy를 반드시 Never 또는 OnFailure로 지정해야 한다. Pod의 기본값인 Always는 “계속 떠 있어야 한다”는 의미라 Job과 양립할 수 없기 때문이다.
kubectl apply -f model-eval-job.yaml
kubectl get jobs
실행 직후에는 COMPLETIONS가 0/1이고, 작업이 끝나면 1/1이 되면서 Job이 Complete 상태가 된다.
NAME COMPLETIONS DURATION AGE
model-eval 0/1 3s 3s
NAME COMPLETIONS DURATION AGE
model-eval 1/1 6s 30s
Deployment와 달리, 끝난 Pod는 삭제되지 않고 Completed 상태로 남는다. 로그를 나중에 확인할 수 있게 하기 위한 설계다.
kubectl get pods
예상 출력:
NAME READY STATUS RESTARTS AGE
model-eval-7k2np 0/1 Completed 0 40s
kubectl logs job/model-eval로 평가 결과를 확인하고, kubectl delete job model-eval로 지우면 Pod도 함께 정리된다. 매번 지우기 번거로우면 ttlSecondsAfterFinished 필드로 완료 후 자동 삭제를 걸 수 있다.
실패하면 어떻게 될까. Pod가 실패하면 Job 컨트롤러는 지수 백오프(10초, 20초, 40초, … 최대 6분)를 두고 재시도를 반복하고, 실패가 backoffLimit 횟수만큼 쌓이면 Job 전체를 Failed로 마킹하고 멈춘다. 위 예시는 backoffLimit: 4이므로 재시도 4번 안에 성공하지 못하면 포기한다는 뜻이다.
병렬 실행도 간단하다. 대규모 이미지 데이터셋을 10개 샤드로 나눠 전처리한다면 completions: 10, parallelism: 3으로 선언한다. 동시에 3개씩 돌면서 총 10개의 Pod가 성공할 때까지 실행된다. 여기에 completionMode: Indexed를 추가하면 각 Pod가 0부터 9까지의 완료 인덱스를 부여받아 “내 번호의 샤드만 처리”하는 구조도 만들 수 있다.
8.2 CronJob — Job을 스케줄에 태우기
CronJob은 cron 스케줄에 따라 Job을 반복 생성하는 컨트롤러다. 계층 구조는 CronJob → Job → Pod로, Deployment → ReplicaSet → Pod와 같은 패턴이다. 스케줄 문법은 리눅스 cron과 동일한 5필드다.
| 위치 (왼쪽부터) | 의미 | 허용 값 |
|---|---|---|
| 1 | 분 | 0–59 |
| 2 | 시 | 0–23 |
| 3 | 일 | 1–31 |
| 4 | 월 | 1–12 |
| 5 | 요일 | 0–6 (0=일요일) |
"0 2 * * *"는 매일 02:00, "*/10 * * * *"는 10분마다, "0 3 * * 1"은 매주 월요일 03:00이다. 매일 새벽 2시에 어제 쌓인 데이터를 학습용으로 전처리하는 CronJob은 이렇게 쓴다.
# nightly-preprocess.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-preprocess
spec:
schedule: "0 2 * * *"
timeZone: "Asia/Seoul"
concurrencyPolicy: Forbid
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
containers:
- name: preprocess
image: python:3.11-slim
command: ["python", "-c", "print('preprocessing done')"]
restartPolicy: OnFailure
-
jobTemplate안이 곧 Job의 spec이다. 스케줄 시각이 되면 CronJob이 이 틀로 이름 뒤에 스케줄 시각 기반 접미사가 붙은 Job을 만들고, 그 Job이 Pod를 만든다. -
timeZone을 지정하지 않으면 스케줄은 kube-controller-manager의 로컬 시간대 기준으로 해석된다. 컨테이너 환경은 대개 UTC라서, “새벽 2시” 작업이 KST 오전 11시에 도는 사고가 나기 쉽다. - 완료된 Job 이력은 기본적으로 성공 3개, 실패 1개까지 보관된다(
successfulJobsHistoryLimit,failedJobsHistoryLimit).
어제 작업이 아직 안 끝났는데 오늘 스케줄 시각이 오면? 그걸 정하는 것이 concurrencyPolicy다.
| 값 | 동작 |
|---|---|
Allow | 동시 실행을 허용한다 (기본값) |
Forbid | 이전 Job이 안 끝났으면 이번 회차를 건너뛴다 |
Replace | 이전 Job을 중단시키고 새 Job으로 교체한다 |
데이터 전처리처럼 같은 대상을 두 번 건드리면 안 되는 작업에는 Forbid가 안전하다. 마지막으로 설계 원칙 하나 — 공식 문서는 드물게 한 회차에 Job이 두 번 만들어지거나 아예 안 만들어질 수 있으므로 Job을 멱등(idempotent)하게 작성하라고 명시한다. 같은 작업이 두 번 돌아도 결과가 같아야 한다는 뜻이다.
9. 어떤 워크로드를 골라야 할까
배운 것을 선택 가이드 표 하나로 압축하면 이렇다.
| 상황 | 리소스 | 이유 |
|---|---|---|
| 무상태 웹/API 서버를 무중단으로 배포·스케일링 | Deployment | 롤링 업데이트 + 롤백 + replicas 보장 |
| DB, 메시지 브로커 등 상태 저장 앱 | StatefulSet | 고정된 Pod ID + Pod별 PVC + 순차 기동 |
| 로그 수집기·모니터링 에이전트를 전 노드에 배치 | DaemonSet | 노드당 정확히 1개, 노드 증감에 자동 대응 |
| 모델 평가·데이터 전처리 같은 일회성 배치 작업 | Job | “성공 종료”가 목표, backoffLimit까지 재시도 |
| 야간 전처리 등 주기적으로 반복되는 배치 작업 | CronJob | cron 스케줄에 따라 Job을 반복 생성 |
| ReplicaSet을 직접 생성 | (거의 없음) | Deployment가 대신 만들어 관리한다 |
이 글의 핵심을 한 줄로 요약하면 — Pod는 직접 만들지 말고, 원하는 상태를 워크로드 리소스로 선언해서 컨트롤러가 지키게 하라.
그런데 아직 큰 구멍이 하나 남았다. Deployment가 Pod 5개를 잘 유지해줘도, Pod IP는 재시작할 때마다 바뀐다. 클라이언트는 대체 어디로 접속해야 할까? StatefulSet에서 슬쩍 등장한 headless Service의 정체는 무엇일까?
이전 글 04: Pod의 모든 것 — 생성부터 스케줄링까지에서는 Pod 하나가 뜨는 전 과정을 해부했다. 다음 글 06: 네트워킹 — Service와 Ingress에서는 끊임없이 바뀌는 Pod들 앞에 고정된 접점을 세우는 방법을 다룬다.
참고 문헌
- 시리즈 로드맵 출처: Kubernetes(쿠버네티스)를 처음 공부하려면 무엇을 공부해야 할까? (seungkyua, SK Devocean, 2024)
- Labels and Selectors — Kubernetes 공식 문서
- ReplicaSet — Kubernetes 공식 문서
- Deployments — Kubernetes 공식 문서
- Deployment v1 API Reference — Kubernetes 공식 문서
- kubectl rollout — Kubernetes 공식 문서
- StatefulSets — Kubernetes 공식 문서
- DaemonSet — Kubernetes 공식 문서
- Jobs — Kubernetes 공식 문서
- Job v1 API Reference — Kubernetes 공식 문서
- CronJob — Kubernetes 공식 문서
- Namespaces — Kubernetes 공식 문서
Enjoy Reading This Article?
Here are some more articles you might like to read next: