카테고리 없음

[Kubernetes] 헷갈렸던 StatefulSet 정리

★용호★ 2023. 10. 29. 12:11

이 게시글은 Database Operator In Kubernetes study (=DOIK) 2기에서 스터디한 내용 중 Database Operator를 위해 필수적으로 필요한 StatefulSet에 대한 내용을 공부하며 기록한 내용입니다.

StatefulSet은?

  • StatefulSet은 Pod의 순서와 고유성에 대해 보장
  • 각 Pod에 영구적인 식별자인 고정 ID를 유지하기 때문에 PV를 사용할 경우 Pod가 제거되고 새로운 Pod가 실행되더라도 동일한 PV에 mount할 수 있도록 함
  • N개의 replica가 설정된 StatefulSet은 각 Pod에 0 ~ N-1까지의 정수가 순서대로 할당됨 (순서 인덱싱)
    • 시작 인덱스 값은 .spec.ordinals 값으로 변경 가능하지만 변경할 일은 거의 없을 것으로 예상
  • StatefulSet 사용이 필요한 요건
    • 고유한 네트워크 식별자가 필요한 경우
    • 지속적인 스토리지가 필요한 경우
    • 순차적으로 실행/확장이 필요한 경우
    • 순차적인 롤링 업데이트가 필요한 경우

네트워크 Identity

  • 각 Pod 별로 도메인을 제어하기 위해서는 헤드리스 서비스 사용
    • $(Pod명).$(StatefulSet의 서비스명).$(네임스페이스명).svc.cluster.local

PV 연동

  • replicaSet의 경우 Pod가 증가하더라도 연결된 동일한 PVC를 통해 동일한 볼륨에 mount하는 반면 StatefulSet은 생성되는 Pod마다 각자의 PVC를 갖게됨
    • StatefulSet에서 볼륨 설정에는 Deployment와 달리 volumeClaimTemplate로 설정
    • Elasticsearch나 MySQL과 같이 클러스터에 속한 각 서버가 각자의 스토리지를 가져야하는 경우 적합

Pod 스케일링

  • Pod 배포 시 {0..N-1}의 순서대로 생성
    • 모든 선행 Pod가 Running 및 Ready 상태여야함
  • Pod 삭제 시 {N-1..0}의 순서대로(역순) 종료
    • 모든 후속 Pod가 완전히 종료되어야 함

주의 사항

  • StatefulSet에 보존할 데이터는 퍼스턴트 볼륨과 연동 되어야함
  • StatefulSet이 삭제되거나 스케일다운 되어도 볼륨이 삭제되면 안됨
  • StatefulSet으로 생성된 Pod의 네트워크를 식별할 수 있는 헤드리스 서비스 필요
  • 롤링업데이트 전략을 사용할 경우 Pod management policy(OrderedReady)를 함께 사용하면 복구를 위한 수동 개입이 필요한 파손 상태에 빠질 수 있음
    • minReadySeconds의 설정값에 따라 롤아웃 진행 상황을 확인하는데 사용되는데 기본값이 0
  • StatefulSet의 오브젝트명은 DNS 서브도메인 이름이 되기 때문에 적절한 이름 지정 필요
  • StatefulSet의 Pod는 종료 시에도 데이터를 안전하게 처리해야 하기 때문에 pod.Spec.TerminationGracePeriodSeconds 값을 0으로 설정하면 안됨
  • StatefulSet은 강제로 삭제할 경우를 만들면 안됨
    • StatefulSet 컨트롤러가 멤버 생성, 스케일링, 삭제를 담당하는데 생성 시 Pod 별로 고유한 Identity가 부여되고 StatefulSet 컨트롤러는 각 Identity 당 최대 1개의 Pod가 실행 중인지를 지속적으로 확인
    • 만약 동일한 Identity를 가진 Pod가 여러개 생성될 경우 데이터 손실로 이어질 수 있음 (Split-brain)
    • StatefulSet의 Pod를 강제 삭제(delete 명령 시 —force 옵션 사용)할 경우 Pod가 정상 종료되었다는 kubelet의 확인을 기다리지 않고, apiserver에서 즉시 이름을 해제하는데 이 경우 StatefulSet 컨트롤러가 동일한 Identity를 가진 대체 Pod를 생성할 수 있기 때문에 1개 이상의 Pod가 실행될 수 있는 가능성이 생김

StatefulSet 테스트 해보기

실습 환경은 Amazon EKS 클러스터를 사용 중이며 EBS CSI Driver가 이미 설치되었다는 전제 하에 진행합니다.

먼저 아래 yaml 파일을 적용하여 StatefulSet을 생성합니다.

cat << EOF > statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql
  namespace: default
  labels:
    app: mysql
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mysql
  serviceName: mysql
  template:
    metadata:
      labels: 
        app: mysql
    spec:
      containers:
        - name: mysql
          image: "public.ecr.aws/docker/library/mysql:5.7"
          args: 
            - "--ignore-db-dir=lost+found"
          imagePullPolicy: IfNotPresent
          env:
            - name: MYSQL_ROOT_PASSWORD
              value: root
            - name: MYSQL_DATABASE
              value: demo
          ports:
            - name: mysql
              containerPort: 3306
              protocol: TCP
          volumeMounts:
            - name: data
              mountPath: /var/lib/mysql
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: gp2
        resources:
          requests:
            storage: 30Gi
EOF

kubectl apply -f statefulset.yaml

 

EBS 볼륨이 동적으로 프로비저닝되는 시간이 소요되기 때문에 Pod 실행까지 약간의 시간이 소요됩니다. 이 후 아래 명령으로 mysql Pod에 EBS 볼륨이 마운트 되었는지 확인합니다.

kubectl exec --stdin mysql-0  -- bash -c "df -h"

 

만약 EBS 볼륨은 AZ a에 생성이 되었는데 Pod는 AZ c에 생성되었다면 어떻게 될까요? EBS 볼륨이 AZ에 의존적이기 때문에 c존의 Pod에서는 해당 볼륨에 마운트할 수 없어 오류가 발생합니다. 하지만 StatefulSet으로 EBS 볼륨을 동적 프로비저닝하면 항상 실패하지 않고 잘 마운트 됩니다.

 

그 이유는 먼저 Kubernetes의 스케쥴러에 의해 Pod가 배치될 노드가 결정되는데 Pod를 생성하기 전에 먼저 EBS CSI Driver를 통해 볼륨을 준비하기 때문입니다. 즉, 노드와 동일한 AZ에 EBS 볼륨을 먼저 생성한 후 Pod를 실행하며 마운트 합니다.

 

그럼 이제 퍼시스턴트 볼륨으로 인해 데이터가 삭제되지 않고 보존이 잘 되는지 확인하겠습니다.

# 파일 생성
kubectl exec mysql-0 -- bash -c  "echo 123 > /var/lib/mysql/test.txt"

# 파일 확인
kubectl exec mysql-0 -- ls -larth /var/lib/mysql/ | grep -i test

# Pod 제거
kubectl delete pods mysql-0

# Pod가 제거되면 StatefulSet에 의해 재생성되므로 Ready 상태가 될 때까지 대기 후 Pod 확인
kubectl wait --for=condition=Ready pod \\
  -l app=mysql --timeout=60s
kubectl get pods -l app=mysql

# 재생성 된 후에도 파일이 남아 있는지 확인
kubectl exec mysql-0 -- ls -larth /var/lib/mysql/ | grep -i test
kubectl exec mysql-0 -- cat /var/lib/mysql/test.txt

 

정상 실행에 대해 테스트 해봤으니 이제 본격적으로 궁금했던 부분들에 대해 테스트해보겠습니다.

StatefulSet Pod는 매번 동일한 노드와 PV에 마운트 될까?

EBS 볼륨은 기본적으로 동일 리전의 인스턴스에서만 마운트가 가능한데 별도의 설정 없이도 Pod 재생성시 매번 동일 노드로 마운트가 되는지 확인해보겠습니다.

cat << EOF > run.sh
#!/bin/bash
i=0
while [ "$i" -lt 10 ]
do
	kubectl delete pods mysql-0
	kubectl wait --for=condition=Ready pod -l app=mysql --timeout=60s
  kubectl exec mysql-0 -- ls -larth /var/lib/mysql/ | grep -i test
  kubectl exec mysql-0 -- cat /var/lib/mysql/test.txt
  a=$(expr $a + 1)
done
EOF
sh run.sh

 

Pod는 재생성 될 때 다른 노드에 배치될 수 있고 그 의미는 EBS 볼륨가 다른 존에 생성될 수도 있다는 얘기이기도 합니다. 그럼 마운트가 실패하는 경우도 있을 것 같은데 왜 문제 없이 Pod가 재생성될까요?

 

Deployment로 생성된 Pod의 경우에는 스케쥴러에 의해 마운트 된 PV와 상관없이 최적의 노드를 찾은 후 배치하기 때문에 EBS 볼륨과 다른 AZ의 노드로 배치될 수 있지만 StatefulSet으로 생성된 Pod는 고유한 Identity를 가지며 Scheduler는 가능한 동일 노드에 Pod를 배치하려고 합니다. 하지만 노드 리소스가 부족하거나 노드가 Not Ready 상태인 경우에는 다른 노드에 배치될 수 있습니다.

 

그럼에도 노드에 Pod가 배치 되기 전 먼저 PV가 연결된 후에 Pod가 배치되기 때문에 새로 생성된 노드가 같은 AZ에 있다면 기존의 EBS 볼륨을 그대로 사용하기 위해 detach/attach하는 절차가 수행되어 동일 PV를 사용할 수 있습니다. 물론 AZ가 달라져 버리면 EBS 볼륨의 경우에는 동일 AZ에 대한 제약 때문에 마운트에 실패하겠죠.

 

Scheduler는 사실 우리가 정보를 주지 않으면 PV 마운트를 위해 Pod를 어떤 노드에 배치해야할 지 모릅니다. 만약 다른 AZ에 있는 노드가 선택되지 않도록 강제하고 싶다면 Scheduler가 노드 레이블의 ‘topology.kubernetes.io/zone’ 정보를 참고해서 특정 AZ의 노드에만 Pod를 배치할 수 있도록 설정해야합니다. 즉, nodeSelector나 아래와 같이 nodeAffinity 설정이 필요하다는 것입니다.

spec:
  affinity:
    nodeAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 1
        preference:
          matchExpressions:
          - key: topology.kubernetes.io/zone
            operator: In
            values:
            - ap-northeast-2a

그러면 아래와 같이 PV에도 기존에 없던 nodeAffinity 정보가 추가 됩니다.

VolumeMode:        Filesystem
Capacity:          30Gi
Node Affinity:     
  Required Terms:  
    Term 0:        topology.kubernetes.io/zone in [ap-northeast-2a]
                   topology.kubernetes.io/region in [ap-northeast-2]

StatefulSet Pod를 실행하고 있는 노드가 제거될 경우 Pod는 어떻게 될까? 다른 노드에 실행 시 Zone이 달라진다면?

Pod가 배치될 노드가 없다면 스케쥴러는 해당 Pod가 실행될 수 있는 다른 노드를 선택하게 되는데 어떤 Zone에 배치해야하는지는 알 수 없습니다. 따라서 새로 배치된 노드가 다른 Zone에 배치되었다면 기존의 EBS 볼륨에는 마운트할 수 없습니다.

 

만약 동일 Zone에 다른 노드로 Pod가 배치되는 경우에는 StatefulSet의 Pod가 노드에 배치되기 전에 볼륨이 사용 가능한 상태가 되어야 합니다. EBS는 한 번에 1개의 노드에서만 사용할 수 있으므로 기존의 노드에 attach된 EBS 볼륨을 detach하고 Pod가 새로 실행된 노드로 attach를 하게 되는데 시간이 소요됩니다.

 

이런 위험 요소들을 방지하려면 앞서 설명한 것 처럼 nodeSelector 또는 nodeAffinity 설정으로 Pod가 동일한 Zone에 실행 될 수 있도록 해야합니다.

StatefulSet Pod의 고가용성을 위해 multi-AZ로 구성할 수는 없을까?

일반적으로 StatefulSet Pod와 EBS 볼륨을 특정 AZ에 바인딩하는데 이 방식의 단점은 애플리케이션의 가용성이 높지 않다는 것입니다. AZ에 장애가 발생하면 AZ가 다시 정상화될 때까지 Pod가 보류 상태로 유지됩니다.

 

만약 AZ 장애 시 기존 AZ에서 다른 AZ로 PVC를 자동으로 장애 조치해야 하는데 이를 위한 방법은 EBS 스냅샷에서 생성된 EBS 볼륨에서 PVC를 수동으로 생성하는 것입니다.

 

자동으로 고가용성을 달성하려면 세개의 AZ에 대해 각각의 StatefulSet를 생성할 수 있습니다. 하지만 이 경우에는 MySQL의 read replica와 같이 복제본 데이터의 경우에는 문제가 없겠지만 Pod 마다 고유한 데이터를 보관한다면 장애난 AZ의 Pod가 복구 될 때까지 해당 데이터를 사용하지 못한다는 위험이 있습니다.

 

AZ 장애에도 자동으로 가용성을 유지하기 위해 EBS 대신 EFS를 사용하는 방법도 고려해볼 수 있습니다. EFS는 여러 AZ에 걸쳐서 고가용성을 제공하기 때문에 데이터 유실에 대한 걱정은 덜 수 있지만 비용이 더 비싸고, 인스턴스와 같은 AZ 내에서 고속의 성능을 제공하는 EBS와 달리 EFS는 고가용성을 위해 다른 AZ에 있는 스토리지로 접근 해야할 수 있기 때문에 성능에 문제가 있을 수 있습니다.

StatefulSet의 컨테이너 이미지 업데이트 시 순단이 발생할 수 있을까?

# 새 터미널에서 아래 명령으로 watch
watch -n1 "kubectl get pods"

# 기존 터미널에서 아래 명령으로 이미지 업데이트
kubectl patch statefulset mysql --type='json' -p='[{"op": "replace", "path": "/spec/template/spec/containers/0/image", "value":"public.ecr.aws/docker/library/mysql:5.6"}]'

 

실행해보면 기존 Pod를 Terminate 시키고, 완전히 제거될 때까지 대기 한 후 새로운 버전으로 Pod를 재생성합니다. 이 때 새로운 이미지이므로 pull 받는 시간과 Pod가 정상 실행될 때까지의 시간 동안은 사용 불가능 상태이므로 순단이 발생할 수 있습니다.

 

아래 내용을 참고하여 사전에 준비가 필요합니다.

  • 시간 단축을 위해 각 노드에 대상 컨테이너 이미지를 미리 pull 받아놓기
    • 미리 업데이트 할 이미지로 DaemonSet을 생성하여 사전에 모든 노드에 미리 이미지 pulling
  • 장애 방지를 위해 한개의 Pod가 종료되어도 클러스터 전체에 영향이 가지 않도록 가용성 확보
    • 예를들어 MySQL의 경우 read replica의 수를 증설 후 업데이트, master의 경우 single Pod로 운영되기 때문에 클라이언트 측에서 retry 로직 구현

참고