K8s StatefulSet 완벽 가이드

StatefulSet이란? Deployment와의 핵심 차이

StatefulSet은 Kubernetes에서 상태가 있는(Stateful) 워크로드를 관리하는 컨트롤러다. Deployment가 모든 Pod를 동일하게 취급하는 반면, StatefulSet은 각 Pod에 고유한 이름, 고정 네트워크 ID, 전용 스토리지를 부여한다. 데이터베이스, 메시지 큐, 분산 캐시처럼 각 인스턴스가 자신만의 데이터와 정체성을 유지해야 하는 워크로드에 필수적이다.

이 글에서는 StatefulSet의 동작 원리, Headless Service, PersistentVolumeClaim 템플릿, 순서 보장, 업데이트 전략, 그리고 실무 운영 패턴까지 깊이 있게 다룬다.

StatefulSet의 3가지 보장

StatefulSet이 Deployment와 구별되는 핵심 보장은 다음 세 가지다:

보장 Deployment StatefulSet
Pod 이름 랜덤 해시 (app-7b9f4d) 순서 인덱스 (app-0, app-1, app-2)
네트워크 ID 매번 변경 고정 DNS (app-0.svc.cluster.local)
스토리지 공유 또는 임시 Pod별 전용 PVC (재스케줄링 시에도 유지)
생성/삭제 순서 병렬 순차 (0→1→2 생성, 2→1→0 삭제)

기본 StatefulSet 매니페스트

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  namespace: database
spec:
  serviceName: postgres-headless   # Headless Service 이름 (필수)
  replicas: 3
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: postgres
          image: postgres:16-alpine
          ports:
            - containerPort: 5432
              name: tcp-postgres
          env:
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: password
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
          resources:
            requests:
              cpu: 500m
              memory: 1Gi
            limits:
              cpu: "2"
              memory: 4Gi
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "postgres"]
            initialDelaySeconds: 10
            periodSeconds: 5
          livenessProbe:
            exec:
              command: ["pg_isready", "-U", "postgres"]
            initialDelaySeconds: 30
            periodSeconds: 10
  volumeClaimTemplates:           # Pod별 자동 PVC 생성
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: fast-ssd
        resources:
          requests:
            storage: 50Gi

volumeClaimTemplates가 핵심이다. StatefulSet은 각 Pod마다 별도의 PVC를 자동 생성한다: data-postgres-0, data-postgres-1, data-postgres-2. Pod가 삭제되고 재생성되어도 동일한 PVC에 다시 바인딩되므로 데이터가 보존된다.

Headless Service: 고정 DNS 엔드포인트

StatefulSet은 반드시 Headless Service(clusterIP: None)와 함께 사용한다. 일반 Service가 로드밸런싱된 단일 IP를 제공하는 반면, Headless Service는 각 Pod의 개별 DNS 레코드를 생성한다.

apiVersion: v1
kind: Service
metadata:
  name: postgres-headless
  namespace: database
spec:
  clusterIP: None          # Headless!
  selector:
    app: postgres
  ports:
    - port: 5432
      targetPort: tcp-postgres
      name: tcp-postgres

---
# 클라이언트용 일반 Service (읽기 로드밸런싱)
apiVersion: v1
kind: Service
metadata:
  name: postgres-read
  namespace: database
spec:
  selector:
    app: postgres
  ports:
    - port: 5432
      targetPort: tcp-postgres

생성되는 DNS 레코드:

# 개별 Pod DNS (Headless Service 경유)
postgres-0.postgres-headless.database.svc.cluster.local
postgres-1.postgres-headless.database.svc.cluster.local
postgres-2.postgres-headless.database.svc.cluster.local

# Primary 연결 (특정 Pod 지정)
jdbc:postgresql://postgres-0.postgres-headless.database.svc.cluster.local:5432/mydb

# 읽기 로드밸런싱 (일반 Service 경유)
jdbc:postgresql://postgres-read.database.svc.cluster.local:5432/mydb

이 패턴이 중요한 이유: DB 복제(Replication)에서 Primary는 postgres-0에 고정하고, 나머지를 Replica로 운영할 수 있다. 애플리케이션은 쓰기는 postgres-0으로, 읽기는 postgres-read Service로 분산한다.

Pod 관리 정책: OrderedReady vs Parallel

기본값인 OrderedReady는 Pod를 순서대로 생성하고 역순으로 삭제한다. 하지만 모든 워크로드에 순서 보장이 필요한 것은 아니다.

spec:
  podManagementPolicy: Parallel  # 모든 Pod 동시 생성/삭제
  # podManagementPolicy: OrderedReady  # 기본값: 순차 생성
정책 동작 적합 사례
OrderedReady 0→1→2 순차, 이전 Pod Ready 후 다음 생성 DB Primary/Replica, ZooKeeper
Parallel 모든 Pod 동시 생성/삭제 Elasticsearch, Cassandra (피어 대등)

업데이트 전략: RollingUpdate와 Partition

StatefulSet의 롤링 업데이트는 역순(높은 인덱스→낮은 인덱스)으로 진행된다. partition 필드를 활용하면 카나리 배포를 구현할 수 있다.

spec:
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      partition: 2          # 인덱스 >= 2인 Pod만 업데이트
      maxUnavailable: 1     # K8s 1.24+: 동시 업데이트 Pod 수

Partition 기반 카나리 배포 워크플로우:

# 1단계: partition=2 → postgres-2만 새 버전으로 업데이트
kubectl patch statefulset postgres -n database 
  -p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":2}}}}'
kubectl set image statefulset/postgres postgres=postgres:17-alpine -n database

# 2단계: postgres-2 정상 확인 후 partition=1
kubectl patch statefulset postgres -n database 
  -p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":1}}}}'

# 3단계: 전체 롤아웃 (partition=0)
kubectl patch statefulset postgres -n database 
  -p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":0}}}}'

# 롤백: 이미지만 되돌리면 partition 이상 Pod가 롤백됨
kubectl set image statefulset/postgres postgres=postgres:16-alpine -n database

스케일링: PVC 생명주기 주의사항

StatefulSet을 스케일다운하면 Pod는 삭제되지만 PVC는 자동 삭제되지 않는다. 이는 데이터 보호를 위한 의도적 설계다.

# 3 → 2로 스케일다운
kubectl scale statefulset postgres --replicas=2 -n database
# postgres-2 Pod 삭제됨, BUT data-postgres-2 PVC는 남아 있음

# 다시 3으로 스케일업하면 기존 PVC에 재바인딩
kubectl scale statefulset postgres --replicas=3 -n database
# postgres-2가 data-postgres-2 PVC를 다시 사용

# PVC 수동 정리 (데이터 삭제 확인 후!)
kubectl delete pvc data-postgres-2 -n database

# K8s 1.27+: PVC 자동 삭제 정책
spec:
  persistentVolumeClaimRetentionPolicy:
    whenDeleted: Delete    # StatefulSet 삭제 시 PVC도 삭제
    whenScaled: Retain     # 스케일다운 시 PVC 유지 (기본값)

Init Container로 클러스터 부트스트랩

DB 클러스터 구성 시 Pod 인덱스에 따라 역할을 분기하는 것이 일반적이다. Init Container에서 hostname 명령으로 인덱스를 추출한다.

spec:
  template:
    spec:
      initContainers:
        - name: init-config
          image: busybox:1.36
          command:
            - sh
            - -c
            - |
              # Pod 인덱스 추출: postgres-0 → 0
              ORDINAL=$(hostname | grep -o '[0-9]*$')

              if [ "$ORDINAL" -eq 0 ]; then
                echo "primary" > /config/role
                cat /etc/postgres/primary.conf > /config/postgresql.conf
              else
                echo "replica" > /config/role
                cat /etc/postgres/replica.conf > /config/postgresql.conf
                # Primary DNS로 replication 설정
                echo "primary_conninfo = 'host=postgres-0.postgres-headless port=5432'" 
                  >> /config/postgresql.conf
              fi

              echo "server_id = $((ORDINAL + 1))" >> /config/postgresql.conf
          volumeMounts:
            - name: config
              mountPath: /config
            - name: config-templates
              mountPath: /etc/postgres
      containers:
        - name: postgres
          volumeMounts:
            - name: config
              mountPath: /etc/postgresql/conf.d

실전 예시: Redis Cluster StatefulSet

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis-cluster
spec:
  serviceName: redis-cluster-headless
  replicas: 6          # 3 master + 3 replica
  podManagementPolicy: Parallel
  selector:
    matchLabels:
      app: redis-cluster
  template:
    metadata:
      labels:
        app: redis-cluster
    spec:
      containers:
        - name: redis
          image: redis:7-alpine
          ports:
            - containerPort: 6379
              name: client
            - containerPort: 16379
              name: gossip
          command:
            - redis-server
            - /etc/redis/redis.conf
            - --cluster-enabled
            - "yes"
            - --cluster-config-file
            - /data/nodes.conf
            - --cluster-node-timeout
            - "5000"
          volumeMounts:
            - name: data
              mountPath: /data
            - name: config
              mountPath: /etc/redis
          resources:
            requests:
              cpu: 250m
              memory: 512Mi
            limits:
              cpu: "1"
              memory: 2Gi
          readinessProbe:
            tcpSocket:
              port: client
            initialDelaySeconds: 5
            periodSeconds: 5
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: fast-ssd
        resources:
          requests:
            storage: 20Gi

---
# 클러스터 초기화 Job
apiVersion: batch/v1
kind: Job
metadata:
  name: redis-cluster-init
spec:
  template:
    spec:
      restartPolicy: OnFailure
      containers:
        - name: init
          image: redis:7-alpine
          command:
            - sh
            - -c
            - |
              sleep 30  # 모든 Pod Ready 대기
              HOSTS=""
              for i in $(seq 0 5); do
                HOSTS="$HOSTS redis-cluster-$i.redis-cluster-headless:6379"
              done
              redis-cli --cluster create $HOSTS 
                --cluster-replicas 1 --cluster-yes

PodDisruptionBudget: 안전한 노드 유지보수

StatefulSet 워크로드는 데이터 정합성이 중요하므로, 노드 드레인 시 동시에 너무 많은 Pod가 종료되지 않도록 PDB를 반드시 설정한다.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: postgres-pdb
  namespace: database
spec:
  minAvailable: 2          # 최소 2개 Pod 유지
  # 또는: maxUnavailable: 1  # 최대 1개만 동시 종료
  selector:
    matchLabels:
      app: postgres

운영 체크리스트

  • StorageClass 확인: volumeBindingMode: WaitForFirstConsumer로 설정해야 Pod가 스케줄된 노드의 Zone에서 PV가 프로비저닝된다. 그렇지 않으면 다른 Zone의 PV에 바인딩되어 Pod가 스케줄링 실패한다
  • Topology 분산: Pod Topology Spread Constraints로 Pod를 여러 Zone/노드에 분산 배치한다
  • 백업: PVC 스냅샷(VolumeSnapshot)을 주기적으로 생성한다. StatefulSet 삭제가 PVC를 자동 삭제하지 않더라도, 실수 대비는 필수다
  • Graceful Shutdown: terminationGracePeriodSeconds를 충분히 설정하여 DB가 안전하게 종료할 시간을 확보한다
  • 리소스 설정: Resource requests/limits를 정확히 설정하여 QoS를 Guaranteed로 유지한다. DB Pod가 Burstable이면 OOMKill 위험이 있다
  • PVC 용량 확장: allowVolumeExpansion: true인 StorageClass를 사용하면 PVC 용량을 온라인으로 확장할 수 있다

StatefulSet은 Kubernetes에서 상태를 관리하는 핵심 컨트롤러다. Headless Service로 고정 DNS를 확보하고, volumeClaimTemplates로 Pod별 전용 스토리지를 보장하며, Partition 기반 카나리 배포로 안전하게 업데이트할 수 있다. 단, 스토리지 생명주기와 순서 보장 특성을 정확히 이해하지 않으면 데이터 유실이나 스케줄링 실패로 이어질 수 있으므로, 운영 전 충분한 테스트가 필수다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux