A vLLM pod at full load can show 15% CPU. The GPU is the bottleneck, and GPU utilisation is a poor scaling signal too (it is "busy" with one request or with fifty). Scale on what users feel: requests waiting and cache pressure.
Signals that work
| Signal | Source | Why |
|---|---|---|
| Requests waiting | vLLM num_requests_waiting (TGI has a queue size metric) | Direct measure of backlog |
| KV cache usage % | vLLM cache usage gauge | Near 100% means preemption soon |
| Requests running per replica | vLLM num_requests_running | Concurrency per pod |
| TTFT p95 | Histogram from server or gateway | SLO-based scaling |
Metric names change between vLLM versions; check /metrics on your build:
kubectl -n llm port-forward deploy/llama-8b 8000:8000 &
curl -s localhost:8000/metrics | grep -E "^vllm:(num_requests|gpu_cache|kv_cache)"
Scrape the metrics
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata: { name: vllm, namespace: llm }
spec:
selector: { matchLabels: { app: llama-8b } }
podMetricsEndpoints: [{ port: http, path: /metrics, interval: 15s }]
KEDA ScaledObject
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata: { name: llama-8b, namespace: llm }
spec:
scaleTargetRef: { name: llama-8b }
minReplicaCount: 2
maxReplicaCount: 12
pollingInterval: 15
cooldownPeriod: 600
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleUp: { stabilizationWindowSeconds: 0, policies: [{ type: Pods, value: 2, periodSeconds: 60 }] }
scaleDown: { stabilizationWindowSeconds: 900, policies: [{ type: Pods, value: 1, periodSeconds: 300 }] }
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus-operated.monitoring:9090
query: sum(vllm:num_requests_waiting{namespace="llm", app="llama-8b"})
threshold: "4"
- type: prometheus
metadata:
serverAddress: http://prometheus-operated.monitoring:9090
query: avg(vllm:gpu_cache_usage_perc{namespace="llm", app="llama-8b"}) * 100
threshold: "80"
- With
threshold: "4"on waiting requests, KEDA targets about 4 waiting requests per replica. - Scale up quickly, scale down slowly: model pods are expensive to start, and traffic is bursty.
- The cache trigger catches long-context load that does not queue yet but is about to preempt.
Warning
Scaling to zero sounds attractive for GPU cost, but the first request then waits for node provisioning, image pull and weight loading, often several minutes. Use
minReplicaCountof at least 1 (2 for HA) for user-facing models, and scale-to-zero only for batch or internal endpoints with relaxed latency.
GPU nodes must follow
Pending pods trigger node scale-up. Make it fast and predictable:
- Dedicated GPU node pool with taints (
nvidia.com/gpu=present:NoSchedule) and the model pods tolerating them. - Cluster autoscaler or Karpenter with GPU instance types allowed and quota available in the region.
- Pod resource request
nvidia.com/gpu: 1(or more for tensor parallel).
Karpenter NodePool sketch (EKS):
apiVersion: karpenter.sh/v1
kind: NodePool
metadata: { name: gpu-inference }
spec:
template:
spec:
requirements:
- { key: karpenter.k8s.aws/instance-family, operator: In, values: ["g6e", "g6", "p5"] }
- { key: karpenter.sh/capacity-type, operator: In, values: ["on-demand"] }
taints: [{ key: nvidia.com/gpu, value: "present", effect: NoSchedule }]
nodeClassRef: { group: karpenter.k8s.aws, kind: EC2NodeClass, name: gpu }
limits: { "nvidia.com/gpu": 32 }
disruption: { consolidationPolicy: WhenEmpty, consolidateAfter: 30m }
WhenEmpty avoids Karpenter evicting busy model pods to consolidate.
Shrink cold start
| Step | Typical time | Speed it up with |
|---|---|---|
| Node provisioning | 1-5 min | Warm spare node (overprovisioning pod with low priority) |
| Image pull (10+ GB) | 1-5 min | Pre-pulled images on node image, image streaming/lazy loading, smaller images |
| Weight download | Minutes | Weights on a node-local cache or fast shared volume, not pulled from the internet |
| Weight load to GPU | 30 s - minutes | Safetensors, faster storage, tensorizer/run:ai streamer where supported |
| Engine warm-up / CUDA graphs | Seconds - a minute | Accept, or reduce captured sizes |
Overprovisioning with a placeholder:
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata: { name: overprovisioning }
value: -10
globalDefault: false
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: gpu-placeholder, namespace: llm }
spec:
replicas: 1
selector: { matchLabels: { app: gpu-placeholder } }
template:
metadata: { labels: { app: gpu-placeholder } }
spec:
priorityClassName: overprovisioning
tolerations: [{ key: nvidia.com/gpu, operator: Exists, effect: NoSchedule }]
containers:
- name: pause
image: registry.k8s.io/pause:3.9
resources: { limits: { nvidia.com/gpu: 1 } }
The placeholder holds one GPU node warm; a real model pod preempts it instantly, and the autoscaler replaces the spare.
Readiness and traffic
- Readiness probe must pass only after weights are loaded (
/healthon vLLM returns OK when ready). Otherwise the load balancer sends traffic to a pod still loading. - Set a generous
startupProbeso slow loads are not killed. - Graceful shutdown:
terminationGracePeriodSecondslong enough to finish in-flight generations.
startupProbe: { httpGet: { path: /health, port: 8000 }, periodSeconds: 10, failureThreshold: 60 }
readinessProbe: { httpGet: { path: /health, port: 8000 }, periodSeconds: 5 }
Test it
Replay a traffic ramp (2x and 5x normal) and record: time from queue growth to new replica Ready, TTFT p95 during the ramp, and whether scale-down causes errors. Tune thresholds and windows from the data.
Prevent it next time
- Alert when
maxReplicaCountis reached or GPU quota blocks node provisioning. - Keep warm capacity sized to the largest burst you can tolerate during cold start.
- Re-check metric names after model server upgrades; a renamed metric silently breaks scaling.