쿠버네티스는 배포를 편리하게 해 줍니다. 편리하지만 수동으로 배포한다면 꽤나 번거로워질 수 있고 Human Error가 발생할 가능성이 높습니다. 만약 GitOps 방식을 채택했다면 배포 과정에서 트래픽이 유실되지 않도록 하려면 복잡한 설정이 필요할 수 있습니다. 파드가 생성된 후 애플리케이션이 완전히 준비돼야 정상적으로 트래픽을 처리할 수 있고, 그 시점에 서비스 객체가 해당 파드를 바라볼 수 있도록 하는 제어가 필요하기 때문입니다.
서비스가 중단되지 않도록 배포하기 위해 무중단 배포 전략이 등장했고, 무중단 배포 전략에는 canary, blue-green, rolling 등이 있습니다. 이 포스팅에선 blue-green 배포 전략을 예시로 들겠습니다.
쿠버네티스에서 트래픽의 이동 경로
쿠버네티스 클러스터 환경에 ingress - service - pod가 있다고 가정하겠습니다. Ingress는 일반적으로 많이 사용되는 Nginx Ingress를 예시로 들겠습니다.
이들의 관계는 아래 도식처럼 되어있습니다.
Ingress
클러스터에 트래픽이 인입되면 Public LB에 의해 Ingress로 전달됩니다.
Ingress는 Public Load Balancer로부터 트래픽을 받고, path에 따라 특정 서비스로 라우팅 합니다.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ingress-nginx
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /api/.*
pathType: ImplementationSpecific
backend:
service:
name: api-svc
port:
number: 8080
Service
Service는 `selector`와 `ports` 규칙에 따라 파드로 트래픽을 보냅니다.
apiVersion: v1
kind: Service
metadata:
name: api-svc
spec:
selector:
app: api
version: blue # blue 버전을 바라보고 있는 중
ports:
- protocol: TCP
port: 8080
targetPort: 8080
name: server
type: ClusterIP
Pod
파드는 내부의 컨테이너에 트래픽을 전달하고, 컨테이너에서 실행 중인 애플리케이션에서 트래픽을 처리합니다.
현재는 `Blue` 버전의 이미지를 사용하는 파드입니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-deployment-blue
spec:
replicas: 1
selector:
matchLabels:
app: api
version: blue # blue 버전
template:
metadata:
labels:
app: api
version: blue # blue 버전
spec:
containers:
- name: backend
image: myRegistry/server:blue # blue 버전
ports:
- containerPort: 8080
무중단 배포 흐름 (Blue-Green)
이제 Blue-Green 전략을 따르는 무중단 배포 흐름을 알아보겠습니다.
Blue-Green 방식의 배포는 큰 틀에서 다음과 같은 흐름으로 설명할 수 있습니다.
1. 기본 상태
클러스터에 트래픽이 지속적으로 인입되는 상태입니다. 파드의 버전은 `Blue`입니다.
2. Green 버전 Deployment 배포 (서비스와 연결 대기)
새로운 Deployment를 생성해 `Green` 버전을 배포합니다. 이 시점에 `Blue` 버전 파드와 `Green` 버전 파드가 동시에 존재합니다.
하지만 배포된 `Green` 버전 파드는 정상적으로 트래픽을 처리할 수 있는 상태가 되기 전까지 서비스에 연결되지 않습니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-deployment-green
spec:
replicas: 1
selector:
matchLabels:
app: api
version: green # green 버전
template:
metadata:
labels:
app: api
version: green # green 버전
spec:
containers:
- name: backend
image: myRegistry/server:green # green 버전
ports:
- containerPort: 8080
3. Green 버전 파드를 서비스에 연결
`Green` 버전 파드가 트래픽을 정상적으로 처리할 수 있는 상태가 되면 서비스가 `Green` 버전 파드를 바라보도록 수정합니다.
apiVersion: v1
kind: Service
metadata:
name: api-svc
spec:
selector:
app: api
version: green # 바라보는 파드를 green 버전으로 업데이트
ports:
- protocol: TCP
port: 8080
targetPort: 8080
name: server
type: ClusterIP
4-1. (Green 버전이 정상 작동) Blue 버전 파드 제거
일정시간 동안 `Green` 버전으로 흐르는 트래픽이 정상적으로 처리되는 것이 확인되면 `Blue` 버전 파드를 제거합니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-deployment-blue
spec:
replicas: 0 # 0으로 설정해 파드를 제거
4-2. (Green 버전에 문제 발생) Blue 버전으로 롤백
만약 `Green` 버전 파드에 문제가 생겼다면 다시 서비스가 `Blue` 버전을 바라보도록 수정해야 합니다.
apiVersion: v1
kind: Service
metadata:
name: api-svc
spec:
selector:
app: api
version: blue # blue 버전으로 롤백
ports:
- protocol: TCP
port: 8080
targetPort: 8080
name: server
type: ClusterIP
그리고 `Green` 버전의 Deployment의 replicas 값도 `0`으로 수정해 파드를 제거합니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-deployment-green
spec:
replicas: 0 # 0으로 설정해 green 버전 파드를 제거
무중단 배포 과정에서 발생하는 트래픽 유실
위의 흐름대로 무중단 배포를 수행해도 진행되는 중간에 트래픽이 유실될 수 있습니다.
위의 이미지를 보시면 무중단 배포 과정에서 `502 Bad Gateway`가 412번 발생한 것을 확인할 수 있습니다.
쿠버네티스가 파드를 제거하는 과정에서 고려해야 할 부분
graceful shutdown (우아한 종료)
`kubelet`은 파드에서 컨테이너의 실행과 종료를 담당하고, 파드의 상태를 모니터링하는 등 파드를 제어합니다.
파드를 제거하기 위해 `kubelet`은 파드에 `SIGTERM` 신호를 보내고, 파드가 graceful shutdown이 되도록 일정 시간 동안 대기합니다. 대기 시간은 Deployment에서 `. spec.template.spec.terminationGracePeriodSeconds` 값이며, 기본값은 30초입니다. 만약 대기 시간 이후에도 graceful shutdown이 되지 않으면 `SIGKILL` 신호를 보내 파드를 강제로 종료합니다.
따라서 컨테이너 내부에서 graceful shutdown이 구현돼 있어야 합니다.
구체적으로 SIGTERM 신호를 컨테이너가 받으면 웹 애플리케이션은 더 이상의 요청을 받지 않으면서 기존 요청까지는 정상적으로 처리하도록 구현돼야 합니다.
Spring Boot Graceful Shutdown
Spring Boot는 기존 요청까지는 처리하되 새 요청은 받지 않는 Graceful shutdown을 지원합니다.
기본적으로 네 가지(Tomcat, Jetty, Reactor Netty, Undertow) 임베디드 웹 서버에 대해 지원하며, 기존 처리가 완료될 때까지 제한 시간도 설정할 수 있습니다.
하지만 graceful shutdown은 3.4.0-M3 버전부터 기본값으로 제공하며, 이전 버전을 사용한다면 `application.yaml`에서 별도의 설정이 필요합니다.
저는 Spring Boot 3.3.4 버전을 사용 중인데, 임베디드 서버로 Tomcat을 사용하고 있습니다.
`org.springframework.boot.web.embedded.tomcat.TomcatWebServer`에서 기본값으로 `IMMEDIATE`인 것을 확인할 수 있습니다.
application.yaml 설정 변경
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 2m
위의 설정을 통해 graceful shutdown을 활성화합니다. 이때 함께 고려해야 할 부분으로 처리 중이던 요청을 완료할 때까지 기다려주는 시간을 알맞게 설정해야 합니다.
저는 가장 느린 API 요청의 처리 시간이 1분 정도인 것을 감안해서 2배인 2분으로 설정했습니다.
애플리케이션에서 graceful shutdown을 고려한 설정이 적용했어도 쿠버네티스 파드 설정에서 `terminationGracePeriodSeconds` 시간이 더 짧다면 트래픽이 유실될 수 있습니다. 이를 위해 `.spec.template.spec.terminationGracePeriodSeconds` 늘려줍니다.
(`terminationGracePeriodSeconds` 기본값은 30초입니다.)
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-deployment
spec:
replicas: 1
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
terminationGracePeriodSeconds: 150 # SIGTERM 시그널을 보낸 후 기다리는 시간
이렇게 설정하면 파드는 쿠버네티스로부터 SIGTERM 신호를 받으면 graceful shutdown을 할 수 있게 됩니다.
쿠버네티스에서 Graceful Shutdown을 위해 고려할 옵션들
파드에서 graceful shutdown을 하더라도 배포 진행 중에 트래픽 유실이 발생할 수 있습니다. 예를 들면, 새로 생성된 파드에서 컨테이너가 아직 로드 중일 때 트래픽을 보낸다면 트래픽 유실이 발생할 것입니다.
위의 이미지를 보시면 Spring 애플리케이션에서 graceful shutdown이 되도록 설정했음에도 로드 중인 파드에 트래픽을 보내 유실된 것을 확인할 수 있습니다.
카카오 테크 블로그(https://tech.kakao.com/posts/360)에선 이 같은 상황을 방지하기 위한 쿠버네티스 파드에 적용할 수 있는 옵션들을 소개하는데, 정리하면 아래와 같습니다.
1. 롤링 업데이트를 위한 maxSurge, maxUnavailable
Blue-Green 전략이 아닌 Rolling 전략에서 사용할만한 옵션입니다.
사용 예시
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-deployment
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
- maxSurge, maxUnavailable 설명
- maxSurge는 배포 과정에서 replicas 수를 기준으로 새로 추가되는 파드의 최대 개수를 의미합니다.
- maxUnavailable은 배포 과정에서 replicas 수를 기준으로 이용이 불가능한 파드의 최대 개수를 의미합니다.
- 두 값이 동시에 0 일 수 없습니다.
- 새 파드 생성 후 기존 파드를 삭제하는 전략
- maxUnavailable 값을 0으로 설정합니다.
- maxSurge 값만큼 새 파드가 생성된 후 기존의 레플리카 파드를 1개씩 제거합니다.
- 기본 파드를 먼저 삭제한 후 새 파드를 생성하는 전략
- maxSurge 값을 0으로 설정합니다.
- 배포 중 기존 파드를 1개 제거하고, 새로운 레플리카 파드를 1개씩 늘립니다.
2. 파드의 컨테이너 프로브를 체크하는 livenessProbe, readinessProbe
`kubelet` 서비스가 파드 내부의 컨테이너에 대해 헬스 체크를 할 때 2가지를 체크합니다. 체크는 프로브 핸들러를 통해 수행되며 4가지 메커니즘 중 하나만 사용합니다.
- httpGet: 특정 Path에 HTTP GET 요청 후 응답 코드 2xx 또는 3xx 확인
- tcpSocket: 컨테이너의 지정된 IP 주소에 대해 TCP 포트가 활성화되어 있는지 확인
- exec: 컨테이너 내 지정된 명령어를 실행 후 명령어 상태 코드가 exit code가 0인지 확인
- grpc: 컨테이너에서 구현된 gRPC 헬스 체크를 통해 확인
프로브는 세 가지 종류가 있습니다. 각 종류마다 상태의 결과로 `Success`, `Failure`, `Unknown` 세 가지가 있으며 `Success` 상태일 때 헬스 체크에 성공한 것으로 간주됩니다. 세 가지 종류에 대해 설정하지 않으면 기본 상태는 `Success`입니다.
- livenessProbe: 상태에 따라 컨테이너를 살릴지 죽일지 결정합니다.
- readinessProbe: 파드의 IP 주소를 제거할지 말지를 결정합니다.
- startupProbe: 컨테이너 내 애플리케이션이 시작되었는지 확인할 때 사용합니다. 상태가 `Success`가 되기 전까지 다른 프로브는 활성화되지 않습니다.
여기서 무중단 배포에 필요한 설정은 `readinessProbe`입니다. Spring 애플리케이션의 경우 actuator를 통해 쉽게 체크할 수 있습니다.
다음은 Spring Boot에서 actuator를 통한 컨테이너 프로브를 체크하는 설정입니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-deployment
spec:
replicas: 1
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
terminationGracePeriodSeconds: 150
containers:
- name: backend
image: myRegistry/backend
imagePullPolicy: Always
ports:
- containerPort: 8080
livenessProbe: # livenessProbe 설정
httpGet:
path: /actuator/health/liveness # actuator 에서 제공하는 경로
port: 9292
initialDelaySeconds: 5
periodSeconds: 20
readinessProbe: # readinessProbe 설정
httpGet:
path: /actuator/health/readiness # actuator 에서 제공하는 경로
port: 9292
initialDelaySeconds: 30
periodSeconds: 10
3. readinessProbe 설정이 어려울 땐 minReadySeconds
`.spec.minReadySeconds` 설정은 파드의 상태가 `ready`가 될 때까지의 최소 대기 시간을 의미합니다. 이 대기 시간 동안은 트래픽을 받지 않기 때문에 `readinessProbe`를 설정하기 어려울 땐 비슷한 효과를 낼 수 있는 `minReadySeconds`를 설정하는 것을 소개합니다.
만약 `readinessProbe`가 `Success`가 되면 `minReadySeconds` 시간이 남아있어도 파드가 준비된 것으로 보고 트래픽을 연결합니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-deployment
spec:
minReadySeconds: 30 # 30초 동안 트래픽을 보내지 않고 대기
4. graceful shutdown 구현이 어려울 땐 postStart hook, preStop hook
파드의 graceful shutdown 구현이 어려울 때 유용하게 사용할 수 있는 파드의 hook을 설정하는 것을 소개합니다.
`postStart` 훅은 파드가 생성된 직후에 실행되고, `preStop` 훅은 컨테이너가 종료되기 직전에 실행됩니다.
`preStop`은 `terminationGracePeriodSeconds`의 초읽기가 시작되기 전에 실행되기 때문에 이 옵션을 통해 graceful shutdown을 흉내 낼 수 있습니다.
아래 예시에선 `preStop` 훅을 이용해 종료 직전 10초를 대기하도록 설정합니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-deployment
spec:
replicas: 1
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: backend
image: myRegistry/backend
imagePullPolicy: Always
ports:
- containerPort: 8080
lifecycle:
preStop: # lifecycle 중 preStop에 설정
exec: # 종료 직전 10초 대기
command: [ "/bin/sh", "-c", "echo 'Shutting down' > && sleep 10" ]
컨테이너 프로브 설정 후 트래픽 무손실 확인
적용 전
fail 11.78% (502 Bad Gateway 424회 / 전체 3600 요청)
적용 후
fail 0% (502 Bad Gateway 0회 / 전체 3600 요청)
긴 글 읽어주셔서 감사합니다.
참고
- https://tech.kakao.com/posts/360
- https://www.baeldung.com/spring-boot-web-server-shutdown
- https://docs.spring.io/spring-boot/reference/web/graceful-shutdown.html#page-title
- https://velog.io/@pinion7/Kubernetes-리소스-Deployment에-대해-이해하고-실습해보기
- https://kubernetes.io/ko/docs/concepts/workloads/pods/pod-lifecycle/
- https://kubernetes.io/ko/docs/concepts/containers/container-lifecycle-hooks/
'Kubernetes' 카테고리의 다른 글
ncloud NKS에서 Nginx Ingress Controller 설치부터 HTTPS 적용하기 (0) | 2025.01.03 |
---|---|
ncloud NKS로 쿠버네티스 클러스터 구성하기 (0) | 2025.01.01 |
[k8s] 쿠버네티스 핵심 개념 (0) | 2024.09.11 |
[k8s] 쿠버네티스 시작하기 (0) | 2024.09.11 |