Skip to main content

GPU 앞에서 CPU HPA는 너무 늦게 움직인다

웹 서비스 시대의 오토스케일링은 비교적 단순했다. 요청이 많아지면 CPU 사용률이 올라갔다. CPU 사용률이 70%를 넘으면 Pod를 늘리고, 트래픽이 줄어 CPU 사용률이 내려가면 Pod를 줄였다. 이 방식은 수많은 API 서버와 백엔드 워커를 안정적으로 운영하게 해준 훌륭한 기본값이었다.

그런데 LLM 추론 서버 앞에서는 이 상식이 자주 깨진다.

vLLM Pod 하나가 GPU 위에서 열심히 토큰을 생성하고 있다고 해보자. GPU 메모리에는 모델 가중치와 KV 캐시가 가득 차 있고, 요청 큐에는 사용자의 프롬프트가 기다리고 있다. 사용자 입장에서는 첫 토큰이 늦게 나오고, 스트리밍 응답 간격도 길어진다. 분명히 서비스는 바쁘다. 그런데 CPU 사용률은 20%일 수 있다. 반대로 토크나이저가 바쁘거나 JSON 직렬화가 몰리는 순간에는 CPU가 높아지지만 GPU는 놀고 있을 수도 있다.

이 장의 출발점은 바로 이 불일치다. AI 추론 워크로드에서 "바쁘다"는 말은 CPU가 바쁘다는 뜻이 아니다. 요청이 큐에 쌓이는지, 첫 토큰까지 시간이 길어지는지, GPU 메모리에 KV 캐시가 얼마나 찼는지, 이미 실행 중인 요청이 몇 개인지, 배치가 포화됐는지를 함께 봐야 한다. llm-d 프로젝트의 워크로드 오토스케일링 가이드도 GPU 사용률은 active batching 상황에서 거의 100%에 붙어 있을 수 있어 실제 부하 신호로는 불안정하며, queue depth, in-flight request, KV cache pressure 같은 SLO 지향 신호가 더 중요하다고 설명한다1.

쿠버네티스 오토스케일링을 이해할 때는 먼저 "무엇을 늘리는가"를 분리해야 한다. Pod를 늘리는 일과 노드를 늘리는 일은 다르다. Pod 수를 늘렸는데 GPU 노드가 없으면 Pod는 Pending 상태로 멈춘다. 노드를 늘렸는데 모델 Pod가 늘지 않으면 GPU는 비싼 대기실이 된다. AI 인프라의 오토스케일링은 이 두 계층을 맞물리게 설계하는 일이다.

이 장에서는 HPA, VPA, Cluster Autoscaler, Karpenter가 각각 어떤 계층을 조정하는지 먼저 정리한다. 그런 다음 KEDA를 이용해 큐와 이벤트 기반으로 추론 워크로드를 확장하는 방법을 살펴본다. 마지막에는 TTFT, ITL, requests waiting, GPU cache usage 같은 추론 메트릭으로 실제 정책을 설계하고, Karpenter로 필요한 GPU 인스턴스를 빠르게 만들고 유휴 노드를 제거하는 비용 최적화 패턴까지 연결한다.

오토스케일링: HPA를 넘어서

오토스케일링의 네 가지 축

쿠버네티스에서 "오토스케일링을 켠다"는 말은 하나의 기능을 켠다는 뜻이 아니다. 실제 운영에서는 여러 컨트롤러가 서로 다른 결정을 내린다. 어떤 컨트롤러는 Pod 복제본 수를 조정하고, 어떤 컨트롤러는 Pod의 리소스 요청값을 추천하거나 변경하며, 어떤 컨트롤러는 노드 자체를 만들고 지운다.

AI 추론 클러스터에서는 다음 네 가지를 구분해야 한다.

구성 요소조정 대상대표 신호AI 워크로드에서의 역할
HPAPod replica 수CPU, 메모리, 커스텀 메트릭추론 서버나 워커 Pod 수를 늘리고 줄인다
VPAPod의 CPU/메모리 requests과거 사용량토크나이저, 라우터, API 서버의 리소스 요청값을 보정한다
Cluster Autoscaler노드 수Pending Pod, 노드 유휴 상태GPU Pod가 Pending이면 GPU 노드 풀을 늘리고, 비면 줄인다
Karpenter노드 수와 인스턴스 타입Pending Pod의 요구 조건필요한 GPU SKU를 즉시 선택해 만들고, 유휴 노드를 공격적으로 정리한다

HPA는 Horizontal Pod Autoscaler의 약자다. 이름 그대로 수평 확장, 즉 Pod 개수를 늘리고 줄인다. 가장 흔한 예는 CPU 사용률 70%를 목표로 Deployment replica를 2개에서 5개로 늘리는 방식이다. HPA 자체는 반드시 CPU만 보는 도구는 아니다. Kubernetes autoscaling/v2 API는 custom metrics와 external metrics도 사용할 수 있다. Prometheus Adapter나 KEDA 같은 계층을 붙이면 큐 길이, 지연 시간, GPU 사용률 같은 신호로도 HPA를 움직일 수 있다.

VPA는 Vertical Pod Autoscaler다. Pod 개수를 늘리는 대신 Pod 하나가 요청하는 CPU와 메모리 양을 조정한다. AI 추론에서 VPA는 GPU 자체보다는 주변 컴포넌트에 더 유용하다. 예를 들어 LLM 라우터, 임베딩 전처리 서버, 토크나이저 전용 워커는 CPU와 메모리 사용량 예측이 어렵다. 처음에는 cpu: 500m, memory: 1Gi로 시작했지만 실제로는 메모리 4Gi가 필요할 수 있다. VPA는 이런 워크로드의 requests를 현실에 맞게 잡아준다.

다만 VPA를 추론 서버에 무작정 적용하면 조심해야 한다. VPA가 Pod를 재시작해 리소스 값을 바꾸는 모드에서는 모델이 다시 로딩된다. 70B 모델을 올리는 Pod가 VPA 때문에 재시작되면 몇 분짜리 장애가 될 수 있다. 그래서 프로덕션 추론 서버에는 VPA를 "추천 모드"로 먼저 사용하고, 실제 적용은 사람이 검토하거나 배포 파이프라인에서 반영하는 편이 안전하다.

Cluster Autoscaler는 Pod가 아니라 노드를 조정한다. 예를 들어 Deployment가 replicas: 3이고 각 Pod가 nvidia.com/gpu: 1을 요청하는데 현재 GPU 노드에 빈 GPU가 없다면, 새 Pod는 Pending 상태가 된다. 이때 Cluster Autoscaler는 "스케줄링할 수 없는 Pod가 있다"는 사실을 보고 GPU 노드 풀을 확장한다. 반대로 어떤 노드가 오래 비어 있고 그 노드의 Pod를 다른 곳으로 옮길 수 있으면 노드를 제거한다.

Karpenter는 Cluster Autoscaler와 같은 노드 계층의 문제를 더 유연하게 푸는 도구다. 전통적인 Cluster Autoscaler는 미리 정의된 노드 그룹을 늘리고 줄이는 방식에 가깝다. Karpenter는 Pending Pod의 nodeSelector, affinity, toleration, resource request를 보고 그 조건을 만족하는 인스턴스 타입을 직접 고른다. L4 하나면 충분한 임베딩 서버와 H100 여러 장이 필요한 대형 모델을 같은 방식으로 다루지 않고, 워크로드 요구에 맞는 노드를 더 세밀하게 만들 수 있다.

여기서 중요한 점은 네 도구가 경쟁 관계가 아니라는 것이다. HPA 또는 KEDA가 Pod 수를 늘린다. 새 Pod가 GPU를 요구하지만 들어갈 노드가 없으면 Cluster Autoscaler나 Karpenter가 노드를 만든다. 트래픽이 빠지면 KEDA가 Pod를 줄인다. GPU 노드가 비면 Karpenter가 노드를 제거한다. AI 인프라의 비용 최적화는 이 체인이 끊기지 않게 만드는 일이다.

HPA로 할 수 있는 것과 할 수 없는 것

HPA는 여전히 중요하다. 이 장의 제목이 "HPA를 넘어서"라고 해서 HPA를 버리자는 뜻은 아니다. 오히려 KEDA, Prometheus Adapter, KServe의 KEDA autoscaler 같은 도구 대부분은 내부적으로 HPA를 이용한다. KEDA도 ScaledObject를 해석해 HPA를 생성하고, external metric을 HPA가 읽을 수 있는 형태로 제공한다2.

가장 단순한 HPA는 다음과 같다.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: router-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: llm-router
  minReplicas: 2
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

이 설정은 LLM 라우터나 API 게이트웨이에는 꽤 잘 맞는다. 라우터는 GPU를 쓰지 않고, 요청 인증, 테넌트 제한, 라우팅, 응답 스트리밍 같은 CPU 중심 작업을 한다. 이런 컴포넌트는 CPU 기반 HPA가 여전히 좋은 출발점이다.

문제는 모델 서버다. vLLM, SGLang, Triton, TensorRT-LLM 같은 추론 서버는 CPU 사용률과 실제 혼잡도가 잘 맞지 않는다. 특히 continuous batching을 사용하는 LLM 서버에서는 요청이 적어도 생성 중에는 GPU utilization이 높게 보일 수 있고, 요청이 폭증해 큐가 쌓이는 순간에는 GPU 사용률 지표가 이미 늦은 신호가 된다. 사용자는 지연을 겪고 있는데 autoscaler는 뒤늦게 "아, 바빴구나"라고 판단한다.

그래도 HPA를 포기할 필요는 없다. HPA가 읽는 메트릭을 바꾸면 된다. CPU 대신 Prometheus Adapter를 통해 다음과 같은 메트릭을 HPA에 연결할 수 있다.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: vllm-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: vllm-qwen
  minReplicas: 1
  maxReplicas: 8
  metrics:
    - type: Pods
      pods:
        metric:
          name: vllm_requests_waiting
        target:
          type: AverageValue
          averageValue: "4"

이 예시는 Pod당 대기 요청 수가 평균 4개를 넘으면 replica를 늘리는 식으로 동작한다. CPU보다 훨씬 더 추론 서비스의 체감 지연에 가깝다. 다만 이 방식은 custom metrics pipeline을 직접 구성해야 한다. Prometheus가 메트릭을 수집하고, Prometheus Adapter가 Kubernetes Custom Metrics API로 변환하고, HPA가 이를 읽는다. 구성은 강력하지만 운영할 컴포넌트가 늘어난다.

그래서 많은 팀은 HPA를 직접 다루기보다 KEDA를 사용한다. KEDA는 이벤트 소스와 HPA 사이의 번역 계층이다. 큐, Kafka lag, SQS 메시지 수, RabbitMQ queue length, Prometheus 쿼리 결과, Cron 스케줄 같은 신호를 HPA가 이해할 수 있는 external metric으로 바꿔준다. KEDA 공식 사이트는 Kafka, RabbitMQ, Prometheus, Cron, Redis, cloud queue 등 70개 이상의 scaler를 제공한다고 설명한다3.

KEDA: 이벤트가 있을 때만 GPU를 깨우기

KEDA의 핵심 아이디어는 간단하다. "일이 있으면 켜고, 일이 없으면 끈다."

웹 API 서버는 보통 항상 켜져 있어야 한다. 사용자가 언제 접속할지 모르기 때문이다. 하지만 모든 AI 워크로드가 실시간 API는 아니다. 요약 작업, 음성 합성, 이미지 생성, 임베딩 일괄 생성, 리포트 생성, 에이전트 백그라운드 작업은 큐 기반으로 처리할 수 있다. 이런 워크로드는 요청을 큐에 넣고, 워커가 큐에서 꺼내 처리하고, 결과를 저장한 뒤 사용자가 polling이나 callback으로 결과를 가져가는 구조가 잘 맞는다.

이 구조에서는 큐가 비어 있을 때 GPU 워커를 0개로 줄일 수 있다. GKE의 KEDA scale-to-zero 문서도 KEDA가 Pub/Sub 같은 외부 이벤트 지표를 사용해 워크로드를 Pod 0개까지 줄이고, 필요할 때 다시 늘릴 수 있다고 설명한다4.

흐름은 다음과 같다.

사용자 요청
  ↓
API Gateway: job_id 반환
  ↓
Queue: Redis / Kafka / SQS / RabbitMQ / Pub/Sub
  ↓
KEDA: queue depth 감시
  ↓
Worker Deployment: 0 → N
  ↓
GPU Inference Pod: 모델 로딩 후 처리
  ↓
Result Store: 결과 저장

KEDA에서 Deployment를 이벤트 기반으로 확장하려면 ScaledObject를 만든다. 예를 들어 Redis list 길이를 기준으로 워커를 확장하는 설정은 다음과 같은 모양이 된다.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: llm-worker-redis-scaler
  namespace: inference
spec:
  scaleTargetRef:
    name: llm-worker
  pollingInterval: 15
  cooldownPeriod: 300
  minReplicaCount: 0
  maxReplicaCount: 10
  triggers:
    - type: redis
      metadata:
        address: redis.inference.svc.cluster.local:6379
        listName: inference_queue
        listLength: "5"

이 설정에서 중요한 필드는 세 가지다. pollingInterval은 KEDA가 이벤트 소스를 확인하는 주기다. 15초라면 15초마다 Redis queue depth를 확인한다. cooldownPeriod는 마지막 이벤트 이후 scale down을 얼마나 기다릴지 정한다. LLM 추론은 모델 로딩 비용이 크므로 너무 빨리 줄이면 다시 곧바로 cold start를 겪는다. minReplicaCount는 0으로 둘지 1 이상으로 둘지 결정한다. 0이면 비용은 줄지만 첫 요청이 느려지고, 1이면 비용은 들지만 항상 따뜻한 Pod가 남는다.

Kafka consumer라면 queue depth 대신 consumer lag을 본다.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: embedding-consumer-kafka-scaler
  namespace: inference
spec:
  scaleTargetRef:
    name: embedding-consumer
  pollingInterval: 10
  cooldownPeriod: 180
  minReplicaCount: 0
  maxReplicaCount: 30
  triggers:
    - type: kafka
      metadata:
        bootstrapServers: kafka.kafka.svc.cluster.local:9092
        consumerGroup: embedding-workers
        topic: document-embedding
        lagThreshold: "100"

SQS를 쓰는 AWS 환경에서는 다음처럼 큐 길이를 기준으로 확장할 수 있다.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: summary-worker-sqs-scaler
  namespace: inference
spec:
  scaleTargetRef:
    name: summary-worker
  pollingInterval: 15
  cooldownPeriod: 300
  minReplicaCount: 0
  maxReplicaCount: 12
  triggers:
    - type: aws-sqs-queue
      metadata:
        queueURL: https://sqs.ap-northeast-2.amazonaws.com/123456789012/summary-jobs
        queueLength: "5"
        awsRegion: ap-northeast-2
      authenticationRef:
        name: keda-aws-auth

RabbitMQ도 비슷하다.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: tts-worker-rabbitmq-scaler
  namespace: inference
spec:
  scaleTargetRef:
    name: tts-worker
  pollingInterval: 10
  cooldownPeriod: 120
  minReplicaCount: 0
  maxReplicaCount: 8
  triggers:
    - type: rabbitmq
      metadata:
        host: amqp://rabbitmq.rabbitmq.svc.cluster.local:5672/
        mode: QueueLength
        queueName: tts_tasks
        value: "20"

여기서 독자가 꼭 기억해야 할 것은 KEDA가 "GPU를 직접 늘리는 도구"가 아니라는 점이다. KEDA는 Pod 수를 늘린다. 새 Pod가 nvidia.com/gpu: 1 같은 리소스를 요청하면, 스케줄러는 GPU 노드를 찾는다. 빈 GPU 노드가 없으면 Pod는 Pending 상태가 된다. 이 Pending Pod를 보고 Cluster Autoscaler나 Karpenter가 GPU 노드를 만든다. 즉 scale-to-zero GPU는 최소 두 계층이 모두 작동해야 한다.

KEDA: 큐가 쌓였으니 GPU Pod를 0 → 1로 늘림
  ↓
Scheduler: 들어갈 GPU 노드가 없어서 Pod Pending
  ↓
Karpenter / Cluster Autoscaler: Pending Pod를 보고 GPU 노드 생성
  ↓
GPU Node Ready
  ↓
Pod Scheduling
  ↓
모델 로딩
  ↓
Readiness 통과 후 요청 처리

이 체인 중 하나라도 느리면 사용자는 기다린다. 그래서 scale-to-zero는 마법이 아니라 절충이다.

Scale-to-zero GPU의 달콤함과 차가운 현실

GPU scale-to-zero는 매력적이다. 트래픽이 없을 때 GPU 비용이 0에 가까워진다. 특히 하루에 몇 시간만 쓰는 내부 도구, 간헐적으로 실행되는 음성 합성, 문서 요약, 이미지 생성, 실험용 모델 API에는 강력하다.

하지만 첫 요청은 느리다. 그리고 LLM에서는 그 느림이 생각보다 길다. GPU 노드 생성, 드라이버 준비, 컨테이너 이미지 pull, 모델 가중치 다운로드 또는 마운트, GPU 메모리 로딩, warmup 요청까지 모두 필요하다. 한 공개 예제에서는 Redis queue depth를 KEDA가 감지해 vLLM Pod를 0에서 1로 늘리고, GKE Cluster Autoscaler가 GPU 노드를 만든 뒤, PVC에 있는 모델을 VRAM으로 로딩해 readiness가 통과하는 구조를 사용했다. 이 예제는 cold start 최적화 전후로 수 분 단위의 차이가 발생하며, 큐가 그 시간을 흡수하는 방식으로 설계되었다5.

이 현실을 받아들이면 설계가 명확해진다. 모든 워크로드에 scale-to-zero를 적용하지 않는다. 대신 서비스 성격에 따라 warm floor를 둔다.

워크로드권장 minReplicaCount이유
실시간 채팅 API1 이상첫 토큰 지연이 곧 사용자 불만이다
내부 운영용 요약/분류0 가능몇 분 지연을 허용할 수 있다
배치 임베딩 생성0 가능큐 기반 처리와 잘 맞는다
유료 고객용 대화형 에이전트1 이상 또는 시간대별 warm pool세션 경험이 중요하다
야간 학습/오프라인 추론0 가능작업이 있을 때만 켜면 된다

실전에서는 다음 세 가지 패턴을 조합한다.

첫째, 완전 scale-to-zero다. minReplicaCount를 0으로 두고, 큐가 쌓일 때만 Pod와 GPU 노드를 만든다. 비용은 가장 낮지만 cold start가 가장 크다. 사용자가 기다릴 수 있거나 비동기 API로 감쌀 수 있는 워크로드에 적합하다.

둘째, warm pod 패턴이다. minReplicaCount를 1 이상으로 두어 항상 모델 서버 하나를 켜둔다. 트래픽이 적을 때도 GPU 비용이 발생하지만 첫 요청 지연을 줄일 수 있다. 대화형 서비스는 보통 이쪽이 안전하다.

셋째, warm pool 패턴이다. 기본적으로는 0 또는 1개만 유지하다가, 출근 시간이나 캠페인 시간처럼 예측 가능한 피크 전에 Cron scaler로 미리 Pod를 늘린다. Azure AKS의 KAITO 예제도 KEDA cron scaler를 이용해 업무 시간에는 InferenceSet replica를 늘리고, 비업무 시간에는 줄이는 방식을 보여준다6.

예를 들어 한국 시간 평일 오전 9시부터 오후 6시까지 vLLM replica를 최소 3개로 유지하고 싶다면 다음처럼 Cron trigger를 추가할 수 있다.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: chat-warmpool-scaler
  namespace: inference
spec:
  scaleTargetRef:
    name: vllm-chat
  minReplicaCount: 0
  maxReplicaCount: 12
  cooldownPeriod: 600
  triggers:
    - type: cron
      metadata:
        timezone: Asia/Seoul
        start: "0 9 * * 1-5"
        end: "0 18 * * 1-5"
        desiredReplicas: "3"
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring.svc:9090
        metricName: vllm_requests_waiting
        threshold: "4"
        query: sum(vllm:num_requests_waiting{model_name="chat"})

여러 trigger가 있으면 보통 더 큰 replica 요구값이 적용된다. 업무 시간에는 기본 3개를 유지하고, requests waiting이 늘어나면 4개, 5개, 그 이상으로 늘어나는 식이다. 업무 시간이 끝나고 큐도 비면 cooldownPeriod 이후 줄어든다.

Scale-to-zero의 핵심은 cold start를 없애는 것이 아니라 관리하는 것이다. 모델 가중치를 이미지에 구워 넣으면 단순하지만 이미지가 거대해진다. 시작 시 Hugging Face나 S3에서 다운로드하면 이미지는 작지만 외부 네트워크와 저장소 지연에 영향을 받는다. PVC나 노드 로컬 캐시를 쓰면 재시작은 빨라지지만 캐시 무효화와 볼륨 관리가 필요하다. 어느 쪽이든 cold start 시간은 SLO로 측정해야 한다. "가끔 느릴 수 있음"이 아니라 "P95 cold start 4분 이하"처럼 숫자로 관리해야 한다.

LLM 추론 메트릭으로 스케일링하기

AI 추론 오토스케일링에서 가장 중요한 변화는 메트릭의 중심이 인프라에서 애플리케이션 내부 상태로 이동한다는 점이다. CPU, 메모리, GPU utilization은 여전히 필요하지만 충분하지 않다. 사용자 경험과 직접 연결되는 메트릭을 봐야 한다.

대표적인 추론 메트릭은 다음과 같다.

메트릭의미스케일링 관점
TTFTTime To First Token, 요청 후 첫 토큰까지 걸린 시간사용자 체감 지연의 핵심 신호
ITLInter-Token Latency, 토큰 사이 간격스트리밍 응답이 끊기는지 판단
requests waiting처리 대기 중인 요청 수scale out의 빠른 선행 신호
requests running현재 처리 중인 요청 수replica당 동시 처리 압력 판단
KV cache usageGPU 메모리 안의 KV 캐시 사용량긴 컨텍스트와 동시 요청 한계 판단
GPU memory usageVRAM 사용량OOM 위험과 모델 적재 여유 판단
tokens/sec초당 생성 토큰 수처리량과 효율 판단
batch wait time배치에 들어가기 전 대기 시간continuous batching 포화 여부 판단

Google Cloud의 TPU 기반 LLM 추론 오토스케일링 권장사항도 서버 측정항목 중 prefill backlog size와 slots used percentage가 지연 시간과 처리량에 밀접하게 연결된다고 설명한다. 특히 큐 크기는 요청이 처리되기 전 기다리는 양을 직접 나타내므로 지연 시간 목표와 비용 최적화를 함께 맞추는 출발점으로 권장된다7. GPU 기반 vLLM에서도 이름은 다르지만 사고방식은 같다. "지금 하드웨어가 몇 퍼센트 바쁜가"보다 "요청이 기다리고 있는가, 캐시가 꽉 차고 있는가"가 더 빠른 신호다.

KServe는 KEDA를 이용해 LLM 메트릭 기반으로 InferenceService를 확장하는 구성을 제공한다. 예를 들어 KServe 문서는 vLLM의 vllm:num_requests_running 메트릭을 Prometheus에서 읽고, target 값을 기준으로 replica를 늘리는 예제를 제시한다8. 이를 일반 Deployment에 적용하면 다음과 비슷하다.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: vllm-prometheus-scaler
  namespace: inference
spec:
  scaleTargetRef:
    name: vllm-chat
  minReplicaCount: 1
  maxReplicaCount: 8
  pollingInterval: 10
  cooldownPeriod: 300
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring.svc:9090
        metricName: vllm_requests_waiting
        threshold: "4"
        query: |
          sum(vllm:num_requests_waiting{namespace="inference",model_name="chat"})
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring.svc:9090
        metricName: vllm_gpu_cache_usage
        threshold: "0.85"
        query: |
          avg(vllm:gpu_cache_usage_perc{namespace="inference",model_name="chat"})

이 설정은 두 가지 신호를 본다. 대기 요청이 늘어나면 scale out한다. GPU cache usage가 85%에 가까워져도 scale out한다. 대기열이 쌓이기 전이라도 KV 캐시가 포화에 가까우면 새 요청을 받을 여유가 줄어들기 때문이다.

TTFT와 ITL은 조금 더 신중하게 써야 한다. 이들은 사용자 경험에 가장 가깝지만, 스케일링 신호로는 노이즈가 클 수 있다. 프롬프트 길이, max_tokens, 스트리밍 여부, 모델 종류, 도구 호출 여부에 따라 값이 크게 흔들린다. 그래서 TTFT와 ITL을 직접 threshold로 쓰기보다, 알림과 SLO 검증에 사용하고 scale out은 requests waiting, running requests, KV cache usage를 중심으로 설계하는 편이 안정적이다.

그래도 TTFT 기반 scale out이 필요한 경우에는 p95 값을 짧은 구간이 아니라 2~5분 정도 윈도우로 부드럽게 만든다.

triggers:
  - type: prometheus
    metadata:
      serverAddress: http://prometheus.monitoring.svc:9090
      metricName: ttft_p95_seconds
      threshold: "2.0"
      query: |
        histogram_quantile(
          0.95,
          sum(rate(vllm:time_to_first_token_seconds_bucket{model_name="chat"}[3m])) by (le)
        )

이런 메트릭을 사용할 때는 scale up과 scale down의 속도를 다르게 가져가야 한다. LLM 추론은 scale up은 빠르게, scale down은 느리게가 기본이다. scale up이 늦으면 사용자가 기다린다. scale down이 너무 빠르면 방금 줄인 Pod를 다시 늘리느라 모델을 반복 로딩한다.

KEDA는 생성하는 HPA의 behavior를 advanced 설정으로 조정할 수 있다.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: vllm-stable-scaler
  namespace: inference
spec:
  scaleTargetRef:
    name: vllm-chat
  minReplicaCount: 1
  maxReplicaCount: 10
  pollingInterval: 10
  cooldownPeriod: 600
  advanced:
    horizontalPodAutoscalerConfig:
      behavior:
        scaleUp:
          stabilizationWindowSeconds: 30
          policies:
            - type: Pods
              value: 3
              periodSeconds: 60
        scaleDown:
          stabilizationWindowSeconds: 600
          policies:
            - type: Pods
              value: 1
              periodSeconds: 120
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring.svc:9090
        metricName: requests_waiting
        threshold: "4"
        query: sum(vllm:num_requests_waiting{model_name="chat"})

이 정책은 1분에 최대 3개까지 빠르게 늘릴 수 있지만, 줄일 때는 2분에 1개씩 천천히 줄인다. 또한 scale down 안정화 기간을 10분으로 둔다. GPU 추론에서 이런 보수적인 scale down은 낭비처럼 보일 수 있지만, 실제로는 반복 cold start를 막아 사용자 경험과 비용을 함께 보호한다.

GPU 사용률 기반 스케일링의 함정

GPU utilization은 직관적이다. "GPU가 80% 이상 바쁘면 늘리자"는 정책은 이해하기 쉽다. Azure AKS 문서는 NVIDIA DCGM exporter가 수집한 DCGM_FI_DEV_GPU_UTIL 메트릭을 Azure Managed Prometheus와 KEDA가 읽어 GPU 워크로드를 자동 확장하는 예제를 제공한다9. NVIDIA Triton 문서도 DCGM, Prometheus, Prometheus Adapter를 연결해 HPA가 추론 서버 메트릭과 GPU 메트릭을 사용할 수 있도록 구성하는 절차를 설명한다10.

예를 들어 AKS나 일반 Kubernetes 환경에서 DCGM 메트릭을 Prometheus로 수집하고 있다면 다음처럼 KEDA를 구성할 수 있다.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: gpu-util-scaler
  namespace: inference
spec:
  scaleTargetRef:
    name: triton-inference
  minReplicaCount: 1
  maxReplicaCount: 20
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring.svc:9090
        metricName: DCGM_FI_DEV_GPU_UTIL
        query: avg(DCGM_FI_DEV_GPU_UTIL{namespace="inference",deployment="triton-inference"})
        threshold: "70"
        activationThreshold: "10"

이 방식은 이미지 분류, 음성 처리, 전통적인 딥러닝 추론처럼 요청 처리 시간이 비교적 짧고 GPU utilization과 처리량이 잘 맞는 워크로드에는 유용하다. 하지만 LLM에서는 조심해야 한다. GPU가 95%라는 사실은 두 가지 의미일 수 있다. 하나는 정말 포화되어 요청이 밀리는 상황이다. 다른 하나는 continuous batching이 잘 작동해 GPU를 효율적으로 쓰고 있지만 큐는 안정적인 상황이다.

반대로 GPU utilization이 낮다고 안전한 것도 아니다. 긴 프롬프트가 prefill 큐에 쌓여 있거나, KV 캐시 메모리가 부족해 새 요청 admission이 막히고 있을 수 있다. 이때 GPU utilization은 순간적으로 낮아도 사용자는 느리다고 느낀다.

그래서 LLM 추론의 스케일링 우선순위는 보통 다음 순서가 좋다.

  1. requests waiting 또는 queue depth를 가장 중요한 scale out 신호로 둔다.

  2. KV cache usage 또는 GPU memory usage를 포화 방지 신호로 추가한다.

  3. TTFT, ITL, p95 latency는 SLO 검증과 보조 scale out 신호로 사용한다.

  4. GPU utilization은 효율 모니터링과 비용 분석에 활용하되, 단독 scale out 신호로 쓰지 않는다.

운영 대시보드에서는 이 네 가지를 함께 봐야 한다. GPU utilization이 높고 queue depth가 낮으면 잘 운영 중일 수 있다. GPU utilization이 높고 queue depth가 계속 증가하면 scale out이 필요하다. GPU utilization이 낮은데 TTFT가 높다면 모델 로딩, 라우팅, 토크나이저, 네트워크, admission control 쪽을 봐야 한다. GPU cache usage가 90%를 넘고 OOM이 발생한다면 replica를 늘리는 것보다 max model length, max num seqs, batch 정책을 조정해야 할 수도 있다.

Karpenter로 GPU 노드를 필요한 만큼만 만들기

KEDA가 Pod 수를 늘렸다면 이제 노드가 필요하다. GPU 노드가 이미 충분히 있으면 Pod는 바로 스케줄링된다. 그러나 비용 최적화를 위해 GPU 노드를 0까지 줄이는 구조라면 새 노드를 만들어야 한다.

전통적인 Cluster Autoscaler도 이 일을 할 수 있다. GPU 노드 풀의 min을 0, max를 3으로 설정하고, Pending Pod가 생기면 노드 풀을 0에서 1로 늘린다. GKE scale-to-zero 예제나 여러 GPU inference 예제는 이 방식으로 Pod와 노드를 함께 0까지 줄인다4.

Karpenter는 여기서 한 단계 더 나아간다. 미리 정해둔 노드 그룹을 늘리는 대신, Pending Pod의 요구 조건을 보고 적합한 인스턴스를 선택한다. 예를 들어 L4, A10G, L40S, A100, H100을 섞어 쓸 수 있는 환경에서 임베딩 모델은 L4로, 중형 LLM은 L40S로, 대형 모델은 H100으로 보내고 싶다. Karpenter의 NodePool 요구 조건과 Pod의 nodeSelector 또는 nodeAffinity를 조합하면 이런 정책을 더 자연스럽게 표현할 수 있다.

AWS EKS 환경의 예시는 다음과 같은 형태다.

apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
  name: gpu-inference
spec:
  template:
    metadata:
      labels:
        workload: inference
    spec:
      requirements:
        - key: karpenter.k8s.aws/instance-gpu-count
          operator: Gt
          values: ["0"]
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64"]
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: ["g5", "g6", "p4", "p5"]
      taints:
        - key: nvidia.com/gpu
          value: "true"
          effect: NoSchedule
      nodeClassRef:
        apiVersion: karpenter.k8s.aws/v1beta1
        kind: EC2NodeClass
        name: gpu-node-class
  limits:
    nvidia.com/gpu: 64
  disruption:
    consolidationPolicy: WhenUnderutilized
    consolidateAfter: 5m

실제 필드와 API 버전은 설치한 Karpenter 버전에 따라 달라질 수 있지만, 핵심은 requirements와 consolidation이다. requirements는 어떤 GPU 노드를 만들 수 있는지 제한한다. consolidation은 유휴 또는 비효율 노드를 정리한다. 한 운영 사례에서는 KEDA가 Pod 계층을, Karpenter가 노드 계층을 담당하도록 구성해 GPU utilization을 개선하고 비용을 줄이는 패턴을 제시했다11.

Pod 쪽에서는 GPU 노드에만 올라가도록 toleration과 nodeSelector를 둔다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-chat
  namespace: inference
spec:
  replicas: 0
  selector:
    matchLabels:
      app: vllm-chat
  template:
    metadata:
      labels:
        app: vllm-chat
    spec:
      tolerations:
        - key: nvidia.com/gpu
          operator: Equal
          value: "true"
          effect: NoSchedule
      nodeSelector:
        workload: inference
        accelerator-tier: l4-or-better
      containers:
        - name: vllm
          image: vllm/vllm-openai:latest
          args:
            - --model=Qwen/Qwen2.5-7B-Instruct
            - --gpu-memory-utilization=0.85
          ports:
            - containerPort: 8000
          resources:
            requests:
              cpu: "4"
              memory: 24Gi
              nvidia.com/gpu: "1"
            limits:
              cpu: "4"
              memory: 24Gi
              nvidia.com/gpu: "1"

여기서 nvidia.com/gpu request가 핵심이다. KEDA가 이 Deployment를 0에서 1로 늘리면 Pod는 GPU 1개를 요구한다. 현재 들어갈 노드가 없으면 Pending이 된다. Karpenter는 Pending Pod의 요구 조건을 보고 GPU 인스턴스를 만든다.

Karpenter 기반 비용 최적화에서 중요한 설정은 consolidateAfter다. 너무 짧으면 노드가 자주 사라져 cold start가 잦아진다. 너무 길면 유휴 GPU 비용이 쌓인다. 추론 워크로드에서는 보통 Pod cooldownPeriod보다 노드 consolidateAfter를 조금 더 길게 둔다. 예를 들어 KEDA cooldownPeriod가 5분이면 Karpenter consolidateAfter는 5~10분으로 시작한다. 이렇게 하면 Pod가 줄어든 직후 바로 노드를 제거하지 않고, 짧은 재유입 트래픽을 흡수할 여지를 둔다.

트래픽 종료
  ↓
KEDA cooldown 5분 후 Pod 0
  ↓
Karpenter consolidateAfter 5~10분 후 GPU 노드 제거
  ↓
완전 idle 비용 0에 가까워짐

이 시간은 비용과 사용자 경험 사이의 다이얼이다. GPU가 시간당 비싸고 트래픽이 정말 드물다면 짧게 둔다. 트래픽이 파도처럼 자주 온다면 길게 둔다.

실전 설계: 대화형 LLM과 비동기 워커는 다르게 스케일링한다

이제 하나의 AI 서비스를 상상해보자. 사용자는 채팅 API를 호출한다. 긴 작업은 백그라운드 큐로 넘긴다. 짧은 대화는 실시간 vLLM 서버가 처리한다. 문서 임베딩과 요약 작업은 비동기 워커가 처리한다.

이 서비스를 하나의 오토스케일링 정책으로 다루면 실패한다. 컴포넌트마다 지연 허용 범위와 비용 구조가 다르기 때문이다.

실시간 채팅 vLLM은 minReplicaCount를 1 이상으로 둔다. scale-to-zero를 하면 첫 사용자가 수 분을 기다릴 수 있다. 스케일링 신호는 requests waiting과 KV cache usage를 중심으로 둔다. TTFT p95는 알림으로 본다. Karpenter는 L4나 L40S 같은 추론용 GPU 노드를 만들되, 업무 시간에는 Cron scaler로 warm pool을 유지한다.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: chat-vllm-scaler
  namespace: inference
spec:
  scaleTargetRef:
    name: chat-vllm
  minReplicaCount: 1
  maxReplicaCount: 10
  pollingInterval: 10
  cooldownPeriod: 600
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring.svc:9090
        metricName: chat_requests_waiting
        threshold: "3"
        query: sum(vllm:num_requests_waiting{model_name="chat"})
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring.svc:9090
        metricName: chat_cache_usage
        threshold: "0.85"
        query: avg(vllm:gpu_cache_usage_perc{model_name="chat"})

반면 문서 요약 워커는 완전 scale-to-zero가 가능하다. 사용자가 요약 요청을 보내면 API는 job_id를 즉시 반환한다. Redis나 SQS에 작업을 넣고, 워커가 나중에 처리한다. 큐가 비면 워커와 GPU 노드가 사라진다.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: summary-worker-scaler
  namespace: inference
spec:
  scaleTargetRef:
    name: summary-worker
  minReplicaCount: 0
  maxReplicaCount: 20
  pollingInterval: 15
  cooldownPeriod: 300
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring.svc:9090
        metricName: summary_queue_depth
        threshold: "10"
        query: sum(summary_queue_depth)

임베딩 워커는 처리량 중심이다. 개별 요청 지연보다 전체 문서 묶음을 빨리 처리하는 것이 중요하다. Kafka lag이나 queue depth가 좋은 신호다. 단, downstream 벡터 DB가 감당할 수 있는 write throughput을 넘지 않도록 maxReplicaCount를 제한해야 한다. 오토스케일링은 항상 자신만 보는 것이 아니라 뒤 시스템까지 봐야 한다. 워커를 100개로 늘렸는데 Pinecone, Weaviate, PostgreSQL, MongoDB가 병목이 되면 비용만 늘고 실패율이 올라간다.

이처럼 하나의 클러스터 안에서도 정책은 여러 개가 된다. 라우터는 CPU HPA, 실시간 모델 서버는 KEDA + Prometheus LLM metrics, 비동기 워커는 KEDA queue scaler, 노드는 Karpenter로 움직인다. 복잡해 보이지만 각 컴포넌트가 자기 역할에 맞는 신호를 쓰기 때문에 오히려 운영은 쉬워진다.

비용 폭주를 막는 상한선

오토스케일링은 비용 최적화 도구이지만, 잘못 설정하면 비용 폭주 도구가 된다. 특히 GPU에서는 maxReplicaCount 하나가 월 비용을 바꾼다. H100 Pod 20개가 자동으로 뜨는 설정은 멋져 보이지만, 장애 상황에서 큐가 비정상적으로 쌓이면 몇 시간 만에 예산을 태울 수 있다.

그래서 GPU 오토스케일링에는 반드시 상한선이 필요하다.

첫 번째 상한선은 KEDA의 maxReplicaCount다. 모델별로 최대로 허용할 replica 수를 정한다. 이 값은 "많을수록 좋다"가 아니라 예산, downstream 처리량, GPU quota를 고려한 비즈니스 결정이다.

두 번째 상한선은 namespace ResourceQuota다. 특정 팀이나 서비스가 사용할 수 있는 GPU 총량을 제한한다.

apiVersion: v1
kind: ResourceQuota
metadata:
  name: inference-gpu-quota
  namespace: inference
spec:
  hard:
    requests.nvidia.com/gpu: "16"
    limits.nvidia.com/gpu: "16"
    requests.cpu: "200"
    requests.memory: 1Ti

세 번째 상한선은 Karpenter NodePool limits다. NodePool 단위로 만들 수 있는 GPU 총량을 제한한다. 앞의 예시처럼 limits.nvidia.com/gpu: 64를 두면 해당 NodePool은 그 이상 확장하지 않는다.

네 번째 상한선은 클라우드 예산 알림과 quota다. 쿠버네티스 안에서 아무리 잘 막아도 클라우드 계정 수준의 예산 알림이 없으면 마음이 불편하다. GPU는 실수 비용이 크다. 하루짜리 실험이 주말 내내 켜져 있으면 작은 팀에는 바로 체감되는 금액이 된다.

마지막으로 admission control을 사용할 수 있다. 예를 들어 H100을 요청하는 Deployment는 특정 label이나 approval annotation이 있어야만 생성되게 할 수 있다. 운영 환경에서는 "누가 어떤 GPU를 몇 개까지 요청할 수 있는가"를 정책으로 만들 필요가 있다.

튜닝 절차: 감이 아니라 실험으로 기준값을 잡는다

오토스케일링 threshold는 책상 앞에서 정하면 틀리기 쉽다. "requests waiting 5면 적당하겠지"라고 생각했지만 실제로는 Pod 하나가 20개 요청을 잘 처리할 수도 있고, 2개만 넘어도 TTFT가 무너질 수도 있다. 모델, GPU, max model length, 평균 프롬프트 길이, 출력 토큰 수, batching 설정에 따라 모두 달라진다.

운영에 넣기 전 다음 순서로 잡아보자.

먼저 단일 replica의 성능 곡선을 측정한다. Locust, hey, k6 같은 도구로 동시 요청 수를 1, 2, 4, 8, 16처럼 늘린다. 각 단계에서 TTFT p50/p95, ITL p95, requests waiting, GPU cache usage, tokens/sec, OOM 여부를 기록한다. 목표 SLO를 처음 깨는 지점을 찾는다. 예를 들어 동시 요청 6개부터 TTFT p95가 2초를 넘는다면 Pod당 target running requests를 4 정도로 잡을 수 있다.

다음으로 queue depth threshold를 정한다. 비동기 워커는 "큐 길이 ÷ replica 수"가 처리 지연과 연결된다. 작업 하나가 평균 10초 걸리고 replica 하나가 동시에 1개를 처리한다면, Pod당 queue length 6은 이미 1분 대기를 뜻한다. 사용자가 30초 안에 결과를 기대한다면 threshold를 더 낮춰야 한다.

그다음 scale up 속도를 조정한다. 갑작스러운 트래픽에 Pod를 1개씩만 늘리면 늦다. 하지만 한 번에 20개를 늘리면 GPU 노드가 대량 생성되고 모델 저장소가 동시에 두들겨 맞는다. scaleUp policies로 1분에 몇 개까지 늘릴지 제한한다.

마지막으로 scale down을 보수적으로 잡는다. scale down은 비용을 줄이지만, 너무 빠르면 다시 cold start가 발생한다. LLM 추론에서는 cooldownPeriod 300초에서 시작하고, 대화형 서비스는 600초 이상도 고려한다. 노드 정리는 Pod 정리보다 더 늦게 일어나게 한다.

운영 중에는 다음 질문을 반복한다.

  • TTFT가 올라갈 때 requests waiting도 함께 올라가는가?

  • requests waiting은 낮은데 TTFT만 높다면 병목은 어디인가?

  • GPU cache usage가 높을 때 OOM이나 admission reject가 발생하는가?

  • scale out 후 새 Pod가 Ready가 되기까지 몇 초 또는 몇 분이 걸리는가?

  • scale down 후 5분 안에 다시 scale up하는 일이 반복되는가?

  • GPU node가 비어 있는데도 오랫동안 제거되지 않는가?

이 질문에 답할 수 있으면 threshold는 점점 안정된다. 오토스케일링은 한 번 설정하고 잊는 기능이 아니라, 서비스의 트래픽과 모델 특성에 맞춰 계속 다듬는 제어 루프다.

운영 체크리스트

AI 추론 오토스케일링을 프로덕션에 넣기 전에는 다음 항목을 확인하자.

  • Pod 계층과 노드 계층이 모두 자동 확장되는가? KEDA가 Pod를 늘려도 GPU 노드가 생기지 않으면 Pending에서 멈춘다.

  • scale-to-zero 대상이 정말 비동기 또는 지연 허용 워크로드인가? 실시간 채팅 API라면 최소 1개 이상의 warm replica를 유지하는 편이 안전하다.

  • readinessProbe가 모델 로딩과 warmup 완료 후에만 통과하는가? 프로세스가 떴다는 이유만으로 트래픽을 받으면 첫 요청이 실패한다.

  • 스케일링 메트릭이 사용자 경험과 연결되어 있는가? CPU 대신 requests waiting, running requests, KV cache usage, TTFT, ITL을 본다.

  • maxReplicaCount, ResourceQuota, NodePool limits로 비용 상한을 걸었는가? 오토스케일링에는 반드시 브레이크가 필요하다.

  • scale up은 충분히 빠르고 scale down은 충분히 느린가? LLM은 모델 로딩 비용이 크므로 축소를 서두르지 않는다.

  • Prometheus, KEDA, HPA, Karpenter 이벤트를 한 화면에서 볼 수 있는가? 문제가 생겼을 때 어느 계층이 멈췄는지 바로 알아야 한다.

이 장에서 다룬 내용은 단순히 "Pod를 자동으로 늘리는 방법"이 아니다. AI 시대의 오토스케일링은 비용과 지연 시간 사이의 조절 장치다. GPU는 비싸고, LLM은 상태ful하며, 사용자는 첫 토큰을 기다려주지 않는다. CPU HPA만으로는 이 세계를 설명할 수 없다.

좋은 스케일링 정책은 세 가지를 동시에 만족한다. 사용자가 기다리기 전에 늘어난다. 일이 없을 때는 과감히 줄어든다. 그리고 비용이 예산을 넘기 전에 멈춘다. 이 균형을 잡는 순간, 쿠버네티스는 단순한 컨테이너 오케스트레이터가 아니라 AI 인프라의 경제성을 제어하는 플랫폼이 된다.

다음 장에서는 이 오토스케일링 위에서 더 복잡한 존재가 움직인다. 단일 요청을 처리하고 끝나는 추론 서버가 아니라, 오래 실행되고 상태를 기억하며 외부 도구를 호출하는 AI 에이전트다. 에이전트 워크로드는 쿠버네티스에 또 다른 질문을 던진다. "Pod가 살아 있다"는 것만으로는 충분한가? 이제 상태, 세션, 도구 호출, 장기 실행을 함께 다뤄야 한다.

참고

1llm-d Workload Autoscaling

2KEDA Kubernetes Event-driven Autoscaling

3KEDA Scalers Catalog

4GKE에서 KEDA를 사용하는 Scale-to-zero

5gpu-autoscale-inference 예제

6Autoscale KAITO inference workloads on AKS using KEDA

7GKE TPU LLM 추론 워크로드 자동 확장 권장사항

8KServe Autoscale InferenceService with LLM metrics

9Autoscale GPU Workloads on AKS Using DCGM Metrics and KEDA

10NVIDIA Triton TensorRT-LLM Autoscaling and Load Balancing

11GPU-Aware Autoscaling on Kubernetes with Karpenter and KEDA

책 목록

AI 시대의 쿠버네티스: LLM 추론과 에이전트를 운영하는 인프라 전략

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기