<p data-ke-size="size16">이 게시글은 <span data-token-index="0">D</span>atabase <span data-token-index="2">O</span>perator <span data-token-index="4">I</span>n <span data-token-index="6">K</span>ubernetes study (=<span data-token-index="8">DOIK</span>) 2기에서 스터디한 내용 중 Database Operator를 위해 필수적으로 필요한 StatefulSet에 대한 내용을 공부하며 기록한 내용입니다.</p>
<h2 data-ke-size="size26">StatefulSet은?</h2>
<ul style="list-style-type: disc;" data-ke-list-type="disc">
<li>StatefulSet은 Pod의 순서와 고유성에 대해 보장</li>
<li>각 Pod에 영구적인 식별자인 고정 ID를 유지하기 때문에 PV를 사용할 경우 Pod가 제거되고 새로운 Pod가 실행되더라도 동일한 PV에 mount할 수 있도록 함</li>
<li>N개의 replica가 설정된 StatefulSet은 각 Pod에 0 ~ N-1까지의 정수가 순서대로 할당됨 (순서 인덱싱)
<ul style="list-style-type: disc;" data-ke-list-type="disc">
<li>시작 인덱스 값은 .spec.ordinals 값으로 변경 가능하지만 변경할 일은 거의 없을 것으로 예상</li>
</ul>
</li>
<li>StatefulSet 사용이 필요한 요건
<ul style="list-style-type: disc;" data-ke-list-type="disc">
<li>고유한 네트워크 식별자가 필요한 경우</li>
<li>지속적인 스토리지가 필요한 경우</li>
<li>순차적으로 실행/확장이 필요한 경우</li>
<li>순차적인 롤링 업데이트가 필요한 경우</li>
</ul>
</li>
</ul>
<h3 data-ke-size="size23">네트워크 Identity</h3>
<ul style="list-style-type: disc;" data-ke-list-type="disc">
<li>각 Pod 별로 도메인을 제어하기 위해서는 헤드리스 서비스 사용
<ul style="list-style-type: disc;" data-ke-list-type="disc">
<li>$(Pod명).$(StatefulSet의 서비스명).$(네임스페이스명).svc.cluster.local</li>
</ul>
</li>
</ul>
<h3 data-ke-size="size23">PV 연동</h3>
<ul style="list-style-type: disc;" data-ke-list-type="disc">
<li>replicaSet의 경우 Pod가 증가하더라도 연결된 동일한 PVC를 통해 동일한 볼륨에 mount하는 반면 StatefulSet은 생성되는 Pod마다 각자의 PVC를 갖게됨
<ul style="list-style-type: disc;" data-ke-list-type="disc">
<li>StatefulSet에서 볼륨 설정에는 Deployment와 달리 volumeClaimTemplate로 설정</li>
<li>Elasticsearch나 MySQL과 같이 클러스터에 속한 각 서버가 각자의 스토리지를 가져야하는 경우 적합</li>
</ul>
</li>
</ul>
<h3 data-ke-size="size23">Pod 스케일링</h3>
<ul style="list-style-type: disc;" data-ke-list-type="disc">
<li>Pod 배포 시 {0..N-1}의 순서대로 생성
<ul style="list-style-type: disc;" data-ke-list-type="disc">
<li>모든 선행 Pod가 Running 및 Ready 상태여야함</li>
</ul>
</li>
<li>Pod 삭제 시 {N-1..0}의 순서대로(역순) 종료
<ul style="list-style-type: disc;" data-ke-list-type="disc">
<li>모든 후속 Pod가 완전히 종료되어야 함</li>
</ul>
</li>
</ul>
<h3 data-ke-size="size23">주의 사항</h3>
<ul style="list-style-type: disc;" data-ke-list-type="disc">
<li>StatefulSet에 보존할 데이터는 퍼스턴트 볼륨과 연동 되어야함</li>
<li>StatefulSet이 삭제되거나 스케일다운 되어도 볼륨이 삭제되면 안됨</li>
<li>StatefulSet으로 생성된 Pod의 네트워크를 식별할 수 있는 헤드리스 서비스 필요</li>
<li>롤링업데이트 전략을 사용할 경우 Pod management policy(OrderedReady)를 함께 사용하면 복구를 위한 수동 개입이 필요한 파손 상태에 빠질 수 있음
<ul style="list-style-type: disc;" data-ke-list-type="disc">
<li>minReadySeconds의 설정값에 따라 롤아웃 진행 상황을 확인하는데 사용되는데 기본값이 0</li>
</ul>
</li>
<li>StatefulSet의 오브젝트명은 DNS 서브도메인 이름이 되기 때문에 적절한 이름 지정 필요</li>
<li>StatefulSet의 Pod는 종료 시에도 데이터를 안전하게 처리해야 하기 때문에 pod.Spec.TerminationGracePeriodSeconds 값을 0으로 설정하면 안됨</li>
<li>StatefulSet은 강제로 삭제할 경우를 만들면 안됨
<ul style="list-style-type: disc;" data-ke-list-type="disc">
<li>StatefulSet 컨트롤러가 멤버 생성, 스케일링, 삭제를 담당하는데 생성 시 Pod 별로 고유한 Identity가 부여되고 StatefulSet 컨트롤러는 각 Identity 당 최대 1개의 Pod가 실행 중인지를 지속적으로 확인</li>
<li>만약 동일한 Identity를 가진 Pod가 여러개 생성될 경우 데이터 손실로 이어질 수 있음 (Split-brain)</li>
<li>StatefulSet의 Pod를 강제 삭제(delete 명령 시 —force 옵션 사용)할 경우 Pod가 정상 종료되었다는 kubelet의 확인을 기다리지 않고, apiserver에서 즉시 이름을 해제하는데 이 경우 StatefulSet 컨트롤러가 동일한 Identity를 가진 대체 Pod를 생성할 수 있기 때문에 1개 이상의 Pod가 실행될 수 있는 가능성이 생김</li>
</ul>
</li>
</ul>
<h2 data-ke-size="size26">StatefulSet 테스트 해보기</h2>
<p data-ke-size="size16">실습 환경은 Amazon EKS 클러스터를 사용 중이며 EBS CSI Driver가 이미 설치되었다는 전제 하에 진행합니다.</p>
<p data-ke-size="size16">먼저 아래 yaml 파일을 적용하여 StatefulSet을 생성합니다.</p>
<pre class="yaml"><code>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
</code></pre>
<p data-ke-size="size16"> </p>
<p data-ke-size="size16">EBS 볼륨이 동적으로 프로비저닝되는 시간이 소요되기 때문에 Pod 실행까지 약간의 시간이 소요됩니다. 이 후 아래 명령으로 mysql Pod에 EBS 볼륨이 마운트 되었는지 확인합니다.</p>
<pre class="cpp"><code>kubectl exec --stdin mysql-0 -- bash -c "df -h"
</code></pre>
<p data-ke-size="size16"> </p>
<p data-ke-size="size16">만약 EBS 볼륨은 AZ a에 생성이 되었는데 Pod는 AZ c에 생성되었다면 어떻게 될까요? EBS 볼륨이 AZ에 의존적이기 때문에 c존의 Pod에서는 해당 볼륨에 마운트할 수 없어 오류가 발생합니다. 하지만 StatefulSet으로 EBS 볼륨을 동적 프로비저닝하면 항상 실패하지 않고 잘 마운트 됩니다.</p>
<p data-ke-size="size16"> </p>
<p data-ke-size="size16">그 이유는 먼저 Kubernetes의 스케쥴러에 의해 Pod가 배치될 노드가 결정되는데 Pod를 생성하기 전에 먼저 EBS CSI Driver를 통해 볼륨을 준비하기 때문입니다. 즉, 노드와 동일한 AZ에 EBS 볼륨을 먼저 생성한 후 Pod를 실행하며 마운트 합니다.</p>
<p data-ke-size="size16"> </p>
<p data-ke-size="size16">그럼 이제 퍼시스턴트 볼륨으로 인해 데이터가 삭제되지 않고 보존이 잘 되는지 확인하겠습니다.</p>
<pre class="crystal"><code># 파일 생성
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
</code></pre>
<p data-ke-size="size16"> </p>
<p data-ke-size="size16">정상 실행에 대해 테스트 해봤으니 이제 본격적으로 궁금했던 부분들에 대해 테스트해보겠습니다.</p>
<h3 data-ke-size="size23">StatefulSet Pod는 매번 동일한 노드와 PV에 마운트 될까?</h3>
<p data-ke-size="size16">EBS 볼륨은 기본적으로 동일 리전의 인스턴스에서만 마운트가 가능한데 별도의 설정 없이도 Pod 재생성시 매번 동일 노드로 마운트가 되는지 확인해보겠습니다.</p>
<pre class="crystal"><code>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
</code></pre>
<p data-ke-size="size16"> </p>
<p data-ke-size="size16">Pod는 재생성 될 때 다른 노드에 배치될 수 있고 그 의미는 EBS 볼륨가 다른 존에 생성될 수도 있다는 얘기이기도 합니다. 그럼 마운트가 실패하는 경우도 있을 것 같은데 왜 문제 없이 Pod가 재생성될까요?</p>
<p data-ke-size="size16"> </p>
<p data-ke-size="size16">Deployment로 생성된 Pod의 경우에는 스케쥴러에 의해 마운트 된 PV와 상관없이 최적의 노드를 찾은 후 배치하기 때문에 EBS 볼륨과 다른 AZ의 노드로 배치될 수 있지만 StatefulSet으로 생성된 Pod는 고유한 Identity를 가지며 Scheduler는 가능한 동일 노드에 Pod를 배치하려고 합니다. 하지만 노드 리소스가 부족하거나 노드가 Not Ready 상태인 경우에는 다른 노드에 배치될 수 있습니다.</p>
<p data-ke-size="size16"> </p>
<p data-ke-size="size16">그럼에도 노드에 Pod가 배치 되기 전 먼저 PV가 연결된 후에 Pod가 배치되기 때문에 새로 생성된 노드가 같은 AZ에 있다면 기존의 EBS 볼륨을 그대로 사용하기 위해 detach/attach하는 절차가 수행되어 동일 PV를 사용할 수 있습니다. 물론 AZ가 달라져 버리면 EBS 볼륨의 경우에는 동일 AZ에 대한 제약 때문에 마운트에 실패하겠죠.</p>
<p data-ke-size="size16"> </p>
<p data-ke-size="size16">Scheduler는 사실 우리가 정보를 주지 않으면 PV 마운트를 위해 Pod를 어떤 노드에 배치해야할 지 모릅니다. 만약 다른 AZ에 있는 노드가 선택되지 않도록 강제하고 싶다면 Scheduler가 노드 레이블의 ‘<b>topology.kubernetes.io/zone</b>’ 정보를 참고해서 특정 AZ의 노드에만 Pod를 배치할 수 있도록 설정해야합니다. 즉, nodeSelector나 아래와 같이 <a href="https://kubernetes.io/docs/tasks/configure-pod-container/assign-pods-nodes-using-node-affinity/">nodeAffinity</a> 설정이 필요하다는 것입니다.</p>
<pre class="yaml"><code>spec:
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 1
preference:
matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values:
- ap-northeast-2a
</code></pre>
<p data-ke-size="size16">그러면 아래와 같이 PV에도 기존에 없던 nodeAffinity 정보가 추가 됩니다.</p>
<pre class="bash" data-ke-language="bash"><code>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]</code></pre>
<h3 data-ke-size="size23">StatefulSet Pod를 실행하고 있는 노드가 제거될 경우 Pod는 어떻게 될까? 다른 노드에 실행 시 Zone이 달라진다면?</h3>
<p data-ke-size="size16">Pod가 배치될 노드가 없다면 스케쥴러는 해당 Pod가 실행될 수 있는 다른 노드를 선택하게 되는데 어떤 Zone에 배치해야하는지는 알 수 없습니다. 따라서 새로 배치된 노드가 다른 Zone에 배치되었다면 기존의 EBS 볼륨에는 마운트할 수 없습니다.</p>
<p data-ke-size="size16"> </p>
<p data-ke-size="size16">만약 동일 Zone에 다른 노드로 Pod가 배치되는 경우에는 StatefulSet의 Pod가 노드에 배치되기 전에 볼륨이 사용 가능한 상태가 되어야 합니다. EBS는 한 번에 1개의 노드에서만 사용할 수 있으므로 기존의 노드에 attach된 EBS 볼륨을 detach하고 Pod가 새로 실행된 노드로 attach를 하게 되는데 시간이 소요됩니다.</p>
<p data-ke-size="size16"> </p>
<p data-ke-size="size16">이런 위험 요소들을 방지하려면 앞서 설명한 것 처럼 nodeSelector 또는 nodeAffinity 설정으로 Pod가 동일한 Zone에 실행 될 수 있도록 해야합니다.</p>
<h3 data-ke-size="size23">StatefulSet Pod의 고가용성을 위해 multi-AZ로 구성할 수는 없을까?</h3>
<p data-ke-size="size16">일반적으로 StatefulSet Pod와 EBS 볼륨을 특정 AZ에 바인딩하는데 이 방식의 단점은 애플리케이션의 가용성이 높지 않다는 것입니다. AZ에 장애가 발생하면 AZ가 다시 정상화될 때까지 Pod가 보류 상태로 유지됩니다.</p>
<p data-ke-size="size16"> </p>
<p data-ke-size="size16">만약 AZ 장애 시 기존 AZ에서 다른 AZ로 PVC를 자동으로 장애 조치해야 하는데 이를 위한 방법은 EBS 스냅샷에서 생성된 EBS 볼륨에서 PVC를 수동으로 생성하는 것입니다.</p>
<p data-ke-size="size16"> </p>
<p data-ke-size="size16">자동으로 고가용성을 달성하려면 세개의 AZ에 대해 각각의 StatefulSet를 생성할 수 있습니다. 하지만 이 경우에는 MySQL의 read replica와 같이 복제본 데이터의 경우에는 문제가 없겠지만 Pod 마다 고유한 데이터를 보관한다면 장애난 AZ의 Pod가 복구 될 때까지 해당 데이터를 사용하지 못한다는 위험이 있습니다.</p>
<p data-ke-size="size16"> </p>
<p data-ke-size="size16">AZ 장애에도 자동으로 가용성을 유지하기 위해 EBS 대신 EFS를 사용하는 방법도 고려해볼 수 있습니다. EFS는 여러 AZ에 걸쳐서 고가용성을 제공하기 때문에 데이터 유실에 대한 걱정은 덜 수 있지만 비용이 더 비싸고, 인스턴스와 같은 AZ 내에서 고속의 성능을 제공하는 EBS와 달리 EFS는 고가용성을 위해 다른 AZ에 있는 스토리지로 접근 해야할 수 있기 때문에 성능에 문제가 있을 수 있습니다.</p>
<h3 data-ke-size="size23">StatefulSet의 컨테이너 이미지 업데이트 시 순단이 발생할 수 있을까?</h3>
<pre class="jboss-cli"><code># 새 터미널에서 아래 명령으로 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"}]'
</code></pre>
<p data-ke-size="size16"> </p>
<p data-ke-size="size16">실행해보면 기존 Pod를 Terminate 시키고, 완전히 제거될 때까지 대기 한 후 새로운 버전으로 Pod를 재생성합니다. 이 때 새로운 이미지이므로 pull 받는 시간과 Pod가 정상 실행될 때까지의 시간 동안은 사용 불가능 상태이므로 순단이 발생할 수 있습니다.</p>
<p data-ke-size="size16"> </p>
<p data-ke-size="size16">아래 내용을 참고하여 사전에 준비가 필요합니다.</p>
<ul style="list-style-type: disc;" data-ke-list-type="disc">
<li>시간 단축을 위해 각 노드에 대상 컨테이너 이미지를 미리 pull 받아놓기
<ul style="list-style-type: disc;" data-ke-list-type="disc">
<li>미리 업데이트 할 이미지로 DaemonSet을 생성하여 사전에 모든 노드에 미리 이미지 pulling</li>
</ul>
</li>
<li>장애 방지를 위해 한개의 Pod가 종료되어도 클러스터 전체에 영향이 가지 않도록 가용성 확보
<ul style="list-style-type: disc;" data-ke-list-type="disc">
<li>예를들어 MySQL의 경우 read replica의 수를 증설 후 업데이트, master의 경우 single Pod로 운영되기 때문에 클라이언트 측에서 retry 로직 구현</li>
</ul>
</li>
</ul>
<h2 data-ke-size="size26">참고</h2>
<ul style="list-style-type: disc;" data-ke-list-type="disc">
<li><a href="https://kubernetes.io/ko/docs/concepts/workloads/controllers/statefulset/#스테이트풀셋-사용">https://kubernetes.io/ko/docs/concepts/workloads/controllers/statefulset/#스테이트풀셋-사용</a></li>
<li><a href="https://bcho.tistory.com/1306">https://bcho.tistory.com/1306</a></li>
<li><a href="https://malwareanalysis.tistory.com/598">https://malwareanalysis.tistory.com/598</a></li>
<li><a href="https://zerobig-k8s.tistory.com/18">https://zerobig-k8s.tistory.com/18</a></li>
<li><a href="https://malwareanalysis.tistory.com/338">https://malwareanalysis.tistory.com/338</a></li>
<li><a href="https://aws.amazon.com/ko/blogs/containers/scaling-kubernetes-with-karpenter-advanced-scheduling-with-pod-affinity-and-volume-topology-awareness/">https://aws.amazon.com/ko/blogs/containers/scaling-kubernetes-with-karpenter-advanced-scheduling-with-pod-affinity-and-volume-topology-awareness/</a></li>
</ul>
↧