Published on

KServe로 vLLM 배포 - OOM·지연 3단계 해결

Authors

서빙용 LLM을 Kubernetes에 올릴 때, KServe와 vLLM 조합은 생산성이 높습니다. KServe는 트래픽 라우팅, 오토스케일, 관측, 롤아웃 같은 운영 기능을 제공하고, vLLM은 PagedAttention 기반으로 높은 처리량을 내며 OpenAI 호환 API까지 제공합니다.

하지만 현실에서는 두 문제가 반복됩니다.

  • OOM: 모델 로딩 직후 또는 트래픽이 몰릴 때 OOMKilled로 떨어짐
  • 지연: P99가 갑자기 튀고, 큐가 길어지며 타임아웃이 발생

이 글은 이를 "3단계"로 나눠 해결합니다.

  1. OOM을 재현 가능하게 만들고, 메모리 예산을 먼저 고정
  2. vLLM 파라미터로 KV 캐시와 동시성을 제어해 OOM을 제거
  3. KServe 레벨에서 프로브, 오토스케일, 롤링 전략으로 지연을 제어

운영 중 CrashLoopBackOff와 OOMKilled 판별이 필요하다면, 원인 분해 관점은 K8s CrashLoopBackOff 진단 - OOMKilled·Probe 글도 함께 보면 좋습니다.

전제: KServe에서 vLLM을 어떻게 띄우는가

KServe는 InferenceService로 워크로드를 정의합니다. vLLM은 컨테이너로 실행하고, HTTP 포트에 OpenAI 호환 엔드포인트를 노출합니다.

핵심은 다음 3가지가 서로 맞물린다는 점입니다.

  • Kubernetes resourcesrequests/limits와 노드 GPU 메모리
  • vLLM의 KV 캐시 크기와 배치 정책
  • KServe의 스케일링 기준 및 프로브 설정

이 중 하나라도 과하게 잡히면 OOM, 너무 보수적으로 잡히면 지연이 증가합니다.

1단계: OOM을 "측정 가능한 예산"으로 바꾸기

OOM을 해결하려면 먼저 "무엇이 얼마나 먹는지"를 고정해야 합니다. LLM 서빙에서 GPU 메모리는 대략 다음으로 나뉩니다.

  • 모델 가중치(Weight): 로딩 순간에 크게 먹고 비교적 고정
  • KV 캐시: 동시 요청 수, 컨텍스트 길이, 생성 길이에 따라 변동
  • 활성화/오버헤드: 커널, 프레임워크, fragmentation 등

1-1. OOM 유형부터 분류하기

증상이 같아도 원인이 다릅니다.

  • 컨테이너가 바로 재시작하고 이벤트에 OOMKilled가 보이면, 주로 "컨테이너 메모리 limit" 문제(대부분 CPU RAM)
  • GPU OOM은 파드가 죽지 않고 프로세스가 예외를 내거나, vLLM 로그에 CUDA OOM이 찍히는 형태가 많음

우선 이벤트부터 확인합니다.

kubectl describe pod -n llm <pod-name>
kubectl get events -n llm --sort-by=.lastTimestamp | tail -n 50

여기서 <pod-name> 같은 꺾쇠는 MDX에서 JSX로 오인될 수 있으니, 반드시 인라인 코드로 감쌌습니다.

1-2. requests/limits를 "의도적으로" 설정하기

LLM 서빙 파드는 CPU RAM도 상당히 씁니다. 토크나이저, 모델 로딩 버퍼, 로그, 메트릭, 런타임 오버헤드가 누적됩니다.

  • GPU는 nvidia.com/gpu: 1 같은 형태로 할당
  • CPU RAM은 resources.limits.memory를 충분히 주지 않으면 커널 OOM으로 죽습니다

예시로, GPU 1장짜리 vLLM 파드를 위한 초안입니다.

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: vllm-llama
  namespace: llm
spec:
  predictor:
    containers:
      - name: vllm
        image: vllm/vllm-openai:latest
        args:
          - "--model"
          - "meta-llama/Llama-3-8B-Instruct"
          - "--host"
          - "0.0.0.0"
          - "--port"
          - "8000"
        ports:
          - containerPort: 8000
        resources:
          requests:
            cpu: "2"
            memory: "8Gi"
            nvidia.com/gpu: "1"
          limits:
            cpu: "4"
            memory: "16Gi"
            nvidia.com/gpu: "1"

포인트는 memory를 "대충" 잡지 않는 것입니다. CPU RAM이 부족하면 GPU OOM처럼 보이지도 않고, 단순히 파드가 재시작합니다.

1-3. 노드 레벨 디스크/로그도 함께 확인

모델을 컨테이너에서 다운로드하거나 캐시를 쓰는 경우, 노드 디스크가 꽉 차서 예기치 않은 실패가 납니다. 특히 이미지 레이어와 모델 캐시가 누적되면 장애가 OOM처럼 보이기도 합니다.

노드 디스크가 100%에 가까운지, 삭제했는데도 회수가 안 되는 케이스는 리눅스 디스크 100% - 삭제해도 용량 안 줄 때 글의 체크리스트가 그대로 적용됩니다.

2단계: vLLM 설정으로 OOM을 제거하는 핵심 3가지

1단계에서 컨테이너/노드 예산을 고정했다면, 이제 GPU OOM과 지연을 만드는 주범인 KV 캐시와 동시성을 제어합니다.

vLLM에서 가장 영향이 큰 설정은 보통 다음입니다.

  • --gpu-memory-utilization
  • --max-model-len
  • --max-num-batched-tokens 또는 배치 관련 파라미터

버전별로 플래그 이름이 조금씩 다를 수 있으니, 실제 사용 중인 이미지의 --help 출력으로 확인하세요.

2-1. gpu-memory-utilization로 "안전한 상한"을 만든다

vLLM은 GPU 메모리를 최대한 활용하려고 합니다. 하지만 K8s 환경에서는 다음이 겹치며 여유가 필요합니다.

  • 드라이버/런타임 오버헤드
  • NCCL 또는 기타 통신 버퍼(멀티 GPU일 때)
  • fragmentation

따라서 1.0에 가깝게 두면 작은 스파이크에도 OOM이 납니다. 운영에서는 보통 0.85에서 0.92 사이에서 시작해 튜닝합니다.

args:
  - "--model"
  - "meta-llama/Llama-3-8B-Instruct"
  - "--gpu-memory-utilization"
  - "0.90"

2-2. max-model-len을 현실적으로 제한한다

컨텍스트 길이가 길수록 KV 캐시가 기하급수적으로 커집니다. 특히 "요청이 동시에 들어오는" 서빙 환경에서는, 최대 컨텍스트를 모델 스펙대로 열어두면 OOM이 매우 흔합니다.

권장 접근은 다음과 같습니다.

  • 제품 요구사항으로 "최대 입력 토큰"을 먼저 정의
  • 그에 맞춰 --max-model-len을 제한
  • 장문 요청은 별도 워크로드(다른 InferenceService)로 분리
args:
  - "--max-model-len"
  - "4096"

이 한 줄만으로도 OOM이 사라지는 경우가 많습니다. 대신 긴 문서 요약 같은 케이스는 잘리므로, API 계약을 함께 조정해야 합니다.

2-3. 배치 토큰 상한으로 "지연 폭발"을 막는다

vLLM은 동적 배치로 처리량을 올리지만, 과도한 배치는 큐잉 지연을 키웁니다. 즉, 처리량은 좋아 보이는데 P99가 폭발하는 전형적인 상황이 나옵니다.

이때는 배치 상한을 둬서 한 번에 처리하는 토큰량을 제한합니다.

args:
  - "--max-num-batched-tokens"
  - "8192"

튜닝 방법은 간단합니다.

  • P50이 낮은데 P99만 높다면 배치가 과할 가능성이 큼
  • GPU 사용률이 낮고 지연이 높다면 배치가 너무 보수적일 가능성이 큼

이 값은 모델 크기, GPU 종류, 평균 입력 길이에 따라 달라서 "정답"은 없습니다. 다만 상한이 없을 때보다 운영 안정성이 크게 좋아집니다.

3단계: KServe에서 지연을 잡는 운영 설정 3종 세트

vLLM 파라미터로 OOM을 줄였는데도 지연이 흔들린다면, 대부분 KServe의 다음 설정이 원인입니다.

  • 프로브가 너무 공격적이어서 롤아웃 중 트래픽이 흔들림
  • 오토스케일 기준이 실제 병목과 다름
  • 스케일 아웃 시 콜드스타트(모델 로딩)가 길어 순간 지연이 급증

3-1. 프로브는 "모델 로딩 시간"을 반영해야 한다

LLM은 기동 시간이 길 수 있습니다. 그런데 readiness probe가 너무 빨리 실패하면, 파드가 준비되기 전에 재시작하거나 트래픽이 다른 파드로 쏠립니다.

KServe에서는 컨테이너 레벨 프로브를 직접 넣을 수 있습니다.

readinessProbe:
  httpGet:
    path: "/v1/models"
    port: 8000
  initialDelaySeconds: 30
  periodSeconds: 10
  timeoutSeconds: 2
  failureThreshold: 12
livenessProbe:
  httpGet:
    path: "/health"
    port: 8000
  initialDelaySeconds: 60
  periodSeconds: 20
  timeoutSeconds: 2
  failureThreshold: 6
  • readiness는 "트래픽을 받아도 되는가"에 집중
  • liveness는 "프로세스가 죽었는가"에 집중

모델 로딩이 2분 걸리는데 initialDelaySeconds가 5초면, 운영이 불가능합니다.

3-2. 오토스케일 기준을 동시성에 맞춘다

KServe는 Knative 기반 오토스케일을 사용하는 경우가 많고, 흔히 동시성 기준으로 스케일합니다. 그런데 vLLM은 "동시 요청"과 "토큰 처리량"이 얽혀 있습니다.

  • 동시성 목표가 너무 낮으면 파드가 불필요하게 늘어남(비용 증가)
  • 동시성 목표가 너무 높으면 한 파드에 큐가 쌓여 P99가 폭발

아래는 개념 예시입니다. 실제 어노테이션 키는 설치 구성에 따라 다를 수 있으니, 사용 중인 KServe 및 Knative 버전 문서를 확인하세요.

metadata:
  annotations:
    autoscaling.knative.dev/metric: "concurrency"
    autoscaling.knative.dev/target: "4"
    autoscaling.knative.dev/minScale: "1"
    autoscaling.knative.dev/maxScale: "10"

운영 팁은 다음입니다.

  • 먼저 minScale을 1 이상으로 두고(콜드스타트 제거), 지연을 안정화
  • 그 다음 target을 올리거나 내려서 비용과 지연의 균형점을 찾기

3-3. 롤링 업데이트 시 "한 번에" 너무 많이 바꾸지 않는다

모델 버전, vLLM 이미지, 플래그, 리소스 제한을 동시에 바꾸면, 지연 스파이크가 나도 원인을 특정하기 어렵습니다.

권장 전략은 다음입니다.

  • 변경은 한 번에 하나씩
  • 카나리 트래픽으로 먼저 검증
  • 실패 시 즉시 롤백 가능한 배포 단위를 유지

Git 워크플로 관점에서 변경을 잘게 쪼개고 충돌을 줄이는 방법은 Git rebase 충돌 최소화 - rerere·autosquash 실전 글의 접근이 인프라 코드에도 그대로 유효합니다.

실전 예시: OOM과 지연을 함께 잡는 InferenceService 템플릿

아래는 앞의 포인트를 합친 예시입니다.

  • CPU RAM limit을 명시
  • vLLM에서 GPU 메모리 사용률 상한
  • max context 제한
  • 배치 토큰 상한
  • readiness/liveness로 롤아웃 안정화
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: vllm-llama
  namespace: llm
  annotations:
    autoscaling.knative.dev/metric: "concurrency"
    autoscaling.knative.dev/target: "4"
    autoscaling.knative.dev/minScale: "1"
    autoscaling.knative.dev/maxScale: "6"
spec:
  predictor:
    containers:
      - name: vllm
        image: vllm/vllm-openai:latest
        args:
          - "--model"
          - "meta-llama/Llama-3-8B-Instruct"
          - "--host"
          - "0.0.0.0"
          - "--port"
          - "8000"
          - "--gpu-memory-utilization"
          - "0.90"
          - "--max-model-len"
          - "4096"
          - "--max-num-batched-tokens"
          - "8192"
        ports:
          - containerPort: 8000
        readinessProbe:
          httpGet:
            path: "/v1/models"
            port: 8000
          initialDelaySeconds: 30
          periodSeconds: 10
          timeoutSeconds: 2
          failureThreshold: 12
        livenessProbe:
          httpGet:
            path: "/health"
            port: 8000
          initialDelaySeconds: 60
          periodSeconds: 20
          timeoutSeconds: 2
          failureThreshold: 6
        resources:
          requests:
            cpu: "2"
            memory: "8Gi"
            nvidia.com/gpu: "1"
          limits:
            cpu: "4"
            memory: "16Gi"
            nvidia.com/gpu: "1"

트러블슈팅 체크리스트: 10분 안에 보는 순서

운영 중 장애가 나면 아래 순서로 보면 빠릅니다.

  1. kubectl describe pod에서 OOMKilled 여부 확인(컨테이너 RAM 문제인지 먼저 분리)
  2. vLLM 로그에서 CUDA OOM 메시지 확인(GPU OOM인지)
  3. --max-model-len이 과도하게 큰지 확인(가장 흔한 원인)
  4. --gpu-memory-utilization을 0.85에서 0.92 사이로 조정
  5. P99 지연이 높으면 --max-num-batched-tokens로 배치 상한 설정
  6. readiness/liveness의 initialDelaySeconds가 모델 로딩 시간보다 짧지 않은지 확인
  7. 오토스케일 target이 너무 높아 큐가 쌓이지 않는지 확인
  8. minScale을 1로 두고 콜드스타트 제거 후 재측정
  9. 노드 디스크, 이미지 캐시, 모델 캐시로 인한 실패가 없는지 확인
  10. 변경은 한 번에 하나씩, 카나리로 검증

마무리: 3단계의 핵심은 "예산 고정 → 동시성 제어 → 운영 안정화"

KServe로 vLLM을 배포할 때 OOM과 지연은 대부분 "메모리 예산이 불명확"하거나 "동시성 상한이 없다"는 데서 시작합니다.

  • 1단계에서 컨테이너 RAM과 이벤트로 OOM 유형을 분리하고 예산을 고정
  • 2단계에서 vLLM의 컨텍스트 길이와 배치 토큰 상한으로 KV 캐시를 통제
  • 3단계에서 프로브와 오토스케일을 모델 로딩 시간과 지연 목표에 맞춤

이 3가지만 체계적으로 적용해도, OOMKilled 재시작 루프와 P99 지연 폭발을 대부분 제거할 수 있습니다.