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한 속성을 가지는 어플리케이션을 운용하는 것은 흔히 권장되지 않는 패턴이지만, 그럼에도 그러한 요구사항을 충족시킬 수 있는 오브젝트가 바로 스풀이다.
기능
파드의 고유성과 순서를 보장하는 것이 스테이트풀셋의 기본 기능.
디플로이먼트가 워크로드 중에선 정석이라 할 수 있는데 이 놈은 확실히 다르게 동작한다.
그래서 다음의 요구사항이 있는 파드, 어플리케이션에 대해 사용한다.
- 안정적이고, 고유한 네트워크 식별자.
- 안정적이고, 영속적인 스토리지
- 순서 있고, 안정적인 배포와 스케일링
- 순서 있는 자동 버전 업데이트
여기에서 안정적이라는 것은 파드 스케줄링으로부터 영속성을 가진다는 것을 말한다.
아무리 새로운 파드가 재실행돼도 해당 파드는 이전 파드와 같은 놈이어야 한다.
그래서 스테이트풀셋에서 관리하는 파드는 클러스터에서 단 하나뿐인 파드라는 것이 보장되며, 이를 보장하기 위해 스테이트풀셋이 존재하는 것이다.
디플로이먼트와의 비교
이게 무슨 말인가, 디플로이먼트과 비교하며 어떤 특성을 가지는지 조금 더 자세히 보자.
한 파드가 꺼져서 개수를 맞추기 위해 하나의 파드가 새로 실행돼야 하는 상황이다.
- 디플로이먼트
- 디플로이먼트의 레플리카셋은 별개의 해시값을 가진 파드를 새로 생성한다.
- 스테이트풀셋
- 스테이트풀셋은 완전히 이름이 같은 파드를 생성한다.
- 엄밀하게 모든 파드는 고유하기에 같다고 볼 수는 없지만, 완전히 같은 환경과 기능을 수행하는 파드가 된다.
주의사항
딱 봐도 특별한 과제를 수행하는 오브젝트이고, 그래서 스테이트풀셋을 만들기 위해서는 다음의 제한이 걸린다.
- 파드가 할당받는 스토리지는 반드시 StorageClass되거나, 관리자에 의해 미리 프로비저닝된 자원이어야 한다.
- 그러니까 pv 자체는 이미 만들어져 있어야 한다는 거니까, hostpath는 안 되나?
- 스풀과 연결된 볼륨은 스풀이 스케일링되거나 삭제된다고 같이 삭제되지 않고 유지된다.
- 여기에 트래픽을 연결하는 Service는 반드시 headless해야 하며, 직접 만들어야 한다.
- 이건 아래에 [[#네트워크 ID]]에서 본다.
- 스풀이 삭제된다고 관리되는 파드의 삭제 순서가 보장되진 않는다.
- 만약 파드 삭제 순서를 보장하고 싶다면, 직접 0으로 스케일링해야 한다.
- 업데이트 전략이 배포 전략#Rolling일 때,
OrderedReady
상태로 이뤄진다.- 이전 파드가 완료되지 않으면 다음 파드는 생성 안 된 채로 멈춘다..
- 직접 핸들링해야 할 정도로 심각한 상태로 남아있을 수도 있다!
파드의 고유성
고유성이라 함은, 각 파드마다 동일한 네트워크, 동일한 스토리지가 연결된다는 것을 말한다.
재스케줄이 된다고 한들 파드는 무조건 똑같은 자원을 받는 것이 보장받는다는 것이 바로 스풀의 핵심이다.
그럼 본격적으로 어떤 것들이 고유하다는 것인지 살펴보자.
이름, 번호 라벨
고유성을 만족하기 위해 각 파드는 고유한 번호를 부여받으며, 이것이 순서와 연결된다.
가령 첫번째로 만들어진 파드는 0이란 번호가 붙고, 이후의 파드들은 1,2,3... 이런 식으로 번호가 붙게 된다.
이런 식으로 스풀이 파드를 관리하면서 자연스럽게 각 파드의 라벨에도 apps.kubernetes/pod-index
로 해당 번호를 부여한다.
또한 이 자체가 이름이 되게 되는데, {스테이트풀셋-이름}-{부여받는 번호}
이런 방식으로 이름지어진다.
가령 web이란 이름을 가진 스테이트풀셋의 첫번째 파드는 web-0
이 될 것이다.
라벨로도, 이름으로도 이 정보들은 명시된다.
왜 굳이 인덱스까지 라벨로 만들어지나 싶을 수도 있을 것이다.
사실은 나도 굳이? 싶은데 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의 레플리카 수를 지정한 스테이트풀셋이 있다고 쳐보겠다.
- 파드가 여러 개 만들어져야 하는 상황
- 파드는 만들어지면서 0번부터 N-1까지 번호를 부여받으며, 순서대로 만들어진다.
- 각 파드가 만들어지기 전, 이전 파드의 Ready 상태가 보장된다.
- 파드가 지워져야 하는 상황
- 가장 마지막으로 만들어진 N-1 파드부터 0번 파드까지 역순으로 종료된다.
- 파드가 종료되기 전, 이전 파드가 완벽히 종료된 상태가 보장된다.
만들어지고 지워지는 모든 과정이 순서가 보장되며, 항상 이전의 파드가 완벽하게 제어되는 것을 전제로 다음 파드에 대한 동작이 행해진다.
왜 이렇게까지 엄격하냐? 항상 제대로 모든 파드의 상태를 체크하고자 하는 것이다.
가령 스케일 인을 하는 상황에서 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
이게 기본적인 방식으로, 스케일링되듯이 업데이트가 자동으로 진행된다.
파드는 끝에서부터 종료되고, 종료된 파드부터 새롭게 재생성된다.
특이한 기능 중 하나는 파티션을 나누는 것이다.
.spec.updateStrategy.rollingUpdate.partition
를 지정하면 지정된 개수들에 한해서만 새 버전으로 업데이트할 수 있다.
가령 이 값을 2로 지정하면, 5개의 레플리카가 있을 때 0,1번째를 제외한 파드들만 새 버전이 된다.
0,1번째 파드는 삭제되어 재생성되더라도 이전 버전으로 업데이트될 것이다.
배포 전략#Canary를 하고 싶을 때 유용한 방식이라고 할 수 있으나, 잘 쓰이지는 않는다고 한다.
여기에 .spec.updateStrategy.rollingUpdate.maxUnavailable
이라는 것도 있는데, 이것은 kube-apiserver에 MaxUnavailableStatefulSet
feature gate가 활성화돼야 한다.
업데이트 진행 간 이용 불가능한 파드의 최대치를 퍼센티지나 개수로 지정할 수 있다.
기본값은 1로 설정되어 있는데, [[#podManagementPolicy]]가 Parallel로 지정된 게 아니면 어차피 다른 값이어도 큰 의미는 없다.
persistentVolumeClaimRetentionPolicy
원래 스테이트풀셋은 파드가 지워지더라도, 스테이트풀셋 자체가 제거되더라도 pvc를 항상 남기도록(유보되도록) 동작했다.
그러나 Kubernetes v1.32 - Penelope 기준으로 stable이 된 기능으로 pvc 삭제 정책을 지정하는 방법이 있다.
whenDeleted
는 스테이트풀셋이 제거될 때 pvc를 어떻게 할 것인지 지정한다.
whenScaled
는 스케일링이 일어나서 파드가 줄어들 때 pvc를 어떻게 할지 지정한다.
내 생각에는, 스테이트풀셋이 제거될 때는 웬만해서 같이 pvc도 제거되도록 하는 게 관리자의 의도 상 적합할 것 같다.
이 정책은 스테이트풀셋이 제거되거나 스케일링 될 때만 동작한다는 것을 유의하자.
즉 노드의 실패라던가 하는 다른 이슈로 인한 파드의 변화에 대해서 이 정책은 동작하지 않도록 되어있다.
스테이트풀셋 컨트롤러는 pvc에 ownerReferences
를 부여하여 가비지 컬렉션에 걸리도록 만든다.
whenDeleted가 Delete면 컨트롤러는 소유자를 스테이트풀셋으로 지정하여 자신이 삭제되면 함께 삭제되게 만든다.
whenScaled가 Delete면 컨트롤러는 스케일링으로 줄어들어야 하는 pvc에 대해 소유자를 같이 지워질 파드로 지정한다.
또 하나, 이러한 방식이기 때문에 만약 클러스터 내에 컨트롤러가 다운되는 일이 발생한다면 이 정책들은 제대로 동작하지 않을 수 있다.
그러니 컨트롤러가 제 기능을 할 때까지 함부로 수동 삭제를 하지 않는 것이 좋다.
어차피 잘 설정되어 있다면 알아서 소유자 지정을 통해 삭제되도록 만들 것이기 때문이다.
관련 문서
이름 | noteType | created |
---|---|---|
T-스테이트풀셋과 연결되는 스토리지에 대한 실험 | topic/idea | 2024-12-27 |
T-스테이트풀셋과 연결되는 헤드리스 서비스에 관한 실험 | topic/temp | 2024-12-27 |