콘텐츠로 이동

5. 워크로드 컨트롤러

Pod을 직접 만들지 않는 이유

Pod을 직접 만들면 —

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

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

flowchart LR
    SPEC["원하는 상태<br/>replicas: 3"] --> LOOP{"컨트롤 루프"}
    ACT["실제 상태<br/>Pod 2개"] --> LOOP
    LOOP -->|"차이만큼 행동"| ACT2["Pod 1개 생성"]
    ACT2 --> ACT

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class SPEC key
    class ACT2 ok
    class LOOP,ACT mute
flowchart TD
    Q1{"작업이 끝나는가"}
    Q1 -->|"끝난다"| Q2{"일정에 따라<br/>반복하는가"}
    Q2 -->|아니오| JOB["Job"]
    Q2 -->|예| CJ["CronJob"]
    Q1 -->|"계속 돈다"| Q3{"노드마다<br/>하나씩 필요한가"}
    Q3 -->|예| DS["DaemonSet"]
    Q3 -->|아니오| Q4{"각 인스턴스가 고유한<br/>신원·저장소·순서가 필요한가"}
    Q4 -->|예| STS["StatefulSet"]
    Q4 -->|아니오| DEP["Deployment ★ 기본"]

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef alt fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class DEP key
    class DS,STS,JOB,CJ alt
    class Q1,Q2,Q3,Q4 mute
필요한 것 컨트롤러
상태 없는 앱을 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도 자기 것으로 센다.

flowchart LR
    RS["ReplicaSet<br/>selector app=web · replicas 3"]
    RS -->|"내가 만든 것"| P1["Pod A · app=web"]
    RS -->|"내가 만든 것"| P2["Pod B · app=web"]
    EXIST["이미 있던 Pod C<br/>app=web"] -->|"입양된다 ⚠️<br/>개수에 포함"| RS
    P1 -->|"라벨을 바꾸면"| ESC["app=web-debug<br/>관리에서 벗어난다"]
    ESC -.->|"부족분을 새로 만든다"| RS

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class RS key
    class EXIST,ESC warn
    class P1,P2 mute
Terminal window
# 관리에서 떼어내 디버깅하는 기법
kubectl label pod web-abc-123 app=web-debug --overwrite
# → ReplicaSet은 부족분을 새로 채우고, 이 Pod은 그대로 남아 조사할 수 있다

Deployment — ReplicaSet 위의 롤아웃 관리자

섹션 제목: “Deployment — ReplicaSet 위의 롤아웃 관리자”
flowchart LR
    D["Deployment web"] --> RS1["ReplicaSet web-5d4f · v1<br/>replicas 0"]
    D --> RS2["ReplicaSet web-7b9c · v2<br/>replicas 3"]
    RS2 --> P1["Pod"]
    RS2 --> P2["Pod"]
    RS2 --> P3["Pod"]
    RS1 -.->|"롤백의 재료로 남는다"| UNDO["kubectl rollout undo"]

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef old fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class D key
    class RS2 ok
    class RS1,UNDO old
    class P1,P2,P3 mute
  • 템플릿을 바꾸면 새 ReplicaSet을 만들고, 옛 것을 0으로 줄이며 새 것을 올린다
  • 옛 ReplicaSet은 지워지지 않는다 (0개로 남는다) — 이것이 롤백의 재료다
  • ReplicaSet 이름의 해시는 Pod 템플릿의 해시다. 템플릿이 같으면 새로 안 만든다
Terminal window
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
flowchart LR
    S["replicas 4<br/>maxSurge 1 · maxUnavailable 1"] --> R["살아 있는 Pod 은<br/>3 ~ 5 개 사이를 오간다"]
    MS["maxSurge<br/>목표보다 더 만들 수 있는 수"] -.->|"0 이면 절대 초과하지 않는다"| R
    MU["maxUnavailable<br/>없어도 되는 수"] -.->|"0 이면 용량이 절대 줄지 않는다"| R
    BOTH["둘 다 0"] -->|"만들 수도 지울 수도 없다"| STUCK["롤아웃이 시작조차 안 된다 ❌"]

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class MS,MU key
    class STUCK bad
    class S,R,BOTH mute

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

Terminal window
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 는 여러 변경을 모아서 한 번에 롤아웃할 때 쓴다
Terminal window
kubectl rollout history deploy/web
# REVISION CHANGE-CAUSE
# 1 <none>
# 2 nginx 1.28로 업그레이드

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

Terminal window
kubectl annotate deploy/web kubernetes.io/change-cause="nginx 1.28로 업그레이드"

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

flowchart LR
    R1["리비전 1<br/>nginx:1.27"] --> R2["리비전 2<br/>nginx:1.28"] --> R3["리비전 3<br/>nginx:1.29"]
    R3 -->|"undo --to-revision=2"| R4["리비전 4<br/>내용은 2와 같다"]

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class R4 key
    class R1,R2,R3 mute

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

Terminal window
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가 붙어 있으면 수동 스케일은 곧 되돌려진다 (8장)
Terminal window
# 롤아웃/스케일이 끝날 때까지 기다리기 — 검산에 유용
kubectl wait --for=condition=available deploy/web --timeout=60s
kubectl wait --for=condition=ready pod -l app=web --timeout=60s
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
flowchart TB
    DS["DaemonSet log-agent<br/>★ replicas 가 없다"]
    DS --> N1["node01 → Pod"]
    DS --> N2["node02 → Pod"]
    DS --> N3["node03 → Pod"]
    DS -.->|"toleration 이 없으면 안 뜬다 ❌"| CP["controlplane<br/>NoSchedule taint"]
    NEW["node04 추가"] -->|"자동으로 하나 더"| DS

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class DS key
    class CP bad
    class NEW ok
    class N1,N2,N3 mute
  • replicas가 없다. 개수는 노드 수가 정한다
  • 노드가 추가되면 자동으로 하나 더 생긴다
  • 쓰임: 로그 수집기, 모니터링 에이전트, kube-proxy, CNI 플러그인
Terminal window
kubectl get ds -A # kube-proxy, CNI가 보인다
kubectl rollout status ds/log-agent
  • 특정 노드에만 놓으려면 nodeSelectoraffinity 를 쓴다
  • taint가 걸린 노드에는 안 뜬다tolerations가 필요하다 (7장)
  • 업데이트 전략은 RollingUpdate(기본) / OnDelete

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

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 } }
flowchart LR
    STS["StatefulSet db"]
    STS --> P0["db-0"] --> V0[("PVC data-db-0")]
    STS --> P1["db-1"] --> V1[("PVC data-db-1")]
    STS --> P2["db-2"] --> V2[("PVC data-db-2")]
    ORD["생성 0 → 1 → 2<br/>삭제 2 → 1 → 0"] -.-> STS
    DNS["db-0.db-headless.…<br/>Pod 별 안정적 DNS"] -.-> P0

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef vol fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class STS key
    class V0,V1,V2 vol
    class P0,P1,P2,ORD,DNS mute

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가 되어야 다음이 시작한다.

Terminal window
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
flowchart LR
    U["업데이트 시작"] --> P2["db-2 먼저"] --> P1["db-1"] --> P0["db-0 마지막"]
    PART["partition: 2"] -.->|"인덱스 2 이상만 바뀐다<br/>db-1 · db-0 은 그대로"| P2

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class PART key
    class U,P0,P1,P2 mute
  • 업데이트는 큰 번호부터 역순으로 진행된다 (2 → 1 → 0)
  • partition: N 이면 N 이상 인덱스만 바뀐다 — 단계적 배포에 쓴다
  • podManagementPolicy: Parallel 이면 순서 없이 동시에 생성/삭제 (기동이 빠르다)
Terminal window
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는 남는다. 다시 늘리면 옛 데이터로 복귀한다.

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"]
Terminal window
kubectl create job import --image=busybox -- echo hi
kubectl create job manual --from=cronjob/nightly # CronJob을 즉시 한 번 실행

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

섹션 제목: “Job 파라미터가 실제로 뜻하는 것”
flowchart LR
    C1["completions 없음"] --> R1["Pod 하나가 성공하면 끝"]
    C2["completions 5<br/>parallelism 1"] --> R2["순차로 5번"]
    C3["completions 5<br/>parallelism 5"] --> R3["5개 동시에"]
    C4["parallelism 만 설정"] --> R4["워커 큐 방식<br/>하나가 성공하면 전체 완료"]

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class R1,R2,R3,R4 key
    class C1,C2,C3,C4 mute

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"]
Terminal window
kubectl create cronjob nightly --image=busybox --schedule="0 3 * * *" -- echo backup
kubectl patch cronjob nightly -p '{"spec":{"suspend":true}}'

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

flowchart LR
    CJ["CronJob nightly<br/>schedule 0 3 * * *"] -->|"일정마다 생성"| J["Job nightly-28901234"]
    J -->|"생성"| P["Pod nightly-28901234-x7k2p"]
    TZ["timeZone 미지정<br/>→ 기본 UTC ⚠️"] -.-> CJ

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class CJ key
    class TZ warn
    class J,P mute

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

flowchart LR
    Q{"concurrencyPolicy"}
    Q -->|"Allow · 기본"| A["겹쳐도 그냥 실행"]
    Q -->|Forbid| B["새 Job 을 건너뛴다"]
    Q -->|Replace| C["이전 것을 죽이고 새로 시작"]

    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
    class A mute
    class B,C warn
  • 시간대 기본은 UTC다 (컨트롤러 매니저 기준). timeZone 필드로 명시하는 게 안전하다
Terminal window
kubectl get cronjob nightly
kubectl get jobs --selector=job-name # CronJob이 만든 Job들
kubectl logs job/nightly-28901234

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

섹션 제목: “PodDisruptionBudget — 자발적 중단으로부터 보호”
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web-pdb
spec:
minAvailable: 2 # 또는 maxUnavailable: 1
selector:
matchLabels:
app: web
flowchart LR
    V["자발적 중단<br/>kubectl drain · 노드 업그레이드"] --> PDB{"PDB 검사<br/>minAvailable 충족?"}
    PDB -->|"충족"| OK["축출 허용 ✅"]
    PDB -->|"위반"| WAIT["drain 이 멈춰서 기다린다 ⏳"]
    IV["비자발적 중단<br/>노드 장애 · 커널 패닉"] -.->|"막지 못한다 ❌"| GONE["그냥 죽는다"]

    classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class OK ok
    class WAIT warn
    class GONE bad
    class V,IV,PDB mute
  • kubectl drain이나 노드 업그레이드 같은 “자발적 중단”에서 최소 가용 수를 지킨다
  • 노드 장애 같은 비자발적 중단은 막지 못한다
  • PDB가 막으면 drain멈춰서 기다린다
flowchart LR
    E1["컨테이너가 죽었다"] --> A1["kubelet 이 같은 노드에서 재시작 ✅"]
    E2["Pod 이 삭제됐다"] --> A2["ReplicaSet 이 새 Pod 생성 ✅<br/>이름·IP 가 바뀐다"]
    E3["노드가 NotReady"] --> A3["약 5분 후 축출<br/>다른 노드에 새로 만든다 ✅"]
    E4["노드가 영구히 죽었다"] --> A4["위와 같다<br/>단 StatefulSet 은 자동으로 안 옮긴다 ⚠️"]
    E5["앱이 살아 있는데 응답을 안 한다"] --> A5["livenessProbe 가 없으면<br/>아무 일도 안 일어난다 ❌"]

    classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    class A1,A2,A3 ok
    class A4 warn
    class A5 bad

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

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