StatefulSet

개요

A StatefulSet runs a group of Pods, and maintains a sticky identity for each of those Pods. This is useful for managing applications that need persistent storage or a stable, unique network identity.[1]

스테이트풀셋은 스틱키(세션 유지 등)한 고유성을 유지하며 파드 그룹을 관리한다.
이는 안정적, 고유적인 네트워크 동일성나 영속 스토리지가 필요한 어플리케이션을 관리할 때 유용하다.

쿠버네티스에서 stateful한 속성을 가지는 어플리케이션을 운용하는 것은 흔히 권장되지 않는 패턴이지만, 그럼에도 그러한 요구사항을 충족시킬 수 있는 오브젝트가 바로 스풀이다.

기능

파드고유성과 순서를 보장하는 것이 스테이트풀셋의 기본 기능.
디플로이먼트워크로드 중에선 정석이라 할 수 있는데 이 놈은 확실히 다르게 동작한다.

그래서 다음의 요구사항이 있는 파드, 어플리케이션에 대해 사용한다.

여기에서 안정적이라는 것은 파드 스케줄링으로부터 영속성을 가진다는 것을 말한다.
아무리 새로운 파드가 재실행돼도 해당 파드는 이전 파드와 같은 놈이어야 한다.
그래서 스테이트풀셋에서 관리하는 파드는 클러스터에서 단 하나뿐인 파드라는 것이 보장되며, 이를 보장하기 위해 스테이트풀셋이 존재하는 것이다.

디플로이먼트와의 비교

이게 무슨 말인가, 디플로이먼트과 비교하며 어떤 특성을 가지는지 조금 더 자세히 보자.
한 파드가 꺼져서 개수를 맞추기 위해 하나의 파드가 새로 실행돼야 하는 상황이다.

주의사항

딱 봐도 특별한 과제를 수행하는 오브젝트이고, 그래서 스테이트풀셋을 만들기 위해서는 다음의 제한이 걸린다.

파드의 고유성

고유성이라 함은, 각 파드마다 동일한 네트워크, 동일한 스토리지가 연결된다는 것을 말한다.
재스케줄이 된다고 한들 파드는 무조건 똑같은 자원을 받는 것이 보장받는다는 것이 바로 스풀의 핵심이다.

그럼 본격적으로 어떤 것들이 고유하다는 것인지 살펴보자.

이름, 번호 라벨

고유성을 만족하기 위해 각 파드는 고유한 번호를 부여받으며, 이것이 순서와 연결된다.
가령 첫번째로 만들어진 파드는 0이란 번호가 붙고, 이후의 파드들은 1,2,3... 이런 식으로 번호가 붙게 된다.

이런 식으로 스풀이 파드를 관리하면서 자연스럽게 각 파드의 라벨에도 apps.kubernetes/pod-index로 해당 번호를 부여한다.
또한 이 자체가 이름이 되게 되는데, {스테이트풀셋-이름}-{부여받는 번호} 이런 방식으로 이름지어진다.
가령 web이란 이름을 가진 스테이트풀셋의 첫번째 파드는 web-0이 될 것이다.
라벨로도, 이름으로도 이 정보들은 명시된다.

pod-index 라벨?

왜 굳이 인덱스까지 라벨로 만들어지나 싶을 수도 있을 것이다.
사실은 나도 굳이? 싶은데 Kubernetes v1.32 - Penelope 기준으로 이것은 완전히 디폴트로 박혀버려서 수정할 수 없다.
이 라벨을 통해 개별 파드를 필터링하고 로깅할 때 유용하다고 한다.

네트워크 ID

파드들이 고유한 이름을 가진다는데, 그럼 고유하다는 이 파드들은 네트워크 상에서는 어떻게 고유할 수 있는가?
생성과 삭제가 잦은 클러스터에서, 파드들은 전부 생성 시점에 ip를 부여받고 이것은 항상 임시적이라고 할 수 있다.
그래서 사용하는 것이 바로 DNS 아니겠나!

Core DNS의 기본 방식에 따라 이런 식으로 ip를 활용하여 파드에 고유하게 부여되는 도메인 이름을 이용할 수는 있다.
그런데 당연히 문제는, 이 IP들이 전혀 고유하지 않고 새로 만들 때마다 새로 생긴다는 것이다.
그래서 그냥 이것은 위에서 말한 고유한 네트워크 식별자로서 활용할 수 없다.

경고

이 내용은 잘못 됐을 수도 있다. 추후에 headless service를 조금 더 자세히 탐구하고 정리하도록 하겠다.

그래서 사용하는 것이 바로 Service#헤드리스 서비스인 것이다.
헤드리스 서비스는 기본적으로 {연결된-파드-이름(호스트네임)}.{서비스-이름}.{네임스페이스}.svc.cluster.local로 자신에 연결된 파드들 도메인 네임을 만들어준다.
일반적인 서비스였다면 {서비스-이름}.{네임스페이스}.svc.cluster.local로 트래픽을 노출할 텐데 파드들의 고유한 이름을 만들 수는 없었을 것이다.
그래서 고유한 네트워크 ID를 위해 headless service를 사용하는 것이다.

헤드리스 서비스는 어떻게 연결된 파드 이름을 서브도메인으로 붙이나?

그럼 이 서비스가 도메인을 붙여줄 때 무엇을 기준으로 삼는가?
뭐.. 우리야 당연히 파드 이름을 사용한다는 것을 아는데 서비스는 이름을 붙일 때 해당 파드의 hostname을 이용한다.

T-스테이트풀셋과 연결되는 헤드리스 서비스에 관한 실험을 보면 알 수 있듯이, 서비스가 연결될 당시에 subdomain이 지정되어 있으면 hostname을 기반으로 서브도메인이 생성된다.
hostname은 스테이트풀셋 컨트롤러가 파드를 생성할 때 statefulset.kubernetes.io/pod-name이란 라벨을 붙여주기에 인식된다.
아무래도 라벨을 확인하는 작업은 캐싱, 인덱싱을 통해 리소스 소모가 상대적으로 적기 때문에 이런 식으로 하는 것 같다.
또한 개별 파드에 서비스를 연결하고자 할 때 이 라벨을 이용하면 되게 만들어둔 목적도 있다.

참고로 dns를 사용하기 때문에 클러스터 dns 방식이 쿼리 성능과 속도에 영향을 준다.
가령 제대로 파드가 올라왔지만 이전에 실패했던 dns 캐시가 된 채로 있어서 계속 실패했다고 뜨는 경우도 있긴 하다는 것.

스토리지

아래 PVC 템플릿을 만들어야 한다.
이렇게 하면 템플릿에 의해 만들어진 고유한 파드들은 고유한 pvc를 할당받게 된다.
그리고 이 pvc는 파드가 삭제되더라도 사라지지 않고 고이고이 남겨져있다가, 파드가 재스케줄됐을 때 다시 사용될 것이다.
설령 web-0에 해당하는 파드가 모종의 문제로 실패하게 되어 다시 파드를 만들어야 한다면, 그 파드는 web-0이라는 이름으로 이전에 연결되었는 pvc에 그대로 연결이 되게 된다.
스토리지의 고유성은 이렇게 충족된다.

여기에서도 몇가지 의문이 생겨서 T-스테이트풀셋과 연결되는 스토리지에 대한 실험을 진행한다.

위에서 말했지만, 기본적으로는 이 pvc는 스테이트풀셋을 지워도 사라지지 않고 계속 남아있어서 직접 지워야 한다;;
그러나!! 아래 [[#persistentVolumeClaimRetentionPolicy]]에서 보는 방법이 생겨서 조금은 편하게 바뀌었다고 할 수 있겠다.

스케일링

이제 이 스테이트풀셋이 어떻게 스케일링되는지 다음의 상황들을 보자.
N의 레플리카 수를 지정한 스테이트풀셋이 있다고 쳐보겠다.

만들어지고 지워지는 모든 과정이 순서가 보장되며, 항상 이전의 파드가 완벽하게 제어되는 것을 전제로 다음 파드에 대한 동작이 행해진다.
왜 이렇게까지 엄격하냐? 항상 제대로 모든 파드의 상태를 체크하고자 하는 것이다.
가령 스케일 인을 하는 상황에서 web-2가 종료되고 web-1을 종료하려는 도중에 web-0이 고장나서 재시작되고 있다면, web-1은 명확하게 web-0이 재시작되기 전까지 종료를 멈추고 기다린다.
이러한 엄격성 덕분에 이런 부분이 꼼꼼하게 체크될 수 있다는 것이다.

스풀에서의 파드는 TerminationGracePeriodSeconds을 0으로 지정하지 않는 것이 강력하게 권장된다.[2]
이게 0이면 kubelet은 무작정 컨테이너에 SIGTERM을 날리며, 컨테이너의 생존을 확인하지 않은 채 kube-apiserver에 파드가 종료됐다고 보고해버린다.
api서버가 이걸 Etcd에 반영해버리면 컨트롤러는 자연스레 같은 고유한 파드를 만드려고 하게 되는데, 이 순간 스테이트풀셋을 통해 보장받았던 고유성이 깨져버릴 수도 있다.
같은 파드가 두 개 존재해버리게 되는 것이다..!
force하게 종료를 시켜야 할 상황 중에 노드에 접근 불가능하여 파드의 종료가 제대로 안 되는 상황도 존재한다.
이럴 때조차 force보다는 노드 오브젝트를 제거하거나, 회복된 후 kubelet이 알아서 파드 제거 상태를 보고하도록 하는 것이 낫다.

양식 작성법

어떤 식으로 만들 수 있는가 슬슬 궁금해진다.

apiVersion: v1
kind: Service
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  ports:
  - port: 80
    name: web
  clusterIP: None # clusterIP가 None인 해당 서비스는 Headless라고 부릅니다!
  selector:
    app: nginx
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: web
spec:
  selector:
    matchLabels:
      app: nginx # has to match .spec.template.metadata.labels
  serviceName: "nginx"
  replicas: 3 # by default is 1
  minReadySeconds: 10 # by default is 0
  template:
    metadata:
      labels:
        app: nginx # has to match .spec.selector.matchLabels
    spec:
      terminationGracePeriodSeconds: 10
      containers:
      - name: nginx
        image: registry.k8s.io/nginx-slim:0.24
        ports:
        - containerPort: 80
          name: web
        volumeMounts:
        - name: www
          mountPath: /usr/share/nginx/html
  volumeClaimTemplates:
  - metadata:
      name: www
    spec:
      accessModes: [ "ReadWriteOnce" ]
      storageClassName: "my-storage-class"
      resources:
        requests:
          storage: 1Gi

이게 문서에서 기본으로 제공되는 예시이다.
간단하게 설명해보자면, 일단 headless 서비스를 만들었다.
그리고 스테이트풀셋을 만드는데, 이때 my-storage-class에 볼륨 클레임을 걸었다.
(만약 전체적인 윤곽이 안 잡힌다면 디플로이먼트 양식을 먼저 보도록 한다.)
볼륨 클레임을, 스테이트풀셋 필드에서 바로 거는데 이게 조금 낯설다.
보통은 동적 프로비저너를 사용하고 pvc가 알아서 만들어지게 하거나, pv-pvc를 직접 만든 후에 워크로드 양식에 명시하는 방식으로 진행하기 때문이다.
여기에, 중간에 serviceName을 직접 지정하여 사용할 헤드리스 서비스를 지정하는 모습까지 보인다.

볼륨 액세스 모드

예시에서는 ReadWriteOnce를 사용했으나, 실제 용례를 따지자면 ReadWriteOncePod로 거는 게 더 바람직하다.


나는 E-NFS 볼륨, 스토리지 클래스 설정을 통해 설정한 내 스토리지 클래스로 바꿔서 실행했다.

각 파드에 대한 pvc가 형성됐고, 이름도 딱 정해진 대로만 만들어진다.

여기에서 디플로이먼트와 다른 것들을 위주로 필드를 정리하고자 한다.

volumeClaimTemplates

스풀은 여기에서 PersistentVolume 양식을 직접 작성한다.
이때의 pvc는 동적 프로비저닝이 되도록, 스토리지 클래스를 통해 연결돼야 한다.
만약 pv에 연결하고 싶다면, 해당 pv도 스토리지 클래스를 통해 만들어지는 것이 강력하게 권장된다. ??

serviceName

여기에서 사용하고 싶은 헤드리스 서비스 이름을 지정해준다.
이를 통해 우리는 명시적으로 사용할 서비스 이름과, 파드들의 도메인 이름을 확정할 수 있다.

각 파드에는 이렇게 서브도메인이 명시되게 된다.

ordinals

여기에서부터는 거의 선택적인 필드이다.

파드에 부여되는 번호를 여기에서 커스텀할 수 있다.
가령 spec.ordinals.start를 2로 지정하면 2부터 번호가 시작될 것이다.

podManagementPolicy

[[#스케일링]]에서 보듯이 스테이트풀셋은 순서를 엄격하게 보장하지만, 이것을 사용자의 희망에 따라 해소시킬 수는 있다.
파드 관리 정책은 기본적으로 OrderedReady지만, Parallel로 두게 되면 스케일링 간의 순서는 지키지만 그렇다고 이전 파드의 작업과 병행되게 스케일링이 진행된다.

updateStrategy

워크로드인 만큼, 스테이트풀셋 역시 버전 업데이트를 하는 것이 가능하나, 이것은 디플로이먼트의 strategy와는 많이 다르다.
업데이트 간 유의할 점이 하나 있다.
스케일링 간 순서가 엄격하게 보장되었듯이, 업데이트에서도 파드 관리 정책을 Parallel로 두지 않는 이상 순서가 보장되게 된다.
달리 말하자면 괜히 잘못 업데이트 걸어서 파드가 실행되지 않는 상태를 만들어버리게 되면 수동으로 이걸 해소하지 않는 이상 롤아웃은 진행되지 않는다.
원래대로 원상복구시키려면 잘못된 업데이트가 되려고 한 모든 파드를 삭제하고 재생성되게 만들어야 한다는 것이다..

일단 OnDelete 타입이 있는데, 이 경우 양식을 어떻게 바꾸더라도 모든 업데이트를 수동으로 진행해야 한다.
즉, 양식을 바꿔 적용을 했다면, 이후에는 기존 파드를 직접 삭제해야만 재생성되면서 업데이트된 양식이 적용된다.

RollingUpdate

Pasted image 20241227134912.png
이게 기본적인 방식으로, 스케일링되듯이 업데이트가 자동으로 진행된다.
파드는 끝에서부터 종료되고, 종료된 파드부터 새롭게 재생성된다.

특이한 기능 중 하나는 파티션을 나누는 것이다.
.spec.updateStrategy.rollingUpdate.partition를 지정하면 지정된 개수들에 한해서만 새 버전으로 업데이트할 수 있다.
가령 이 값을 2로 지정하면, 5개의 레플리카가 있을 때 0,1번째를 제외한 파드들만 새 버전이 된다.
0,1번째 파드는 삭제되어 재생성되더라도 이전 버전으로 업데이트될 것이다.
배포 전략#Canary를 하고 싶을 때 유용한 방식이라고 할 수 있으나, 잘 쓰이지는 않는다고 한다.

여기에 .spec.updateStrategy.rollingUpdate.maxUnavailable이라는 것도 있는데, 이것은 kube-apiserverMaxUnavailableStatefulSet feature gate가 활성화돼야 한다.
업데이트 진행 간 이용 불가능한 파드의 최대치를 퍼센티지나 개수로 지정할 수 있다.
기본값은 1로 설정되어 있는데, [[#podManagementPolicy]]가 Parallel로 지정된 게 아니면 어차피 다른 값이어도 큰 의미는 없다.

persistentVolumeClaimRetentionPolicy

원래 스테이트풀셋은 파드가 지워지더라도, 스테이트풀셋 자체가 제거되더라도 pvc를 항상 남기도록(유보되도록) 동작했다.
그러나 Kubernetes v1.32 - Penelope 기준으로 stable이 된 기능으로 pvc 삭제 정책을 지정하는 방법이 있다.
Pasted image 20241227142204.png
whenDeleted는 스테이트풀셋이 제거될 때 pvc를 어떻게 할 것인지 지정한다.
whenScaled는 스케일링이 일어나서 파드가 줄어들 때 pvc를 어떻게 할지 지정한다.
내 생각에는, 스테이트풀셋이 제거될 때는 웬만해서 같이 pvc도 제거되도록 하는 게 관리자의 의도 상 적합할 것 같다.

유의점 - pvc 제거 방식

이 정책은 스테이트풀셋이 제거되거나 스케일링 될 때만 동작한다는 것을 유의하자.
즉 노드의 실패라던가 하는 다른 이슈로 인한 파드의 변화에 대해서 이 정책은 동작하지 않도록 되어있다.
스테이트풀셋 컨트롤러는 pvc에 ownerReferences를 부여하여 가비지 컬렉션에 걸리도록 만든다.
whenDeleted가 Delete면 컨트롤러는 소유자를 스테이트풀셋으로 지정하여 자신이 삭제되면 함께 삭제되게 만든다.
whenScaled가 Delete면 컨트롤러는 스케일링으로 줄어들어야 하는 pvc에 대해 소유자를 같이 지워질 파드로 지정한다.

또 하나, 이러한 방식이기 때문에 만약 클러스터 내에 컨트롤러가 다운되는 일이 발생한다면 이 정책들은 제대로 동작하지 않을 수 있다.
그러니 컨트롤러가 제 기능을 할 때까지 함부로 수동 삭제를 하지 않는 것이 좋다.
어차피 잘 설정되어 있다면 알아서 소유자 지정을 통해 삭제되도록 만들 것이기 때문이다.

관련 문서

이름 noteType created
T-스테이트풀셋과 연결되는 스토리지에 대한 실험 topic/idea 2024-12-27
T-스테이트풀셋과 연결되는 헤드리스 서비스에 관한 실험 topic/temp 2024-12-27

참고


  1. https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/ ↩︎

  2. https://kubernetes.io/docs/tasks/run-application/force-delete-stateful-set-pod/ ↩︎