Table of contents
- 파드만으로는 부족하다
- ReplicaSet: 파드 N개를 유지하는 가장 작은 약속
- Deployment: ReplicaSet 위의 지휘자
- 롤링 업데이트가 무중단을 만드는 방식
- 롤백: 한 줄로 되돌리기
- StatefulSet: 각자의 정체성이 필요한 파드들
- DaemonSet: 모든 노드에 하나씩
- 네 가지를 한 장에 정리
- 선택의 기준
파드만으로는 부족하다
3편에서 파드를 직접 띄워봤다. 그런데 파드 하나를 손으로 만드는 건 실무에서 거의 하지 않는 일이다. 이유는 간단하다. 그렇게 만든 파드는 죽으면 끝이기 때문이다.
Kubernetes의 진짜 힘은 “파드를 선언적으로 관리해주는 컨트롤러”에서 나온다. 우리는 “nginx를 항상 3개 띄워둬라”처럼 원하는 상태를 선언할 뿐이고, 그 상태를 유지하는 일은 컨트롤러가 한다. 파드가 죽으면 살리고, 배포를 업데이트하면 기존 파드를 서서히 새 버전으로 갈아끼운다.
이 편에서는 가장 자주 쓰는 네 가지 컨트롤러를 뜯어본다. 각자 푸는 문제가 다르기 때문에 상황에 맞는 도구를 고를 수 있어야 한다.
ReplicaSet: 파드 N개를 유지하는 가장 작은 약속
가장 단순한 컨트롤러는 ReplicaSet이다. 이름 그대로 “파드의 복제본 집합”이다. ReplicaSet은 딱 한 가지를 보장한다. 내가 관리하는 라벨을 가진 파드가 항상 N개 있게 한다.
apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: nginx-rs
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.25
이 ReplicaSet을 만들면 app: nginx 라벨을 가진 파드가 3개 뜬다. 하나를 수동으로 지워봐도 금세 새 파드가 그 자리를 메꾼다.
kubectl apply -f rs.yaml
kubectl get pods -l app=nginx
kubectl delete pod <파드이름> # 하나 지우면
kubectl get pods -l app=nginx # 곧바로 새 파드가 뜬다
그런데 ReplicaSet을 직접 쓰는 일은 드물다. 버전 업데이트 기능이 없기 때문이다. 이미지 태그를 1.25에서 1.26으로 바꿔도 ReplicaSet은 기존 파드를 그대로 두고 아무 일도 하지 않는다. “3개 있으면 됐지”라는 단순한 판단밖에 못한다.
Deployment: ReplicaSet 위의 지휘자
실무에서 가장 많이 쓰는 컨트롤러는 Deployment다. Deployment는 ReplicaSet을 관리하는 상위 컨트롤러로, 버전 업데이트와 롤백을 담당한다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: myapp:1.0
ports:
- containerPort: 8080
Deployment가 만들어내는 계층 구조는 이렇다.
flowchart TB
D[Deployment: web]
RS1[ReplicaSet: web-abc123<br/>image: myapp:1.0]
P1[Pod 1]
P2[Pod 2]
P3[Pod 3]
D --> RS1
RS1 --> P1
RS1 --> P2
RS1 --> P3
사용자는 Deployment만 건드린다. ReplicaSet과 Pod는 Deployment가 알아서 관리한다.
롤링 업데이트가 무중단을 만드는 방식
이미지 버전을 1.0에서 2.0으로 올려보자.
kubectl set image deployment/web web=myapp:2.0
또는 YAML을 수정해서 kubectl apply -f로 적용해도 된다. Deployment가 감지한 뒤 벌어지는 일을 그림으로 따라가보자.
sequenceDiagram
participant D as Deployment
participant OLD as ReplicaSet v1 (3)
participant NEW as ReplicaSet v2 (0)
D->>NEW: ReplicaSet 생성 (replicas=0)
D->>NEW: replicas=1로 증가
Note over NEW: v2 파드 1개 뜸, readiness 통과
D->>OLD: replicas=2로 감소
Note over OLD: v1 파드 1개 제거
D->>NEW: replicas=2로 증가
D->>OLD: replicas=1로 감소
D->>NEW: replicas=3로 증가
D->>OLD: replicas=0로 감소
Note over OLD: 모든 v1 파드 제거 완료
Deployment는 새 버전 ReplicaSet을 만들고, 천천히 복제본을 늘리면서 동시에 기존 ReplicaSet을 줄인다. 이 과정이 RollingUpdate 전략이다.
maxUnavailable: 1은 “업데이트 중에도 최대 1개만 죽어 있어도 된다”는 뜻이고, maxSurge: 1은 “기존 개수보다 최대 1개까지는 더 떠도 된다”는 뜻이다. 즉 3개 기준으로 2개는 반드시 서비스 가능 상태여야 하고, 동시에 최대 4개까지 잠시 떠 있을 수 있다. 이 제약이 무중단을 만들어낸다.
주의할 점은 readiness probe가 제대로 설정되어 있어야 한다는 점이다(readiness probe — 파드가 트래픽을 받을 준비가 됐는지 확인하는 헬스 체크. 통과 전까지는 Service 뒤에서 제외된다). 새 파드가 readiness 통과하지 않으면 기존 파드를 내리지 않는다. 그래서 앱이 초기화 중인데 트래픽이 들어가는 사고를 막는다. readiness 없이 롤링 업데이트하면 순간적으로 에러가 치솟는 걸 종종 보게 된다.
롤백: 한 줄로 되돌리기
배포했는데 뭔가 이상하다면? Deployment는 이전 ReplicaSet을 지우지 않고 replicas=0으로만 내려두기 때문에, 한 줄로 되돌릴 수 있다.
kubectl rollout undo deployment/web # 바로 이전 버전으로
kubectl rollout undo deployment/web --to-revision=3 # 특정 리비전으로
리비전 히스토리도 확인할 수 있다.
kubectl rollout history deployment/web
kubectl rollout history deployment/web --revision=3
얼마나 많은 리비전을 보관할지는 spec.revisionHistoryLimit으로 제어한다. 기본값 10이지만, etcd 부담을 줄이려고 3~5 정도로 낮춰서 쓰는 팀이 많다.
롤아웃 상태를 지켜볼 때는 kubectl rollout status deployment/web이 유용하다. 업데이트가 완료될 때까지 블로킹되어서 CI 파이프라인에 넣기도 좋다.
StatefulSet: 각자의 정체성이 필요한 파드들
Deployment는 모든 파드가 똑같다고 가정한다. 이름이 web-abc든 web-xyz든 아무거나 써도 된다. 그런데 어떤 워크로드는 각 파드가 고유한 정체성을 가져야 한다. 대표적인 게 DB 클러스터다.
MySQL, MongoDB, Kafka, Elasticsearch 같은 시스템은 다음을 요구한다.
- 안정적인 이름:
mysql-0,mysql-1,mysql-2처럼 순차적이고 예측 가능한 이름 - 안정적인 스토리지: 파드가 재시작되어도 자기 볼륨을 계속 쓴다.
mysql-0은 항상data-mysql-0볼륨 - 순서 있는 배포: 0번이 뜬 뒤에 1번, 1번이 뜬 뒤에 2번. 마스터 복제 설정 같은 데 필요
이런 요구를 채우는 게 StatefulSet이다.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
spec:
serviceName: "mysql"
replicas: 3
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql:8.0
volumeMounts:
- name: data
mountPath: /var/lib/mysql
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
이 StatefulSet은 mysql-0, mysql-1, mysql-2 파드를 순서대로 띄우고, 각 파드에 data-mysql-0, data-mysql-1, data-mysql-2 볼륨을 일대일로 연결한다. mysql-1이 죽었다가 살아나도 같은 이름, 같은 볼륨을 쓴다.
운영 측면에서 StatefulSet은 Deployment보다 까다롭다. 롤링 업데이트도 한 번에 하나씩 순서대로 진행되고, 확장/축소에도 고려할 점이 많다. 그래서 상태가 없는 앱은 Deployment, 상태가 필요한 앱은 StatefulSet으로 가는 게 일반적인 기준이다.
StatefulSet이 만들어내는 구조를 그림으로 보자. 각 파드가 고유한 이름과 일대일 PVC(PersistentVolumeClaim — 파드가 “이만큼의 영속 스토리지가 필요하다”고 요청하는 리소스. 8편에서 본격적으로 다룬다)를 가지는 게 핵심이다.
flowchart TB
SS["StatefulSet: mysql\n(replicas: 3)"]
P0["Pod: mysql-0"]
P1["Pod: mysql-1"]
P2["Pod: mysql-2"]
V0[("PVC: data-mysql-0")]
V1[("PVC: data-mysql-1")]
V2[("PVC: data-mysql-2")]
SS -->|순서대로 생성| P0
SS -->|P0 Ready 후| P1
SS -->|P1 Ready 후| P2
P0 -.->|고정 바인딩| V0
P1 -.->|고정 바인딩| V1
P2 -.->|고정 바인딩| V2
DaemonSet: 모든 노드에 하나씩
어떤 파드는 “모든 노드에 반드시 하나씩 떠 있어야” 한다. 대표적인 예가 이런 것들이다.
- 로그 수집기(Fluent Bit, Filebeat): 각 노드의 로그 파일을 읽어야 하니 모든 노드에 필요
- 모니터링 에이전트(Node Exporter): 각 노드의 메트릭을 수집해야 함
- 네트워크 플러그인(CNI 에이전트): 노드의 네트워크 설정을 관리
이걸 Deployment로 구현하면 파드가 노드에 골고루 분산되지 않을 수 있다. 그래서 DaemonSet이라는 별도 컨트롤러가 있다.
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: node-exporter
spec:
selector:
matchLabels:
app: node-exporter
template:
metadata:
labels:
app: node-exporter
spec:
hostNetwork: true
containers:
- name: node-exporter
image: prom/node-exporter:latest
ports:
- containerPort: 9100
hostPort: 9100
DaemonSet이 배포되면 현재 클러스터의 모든 노드에 이 파드가 하나씩 뜬다. 새 노드가 추가되면 자동으로 그 노드에도 뜬다. 노드가 제거되면 파드도 함께 사라진다. 노드 기준의 1:1 관계를 Kubernetes가 유지해주는 셈이다.
Deployment가 파드를 노드에 임의로 분산하는 것과, DaemonSet이 노드당 1개씩 정확히 배치하는 방식의 차이를 그림으로 비교해보자.
flowchart LR
subgraph DEP["Deployment (replicas: 2)"]
DN1["Node 1\n[pod-a, pod-b]"]
DN2["Node 2\n(비어 있음)"]
DN3["Node 3\n(비어 있음)"]
end
subgraph DS["DaemonSet"]
SN1["Node 1\n[node-exporter]"]
SN2["Node 2\n[node-exporter]"]
SN3["Node 3\n[node-exporter]"]
end
노드 일부에만 띄우고 싶다면 spec.template.spec.nodeSelector나 taint/toleration을 활용해서 조건을 걸 수 있다.
네 가지를 한 장에 정리
지금까지 본 네 컨트롤러의 차이를 표로 정리하면 감이 더 선명해진다.
| 컨트롤러 | 관리 대상 | 파드 정체성 | 스토리지 | 대표 용도 |
|---|---|---|---|---|
| ReplicaSet | N개 복제본 유지 | 익명 | 공유 또는 없음 | Deployment가 대신 씀 |
| Deployment | ReplicaSet + 롤링 업데이트 | 익명 | 공유 또는 없음 | 상태 없는 앱(API, 웹) |
| StatefulSet | 순서 있는 파드 집합 | 고유 이름 | 파드별 독립 볼륨 | DB, 분산 저장소 |
| DaemonSet | 노드별 1개 파드 | 노드 기준 | 노드 로컬 | 로그/모니터링 에이전트 |
이외에도 Job(일회성 배치), CronJob(주기적 배치) 같은 컨트롤러가 있지만, 입문 단계에서는 위 네 가지부터 확실히 이해하는 게 좋다.
선택의 기준
실무에서 컨트롤러를 고를 때의 질문은 대략 이 순서로 흐른다.
- 모든 노드에 파드가 필요한가? → DaemonSet
- 파드마다 고유한 정체성과 전용 스토리지가 필요한가? → StatefulSet
- 일회성 작업인가? → Job / CronJob
- 나머지 대부분 → Deployment
대부분의 애플리케이션은 4번에 해당한다. 상태는 외부 DB로 밀어내고, 파드는 상태 없는 처리 유닛으로 유지하며, Deployment로 관리하는 게 Kubernetes 친화적인 기본기다.
다음 편에서는 파드들을 외부에서, 그리고 서로가 찾을 수 있게 해주는 Service와 네트워킹을 들여다본다. 왜 파드 IP를 직접 쓰면 안 되는지, ClusterIP/NodePort/LoadBalancer가 각자 무엇을 푸는지 정리한다.




Loading comments...