Skip to content
ioob.dev
Go back

Kubernetes 입문 4편 — 컨트롤러

· 8분 읽기
Kubernetes 시리즈 (4/12)
  1. Kubernetes 입문 1편 — Kubernetes란
  2. Kubernetes 입문 2편 — 클러스터 구조
  3. Kubernetes 입문 3편 — Pod
  4. Kubernetes 입문 4편 — 컨트롤러
  5. Kubernetes 입문 5편 — 서비스와 네트워킹
  6. Kubernetes 입문 6편 — Ingress와 Gateway API
  7. Kubernetes 입문 7편 — ConfigMap과 Secret
  8. Kubernetes 입문 8편 — 스토리지: PV, PVC, StorageClass
  9. Kubernetes 입문 9편 — 리소스 관리와 오토스케일링
  10. Kubernetes 입문 10편 — RBAC과 보안: 최소 권한의 원칙
  11. Kubernetes 입문 11편 — 관측성: 로그, 메트릭, 추적
  12. Kubernetes 입문 12편 — Helm과 패키지 관리
Table of contents

Table of contents

파드만으로는 부족하다

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-abcweb-xyz든 아무거나 써도 된다. 그런데 어떤 워크로드는 각 파드가 고유한 정체성을 가져야 한다. 대표적인 게 DB 클러스터다.

MySQL, MongoDB, Kafka, Elasticsearch 같은 시스템은 다음을 요구한다.

이런 요구를 채우는 게 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: 모든 노드에 하나씩

어떤 파드는 “모든 노드에 반드시 하나씩 떠 있어야” 한다. 대표적인 예가 이런 것들이다.

이걸 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을 활용해서 조건을 걸 수 있다.

네 가지를 한 장에 정리

지금까지 본 네 컨트롤러의 차이를 표로 정리하면 감이 더 선명해진다.

컨트롤러관리 대상파드 정체성스토리지대표 용도
ReplicaSetN개 복제본 유지익명공유 또는 없음Deployment가 대신 씀
DeploymentReplicaSet + 롤링 업데이트익명공유 또는 없음상태 없는 앱(API, 웹)
StatefulSet순서 있는 파드 집합고유 이름파드별 독립 볼륨DB, 분산 저장소
DaemonSet노드별 1개 파드노드 기준노드 로컬로그/모니터링 에이전트

이외에도 Job(일회성 배치), CronJob(주기적 배치) 같은 컨트롤러가 있지만, 입문 단계에서는 위 네 가지부터 확실히 이해하는 게 좋다.

선택의 기준

실무에서 컨트롤러를 고를 때의 질문은 대략 이 순서로 흐른다.

  1. 모든 노드에 파드가 필요한가? → DaemonSet
  2. 파드마다 고유한 정체성과 전용 스토리지가 필요한가? → StatefulSet
  3. 일회성 작업인가? → Job / CronJob
  4. 나머지 대부분 → Deployment

대부분의 애플리케이션은 4번에 해당한다. 상태는 외부 DB로 밀어내고, 파드는 상태 없는 처리 유닛으로 유지하며, Deployment로 관리하는 게 Kubernetes 친화적인 기본기다.


다음 편에서는 파드들을 외부에서, 그리고 서로가 찾을 수 있게 해주는 Service와 네트워킹을 들여다본다. 왜 파드 IP를 직접 쓰면 안 되는지, ClusterIP/NodePort/LoadBalancer가 각자 무엇을 푸는지 정리한다.

5편: 서비스와 네트워킹


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Kubernetes 입문 3편 — Pod
Next Post
Kubernetes 입문 5편 — 서비스와 네트워킹