콘텐츠로 이동
Study NoteCKA

워크로드 컨트롤러

Pod을 직접 만들지 않는 이유

컨트롤러 — 왜 필요하고 무엇을 고르나

섹션 제목: “컨트롤러 — 왜 필요하고 무엇을 고르나”

Pod을 직접 만들면 —

  • 노드가 죽으면 그걸로 끝이다. 아무도 다시 만들어주지 않는다
  • 개수를 늘리려면 이름을 바꿔가며 손으로 복사해야 한다
  • 이미지를 바꾸려면 전부 지우고 다시 만들어야 한다

컨트롤러는 “몇 개가 어떤 모습으로 있어야 하는가”를 선언받고, 그 상태를 계속 유지한다. 이것이 자가치유(self-healing)의 실체다.

원하는 상태 replicas 3 과 실제 상태 Pod 2개의 차이를 컨트롤 루프가 Pod 1개 생성으로 메우는 순환 작업이 끝나는지 · 반복하는지 · 노드마다 필요한지 · 고유 신원이 필요한지로 Deployment·DaemonSet·StatefulSet·Job·CronJob 을 고르는 결정 트리
필요한 것컨트롤러
상태 없는 앱을 N개Deployment
모든(또는 일부) 노드에 하나씩DaemonSet
고유한 이름·저장소·순서가 필요한 앱StatefulSet
한 번 실행하고 끝나는 작업Job
일정에 따라 반복되는 작업CronJob
Pod 개수만 유지 (직접 쓸 일은 거의 없다)ReplicaSet

기본은 Deployment다. 나머지는 “왜 Deployment로는 안 되는가”에 답이 있을 때 쓴다.

apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: web-rs
spec:
replicas: 3
selector: # 어떤 Pod을 내 것으로 볼 것인가
matchLabels:
app: web
template: # 부족하면 이 틀로 만든다
metadata:
labels:
app: web # selector와 반드시 일치해야 한다
spec:
containers:
- name: nginx
image: nginx:1.27
  • 루프: 셀렉터에 맞는 Pod 개수를 센다 → 부족하면 만들고, 많으면 지운다
  • 셀렉터와 템플릿 라벨이 다르면 생성 시 거부된다 (무한 생성 방지)

셀렉터에 맞기만 하면 자기가 안 만든 Pod도 자기 것으로 센다.

ReplicaSet 이 셀렉터에 맞는 기존 Pod 을 입양하고 라벨을 바꾼 Pod 은 관리에서 벗어나 부족분이 새로 생기는 관계
터미널 창
# 관리에서 떼어내 디버깅하는 기법
kubectl label pod web-abc-123 app=web-debug --overwrite
# → ReplicaSet은 부족분을 새로 채우고, 이 Pod은 그대로 남아 조사할 수 있다

Deployment — ReplicaSet 위의 롤아웃 관리자

섹션 제목: “Deployment — ReplicaSet 위의 롤아웃 관리자”
Deployment 가 버전마다 ReplicaSet 을 두고 옛 ReplicaSet 은 replicas 0 으로 남아 롤백의 재료가 되는 구조
  • 템플릿을 바꾸면 새 ReplicaSet을 만들고, 옛 것을 0으로 줄이며 새 것을 올린다
  • 옛 ReplicaSet은 지워지지 않는다 (0개로 남는다) — 이것이 롤백의 재료다
  • ReplicaSet 이름의 해시는 Pod 템플릿의 해시다. 템플릿이 같으면 새로 안 만든다
터미널 창
kubectl create deploy web --image=nginx:1.27 --replicas=3 --dry-run=client -o yaml > web.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
revisionHistoryLimit: 10 # 보관할 옛 ReplicaSet 개수 (기본 10)
selector:
matchLabels:
app: web
strategy:
type: RollingUpdate # RollingUpdate(기본) | Recreate
rollingUpdate:
maxSurge: 25% # 목표보다 몇 개 더 만들 수 있나
maxUnavailable: 25% # 몇 개까지 없어도 되나
template:
metadata:
labels:
app: web
spec:
containers:
- name: nginx
image: nginx:1.27
maxSurge 와 maxUnavailable 이 롤링 업데이트 중 살아 있는 Pod 수의 상한과 하한을 정하고 둘 다 0 이면 API 가 거부하는 관계

무중단으로 조금씩 교체한다. 기본 전략이다.

터미널 창
kubectl set image deploy/web nginx=nginx:1.28
kubectl rollout status deploy/web # 완료될 때까지 지켜본다
kubectl rollout history deploy/web
kubectl rollout history deploy/web --revision=2
kubectl rollout undo deploy/web # 직전으로
kubectl rollout undo deploy/web --to-revision=2
kubectl rollout restart deploy/web # 템플릿 변경 없이 전부 재생성
kubectl rollout pause deploy/web
kubectl rollout resume deploy/web
  • rollout restart 는 템플릿에 타임스탬프 애노테이션을 넣어 롤아웃을 유발한다 → ConfigMap을 바꾼 뒤 반영하는 표준 방법이다
  • pause 는 여러 변경을 모아서 한 번에 롤아웃할 때 쓴다
터미널 창
kubectl rollout history deploy/web
# REVISION CHANGE-CAUSE
# 1 <none>
# 2 nginx 1.28로 업그레이드

CHANGE-CAUSE는 애노테이션 kubernetes.io/change-cause 를 읽은 것이다.

터미널 창
kubectl annotate deploy/web kubernetes.io/change-cause="nginx 1.28로 업그레이드"

undo는 “돌아가는” 게 아니라 앞으로 나아간다.

리비전 1·2·3 을 거쳐 undo --to-revision=2 를 하면 내용이 2와 같은 리비전 4가 새로 쌓이는 흐름

revisionHistoryLimit을 0으로 두면 롤백이 불가능해진다. 기본 10을 그대로 두자.

터미널 창
kubectl scale deploy web --replicas=5
kubectl scale deploy web --replicas=5 --current-replicas=3 # 조건부
kubectl scale --replicas=5 -f web.yaml
kubectl scale statefulset db --replicas=3
  • replicas: 0 도 유효하다 — 삭제하지 않고 멈추는 방법
  • HPA(Horizontal Pod Autoscaler — 부하에 따라 replicas를 자동 조절)가 붙어 있으면 수동 스케일은 곧 되돌려진다 (오토스케일링)
터미널 창
# 롤아웃/스케일이 끝날 때까지 기다리기 — 검산에 유용
kubectl wait --for=condition=available deploy/web --timeout=60s
kubectl wait --for=condition=ready pod -l app=web --timeout=60s

Deployment는 “몇 개”는 보장하지만 “어디에”는 스케줄러에 맡긴다. 로그 수집기·모니터링 에이전트처럼 모든 노드에 정확히 하나씩 있어야 하는 것은 개수가 replicas가 아니라 노드 수를 따라가야 한다 — 그 컨트롤러가 DaemonSet이다.

apiVersion: apps/v1
kind: DaemonSet
metadata:
name: log-agent
spec:
selector:
matchLabels:
app: log-agent
template:
metadata:
labels:
app: log-agent
spec:
tolerations: # 컨트롤 플레인에도 놓으려면 필요
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: agent
image: fluent-bit:3.0
DaemonSet 이 노드마다 Pod 을 하나씩 두고 새 노드에도 자동으로 붙지만 toleration 이 없으면 taint 가 걸린 controlplane 에는 뜨지 않는 구조
  • replicas가 없다. 개수는 노드 수가 정한다
  • 노드가 추가되면 자동으로 하나 더 생긴다
  • 쓰임: 로그 수집기, 모니터링 에이전트, kube-proxy, CNI 플러그인

DaemonSet은 kubectl create 단축 명령이 없다 — 시험장에서는 공식 DaemonSet 페이지의 YAML 뼈대를 복사해 오거나, create deploy로 뽑은 YAML에서 kind를 바꾸고 replicas·strategy를 지운다.

터미널 창
kubectl get ds -A # kube-proxy, CNI가 보인다
kubectl rollout status ds/log-agent
  • 특정 노드에만 놓으려면 nodeSelector 나 affinity 를 쓴다
  • taint가 걸린 노드에는 안 뜬다 — tolerations가 필요하다 (스케줄링)
  • 업데이트 전략은 RollingUpdate(기본) / OnDelete

DaemonSet Pod은 기본 스케줄러가 배치하지만, 노드 리소스 부족 등의 이유로 Pending이 될 수 있다.

Deployment의 Pod은 서로 바꿔치기 가능한 복제본이다 — 이름은 무작위고, 죽으면 다른 이름·다른 저장소로 새로 뜬다. DB 복제처럼 “내가 몇 번 인스턴스인지”와 “내 데이터 디스크가 어느 것인지”가 중요한 앱은 이 무작위성 때문에 Deployment로는 안 된다 — 각 Pod에 고정된 신원을 주는 컨트롤러가 StatefulSet이다.

apiVersion: apps/v1
kind: StatefulSet
metadata: { name: db }
spec:
serviceName: db-headless # 반드시 headless Service를 가리킨다
replicas: 3
selector: { matchLabels: { app: db } }
template:
metadata:
labels: { app: db }
spec:
containers:
- name: postgres
image: postgres:17
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates: # Pod마다 PVC를 하나씩 만든다
- metadata: { name: data }
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: standard
resources: { requests: { storage: 10Gi } }
StatefulSet 의 Pod 이 db-0·db-1·db-2 라는 고정 이름과 각자의 PVC·안정적 DNS 를 갖고 순서대로 생성·삭제되는 구조

1 · 안정적인 이름

db-0, db-1, db-2. 재시작해도 이름이 그대로다.

2 · 안정적인 저장소

data-db-0, data-db-1 … PVC가 Pod 이름에 묶인다. db-0이 죽었다 살아나면 같은 PVC를 다시 붙인다.

3 · 순서 보장

생성은 0 → 1 → 2, 삭제는 2 → 1 → 0. 앞 Pod이 Ready가 되어야 다음이 시작한다.

터미널 창
kubectl get pvc
# data-db-0 Bound pvc-xxx 10Gi RWO
# data-db-1 Bound pvc-yyy 10Gi RWO
apiVersion: v1
kind: Service
metadata:
name: db-headless
spec:
clusterIP: None # ← headless
selector:
app: db
ports:
- port: 5432

clusterIP: None이면 Service IP를 만들지 않고 DNS가 Pod IP들을 직접 반환한다.

db-0.db-headless.default.svc.cluster.local → 10.244.1.5
db-1.db-headless.default.svc.cluster.local → 10.244.2.7

그래서 “1번 레플리카에만 연결” 같은 것이 가능하다. DB 복제에서 primary/replica를 구분해 붙일 때 이 이름을 쓴다.

spec:
updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 2 # 인덱스 2 이상만 업데이트 (카나리)
podManagementPolicy: OrderedReady # OrderedReady(기본) | Parallel
StatefulSet 업데이트가 db-2 부터 역순으로 진행되고 partition 2 를 주면 인덱스 2 이상만 바뀌는 순서
  • 업데이트는 큰 번호부터 역순으로 진행된다 (2 → 1 → 0)
  • partition: N 이면 N 이상 인덱스만 바뀐다 — 단계적 배포에 쓴다
  • podManagementPolicy: Parallel 이면 순서 없이 동시에 생성/삭제 (기동이 빠르다)

여기서는 “업데이트가 큰 번호부터 역순”이라는 것만 기억하면 된다. partition·podManagementPolicy까지 깊게 팔 필요는 없다 — 필드 이름과 용도만 알아두자.

터미널 창
kubectl scale sts db --replicas=5 # 3 → 5: db-3, db-4가 순서대로 추가
kubectl scale sts db --replicas=2 # 5 → 2: db-4, db-3, db-2가 역순으로 삭제

축소해도 PVC는 남는다. 다시 늘리면 옛 데이터로 복귀한다.

Deployment는 끝나는 작업에 쓸 수 없다 — Pod 템플릿에 restartPolicy: Always만 허용되니, 작업이 성공해 exit 0으로 끝나도 계속 되살린다. “몇 번 성공하면 완료”라는 개념을 아는 컨트롤러가 따로 필요하다 — 그게 Job이다.

apiVersion: batch/v1
kind: Job
metadata:
name: import
spec:
completions: 5 # 총 몇 번 성공해야 하는가
parallelism: 2 # 동시에 몇 개까지
backoffLimit: 4 # 실패 재시도 횟수 (기본 6)
activeDeadlineSeconds: 300 # 전체 제한 시간 — 넘으면 중단
ttlSecondsAfterFinished: 100 # 끝나고 100초 뒤 Job과 Pod을 자동 삭제
template:
spec:
restartPolicy: OnFailure # Never 또는 OnFailure만 가능
containers:
- name: worker
image: busybox:1.36
command: ["sh", "-c", "echo processing; sleep 5"]
터미널 창
kubectl create job import --image=busybox -- echo hi
kubectl create job manual --from=cronjob/nightly # CronJob을 즉시 한 번 실행

Job 파라미터가 실제로 뜻하는 것

섹션 제목: “Job 파라미터가 실제로 뜻하는 것”
completions 와 parallelism 조합에 따라 Job 이 한 번 실행 · 순차 5번 · 동시 5개 · 워커 큐 방식으로 달라지는 대응

completionMode: Indexed 를 쓰면 각 Pod에 인덱스(0, 1, 2 …) 가 붙는다. JOB_COMPLETION_INDEX 환경변수와 Pod 이름 접미사로 들어온다 — 데이터를 나눠 처리할 때 쓴다. 깊게 팔 필요는 없다 — 이런 모드가 있다는 것만 알아두자.

CronJob — 일정에 따라 Job을 만든다

섹션 제목: “CronJob — 일정에 따라 Job을 만든다”
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly
spec:
schedule: "0 3 * * *" # 분 시 일 월 요일
timeZone: "Asia/Seoul" # 없으면 컨트롤러의 시간대(보통 UTC)
concurrencyPolicy: Forbid # Allow(기본) | Forbid | Replace
startingDeadlineSeconds: 120 # 이 시간 안에 못 시작하면 건너뛴다
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
suspend: false # true면 새 Job을 만들지 않는다
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: backup
image: busybox:1.36
command: ["sh", "-c", "echo backup"]
터미널 창
kubectl create cronjob nightly --image=busybox --schedule="0 3 * * *" -- echo backup
kubectl patch cronjob nightly -p '{"spec":{"suspend":true}}'

3단 소유 사슬이다. Pod을 찾으려면 두 단계를 내려가야 한다.

CronJob 이 일정마다 Job 을 만들고 Job 이 Pod 을 만드는 생성 사슬과 timeZone 미지정 시 UTC 가 되는 함정

concurrencyPolicy — 이전 Job이 아직 도는데 다음 일정이 오면?

concurrencyPolicy 의 Allow·Forbid·Replace 가 이전 실행과 겹칠 때 각각 어떻게 동작하는지
  • 시간대 기본은 UTC다 (컨트롤러 매니저 기준). timeZone 필드로 명시하는 게 안전하다
터미널 창
kubectl get cronjob nightly
kubectl get jobs --selector=job-name # CronJob이 만든 Job들
kubectl logs job/nightly-28901234

PodDisruptionBudget — 자발적 중단으로부터 보호

섹션 제목: “PodDisruptionBudget — 자발적 중단으로부터 보호”

컨트롤러가 죽은 Pod을 다시 만들어 주긴 하지만, 새 Pod이 뜨기까지는 시간이 걸린다. 노드 업그레이드로 kubectl drain을 돌릴 때(노드 유지보수) 한 앱의 Pod들이 한꺼번에 축출되면 그 공백 동안 서비스가 순간적으로 비어 버릴 수 있다 — “동시에 죽어도 되는 수의 예산”을 선언해 두는 것이 PodDisruptionBudget(PDB)이다.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web-pdb
spec:
minAvailable: 2 # 또는 maxUnavailable: 1
selector:
matchLabels:
app: web
PodDisruptionBudget 이 drain 같은 자발적 중단만 막고 노드 장애 같은 비자발적 중단은 막지 못하는 갈래
  • kubectl drain이나 노드 업그레이드 같은 “자발적 중단”에서 최소 가용 수를 지킨다
  • 노드 장애 같은 비자발적 중단은 막지 못한다
  • PDB가 막으면 drain이 멈춰서 기다린다
컨테이너 죽음 · Pod 삭제 · 노드 NotReady · 노드 영구 장애 · 앱 무응답 다섯 상황에서 쿠버네티스가 각각 무엇을 하는지

마지막 줄이 중요하다. Kubernetes는 “프로세스가 살아 있는가”만 본다. “제대로 동작하는가”는 프로브를 통해 당신이 알려줘야 한다.

  • Deployment → ReplicaSet → Pod. 옛 ReplicaSet이 남아 있는 것이 롤백의 재료다
  • 롤링 업데이트는 maxSurge / maxUnavailable 두 손잡이. 둘 다 0은 API가 거부한다
  • rollout undo는 되돌아가는 게 아니라 새 리비전을 만든다
  • DaemonSet은 노드마다 하나. 안 뜨면 십중팔구 taint
  • StatefulSet은 이름·저장소·순서를 보장한다. 삭제해도 PVC는 남는다
  • Job의 restartPolicy는 Never/OnFailure만 — 기본값 그대로 두면 거부된다
  • CronJob은 UTC 기본, concurrencyPolicy로 겹침을 제어
  • PDB는 자발적 중단만 막는다. replicas: 1 + minAvailable: 1 = drain 교착