쿠버네티스를 사용하면 어플리케이션의 버전 업 배포를 쉽게 할 수 있다.
디플로이먼트에서 의도하는 상태 를 설명하고, 디플로이먼트 컨트롤러(Controller)는 현재 상태에서 의도하는 상태로 비율을 조정하며 변경한다. 새 레플리카셋을 생성하는 디플로이먼트를 정의하거나 기존 디플로이먼트를 제거하고, 모든 리소스를 새 디플로이먼트에 적용할 수 있다.
디플로이먼트는 새로운 버전을 배포할 수 있게 해주는 리소스이다. 쿠버네티스에서 가장 기본적으로 파드를 만드는 방법이 바로 디플로이먼트를 사용한 방법이다. 레플리카셋은 새로운 버전을 배포하고 싶어도 배포할 수 없다. 단순히 레플리카셋은 템플릿에 있는 것을 복제본의 갯수만 맞춰주기 때문이다. 레플리카셋을 이용하여 버전 업을 하려면 이전 버전의 파드를 일일히 삭제해 줘야 한다.
디플로이먼트는 strategy 필드 외에는 레플리카셋과 비슷한 부분이 많다. 어떻게 보면 레플리카셋의 확장판이라고 볼 수도 있다. 디플로이먼트라는 컨트롤러를 만들면 디플로이먼트가 레플리카셋을 만들고, 레플리카셋이 파드를 만드는 구조이다.
디플로이먼트의 strategy는 말 그대로 배포 전략을 뜻한다. 디플로이에서 새로운 버전의 이미지를 적용하려면 새로운 버전의 레플리카셋을 만들고 파드를 다시 생성한다. 이 때 레플리카셋을 두 개 볼 수 있는데, 이전 버전과 새로운 버전의 파드들이 순차적으로 하나씩 이동을 하게 된다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp-deploy
labels:
app: myapp-deploy
spec:
strategy:
type: RollingUpdate #배포 전략
rollingUpdate: #
maxUnavailable: 1 #기본값 25%
maxSurge: 1 #기본값 25%
minReadySeconds: 20
replicas: 3
selector:
matchLabels:
app: myapp-deploy
template:
metadata:
labels:
app: myapp-deploy
spec:
containers:
- image: ghcr.io/c1t1d0s7/go-myweb:v1.0 #1.0 버전
name: myapp #어떤 이미지를 바꿀 것인지 꼭 선언해줘야 한다.
ports:
- containerPort: 8080
한꺼번에 하나를 지울 수 있다.
복제본이 레플리카셋의 갯수에서 순간적으로 maxSurge에 나온 수 만큼 추가적으로 생성될 수 있다. 이 경우에는, 순간적으로 4개까지 가능하다는 뜻이다.
V1: 3 - 3 - 2 - 1 - 0
V2: 0 - 0 - 1 - 2 - 3
kubectl replace -f myapp-deploy #yaml 파일 수정
kubectl apply -f myapp-deploy #deploy 교체
kubectl edit deploy myapp-deploy #대화식 작업
kubectl patch deploy myapp-deploy -p "{JSON}" #JSON형식으로 수정, 잘안씀
kubectl set image <TYPE> <NAME> <CONTAINER_NAME>=<NEW-IMAGE> #이미지만 변경
kubectl rollout undo deployment myapp-deploy --to-revision 2 #특정 이전 버전으로 돌아가기
디플로이먼트에서 kubernetes.io/change-cause annotation을 사용하면바꾼 기록이 된다. 새롭게 레플리카셋을 만드는 것이 아니라 현재 레플리카셋의 개체 수를 조절하는 것이다.
쿠버네티스에서는 Recreate와 Ramped(RollingUpdate)를 지원한다.
디플로이먼트의 새로운 버전이 업데이트되면 이전 버전의 레플리카 셋을 삭제하고 새로운 버전을 가지고 있는 레플리카셋을 생성한다. 이 방식의 단점은 다운 타임이 생긴다는 것이다. 이전 버전에 로그인하고 있는 사용자가 있을 경우, 사용자들은 세션이 끊겨 버린다. 따라서, 이 방식은 별로 사용되지 않는다. Recreate 방식은 계획된 다운타임인 경우에만 사용할 수 있다.
세팅이 쉽고, 새로운 완전한 상태를 배포하기 쉽지만 사용자에게 높은 영향을 미친다.
디플로이먼트 배포 전략의 기본값이다. 순차적으로 업데이트한다는 뜻이다. 무중단 시스템에서 대부분의 배포 전략은 롤링 업데이트이다. 업데이트된 버전의 파드 수를 서서히 늘여나가 기존 버전을 대체한다. 하지만 이 경우 기존 버전의 사용자와 새로운 버전을 사용하는 사용자가 혼재될 수 있으므로 이 때 문제가 되지 않도록 해야 한다.
세팅이 쉽고, 새로운 버전을 배포할 때 까지 시간이 오래 걸릴 수도 있다. API의 기능이 달라지는 경우 대응이 어려워질 수 있다.
새로운 버전을 미리 만들어 놓은 후 기존 버전을 삭제하는 즉시 새로운 버전으로 트래픽을 전송시킨다. 이 방법을 사용하면 다운타임이 거의 없거나 아예 없을 수도 있다. 하지만 순간적으로나마 리소스가 2배로 늘어나므로 가격도 2배가 든다. 쿠버네티스 환경에서는 그다지 문제가 아닐 수도 있지만, 물리적인 서버 환경에서는 구현하기가 매우 어렵다.
요즘 개발 환경에서 가장 선호하는 배포 방법이다. 기존 버전과 새로운 버전을 모두 생성해 놓고, 새로운 버전에서 오류가 생길 것을 방지하는 차원에서 일부 트래픽만 새로운 버전으로 보낸다. 결과적으로 아무런 문제가 없다는 것이 확인되면, 모든 사용자의 트래픽을 새로운 버전으로 보낸다. 쿠버네티스 자체로는 Canary를 구현하는 것이 불가능하다. 하지만 Istio 내에 envoy가 내장되어 있어 Istio에서 Canary 배포 등이 가능하다. Istio는 마이크로서비스 매시 환경을 구축해 준다.
쿠버네티스만으로 Canary를 구현하기 위해선 새로운 버전의 디플로이먼트를 새로 만들어 레플리카셋 파드를 하나만 설정한 후 레이블을 같게 설정하면 된다. 그러면 서비스가 파드를 선택하는데, 이것을 설정하고자 하는 비율만큼 설정하면 된다.
A 그룹과 B 그룹을 나누어 테스트한다. Canary 배포와 비슷해 보이지만 Canary는 불특정 다수를 테스트하는 것이고, A/B Testing은 PC 사용자, 모바일 사용자 등 특정 그룹으로 나누어 테스트한다. 브라우저 쿠키, 쿼리 파라미터, 위치, 언어, 웹브라우저 버전, 스크린 사이즈 등의 여러 그룹으로 나눌 수 있다.
스테이트풀셋은 레플리카셋, 디플로이먼트와 같이 복제본을 제공한다. 하지만 상태를 가지고 있는 어플리케이션에 특화되어 있다. 웹, 앱과 같이 상태가 없는 어플리케이션은 주로 디플로이먼트로 배포하고, 상태를 가지고 있는, DB같은 어플리케이션은 스테이트풀셋으로 배포한다.
상태는 결국 별도의 볼륨에 저장하는 것이 기본이다. 레플리카셋은 아무리 파드를 늘려도 같은 PVC를 바라보게 된다. 모든 파드는 동일한 정보(템플릿)을 가지기 때문이다. PVC가 같으면 PV가 같고, PV가 같으면 같은 스토리지를 바라보고 있다는 뜻이므로 별도로 고유의 상태를 가지고 있지 않다는 뜻이 된다. 이런 경우, 볼륨을 쓰는 이는 볼륨에서 정보를 가져오기 위한 것이 일반적이다. 웹/앱이 자체적으로 상태를 가지고 있지 않기 때문에 PV를 데이터를 저장하기 위한 목적으로 사용하지 않기 때문이다. 따라서, 상태가 변경되지 않고 항상 똑같다. (Stateless)
스테이트풀셋은 다음 중 하나 또는 이상이 필요한 애플리케이션에 유용하다.
스테이트풀셋을 통해 복제된 파드는 별개로 고유한 상태를 가져야 한다. 이를 위해선 파드가 별도의 스토리지를 따로따로 가져야 한다. 그리고 이 스토리지와 파드는 고유성을 가지고 있기 때문에 서로 교체할 수 없다.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: myapp-sts
spec:
selector:
matchLabels:
app: myapp-sts
serviceName: myapp-svc-headless #서비스의 이름을 지정한다. 헤드리스서비스여야 함
replicas: 2
template:
metadata:
labels:
app: myapp-sts
spec:
containers:
- name: myapp
image: ghcr.io/c1t1d0s7/go-myweb:alpine
ports:
- containerPort: 8080
apiVersion: v1
kind: Service
metadata:
name: myapp-svc-headless
labels:
app: myapp-svc-headless
spec:
ports:
- name: http
port: 80
clusterIP: None #반드시 헤드리스 서비스(clusterIP가 none)이어야 함!!
selector:
app: myapp-sts
앞서 배웠던 것처럼, 헤드리스 서비스를 요청하면 헤드리스 서비스에 연결된 파드의 IP가 나온다. 이것 때문에 스테이트풀셋의 서비스는 무조건 헤드리스 서비스여야 한다. 스테이트풀셋과 헤드리스 서비스를 결합시키면 고유한 파드의 이름으로 통신할 수 있다. 고유한 파드의 이름으로 원하는 상태의 파드를 가져올 수 있도록 구분할 수 있게 된다. 이것은 스테이트풀셋으로 등록했을 때만 가능하다.
여태껏 컨트롤러 이름 뒤에는 임의의 이름이 붙었지만, sts는 고유성을 가지고 있기 때문에 무조건 컨트롤러 이름 뒤에 0부터 시작하는 정수가 붙게 된다. 파드가 삭제되면 삭제된 파드와 똑같은 이름으로 새로운 파드가 만들어진다. sts-0을 지우면 sts-0이 생성되고, sts-1을 지우면 sts-1이 생성된다. 이를 통해 항상 똑같은 상태를 가지게 됨을 알 수 있다.
kubectl run nettool --image ghcr.io/c1t1d0s7/network-multitool -it --rm
host myapp-svc-headless #10.233.74.60과 10.233.118.74가 리턴된다
host myapp-sts-0.myapp-svc-headless #10.233.74.60만 리턴된다
host myapp-sts-1.myapp-svc-headless #10.233.118.74만 리턴된다
스테이트풀셋은 파드의 고유한 상태를 저장하기 위한 고유한 볼륨이 필요하다. 파드가 각자의 볼륨을 가지고 있어야 한다는 뜻이다. 레플리카셋이나 데몬셋은 volumeClaimTemplates가 없기 때문에 불가능하다.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: myapp-sts-vol
spec:
selector:
matchLabels:
app: myapp-sts-vol
serviceName: myapp-svc-headless
replicas: 2
template: #pod
metadata:
labels:
app: myapp-sts-vol
spec:
containers:
- name: myapp
image: ghcr.io/c1t1d0s7/go-myweb:alpine
ports:
- containerPort: 8080
volumeMounts:
- name: myapp-data
mountPath: /data
volumeClaimTemplates: #pvc
- metadata:
name: myapp-data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
storageClassName: nfs-client
파드가 두개 만들어지고 각각 파드에 대한 pvc가 만들어진다. 이 pvc는 nfs-client라는 스토리지 클래스에 pv 생성을 요청한다. 그 이후 이 pv들은 별도의 스토리지를 생성한다. 따라서, 두 개의 파드는 각각의 고유한 상태를 가진 파드가 된다. volumeClaimTemplates는 무조건적으로 pvc만 만들 수 있다.
kubectl scale sts myapp-sts-vol --replicas 3
scale을 통해 파드의 갯수를 늘리면 PV와 PVC의 갯수를 늘릴 수 있다.
kubectl scale sts myapp-sts-vol --replicas 2
scale을 통해 파드의 갯수를 줄여도 PV와 PVC의 갯수는 줄어들지 않는다. 한 번 만들어진 PV와 PVC의 갯수는 줄어들지 않는다. 이 데이터들이 고유하기 때문에 파드가 삭제된다고 PV와 PVC를 삭제해 버리면 고유한 데이터 역시 삭제되어 버리기 때문이다. 따라서, 데이터가 필요없어진다면 수동으로 삭제해야 한다.
줄어든 파드의 갯수를 다시 원래대로 늘리면 PV와 PVC는 늘어나지 않는다. 기존의 볼륨에 그대로 연결시켜 고유성을 그대로 가져가게 한다. 가장 중요한 것은 고유성이고, 고유성은 볼륨에 갖고 있기 때문이다. 따라서 필요없다면 미리미리 지우는 것이 중요하다.
스테이트풀셋의 헤드리스 서비스와 파드가 연결되어 있다. 어디서 정보를 저장할지 정해야 하는데, 정보를 저장하겠다고 정한 데이터베이스가 프라이머리, 복제본이 세컨더리가 된다. 웹/앱이 이 헤드리스 서비스를 통해 스테이트풀셋의 프라이머리/세컨더리 데이터베이스에 접속할 수 있다. 이 때, 프라이머리 데이터베이스에 접속할지 세컨더리 데이터베이스에 접속할지 선택할 수 있어야 한다. 이것은 스테이트풀셋에서만 가능하다. 이중화를 구성하는 순간부터는 스테이트풀셋을 사용할 수밖에 없다.
고유한 상태를 유지하기 위해 볼륨을 분리시켜 놨지만, Read Replication(읽기 복제본)을 만들기 위해선 두 볼륨이 동기화되어야 한다. 이 동기화해주는 절차가 필요하다. 디플로이먼트를 통해 동일 스토리지를 바라볼 수 있게 만들면 되지 않을까 생각할 수도 있는데 이것은 불가능하다. 데이터베이스를 같은 스토리지에 공급하게 되면 두 개의 파드에서 동시에 쓰기 작업이 안 되기 때문이다.
cat mydb-cm-mysql.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: mydb-config
labels:
app: mydb
app.kubernetes.io/name: mydb
data:
primary.cnf: | #my.cnf 대체를 위한 설정 파일
[mysqld]
log-bin
replica.cnf: |
[mysqld]
super-read-only
cat mydb-sts-mysql.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mydb
spec:
selector:
matchLabels:
app: mydb
app.kubernetes.io/name: mydb
serviceName: mydb
replicas: 2
template:
metadata:
labels:
app: mydb
app.kubernetes.io/name: mydb
spec:
initContainers: #초기화 컨테이너가 두 개 있다.
- name: init-mysql #1번
image: mysql:5.7 #설정파일을 가져오는 용도로 사용
command:
- bash
- "-c"
- |
set -ex
[[ $HOSTNAME =~ -([0-9]+)$ ]] || exit 1
ordinal=${BASH_REMATCH[1]}
echo [mysqld] > /mnt/conf.d/server-id.cnf
echo server-id=$((100 + $ordinal)) >> /mnt/conf.d/server-id.cnf
if [[ $ordinal -eq 0 ]]; then
cp /mnt/config-map/primary.cnf /mnt/conf.d/
else
cp /mnt/config-map/replica.cnf /mnt/conf.d/
fi #0번이면 primary.cnf, 아니면 replica.cnf를 가옴
volumeMounts:
- name: conf
mountPath: /mnt/conf.d
- name: config-map
mountPath: /mnt/config-map
- name: clone-mysql #2번, 상대방의 db에서 데이터를 동기화한다
image: gcr.io/google-samples/xtrabackup:1.0
command:
- bash
- "-c"
- |
set -ex
[[ -d /var/lib/mysql/mysql ]] && exit 0
[[ `hostname` =~ -([0-9]+)$ ]] || exit 1
ordinal=${BASH_REMATCH[1]}
[[ $ordinal -eq 0 ]] && exit 0
ncat --recv-only mydb-$(($ordinal-1)).mydb 3307 | xbstream -x -C /var/lib/mysql
xtrabackup --prepare --target-dir=/var/lib/mysql
volumeMounts:
- name: data
mountPath: /var/lib/mysql
subPath: mysql
- name: conf
mountPath: /etc/mysql/conf.d
containers:
- name: mysql
image: mysql:5.7
env:
- name: MYSQL_ALLOW_EMPTY_PASSWORD
value: "1"
ports:
- name: mysql
containerPort: 3306
volumeMounts:
- name: data
mountPath: /var/lib/mysql
subPath: mysql
- name: conf
mountPath: /etc/mysql/conf.d
livenessProbe:
exec:
command: ["mysqladmin", "ping"]
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
exec:
command: ["mysql", "-h", "127.0.0.1", "-e", "SELECT 1"]
initialDelaySeconds: 5
periodSeconds: 2
timeoutSeconds: 1
- name: xtrabackup #실행되고 있는 중에 스토리지의 동기화를 해준다
image: gcr.io/google-samples/xtrabackup:1.0
ports:
- name: xtrabackup
containerPort: 3307
command:
- bash
- "-c"
- |
set -ex
cd /var/lib/mysql
if [[ -f xtrabackup_slave_info && "x$(<xtrabackup_slave_info)" != "x" ]]; then
cat xtrabackup_slave_info | sed -E 's/;$//g' > change_master_to.sql.in
rm -f xtrabackup_slave_info xtrabackup_binlog_info
elif [[ -f xtrabackup_binlog_info ]]; then
[[ `cat xtrabackup_binlog_info` =~ ^(.*?)[[:space:]]+(.*?)$ ]] || exit 1
rm -f xtrabackup_binlog_info xtrabackup_slave_info
echo "CHANGE MASTER TO MASTER_LOG_FILE='${BASH_REMATCH[1]}',\
MASTER_LOG_POS=${BASH_REMATCH[2]}" > change_master_to.sql.in
fi
if [[ -f change_master_to.sql.in ]]; then
echo "Waiting for mysqld to be ready (accepting connections)"
until mysql -h 127.0.0.1 -e "SELECT 1"; do sleep 1; done
echo "Initializing replication from clone position"
mysql -h 127.0.0.1 \
-e "$(<change_master_to.sql.in), \
MASTER_HOST='mydb-0.mydb', \
MASTER_USER='root', \
MASTER_PASSWORD='', \
MASTER_CONNECT_RETRY=10; \
START SLAVE;" || exit 1
mv change_master_to.sql.in change_master_to.sql.orig
fi
exec ncat --listen --keep-open --send-only --max-conns=1 3307 -c \
"xtrabackup --backup --slave-info --stream=xbstream --host=127.0.0.1 --user=root"
volumeMounts:
- name: data
mountPath: /var/lib/mysql
subPath: mysql
- name: conf
mountPath: /etc/mysql/conf.d
volumes:
- name: conf
emptyDir: {}
- name: config-map
configMap:
name: mydb-config
volumeClaimTemplates: #스토리지 클래스가 없기 때문에 default가 구성되어 있어야 한다.
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
cat mydb-svc-read.yaml
apiVersion: v1
kind: Service
metadata:
name: mydb-read
labels:
app: mydb
app.kubernetes.io/name: mydb
spec:
ports:
- name: mysql #읽기 작업은 헤드리스일 필요가 없다.
port: 3306
selector:
app: mydb
app.kubernetes.io/name: mysql
cat mydb-svc-write.yaml
apiVersion: v1
kind: Service
metadata:
name: mydb
labels:
app: mydb
app.kubernetes.io/name: mydb
spec:
ports:
- name: mysql
port: 3306
clusterIP: None #쓰기 작업이나 업데이트를 위해선 결국 파드 이름으로 접근해야 한다.
selector:
app: mydb
app.kubernetes.io/name: mydb
kubectl create -f . #전체 생성
kubectl run dbclient --image ghcr.io/c1t1d0s7/network-multitool -it bash
host mydb #모든 파드
host mtdb-0.mydb #첫번째 파드
host mydb-read #일반 서비스
데이터베이스가 원격에 있기 때문에 -h로 호스트 옵션을 줘야 한다.
mysql -h mydb-0.mydb -u root -p #mydb-0에 접속
mysql -h mydb-0.mydb -u root -p
>show databases;
>CREATE DATABASE mydb;
>CREATE TABLE mydb.mytb (message VARCHAR(100));
>INSERT INTO mydb.mytb VALUES ("Hello world");
>SELECT * FROM mydb.mytb;
mysql -h mydb-1.mydb -u root -p #mydb-1에 접속
읽기 전용이라 write는 불가능하다.
mydb가 만들어져 있는 것을 확인할 수 있다.
mysql -h mydb-1.mydb -u root -e 'SELECT * FROM mydb.mytb' #명령어로 확인
바로바로 동기화 되어 있는 것을 확인 가능하다. scale을 이용해 새로운 파드를 만들면 새로 만들어진 파드도 동기화가 되어 있다.
데이터베이스를 세로로 쪼개거나 가로로 쪼갤 수 있다. 이것을 파드의 스토리지라고 생각해 보면 모든 파드가 각각의 고유한 데이터를 가지고 있는 것이다. 데이터가 너무 많아지면 부하가 걸리므로 샤딩이라는 기법으로 쪼개준다. 하지만 특정 쿼리에 요청이 쏠린다면 결국 부하는 걸릴 수밖에 없다.