Published on

KServe+Knative로 GPU 모델 자동스케일 배포

Authors

GPU 추론 서비스를 쿠버네티스에 올릴 때 가장 큰 비용은 대개 유휴 GPU입니다. 트래픽이 들쭉날쭉한데도 항상 GPU 파드를 띄워두면, 비용은 물론이고 노드 자원도 묶입니다. 이럴 때 KServe의 모델 서빙 추상화와 Knative Serving의 요청 기반 오토스케일을 결합하면, 요청이 없을 땐 0으로 내려가고(Scale to Zero), 요청이 오면 자동으로 GPU 파드를 띄우는 구조를 만들 수 있습니다.

이 글은 다음을 목표로 합니다.

  • KServe가 Knative 위에서 어떻게 동작하는지 이해
  • GPU 모델을 InferenceService로 배포하고 자동스케일 설정
  • 운영에서 자주 터지는 콜드스타트, 동시성, OOM, 스케줄링 이슈 예방

추론 최적화(예: TensorRT, INT8)까지 연결하려면 아래 글도 같이 보면 좋습니다.


KServe와 Knative의 역할 분담

KServe가 해주는 것

KServe는 모델 서빙을 CRD로 추상화합니다. 핵심은 InferenceService 하나로 아래를 묶어 관리한다는 점입니다.

  • Predictor(실제 추론 컨테이너)
  • (선택) Transformer(전처리/후처리)
  • (선택) Explainer
  • 트래픽 라우팅, 리비전 관리, 배포 상태

또한 ModelMeshlocal model 패턴, 스토리지에서 모델 아티팩트 로딩 등 모델 운영에 필요한 기능을 제공합니다.

Knative Serving이 해주는 것

Knative Serving은 HTTP 요청 기반 워크로드를 대상으로 다음을 제공합니다.

  • Revision 단위 배포 및 트래픽 스플릿
  • Autoscaler(KPA)로 요청 수 기반 확장
  • Scale to Zero 및 빠른 롤아웃

KServe는 내부적으로 Knative Service를 생성해 Predictor를 올리는 방식이 일반적입니다(설치 옵션에 따라 차이는 있음).


사전 준비 체크리스트

환경마다 설치 방식이 다르니, 여기서는 “GPU 노드가 있는 쿠버네티스 클러스터”에서 KServe가 Knative 기반으로 동작한다는 전제를 둡니다.

1) GPU 디바이스 플러그인과 런타임

  • NVIDIA GPU 노드
  • nvidia-device-plugin 설치
  • 컨테이너 런타임에서 GPU 사용 가능

GPU가 제대로 잡히는지 가장 먼저 확인합니다.

kubectl describe node <node-name> | grep -i nvidia -n
kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.allocatable.nvidia\.com/gpu}{"\n"}{end}'

위 명령에서 nvidia.com/gpu 할당량이 보이지 않으면, 이후 설정이 모두 무의미해집니다.

2) KServe, Knative, Istio(또는 게이트웨이)

KServe는 네트워킹 레이어로 Istio를 많이 씁니다. 설치 조합에 따라 다르지만, 최소한 아래가 준비돼야 합니다.

  • Knative Serving
  • KServe
  • Ingress Gateway(예: Istio)

설치 후 리소스가 정상인지 확인합니다.

kubectl get pods -n knative-serving
kubectl get pods -n kserve
kubectl get crd | grep -E 'inferenceservices|serving.knative'

GPU 모델을 KServe InferenceService로 배포하기

가장 현실적인 시작은 커스텀 컨테이너로 Predictor를 띄우는 방식입니다. 프레임워크 서버(예: Triton, TorchServe)를 써도 되지만, 여기서는 구조를 명확히 보이기 위해 간단한 FastAPI 기반 예시를 듭니다.

1) 예시 추론 서버(간단 FastAPI)

/v1/models/model:predict 같은 형태로 맞추면 클라이언트 통합이 쉬워집니다.

# app.py
from fastapi import FastAPI
from pydantic import BaseModel
import torch

app = FastAPI()

class Req(BaseModel):
    text: str

# 예시: 실제로는 모델 로드
@app.on_event("startup")
def load_model():
    global device
    device = "cuda" if torch.cuda.is_available() else "cpu"

@app.post("/v1/models/model:predict")
def predict(req: Req):
    # 실제 추론 로직 대신 더미
    return {"device": device, "output": req.text[::-1]}

컨테이너는 GPU를 쓰려면 torch CUDA 빌드, CUDA 베이스 이미지 등이 필요합니다.

# Dockerfile
FROM pytorch/pytorch:2.2.0-cuda12.1-cudnn8-runtime

WORKDIR /app
COPY app.py /app/app.py

RUN pip install --no-cache-dir fastapi uvicorn

EXPOSE 8080
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]

2) InferenceService YAML

아래는 핵심 포인트만 넣은 예시입니다.

  • resources.limitsnvidia.com/gpu: "1"
  • containerConcurrency로 동시성 제어
  • Knative 오토스케일 어노테이션으로 최소/최대 스케일, 타깃 동시성 설정
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: gpu-text-reverser
  namespace: ml
  annotations:
    autoscaling.knative.dev/minScale: "0"
    autoscaling.knative.dev/maxScale: "5"
    autoscaling.knative.dev/target: "1"
    autoscaling.knative.dev/scaleDownDelay: "30s"
    autoscaling.knative.dev/metric: "concurrency"
spec:
  predictor:
    containerConcurrency: 1
    containers:
      - name: user-container
        image: registry.example.com/ml/gpu-text-reverser:0.1.0
        ports:
          - containerPort: 8080
        resources:
          limits:
            cpu: "2"
            memory: "4Gi"
            nvidia.com/gpu: "1"
          requests:
            cpu: "1"
            memory: "2Gi"
        env:
          - name: UVICORN_WORKERS
            value: "1"

적용합니다.

kubectl create ns ml
kubectl apply -f inferenceservice.yaml
kubectl get inferenceservice -n ml
kubectl describe inferenceservice gpu-text-reverser -n ml

오토스케일 동작을 “GPU 관점”에서 이해하기

Knative KPA는 기본적으로 HTTP 요청을 보고 파드를 늘립니다. 하지만 GPU 추론은 CPU 웹앱과 다르게 다음 특성이 있습니다.

  • 한 파드가 GPU 1장을 독점하거나, 제한적으로만 공유
  • 모델 로드 시간이 길어 콜드스타트가 치명적
  • 동시성 과다 시 GPU 메모리 파편화, OOM, 지연 폭증

따라서 오토스케일 설정은 “QPS”보다 “동시성”이 안전한 경우가 많습니다.

targetcontainerConcurrency

  • containerConcurrency: 파드 하나가 동시에 처리할 최대 요청 수
  • autoscaling.knative.dev/target: 오토스케일이 목표로 삼는 동시성

예를 들어 containerConcurrency: 1target: 1이면, 요청이 동시에 2개 들어오는 순간 새 파드를 늘리려는 방향으로 동작합니다. GPU 메모리 여유가 없거나, 단일 요청 지연을 안정적으로 유지해야 하는 모델에 유리합니다.

반대로 배치 추론을 하거나 GPU 여유가 많다면 containerConcurrency를 2에서 4 정도로 올려도 됩니다. 다만 이때는 모델과 프레임워크의 스레딩, CUDA 스트림 사용, 토크나이저 CPU 병목 등을 함께 봐야 합니다.


Scale to Zero의 함정: 콜드스타트 최소화 전략

GPU 모델은 Scale to Zero가 비용 면에서는 최고지만, 콜드스타트가 길면 사용자 경험이 망가집니다. 실전에서 많이 쓰는 타협점은 아래 중 하나입니다.

1) minScale을 0이 아닌 1로

야간에도 간헐 요청이 있고, 첫 요청 지연이 치명적이면 minScale: "1"로 두고 비용을 감수합니다.

2) 모델 로딩을 최적화

  • 모델 가중치를 이미지에 bake-in(단, 이미지 크기 증가)
  • PV에서 로딩 시, 스토리지 성능 점검
  • TensorRT 엔진을 미리 빌드해 로드 시간 단축

최적화 과정에서 실수로 성능이 오히려 떨어지는 케이스도 흔합니다.

3) 워밍업 요청(운영 트릭)

외부 크론이나 내부 잡으로 주기적으로 요청을 날려 0으로 내려가지 않게 만드는 방식입니다. 다만 이건 “Scale to Zero”의 의미를 약하게 만들고, 트래픽 패턴에 따라 오히려 비용이 늘 수 있습니다.


GPU 스케줄링: 노드 셀렉터와 톨러레이션

GPU 노드 풀을 따로 운영한다면, GPU 파드가 CPU 노드에 스케줄링 시도하다 Pending에 걸리는 일이 흔합니다. 아래를 명시해 두면 안전합니다.

spec:
  predictor:
    containers:
      - name: user-container
        image: registry.example.com/ml/gpu-text-reverser:0.1.0
        resources:
          limits:
            nvidia.com/gpu: "1"
    nodeSelector:
      nodepool: gpu
    tolerations:
      - key: "nvidia.com/gpu"
        operator: "Exists"
        effect: "NoSchedule"

클러스터 정책에 따라 라벨과 톨러레이션 키는 다를 수 있습니다.


관측과 장애 대응 포인트

GPU 서빙은 장애가 나면 보통 증상이 비슷합니다. 하지만 원인은 여러 갈래라, 관측 포인트를 미리 정해두는 게 중요합니다.

1) OOMKilled, CrashLoopBackOff

모델 로드 시점에 메모리를 크게 먹거나, 동시성 과다로 메모리가 튀면 OOMKilled가 납니다. 이 경우 Knative는 재시작을 반복하면서 오토스케일이 꼬이기도 합니다.

  • 파드 이벤트에서 OOMKilled 확인
  • limits.memory 상향 또는 모델 메모리 최적화
  • containerConcurrency 하향

쿠버네티스 장애 패턴은 아래 글이 체크리스트로 좋습니다.

2) 지연 폭증: CPU 병목과 큐잉

GPU를 쓰는데도 느리다면, 실제 병목은 종종 CPU 토크나이저, JSON 파싱, 이미지 전처리입니다. 이때는 GPU 파드를 늘려도 해결이 안 됩니다.

  • requests.cpu를 너무 낮게 주면 스케줄링은 되지만 지연이 늘어남
  • containerConcurrency를 올리면 CPU 큐잉이 더 심해질 수 있음

3) Knative 오토스케일이 기대대로 안 늘어나는 경우

  • 동시성 메트릭 기준인데, 클라이언트가 keep-alive로 연결만 잡고 요청을 안 보내는 경우
  • 타임아웃 설정으로 요청이 중간에 끊겨 메트릭이 왜곡되는 경우
  • 스케일 업은 되었지만 GPU 노드가 부족해 Pending이 쌓이는 경우

Pending이 쌓이면 “오토스케일은 했는데 처리량이 안 늘어나는” 상태가 됩니다. 이때는 노드 오토스케일러(Cluster Autoscaler 등)와 함께 봐야 합니다.


실전 권장 설정 조합(출발점)

모델과 트래픽 성격에 따라 다르지만, 처음 운영에 들어갈 때 비교적 안전한 출발점은 아래입니다.

  • containerConcurrency: 1
  • autoscaling.knative.dev/target: "1"
  • minScale: "0"(콜드스타트 허용 시) 또는 "1"(첫 요청 지연이 치명적일 때)
  • maxScale: GPU 노드 수와 1대당 GPU 수를 고려해 상한 설정
  • 파드 리소스는 requests를 너무 낮게 잡지 말 것(특히 CPU)

또한 배포 전략(카나리, 롤링)까지 포함하면 운영 안정성이 크게 올라갑니다. KServe 자체도 트래픽 분할을 지원하지만, “GPU 추론 배포 운영” 관점의 실전 팁은 아래 글도 참고할 만합니다.


요청 테스트와 엔드포인트 확인

KServe는 보통 Ingress를 통해 라우팅됩니다. 환경에 따라 도메인/게이트웨이가 다르므로, 먼저 생성된 Knative Service와 URL을 확인합니다.

kubectl get ksvc -n ml
kubectl get inferenceservice gpu-text-reverser -n ml -o jsonpath='{.status.url}{"\n"}'

테스트 요청 예시입니다.

curl -X POST \
  -H 'Content-Type: application/json' \
  -d '{"text":"hello gpu"}' \
  $(kubectl get inferenceservice gpu-text-reverser -n ml -o jsonpath='{.status.url}')/v1/models/model:predict

요청이 없으면 잠시 후 0으로 내려가는지 확인하려면, 리비전의 파드 수를 관찰합니다.

kubectl get pods -n ml -w

마무리: 비용과 지연의 균형을 설계하라

KServe와 Knative 조합의 핵심 가치는 “모델 서빙 표준화”와 “요청 기반 자동 확장”입니다. 특히 GPU처럼 단가가 큰 리소스에서 Scale to Zero는 비용 효율을 극적으로 올릴 수 있습니다.

다만 GPU 모델은 콜드스타트와 동시성에 민감하므로, 다음 3가지를 반드시 같이 설계해야 합니다.

  • 콜드스타트 허용 여부에 따른 minScale 전략
  • GPU 메모리 안정성을 위한 containerConcurrencytarget 튜닝
  • GPU 노드 풀 스케줄링과 노드 오토스케일 연동

이 3가지만 잡아도 “돌아는 가는데 운영이 불안한 GPU 서빙”에서 “예측 가능한 비용과 지연”으로 넘어갈 수 있습니다.