Published on

KServe+Knative 콜드스타트 10배 줄이는 실전 처방

Authors

KServe를 Knative 위에 올려 서빙하면, 트래픽이 없을 때 scale-to-zero로 비용을 아끼는 대신 첫 요청이 느려지는 콜드스타트가 따라옵니다. 문제는 “느리다”로 끝나지 않습니다. L7 라우팅(Istio), 큐 프록시(queue-proxy), 파드 스케줄링, 이미지 풀, 모델 다운로드, 런타임 워밍업이 직렬로 이어지면서 P95가 수 초에서 수십 초까지 튀는 경우가 흔합니다.

이 글은 콜드스타트를 어디서 얼마나 쓰는지를 분해하고, 실제로 체감 성능을 10배 수준으로 줄이는 데 자주 쓰는 처방(설정/아키텍처/운영 팁)을 한 번에 정리합니다.

문제 상황 중 503이나 라우팅/리비전 전환 이슈가 섞여 있다면 먼저 아래 글의 체크리스트로 “정상 동작”을 확보하는 게 좋습니다.


콜드스타트는 5단계로 쪼개서 봐야 줄어든다

KServe+Knative에서 첫 요청 지연은 대개 아래 5단계 합입니다.

  1. Activator 경유 및 라우팅 준비: 트래픽이 0이면 Activator가 받았다가 새 파드가 뜰 때까지 버퍼링
  2. 스케줄링 대기: 노드 여유/오토스케일(Karpenter, Cluster Autoscaler)로 노드가 늦게 붙는 경우 포함
  3. 이미지 Pull: 대형 런타임/베이스 이미지면 여기서 수 초~수십 초
  4. 컨테이너 기동 및 런타임 초기화: Python import, CUDA 초기화, TorchScript 로딩 등
  5. 모델 로딩/다운로드: PV/S3/GCS에서 weight를 가져오거나, 로컬 캐시 미스

콜드스타트를 10배 줄이는 핵심은 “각 단계의 상한을 깎고, 병렬화/캐시로 직렬 구간을 끊는 것”입니다.


1) scale-to-zero를 포기하지 말고 minScale부터 조절한다

가장 확실한 방법은 0으로 내려가지 않게 만드는 겁니다. 하지만 비용이 문제라면, 전 서비스에 적용하지 말고 핵심 엔드포인트만 minScale을 1로 두는 식으로 타협합니다.

KServe InferenceService에 Knative autoscaling annotation을 걸어 minScale을 설정할 수 있습니다.

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: sentiment
  namespace: ml
  annotations:
    autoscaling.knative.dev/minScale: "1"
    autoscaling.knative.dev/maxScale: "10"
spec:
  predictor:
    containers:
      - name: kserve-container
        image: ghcr.io/acme/sentiment:1.0.0
        ports:
          - containerPort: 8080

운영 팁

  • minScale: 1만으로도 “첫 요청 20초”가 “항상 200ms”로 떨어지는 케이스가 많습니다.
  • 비용이 부담이면 업무 시간대만 minScale을 올리고 야간에는 0으로 내리는 스케줄도 실전에서 자주 씁니다(예: CronJob으로 annotation 패치).

2) scaleDownDelay로 “자주 식는 서비스”를 막는다

트래픽이 간헐적으로 들어오는 서비스는 scale-to-zero로 내려갔다가 다시 올라오는 사이클이 반복되며 체감이 최악이 됩니다. 이때는 내려가는 시간을 늦추는 것만으로도 콜드스타트 빈도를 크게 줄일 수 있습니다.

metadata:
  annotations:
    autoscaling.knative.dev/scaleDownDelay: "10m"
  • 10분 동안 요청이 없더라도 바로 0으로 내리지 않게 하여, “간헐 트래픽”의 첫 요청을 대부분 웜 상태에서 처리합니다.

3) 컨테이너 동시성(containerConcurrency)과 타깃(target)을 재설계한다

Knative는 containerConcurrency와 autoscaling target(동시 요청/초 기반)을 기준으로 파드를 늘립니다. 여기 설정이 모델 특성과 안 맞으면 “파드 1개가 과부하로 느려짐” 또는 “불필요하게 파드가 늘어 콜드스타트가 더 자주 발생”합니다.

예시로, GPU 1장당 동시성 1~2가 적절한 모델인데 동시성을 10으로 두면 첫 파드가 뜬 뒤에도 응답이 길어져 “느린데 스케일도 늦는” 상황이 생깁니다.

metadata:
  annotations:
    autoscaling.knative.dev/target: "1"
    autoscaling.knative.dev/metric: "concurrency"
spec:
  predictor:
    containerConcurrency: 1
    containers:
      - name: kserve-container
        image: ghcr.io/acme/gpu-model:2.1.0

기준 잡는 법

  • GPU 추론이 무거우면 containerConcurrency: 1부터 시작해 측정으로 올립니다.
  • CPU 경량 모델이면 containerConcurrency를 올려 파드 수를 줄이면 콜드스타트 빈도가 감소합니다.

4) 이미지 Pull 시간을 없애려면 “작게”보다 “가까이”가 먼저다

이미지 최적화(멀티스테이지, slim)는 중요하지만, 실전에서 더 큰 효과는 다음 2가지가 자주 냅니다.

4-1) 노드에 이미지 프리풀(Pre-pull)하기

트래픽이 올 때마다 새 노드가 생기거나(오토스케일), 노드가 자주 교체되면 이미지 pull이 반복됩니다. DaemonSet으로 주요 서빙 이미지를 미리 당겨두면 콜드스타트가 크게 줄어듭니다.

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: prepull-kserve-images
  namespace: kube-system
spec:
  selector:
    matchLabels:
      app: prepull-kserve-images
  template:
    metadata:
      labels:
        app: prepull-kserve-images
    spec:
      containers:
        - name: prepull
          image: ghcr.io/acme/sentiment:1.0.0
          command: ["/bin/sh", "-c", "sleep 360000"]
      tolerations:
        - operator: "Exists"
  • 실제 서비스 파드가 아니라 “이미지 캐시를 채우는 용도”입니다.
  • 이미지가 여러 개면 컨테이너를 여러 개 두거나, 태그를 환경별로 분리합니다.

4-2) 레지스트리를 클러스터 근처로

  • EKS라면 ECR, GKE라면 Artifact Registry처럼 동일 리전 레지스트리를 쓰는 것만으로 pull 편차가 줄어듭니다.

5) 모델 다운로드를 “요청 경로”에서 빼라: PV 캐시 또는 웨이트 프리패치

KServe에서 가장 큰 병목은 모델 웨이트를 원격 스토리지에서 가져오는 단계입니다. 이를 첫 요청에 수행하면 콜드스타트는 구조적으로 길어집니다.

선택지 A: PV에 모델 캐시(노드/파드 재시작에도 유지)

  • 모델을 PVC에 두고, 컨테이너가 로컬 파일로 로딩하게 만듭니다.
  • 장점: 네트워크/스토리지 지연이 줄고, 재기동에도 캐시가 남습니다.

선택지 B: initContainer로 프리패치(요청 전에 받기)

initContainer는 본 컨테이너가 뜨기 전에 실행되므로, 최소한 “서빙 컨테이너가 뜬 뒤 다운로드”는 막을 수 있습니다.

spec:
  predictor:
    containers:
      - name: kserve-container
        image: ghcr.io/acme/sentiment:1.0.0
        env:
          - name: MODEL_PATH
            value: /models/model.pt
        volumeMounts:
          - name: model-vol
            mountPath: /models
    initContainers:
      - name: fetch-model
        image: curlimages/curl:8.5.0
        command:
          - /bin/sh
          - -c
          - |
            set -e
            test -f /models/model.pt || \
              curl -L -o /models/model.pt "https://storage.example.com/models/model.pt"
        volumeMounts:
          - name: model-vol
            mountPath: /models
    volumes:
      - name: model-vol
        persistentVolumeClaim:
          claimName: sentiment-model-pvc
  • test -f로 캐시 히트 시 다운로드를 건너뜁니다.

6) 런타임 워밍업: “첫 추론”을 부팅 시점에 끝내기

PyTorch/TF는 첫 추론에서 커널 컴파일, 메모리 할당, 그래프 최적화가 터지며 지연이 큽니다. 이를 첫 사용자 요청에 떠넘기지 말고, 컨테이너 시작 시점에 끝내야 합니다.

가장 단순한 방법은 애플리케이션 엔트리포인트에서 더미 입력으로 1회 워밍업을 수행하는 겁니다.

# app.py
import os
import time
import torch

MODEL_PATH = os.getenv("MODEL_PATH", "/models/model.pt")

def load_model():
    model = torch.jit.load(MODEL_PATH, map_location="cpu")
    model.eval()
    return model

model = load_model()

# warmup
with torch.no_grad():
    x = torch.zeros((1, 768))
    _ = model(x)

STARTED_AT = time.time()

def predict(vec):
    with torch.no_grad():
        return model(vec)

추가로 모델 자체를 경량화하면 워밍업/로딩 시간이 줄어 콜드스타트가 더 내려갑니다. 특히 CPU 서빙이면 PTQ 기반 int8 양자화가 체감이 큽니다.


7) Readiness Probe를 “진짜 준비됨”으로 바꿔라

콜드스타트가 길어 보이는 이유가 “파드는 떴는데 준비가 안 됨”인 경우가 많습니다. 이때 readiness가 너무 이르게 통과하면, 실제로는 모델이 준비되지 않았는데 트래픽이 들어와 타임아웃/재시도 폭탄이 생깁니다.

권장 패턴은 /readyz모델 로딩 완료 + 워밍업 완료를 확인하도록 만들고, K8s readinessProbe가 그 엔드포인트만 보게 하는 겁니다.

readinessProbe:
  httpGet:
    path: /readyz
    port: 8080
  initialDelaySeconds: 1
  periodSeconds: 2
  timeoutSeconds: 1
  failureThreshold: 30
  • failureThreshold를 늘려 “초기 준비 시간”을 충분히 허용합니다.
  • 이 설정은 콜드스타트 시간을 줄이진 않지만, 첫 요청 실패/지연 폭발을 줄여 체감 품질을 크게 올립니다.

8) 노드 스케줄링이 병목이면: 리소스/어피니티/프로비저닝을 먼저 손본다

파드가 뜨기까지 오래 걸리는 경우, 원인은 애플리케이션이 아니라 노드가 없어서입니다. 특히 GPU는 더 심합니다.

체크 포인트:

  • GPU 노드가 0으로 내려가면, 첫 요청은 “노드 생성 + 드라이버 준비 + 이미지 pull”까지 포함됩니다.
  • 우선순위가 낮아 스케줄링이 밀리는 경우가 있습니다.

실전 처방:

  • GPU 풀은 min을 1로 유지(최소 1대 상시)
  • nodeSelector/tolerations/affinity로 정확한 노드에만 올라가게 고정
  • 가능하면 모델별로 노드풀을 분리해 간섭을 줄임

노드 오토스케일이 기대대로 안 움직이는 케이스는 아래 글의 점검 항목이 도움이 됩니다.


9) Knative/KServe에서 “측정” 없이는 최적화가 안 된다

콜드스타트 최적화는 감으로 하면 실패합니다. 최소한 아래 3가지는 시간으로 찍어야 합니다.

  • Request가 들어온 시점부터 Pod Ready까지
  • Pod Ready부터 첫 200 응답까지
  • 200 응답의 서버 내부 구간(모델 로딩, 워밍업, 전처리)

빠른 측정 커맨드

# 리비전/파드 이벤트로 스케줄링 지연 확인
kubectl -n ml describe pod -l serving.kserve.io/inferenceservice=sentiment

# Knative 서비스/리비전 상태 확인
kubectl -n ml get ksvc
kubectl -n ml get revision

# 실측: 첫 요청과 두 번째 요청 비교
curl -s -o /dev/null -w "time_total=%{time_total}\n" https://your-domain/v1/models/sentiment:predict -d '{"inputs":[0]}'
curl -s -o /dev/null -w "time_total=%{time_total}\n" https://your-domain/v1/models/sentiment:predict -d '{"inputs":[0]}'
  • 첫 번째와 두 번째 요청 차이가 크면 콜드스타트가 맞고, 두 번째도 느리면 모델/서버 자체 병목일 가능성이 큽니다.

10) “10배”를 만드는 현실적인 조합(추천 레시피)

환경마다 다르지만, 운영에서 성공률이 높은 조합을 정리하면 아래와 같습니다.

레시피 A: 비용보다 SLA가 중요한 핵심 모델

  • autoscaling.knative.dev/minScale: 1
  • containerConcurrency를 모델 특성에 맞게 낮춤(GPU면 1부터)
  • readiness를 모델 준비 완료 기준으로 강화
  • 이미지 프리풀 + 동일 리전 레지스트리

체감: 첫 요청이 사실상 사라져 “항상 웜”에 가까워집니다.

레시피 B: 비용 민감 + 간헐 트래픽

  • scaleDownDelay: 10m로 빈번한 냉각 방지
  • PV 캐시 또는 initContainer 프리패치
  • 이미지 프리풀(특히 노드 교체가 잦을 때)

체감: 콜드스타트 발생 빈도가 크게 줄고, 발생하더라도 상한이 내려갑니다.

레시피 C: GPU + 노드 오토스케일이 섞인 최악의 케이스

  • GPU 노드풀 min을 1로 유지(또는 업무 시간대만)
  • 모델 weight를 노드 로컬/PV로 캐시
  • 프리풀로 이미지 pull 제거
  • 워밍업을 부팅 시점에 수행

체감: “노드 생성이 포함된 60초”를 “수 초” 수준으로 줄이는 게 목표입니다.


마무리: 콜드스타트는 설정 하나가 아니라 파이프라인 최적화다

KServe+Knative 콜드스타트는 단일 원인이 아니라, 라우팅부터 모델 로딩까지 여러 구간이 직렬로 이어진 결과입니다. 따라서 10배 개선도 “한 방”보다는 다음 순서로 접근할 때 성공 확률이 높습니다.

  1. minScale 또는 scaleDownDelay콜드스타트 빈도부터 줄이고
  2. 이미지 프리풀/레지스트리 최적화로 pull 지연을 없애고
  3. PV 캐시/프리패치로 모델 다운로드를 요청 경로에서 제거하고
  4. 워밍업+readiness로 첫 요청 실패/폭발을 막습니다.

이 과정을 거치면, 대부분의 팀이 목표하는 “첫 요청도 실서비스 수준”에 가까운 응답 시간을 만들 수 있습니다.