Kubernetes의 StatefulSet과 ReplicaSet의 차이점은 무엇일까?
MySQL을 직접 배포하고 실습해보며 알아보자
기본 개념
요즘 논스 제네시스 1호점 코워킹에서 지내고 있는데 (주: 스타트업 커뮤니티 논스에서 장학생을 모집하고 있으니 살펴보시면 좋겠다) 플레이스테이션이 있어 (고석현 대표님 감사합니다.) 논스 친구들과 함께 피파와 같은 게임을 즐기고 있다. 플레이스테이션에서 빠져서는 안되는 것이 바로 컨트롤러이다. 컨트롤러가 없으면 게임을 플레이할 수 없다.
마찬가지로 쿠버네티스에서도 컨트롤러가 중요하다. 우리가 일반적으로 Sets라고 말하는 것들이 바로 쿠버네티스에서의 오브젝트 단위이고 컨트롤러이다. 컨트롤러의 임무는 Pod와 Service 같은 리소스들의 현재 상태를 모니터링하고 우리가 원하는 특정한 상태로 상시 유지될 수 있도록 하는 것이다. 각각의 컨트롤러는 특정한 쿠버네티스 리소스를 관리한다. 컨트롤러는 쿠버네티스 API를 활용하여 특정 리소스의 상태를 모니터링하고 필요할 경우 리소스를 스케일링하거나, 업데이트하거나, 삭제하는 행위를 한다.
쿠버네티스에서 가장 최소 단위는 바로 파드 단위다. 컨테이너는 파드 내부에서 동작시키는데, 만약 앱을 파드 안에서 돌리고 있는데 대용량 처리가 필요하다고 하면, 하나의 싱글 파드로는 핸들하는 것이 불가능하다. 그래서 ReplicaSet이라는 개념이 있는데, 이는 사용자의 애플리케이션을 하나의 파드 단위에 복수의 인스턴스를 띄우도록 함으로써 대용량 처리를 가능하도록 하는 개념이다. 따라서 특정한 리소스에만 트래픽이 몰리도록 하지 않고 인스턴스 내부에서 여러 개의 파드에 적절히 로드 밸런싱이 되도록 구현할 수 있다.
apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: nginx-replicaset
labels:
app: nginx
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80
위의 yaml 파일을 보면, nginx라고 하는 애플리케이션 하나의 단위에서 ReplicaSet을 정의하는 것을 볼 수 있는데 여기서는 3개의 replica가 등장한다. 일반적으로 마이크로서비스에서는 어플리케이션의 stateless를 강조하기 때문에 애플리케이션 단위의 의존성이 사라지게 되어 배포와 확장에 용이하다. ReplicaSet은 여기에서 사용되는 개념이다. 그런데 ReplicaSet을 이용하면 Pod의 숫자가 고정되므로 관리자가 애플리케이션을 안정적으로 배포할 수 있다는 장점은 있지만 해당 애플리케이션을 동적으로 업그레이드하여 배포할 수 있는 경우를 지원하지 못한다.
Deployment 오브젝트는 ReplicaSet과는 유사한 개념이지만 Pod와 Replica에 대해 선언적으로 (declarative) 업데이트를 할 수 있도록 지원한다. 하위에 ReplicaSet을 제어하고, ReplicaSet이 하위의 Pod를 제어하며, Deployment가 ReplicaSet을 제어한다. 특히 Deployment 오브젝트에서는 다양한 배포 전략을 지원하는데, 카나리 배포나 blue-green 배포를 지원한다는 점에서 구버전과 신버전이 있다고 하였을 때 각각의 버전에 대한 Pod를 모두 구성하여 트래픽의 양을 조절하여 테스트를 진행하고 교체하는 등의 무중단 배포를 실현하기에 적합한 오브젝트라고 볼 수 있다.
이와 달리 StatefulSet이라는 개념도 있다. 네트워크의 식별자가 유지되어야 하거나, 배포나 업데이트 또는 스케일링 시에 해당 작업이 순차적으로 진행되어야 한다거나, 아니면 스토리지가 특정 Pod가 삭제되더라도 유지되어야 한다거나 하는 경우에 사용할 수 있다.
StatefulSet과 Deployment와의 주요 차이점을 비교해본다면 다음을 꼽아볼 수 있겠다.
- Service vs Headless Service: Deployment는 Service를 통해서 외부에 노출이 되는데, Service로 요청을 보내면 랜덤하게 (정확히는 라운드-로빈하게) Pod가 선택된다. StatefulSet은 Headless Service를 통해서 외부에 노출이 되고, 각 파드 별로 고유한 DNS를 가지게 되어서 원하는 Pod를 지정하여 Request를 해야 한다. 직접 Service에 Request를 하는 것은 불가능하다. 특히 Headless Service는 Pod의 IP가 생성될 때마다 달라지는 것 떄문에 매번 IP를 찾아서 접속하는 것이 번거로우므로 사용하는 방법이다. 이는 특정 Pod와 직접 통신을 해야 하는 StatefulSet의 경우에 알맞다. service.yaml과 같은 파일에 spec.clusterIP를 None으로 지정하면 Headless를 사용할 수 있다.
- Rollback vs ReplicaSet: Deployment는 내부적으로 ReplicaSet을 생성하여 Pod를 관리하고, rollback한다. Rolling update를 할 때 새로운 ReplicaSet이 생성되게 되고, 기존의 ReplicaSet의 개수는 줄어 새로운 ReplicaSet이 증가하게 된다. StatefulSet은 내부적으로 ReplicaSet을 생성하지 않으며 rollback은 불가능하다.
우리가 쿠버네티스에서 stateful하다고 말하는 것은, 어떠한 관계를 유지하여 애플리케이션의 상태가 안정적으로 된다는 것을 의미한다. 따라서 안정적인 상태가 필요한 데이터베이스 등에 만이 사용된다. 쿠버네티스에서 안정적으로 유지하는 상태에는 파드 이름, 네트워크 신원, 스토리지 관계를 유지한다. 파드가 재실행되어도 호스트네임이 같으며 SRV 레코드가 설정되어 파드마다 고유한 서브도메인을 갖는다.
또한 해당 서브도메인은 동일한 쿠버네티스 service resource headless domain을 가르키게 된다. (네트워크 신원), 파드가 삭제되어도 같은 이름으로 파드가 생성된다. (파드 이름) ReplicaSet은 파드수가 증가하여도 모든 파드가 동일한 PVC(PV)를 바라보지만, statefulset에서는 파드가 증가하면 각자만의 pvc를 하나씩 갖게 된다. 파드가 재부팅되어도 한 번 연결되었던 PVC로 계속 연결되기 때문에 자신만의 상태를 계속 고유하게 유지하여야 하는 경우에 적합하다.
실습 with MySQL
아래 실습은 다음 자료를 참고하였습니다.
[K8S Docs] Run a Replicated Stateful Application : MySQL — 링크 AWS_Workshop
AMAZON EBS CSI DRIVER
- Configuring IAM Policy
Amazon EBS CSI 드라이버는 Amazon EKS 클러스터가 Amazon EBS 볼륨에 대한 수명 주기를 관리할 수 있도록 CSI 인터페이스를 제공한다.
IAM 정책 구성: CSI 드라이버는 Kubernetes 파드들로 배포된다. 이 파드들은 볼륨 생성 및 삭제, EC2 워커 노드에 볼륨 연결과 같은 EBS API 작업을 수행할 권한을 갖는다.
(iam-root-account@myeks:N/A) [root@myeks-bastion-EC2 ebs_statefulset]# aws iam create-policy \
> --region ${AWS_REGION} \
> --policy-name ${EBS_CSI_POLICY_NAME} \
> --policy-document file://${HOME}/environment/ebs_statefulset/ebs-csi-policy.json
{
"Policy": {
"PolicyName": "Amazon_EBS_CSI_Driver",
"PolicyId": "ANPA2LU4OVCKXNB6U6DYO",
"Arn": "arn:aws:iam::712218945685:policy/Amazon_EBS_CSI_Driver",
"Path": "/",
"DefaultVersionId": "v1",
"AttachmentCount": 0,
"PermissionsBoundaryUsageCount": 0,
"IsAttachable": true,
"CreateDate": "2023-10-21T06:32:50+00:00",
"UpdateDate": "2023-10-21T06:32:50+00:00"
}
}
export EBS_CSI_POLICY_ARN=$(aws --region ${AWS_REGION} iam list-policies --query 'Policies[?PolicyName==`'$EBS_CSI_POLICY_NAME'`].Arn' --output text)
(iam-root-account@myeks:N/A) [root@myeks-bastion-EC2 ebs_statefulset]# echo $EBS_CSI_POLICY_ARN
CLUSTER_NAME=$(aws eks list-clusters --region $AWS_REGION --query 'clusters[0]' --output text)
eksctl utils associate-iam-oidc-provider --region=$AWS_REGION --cluster=$CLUSTER_NAME --approve
eksctl create iamserviceaccount --cluster $CLUSTER_NAME --name ebs-csi-controller-irsa --namespace kube-system --attach-policy-arn $EBS_CSI_POLICY_ARN --override-existing-serviceaccounts --approve
Kubernetes 서비스 계정에 IAM 역할을 연결할 수 있다. 해당 서비스 계정을 사용하는 모든 파드의 컨테이너에 AWS 권한을 제공할 수 있다. 이 기능을 사용하면 해당 노드의 파드가 AWS API를 호출할 수 있도록 Amazon EKS 노드 IAM 역할에 확장된 권한을 제공할 필요가 없어진다.
방금 생성한 IAM 정책을 포함하는 IAM 역할을 생성하도록 eksctl에 요청하고, 이를 CSI 드라이버에서 사용될 ebs-csi-controller-irsa라는 Kubernetes 서비스 계정과 연결하게 된다.
Helm 차트로 CSI Driver를 배포한다.
# add the aws-ebs-csi-driver as a helm repo
helm repo add aws-ebs-csi-driver https://kubernetes-sigs.github.io/aws-ebs-csi-driver
# search for the driver
helm search repo aws-ebs-csi-driver
helm upgrade --install aws-ebs-csi-driver \
--version=1.2.4 \
--namespace kube-system \
--set serviceAccount.controller.create=false \
--set serviceAccount.snapshot.create=false \
--set enableVolumeScheduling=true \
--set enableVolumeResizing=true \
--set enableVolumeSnapshot=true \
--set serviceAccount.snapshot.name=ebs-csi-controller-irsa \
--set serviceAccount.controller.name=ebs-csi-controller-irsa \
aws-ebs-csi-driver/aws-ebs-csi-driver
kubectl -n kube-system rollout status deployment ebs-csi-controller
Dynamic Volume Provisioning은 스토리지 볼륨이 특정한 요청에 따라 생성될 수 있도록 돕는데, 이런 동적 프로비저닝이 이루어질 때 어떤 파라미터가 전달되어야 하는지 사전에 StorageClass가 정의되어야 한다.
cat << EoF > ${HOME}/environment/ebs_statefulset/mysql-storageclass.yaml kind: StorageClass apiVersion: storage.k8s.io/v1 metadata: name: mysql-gp2 provisioner: ebs.csi.aws.com # Amazon EBS CSI driver parameters: type: gp2 encrypted: 'true' # EBS volumes will always be encrypted by default volumeBindingMode: WaitForFirstConsumer # EBS volumes are AZ specific reclaimPolicy: Delete mountOptions: - debug EoF
kubectl create -f ${HOME}/environment/ebs_statefulset/mysql-storageclass.yaml
> storageclass.storage.k8s.io/mysql-gp2 created
kubectl describe storageclass mysql-gp2
ConfigMap을 이용하여 Image Artifact와 환경변수를 분리하여 사용할 수 있는데, 먼저 네임스페이스를 생성하고 ConfigMap에서는 master.cnf와 slave.cnf를 저장하고 StatefulSet에서 정의된 Leader와 Follower Pod를 초기화할 때 전달하게 된다. 전자는 데이터 변경의 기록을 팔로워 서버에 전송하기 위한 log-bin과 같은 바이너리 로그 옵션을 설정할 수 있게 되고, 후자는 super-read-only option으로 팔로워 파드를 구성하게 된다.
kubectl create namespace mysql
cd ${HOME}/environment/ebs_statefulset
cat << EoF > ${HOME}/environment/ebs_statefulset/mysql-configmap.yaml apiVersion: v1 kind: ConfigMap metadata: name: mysql-config namespace: mysql labels: app: mysql data: master.cnf: | # Apply this config only on the leader. [mysqld] log-bin slave.cnf: | # Apply this config only on followers. [mysqld] super-read-only EoF
kubectl create -f ${HOME}/environment/ebs_statefulset/mysql-configmap.yaml
> configmap/mysql-config created
이제 Headless Service를 만든다.
cat << EoF > ${HOME}/environment/ebs_statefulset/mysql-services.yaml
# Headless service for stable DNS entries of StatefulSet members.
apiVersion: v1
kind: Service
metadata:
namespace: mysql
name: mysql
labels:
app: mysql
spec:
ports:
- name: mysql
port: 3306
clusterIP: None
selector:
app: mysql
---
# Client service for connecting to any MySQL instance for reads.
# For writes, you must instead connect to the leader: mysql-0.mysql.
apiVersion: v1
kind: Service
metadata:
namespace: mysql
name: mysql-read
labels:
app: mysql
spec:
ports:
- name: mysql
port: 3306
selector:
app: mysql
EoF
```
```
kubectl create -f ${HOME}/environment/ebs_statefulset/mysql-services.yaml
> service/mysql created
> service/mysql-read created
쿠버네티스 서비스는 Pods의 논리적인 집합과 해당 Pod에 접근할 수 있는 정책을 정의할 수 있는데, serviceSpec에 특정한 타입을 지정함으로써 서비스를 노출할 수 있다. 특히 clusterIP에 None을 지정하면 Headless Service를 만들 수 있다.
이제 StatefulSet을 만들 것인데, StatefulSet은 ServiceName (여기서는 mysql이고,headless service로 위에서 만들었다) 와 replica (여기서는 3개의 파드를 배포할 것이다) 그리고 template (pod의 구성) 그리고 volumeClaimTemplates (storageClassName을 바탕으로 한 Pod의 volume) 으로 이루어지게 된다.
cd ${HOME}/environment/ebs_statefulset
wget https://eksworkshop.com/beginner/170_statefulset/statefulset.files/mysql-statefulset.yaml
kubectl apply -f ${HOME}/environment/ebs_statefulset/mysql-statefulset.yaml
StatefulSet이 배포되는 과정을 직접 터미널을 띄워서 눈으로 지켜보자.
kubectl -n mysql rollout status statefulset mysql
kubectl -n mysql get pods -l app=mysql - watch
kubectl -n mysql get pvc -l app=mysql
(iam-root-account@myeks:N/A) [root@myeks-bastion-EC2 ebs_statefulset]# kubectl -n mysql rollout status statefulset mysql
partitioned roll out complete: 2 new pods have been updated...
직접 테스트를 해보자.
kubectl -n mysql run mysql-client --image=mysql:5.7 -i --rm --restart=Never --\
mysql -h mysql-0.mysql <<EOF
CREATE DATABASE test;
CREATE TABLE test.messages (message VARCHAR(250));
INSERT INTO test.messages VALUES ('hello, i am gashida');
EOF
위와 같이 실행하면 서버 ID가 계속 바뀌면서 호출되는 것을 볼 수 있다. 로드 밸런싱이 되는 것이다.
kubectl -n mysql run mysql-client-loop --image=mysql:5.7 -i -t --rm --restart=Never --\
bash -ic "while sleep 1; do mysql -h mysql-read -e 'SELECT @@server_id,NOW()'; done"
한 개의 컨테이너를 unhealthy하게 만들어보자. 곧 이어 컨트롤러가 이를 감지하고 다시 띄우게 되어 서버 ID가 하나만 나오는 경우를 조금의 시간 차이에서만 느낄 것이다.
kubectl -n mysql exec mysql-1 -c mysql -- mv /usr/bin/mysql /usr/bin/mysql.off₩
Pod를 삭제해도 컨트롤러가 정상적으로 복구하는 것을 알 수 있다.
(iam-root-account@myeks:N/A) [root@myeks-bastion-EC2 ~]# kubectl -n mysql delete pod mysql-1
pod "mysql-1" deleted
(iam-root-account@myeks:N/A) [root@myeks-bastion-EC2 ~]#
(iam-root-account@myeks:N/A) [root@myeks-bastion-EC2 ~]# kubectl -n mysql get pod mysql-1 -w
NAME READY STATUS RESTARTS AGE
mysql-1 0/2 Init:0/2 0 5s
mysql-1 0/2 Init:1/2 0 11s
mysql-1 0/2 PodInitializing 0 12s
mysql-1 1/2 Running 0 13s
mysql-1 2/2 Running 0 18s
^C(iam-root-account@myeks:N/A) [root@myeks-bastion-EC2 ~]# kubectl -n mysql scale statefulset mysql --replicas=3
statefulset.apps/mysql scaled
(iam-root-account@myeks:N/A) [root@myeks-bastion-EC2 ~]# kubectl -n mysql rollout status statefulset mysql
Waiting for 1 pods to be ready...
partitioned roll out complete: 3 new pods have been updated...
(iam-root-account@myeks:N/A) [root@myeks-bastion-EC2 ~]# kubectl -n mysql get pods -l app=mysql
NAME READY STATUS RESTARTS AGE
mysql-0 2/2 Running 0 31m
mysql-1 2/2 Running 0 4m7s
mysql-2 2/2 Running 0 2m48s
kubectl -n mysql run mysql-client-loop --image=mysql:5.7 -i -t --rm --restart=Never --\
bash -ic "while sleep 1; do mysql -h mysql-read -e 'SELECT @@server_id,NOW()'; done"
기존에 Replicate 되었던 파드에 대하여서도 SELECT 문으로 조회하면 같은 데이터가 반환된다는 것을 알 수 있다.
kubectl -n mysql run mysql-client --image=mysql:5.7 -i -t --rm --restart=Never --\ mysql -h mysql-2.mysql -e "SELECT * FROM test.messages"
스케일링도 잘 된다. 2대에서 3대로 확장해보자.
(iam-root-account@myeks:N/A) [root@myeks-bastion-EC2 ~]# kubectl -n mysql scale statefulset mysql --replicas=2
statefulset.apps/mysql scaled
(iam-root-account@myeks:N/A) [root@myeks-bastion-EC2 ~]# kubectl -n mysql get pods -l app=mysql
NAME READY STATUS RESTARTS AGE
mysql-0 2/2 Running 0 32m
mysql-1 2/2 Running 0 5m29s
mysql-2 2/2 Terminating 0 4m10s
kubectl -n mysql get pvc -l app=mysql
만약 PVC를 삭제하면 어떻게 될까? PVC를 삭제하면 PV도 삭제된다. 하지만 PVC를 삭제하더라도 PV는 남길 수 있도록 관련 볼륨 정책을 변경할 수 있다.
export pv=$(kubectl -n mysql get pvc data-mysql-2 -o json | jq --raw-output '.spec.volumeName')
(iam-root-account@myeks:N/A) [root@myeks-bastion-EC2 ~]# echo data-mysql-2 PersistentVolume name: ${pv}
data-mysql-2 PersistentVolume name: pvc-3ad4bc57-0320-48cc-93d5-d79e8e7cd7de
kubectl -n mysql patch pv ${pv} -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
>
persistentvolume/pvc-3ad4bc57-0320-48cc-93d5-d79e8e7cd7de patched
(iam-root-account@myeks:N/A) [root@myeks-bastion-EC2 ~]# kubectl get persistentvolume
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
pvc-3ad4bc57-0320-48cc-93d5-d79e8e7cd7de 10Gi RWO Retain Bound mysql/data-mysql-2 mysql-gp2 12m
pvc-40c813e8-9d34-42f5-b3a2-322fcf62f3a5 10Gi RWO Delete Bound mysql/data-mysql-0 mysql-gp2 40m
pvc-9053b1e7-7908-4541-9070-d2281ca1c3a7 1Gi RWO Delete Bound default/www-web-1 gp3 14h
pvc-f3a1ac7c-7986-45e7-9d39-4421f50da036 10Gi RWO Delete Bound mysql/data-mysql-1 mysql-gp2 39m
pvc-f7852077-d41d-47cb-af59-963d4572bbe7 1Gi RWO Delete Bound default/www-web-0 gp3 14h