Published on

BentoML+KServe GPU 롤링배포 실패 해결법

Authors

서빙 스택을 BentoML로 패키징하고, 쿠버네티스 배포는 KServe(특히 InferenceService)로 가져가면 운영 표준화가 쉬워집니다. 문제는 GPU 모델에서 롤링 배포(무중단 업데이트) 가 생각보다 자주 실패한다는 점입니다. 증상은 비슷합니다.

  • 새 리비전 Pod가 뜨지만 Ready가 안 되고 트래픽이 절대 넘어가지 않음
  • CrashLoopBackOff 또는 OOMKilled로 새 Pod가 반복 재시작
  • GPU가 1장인데 두 Pod가 동시에 뜨려다 nvidia 디바이스 할당 실패
  • 모델 로딩이 오래 걸려 progressDeadlineSeconds 또는 KServe의 준비 타임아웃에 걸림

이 글은 BentoML+KServe 조합에서 GPU 롤링 배포가 실패하는 대표 원인을 재현 가능한 형태로 분해하고, KServe 설정과 컨테이너/모델 로딩 전략을 통해 실제로 롤링이 되게 만드는 방법을 정리합니다.

관련해 인프라 측 504/타임아웃 진단 관점은 이 글도 같이 보면 도움이 됩니다: EKS ALB Ingress 504인데 Pod는 정상일 때

전제: BentoML 컨테이너를 KServe로 띄우는 방식

KServe는 기본적으로 predictor 컨테이너가 HTTP로 추론을 제공하면 됩니다. BentoML은 bentoml serve로 HTTP 서버를 띄우므로, KServe의 InferenceService에 BentoML 이미지를 넣는 패턴이 흔합니다.

핵심은 KServe가 트래픽 절체를 판단하는 기준이 “준비 상태(Ready) + 프로브 성공” 이라는 점입니다. GPU 모델은 로딩이 길고 메모리를 크게 먹기 때문에, 기본값으로는 새 리비전이 준비되기 전에 실패하거나, 준비되더라도 기존 리비전과 자원 경합이 나서 실패합니다.

실패 패턴 1: GPU 1장인데 롤링 중 새 Pod가 GPU를 못 잡음

증상

  • 새 Pod 이벤트에 Insufficient nvidia.com/gpu 혹은 스케줄링 대기
  • 혹은 스케줄은 됐는데 런타임에서 CUDA 초기화 실패
  • 롤링 배포가 “새 리비전 0%”에서 멈춤

원인

GPU 리소스는 requests/limits가 사실상 동일하게 동작하고, 한 GPU를 둘 이상의 Pod가 동시에 쓰도록 스케줄링하지 않습니다(일반적인 nvidia-device-plugin 기준). 그런데 롤링 배포는 기본적으로 기존 Pod를 유지한 채 새 Pod를 하나 더 띄우는 순간이 생깁니다.

즉, GPU가 1장이고 replicas=1이면, 롤링이라는 개념 자체가 물리적으로 불가능해지는 구간이 존재합니다.

해결 전략

1) 단일 GPU/단일 레플리카면 “롤링” 대신 “재생성(Recreate)”로 설계

무중단을 포기하는 대신, 실패 없는 배포를 우선하는 선택입니다. KServe는 내부적으로 Knative를 쓰는 경우가 많고, 트래픽 스플릿 기반 롤링을 기대하지만 GPU 1장 환경에서는 현실적으로 어렵습니다.

  • 선택지 A: GPU를 2장 이상으로 늘려 “동시 2 Pod”가 가능하게 만들기
  • 선택지 B: canary를 포기하고, 다운타임을 허용하는 재배포로 가기

운영적으로는 B를 택하되, ALB/게이트웨이 레벨에서 재시도/타임아웃을 조정해 사용자 영향 시간을 줄이는 방식이 자주 쓰입니다.

2) GPU를 늘릴 수 없다면 “미리 워밍업된 예비 노드”로 시간을 줄이기

GPU 노드 증설이 어렵다면, 모델 로딩 시간을 줄여 “재생성 동안의 공백”을 최소화해야 합니다.

  • 이미지 빌드 최적화(레이어 캐시, 멀티스테이지)
  • 모델 아티팩트 다운로드 최소화(가능하면 이미지에 포함)

이미지 빌드 시간을 줄이는 방법은 이 글의 패턴을 그대로 적용할 수 있습니다: Docker BuildKit 캐시·멀티스테이지로 CI 빌드 70% 단축

실패 패턴 2: 준비 프로브가 모델 로딩을 못 기다려서 실패

증상

  • 컨테이너 로그상 모델은 로딩 중인데, KServe/Knative가 준비 실패로 재시작
  • 이벤트에 Readiness probe failed 반복
  • RevisionMissing 또는 NotReady 상태가 지속

원인

GPU 모델은 첫 로딩에서 다음이 겹칩니다.

  • 가중치 로딩(수 GB)
  • CUDA 컨텍스트 초기화
  • 커널 컴파일/캐시(프레임워크에 따라)

이 과정이 수십 초~수 분이면, 기본 readinessProbe 타임아웃/실패 임계치로는 버티기 어렵습니다.

해결 전략

1) “모델 로딩 완료 후에만 200을 주는” 헬스 엔드포인트 만들기

BentoML 서비스에서 준비 상태를 명시적으로 관리하는 게 가장 확실합니다.

# service.py
import time
import threading
import bentoml
from bentoml.io import JSON

READY = False

@bentoml.service(resources={"gpu": 1})
class Svc:
    def __init__(self):
        # 비동기로 로딩하고, 준비 완료 시 READY=True
        def _load():
            global READY
            # 예: 모델 로딩(가중치, CUDA init 등)
            time.sleep(60)
            READY = True
        threading.Thread(target=_load, daemon=True).start()

    @bentoml.api(input=JSON(), output=JSON())
    def predict(self, payload):
        if not READY:
            # 준비 전에는 명확히 실패로 응답
            return {"error": "warming up"}
        return {"ok": True}

    @bentoml.api(input=JSON(), output=JSON())
    def healthz(self, _):
        return {"ready": READY}

중요 포인트는 준비 전에는 준비 엔드포인트가 실패하도록 만들고, KServe의 readiness가 이 엔드포인트를 보게 하는 것입니다.

2) KServe InferenceService에 충분한 프로브 여유를 준다

KServe 설치 형태에 따라 podSpec에 프로브를 직접 넣을 수 있습니다. 아래 예시는 predictor 컨테이너에 startupProbereadinessProbe를 명시해 “초기 로딩은 오래 기다리고, 준비 완료 후에는 빠르게 감지”하도록 구성합니다.

주의: KServe 버전/설치 방식에 따라 필드가 다를 수 있으니, 실제 클러스터 CRD 스키마에 맞춰 조정하세요.

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: bentoml-gpu
spec:
  predictor:
    containers:
      - name: predictor
        image: myrepo/bentoml-gpu:2026-02-25
        ports:
          - containerPort: 3000
        env:
          - name: BENTOML_PORT
            value: "3000"
        resources:
          limits:
            nvidia.com/gpu: "1"
            cpu: "2"
            memory: 12Gi
          requests:
            nvidia.com/gpu: "1"
            cpu: "1"
            memory: 8Gi
        startupProbe:
          httpGet:
            path: /healthz
            port: 3000
          # 모델 로딩이 5분 걸릴 수 있으면 넉넉히
          failureThreshold: 60
          periodSeconds: 5
          timeoutSeconds: 2
        readinessProbe:
          httpGet:
            path: /healthz
            port: 3000
          failureThreshold: 3
          periodSeconds: 5
          timeoutSeconds: 2

startupProbe는 “초기 기동 구간”을 별도로 다루기 때문에, readiness만 늘리는 것보다 안정적입니다.

실패 패턴 3: 롤링 중 메모리 스파이크로 OOMKilled

증상

  • 새 리비전이 뜨는 순간 OOMKilled
  • 기존 리비전은 정상인데 새 리비전만 죽음
  • 특히 대형 LLM/비전 모델에서 자주 발생

원인

롤링 순간에는 짧게나마 다음이 동시에 일어납니다.

  • 기존 Pod: 이미 GPU 메모리/호스트 메모리를 점유
  • 새 Pod: 모델 로딩을 위해 호스트 메모리 버퍼 + GPU 메모리 할당을 시도

또한 BentoML/프레임워크가 모델 로딩 중에 일시적으로 peak 메모리를 크게 쓰는 경우가 많습니다(가중치 로딩 버퍼, dtype 변환, 그래프 컴파일 등).

해결 전략

1) 메모리 requests/limits를 “피크 기준”으로 재산정

관찰 없이 감으로 잡으면 계속 실패합니다. 최소한 아래를 확인하세요.

  • kubectl describe pod로 종료 사유가 OOM인지
  • 컨테이너 로그에서 로딩 단계가 어디까지 갔는지
  • 노드 메모리 여유가 있는지

그리고 limits를 늘릴 수 없다면, 피크를 줄이는 쪽으로 가야 합니다.

2) 모델 경량화(양자화)로 로딩 피크를 낮춘다

운영에서 가장 효과가 큰 방법 중 하나가 정확도를 크게 깎지 않고 메모리만 줄이는 양자화입니다. 특히 GPU 롤링 실패는 “성능”보다 “기동 안정성”이 우선인 경우가 많습니다.

3) 로딩 시점 최적화: lazy init 대신 eager init

요청이 들어올 때 모델을 로딩(lazy)하면 readiness는 통과하지만 첫 요청에서 터질 수 있고, 롤링 중 트래픽 절체 타이밍에 장애가 납니다. 가능하면 컨테이너 부팅 시점에 로딩을 끝내고, readiness는 그 이후에만 통과시키는 게 안정적입니다.

실패 패턴 4: 트래픽은 넘어갔는데 첫 요청이 느려서 타임아웃

증상

  • 새 리비전으로 전환 직후 504 또는 클라이언트 타임아웃
  • Pod는 Ready인데 실제 추론은 “첫 요청”에서 오래 걸림

원인

준비 프로브는 “서버가 응답한다”만 확인하고, 실제 추론 워밍업(CUDA 커널 캐시, 그래프 컴파일, 텐서RT 엔진 로딩 등)을 보장하지 않습니다.

해결 전략

1) PostStart 훅 또는 별도 워밍업 Job으로 “첫 추론”을 미리 실행

컨테이너가 뜨자마자 더미 입력으로 1회 추론을 실행하면, 트래픽 절체 후 첫 요청 지연을 크게 줄일 수 있습니다.

예를 들어, 컨테이너 엔트리포인트에서 서버를 백그라운드로 띄운 뒤 워밍업 호출을 수행합니다.

#!/usr/bin/env bash
set -euo pipefail

bentoml serve service:Svc --port 3000 &
SERVER_PID=$!

# 서버 up 대기
for i in $(seq 1 60); do
  if curl -fsS http://127.0.0.1:3000/healthz >/dev/null; then
    break
  fi
  sleep 1
done

# 워밍업(실제 predict 호출)
curl -fsS -X POST http://127.0.0.1:3000/predict \
  -H 'content-type: application/json' \
  -d '{"warmup": true}' >/dev/null || true

wait $SERVER_PID

이 방식은 단순하지만, 워밍업이 길면 startupProbe와 함께 조정해야 합니다.

2) 게이트웨이/Ingress 타임아웃도 함께 조정

서버는 정상인데 504가 난다면, L7(예: ALB, NGINX, Istio) 타임아웃일 가능성이 큽니다. 특히 롤링 직후에는 워밍업으로 인해 응답이 길어질 수 있으니, “첫 요청” 구간만이라도 버틸 수 있게 타임아웃을 늘리거나 재시도를 둡니다.

진단 체크리스트는 이 글의 흐름이 그대로 적용됩니다: EKS ALB Ingress 504인데 Pod는 정상일 때

운영에서 통하는 디버깅 루틴

GPU 롤링 실패는 원인이 섞여 보이기 때문에, 아래 순서로 분리하면 빨라집니다.

1) KServe 상태부터 본다

kubectl get inferenceservice bentoml-gpu -o yaml
kubectl describe inferenceservice bentoml-gpu
  • 최신 리비전이 생성됐는지
  • Ready가 어디에서 막히는지(구성/라우팅/리비전)

2) 새 리비전 Pod 이벤트를 본다

kubectl get pods -n <namespace>
kubectl describe pod <pod-name>

여기서 거의 결론이 납니다.

  • Insufficient nvidia.com/gpu면 구조적으로 동시 기동 불가
  • Readiness probe failed면 프로브/헬스 설계 문제
  • OOMKilled면 메모리 피크/limits 문제

위 명령의 namespacepod-name 같은 값에 들어갈 문자열은 반드시 인라인 코드로 감싸 MDX 빌드 오류를 피하세요.

3) 컨테이너 로그는 “로딩 단계”를 찍어라

모델 로딩은 블랙박스가 되기 쉽습니다. 로딩 단계별로 로그를 남기면, readiness 타임아웃인지 OOM인지 구분이 빨라집니다.

import logging
log = logging.getLogger(__name__)

log.info("load: start")
# weights load
log.info("load: weights loaded")
# cuda init
log.info("load: cuda ready")
# warmup
log.info("load: warmup done")

권장 레퍼런스 구성(현실적인 타협)

GPU가 1장이고 replicas=1인 환경에서 “진짜 무중단 롤링”을 달성하려면 결국 GPU를 추가해야 합니다. 그게 불가능한 경우, 운영에서 실패를 줄이는 현실적인 구성은 다음 중 하나입니다.

  • 다운타임 허용 Recreate + 로딩 최적화 + 게이트웨이 재시도
  • GPU 2장 이상 확보 + canary 트래픽 스플릿 + startupProbe/readinessProbe 정교화

어느 쪽이든 공통으로 중요한 건 다음 3가지입니다.

  1. 준비 상태는 “서버 up”이 아니라 “모델 추론 가능”을 의미해야 함
  2. 초기 로딩은 startupProbe로 길게, 운영 중 체크는 readinessProbe로 짧게
  3. 롤링 순간의 피크 메모리와 GPU 동시 점유 가능성을 반드시 계산

마무리

BentoML+KServe 조합에서 GPU 모델 롤링 배포가 실패하는 이유는 대개 KServe 자체의 문제가 아니라, GPU라는 희소 자원 + 긴 모델 로딩 + 프로브/타임아웃 기본값이 충돌하기 때문입니다.

  • GPU 1장이라면 “롤링이 구조적으로 불가능한 순간”이 있다는 걸 먼저 인정하고 배포 전략을 바꾸거나
  • GPU를 늘릴 수 있다면, 프로브/워밍업/메모리 피크를 제어해 “새 리비전이 준비된 뒤에만” 트래픽을 넘기게 만들면 됩니다.

다음 단계로는, 실제 클러스터(KServe 버전, Knative 사용 여부, Istio/ALB 구성, GPU 플러그인 종류)에 맞춰 InferenceService 스펙과 프로브 필드를 정확히 맞추는 작업이 필요합니다. 원하시면 현재 사용 중인 KServe 버전과 InferenceService YAML, 그리고 새 리비전 Pod의 describe 이벤트를 기반으로 케이스별로 더 구체적인 수정안을 같이 잡아드릴 수 있습니다.