Autoscaling de inferencia LLM en Kubernetes: HPA con custom metrics y KEDA para vLLM

Este post complementa los de Observabilidad GPU para inferencia LLM (de donde vienen las métricas que alimentan al HPA), Capacity planning (qué techo y qué head-room presupone el autoscaler) y Continuous batching (lo que explica por qué num_requests_waiting es la métrica primaria).

TL;DR

El autoscaling clásico de Kubernetes —HPA sobre cpu o memoryno sirve para inferencia LLM. Razón: el pod vLLM consume poco CPU (el trabajo lo hace la GPU) y la memoria RSS del proceso es plana; ambas métricas pueden quedarse al 30 % mientras la GPU está saturada y la cola de requests crece sin freno. Las cuatro señales viables que sí responden a la carga real son: vllm:num_requests_waiting (la cola, la métrica primaria), vllm:gpu_cache_usage_perc (presión sobre el KV cache pool), TTFT P95 vía histogram de vllm:time_to_first_token_seconds_bucket (la garantía del SLO) y el batch fill ratio num_requests_running / max_num_seqs (utilización del techo de concurrencia). Para que un HPA pueda consumir métricas Prometheus hace falta un adaptador; en mayo 2026 hay dos opciones maduras: prometheus-adapter (sigma de cluster, configuración estática, output external.metrics.k8s.io) y KEDA (ScaledObject con trigger Prometheus, polling configurable, escalado a cero opcional, integración con cron). KEDA es la opción dominante para LLM en cluster genérico porque resuelve el patrón “warm pool + cron + métrica del motor” en un solo CRD. El reto operacional dominante no es la lógica de escalado sino el cold start: un pod vLLM con Llama 70B BF16 (140 GB) tarda entre 90 segundos (modelo precacheado en PV local) y 6 minutos (image pull + descarga del modelo desde object store) hasta servir el primer token. Las cinco palancas que lo recortan son imagen pre-pulled vía DaemonSet, modelo cacheado en PV o tmpfs regional, warm pool con minReplicaCount > 0, predictive scaling vía KEDA cron cuando el patrón de tráfico es predecible (oficinas 9–18 h), y descarga paralela del modelo. Los tres pitfalls específicos del scale-down LLM: cortar conexiones SSE de streaming a media respuesta (drain elegante con terminationGracePeriodSeconds ≥ 60 s), oscilación de scale-out/in por stabilization window mal calibrada, y olvidar que el HPA solo escala pods — los nodos GPU se escalan con cluster-autoscaler sobre nodepools etiquetados. Este post incluye los manifests YAML mínimos.

Estás aquí: DEPLOY

Estás aquí: DEPLOY · autoscaling sobre las métricas que mide OBSERVE1 · Data2 · Tune3 · Eval4 · Deploy5 · Observe6 · Retrain

La analogía: la panadería con hornos de leña

Una panadería artesanal tiene tres hornos de leña. Cada horno tarda 25 minutos en alcanzar temperatura desde frío. Una vez caliente, hornea pan continuamente con una tirada de 18 minutos por hornada. La encargada quiere maximizar pan vendido por día sin gastar leña inútil, y sabe tres cosas: que hay un pico de demanda a las 7:30 cada mañana, que los lunes no se vende casi nada, y que cuando se acaba el pan en mostrador los clientes se van al supermercado de al lado.

La estrategia barata —encender hornos cuando hay cola en la tienda— no funciona. Para cuando la cola crece y la encargada enciende el segundo horno, ese horno no estará listo hasta 25 minutos después; los clientes de esa ventana se perdieron. La señal “cola en mostrador” llega tarde.

La estrategia inteligente: encender el segundo horno a las 6:55, antes del pico previsible de las 7:30, y dejarlo activo hasta las 10:00 aunque la cola baje a las 8:15. Mantener el tercer horno apagado entre lunes y miércoles porque la demanda no llega; encenderlo proactivamente los jueves a las 12:00 porque históricamente sube. Tener una bolsa de masa cruda pre-fermentada en cámara para que cuando el horno esté listo, el pan entre en 30 segundos y no haya que esperar dos horas de fermentación.

El autoscaling de un cluster de inferencia LLM funciona igual:

  • Encender hornos en frío = scale-out reactivo cuando la cola crece (lento, pierde clientes).
  • Cron proactivo = predictive scaling cuando el patrón es conocido (horario laboral, picos previstos).
  • Masa pre-fermentada = warm pool de réplicas con modelo cargado pero a 0 carga.
  • Apagar hornos sin pan en curso = scale-down respetando las streamings activas (no se cierra el horno con pan dentro).

La métrica clave —“cuántos clientes hay en cola”— se llama num_requests_waiting. La métrica que dice “el horno se va a quedar sin masa para nuevos panes” se llama gpu_cache_usage_perc. Y la métrica de calidad de servicio —“cuánto tarda el primer pan en salir cuando un cliente nuevo entra”— se llama TTFT.

Por qué HPA sobre CPU no sirve

El HPA clásico de Kubernetes mira resource.cpu del pod. Para un servicio HTTP convencional —Node.js, una API REST— la CPU se mueve linealmente con el tráfico y el HPA escala con razonable acierto. Para un pod vLLM o SGLang sobre GPU, la CPU del pod típicamente vive entre 5 % y 15 % independientemente de si la GPU está al 30 % o al 99 % de carga: el trabajo real lo hace el dispositivo, no el proceso. Resultado: el HPA basado en CPU nunca dispara scale-out aunque la GPU esté reventando, y los clientes acumulan en la cola hasta que TTFT P95 cruza el SLO. El operador descubre el problema por la alerta de TTFT, no por el HPA.

memory tampoco sirve: la RSS del proceso vLLM es plana después del arranque (modelo + buffers cargados de una vez); no refleja la presión real sobre la GPU. Lo único que crece y baja con la carga útil de inferencia son métricas que el motor publica explícitamente: cola de requests, KV cache pool, latencias del SLO. Sin un adaptador que las haga visibles al HPA, el autoscaling es ciego.

Las cuatro señales viables

Las cuatro métricas que alimentan el HPA LLM1 · COLA (PRIMARIA)vllm:num_requests_waiting¿Hay requests esperando entrar al batch?Reacciona al instante. Robusta a cambios de modelo.Umbral típico HPA: target = 5 (scale-out si > 5 sostenido).2 · KV CACHE POOLvllm:gpu_cache_usage_perc¿Cuánta VRAM de KV cache se usa?Predictiva: avisa antes de que la cola empiece.Umbral típico: target = 0.85 (scale-out si > 0.85).3 · TTFT P95 (SLO)histogram_quantile(0.95,rate(vllm:time_to_first_token_seconds_bucket[5m]))La garantía contractual al cliente.Backup de las dos anteriores; reacciona tarde pero defiende SLO.4 · BATCH FILL RATIOvllm:num_requests_running/ max_num_seqs (config)Utilización del techo de concurrencia del motor.Útil para scale-down: si ratio < 0.4 sostenido, sobra réplica.Política recomendada: cola como primaria, KV cache como secundaria, TTFT como guardrail

Señal 1 — vllm:num_requests_waiting (cola). Es la métrica más directa: cuántas requests esperan entrar al batch. Reacciona en el instante en que la concurrencia objetivo se satura. Es robusta frente a cambios de modelo (el número de requests es el mismo concepto sea Llama 7B o 70B). Es la métrica primaria del HPA LLM. Umbral típico: target = 5 requests waiting de media; si la cola crece por encima de 5 sostenido durante 2 minutos, scale-out.

Señal 2 — vllm:gpu_cache_usage_perc (KV pool). Se mueve antes que la cola: el KV pool se va llenando mientras los slots del batch aún están libres, hasta que el motor empieza a rechazar nuevas requests por OOM-prevention y se forma la cola. Por tanto es predictiva: dispara scale-out antes de que el cliente note degradación. Umbral típico: target = 0.85 (85 % de pool usado).

Señal 3 — TTFT P95. La garantía contractual. Si TTFT P95 sale del SLO, scale-out aunque cola y KV pool parezcan razonables (puede haber un pico de prompts largos). Es reactiva —sale del SLO antes de que tu HPA reaccione— pero sirve de guardrail final.

Señal 4 — batch fill ratio. El cociente num_requests_running / max_num_seqs (este último es config del motor, no métrica). Útil para scale-down: si el ratio queda por debajo de 0.4 durante 10 minutos, sobra capacidad y se puede reducir réplicas con seguridad.

La política recomendada combina las cuatro: la cola y el KV pool disparan scale-out (lo que llegue antes), TTFT lo confirma como guardrail, y el batch fill ratio gestiona scale-down. Implementarlo en un único HPA exige métricas externas; KEDA hace esto manejable.

El cableado: KEDA como adaptador Prometheus

KEDA introduce dos CRDs principales: TriggerAuthentication (cómo autenticarse contra la fuente) y ScaledObject (qué deployment escalar con qué triggers). Para un deployment vLLM con Prometheus como fuente:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: vllm-llama70b-scaler
  namespace: inference
spec:
  scaleTargetRef:
    name: vllm-llama70b
  minReplicaCount: 2          # warm pool
  maxReplicaCount: 20
  pollingInterval: 15
  cooldownPeriod: 300         # 5 min antes de scale-down
  advanced:
    horizontalPodAutoscalerConfig:
      behavior:
        scaleDown:
          stabilizationWindowSeconds: 600   # ventana grande para evitar oscilación
          policies:
            - type: Pods
              value: 1
              periodSeconds: 120
        scaleUp:
          stabilizationWindowSeconds: 30
          policies:
            - type: Pods
              value: 2
              periodSeconds: 60
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.observability.svc:9090
        metricName: vllm_queue_depth
        threshold: "5"
        query: |
          avg(vllm:num_requests_waiting{deployment="vllm-llama70b"})          
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.observability.svc:9090
        metricName: vllm_kv_cache
        threshold: "0.85"
        query: |
          avg(vllm:gpu_cache_usage_perc{deployment="vllm-llama70b"})          
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.observability.svc:9090
        metricName: vllm_ttft_p95
        threshold: "1.5"
        query: |
          histogram_quantile(0.95,
            sum by(le)(rate(vllm:time_to_first_token_seconds_bucket{deployment="vllm-llama70b"}[5m])))          

Tres detalles operativos no obvios:

minReplicaCount: 2. Es el warm pool. Mantener al menos dos réplicas garantiza disponibilidad ante pérdida de un nodo y absorbe spikes sin esperar al cold start del primer escalado. Bajarlo a 0 ahorra GPU en off-peak pero introduce 90 s–6 min de latencia al primer cliente nuevo.

stabilizationWindowSeconds: 600 en scale-down. Diez minutos. Los modelos no son nginx: si una réplica se cierra prematuramente y a los dos minutos hay otro pico, el cold start de un nuevo pod tarda lo que el cliente espera. Mejor mantener réplicas extra el doble de lo que mantendrías para un servicio web normal.

scaleUp: stabilizationWindowSeconds: 30. Treinta segundos. El scale-out tiene que ser rápido — el cold start del nuevo pod añade su propio retraso, y si encima el HPA espera otros minutos antes de disparar, el SLO ya está roto.

El gran problema operativo: cold start

Un pod vLLM cargando Llama 70B pasa por estas fases antes de servir el primer token:

FaseTiempo típicoAcelerable con
Image pull (4–6 GB)30–90 sDaemonSet pre-pull
Descarga del modelo (140 GB BF16)60–300 sPV regional cacheado, S3 + multi-thread
Carga del modelo a HBM30–90 stmpfs o NVMe local
Capture de CUDA graphs20–60 s--enforce-eager (más lento en runtime pero arranque rápido)
Warmup de PagedAttention5–15 s
Health check ready10–30 stuning de probe

Total sin optimización: 4–10 minutos. Una réplica nueva tarda eso en absorber tráfico. Con todas las palancas combinadas: 45–90 segundos. La diferencia entre los dos números es el principal trabajo de plataforma para autoscaling LLM.

Las cinco palancas

Palanca 1 — imagen pre-pulled. Un DaemonSet trivial corre ctr image pull (o crictl pull) sobre los nodos GPU en cuanto se incorporan al cluster. La imagen del motor de inferencia queda en disco; los nuevos pods saltan los 30–90 s de pull. Coste: ~6 GB de disco por nodo.

apiVersion: apps/v1
kind: DaemonSet
metadata: { name: vllm-image-warmer }
spec:
  selector: { matchLabels: { app: vllm-warmer } }
  template:
    metadata: { labels: { app: vllm-warmer } }
    spec:
      nodeSelector: { workload: gpu }
      initContainers:
        - name: pull
          image: vllm/vllm-openai:v0.10.0
          command: ["/bin/true"]
      containers:
        - name: pause
          image: registry.k8s.io/pause:3.10

Palanca 2 — modelo en PV regional. El download del modelo (140 GB BF16 o 35 GB FP8) desde object storage central es el componente dominante del cold start. Cachear el modelo en un PV de zona/rack —Rook-Ceph RBD, o NVMe local provisionado por el operador— recorta 60–300 s a 5–15 s. El antipatrón: descargar el modelo en cada arranque desde S3 externo.

volumeMounts:
  - name: model-cache
    mountPath: /models
    readOnly: true
volumes:
  - name: model-cache
    persistentVolumeClaim:
      claimName: llama70b-fp8-pvc        # RWX shared, llenado offline

Palanca 3 — warm pool. minReplicaCount > 0 mantiene réplicas pre-cargadas en idle. El coste es GPU ociosa; el beneficio es 0 s de cold start para el primer cliente de un pico. Para clusters productivos con tráfico continuo: warm pool de 2–3 réplicas. Para clusters batch nocturnos con tráfico 0: warm pool 0 y aceptar el cold start, o KEDA con cron que pre-encienda 10 minutos antes.

Palanca 4 — predictive scaling con cron. Cuando el patrón es predecible (oficinas 9–18 h):

triggers:
  - type: cron
    metadata:
      timezone: Europe/Madrid
      start: "30 8 * * 1-5"      # 8:30 lunes–viernes
      end:   "0 19 * * 1-5"      # 19:00
      desiredReplicas: "6"

Combinado con triggers reactivos. El HPA escala según el máximo de las señales: si la cron pide 6 y la cola pide 10, el resultado es 10.

Palanca 5 — descarga paralela y formato eficiente. Para PVs no pre-cargados, herramientas como nvidia-modelmanager, s5cmd o aria2c paralelizan la descarga del modelo. Pasar de descarga serial (~150 MB/s) a paralela 8 threads (~1.2 GB/s) divide entre 8 el tiempo. Y formatos como safetensors se cargan en HBM más rápido que PyTorch pickle original.

Cuándo escalar nodos, no solo pods

El HPA escala pods. Si el cluster no tiene nodos GPU libres, el nuevo pod se queda en Pending por falta de recursos. Para escalar nodos, hace falta cluster-autoscaler con un nodepool GPU específico, etiquetado:

# nodepool config (Karpenter o cluster-autoscaler equivalent)
labels:
  workload: gpu
  gpu-model: h100-sxm-80gb
taints:
  - key: nvidia.com/gpu
    effect: NoSchedule
limits:
  min: 2 nodes
  max: 8 nodes

Sin esto, el HPA puede pedir 10 réplicas pero el cluster solo entrega las que caben en nodos ya levantados. El cold start de un nodo nuevo (provisioning bare metal o cloud, PXE, OS boot, drivers NVIDIA, join del cluster) es mucho mayor que el cold start de un pod: típicamente 5–15 minutos en bare metal preconfigurado, 30–60 minutos en provisioning real. Para clusters on-premise, el nodepool debe estar siempre dimensionado al máximo previsto, y el “scaling” es solo del lado de pods. El concepto de scale-out reactivo de nodos solo aplica a clouds; en on-premise hay que comprar para el pico.

Tres pitfalls específicos del scale-down LLM

Pitfall 1 — cortar conexiones SSE de streaming. Cuando una réplica entra en Terminating, Kubernetes envía SIGTERM al pod y, por defecto, lo mata 30 segundos después. Para vLLM eso significa cortar conexiones SSE de streaming a la mitad de la respuesta. El cliente recibe un error 502 con el output parcial perdido. Solución: terminationGracePeriodSeconds: 120 + un preStop hook que avise al motor de no aceptar nuevas requests pero terminar las en curso:

spec:
  terminationGracePeriodSeconds: 120
  containers:
    - name: vllm
      lifecycle:
        preStop:
          httpGet:
            path: /shutdown
            port: 8000

Esto requiere que el motor exponga un endpoint de shutdown elegante; vLLM v1 lo soporta vía --enable-graceful-shutdown. Sin esto, el scale-down rompe SLO aunque las métricas no lo capturen (las requests cortadas no entran al histograma de TTFT).

Pitfall 2 — oscilación scale-up/scale-down. Si la stabilizationWindowSeconds del scale-down es corta (~60 s default), la siguiente bajada de cola dispara scale-down, y dos minutos después el siguiente pico dispara scale-up. El sistema oscila, paga cold starts repetidos, y nunca alcanza un régimen estable. Solución: scale-down con ventana de 10 minutos como mínimo y políticas conservadoras (type: Pods, value: 1, periodSeconds: 120 — máximo una réplica menos cada 2 minutos).

Pitfall 3 — vllm:num_requests_waiting con avg cuando hay rebalanceo. Si dos réplicas están desbalanceadas (una con cola 20, otra con cola 0), avg da 10 — el HPA dispara scale-out cuando lo correcto sería rebalancear vía el load balancer. Para detectarlo: añadir una alerta sobre stddev(vllm:num_requests_waiting) por deployment. Si la dispersión es alta, el problema no es de capacidad sino de routing.

Manifest completo de ejemplo

Para un deployment vLLM con Llama 70B FP8 en 4×H100 SXM por réplica, KEDA con warm pool 2:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-llama70b
  namespace: inference
spec:
  replicas: 2                                 # gestionado por KEDA después
  selector: { matchLabels: { app: vllm-llama70b } }
  template:
    metadata:
      labels: { app: vllm-llama70b, deployment: vllm-llama70b }
    spec:
      terminationGracePeriodSeconds: 120
      nodeSelector: { workload: gpu, gpu-model: h100-sxm-80gb }
      tolerations:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule
      containers:
        - name: vllm
          image: vllm/vllm-openai:v0.10.0
          args:
            - --model=/models/llama-3.3-70b-fp8
            - --tensor-parallel-size=4
            - --max-num-seqs=64
            - --enable-prefix-caching
            - --enable-graceful-shutdown
          ports:
            - { name: http, containerPort: 8000 }
            - { name: metrics, containerPort: 8000 }
          resources:
            limits:
              nvidia.com/gpu: "4"
              memory: 200Gi
          readinessProbe:
            httpGet: { path: /health, port: 8000 }
            initialDelaySeconds: 60
            periodSeconds: 10
            failureThreshold: 30                # tolera el warmup
          lifecycle:
            preStop:
              httpGet: { path: /shutdown, port: 8000 }
          volumeMounts:
            - { name: model-cache, mountPath: /models, readOnly: true }
            - { name: dshm, mountPath: /dev/shm }
      volumes:
        - name: model-cache
          persistentVolumeClaim: { claimName: llama70b-fp8-pvc }
        - name: dshm
          emptyDir: { medium: Memory, sizeLimit: 16Gi }
---
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata: { name: vllm-llama70b-metrics, namespace: inference }
spec:
  selector: { matchLabels: { app: vllm-llama70b } }
  podMetricsEndpoints:
    - port: metrics
      path: /metrics
      interval: 15s
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata: { name: vllm-llama70b-scaler, namespace: inference }
spec:
  scaleTargetRef: { name: vllm-llama70b }
  minReplicaCount: 2
  maxReplicaCount: 20
  pollingInterval: 15
  cooldownPeriod: 300
  advanced:
    horizontalPodAutoscalerConfig:
      behavior:
        scaleDown:
          stabilizationWindowSeconds: 600
          policies:
            - { type: Pods, value: 1, periodSeconds: 120 }
        scaleUp:
          stabilizationWindowSeconds: 30
          policies:
            - { type: Pods, value: 2, periodSeconds: 60 }
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.observability.svc:9090
        metricName: vllm_queue
        threshold: "5"
        query: avg(vllm:num_requests_waiting{deployment="vllm-llama70b"})
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.observability.svc:9090
        metricName: vllm_kv
        threshold: "0.85"
        query: avg(vllm:gpu_cache_usage_perc{deployment="vllm-llama70b"})
    - type: cron
      metadata:
        timezone: Europe/Madrid
        start: "30 8 * * 1-5"
        end:   "0 19 * * 1-5"
        desiredReplicas: "6"

Este conjunto es el mínimo viable para autoscaling LLM en cluster genérico con NVIDIA GPU Operator. Cada equipo lo adapta a su SLO concreto.

Aplicado a hardware on-premise típico

Para un cluster genérico de 4×H100 SXM 80 GB por nodo, 4 nodos GPU:

  • Cada nodo aloja una réplica vLLM TP=4 con Llama 70B FP8 (un modelo por nodo, no se comparten).
  • Warm pool de 2 réplicas en off-peak; KEDA cron eleva a 4 en horario laboral.
  • Cluster-autoscaler no aplica (4 nodos físicos comprados; el escalado es solo de pods). El número de réplicas concurrentes es como máximo el número de nodos disponibles (si cada réplica usa los 4 GPUs del nodo entero).
  • Si el dimensionamiento requiere más réplicas simultáneas que nodos, hay dos vías: (a) bajar el TP de cada réplica para que entren dos por nodo, (b) ampliar el nodepool físico. La decisión la dicta el capacity planning —ver Capacity planning para inferencia LLM on-premise—.

Volumen de eventos KEDA: ~5 evaluations/min por ScaledObject. Para 10 modelos servidos en paralelo, 3 000 evaluations/h. Manejable con un KEDA operator por cluster.

Lo que no hemos cubierto (próximos artículos)

  • Cluster-autoscaler para nodos GPU on-premise: cómo orquestar provisioning bare metal (Tinkerbell, Metal³) en función de demanda.
  • Multi-cluster autoscaling: escalar entre clusters de DCs distintos para resiliencia geográfica.
  • Cost-aware autoscaling: priorizar nodos según coste energético horario (en clusters con tarifa indexada).
  • Predictive ML-based scaling: en lugar de cron estático, entrenar un modelo que prediga demanda con 30 minutos de antelación.
  • Quotas y fairness multi-tenant: KEDA con namespace quotas para que un tenant no acapare el HPA.

Ver también

Referencias

  • KEDA project — keda.sh (documentación oficial de triggers Prometheus y cron).
  • Kubernetes — Horizontal Pod Autoscaler walkthrough (kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale).
  • NVIDIA — GPU Operator on Kubernetes (Helm chart oficial con DaemonSet de drivers y DCGM).
  • vLLM project — production_monitoring/ (métricas Prometheus expuestas por el servidor).
  • Karpenter — NodePool spec (etiquetado y taints para nodepools GPU).
  • Cluster Autoscaler — Scaling GPU nodes (caveats de descubrimiento de recursos GPU).
  • Kubernetes — Pod lifecycle and termination (preStop, terminationGracePeriodSeconds).