Skip to content
Published on

KServe 모델 서빙 완벽 가이드: InferenceService·Canary 배포·Transformer·InferenceGraph 프로덕션 운영

Authors
  • Name
    Twitter
KServe 모델 서빙

들어가며

ML 모델을 학습시키는 것과 프로덕션 환경에서 안정적으로 서빙하는 것은 본질적으로 다른 엔지니어링 과제다. 학습 단계에서는 GPU 활용률과 수렴 속도가 핵심이지만, 서빙 단계에서는 레이턴시, 처리량, 버전 관리, 안전한 롤아웃, 장애 복구가 결정적이다. 특히 여러 모델이 상호작용하는 복합 추론 파이프라인에서는 단순히 Flask에 모델을 올리는 방식으로는 운영 복잡도를 감당할 수 없다.

KServe(구 KFServing)는 이러한 프로덕션 모델 서빙 문제를 Kubernetes 네이티브 방식으로 해결하기 위해 설계된 CNCF Incubating 프로젝트다. InferenceService CRD를 통해 선언적으로 모델을 배포하고, Knative 기반 오토스케일링으로 트래픽 변동에 대응하며, Canary 배포로 안전한 롤아웃을 수행하고, InferenceGraph로 DAG 기반 복합 추론을 구성할 수 있다.

KServe는 2019년 KFServing이라는 이름으로 Kubeflow 프로젝트의 일부로 시작되었으며, 2021년에 독립 프로젝트로 분리되면서 KServe로 리브랜딩되었다. 2023년 CNCF Sandbox에 합류한 후 빠르게 성장하여 2025년 Incubating 단계로 승격되었다. TFServing, TorchServe, Triton, vLLM 등 주요 추론 런타임을 모두 지원하며, LLM 시대에 맞춰 vLLM 백엔드와 Envoy AI Gateway 연동까지 확장하고 있다.

이 글에서는 KServe의 핵심 아키텍처부터 프로덕션 운영 전략까지, 실전에서 필요한 모든 것을 코드와 함께 다룬다.

InferenceService CRD 심화

Predictor / Transformer / Explainer 아키텍처

KServe의 InferenceService는 세 가지 핵심 컴포넌트로 구성된다.

Client Request
┌─────────────┐     ┌─────────────┐     ┌─────────────┐
Transformer │────▶│  Predictor  │────▶│  Explainer (전처리/ (모델 추론) (설명 생성)│  후처리)    │◀────│             │     │             │
└─────────────┘     └─────────────┘     └─────────────┘
Client Response
  • Predictor: 핵심 추론 엔진. TFServing, TorchServe, Triton, XGBoost, LightGBM, Sklearn, vLLM 등 다양한 런타임을 지원한다.
  • Transformer: 추론 요청의 전처리와 응답의 후처리를 담당한다. 이미지 리사이즈, 토큰화, 피처 엔지니어링 등을 Predictor와 분리하여 독립적으로 스케일링할 수 있다.
  • Explainer: 추론 결과에 대한 설명을 생성한다. Alibi Explainer, AIF360 등을 통해 모델의 예측 근거를 제공한다.

지원 런타임 비교

런타임프레임워크GPU 지원동적 배칭LLM 서빙주요 용도
TFServingTensorFlowOOXTF SavedModel 서빙
TorchServePyTorchOOXPyTorch 모델 서빙
Triton멀티 프레임워크OOO멀티 모델 동시 서빙
vLLMPyTorch/HuggingFaceOOOLLM 추론 최적화
SklearnScikit-learnXXX경량 ML 모델
XGBoostXGBoostOXX그래디언트 부스팅
LGBMLightGBMXXX그래디언트 부스팅

기본 InferenceService YAML

가장 기본적인 형태의 InferenceService는 Predictor만 정의한다. 아래는 S3에 저장된 Sklearn 모델을 서빙하는 예시다.

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: sklearn-iris
  namespace: ml-serving
  annotations:
    serving.kserve.io/deploymentMode: Serverless
spec:
  predictor:
    minReplicas: 1
    maxReplicas: 10
    scaleTarget: 5
    scaleMetric: concurrency
    model:
      modelFormat:
        name: sklearn
      storageUri: 's3://ml-models/sklearn/iris/v1'
      resources:
        requests:
          cpu: '500m'
          memory: '512Mi'
        limits:
          cpu: '1'
          memory: '1Gi'

이 YAML을 적용하면 KServe 컨트롤러가 Knative Service를 생성하고, Istio VirtualService를 통해 라우팅을 설정하며, Knative Pod Autoscaler가 concurrency 기반으로 오토스케일링을 수행한다.

vLLM 백엔드 LLM 서빙

KServe v0.13부터 vLLM을 1급 런타임으로 지원한다. OpenAI 호환 API를 자동으로 노출하므로 기존 OpenAI 클라이언트 코드를 그대로 사용할 수 있다.

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: llama3-vllm
  namespace: ml-serving
spec:
  predictor:
    minReplicas: 1
    maxReplicas: 4
    model:
      modelFormat:
        name: vLLM
      storageUri: 'pvc://llm-model-cache/Meta-Llama-3.1-8B-Instruct'
      args:
        - '--max-model-len=8192'
        - '--gpu-memory-utilization=0.90'
        - '--enable-chunked-prefill'
        - '--max-num-batched-tokens=16384'
        - '--tensor-parallel-size=2'
      resources:
        requests:
          cpu: '8'
          memory: '32Gi'
          nvidia.com/gpu: '2'
        limits:
          cpu: '16'
          memory: '64Gi'
          nvidia.com/gpu: '2'
    tolerations:
      - key: 'nvidia.com/gpu'
        operator: 'Exists'
        effect: 'NoSchedule'
    nodeSelector:
      gpu-type: 'a100'

storageUri에 PVC를 지정하면 대용량 LLM 가중치를 미리 노드에 캐싱해 두고 빠르게 로드할 수 있다. tensor-parallel-size=2는 2장의 GPU에 모델을 분산 배치하여 단일 GPU 메모리 한계를 극복한다.

Canary 배포 전략

canaryTrafficPercent를 활용한 점진적 롤아웃

프로덕션에서 모델을 업데이트할 때 가장 위험한 순간은 새 버전 배포 직후다. KServe는 canaryTrafficPercent 필드를 통해 트래픽을 점진적으로 새 버전으로 이동시키는 Canary 배포를 네이티브로 지원한다.

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: fraud-detector
  namespace: ml-serving
  annotations:
    serving.kserve.io/deploymentMode: Serverless
spec:
  predictor:
    canaryTrafficPercent: 10
    minReplicas: 2
    maxReplicas: 20
    model:
      modelFormat:
        name: sklearn
      storageUri: 's3://ml-models/fraud/v2'
      resources:
        requests:
          cpu: '1'
          memory: '2Gi'
        limits:
          cpu: '2'
          memory: '4Gi'

위 설정은 새 모델(v2)에 전체 트래픽의 10%만 라우팅하고, 나머지 90%는 기존 안정 버전으로 보낸다. 배포 후 모니터링을 통해 새 버전의 성능을 검증한 뒤, canaryTrafficPercent를 단계적으로 올린다.

단계별 전환 스크립트

점진적 롤아웃을 자동화하는 스크립트를 작성하면 수동 실수를 줄일 수 있다.

#!/bin/bash
# canary-promote.sh - 점진적 Canary 프로모션 스크립트
ISVC_NAME="fraud-detector"
NAMESPACE="ml-serving"
STAGES=(10 30 50 80 100)
MAX_ERROR_RATE=0.05
OBSERVE_MINUTES=10

for pct in "${STAGES[@]}"; do
  echo "=== Canary traffic를 ${pct}%로 업데이트 ==="

  kubectl patch inferenceservice "$ISVC_NAME" -n "$NAMESPACE" \
    --type='json' \
    -p="[{\"op\": \"replace\", \"path\": \"/spec/predictor/canaryTrafficPercent\", \"value\": $pct}]"

  echo "${OBSERVE_MINUTES}분간 메트릭 관찰 중..."
  sleep $((OBSERVE_MINUTES * 60))

  # Prometheus에서 canary 에러율 조회
  ERROR_RATE=$(curl -s "http://prometheus:9090/api/v1/query" \
    --data-urlencode "query=rate(revision_request_count{revision_name=~\".*canary.*\",response_code!=\"200\"}[5m]) / rate(revision_request_count{revision_name=~\".*canary.*\"}[5m])" \
    | jq -r '.data.result[0].value[1] // "0"')

  if (( $(echo "$ERROR_RATE > $MAX_ERROR_RATE" | bc -l) )); then
    echo "에러율 ${ERROR_RATE}이 임계치 ${MAX_ERROR_RATE}를 초과. 롤백 수행."
    kubectl patch inferenceservice "$ISVC_NAME" -n "$NAMESPACE" \
      --type='json' \
      -p='[{"op": "replace", "path": "/spec/predictor/canaryTrafficPercent", "value": 0}]'
    exit 1
  fi

  echo "에러율 ${ERROR_RATE} - 정상 범위. 다음 단계로 진행."
done

echo "=== Canary 프로모션 완료. v2가 100% 트래픽을 처리합니다. ==="

이 스크립트는 10% 에서 100%까지 5단계로 트래픽을 전환하며, 각 단계에서 10분간 에러율을 모니터링한다. 에러율이 5%를 초과하면 자동으로 0%로 롤백한다.

Custom Transformer 구현

전처리/후처리 파이프라인 설계

실제 프로덕션에서는 클라이언트가 보내는 원시 데이터(이미지 URL, 텍스트, JSON)를 모델이 기대하는 텐서 형식으로 변환해야 한다. Transformer를 Predictor와 분리하면 다음과 같은 이점이 있다.

  • 독립적 스케일링: 전처리가 CPU 집약적이고 추론이 GPU 집약적일 때 각각 다른 스케일링 정책 적용 가능
  • 재사용성: 동일한 Transformer를 여러 모델 버전에 재사용
  • 배포 독립성: Transformer 로직 변경 시 모델 재배포 없이 업데이트 가능

kserve.Model 상속과 핸들러 구현

KServe Python SDK의 kserve.Model 클래스를 상속하여 Custom Transformer를 구현한다.

import kserve
from kserve import InferRequest, InferResponse, InferInput
from typing import Dict, List
import numpy as np
from PIL import Image
import requests
from io import BytesIO
import logging

logger = logging.getLogger(__name__)

class ImageTransformer(kserve.Model):
    """이미지 URL을 받아 전처리 후 Predictor로 전달하는 Transformer"""

    def __init__(self, name: str, predictor_host: str):
        super().__init__(name)
        self.predictor_host = predictor_host
        self.target_size = (224, 224)
        self.mean = np.array([0.485, 0.456, 0.406])
        self.std = np.array([0.229, 0.224, 0.225])
        self.ready = False

    def load(self):
        """모델 초기화. Warm-up 로직을 여기에 배치."""
        logger.info("ImageTransformer 초기화 완료")
        self.ready = True

    def preprocess(
        self, payload: Dict, headers: Dict = None
    ) -> InferRequest:
        """이미지 URL을 받아 정규화된 텐서로 변환"""
        instances = payload.get("instances", [])
        processed_images = []

        for instance in instances:
            image_url = instance.get("image_url")
            response = requests.get(image_url, timeout=10)
            response.raise_for_status()

            image = Image.open(BytesIO(response.content)).convert("RGB")
            image = image.resize(self.target_size)

            # numpy 배열로 변환 후 정규화
            img_array = np.array(image, dtype=np.float32) / 255.0
            img_array = (img_array - self.mean) / self.std
            img_array = np.transpose(img_array, (2, 0, 1))  # CHW
            processed_images.append(img_array)

        input_tensor = np.stack(processed_images)

        infer_input = InferInput(
            name="input",
            shape=list(input_tensor.shape),
            datatype="FP32",
            data=input_tensor.tolist(),
        )
        return InferRequest(
            model_name=self.name,
            infer_inputs=[infer_input],
        )

    def postprocess(
        self, response: InferResponse, headers: Dict = None
    ) -> Dict:
        """모델 출력을 사람이 읽기 좋은 형식으로 변환"""
        predictions = response.outputs[0].data

        class_names = ["cat", "dog", "bird", "fish", "other"]
        results = []

        for pred in predictions:
            if isinstance(pred, list):
                probs = np.array(pred)
            else:
                probs = np.array([pred])

            top_idx = int(np.argmax(probs))
            results.append({
                "class": class_names[top_idx],
                "confidence": float(probs[top_idx]),
                "all_scores": {
                    name: float(score)
                    for name, score in zip(class_names, probs)
                },
            })

        return {"predictions": results}


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--predictor_host", required=True)
    parser.add_argument("--model_name", default="image-classifier")
    args = parser.parse_args()

    transformer = ImageTransformer(
        name=args.model_name,
        predictor_host=args.predictor_host,
    )
    transformer.load()
    kserve.ModelServer(workers=4).start([transformer])

Transformer 포함 InferenceService YAML

Transformer를 InferenceService에 통합하면 KServe가 자동으로 Transformer와 Predictor 간의 내부 라우팅을 설정한다.

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: image-classifier
  namespace: ml-serving
spec:
  transformer:
    minReplicas: 2
    maxReplicas: 15
    scaleTarget: 10
    scaleMetric: concurrency
    containers:
      - name: image-transformer
        image: registry.example.com/ml/image-transformer:v1.2.0
        args:
          - '--model_name=image-classifier'
        resources:
          requests:
            cpu: '1'
            memory: '2Gi'
          limits:
            cpu: '2'
            memory: '4Gi'
        env:
          - name: STORAGE_URI
            value: ''
  predictor:
    minReplicas: 1
    maxReplicas: 8
    model:
      modelFormat:
        name: pytorch
      storageUri: 's3://ml-models/image-classifier/resnet50-v2'
      resources:
        requests:
          cpu: '2'
          memory: '4Gi'
          nvidia.com/gpu: '1'
        limits:
          cpu: '4'
          memory: '8Gi'
          nvidia.com/gpu: '1'

이 구성에서 Transformer는 CPU 노드에서 최대 15개까지 스케일아웃되고, Predictor는 GPU 노드에서 최대 8개까지 스케일아웃된다. 전처리가 병목인 경우 Transformer만 증설하면 되므로 비용 효율적이다.

InferenceGraph: DAG 기반 복합 추론

4가지 노드 유형

InferenceGraph는 여러 InferenceService를 DAG로 연결하여 복합 추론 파이프라인을 구성하는 KServe의 고급 기능이다. v0.11부터 GA로 승격되었으며, 4가지 노드 유형을 지원한다.

노드 유형실행 방식입력 전달주요 Use Case설명
Sequence순차 실행이전 노드 출력을 다음 입력으로전처리 체인A 결과를 B 입력으로 전달
Switch조건 분기조건에 따라 하나의 노드 선택A/B 테스트, 라우팅조건 기반 단일 경로 선택
Ensemble병렬 실행 + 결합동일 입력을 모든 노드에 전달앙상블 추론여러 모델 결과를 합산/투표
Splitter가중 분배비율에 따라 하나의 노드 선택트래픽 분할, Canary가중치 기반 트래픽 라우팅

A/B 테스트와 앙상블 패턴

실제 프로덕션에서 자주 사용되는 패턴은 앙상블과 A/B 테스트의 조합이다. 예를 들어, 사기 탐지 시스템에서 규칙 기반 모델, XGBoost 모델, 딥러닝 모델을 동시에 실행하고 결과를 앙상블하면 단일 모델 대비 정확도를 높일 수 있다.

apiVersion: serving.kserve.io/v1alpha1
kind: InferenceGraph
metadata:
  name: fraud-detection-ensemble
  namespace: ml-serving
  annotations:
    serving.kserve.io/propagateHeaders: 'x-request-id,x-trace-id'
spec:
  nodes:
    root:
      routerType: Sequence
      steps:
        - name: feature-enrichment
          serviceName: feature-enricher
          weight: 100
        - name: ensemble-node
          nodeName: model-ensemble
    model-ensemble:
      routerType: Ensemble
      steps:
        - name: xgboost-model
          serviceName: fraud-xgboost
          weight: 40
        - name: deep-model
          serviceName: fraud-deep-learning
          weight: 40
        - name: rule-engine
          serviceName: fraud-rule-engine
          weight: 20
    result-combiner:
      routerType: Sequence
      steps:
        - name: weighted-average
          serviceName: ensemble-combiner
          weight: 100

이 InferenceGraph는 다음과 같이 동작한다.

  1. root 노드 (Sequence): 먼저 feature-enricher가 원시 데이터를 풍부한 피처로 변환한 뒤, 결과를 model-ensemble 노드로 전달한다.
  2. model-ensemble 노드 (Ensemble): XGBoost, 딥러닝, 규칙 엔진 세 모델이 동일한 입력으로 병렬 실행된다. 각 모델의 weight는 최종 결과 합산 시 가중치로 사용된다.
  3. 최종 결과는 세 모델의 가중 평균으로 산출된다.

A/B 테스트를 위한 Splitter 패턴

apiVersion: serving.kserve.io/v1alpha1
kind: InferenceGraph
metadata:
  name: recommendation-ab-test
  namespace: ml-serving
spec:
  nodes:
    root:
      routerType: Splitter
      steps:
        - name: model-v1-stable
          serviceName: recommender-v1
          weight: 80
        - name: model-v2-experiment
          serviceName: recommender-v2
          weight: 20

Splitter는 트래픽의 80%를 안정 버전으로, 20%를 실험 버전으로 분배한다. InferenceService의 Canary와 달리, InferenceGraph의 Splitter는 완전히 독립된 InferenceService 간의 트래픽 분할이므로 서로 다른 모델 아키텍처 간의 A/B 테스트에 적합하다.

오토스케일링 전략

Knative Pod Autoscaler (KPA) vs HPA vs KEDA

KServe는 세 가지 오토스케일러를 지원한다. 워크로드 특성에 따라 적합한 스케일러가 다르다.

특성KPA (Knative)HPA (Kubernetes)KEDA
Scale-to-ZeroOXO
스케일링 메트릭concurrency, rpscpu, memory, custom외부 메트릭 (Prometheus, CloudWatch 등)
반응 속도빠름 (1-2초)보통 (15-30초)보통 (15초)
GPU 워크로드제한적적합매우 적합
커스텀 메트릭제한적Metrics API 필요풍부한 Scaler 지원
Cold Start 대응activationScaleN/AminReplicaCount
설정 복잡도낮음중간높음

Scale-to-Zero for GPU 워크로드

GPU 인스턴스는 비용이 높으므로 Scale-to-Zero가 중요하다. 하지만 GPU 모델은 cold start 시간이 길어(수십 초에서 수 분) 주의가 필요하다. KPA를 사용할 때 scaledown 지연을 적절히 설정해야 한다.

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: gpu-model
  namespace: ml-serving
  annotations:
    # Scale-to-Zero 활성화 (기본값)
    serving.kserve.io/enable-scale-to-zero: 'true'
    # 마지막 요청 후 Scale-to-Zero까지 대기 시간
    autoscaling.knative.dev/scale-to-zero-pod-retention-period: '15m'
    # 스케일다운 지연 (급격한 축소 방지)
    autoscaling.knative.dev/scale-down-delay: '5m'
    # 동시 처리 가능 요청 수 (GPU 모델은 낮게 설정)
    autoscaling.knative.dev/target: '2'
    autoscaling.knative.dev/metric: 'concurrency'
spec:
  predictor:
    minReplicas: 0
    maxReplicas: 4
    model:
      modelFormat:
        name: pytorch
      storageUri: 's3://ml-models/large-model/v1'
      resources:
        requests:
          nvidia.com/gpu: '1'
        limits:
          nvidia.com/gpu: '1'

KEDA + vLLM 메트릭 오토스케일링

LLM 서빙에서는 CPU/메모리가 아닌 vLLM 자체 메트릭(대기 중인 요청 수, KV 캐시 사용률)을 기반으로 스케일링하는 것이 효과적이다. KEDA의 Prometheus Scaler를 활용하면 이를 구현할 수 있다.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: llama3-vllm-scaler
  namespace: ml-serving
spec:
  scaleTargetRef:
    apiVersion: serving.kserve.io/v1beta1
    kind: InferenceService
    name: llama3-vllm
  minReplicaCount: 1
  maxReplicaCount: 8
  pollingInterval: 15
  cooldownPeriod: 300
  advanced:
    restoreToOriginalReplicaCount: true
    horizontalPodAutoscalerConfig:
      behavior:
        scaleUp:
          stabilizationWindowSeconds: 30
          policies:
            - type: Pods
              value: 2
              periodSeconds: 60
        scaleDown:
          stabilizationWindowSeconds: 300
          policies:
            - type: Pods
              value: 1
              periodSeconds: 120
  triggers:
    # vLLM 대기 요청 수 기반 스케일링
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring:9090
        metricName: vllm_waiting_requests
        query: |
          avg(vllm:num_requests_waiting{model_name="llama3-vllm"})
        threshold: '5'
    # KV Cache 사용률 기반 스케일링
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring:9090
        metricName: vllm_kv_cache_usage
        query: |
          avg(vllm:gpu_cache_usage_perc{model_name="llama3-vllm"})
        threshold: '0.85'

이 설정은 두 가지 조건 중 하나라도 충족되면 스케일아웃을 트리거한다.

  • vLLM 대기 요청이 평균 5개를 초과하면 Pod를 추가한다.
  • GPU KV 캐시 사용률이 85%를 초과하면 Pod를 추가한다.

스케일다운 시에는 5분의 안정화 윈도우와 2분당 1개씩 감소 정책을 적용하여 급격한 축소를 방지한다.

v0.15 신규 기능

Envoy AI Gateway 연동

KServe v0.15(2025년 6월)에서 가장 주목할 기능은 Envoy AI Gateway 연동이다. LLM 서빙에 특화된 라우팅과 관측성을 제공한다.

  • 토큰 기반 Rate Limiting: API 키별로 분당/시간당 토큰 사용량을 제한
  • 모델 레벨 라우팅: 요청의 model 필드를 기반으로 적절한 백엔드(vLLM, Triton, 외부 API)로 라우팅
  • Semantic Caching: 유사한 프롬프트에 대한 응답을 캐싱하여 비용과 레이턴시 절감
  • Usage Tracking: 토큰 사용량, 레이턴시, 에러율 등을 모델/사용자/팀 단위로 추적

LocalModelCache 멀티 노드 그룹

대규모 LLM 가중치를 매번 S3에서 다운로드하면 cold start가 수 분씩 소요된다. v0.15의 LocalModelCache는 노드 로컬 디스크에 모델을 미리 캐싱하여 startup 시간을 획기적으로 단축한다.

apiVersion: serving.kserve.io/v1alpha1
kind: LocalModelCache
metadata:
  name: llama3-cache
  namespace: ml-serving
spec:
  sourceModelUri: 's3://ml-models/Meta-Llama-3.1-8B-Instruct'
  nodeGroups:
    - name: a100-nodes
      nodeSelector:
        gpu-type: 'a100'
      persistentVolumeClaimSpec:
        accessModes:
          - ReadWriteOnce
        resources:
          requests:
            storage: 50Gi
        storageClassName: local-nvme

이 설정은 gpu-type: a100 레이블이 있는 모든 노드에 50Gi NVMe 볼륨을 생성하고, S3에서 모델 가중치를 미리 다운로드해 둔다. Pod가 시작될 때 S3 대신 로컬 디스크에서 가중치를 로드하므로, cold start 시간이 수 분에서 수 초로 단축된다.

LLM 서빙 최적화

v0.15에서는 LLM 워크로드에 대한 최적화가 다수 포함되었다.

  • LoRA 어댑터 Hot-Swapping: 기본 모델을 유지하면서 LoRA 어댑터만 동적으로 교체. 멀티테넌트 환경에서 고객별 파인튜닝 모델을 효율적으로 서빙
  • Speculative Decoding 지원: vLLM 백엔드에서 Draft Model을 활용한 추측 디코딩으로 토큰 생성 속도 2-3배 향상
  • Prefix Caching: 시스템 프롬프트 등 반복되는 프리픽스의 KV 캐시를 공유하여 TTFT(Time To First Token) 단축

실패 사례와 트러블슈팅

사례 1: Transformer OOM으로 추론 파이프라인 중단

증상: 이미지 분류 파이프라인에서 Transformer Pod가 간헐적으로 OOMKilled 되면서 전체 추론 체인이 실패.

원인 분석: Transformer에서 대용량 이미지(8K 해상도)를 메모리에 로드할 때 PIL이 압축 해제된 원본 크기만큼 메모리를 할당. 동시 요청 10개가 들어오면 10 x 200MB = 2GB가 순간적으로 필요했으나, 메모리 limit이 1Gi로 설정되어 있었다.

해결 방법:

# 이미지 크기 제한을 전처리 초기 단계에서 적용
from PIL import Image

# PIL DecompressionBomb 방지 설정
Image.MAX_IMAGE_PIXELS = 89_478_485  # 약 9500x9500

def safe_load_image(image_bytes: bytes, max_size: int = 2048):
    """메모리 안전한 이미지 로드"""
    img = Image.open(BytesIO(image_bytes))

    # 원본 크기가 max_size를 초과하면 즉시 리사이즈
    if max(img.size) > max_size:
        ratio = max_size / max(img.size)
        new_size = (int(img.width * ratio), int(img.height * ratio))
        img = img.resize(new_size, Image.LANCZOS)

    return img.convert("RGB")

추가로 Transformer의 메모리 limit을 4Gi로 상향하고, concurrency target을 5로 낮춰 동시 처리량을 제한했다.

사례 2: Scale-to-Zero에서 Cold Start 지연

증상: GPU 모델이 Scale-to-Zero 상태에서 첫 요청 시 60초 이상 타임아웃 발생.

원인 분석: 모델 가중치(2GB)를 S3에서 다운로드하고 GPU 메모리에 로드하는 시간이 Knative의 기본 타임아웃(30초)을 초과.

해결 방법:

  1. progressDeadline을 600초로 확장
  2. LocalModelCache를 적용하여 노드 로컬에 모델 사전 캐싱
  3. minReplicas: 1로 설정하여 최소 1개 Pod 유지 (비용이 허용되는 경우)

디버깅 체크리스트

KServe 배포 문제를 디버깅할 때 체계적으로 확인해야 할 항목들이다.

  1. InferenceService 상태 확인: kubectl get isvc -n ml-serving 에서 READY가 True인지 확인
  2. Pod 상태 확인: kubectl get pods -n ml-serving -l serving.kserve.io/inferenceservice=MODEL_NAME 으로 Pod 상태 점검
  3. 이벤트 확인: kubectl describe isvc MODEL_NAME -n ml-serving 에서 Events 섹션 확인
  4. Knative Revision 확인: kubectl get revisions -n ml-serving 에서 revision 상태 점검
  5. 스토리지 접근 확인: StorageInitializer 로그에서 S3/GCS 접근 오류가 없는지 확인
  6. Istio 라우팅 확인: kubectl get virtualservice -n ml-serving 에서 트래픽 라우팅 규칙 점검
  7. 리소스 부족 확인: kubectl describe node 에서 GPU 할당 가능량과 메모리 여유 확인

운영 시 주의사항

GPU 노드 스케줄링과 tolerations

GPU 노드에는 일반적으로 taint가 설정되어 있으므로, InferenceService에 반드시 tolerations과 nodeSelector를 명시해야 한다. 이를 누락하면 Pod가 Pending 상태에 머무른다.

spec:
  predictor:
    tolerations:
      - key: 'nvidia.com/gpu'
        operator: 'Exists'
        effect: 'NoSchedule'
    nodeSelector:
      gpu-type: 'a100'
    model:
      modelFormat:
        name: vLLM
      # ... 모델 설정

모델 캐싱 전략

대규모 모델을 운영할 때는 계층적 캐싱 전략이 필수다.

  • 1차 캐시 (노드 로컬): LocalModelCache를 활용하여 NVMe SSD에 모델 가중치 저장. 가장 빠른 로드 속도
  • 2차 캐시 (PVC): PersistentVolumeClaim을 활용하여 네트워크 스토리지에 캐싱. 노드 간 공유 가능
  • 3차 원본 (Object Storage): S3, GCS 등에 모델의 원본을 보관. 가장 느리지만 내구성이 높음

리소스 Quota 관리

네임스페이스별 ResourceQuota를 설정하여 팀 간 GPU 자원 경합을 방지한다.

apiVersion: v1
kind: ResourceQuota
metadata:
  name: ml-serving-quota
  namespace: ml-serving
spec:
  hard:
    requests.cpu: '64'
    requests.memory: '256Gi'
    requests.nvidia.com/gpu: '8'
    limits.cpu: '128'
    limits.memory: '512Gi'
    limits.nvidia.com/gpu: '8'
    persistentvolumeclaims: '20'

이 Quota는 ml-serving 네임스페이스에서 최대 GPU 8장, CPU 128코어, 메모리 512Gi까지 사용할 수 있도록 제한한다. 팀별로 네임스페이스를 분리하고 각각 적절한 Quota를 할당하면 안정적인 멀티테넌트 운영이 가능하다.

참고자료