AWS

EKS 무중단 배포 — lifecycle(preStop) · Pod Readiness Gatestrategy(RollingUpdate) · PodDisruptionBudget

Somaz 2026. 7. 9. 00:00
728x90
반응형

Overview

EKS와 Karpenter를 함께 사용하는 환경에서 `kubectl rollout restart` 를 실행한 후, 노드가 한 대 늘어났다가 영원히 줄어들지 않는 문제를 겪었다. 처음에는 "오토스케일러 버그인가" 싶었지만, 원인을 파고들어 보니 Deployment의 배포 설정과 Karpenter의 consolidation(노드 통정) 정책이 맞물려 발생한 구조적인 문제였다.

 

이 글은 해당 문제를 해결하며 정리한 쿠버네티스 무중단 배포의 네 가지 핵심 축을 다룬다.

  • `lifecycle.preStop` & Pod Readiness Gate — 트래픽 안전의 한 쌍. preStop은 종료되는 파드가 ALB에서 빠질 때까지(outbound) 트래픽을 안전하게 처리하고, Readiness Gate는 새 파드가 ALB에 등록·헬스체크를 통과할 때까지(inbound) 교체를 붙잡아둔다.
  • `strategy`(RollingUpdate) — `maxSurge` 와 `maxUnavailable` 이라는 두 가지 옵션(Knob)으로 노드 증가와 롤링 배포 속도를 제어하는 방법
  • `PodDisruptionBudget`(PDB) — 노드 통합(Consolidation)이나 드레인 작업 중에도 Ready 상태의 파드가 0이 되지 않도록 가용성을 보장하는 안전장치

 

이 세 가지 설정은 따로 보면 각자 사소해 보이지만, 하나로 합쳐졌을 때 비로소 Karpenter의 consolidation을 진정한 무중단으로 만드는 완벽한 한 세트가 된다. 실제 운영 클러스터(prod-app-v1, Karpenter ON_DEMAND ARM NodePool)에 적용한 구체적인 수치와 함께 내용을 정리했다.

 

 

 

 

 

 

 


 

 

 

 

 

1. 문제 상황 — 재시작 후 노드가 줄어들지 않는다

증상은 단순했다. `kubectl rollout restart deployment/A` 을 실행하면 노드가 한 대 더 생성되는데, 롤링 배포가 끝난 후에도 이 노드가 회수되지 않고 그대로 남아 있었다. 원인은 세 가지 설정이 겹친 결과였다.

 

 

(1) NodePool의 정책이 'WhenEmpty'로 설정되어 있었다

A 서비스의 NodePool은 `consolidationPolicy: WhenEmpty` 로 잡혀 있었다. 이는 `ON_DEMAND` + `WhenEmpty` 조합을 통해 라이브 트래픽을 처리하는 파드를 Karpenter가 임의로 쫓아내지(Evict) 않도록 의도한 설계였다. 하지만 노드에 파드가 단 하나라도 남아 있으면 절대 노드를 회수하지 않는다는 분명한 단점이 있었다.

 

 

 

(2) Deployment의 maxSurge가 1로 설정되어 있었다

`rollout restart` 는 기존 파드를 정리하기 전에 새 파드를 먼저 생성한다(Surge). 그런데 A 파드의 Memory Request가 2Gi로 필요 이상으로 크게 잡혀 있어, 기존 노드에는 새 파드가 들어갈 공간이 부족했다. 결국 이 Surge 파드 하나 때문에 새로운 노드가 하나 더 실행되었다.

 

 

 

(3) 두 조건이 맞물려 발생한 '파드 분산(Straddle)' 현상

롤링 배포가 끝나자 파드들이 기존 노드와 새로 뜬 노드에 각각 걸쳐서(Straddle) 배치되었다. 두 노드 모두 완전히 비어 있는 상태가 아니었기 때문에, `WhenEmpty` 정책에 따라 영원히 노드가 회수되지 않는 교착 상태에 빠졌다.

재시작 직후 실측한 노드 상태는 다음과 같았다.

노드 인스턴스 Memory Request 사용률 시스템 외 실행 파드
node-ac7g.xlarge 92% (6248/6933Mi) A× 2, C, D
node-bc7g.large 35% (1128/3209Mi) B (1개만 남음)

 

 

c7g.large 노드가 오직 B 파드 하나 때문에 비워지지 않고 잔존한 것이다. 게다가 xlarge 노드는 이미 사용률이 92%에 달해 B 파드를 받아줄 여유 공간이 없었다. 즉, 정책을 `WhenEmptyOrUnderutilized` 로 바꾸더라도 결합할 공간이 없는 실질적인 용량 부족 상태였다.

 

근본적인 원인은 A 파드의 Memory Request가 2Gi로 과도하게 설정된 데 있었다. 실제 Idle 상태의 사용량은 파드당 약 101Mi에 불과했으므로, 2Gi는 실사용량의 20배에 달하는 수치였다. 이 과다 설정된 Request가 자원 배치(Bin-packing)의 효율성을 완전히 망가뜨리고 있었다.

 

 

 

 

2. lifecycle(preStop) — Graceful Drain

해결의 첫 단추는 "노드를 줄이는 것"보다, "파드가 종료될 때 502 에러가 발생하지 않도록 하는 것"이었다. 이 부분이 해결되지 않으면 어떤 노드 통합(Consolidation)이나 롤링 배포도 안전하게 진행할 수 없기 때문이다.

 

 

 

왜 502 에러가 발생하는가

애플리케이션(A)은 Stateless HTTP(Fastify) 기반이며 WebSocket이나 TCP 같은 롱리브드(Long-lived) 소켓을 사용하지 않는다. 세션 상태 또한 파드가 아닌 Redis에서 관리한다. 즉, 파드를 종료하더라도 끊길 세션은 없다. 진짜 문제는 세션이 아니라, 종료 단계에 접어든 파드로 ALB가 여전히 트래픽을 보내면서 발생하는 502 에러였다.

 

게다가 애플리케이션에 SIGTERM 핸들러(enableShutdownHooks)가 구현되어 있지 않아, SIGTERM 신호를 받으면 처리 중이던 요청(In-flight Request)을 즉시 중단했다. 애플리케이션 코드를 수정하지 않고 이를 막아야 했다.

 

 

 

 

preStop sleep 설정 하나로 해결하기

코드를 건드리지 않고 502 에러를 방어하는 가장 좋은 방법이 바로 preStop 훅이다.

terminationGracePeriodSeconds: 60
lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 35"]

 

 

 

 

이 설정을 적용하면 종료 순서가 다음과 같이 진행된다.

  1. 파드 상태가 Terminating으로 변경되며 서비스 엔드포인트(Endpoint)에서 제외된다.
  2. 이와 동시에 `preStop sleep 35s` 가 시작된다. (파드는 여전히 살아있으며 기존 요청을 처리한다.)
  3. 이 35초 동안 ALB는 IP 타겟의 등록 해제(`Deregistration Delay=30s`)를 완료하므로, 해당 파드로 들어오는 새로운 트래픽은 0이 된다.
  4. 35초 후 preStop이 종료되면 SIGTERM 신호가 전달되고 프로세스가 안전하게 종료된다. (이 시점에는 들어오는 트래픽이 없다.)

 

 

 

여기서 핵심은 세 가지 시간의 유기적인 관계다.

preStop sleep(35s) > ALB deregistration_delay(30s) 등록 해제가 완전히 끝날 때까지 파드가 살아있으므로, 종료 중인 파드로 새로운 요청이 유입되는 것을 막는다.

terminationGracePeriodSeconds(60s) > preStop(35s) preStop이 끝난 후 SIGTERM이 정상적으로 전달되며, 남은 25초 동안 애플리케이션이 안전하게 프로세스를 정리할 수 있다.

 

 

 

 

이 흐름이 성립하려면 Ingress 설정에 `target-type: ip` 와 `deregistration_delay=30s` 가 반드시 선행되어야 한다. 이 방식을 적용하면 애플리케이션이 자체적으로 SIGTERM을 처리하지 못하더라도, 롤링 배포나 재시작 시 발생하는 502 에러를 완전히 없앨 수 있다.

# Ingress (AWS Load Balancer Controller)
metadata:
  annotations:
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/target-group-attributes: deregistration_delay.timeout_seconds=30

참고: ASP.NET Core 같은 일부 런타임은 SIGTERM을 받으면 기본적으로 약 30초간 자체 드레인(Drain)을 수행한다.

이러한 애플리케이션은 preStop(35s) + 앱 자체 드레인(~30s) = 75s 처럼 전체 Grace Period를 더 길게 잡아야 한다. 반면 Node.js처럼 기본 드레인 기능이 없는 런타임은 preStop이 사실상 유일한 방어선이다.

 

 

 

 

 

런타임마다 SIGTERM 행동이 다르다

 

preStop이 "ALB가 트래픽을 끊어줄 시간"을 벌어준다면, 그 뒤에 날아오는 SIGTERM을 앱이 어떻게 받아들이느냐는 런타임마다 완전히 다르다. 이걸 모르고 preStop sleep만 넣으면 502가 그대로 남는 경우가 있다. 크게 세 부류로 나뉜다.

런타임 SIGTERM  받으면 기본 drain 해야 할 일
ASP.NET Core (.NET 5+) 자동 graceful drain ~30초 거의 그대로 OK (.NET Core 3.x 이하는 5초)
Spring Boot 기본 즉시 종료(immediate) graceful 켜면 ~30초 server.shutdown=graceful 설정 필요
Node.js / Go 즉시 종료 없음 preStop이 유일한 방어선 (앱 핸들러 권장)
Python (gunicorn/uvicorn) graceful ~30초 대체로 OK
nginx / PHP-FPM fast 종료 → 처리 중인 요청(in-flight) 끊김 SIGQUIT 전환 필수

 

 

 

특히 마지막 줄이 함정이다. nginx는 SIGTERM을 "graceful"이 아니라 fast shutdown(즉시 종료)으로 해석해, 처리 중이던 요청을 그냥 끊어버린다. 즉 preStop sleep으로 트래픽을 다 빼놨어도, 마지막에 날아오는 SIGTERM 한 방에 처리 중인(in-flight) 요청이 끊겨 502가 난다.

 

해결책은 `STOPSIGNAL SIGQUIT` 로 종료 시그널 자체를 바꾸거나, preStop에서 `nginx -s quit` 을 보내는 것이다. (PHP-FPM도 동일한 이유로 process_control_timeout 설정이 필요하다.)

 

 

다행히 공식 nginx 도커 이미지는 2020년 11월부터 기본 STOPSIGNAL이 SIGQUIT으로 바뀌었으니, 최신 공식 이미지를 쓴다면 이 문제는 없다. 커스텀 이미지나 오래된 base를 쓴다면 반드시 확인하자. 

  • STOPSIGNAL은 컨테이너 종료 시 PID 1로 보낼 시그널을 지정하는 Dockerfile 명령어로, 지정하지 않으면 기본값이 SIGTERM이다.
  • nginx 이미지에 STOPSIGNAL SIGQUIT 한 줄을 추가하면 종료 시그널이 SIGQUIT으로 바뀌어, 별도 preStop 없이도 graceful shutdown이 동작한다.

 

 

 

 

우리 앱의 drain 시간은 어떻게 재는가

알아야 할 건 두 가지뿐이다.

 

① 이 앱이 SIGTERM에 in-flight를 끝까지 처리하는가 아니면 끊는가,

② 처리한다면 몇 초 걸리는가. 가장 간단한 검증은 단독 컨테이너에 직접 시그널을 쏴보는 것이다.

# 응답이 오래 걸리는 엔드포인트(예: 10초)를 in-flight 상태로 만들고
curl -I localhost &
# 종료시키면서 소요 시간을 측정
/usr/bin/time --format '%E' docker stop <container>
# (컨테이너 내부라면) kill -SIGTERM 1

 

 

 

 

이때 그 요청이 200으로 정상 종료되는지 vs Empty reply from server로 끊기는지가 곧 graceful 여부 판별이고, docker stop에 걸린 시간이 실제 drain 소요 시간이다.

 

쿠버네티스에서 실측하려면 `terminationGracePeriodSeconds` 를 넉넉히 둔 뒤, 앱 로그에 SIGTERM 수신 시각마지막 요청 완료 시각을 찍어 그 차이를 보면 된다. 동시에 vegeta·k6·hey 같은 도구로 지속 트래픽을 흘리며 `rollout restart` 를 돌려 5xx를 세면 실전 검증까지 끝난다.

 

 

preStop sleep 값은 worst-case가 아니라 p99 처리시간을 기준으로 역산하는 것이 정석이다. 최종 공식은 다음과 같다.

terminationGracePeriodSeconds
    > preStop sleep(≥ ALB deregistration_delay)
      + 앱 자체 drain 시간(≥ p99)
      + 여유

 

 

 

 

 

 

 

 

3. Pod Readiness Gate

인입(등록) 측 무중단 preStop이 "파드가 안전하게 빠져나가는 길"(outbound)을 책임진다면, Pod Readiness Gate는 그 정반대인 "파드가 안전하게 들어오는 길"(inbound)을 책임진다. 이 둘은 한 쌍이며, 둘이 합쳐졌을 때 비로소 배포가 양방향 무중단이 된다.

 

 

인입 측에서 502가 발생하는 이유

ALB가 target-type: ip 모드이면 Pod IP를 타겟 그룹에 직접 등록한다. 그런데 쿠버네티스의 기본 readinessProbe는 오직 "파드 안의 앱이 떴는가"만 판단할 뿐, "ALB가 이 파드를 타겟으로 등록하고 헬스체크까지 통과시켰는가"는 전혀 알지 못한다. 그래서 롤링 배포 시 다음과 같은 타이밍 갭이 생긴다.

새 파드 앱 기동 → readinessProbe 통과 → 쿠버네티스가 "Ready" 판정 → 구 파드 종료 시작
                                              ↑
                        하지만 이 순간 ALB는 새 파드 IP를 아직
                        타겟 그룹에 등록·헬스체크 완료하지 못한 상태

 

 

이 갭 동안 트래픽이 갈 곳을 잃어 502 Bad Gateway / connection reset이 순간적으로 발생한다.

 

섹션 2의 preStop이 "죽는 파드"의 502를 막았다면, 이 문제는 정반대로 "새로 뜨는 파드"가 아직 트래픽을 받을 준비가 안 된 상태에서 교체가 진행되어 생기는 502다. 특히 스케일아웃(HPA)이나 대량 롤아웃처럼 한 번에 여러 파드가 동시에 교체될 때 두드러진다.

 

 

Readiness Gate가 동작하는 순서

Readiness Gate는 "ALB 타겟이 healthy 한가?"를 파드의 Ready 조건에 하나 더 추가한다. 섹션 2의 종료 순서를 거울처럼 뒤집은 흐름이다.

새 파드 생성 → 앱 기동 → readinessProbe 통과
  → 하지만 readinessGate가 막아 아직 NotReady
     └ 이 동안 ALB가 IP 타겟 등록(registration) + 헬스체크 통과를 진행
  → gate가 True로 전환 → 비로소 파드 Ready
  → 그제서야 구 파드 종료 (트래픽 받을 준비가 끝난 뒤에야 교체)

 

 

즉 ALB가 새 파드로 트래픽을 보낼 준비가 끝난 것이 확인된 뒤에야 구 파드를 죽인다. preStop이 outbound drain을, Readiness Gate가 inbound registration을 담당해 인입·배출 양방향이 모두 막힌다.

 

 

 

앱 코드 변경 없이 라벨 하나로 적용하기

preStop과 마찬가지로 애플리케이션 코드는 한 줄도 건드릴 필요가 없다. 적용 대상 네임스페이스에 라벨을 하나 붙이면, AWS Load Balancer Controller의 mutating webhook이 해당 네임스페이스에서 새로 생성되는 파드에 readiness gate를 자동으로 주입한다.

# 네임스페이스에 이 라벨만 있으면 webhook이 자동 주입
elbv2.k8s.aws/pod-readiness-gate-inject: enabled

 

 

GitOps 환경이라면 이 라벨을 ArgoCD ApplicationSet의 managedNamespaceMetadata.labels로 선언해, 네임스페이스 생성과 동시에 라벨이 따라붙도록 할 수 있다(차트·앱 수정 0).

 

이 방식이 성립하는 선행 조건은 섹션 2의 preStop과 정확히 동일하다 — Ingress가 target-type: ip 여야 한다. 두 기법 모두 "ALB가 Pod IP를 직접 타겟으로 다룬다"는 같은 전제 위에서 동작한다.

 

 

 

 

실제 적용 결과

2-replica 규모의 서비스에 적용한 직후, 새로 생성된 파드에는 target-health.elbv2.k8s.aws/<타겟그룹-ID> 형태의 readiness gate가 붙었고, ALB 등록이 끝나기 전까지 READY=False로 대기하다가 등록·헬스체크가 완료되는 순간 True로 전환되는 것을 확인했다. (하나의 파드가 여러 타겟 그룹에 물려 있으면 gate도 타겟 그룹 수만큼 붙는다.)

 

라벨이 붙기 전부터 떠 있던 기존 파드에는 gate가 소급 적용되지 않으므로, 활성화 시점에는 한 번의 rollout restart로 파드를 재생성해 주어야 gate가 실제로 부착된다.

 

 

 

트레이드오프

preStop과 달리 이 기법에는 명확한 비용이 있다. 파드가 ALB 헬스체크 통과를 기다리느라 Ready가 되기까지 보통 30초~2분이 더 걸려, 롤아웃 전체가 그만큼 느려진다.

 

이는 무중단을 위한 의도된 대가다. 다만 ALB 헬스체크 경로가 잘못 설정되어 있으면 새 파드가 영원히 Ready가 되지 못해 배포가 멈출 수 있으므로, healthcheck-path가 앱의 실제 헬스 엔드포인트와 일치하는지 반드시 선행 확인해야 한다.

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

4. strategy(RollingUpdate) — 규모별 최적의 조합과 병목의 변화

preStop이 "하나의 파드가 안전하게 죽는 방법"이라면, strategy는 "여러 파드를 어떤 순서와 리듬으로 교체할 것인가"를 정의한다.

  • maxSurge: 기준값(Desired)보다 파드를 최대 몇 개나 더 띄울 것인가 → 노드의 일시적인 증가를 결정
  • maxUnavailable: 배포 중 동시에 몇 개의 파드를 내릴 수 있는가 → 롤링 속도와 일시적인 용량 저하를 결정

 

 

예를들어 A라는 서비스는 Replica 수가 2개로 적기 때문에 절대값으로 1 / 1을 설정했다.

strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 1        # 기준값보다 최대 1개 더 생성 → 롤링 중 노드가 최대 1대 늘어날 수 있음
    maxUnavailable: 1  # 한 번에 최대 1개만 종료 (Replica 2~3개 규모에 적합)

 

 

 

서비스 규모가 커지면 병목이 바뀐다

소규모(2~20개) 환경에서 지켜야 할 가치는 "최소 안전 가용 용량 아래로 떨어지지 않기"였다. 하지만 Replica가 1,000개, 10,000개 규모로 증가하면 퍼센트 계산상 가용 용량은 더 이상 병목이 되지 않는다. 예를 들어 10,000개의 75%는 여전히 7,500개이므로 라이브 트래픽을 처리하기에 차고 넘치기 때문이다.

 

대규모 인프라에서 새로 등장하는 진짜 병목은 다음 네 가지다.

새로운 병목 지점 상세 내용
Endpoint / 데이터플레인 Churn 한 배치에 수백~수천 개 파드가 동시에 교체되면 EndpointSlice 갱신 부하가 발생하고, 모든 노드의 kube-proxy 재프로그래밍으로 이어져 API Server 부하가 폭증한다.
Endpoint 전파 지연 (Propagation Lag) 한 번에 빠지는 파드가 너무 많으면 ALB 및 kube-proxy 갱신 속도가 파드 종료 속도를 따라가지 못한다. 결국 이미 죽고 있는 파드로 트래픽이 유입되어 대량의 5xx 에러가 발생한다.
다운스트림 Thundering Herd 한 번에 수백 개의 새 파드가 동시에 실행되면 데이터베이스(DB) 커넥션 폭주, 캐시 콜드스타트(Cold Start), 커넥션 풀 재수립 부하가 한꺼번에 터진다.
폭발 반경 (Blast Radius) 배포 실패나 버그가 포함된 버전이 유출되었을 때, 잘못된 배치의 크기가 5개일 때와 2,500개일 때 프로덕션에 미치는 타격과 피해 규모는 완전히 다르다.

 

 

결과적으로 소규모 환경은 "얼마나 살려둘 것인가"가 기준이지만, 대규모 환경은 "한 번에 얼마나 흔들어야 인프라 시스템이 견디는가"가 배포 설계의 핵심 기준이 된다.

 

 

"25% 고정 법칙"이 대규모에서 깨지는 이유

많은 가이드에서 maxUnavailable: 25%를 표준처럼 권장한다. 이 방식은 전체 배치를 언제나 4번(100% / 25%)만에 끝내주기 때문에 소규모에서는 매력적이다. 하지만 대규모에서는 배치 수가 아니라 배치당 절대 규모(Blast Radius)가 시스템을 무너뜨리는 주범이 된다.

  • Replica 20개에서 25% → 배치당 5개 교체 (안전함)
  • Replica 1,000개에서 25% → 배치당 250개 교체 (위험함)
  • Replica 10,000개에서 25% → 배치당 2,500개 교체 (재앙에 가까움)

배치 횟수는 4번으로 동일하지만, 한 배치가 시스템을 흔드는 절대적인 양은 선형으로 커진다. 게다가 배치당 파드 수가 지나치게 많아지면 컨테이너 이미지 풀(Pull) 폭주, ALB 타겟 등록 지연, Readiness Probe 체크 부하, 다운스트림 커넥션 병목 때문에 오히려 배치 하나가 완료되는 데 걸리는 소요 시간 자체가 늘어난다. "4배치만에 빠르게 끝난다"는 안도감은 절반은 착각인 셈이다.

 

 

 

사고방식의 전환: "배치당 파드 수"를 고정하라

대규모 환경에서 올바른 배포 전략은 비율(%)을 고정하는 것이 아니라, "데이터플레인과 다운스트림 시스템이 한 번에 안정적으로 흡수할 수 있는 절대 파드 수(예: 50~200개)를 먼저 정의하고, 역으로 퍼센트를 도출"하는 것이다.

목표: 단일 배치당 교체 규모를 약 100개 이하로 제한한다.

Replica 1,000개 환경 ──> maxUnavailable: 10% (= 100개)
Replica 10,000개 환경 ──> maxUnavailable: 1% (= 100개)

 

 

 

이렇게 설계하면 Replica 규모가 10배로 커지더라도 한 번에 흔들리는 절대적인 양은 일정하게 유지되므로, 네트워크 Churn과 폭발 반경(Blast Radius)을 완벽하게 통제할 수 있다.

참고: 쿠버네티스 내부 메커니즘상 maxUnavailable 퍼센트는 내림(Round Down) 처리되고, maxSurge 퍼센트는 올림(Round Up) 처리된다.
대규모 플릿에서는 영향이 미비하지만, 소규모 배포 시 1개 파드의 차이를 만드는 원인이 바로 이 계산 방식에 있다.

 

 

maxSurge:  대규모일수록 '0'이 강력한 규칙으로 적용된다.

앞선 섹션 1의 결론(새 노드 생성을 억제하기 위해 maxSurge를 제한하는 것)은 대규모 환경에서 훨씬 더 강력한 규칙으로 작용한다.

  • 대규모에서 maxSurge를 퍼센트(예: 25%)로 잡을 경우: 10,000개 규모에서 2,500개의 새 파드를 미리 띄우기 위해 Karpenter가 수십~수백 대의 EC2 노드를 동시에 프로비저닝하기 시작한다. 이는 급격한 노드 난립(Node Sprawl)을 유발하며, 클라우드 API 및 프로비저닝 속도가 파드 생성 속도를 따라가지 못해 결국 롤아웃이 '노드 대기 상태'로 멈추는 병목이 발생한다.
  • maxSurge: 0 설정의 이점: 기존 파드가 빠지면서 생기는 빈 슬롯을 그대로 재사용하므로, 신규 노드 추가를 0대로 제한할 수 있다. 이미 대규모 플릿(Fleet)이 정상적으로 구성되어 있는 환경에 가장 최적화된 방식이다.

 

단, 배포 진행 중에 단 1%의 용량 저하(Dip)도 허용할 수 없는 초민감 서비스라면 maxSurge를 퍼센트가 아닌 명확한 상한선이 있는 절대값(예: 10~50)으로 제한하여 띄우는 것이 타협점이 된다.

 

 

서비스 규모별 배포 전략 확장 가이드

Replica 수 maxSurge maxUnavailable 예상 배치 수 배치당 파드 수 핵심 아키텍처 의도
2 ~ 3 1 (절대값) 1 (절대값) ~2회 1개 가용 용량 100% 보존 우선
10 ~ 20 0 25% 4회 3~5개 추가 노드 증가 없이 신속한 배포
100 ~ 200 0 10% 10회 10~20개 데이터플레인 Churn 발생 구간 진입, 비율 축소 시작
1,000 0 5% (또는 절대값 50) 20회 50개 한 배치의 규모를 데이터플레인이 흡수 가능한 선으로 제한
10,000 ~ 0 1~2% (또는 절대값 100~200) 50~100회

 

 

 

 

 

5. PodDisruptionBudget — 가용성의 최저 마지노선

preStop과 strategy만으로는 완벽하지 않다. Karpenter의 노드 통합(Consolidation)이나 일반적인 노드 드레인(Drain) 작업은 Deployment의 배포 경로가 아니라, '자발적 중단(Voluntary Disruption)' 경로를 통해 파드를 강제로 쫓아낸다(Evict).

 

이때 "동시에 파드를 몇 개까지 지워도 되는가"에 대해 strategy 설정은 아무런 보장을 해주지 못한다. 이 구멍을 메워주는 것이 바로 PDB다.

podDisruptionBudget:
  enabled: true
  minAvailable: 1

 

 

minAvailable: 1Replica: 2 조합을 적용하면, Karpenter가 비용 절감을 위해 노드를 비우려고 할 때 A 파드를 한 번에 모두 종료시키지 못하도록 강제한다. 반드시 최소 한 개 이상의 파드를 Ready 상태로 유지하도록 제한하며, 교체 파드가 정상적으로 구동된 것을 확인한 후에야 비로소 다음 파드를 쫓아낸다.

 

특히 대규모 환경으로 갈수록 maxUnavailable 설정과 PDB의 정렬(Alignment)이 필수적이다. 만약 Deployment의 배포 전략과 PDB의 허용 수치가 서로 어긋나게 설정되면, 롤아웃 배포 도중 파드 교체와 축출이 서로를 가로막아 배포가 영원히 멈춰버리는 데드락(Deadlock) 상태에 빠질 수 있다.

 

PDB는 Karpenter가 자원이 남는 노드들을 능동적으로 병합할 때, "반드시 Ready 상태를 유지하며 정해진 속도대로만 교체하라"는 강력한 속도 제한 역할을 해준다.

 

 

 

 

 

 

6.  대규모 환경에서의 전제 조건

문제의 근본적인 원인은 A 서비스 NodePool의 `WhenEmpty` 정책이었다. 이를 `WhenEmptyOrUnderutilized` 로 변경하고 `consolidateAfter: 5m` 설정을 추가하면, 파드가 걸쳐 있어서 낭비되던 잉여 노드를 자동으로 회수할 수 있다.

 

다만 인프라의 규모가 1,000개, 10,000개 이상으로 커지면 기본 세 축 외에도 아키텍처 관점에서 아래 인프라 레이어의 전제 조건들이 완벽하게 결합되어야만 502 에러와 시스템 장애를 방어할 수 있다.

  • AWS Load Balancer Controller의 'Pod Readiness Gate' 필수 적용: 앞서 섹션 3에서 다룬 Pod Readiness Gate는 소규모에서는 권장 사항이지만, 수백 개의 파드가 동시에 기동되는 대규모 환경에서는 선택이 아니라 필수로 격상된다. 한 배치에 수백 개의 새 파드가 ALB 타겟 그룹에 등록 후 헬스체크를 통과해 healthy가 되는 순간까지 교체 흐름을 붙잡아두지 못하면, 아직 트래픽을 받을 준비가 안 된 파드들로 인해 대량의 502 에러가 발생한다.
  • preStop sleep의 진가 발휘: 수백 개의 파드가 동시에 드레인되는 대규모 환경일수록 ALB의 타겟 해제(Deregistration Delay) 속도가 지연될 확률이 높다. 섹션 2에서 다룬 충분한 preStop sleep 시간과 여유로운 terminationGracePeriodSeconds 설계가 대규모 환경에서 연결 끊김을 막아주는 핵심 방어선이 된다.
  • 이미지 사전 풀(Pre-pull) 및 ECR 캐싱 구조: 한 배치에 100~200개의 신규 파드가 한꺼번에 컨테이너 이미지를 다운로드하면 ECR(Elastic Container Registry) 호출 대역폭과 노드의 네트워크 IO가 마비된다. 대규모 플릿에서는 이미지 가속화 장치나 사전 풀링 전략이 동반되어야 한다.
  • 다운스트림 시스템 보호 파라미터 점검: 배포 규모와 속도에 맞추어 백엔드 DB의 max_connections, Redis 커넥션 풀 상한선, 캐시 워밍(Cache Warming) 전략이 함께 정렬되어 있는지 반드시 사전 검증해야 한다.

 

 

 

 

 

7. Replica가 1개인 서비스는 어떻게 처리할 것인가 — PDB의 트레이드 오프

동일한 환경에 있는 B 서비스는 모Replica 수가 1개다. 여기에 PDB를 적용할 때는 치명적인 함정이 존재한다. Replica가 1개인데 `minAvailable: 1` 을 걸어버리면, 시스템은 이 유일한 파드를 절대 내릴 수 없게 된다. 결국 Karpenter의 노드 통합 기능 자체가 차단되어 노드가 회수되지 않는 초기 문제로 되돌아가게 된다.

 

따라서 Replica가 1개인 서비스에는 당장 PDB를 적용하지 않는다. 노드 통합이 일어날 때 아주 잠깐의 재기동 공백(Gap)이 발생하지만, 내부 어드민이나 배틀용 서비스의 특성상 해당 리스크는 감수하기로 결정했다. 대신 preStop을 통한 Graceful Drain은 동일하게 적용하여 502 에러는 방어해 두었다.

 

만약 이 서비스들도 완벽한 무중단이 필요한 시점이 온다면 해결책은 간단하다. Replica 수를 2개 이상으로 늘리고, 주석 처리해 둔 PDB 설정을 활성화하면 된다. PDB는 결국 "여분의 자원이 존재할 때만 의미가 있는 안전장치"라는 점을 잘 보여주는 사례다.

애플리케이션 Replica 수 preStop Drain PDB 설정 여부 비고
A 2 적용 minAvailable: 1 무중단 및 서비스 가용성 최우선
B 1 적용 제외 (주석 처리) Replica 2개 이상 확장 시 PDB 활성화 가능

 

 

 

 

 

 

 


 

 

 

 

 

솔직한 결론: 10,000개 규모라면 'RollingUpdate'를 졸업하라

쿠버네티스 네이티브 오브젝트인 RollingUpdate 전략은 구조적인 한계가 명확하다. 이 메커니즘은 "파드가 기술적으로 떴는가(Ready)"만 판단할 뿐, "새로 배포된 파드가 프로덕션 환경에서 실제 유저의 비즈니스 트래픽을 에러 없이 성공적으로 처리하고 있는가"에 대한 관제 능력은 전혀 없다.

 

 

Readiness Probe는 통과했으나 실제 런타임 진입 후 특정 에러를 뿜어내는 '악성 버전'이 배포된다고 가정해 보자. 10,000개 규모의 25% 배치 전략 환경이라면 배포 즉시 2,500개짜리 대형 장애 폭탄이 되어 전사 시스템을 강타하게 된다.

 

 

이 정도의 초대형 규모에 도달하면 네이티브 RollingUpdate를 졸업하고, Argo RolloutsFlagger 기반의 전진적 배포(Progressive Delivery) 체계로 전환하는 것이 정석이다.

  • 점진적 트래픽 전환: 배포가 시작되면 전체 트래픽의 1% → 5% → 25% → 50% → 100% 순으로 카나리(Canary) 환경에 실시간 제어하며 흘려보낸다.
  • 프로메테우스 메커니즘 연동 자동 롤백: 각 배포 단계마다 에러율, p99 레이턴시, SLO 등 프로덕션 핵심 메트릭을 시스템이 스스로 실시간 분석한다. 메트릭이 단 조금이라도 흔들리면 배포를 즉시 중단하고 자동으로 100% 안전하게 원복(Rollback)시킨다.
  • 폭발 반경의 극소화: 버그가 숨어있는 나쁜 버전을 25%(2,500개) 파드가 깨진 시점이 아니라, 시스템에 아무런 타격이 없는 최초 1%(100개) 단계에서 조기에 완벽하게 진압해 낼 수 있다.

 

 

최종 요약하자면 서비스 인프라 규모에 따른 아키텍처 로드맵은 다음과 같다.

  • Replica 2 ~ 20개 구간: RollingUpdate 전략(절대값 기반 수치 최적화)으로도 무중단 방어에 충분하다.
  • Replica 100 ~ 1,000개 구간: RollingUpdate 방식을 유지하되, 비율 고정이 아닌 "배치당 절대 파드 수 고정" 사고방식으로 선회해야 한다. 아울러 PDB와 AWS Pod Readiness Gate 설정을 완벽하게 정렬해야 병목을 피할 수 있다.
  • Replica 10,000개 이상 구간: 기본 쿠버네티스 배포 전략을 졸업하고, Argo Rollouts를 통한 Canary 기반 Progressive Delivery 아키텍처로 완전히 전환해야 한다. 서비스의 규모가 확장되면 인프라를 바라보는 정답의 패러다임도 완전히 바뀌어야 하기 때문이다.

 

 

 

 

 

 

 

 

 


Reference

 

 

 

 

 

Somaz | DevOps Engineer | Kubernetes & Cloud Infrastructure Specialist

728x90
반응형