Deployment와 ReplicaSet
선언형 배포와 롤링 업데이트를 다룹니다. Deployment / ReplicaSet / Pod 세 단의 관계를 잡고, replicas: 3의 self-healing, RollingUpdate의 maxSurge / maxUnavailable, rollout undo 롤백, Deployment가 풀지 않는 워크로드 (StatefulSet · DaemonSet · Job)까지 한 사이클로 정리합니다.
3장 kubectl과 첫 Pod의 마지막에서 확인한 한 줄 — Pod는 mortal이라 직접 띄우면 사라질 뿐이다 — 이 이번 챕터의 출발점이 됩니다. 이번 챕터에서는 그 빈 곳을 자동으로 메우는 첫 컨트롤러인 Deployment와 그 아래의 ReplicaSet을 다룹니다. replicas: 3을 선언해 Pod를 유지하는 방법, Pod 한 개를 지웠을 때 자동 복구되는 원리, 이미지 태그를 바꿨을 때 롤링 업데이트와 롤백이 어떻게 동작하는지까지 한 사이클로 정리합니다.
이번 챕터의 끝에서는 Pod를 사람이 일일이 띄우지 않고 컨트롤러에게 맡기는 첫 매니페스트가 손에 들어옵니다. 여기서부터가 사실상 운영에서 사람이 적는 매니페스트의 기본형입니다.
Deployment, ReplicaSet, Pod — 세 단의 관계 #
이번 챕터의 주인공은 세 리소스인데, 사람이 직접 적는 건 맨 위 한 층뿐입니다. 머릿속 그림은 이렇게 잡아 두면 편합니다.
┌──────────────────────┐
│ Deployment │ ← 사람이 적는 매니페스트
│ (web) │
└──────────┬───────────┘
│ 만들고/관리한다
▼
┌──────────────────────┐
│ ReplicaSet │ ← Deployment 가 자동으로 만든다
│ (web-abc123) │
└──────────┬───────────┘
│ 만들고/유지한다
▼
┌──────────┬──────────┬──────────┐
│ Pod │ Pod │ Pod │ ← 실제 워크로드
│ web-... │ web-... │ web-... │
└──────────┴──────────┴──────────┘각 단의 책임을 한 줄씩 정리하면 다음과 같습니다.
- Deployment — 사람이 적는 매니페스트입니다. “이 Pod 템플릿으로 N개를 띄워 두고, 새 버전으로 어떻게 바꿀지 (롤링 업데이트)“까지 적습니다. 사실상 운영에서 가장 자주 만지는 리소스입니다.
- ReplicaSet — Deployment가 자동으로 만들어 내는 중간 객체입니다. 책임은 한 가지 — **“이 Pod 템플릿으로 N개를 항상 유지한다”**입니다. 사람이 직접 ReplicaSet 매니페스트를 적는 일은 거의 없습니다.
- Pod — 실제 워크로드입니다. ReplicaSet이 만들어 내고, 죽으면 ReplicaSet이 다시 만듭니다. 3장에서 손으로 띄우던 그 Pod 지만, 이제 누가 죽여도 알아서 다시 떠오릅니다.
왜 두 층인가 #
처음 보면 Deployment 한 층이면 충분해 보입니다. ReplicaSet은 왜 따로 존재하는지 묻게 됩니다. 이유는 새 버전 배포에 있습니다.
새 버전을 배포할 때 Deployment는 새 ReplicaSet을 하나 더 만듭니다. 그 새 ReplicaSet의 replicas를 1, 2, 3 점진적으로 올리면서 옛 ReplicaSet의 replicas를 3, 2, 1 점진적으로 내립니다. 그 사이에 두 ReplicaSet의 Pod 들이 같이 떠 있는 짧은 구간이 생깁니다 — 이게 롤링 업데이트의 본체입니다. 끝나면 옛 ReplicaSet은 0개로 비어 있되 객체로는 남아 있어, 롤백이 필요할 때 그쪽을 다시 N개로 키우는 식으로 되돌립니다.
요컨대 **Deployment는 “버전 사이의 전환을 다루는 층”**이고 **ReplicaSet은 “한 버전을 N개로 유지하는 층”**입니다. 둘이 갈라져 있어서 같은 클러스터 안에 옛 버전과 새 버전이 잠깐 공존할 수 있습니다.
첫 Deployment 매니페스트 #
같은 nginx:1.27을 이번에는 Pod가 아닌 Deployment로 작성합니다. 파일 이름은 web.yaml로 두고, 다음과 같이 적습니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
labels:
app: web
spec:
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: nginx:1.27
ports:
- containerPort: 803장에서 본 Pod 매니페스트와 비교하면 새로 들어온 부분이 셋 있습니다.
apiVersion: apps/v1— Pod는v1이었지만 Deployment는apps/v1API 그룹에 들어 있습니다. 컨트롤러 계열 리소스 (Deployment, StatefulSet, DaemonSet, ReplicaSet)는 모두 같은 그룹에 속합니다.spec.replicas: 3— 이 Pod 템플릿이 항상 3개 떠 있어야 한다는 선언입니다.spec.selector.matchLabels+spec.template— Deployment가 자기가 관리할 Pod를 찾는 라벨 조건과, 그 Pod가 어떤 모양이어야 하는지의 템플릿입니다.template아래의 모양은 3장에서 본 Pod의metadata+spec과 정확히 같습니다.
한 가지 규칙 — selector와 template 라벨이 일치해야 한다 #
처음 매니페스트를 적을 때 가장 자주 터지는 실수가 여기입니다. spec.selector.matchLabels와 spec.template.metadata.labels는 서로 일치해야 합니다. 일치하지 않으면 K8s가 매니페스트를 거부합니다. 단순한 권장 규약이 아니라 apiserver가 들고 있는 검증 규칙입니다.
위 매니페스트에서 둘 다 app: web으로 맞춰 둔 게 그 이유입니다. 만약 selector를 app: web으로 두고 template의 라벨만 app: nginx로 바꿨다면, kubectl apply가 다음 같은 에러를 뱉습니다.
The Deployment "web" is invalid: spec.template.metadata.labels:
Invalid value: map[string]string{"app":"nginx"}:
`selector` does not match template `labels`머릿속 단순한 모델은 다음과 같습니다 — **selector는 “내가 관리할 Pod를 어떻게 알아보는가"이고, template은 “내가 만드는 Pod의 라벨”**입니다. 둘이 일치해야 자기가 만든 Pod를 다시 자기가 알아본다는, 거의 동어반복에 가까운 규칙입니다.
적용해 보기 #
web.yaml을 클러스터에 반영합니다.
kubectl apply -f web.yamldeployment.apps/web created세 종류의 리소스를 한 번에 봅시다. kubectl get은 콤마로 여러 리소스 종류를 한 번에 받을 수 있습니다.
kubectl get deploy,rs,podsNAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/web 3/3 3 3 20s
NAME DESIRED CURRENT READY AGE
replicaset.apps/web-abc123 3 3 3 20s
NAME READY STATUS RESTARTS AGE
pod/web-abc123-aa11 1/1 Running 0 20s
pod/web-abc123-bb22 1/1 Running 0 20s
pod/web-abc123-cc33 1/1 Running 0 20s읽는 법은 다음과 같습니다.
- Deployment 줄 —
READY 3/3은 desired 3개가 모두 ready,UP-TO-DATE 3은 현재 템플릿으로 갱신된 Pod 수,AVAILABLE 3은 minReadySeconds까지 살아 있어 트래픽을 받을 만한 Pod 수입니다. - ReplicaSet 줄 — 이름이
web-abc123형태입니다. 뒤의abc123은 K8s가 템플릿 해시로 자동 생성한 임의값입니다.DESIRED 3 / CURRENT 3 / READY 3이 핵심 컬럼입니다. - Pod 줄 — 이름이
web-abc123-aa11같은 두 단계 임의값을 달고 있습니다. 앞부분 (web-abc123)이 ReplicaSet 이름과 일치하는 게 보입니다. 누가 자기를 만들었는지가 이름에 그대로 드러납니다.
이름 패턴을 한 줄로 짚어 두면 — <deployment>-<replicaset-hash>-<pod-suffix>입니다. 이 모양은 본 책 끝까지 자주 만납니다.
Pod를 죽여 보기 — self-healing #
이번 매니페스트의 첫 효과를 확인할 차례입니다. 3장에서는 Pod를 지우면 그냥 사라졌습니다. 이번에는 어떻게 다른지 확인합니다.
kubectl delete pod web-abc123-aa11pod "web-abc123-aa11" deleted곧장 Pod 목록을 다시 받아 봅시다.
kubectl get podsNAME READY STATUS RESTARTS AGE
web-abc123-bb22 1/1 Running 0 2m
web-abc123-cc33 1/1 Running 0 2m
web-abc123-dd44 1/1 Running 0 5s세 개가 그대로 떠 있습니다. 다만 한 줄을 자세히 보면 차이가 보입니다 — bb22와 cc33은 AGE 2m 인데 새로 보이는 dd44는 AGE 5s입니다. 방금 새로 떠올린 Pod입니다. 이름 뒤의 임의값이 바뀐 것도 새 Pod라는 단서입니다.
이게 1장 쿠버네티스란에서 그림으로 본 reconcile loop가 일하는 모습입니다. ReplicaSet은 “Pod 3개가 있어야 한다"를 들고 있고, 사람이 한 개를 지운 순간 desired (3)와 actual (2)이 어긋났습니다. controller-manager 안의 ReplicaSet 컨트롤러가 그 차이를 감지하고, Pod를 한 개 더 만들라고 apiserver에 요청합니다. scheduler가 노드를 정하고, kubelet이 컨테이너를 띄우고, 다시 3개로 맞춰집니다. 사람이 별도로 한 일은 없습니다.
같은 일이 노드 단위에서도 벌어집니다. 어떤 Pod가 떠 있던 노드가 죽으면, K8s는 그 Pod 들을 다른 살아 있는 노드로 옮겨 다시 띄웁니다. 1장에서 본 “노드가 죽어도 서비스는 살아 있어야 한다"가 사실은 이 ReplicaSet 컨트롤러가 풀어 주는 문제입니다.
replicas 조정 #
3개로는 모자라거나 너무 많을 때, 개수를 조절하는 길은 두 갈래입니다.
선언형 — 매니페스트의 숫자를 바꾸고 다시 apply 합니다. 가장 깔끔한 길입니다.
spec:
replicas: 5
...kubectl apply -f web.yamldeployment.apps/web configuredkubectl get pods로 보면 곧 5개로 늘어 있습니다. 줄일 때도 같은 방식입니다 — 매니페스트의 숫자를 줄이고 apply 합니다.
명령형 — 빠르지만 임시입니다.
kubectl scale deploy/web --replicas=5deployment.apps/web scaled순간적으로 늘리고 줄이기에는 가볍습니다. 다만 단점이 분명합니다 — 매니페스트의 replicas 값과 클러스터의 실제 상태가 어긋납니다. 매니페스트에는 여전히 replicas: 3이 적혀 있는데 클러스터에는 5개가 떠 있는 상태가 되기 때문입니다. 다음에 누가 별 생각 없이 kubectl apply -f web.yaml을 한 번 더 부르면 5개가 다시 3개로 줄어 버립니다.
그래서 한 줄로 정리해 두면 — **선언형 매니페스트가 항상 진실의 출처 (source of truth)**입니다. kubectl scale은 디버깅 중 빠르게 손봐야 할 때나, 매니페스트를 곧 다시 동기화할 작정일 때만 쓰고, 정상 흐름은 매니페스트를 고치고 apply입니다. 이 원칙이 1장에서 본 desired state 모델 전체의 토대이고, 20장 GitOps에서 ArgoCD / Flux로 한 번 더 본격적으로 다룹니다.
replicas를 사람이 정하지 않고 부하에 따라 자동으로 늘렸다 줄였다 하는 옵션은 13장 오토스케일링에서 HPA · VPA · Cluster Autoscaler로 다룹니다.
롤링 업데이트 — 무중단 배포의 기본 동작 #
이제 본 책에서 처음으로 새 버전을 배포 합니다. 이미지 태그를 nginx:1.27에서 nginx:1.28로 한 글자만 바꿉니다.
containers:
- name: web
image: nginx:1.28
ports:
- containerPort: 80kubectl apply -f web.yamldeployment.apps/web configured겉으로 떨어진 메시지는 한 줄이지만, 안에서는 꽤 큰 일이 벌어집니다.
안에서 벌어지는 일 #
Deployment 컨트롤러는 템플릿이 바뀐 것을 보고, 그 새 템플릿을 위한 새 ReplicaSet을 하나 만듭니다. 그러고 나서 새 RS의 replicas는 0에서 1, 2, 3으로 키우고, 옛 RS의 replicas는 3에서 2, 1, 0으로 줄여 갑니다. 한 단계마다 새 Pod가 Ready 상태로 들어와야 다음 단계로 넘어갑니다.
진행 중에 kubectl get rs로 보면 ReplicaSet이 두 줄 보입니다.
kubectl get rsNAME DESIRED CURRENT READY AGE
web-abc123 2 2 2 10m ← 옛 RS (1.27)
web-def456 2 2 1 30s ← 새 RS (1.28)옛 RS는 3에서 2로 줄어 있고, 새 RS는 0에서 2로 올라와 있습니다. 이 한 컷이 롤링 업데이트의 본체입니다. 두 RS의 Pod가 잠깐 같이 떠 있습니다 — 그 사이의 트래픽은 5장 Service에서 다룰 Service가 골고루 분배합니다.
진행 모니터링 #
롤아웃 진행 상황을 한 줄로 보고 싶으면 다음 명령이 가장 편합니다.
kubectl rollout status deploy/webWaiting for deployment "web" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "web" rollout to finish: 2 out of 3 new replicas have been updated...
Waiting for deployment "web" rollout to finish: 1 old replicas are pending termination...
deployment "web" successfully rolled out화면에 진행 단계가 한 줄씩 떨어지고, 마지막에 성공이 찍히면 새 버전 배포가 끝난 겁니다. 끝난 다음 kubectl get rs를 다시 보면 옛 RS가 DESIRED 0으로 비어 있되 객체로는 남아 있습니다. 이 구조가 다음 절의 롤백을 가능하게 합니다.
기본 strategy 한 줄 #
위 흐름이 일어나는 정확한 이유는 Deployment의 spec.strategy 기본값이 **RollingUpdate**이고, 그 안의 두 파라미터가 다음과 같기 때문입니다.
maxSurge: 25%— desired 개수보다 일시적으로 더 떠 있어도 되는 한도입니다. 3개 기준으로 1개까지 추가 허용됩니다.maxUnavailable: 25%— desired 개수보다 일시적으로 부족해도 되는 한도입니다. 3개 기준으로 1개까지 부족 허용됩니다.
다른 옵션은 Recreate입니다 — 옛 Pod를 전부 죽이고 새 Pod를 띄우는 방식입니다. 무중단이 안 되지만, 두 버전이 동시에 떠 있으면 안 되는 상태성 워크로드 (예: 같은 볼륨을 점유하는 DB 마이그레이션)에서 가끔 씁니다. 일반 웹 서버라면 기본값인 RollingUpdate로 충분합니다.
실패하면 어떻게 되는가 #
이미지 태그를 일부러 잘못 적어 봅시다 — 예를 들어 nginx:1.99-not-real 같은 식입니다.
kubectl apply -f web.yamlkubectl rollout status deploy/web이 한참 멈춰 있고, kubectl get pods로 보면 새로 만들어진 Pod 하나가 ImagePullBackOff 상태에 있습니다.
NAME READY STATUS RESTARTS AGE
web-abc123-aa11 1/1 Running 0 15m
web-abc123-bb22 1/1 Running 0 15m
web-abc123-cc33 1/1 Running 0 15m
web-ghi789-zz99 0/1 ImagePullBackOff 0 40s흥미로운 건 옛 Pod 3개가 그대로 살아 있다는 점입니다. 새 Pod가 Ready로 들어오지 못하면 Deployment는 다음 단계로 넘어가지 않습니다. 즉 옛 RS를 0으로 줄이지 않습니다. 롤아웃이 멈춘 동안에도 옛 버전은 트래픽을 정상적으로 받습니다. 무중단의 핵심이 여기 있습니다.
이때의 디버깅 순서는 3장의 마지막에서 정리한 그대로입니다.
kubectl describe deploy/web
kubectl describe pod web-ghi789-zz99describe deploy의 Events에는 ReplicaSet ... has timed out progressing 같은 메시지가, describe pod의 Events에는 Failed to pull image "nginx:1.99-not-real"가 적혀 있습니다. 답은 거의 항상 이 두 출력 안에 있습니다. 진단 트리의 완성된 버전은 27장 kubectl 디버깅 패턴에서 정리합니다.
롤백 #
새 버전이 잘못 올라간 걸 발견했으면, 옛 버전으로 되돌리는 길이 한 줄로 준비돼 있습니다.
kubectl rollout history deploy/webdeployment.apps/web
REVISION CHANGE-CAUSE
1 <none>
2 <none>리비전 목록이 보입니다. 1번이 처음의 nginx:1.27, 2번이 방금 올린 nginx:1.28입니다. 직전 리비전으로 되돌리려면 다음과 같이 합니다.
kubectl rollout undo deploy/webdeployment.apps/web rolled back특정 리비전을 지정하고 싶다면 --to-revision 플래그를 씁니다.
kubectl rollout undo deploy/web --to-revision=1이게 가능한 이유는 한 줄로 정리됩니다 — 리비전은 ReplicaSet의 또 다른 모습입니다. 새 버전을 배포할 때 옛 ReplicaSet은 사라지지 않고 replicas: 0으로 비워진 채 남아 있었기 때문입니다. undo는 그 옛 RS를 다시 N개로 키우는 일입니다. 그래서 거의 즉시 옛 버전이 다시 트래픽을 받습니다.
기본적으로 K8s는 리비전을 10개까지 보관합니다. 매니페스트의 spec.revisionHistoryLimit로 늘리거나 줄일 수 있습니다. 너무 길게 두면 옛 ReplicaSet이 등록부에 쌓여 정리가 번잡해지고, 너무 짧게 두면 한참 전 버전으로 한 번에 못 돌아가니 운영 환경의 배포 빈도에 맞춰 정합니다 — 일반적인 웹 서비스라면 기본값 10이 무난합니다.
Deployment가 풀어 주지 않는 것 #
여기까지의 Deployment가 모든 워크로드 모양을 다 다루지는 않습니다. 결이 다른 경우들을 한 단락에 짚어 둡니다.
- 상태 (stateful) 워크로드 — 데이터베이스처럼 각 인스턴스가 자기 이름과 자기 디스크를 가져야 하는 워크로드는
StatefulSet이 맞습니다. Pod 이름이web-0,web-1처럼 안정적으로 매겨지고, 각자에게 매니페스트로 정의한 PVC가 1:1로 연결됩니다. 시작 순서도0→1→2로 보장됩니다. Deployment는 Pod의 이름과 디스크 모두 임의값으로 다루기 때문에 DB에는 적합하지 않습니다. - 노드마다 한 개씩 떠야 하는 워크로드 — 로그 수집기 (Fluent Bit, Filebeat), 노드 모니터 (Node Exporter), CNI 에이전트 같은 워크로드는
DaemonSet이 적합합니다. 새 노드가 클러스터에 합류하면 자동으로 그 노드에도 한 개가 떠오릅니다. - 일회성 작업 — 마이그레이션, 백업, 배치 잡 같은 한 번 돌고 끝나는 일은
Job(즉시 실행) 또는CronJob(스케줄 실행)을 씁니다. Pod의 Phase가Succeeded로 들어가는 게 자연스러운 워크로드입니다.
이 셋은 8장 StatefulSet / DaemonSet / Job / CronJob에서 본격적으로 다룹니다. 본 챕터에서는 가장 자주 만지는 Deployment에만 집중합니다. 다만 머릿속 분류는 미리 잡아 두면 좋습니다 — 상태 없으면 Deployment, 상태 있으면 StatefulSet, 노드마다 하나씩이면 DaemonSet, 일회성이면 Job입니다.
정리·치우기 #
오늘 만든 리소스를 깨끗이 지웁니다. Deployment 한 개를 지우면 그 아래 ReplicaSet과 Pod가 함께 정리됩니다. K8s가 부모-자식 관계 (owner reference)를 통해 가비지 컬렉션을 해 주는 부분입니다.
kubectl delete -f web.yamldeployment.apps "web" deletedkubectl get deploy,rs,podsNo resources found in default namespace.이름으로 직접 지우는 길도 있습니다.
kubectl delete deploy webDeployment만 지웠는데 ReplicaSet과 Pod까지 한꺼번에 사라지는 것을 확인해 두세요. 이 owner reference 모델은 본 책 뒷부분에서도 같은 방식으로 동작합니다.
연습문제 #
- 위 본문대로
web.yaml을replicas: 3으로 띄운 뒤,kubectl delete pod <name>으로 Pod 한 개를 강제 삭제해 보세요.kubectl get pods의AGE컬럼이 어떻게 달라지는지를 시간 순서대로 기록하고, 새 Pod의 이름 뒤 임의값이 어떻게 바뀌었는지를 메모합니다. ReplicaSet 컨트롤러의 reconcile loop가 어디서 차이를 메웠는지를 한 단락으로 적습니다. nginx:1.27에서nginx:1.28로 한 번 롤아웃해 본 뒤, 일부러nginx:1.99-not-real같은 없는 태그로 한 번 더apply해 보세요.kubectl get rs와kubectl get pods가 어떤 모양으로 멈추는지, 옛 Pod 3개가 그대로 트래픽을 받을 수 있는 상태로 남아 있는 것이 §“실패하면 어떻게 되는가"의 무중단 핵심과 어떻게 연결되는지를 정리합니다.kubectl rollout undo deploy/web으로 깨끗이 되돌릴 때까지의 전 흐름을 한 사이클로 기록합니다.kubectl scale deploy/web --replicas=5로 명령형으로 늘린 뒤, 매니페스트 (replicas: 3)의kubectl apply를 다시 한 번 실행해 5개가 다시 3개로 줄어드는 것을 확인해 보세요. “선언형 매니페스트가 항상 진실의 출처"라는 §“replicas 조정"의 결론이 20장 GitOps의 멘탈 모델과 어떻게 이어지는지를 한 단락으로 메모합니다.
한 줄 요약: Deployment는 “이 Pod 템플릿으로 N개 유지 + 새 버전으로의 롤링 전환"을 다루는 매니페스트이고, 그 아래의 ReplicaSet이 한 버전의 N개 유지를, Pod가 실제 실행을 책임진다. Pod를 지워도 ReplicaSet이 새로 띄워 self-healing이 자동으로 동작한다. 새 버전 배포는 새 RS를 점진적으로 키우고 옛 RS를 점진적으로 줄이는 롤링 업데이트로, 실패 시 옛 RS가 그대로 트래픽을 받고,
kubectl rollout undo한 줄로 옛 RS를 다시 키워 되돌릴 수 있다.
다음 챕터 #
여기까지 와도 한 가지가 여전히 안 풀려 있습니다 — 외부에서 클러스터 안의 Pod로 어떻게 트래픽을 보내는가입니다. 지금 우리가 만든 nginx Pod 3개에는 클러스터 내부 IP가 붙어 있을 뿐, 그 IP는 Pod가 죽고 새로 뜰 때마다 바뀝니다. ReplicaSet이 Pod를 자동으로 살려 주긴 하지만, 그렇게 살아난 Pod의 IP가 매번 다르니 클라이언트가 어디로 접속해야 할지가 모호합니다.
5장 Service에서는 Service가 Pod 앞단에 안정적인 가상 IP / DNS 이름을 어떻게 붙여 주는지, 클러스터 내부 통신용 ClusterIP, 노드 포트로 외부에 여는 NodePort, 클라우드 로드밸런서를 붙이는 LoadBalancer 세 종류의 차이, 그리고 이번 챕터에서 띄운 app: web Pod 앞단에 Service를 붙여 첫 외부 접속을 만드는 흐름까지 다룹니다. 이번 챕터의 Pod 3개가 거기서 처음으로 “주소가 있는 서비스"가 됩니다.