K8s 기초 #4 Deployment와 ReplicaSet — 선언형 배포와 롤링 업데이트

14 분 소요

#3 kubectl과 첫 Pod의 마지막에서 확인한 것처럼 Pod는 직접 띄우면 사라질 뿐입니다. 이번 글에서는 Pod가 사라진 자리를 자동으로 채워 주는 첫 컨트롤러인 Deployment와 그 아래의 ReplicaSet을 다루겠습니다. replicas: 3을 선언해 Pod를 유지하는 방법, Pod 한 개를 지웠을 때 자동 복구되는 원리, 이미지 태그를 바꿨을 때 롤링 업데이트와 롤백이 어떻게 동작하는지까지 한 사이클로 정리하겠습니다.

이번 시리즈는 K8s 기초 7편입니다.

이번 글의 끝에서는 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로 두고, 다음과 같이 작성합니다.

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: 80

#3에서 본 Pod 매니페스트와 비교하면 새로 들어온 부분이 셋 있습니다.

  • apiVersion: apps/v1 — Pod는 v1이었지만 Deployment는 apps/v1 API 그룹에 들어 있습니다. 컨트롤러 계열 리소스(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.matchLabelsspec.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의 라벨”**입니다. 그러니 둘이 일치해야 자기가 만든 걸 다시 자기가 알아본다는, 거의 동어반복에 가까운 규칙입니다.

적용해 보기 #

web.yaml을 클러스터에 반영합니다.

Deployment 만들기
kubectl apply -f web.yaml
출력 예시
deployment.apps/web created

세 종류의 리소스를 한 번에 봅시다. kubectl get은 콤마로 여러 리소스 종류를 한 번에 받을 수 있습니다.

세 단을 한 번에
kubectl get deploy,rs,pods
출력 예시
NAME                  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를 지우면 그냥 사라졌습니다. 이번에는 어떻게 다른지 확인하겠습니다.

Pod 한 개 강제 삭제
kubectl delete pod web-abc123-aa11
출력 예시
pod "web-abc123-aa11" deleted

곧장 Pod 목록을 다시 받아 보면.

다시 보기
kubectl get pods
출력 예시
NAME                  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

세 개가 그대로 떠 있습니다. 다만 한 줄을 자세히 보면 차이가 보입니다 — bb22cc33AGE 2m인데 새로 보이는 dd44AGE 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. 가장 깔끔한 길입니다.

web.yaml — replicas만 5로
spec:
  replicas: 5
  ...
다시 적용
kubectl apply -f web.yaml
출력 예시
deployment.apps/web configured

kubectl get pods로 보면 곧 5개로 늘어 있습니다. 줄일 때도 같은 방식입니다 — 매니페스트의 숫자를 줄이고 apply.

명령형 — 빠르지만 임시입니다.

명령형 스케일
kubectl scale deploy/web --replicas=5
출력 예시
deployment.apps/web scaled

순간적으로 늘리고 줄이기에는 가볍습니다. 다만 단점이 분명합니다 — 매니페스트의 replicas 값과 클러스터의 실제 상태가 어긋납니다. 매니페스트에는 여전히 replicas: 3이 적혀 있는데 클러스터에는 5개가 떠 있는 상태가 되기 때문입니다. 다음에 누가 별 생각 없이 kubectl apply -f web.yaml을 한 번 더 부르면 5개가 다시 3개로 줄어 버립니다.

그래서 한 줄로 정리해 두면 — 선언형 매니페스트가 항상 진실의 출처(source of truth) 입니다. kubectl scale은 디버깅 중 빠르게 손봐야 할 때나, 매니페스트를 곧 다시 동기화할 작정일 때만 쓰고, 정상 흐름은 매니페스트를 고치고 apply입니다. 이 원칙이 #1에서 본 desired state 모델 전체의 토대입니다.

롤링 업데이트 — 무중단 배포의 기본 동작 #

이제 이 시리즈에서 처음으로 새 버전을 배포합니다. 이미지 태그를 nginx:1.27에서 nginx:1.28로 한 글자만 바꿉니다.

web.yaml — 이미지 태그만 1.28로
      containers:
        - name: web
          image: nginx:1.28
          ports:
            - containerPort: 80
새 버전 적용
kubectl apply -f web.yaml
출력 예시
deployment.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 rs
출력 예시 — 한가운데
NAME             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가 골고루 분배합니다.

진행 모니터링 #

롤아웃 진행 상황을 한 줄로 보고 싶으면 다음 명령이 가장 편합니다.

롤아웃 진행 상황
kubectl rollout status deploy/web
출력 예시
Waiting 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.yaml

kubectl 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-zz99

describe deploy의 Events에는 ReplicaSet ... has timed out progressing 같은 메시지가, describe pod의 Events에는 Failed to pull image "nginx:1.99-not-real"이 적혀 있습니다. 답은 거의 항상 이 두 출력 안에 있습니다.

롤백 #

새 버전이 잘못 올라간 걸 발견했으면, 옛 버전으로 되돌리는 길이 한 줄로 준비돼 있습니다.

롤아웃 히스토리
kubectl rollout history deploy/web
출력 예시
deployment.apps/web
REVISION  CHANGE-CAUSE
1         <none>
2         <none>

리비전 목록이 보입니다. 1번이 처음의 nginx:1.27, 2번이 방금 올린 nginx:1.28입니다. 직전 리비전으로 되돌리려면.

직전 리비전으로 되돌리기
kubectl rollout undo deploy/web
출력 예시
deployment.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로 연결됩니다. 시작 순서도 012로 보장되고요. Deployment는 Pod의 이름,디스크 모두 임의값으로 다루기 때문에 DB에는 적합하지 않습니다.
  • 노드마다 한 개씩 떠야 하는 워크로드 — 로그 수집기(Fluent Bit, Filebeat), 노드 모니터(Node Exporter), CNI 에이전트 같은 것은 DaemonSet이 적합합니다. 새 노드가 클러스터에 합류하면 자동으로 그 노드에도 한 개가 떠올라요.
  • 일회성 작업 — 마이그레이션, 백업, 배치 잡 같은 한 번 돌고 끝나는 일은 Job(즉시 실행) 또는 CronJob(스케줄 실행)을 씁니다. Pod의 Phase가 Succeeded로 들어가는 게 자연스러운 워크로드입니다.

이 셋은 K8s 중급에서 한 편씩 다루겠습니다. 이 시리즈에서는 가장 자주 만지는 Deployment에만 집중하겠습니다. 다만 머릿속 분류는 미리 잡아 두면 좋습니다 — 상태 없으면 Deployment, 상태 있으면 StatefulSet, 노드마다 하나씩이면 DaemonSet, 일회성이면 Job.

정리,치우기 #

오늘 만든 리소스를 깨끗이 지웁니다. Deployment 한 개를 지우면 그 아래 ReplicaSet과 Pod가 함께 정리됩니다. K8s가 부모-자식 관계(owner reference)를 통해 가비지 컬렉션을 해 주는 부분입니다.

매니페스트로 정리
kubectl delete -f web.yaml
출력 예시
deployment.apps "web" deleted
정말 비었나
kubectl get deploy,rs,pods
출력 예시
No resources found in default namespace.

이름으로 직접 지우는 길도 있습니다.

이름으로 정리
kubectl delete deploy web

Deployment만 지웠는데 ReplicaSet과 Pod까지 한꺼번에 사라지는 것을 확인해 두세요. 이 owner reference 모델은 시리즈 뒷편들에서도 같은 방식으로 동작합니다.

정리 #

이번 글에서 잡은 흐름을 정리합니다.

  • Deployment / ReplicaSet / Pod의 세 단 — 사람이 적는 건 Deployment 한 층뿐이고, ReplicaSet은 자동으로 만들어지는 중간 객체, Pod는 ReplicaSet이 만들어 내는 실제 워크로드.
  • 매니페스트의 척추는 apiVersion: apps/v1 / kind: Deployment / metadata / spec 네 필드. spec 안에서 새로 만나는 부분은 replicas, selector.matchLabels, template 셋이고, selector와 template 라벨은 반드시 일치해야 합니다.
  • Pod 한 개를 강제로 지워도 ReplicaSet 컨트롤러가 곧 새 Pod 한 개를 띄워 desired(N)와 actual을 다시 맞춘다 — #1에서 본 reconcile loop의 가장 단순한 모습.
  • replicas 조정은 매니페스트 수정 + apply가 정공법, kubectl scale은 임시. 선언형 매니페스트가 항상 진실의 출처입니다.
  • 롤링 업데이트는 새 ReplicaSet을 만들고 옛 ReplicaSet을 점진적으로 비워 가는 흐름을 정리합니다. 기본 strategy는 RollingUpdate(maxSurge 25%, maxUnavailable 25%), 진행은 kubectl rollout status로 본다.
  • 롤백은 kubectl rollout undo 한 줄. 가능한 이유는 옛 ReplicaSet이 replicas: 0으로 비워진 채 남아 있기 때문.

다음 — Service #

여기까지 와도 한 가지가 여전히 안 풀려 있습니다 — 외부에서 클러스터 안의 Pod로 어떻게 트래픽을 보내는가. 지금 우리가 만든 nginx Pod 3개에는 클러스터 내부 IP가 붙어 있을 뿐, 그 IP는 Pod가 죽고 새로 뜰 때마다 바뀝니다. ReplicaSet이 Pod를 자동으로 살려 주긴 하지만, 그렇게 살아난 Pod의 IP가 매번 다르니 클라이언트가 어디로 접속해야 할지가 모호합니다.

#5 Service — ClusterIP / NodePort / LoadBalancer에서는 (1) Service가 Pod 앞단에 안정적인 가상 IP/DNS 이름을 어떻게 붙여 주는지, (2) 클러스터 내부 통신용 ClusterIP, 노드 포트로 외부에 여는 NodePort, 클라우드 로드밸런서를 붙이는 LoadBalancer 세 종류의 차이, (3) 이번 글에서 띄운 app: web Pod 앞단에 Service를 붙여 첫 외부 접속을 만드는 흐름까지 다루겠습니다. 이번 글의 Pod 3개가 거기서 처음으로 “주소가 있는 서비스"가 됩니다.

X