Post

백엔드/웹서버 실제 설정으로 보는 롤링 업데이트

왜 이 글을 쓰는가

롤링 업데이트는 어디서나 “구버전 파드를 순차적으로 신버전으로 교체하는 것”이라고 설명한다. 그런데 이 한 줄짜리 설명은 실제 운영에서 별 도움이 안 된다. 진짜 궁금한 건 “우리 시스템의 maxSurge, maxUnavailable, Probe 설정값으로 실제 배포 순간에 무슨 일이 벌어지는가”다. 이 글은 백엔드(aidt-api-lm)와 웹서버(nginx) 두 차트의 실측 설정값을 기준으로 그 과정을 그대로 재구성한다. 현재 직장에 구성된 내용을 다시 한번 이 포스팅으로 정리함으로써, 나중에 복기하고 보다 나은 구성으로 고도화 가능할 수 있는 기반이 되도록 한다.

두 차트의 실측 설정 대비

항목백엔드 (prod)nginx
strategyRollingUpdateRollingUpdate
maxSurge10
maxUnavailable01
minReadySeconds55
replicas 관리 주체HPA (min=max=1)HPA (min=3, max=5)
readinessProbehttpGet /healthcheck:8080, period 5s, timeout 1s, failureThreshold 5없음
livenessProbe동일 엔드포인트, initialDelaySeconds=60s없음

두 값이 완전히 반대로 설정된 이유부터 짚고 가야 한다. 백엔드는 maxSurge=1, maxUnavailable=0을 써서 “잠깐 리소스를 더 쓰더라도 절대 트래픽을 못 받는 파드가 있으면 안 된다”는 쪽을 선택했다. 반면 nginx는 maxSurge=0, maxUnavailable=1이라 “새 파드를 추가로 띄우지 않고, 기존 파드 중 하나를 먼저 내리고 그 자리에 새 파드를 채운다”는 방식이다. 이런 선택을 한 사유는 nginx가 HPA로 3~5개 replica를 이미 넉넉히 굴리고 있어서, 순간적으로 하나 빠져도 나머지가 트래픽을 받아낼 수 있다고 판단했기 때문으로 보인다. 반대로 백엔드는 replica가 사실상 1개(HPA min=max=1)라 하나라도 빠지면 바로 서비스 중단이 되므로, 리소스를 더 쓰더라도 무중단 쪽을 택할 수밖에 없는 구조다.

백엔드 (prod) maxSurge: 1 maxUnavailable: 0 먼저 새로 띄우고 준비되면 구버전 종료 (replica 1개 · 무중단 최우선) nginx maxSurge: 0 maxUnavailable: 1 구버전 하나 먼저 종료 후 그 자리에 신버전 채움 (replica 3~5개 · 리소스 절약 우선)

1. 백엔드 시뮬레이션 — 실제로 벌어지는 일

HPA가 minReplicas: maxReplicas: 1로 고정돼 있어서 사실상 단일 Pod로 운영한다. 그런데 maxUnavailable=0이 무중단을 강제하니, 이 조합이 실제로 어떻게 굴러가는지 단계 하나하나를 쪼개서 본다.

1단계 — 배포 요청이 API 서버에 접수된다

Jenkins helm upgrade API 서버 Deployment PATCH Deployment Controller 새 ReplicaSet(v2) 생성 이 시점 v1은 그대로 Ready 상태로 트래픽을 받고 있다

Jenkins가 커밋 해시 기반 새 태그로 values를 override해서 helm upgrade --install을 실행하면, 실제로 바뀌는 건 클러스터 안의 파일이 아니라 Deployment 오브젝트의 Pod Template 필드다. API 서버가 이 변경을 받아들이는 순간 Deployment Controller가 “템플릿 해시가 달라졌다”는 걸 감지하고, 새 해시를 가진 ReplicaSet(v2)을 하나 만든다. 이 시점엔 아직 v2 파드는 존재하지 않고 ReplicaSet 오브젝트만 생긴 상태다 — v1은 아무 영향 없이 계속 트래픽을 받는다.

2단계 — surge 자리로 v2 파드가 스케줄링된다

v1 [Ready] v2 [Pending] 스케줄러 노드 배정 중 kubelet 이미지 pull 시작 desired(1) + maxSurge(1) = 최대 2개까지 허용되는 순간

maxUnavailable=0이라 v1을 먼저 내릴 수 없다. 대신 maxSurge=1이 있어서 desired(1) + 1 = 최대 2개까지 허용되므로, v2 파드를 기존 v1을 그대로 둔 채로 “추가로” 하나 더 만든다. kube-scheduler가 이 파드를 어느 노드에 배치할지 결정하고(노드 리소스 여유 확인), 결정되면 그 노드의 kubelet이 이미지를 레지스트리에서 pull하기 시작한다. 이 순간부터 클러스터는 순간적으로 리소스를 평소의 2배 쓴다 — nginx 대비 넉넉하게 리소스를 잡아둔 이유이기도 하다.

3단계 — init 컨테이너가 순차 실행되고, probe는 아직 시작 전이다

v1 [Ready] v2 Pod (Running, NotReady) init: vault-agent 메인 컨테이너 probe 대기중 initialDelaySeconds=50s가 지나야 첫 readinessProbe 호출

파드가 뜬다고 컨테이너들이 동시에 시작하는 게 아니다. init 컨테이너(vault-agent-init 등)가 순서대로 하나씩 완료돼야 메인 컨테이너가 시작된다. 메인 컨테이너가 시작해도 initialDelaySeconds=50s가 지나기 전까지는 kubelet이 readinessProbe 자체를 호출하지 않는다 — 이 구간에서 파드 상태는 Running이지만 Ready는 아니다. 대기 시간을 이렇게 넉넉히 잡은 사유는, 사이드카/init 컨테이너가 완전히 준비되기 전에 트래픽을 받아버리는 걸 막기 위해서다.

4단계 — probe 성공, EndpointSlice에 등록된다

v1 [Ready] v2 [Ready] probe 성공 EndpointSlice v2 IP 등록됨 minReadySeconds=5초 경과 후 v1 · v2 둘 다 동시에 트래픽 수신

50초 뒤 첫 readinessProbe가 성공하면 kubelet이 파드를 Ready로 표시하고, 이 정보가 API 서버를 거쳐 EndpointSlice 컨트롤러에 전달돼 v2의 IP가 Service의 EndpointSlice에 추가된다. 다만 바로 “Available”로 카운트되는 건 아니고 minReadySeconds=5초가 더 지나야 한다 — 이 짧은 유예 시간은 probe가 우연히 한 번만 성공하고 곧바로 불안정해지는 걸 걸러내기 위한 안전판이다. 이 순간부터 v1과 v2가 동시에 실제 트래픽을 나눠 받는다.

5단계 — 조건이 충족되어 구버전이 종료된다

v1 [Terminating] SIGTERM 전송됨 v2 [Ready] terminationGrace 최대 30초 대기 EndpointSlice에서는 즉시 제거 — 새 요청은 v1로 안 감

v2가 Ready가 됨으로써 이제 “정상 replica 수(1개)가 항상 유지된다”는 maxUnavailable=0 조건이 만족된 상태다. 그러면 Deployment Controller가 v1에 SIGTERM을 보낸다. 이때 v1은 두 가지가 동시에 일어난다 — ① EndpointSlice에서 즉시 제거되어 새로운 요청은 더 이상 v1로 라우팅되지 않고, ② 프로세스 자체는 terminationGracePeriodSeconds=30초 동안 살아남아 이미 받은 요청을 마저 처리한다. 이 둘을 분리해두는 사유는 “새 요청 차단”과 “기존 요청 완료”가 동시에 되도록 해서 커넥션이 중간에 끊기지 않게 하기 위해서다.

6단계 — 최종 상태

v2 [Ready] v1 완전히 사라지고 v2만 남는다 — 이 사이 트래픽 끊김 없음

v1의 grace period가 끝나면 컨테이너가 완전히 종료되고 ReplicaSet에서도 제거된다. replica가 1개인 상태로 배포 시작 전과 배포 후가 동일하게 유지되면서, 그 사이 트래픽은 한 번도 끊기지 않았다 — 대신 짧은 구간 동안 리소스를 2배 쓰는 걸 감수한 결과다.

readinessProbe가 계속 실패하면 어떻게 되는가. failureThreshold=5, periodSeconds=5라 약 25초 연속 실패하면 v2는 계속 NotReady 상태로 남는다. 이러면 4단계가 영원히 안 일어나고, maxUnavailable=0이라 v1도 못 내리고 maxSurge=1을 이미 다 썼으니 v2를 더 늘리지도 못한다. 결과적으로 서비스 자체는 v1이 계속 받아내서 정상이지만, 배포는 그 자리에서 멈춘다 — Deployment가 Progressing 상태에 갇혀서 안 끝나는 상황이 바로 이거다.

2. nginx — 두 가지 서로 다른 배포 경로가 있다는 게 핵심

nginx는 한 가지가 아니라 두 가지 완전히 다른 배포 경로를 갖고 있다는 걸 먼저 짚어야 한다. 이 구분을 모르면 “nginx도 롤링 업데이트 되겠지”라고 오해하기 쉽다.

nginx 서버 자체 변경 (설정, 베이스 이미지) Kaniko 이미지 빌드 → helm upgrade → Deployment spec 변경 → 표준 RollingUpdate 발동 maxSurge:0 / maxUnavailable:1 경로를 그대로 탐 정적 파일(dist) 변경 (프론트엔드 배포) npm build → kubectl cp로 떠 있는 파드에 직접 복사 → Pod Spec 변경 없음 RollingUpdate 개념 자체가 적용되지 않음

왜 이렇게 나뉘어 있는가. “무엇이 바뀌었는가”에 따라 배포의 무게를 다르게 가져가려는 설계다. 코드나 실행 환경이 바뀌면 이미지 자체가 바뀌어야 하니 Kaniko + Helm + RollingUpdate라는 표준 경로를 그대로 태우고, 반대로 빌드 결과물(정적 파일)만 바뀌는 경우엔 이미지를 새로 굽는 무거운 과정을 생략하고 kubectl cp로 파일만 갈아 끼운다. 가벼운 대신 표준 롤백 수단(kubectl rollout undo)을 못 쓴다는 트레이드오프를 함께 가져가는 선택이다.

2-1. nginx 서버 자체가 바뀔 때 — 실제 RollingUpdate 동작

maxSurge=0, maxUnavailable=1이라 백엔드와 정반대 순서로 진행된다. replica는 HPA min=3 기준으로 본다.

v1 [Ready] v1 [Ready] v1 [Ready] 1단계 — 배포 전, v1 파드 3개가 정상적으로 트래픽을 받는 상태 v1 [Terminating] v1 [Ready] v1 [Ready] 2단계 — maxUnavailable=1 한도까지 v1 하나를 먼저 종료 (정상 replica 2개로 감소) v2 [Ready] probe 없이 즉시 v1 [Ready] v1 [Ready] 3단계 — readinessProbe가 없어 컨테이너 시작과 동시에 Ready 판정, 트래픽 즉시 수신 v2 [Ready] v2 [Ready] v2 [Ready] 4단계 — 이 과정이 replica 수만큼 하나씩 반복되어 전체가 v2로 교체된다

각 단계를 사유와 함께 풀면 이렇다.

  1. 새 ReplicaSet(v2)이 생성되지만, maxSurge=0이라 먼저 추가로 띄우는 일은 없다. 백엔드와 달리 여유 리소스를 쓰지 않는 쪽을 선택한 것이다.
  2. maxUnavailable=1이 허용하는 한도까지 구버전(v1) 파드 하나를 먼저 Terminating 상태로 만든다. HPA가 min=3이므로 이 순간 정상 replica는 2개로 줄어든다 — 백엔드였다면 절대 허용 안 되는 상태지만, nginx는 replica가 여유 있어 감수 가능하다고 판단한 것으로 보인다.
  3. v1 파드가 완전히 빠진 자리에 v2 파드가 새로 뜬다. 여기서 결정적인 차이가 나온다 — nginx Deployment에는 readinessProbe가 없다. 즉 v2 컨테이너가 시작되자마자(프로세스가 뜨는 순간) 바로 Ready로 간주돼 트래픽을 받기 시작한다. 컨테이너 시작과 “실제로 요청을 처리할 준비가 됐는가” 사이에 별도 확인 절차가 없는 셈이다.
  4. 이 과정이 replica 수만큼 반복되며 전체가 v2로 교체된다.

probe가 없다는 게 왜 문제가 될 수 있는가. 백엔드처럼 init 컨테이너(vault-agent 등)를 기다릴 필요가 nginx엔 상대적으로 적어서 지금까지는 큰 문제 없이 운영돼왔을 가능성이 높다. 다만 nginx 설정 리로드나 초기화에 아주 짧은 시간이라도 걸린다면, 그 찰나의 요청이 실패로 이어질 여지가 이론상 남아있다. Probe를 추가하는 게 왜 필요한지 판단할 때 참고할 지점이다.

2-2. 정적 파일(dist)만 바뀔 때 — RollingUpdate가 아예 발동하지 않음

kubectl cp는 이미 떠 있는 컨테이너의 파일시스템에 파일을 밀어넣는 동작이라, Deployment의 Pod Template 자체를 건드리지 않는다. 그래서 이 경로는 롤링 업데이트라는 개념이 아예 적용되지 않는다 — 파드 재시작도, ReplicaSet 교체도 일어나지 않고 그냥 실행 중인 파드 안의 파일 하나가 바뀔 뿐이다.

여기서 짚고 넘어가야 할 리스크가 있다. kubectl cp로 넣은 파일은 컨테이너의 ephemeral layer에만 존재한다. 만약 다른 이유로(리소스 제한 변경, 노드 재스케줄링 등) 파드가 재시작되면, 새로 뜬 파드에는 이 파일이 없다 — 이 부분이 이 배포 방식의 가장 큰 구조적 약점으로, kubectl cp 방식을 쓸 때는 반드시 인지하고 있어야 하는 지점이다.

부록 — 개념 자체 복기

실측값 시뮬레이션만 보면 나중에 “그래서 maxSurge랑 maxUnavailable이 정확히 뭐였지”가 다시 헷갈릴 수 있어서, 개념 자체를 처음부터 다시 정리해둔다.

maxSurge — 실제 값으로 비교하면

같은 값이라도 실제 차트에 어떻게 박혀 있는지 봐야 감이 온다. 백엔드(prod)의 실제 values.yaml은 이렇다.

1
2
3
4
5
6
# aidt-api-lm (backend, prod)
strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 1
    maxUnavailable: 0

maxSurge: 1이라 desired replica(1) + 1 = 최대 2개까지 동시에 뜰 수 있다. 실제로 이 값 때문에 벌어지는 일이 위 1번 시뮬레이션의 2단계다 — v1을 그대로 둔 채 v2를 “추가로” 하나 더 띄운다.

반면 nginx의 실제 값은 이렇다.

1
2
3
4
5
6
# nginx
strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 0
    maxUnavailable: 1

maxSurge: 0이면 아예 추가로 띄우는 일이 없다. desired replica(3)를 절대 넘기지 않겠다는 뜻이라, 새 파드를 만들려면 반드시 기존 파드 하나가 먼저 빠져야 자리가 생긴다. 백엔드처럼 여유 리소스를 잠깐 더 쓰는 방식이 아예 선택지에서 빠져 있는 것이다 — nginx가 HPA로 3~5개를 넉넉히 굴리고 있어서, 순간적으로 리소스를 더 쓰기보다는 있는 자원 안에서 하나씩 교체하는 쪽을 택한 것으로 보인다.

maxUnavailable — 실제 값으로 비교하면

같은 두 값을 이번엔 maxUnavailable 관점에서 보면 왜 정반대로 설계했는지가 명확해진다.

  • 백엔드 maxUnavailable: 0: HPA가 minReplicas: maxReplicas: 1로 고정돼 있어서 replica가 사실상 1개뿐이다. 즉 파드 하나가 곧 서비스 전체다. 이 상태에서 maxUnavailable이 조금이라도 허용되면 배포 중 서비스가 완전히 끊기는 순간이 생긴다 — 그래서 0으로 못박아 “항상 최소 1개는 떠 있어야 한다”를 강제한 것이다.
  • nginx maxUnavailable: 1: HPA min=3이라 파드 하나가 잠깐 빠져도 나머지 2개가 트래픽을 받아낼 수 있다. 그래서 구버전 하나를 먼저 내리고 그 자리에 새 버전을 채우는, 리소스를 더 안 쓰는 방식을 택할 여유가 있다.

참고로 stg 환경은 아예 Recreate 전략을 쓰는데, 이것도 같은 맥락이다. stg는 replica가 1개인 테스트 환경이라 무중단 자체가 의미가 없어서, 구버전을 통째로 내리고 신버전을 새로 띄우는 가장 단순한 방식을 쓴다. prod가 RollingUpdate + maxUnavailable: 0이라는 훨씬 까다로운 조합을 쓰는 이유는, stg와 달리 실제 사용자 트래픽이 들어오기 때문이다.

롤링 업데이트 5단계 진행 과정 maxSurge=1, maxUnavailable=0 조합(백엔드)에서 새 파드가 추가로 뜨고, Ready가 확인된 뒤에야 구 파드가 내려가는 흐름

Readiness Probe가 왜 “게이트” 역할을 하는가

새 파드가 뜬다고 바로 트래픽을 받는 게 아니다. Deployment 컨트롤러는 Readiness Probe가 성공할 때까지 다음 단계로 넘어가지 않는다 — 이게 롤링 업데이트에서 가장 중요한 지점이다. 파드 상태가 Running이어도 Ready가 아니면 Service의 EndpointSlice에 등록되지 않아서 트래픽을 못 받는다.

백엔드의 실제 probe 설정은 이렇다.

1
2
3
4
5
6
7
8
readinessProbe:
  httpGet:
    path: /healthcheck
    port: 8080
  initialDelaySeconds: 50
  periodSeconds: 5
  timeoutSeconds: 1
  failureThreshold: 5

initialDelaySeconds: 50을 이렇게 넉넉히 잡은 사유가 있다. 이 파드는 vault-agent-init 같은 init 컨테이너와 istio-proxy 사이드카가 함께 뜨는 구조라, 그것들이 전부 초기화될 때까지 메인 컨테이너의 Readiness가 통과되지 않도록 여유를 준 것이다. 이 값이 너무 짧으면, 사이드카가 준비되기 전에 트래픽을 받아버리는 상황이 생길 수 있다. 반대로 nginx Deployment에는 readinessProbe 자체가 아예 없다 — 이게 2-1절에서 다룰 nginx의 리스크 포인트다.

Probe가 계속 실패하면 롤링 업데이트 자체가 멈춘다. failureThreshold: 5, periodSeconds: 5이니 약 25초 연속 실패하면 새 파드는 계속 NotReady로 남는다. maxUnavailable=0인 상태에서 새 파드가 계속 NotReady면, 구 파드를 못 내리고(무중단 조건 위반이라서) 새 파드를 더 늘리지도 못한다(maxSurge를 이미 다 썼으므로). 서비스는 구 버전이 계속 받아내서 정상이지만, 배포 자체는 Progressing 상태에 갇혀서 끝나지 않는다.

Topology Spread Constraint (maxSkew) — 롤링 업데이트와는 별개의 개념

maxSurge/maxUnavailable이 “언제 몇 개씩 뜨고 내리는가”를 정하는 규칙이라면, maxSkew는 “그 파드들을 어느 노드에 배치할 것인가”를 정하는 완전히 다른 레이어의 규칙이다. 롤링 업데이트 중 새 파드가 스케줄링될 때 이 제약도 함께 참조된다.

아래는 실제 우리 백엔드 앱(aidt-api-lm)에 이 제약을 적용한다면 들어갈 형태다 — 다만 현재 차트에 실제로 이 값이 설정돼 있는지는 별개로 확인이 필요한 부분이라, 여기서는 개념 설명용으로 우리 앱 이름을 그대로 넣은 예시로 본다.

1
2
3
4
5
6
7
8
# aidt-api-lm 차트에 적용한다면
topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: kubernetes.io/hostname
    whenUnsatisfiable: DoNotSchedule
    labelSelector:
      matchLabels:
        app: aidt-api-lm

비유로 이해하면 쉽다. 학생 10명을 조 A, B, C 세 개에 나눠 앉힐 때 “한 조에 몰빵하지 말고 최대한 비슷하게 나눠라”는 규칙과 같다. maxSkew는 그 “몰림을 얼마나 봐줄 것인가”의 허용치다.

  • skew 계산법: (한 그룹의 파드 개수) - (전체 그룹 중 가장 적은 그룹의 개수)
  • 모든 그룹의 skew 중 가장 큰 값이 maxSkew를 넘으면 안 된다

예를 들어 노드 3대에 파드가 4, 2, 2개로 떠 있다면 skew = 4 - 2 = 2다. maxSkew: 1로 설정돼 있으면 이 상태는 위반이라, 다음 파드는 무조건 파드가 적은 노드(B나 C) 쪽으로 스케줄링된다.

maxSkew 계산 예시 노드 A(4개)가 이미 초과 상태라 새 파드는 B나 C로만 스케줄링된다 maxSkew: 1은 사실상 “완전 균등”에 가깝지만 정확히는 “제일 많은 곳과 제일 적은 곳의 차이가 1개를 넘지 않는 선까지”를 허용하는 것이다. 그래서 파드 5개를 노드 3대에 나눌 때 2·2·1 분포는 통과하지만 3·1·1 분포는 위반이 된다.

왜 필요한가. 이 제약이 없으면 스케줄러는 그냥 여유 있는 노드부터 채우기 때문에, 극단적으로는 파드 대부분이 노드 하나에 몰릴 수 있다. 그 상태에서 그 노드가 죽으면 서비스 replica 대부분이 한 번에 사라진다. maxSkew는 “노드(혹은 존) 하나가 죽어도 서비스 전체가 죽지 않도록” 미리 파드를 흩어두는 안전장치다. topologyKeyzone으로 잡으면 가용 영역 단위 장애까지 방어 범위에 들어오고, hostname으로 잡으면 노드 단위 장애를 방어한다.

whenUnsatisfiable: DoNotSchedule로 엄격하게 걸려 있으면, 롤링 업데이트 도중 surge로 파드가 순간적으로 늘어나서 배치할 자리가 애매해질 때 새 파드가 Pending에 걸려 배포가 멈추는 경우도 생길 수 있다 — maxSurge/maxUnavailable과 maxSkew를 같이 튜닝할 때 염두에 둬야 하는 상호작용이다.

정리

같은 “롤링 업데이트”라는 말을 쓰지만, 실제로는 세 가지 서로 다른 상황이 존재한다.

상황발동 여부핵심 설정
백엔드 이미지 배포O (무중단 우선)maxSurge=1, maxUnavailable=0, probe 있음
nginx 서버 자체 배포O (리소스 절약 우선)maxSurge=0, maxUnavailable=1, probe 없음
nginx 정적 파일 배포X (개념 자체 미적용)kubectl cp, Pod Spec 불변

같은 클러스터, 심지어 같은 nginx Deployment라도 “무엇을 배포하느냐”에 따라 전혀 다른 메커니즘이 동작한다는 게 이 정리의 핵심이다.

This post is licensed under CC BY 4.0 by the author.

© noah. Some rights reserved.