Kubernetes Pod Autoscaling: HPA, VPA, and KEDA — When Each One Actually Helps
HPA, VPA, and KEDA solve three different scaling problems, and running the wrong one is why 'autoscaling doesn't work' tickets keep coming back. A field guide to picking the right one — and why HPA and VPA fight each other when misconfigured together.
Every "our autoscaling isn't working" incident I've debugged traces back to the same root cause: someone picked an autoscaler based on its name instead of the problem it actually solves. Kubernetes ships three of them, and they scale three completely different things.
HPA scales replica count, not resource size
The Horizontal Pod Autoscaler watches a metric — CPU, memory, or a custom metric from an adapter like Prometheus — and adds or removes pods to keep that metric near a target. It never changes how much CPU or memory a single pod requests.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api
minReplicas: 3
maxReplicas: 30
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 65HPA is the right tool when load is roughly uniform across replicas and horizontal scaling is cheap — stateless HTTP APIs, workers pulling from a shared queue, most web backends.
VPA scales resource requests, not replica count
The Vertical Pod Autoscaler watches actual usage over time and adjusts a pod's CPU/memory requests and limits — it resizes the container, it doesn't add more of them. In its default "Auto" mode it evicts and recreates pods to apply a new size, which is disruptive for anything that isn't tolerant of restarts.
The pitfall almost nobody reads the docs closely enough to catch: if you point both HPA and VPA at CPU on the same workload, they fight. VPA shrinks the per-pod CPU request because average utilization looks low; HPA sees "more room per pod" and scales replicas down; now each remaining pod is hotter, VPA raises the request back up, and the two controllers oscillate against each other indefinitely. The fix is to run VPA in updateMode: "Off" (recommendation-only, surfaced via kubectl describe vpa) for anything HPA also manages, and only let VPA actively resize workloads that don't have an HPA — batch jobs, singleton stateful services, cron workloads.
KEDA scales from event sources HPA can't see, and scales to zero
HPA's metric sources are CPU/memory or whatever your metrics adapter exposes — it has no native concept of "Kafka consumer lag" or "SQS queue depth." KEDA plugs in as a metrics adapter for HPA and adds ~60 built-in scalers for exactly these event-driven signals, plus one HPA can never do on its own: scaling a deployment to zero replicas when there's no work queued.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: worker-scaler
spec:
scaleTargetRef:
name: order-worker
minReplicaCount: 0
maxReplicaCount: 50
triggers:
- type: kafka
metadata:
bootstrapServers: kafka:9092
consumerGroup: order-worker-group
topic: orders
lagThreshold: "50"This is the right shape for background workers, event consumers, and anything with genuinely spiky, queue-driven load — CPU utilization is a lagging, noisy proxy for "is there work to do" compared to reading the queue depth directly.
The decision, compressed
- Stateless service, roughly steady traffic, scaling is about handling more concurrent requests → HPA.
- A workload with no HPA that just needs "right-sized" requests over time so it stops getting OOMKilled or wasting a full CPU core it never uses → VPA (Auto mode).
- A workload that already has an HPA and you want VPA's sizing advice without the fight → VPA in Off mode, read the recommendation, apply it manually.
- Load is driven by a queue, stream, or external metric, and idle periods should cost you nothing → KEDA.
Most production clusters need all three, on different workloads, at the same time. The failure isn't picking the wrong one in isolation — it's pointing two of them at the same signal and expecting them to agree.