Published on

KServe로 GPU LLM 배포 - 콜드스타트 0에 가깝게

Authors

서빙 환경에서 LLM 콜드스타트는 단순히 Pod 가 뜨는 시간만 의미하지 않습니다. GPU 노드 프로비저닝, 이미지 풀링, 모델 다운로드, 가중치 로딩, CUDA 커널 워밍업, KV 캐시/메모리 할당, 라우팅 준비까지 합쳐져 사용자 입장에서는 수십 초에서 수 분의 지연으로 체감됩니다.

KServe는 InferenceService 라는 일관된 추상화로 라우팅, 오토스케일링, 관측을 묶어주지만, GPU LLM은 “기본값”만으로는 콜드스타트를 충분히 줄이기 어렵습니다. 이 글은 콜드스타트를 0 에 가깝게 만들기 위한 현실적인 접근을 네 계층으로 나눠 설명합니다.

  • 노드 계층: GPU 노드가 이미 떠 있어야 한다
  • 이미지 계층: 컨테이너 이미지가 이미 캐시되어야 한다
  • 모델 계층: 가중치가 로컬에 있고 로딩이 빠르며, 가능한 한 재사용되어야 한다
  • 트래픽 계층: 오토스케일링이 “0에서 1”로 튀지 않도록 최소 가용성을 유지해야 한다

KServe GPU LLM 콜드스타트의 병목 지도

GPU LLM에서 흔히 마주치는 지연 구간을 먼저 쪼개면, 어디에 투자해야 효과가 큰지 보입니다.

1) 노드 프로비저닝

  • 클러스터 오토스케일러가 GPU 노드를 올리는 데 수십 초~수 분
  • GPU 드라이버/디바이스 플러그인 준비

2) 이미지 풀

  • 수 GB 이미지 풀링
  • 레이어 캐시 미스, 사설 레지스트리 속도

3) 모델 다운로드 및 로딩

  • S3, PVC, HF Hub 등에서 수~수십 GB 다운로드
  • safetensors 로딩 및 텐서 배치
  • 텐서 병렬/파이프라인 병렬 초기화

4) 런타임 워밍업

  • 첫 요청에서 CUDA 커널 컴파일/캐시
  • vLLM 같은 엔진은 첫 프롬프트에서 메모리 풀과 KV 캐시가 안정화

결론은 단순합니다. “스케일 투 제로”를 유지하면서 “콜드스타트 0”을 달성하는 것은 물리적으로 어렵습니다. 대신 최소 레플리카를 1 이상 유지하고, 노드/이미지/모델을 미리 워밍하는 쪽이 실전에서 가장 효과적입니다.

아키텍처 선택: KServe에서 LLM을 어떻게 붙일까

KServe는 크게 두 가지 패턴이 있습니다.

  1. Custom predictor: 내가 만든 서버(예: vLLM, TGI, Triton, FastAPI)를 컨테이너로 올리고 KServe가 라우팅/스케일링
  2. ModelMesh: 다중 모델을 한 풀에서 운영하는 방식(LLM에서는 모델 크기 때문에 적용이 제한적)

GPU LLM은 보통 vLLM 또는 TGI 를 컨테이너로 패키징한 뒤 KServe predictor 로 넣는 방식이 단순하고 튜닝 포인트가 명확합니다.

목표: “콜드스타트 0에 가깝게”의 현실적인 정의

운영에서 의미 있는 목표를 다음처럼 잡는 것을 권합니다.

  • 평상시 p95 첫 토큰 지연이 안정적
  • 트래픽이 0으로 내려가도 완전 0으로 스케일하지 않고 minReplicas=1 로 유지
  • 장애/배포 직후에도 첫 요청이 수 초 내 응답하도록 이미지/모델 워밍

즉, 비용을 조금 쓰더라도 항상 뜨는 최소 1개를 유지하고, 나머지는 오토스케일로 처리합니다.

핵심 1: 최소 레플리카와 오토스케일 튜닝

KServe는 내부적으로 Knative 기반 오토스케일링을 사용하거나, 환경에 따라 HPA 기반으로 구성합니다. 콜드스타트를 줄이려면 가장 먼저 minReplicas1 이상으로 두는 것이 효과가 큽니다.

아래는 GPU LLM용 InferenceService 예시입니다. 포인트는 다음입니다.

  • minReplicas: 1 로 0 스케일 방지
  • maxReplicas 는 GPU 여유와 동시성 목표에 맞춤
  • containerConcurrency 로 한 파드가 처리할 동시 요청 수를 제한(LLM은 무작정 높이면 지연이 폭발)
  • resources.limitsnvidia.com/gpu 를 명시
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: llm-vllm
  namespace: llm
spec:
  predictor:
    minReplicas: 1
    maxReplicas: 4
    containerConcurrency: 2
    timeout: 600
    containers:
      - name: predictor
        image: my-registry.example.com/llm/vllm:0.6.3
        args:
          - "--model=/models/llama"
          - "--host=0.0.0.0"
          - "--port=8000"
          - "--gpu-memory-utilization=0.90"
          - "--max-model-len=8192"
        ports:
          - containerPort: 8000
        resources:
          requests:
            cpu: "2"
            memory: "8Gi"
            nvidia.com/gpu: "1"
          limits:
            cpu: "4"
            memory: "16Gi"
            nvidia.com/gpu: "1"
        volumeMounts:
          - name: model-store
            mountPath: /models
    volumes:
      - name: model-store
        persistentVolumeClaim:
          claimName: llm-model-pvc

동시성 튜닝의 함정

LLM은 CPU 웹서버처럼 동시성만 올리면 처리량이 늘지 않습니다. GPU 메모리와 KV 캐시가 병목이 되기 때문에, 동시성을 올리면 오히려 p95 가 급격히 나빠질 수 있습니다. 실전에서는 다음 순서로 튜닝합니다.

  • containerConcurrency 를 낮게 시작(예: 1 또는 2)
  • maxNumBatchedTokens 같은 엔진 파라미터로 배치 최적화
  • maxReplicas 로 수평 확장

핵심 2: 노드 콜드스타트 제거(노드 풀 워밍)

파드가 아무리 빨리 떠도 GPU 노드가 없으면 끝입니다. 따라서 “콜드스타트 0”의 첫 번째 조건은 GPU 노드가 상시 대기하는 것입니다.

방법 A: GPU 노드 그룹 최소 용량 유지

  • 클러스터 오토스케일러 또는 Karpenter에서 GPU 노드 풀의 minSize1 이상
  • 비용은 들지만 가장 확실

방법 B: 스케줄링을 통한 상시 점유(더미 파드)

  • GPU를 실제로 점유하지 않는 더미 파드로 노드만 띄우는 방식은 환경마다 제약이 큽니다
  • GPU 리소스 스케줄링이 걸리면 결국 GPU도 점유할 수 있어 비권장

방법 C: 예약 인스턴스/스팟 혼합

  • 기본 1대는 온디맨드, 추가 확장은 스팟
  • LLM은 요청이 길어 중단 내성이 낮을 수 있으므로 스팟은 신중

핵심 3: 이미지 풀 시간을 없애기(프리풀)

이미지 풀은 생각보다 큰 병목입니다. 특히 LLM 서빙 이미지는 CUDA, PyTorch, 엔진 라이브러리로 커지기 쉽습니다.

DaemonSet으로 이미지 프리풀

GPU 노드에 새 노드가 붙을 때마다 특정 이미지를 미리 풀링하도록 DaemonSet 을 둘 수 있습니다. 아래 예시는 sleep 컨테이너로 이미지를 “한 번” 받아 캐시에 남기는 패턴입니다.

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: image-prepull-vllm
  namespace: llm
spec:
  selector:
    matchLabels:
      app: image-prepull-vllm
  template:
    metadata:
      labels:
        app: image-prepull-vllm
    spec:
      nodeSelector:
        nvidia.com/gpu.present: "true"
      containers:
        - name: prepull
          image: my-registry.example.com/llm/vllm:0.6.3
          command: ["/bin/sh", "-c", "sleep 360000"]
          resources:
            requests:
              cpu: "10m"
              memory: "32Mi"
      tolerations:
        - key: "nvidia.com/gpu"
          operator: "Exists"
          effect: "NoSchedule"

운영 팁:

  • 프리풀 전용 이미지는 가능한 작게(불필요한 툴 제거)
  • 레지스트리 근접 배치, 레이어 캐시 최적화

핵심 4: 모델 로딩을 빠르게(스토리지와 포맷)

모델 로딩은 콜드스타트의 절반 이상을 차지하는 경우가 많습니다. “처음 한 번만 느리게” 만들고 이후 재시작에서도 빠르게 만드는 것이 핵심입니다.

모델 저장소 선택

  • PVC 로 노드 로컬 캐시를 활용하면 재시작이 빨라짐
  • 원격 오브젝트 스토리지(S3 등)에서 매번 내려받으면 느림

S3를 모델 스토어로 쓰는 경우 권한/정책 문제로 갑자기 403 이 터지면 콜드스타트가 아니라 “영구 장애”가 됩니다. 운영 중 S3 권한 이슈를 빠르게 진단하는 체크리스트는 다음 글이 도움이 됩니다.

파일 포맷과 로더

  • safetensors 는 보통 bin 대비 로딩이 빠르고 안전
  • 텐서 병렬을 쓰면 shard 파일 구성과 로더 옵션이 중요

초기 워밍업 요청

모델이 뜬 직후 첫 요청이 느린 문제는 “가짜 트래픽”으로 상당 부분 해결됩니다. startupProbe 가 성공한 직후 워밍업을 한 번 쏴서 CUDA 커널과 메모리 풀을 미리 준비시키는 방식입니다.

K8s에서 워밍업을 넣는 가장 단순한 방법은 postStart 훅입니다.

lifecycle:
  postStart:
    exec:
      command:
        - "/bin/sh"
        - "-c"
        - |
          sleep 5
          curl -s -X POST http://127.0.0.1:8000/v1/completions \
            -H 'Content-Type: application/json' \
            -d '{"model":"local","prompt":"warmup","max_tokens":1}' >/dev/null || true

주의점:

  • 서버가 아직 바인딩 전이면 실패할 수 있으니 sleep 또는 재시도 로직 필요
  • 워밍업 요청이 실제 엔진 경로를 타도록 엔드포인트를 맞춰야 함

KServe 프로브 설계: 준비 완료를 “진짜 준비”로 정의하기

LLM 서빙에서 readinessProbe 를 단순 HTTP 200 으로 두면 문제가 생깁니다. 프로세스는 살아 있지만 모델 로딩이 끝나지 않았는데 트래픽이 들어와 첫 요청이 타임아웃 날 수 있습니다.

권장 패턴:

  • startupProbe 는 모델 로딩 완료까지 넉넉히
  • readinessProbe 는 “모델 로딩 완료 + 추론 가능”을 확인

예시:

startupProbe:
  httpGet:
    path: /health
    port: 8000
  failureThreshold: 180
  periodSeconds: 5
readinessProbe:
  httpGet:
    path: /ready
    port: 8000
  failureThreshold: 3
  periodSeconds: 5
livenessProbe:
  httpGet:
    path: /health
    port: 8000
  failureThreshold: 3
  periodSeconds: 10

서버 구현에서 /ready 는 반드시 “모델이 메모리에 올라가 있고 첫 토큰 생성이 가능한 상태”를 의미하도록 하세요.

메모리·OOM이 콜드스타트를 악화시키는 방식

LLM은 GPU 메모리뿐 아니라 CPU 메모리도 크게 씁니다. 재시작이 반복되면 사용자는 이를 콜드스타트로 느낍니다. 특히 다음 상황이 흔합니다.

  • OOMKilled 로 파드가 재시작
  • 재시작 때마다 모델 재다운로드/재로딩
  • 트래픽이 몰릴 때 동시성 증가로 메모리 폭발

K8s에서 OOMKilled 를 반복적으로 겪는 경우는 콜드스타트 최적화 이전에 반드시 잡아야 합니다. 메모리 리밋과 GC, 워킹셋을 진단하는 방법은 아래 글을 함께 참고하세요.

운영에서 자주 쓰는 “거의 0 콜드스타트” 조합

현실적으로 가장 많이 쓰는 조합은 다음입니다.

  1. GPU 노드 풀 min=1
  2. KServe minReplicas=1
  3. 이미지 프리풀 DaemonSet
  4. 모델은 PVC 또는 노드 로컬 캐시로 유지
  5. startupProbe 를 길게, readinessProbe 를 엄격하게
  6. 배포 직후 워밍업 요청 자동화

이 조합이면 “트래픽이 없던 새벽 이후 첫 요청”도 대체로 수 초 내로 들어오게 만들 수 있습니다. 반대로 minReplicas=0 을 고수하면, 어떤 최적화를 해도 노드/이미지/모델 단계에서 시간이 다시 튀기 쉽습니다.

예시: vLLM 기반 컨테이너 엔트리포인트

서버가 준비 상태를 정확히 노출하려면 애플리케이션 코드가 필요합니다. 아래는 FastAPI/ready 를 제공하고, 백그라운드에서 모델 로딩 완료 플래그를 세팅하는 간단한 예시입니다.

import os
import time
from fastapi import FastAPI

app = FastAPI()

READY = False

@app.on_event("startup")
def startup():
    global READY
    # 실제로는 vLLM 엔진 초기화, 모델 로딩 등을 수행
    model_path = os.environ.get("MODEL_PATH", "/models/llama")
    _ = model_path
    time.sleep(10)
    READY = True

@app.get("/health")
def health():
    return {"status": "ok"}

@app.get("/ready")
def ready():
    if not READY:
        return {"ready": False}
    return {"ready": True}

핵심은 “프로세스 생존”과 “추론 가능”을 분리하는 것입니다.

비용과 성능의 트레이드오프: 어디까지가 적정선인가

콜드스타트를 0에 가깝게 만들수록 상시 비용은 올라갑니다. 의사결정 기준을 숫자로 잡아두면 팀 내 합의가 쉬워집니다.

  • SLO: 첫 토큰 지연 p95x 초 이내
  • 트래픽 패턴: 야간 0에 수렴하는지, 상시 요청이 있는지
  • 비용 상한: GPU 1대를 상시 유지할 수 있는지

만약 야간 트래픽이 완전히 0이라도 “아침 첫 요청이 느려도 된다”가 아니라면, minReplicas=1 은 사실상 필수입니다.

체크리스트: 콜드스타트가 여전히 느릴 때

아래 순서로 시간을 계측하면 원인을 빠르게 좁힐 수 있습니다.

  1. GPU 노드가 이미 있었나
  2. 파드 스케줄링까지 몇 초 걸렸나
  3. 이미지 풀 시간이 얼마나 됐나
  4. 모델 다운로드가 발생했나(캐시 미스)
  5. 모델 로딩이 몇 초 걸렸나
  6. 첫 요청에서만 느린가(워밍업 부재)
  7. 재시작이 반복되는가(OOMKilled, GPU 메모리 부족)

각 구간을 로그로 남기고, 배포 파이프라인에서 “새 버전 롤아웃 후 워밍업 완료까지 시간”을 지표로 잡으면 개선이 누적됩니다.

마무리

KServe 자체는 LLM 콜드스타트를 마법처럼 없애주지 않습니다. 대신 KServe가 제공하는 표준화된 배포/라우팅/스케일링 위에서, GPU LLM에 맞는 최소 가용성 유지 + 워밍 전략을 설계하면 콜드스타트를 체감 0 에 가깝게 만들 수 있습니다.

  • minReplicas=1 로 0 스케일을 피하고
  • GPU 노드 풀을 워밍하며
  • 이미지와 모델을 캐시하고
  • 프로브와 워밍업 요청으로 “진짜 준비 완료”를 보장하세요.

이 네 가지가 갖춰지면, LLM 서빙은 더 이상 “첫 요청이 불안한 서비스”가 아니라 예측 가능한 인프라가 됩니다.