4.5 Autoscale¶
CPU is a useless signal for a GPU server: the GPU can be saturated while the container's CPU sits at 20%. You scale a model server on work in flight — and the metrics for that are already in Prometheus. KEDA turns a PromQL query into an HPA.
The ScaledObject¶
# KEDA ScaledObject: scale the vLLM Deployment on requests in flight.
#
# Why "running + waiting" and not just the queue?
# vLLM admits up to --max-num-seqs requests into a batch before anything
# queues, so "waiting" is 0 until a replica is saturated, then spikes.
# In-flight requests per replica is a steadier signal. KV-cache usage is
# the other good option for memory-bound models.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: vllm
namespace: vllm
spec:
scaleTargetRef:
name: vllm
minReplicaCount: 1
maxReplicaCount: 2
pollingInterval: 15
cooldownPeriod: 60
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Pods
value: 1
periodSeconds: 30
scaleDown:
# 5 minutes: long enough that a replica which is still cold-starting
# when the load stops isn't killed before it ever served a request.
stabilizationWindowSeconds: 300
policies:
- type: Pods
value: 1
periodSeconds: 60
triggers:
- type: prometheus
metricType: AverageValue
metadata:
serverAddress: http://kube-prometheus-stack-prometheus.monitoring.svc:9090
query: |
sum(vllm:num_requests_running{model_name="qwen2.5-1.5b"})
+ sum(vllm:num_requests_waiting{model_name="qwen2.5-1.5b"})
# Target 12 in-flight requests per replica. 24 virtual users → 2 replicas.
threshold: "12"
query— running + waiting across all replicas of this model. Why not just the queue? Becausewaitingis 0 until a replica hits--max-num-seqs, then jumps; it's a saturation alarm, not a load signal. In-flight requests is smooth and proportional. (KV-cache usage is the other good choice for memory-bound models.)threshold: "12"withmetricType: AverageValue— target 12 in-flight per replica. HPA math:desired = ceil(24 / 12) = 2.minReplicaCount: 1,maxReplicaCount: 2— a hard cap. Between this and the NodePool'slimits.nvidia.com/gpu, nothing in this lab can run away.behavior— scale up immediately, one pod at a time; scale down only after 5 quiet minutes. A GPU replica costs minutes to start; you never want to flap.
READY True: KEDA reached Prometheus and the query returned. ACTIVE False: no load, nothing to do. Note that KEDA created and owns the HPA — never add a second autoscaler for the same Deployment. KEDA's admission webhook refuses a ScaledObject for a workload that already has an HPA, and two autoscalers on one Deployment would fight.
KEDA's Prometheus address
serverAddress is the in-cluster Service of the base stack's Prometheus: http://kube-prometheus-stack-prometheus.monitoring.svc:9090. If your ScaledObject shows READY False, kubectl -n vllm describe scaledobject vllm — nine times out of ten it's this URL or a query that returns no series (check the model_name label matches --served-model-name).
Put it under load¶
Set up your watches first — three panes, or one watch:
watch -n5 'kubectl -n vllm get hpa; echo; kubectl -n vllm get pods -o wide; echo; kubectl get nodeclaims'
(macOS without watch: run the three kubectl get … -w commands in three panes instead.)
Then start the 15-minute load job (long enough for a cold node to join and serve for a few minutes):
Here's the sequence, with typical times from the start of the load:
# 0:30 — HPA sees 24 in flight / 12 target → wants 2
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
keda-hpa-vllm Deployment/vllm 24/12 (avg) 1 2 2
# 0:45 — replica 2 is Pending: node A has vllm-1, node B has the notebook
NAME READY STATUS RESTARTS AGE NODE
vllm-7b9d6c4f8-tq2xm 1/1 Running 0 21m ip-10-0-23-77.ec2.internal
vllm-7b9d6c4f8-zr8hk 0/1 Pending 0 15s <none>
# 0:50 — Karpenter launches node C
NAME TYPE CAPACITY ZONE NODE READY AGE
gpu-lk7m2 g6.xlarge spot us-east-1b ip-10-0-23-77.ec2.internal True 78m
gpu-9wq4d g5.xlarge spot us-east-1a ip-10-0-5-140.ec2.internal True 41m
gpu-c3ptx g6.xlarge on-demand us-east-1c Unknown 4s
# ~3:30 — node C initialised; replica 2 goes ContainerCreating (image pull, no warm cache this time)
# ~7:00 — image pulled; vLLM starts, downloads weights
# ~9:00 — replica 2 Ready; HPA target drops to 12/12
Every step of that chain is something you built: the PodMonitor feeds Prometheus, Prometheus feeds KEDA, KEDA drives the HPA, the HPA raises replicas, the Pending pod wakes Karpenter, the NodePool tells it what to buy, the EC2NodeClass what to boot, the Operator's DaemonSets make the GPU appear, and the startupProbe keeps kubelet patient while vLLM loads.
While replica 2 is starting, go to 4.6 and time it. Come back here when it's Ready.
When replica 2 is serving¶
Confirm both replicas take traffic:
curl -s 'localhost:9090/api/v1/query?query=vllm:num_requests_running' | jq -r '.data.result[] | "\(.metric.pod) running=\(.value[1])"'
24 in flight split across two replicas, nothing waiting. When the k6 job finishes, its summary is the blend of ~9 minutes on one replica and ~6 on two:
The blended p95 is worse than two-replica steady state because the first nine minutes were one replica. That's the honest cost of a cold scale-out, and the whole point of the next page. (Re-run make baseline now with both replicas up if you want the clean two-replica number.)
And back down¶
Five minutes after the load stops, the HPA's scale-down window expires:
Replica 2 is gone. Node C is now empty — and with consolidateAfter: 2h it will sit there for two hours costing money. In Module 5 you fix that live.
Watch it in Grafana instead
make grafana port-forwards Grafana to http://localhost:3000. Open Dashboards → GPU Workshop: vLLM serving. Module 5 does the full tour; you can peek now.