CPU is the wrong signal for most workloads
Kubernetes' built-in Horizontal Pod Autoscaler (HPA) scales on resource metrics — usually CPU and memory. That's fine for CPU-bound services, but it's a poor proxy for a huge class of real workloads:
- A worker draining a queue should scale on queue depth, not CPU.
- An API should often scale on requests per second or concurrency, not CPU.
- A consumer should scale on Kafka consumer lag.
- A cron-ish job triggered by events should scale from zero when work arrives.
HPA can't natively read those signals, and crucially, HPA cannot scale a Deployment to zero replicas. KEDA — Kubernetes Event-Driven Autoscaling — was built to close both gaps.
What KEDA is and how it fits
KEDA is a CNCF graduated project. It does not replace HPA — it *drives* it. You install KEDA, and it adds two custom resources: ScaledObject (for Deployments/StatefulSets) and ScaledJob (for Jobs). KEDA watches external event sources and translates them into something the HPA can act on.
Architecturally, KEDA has two pieces:
- The operator/controller, which reconciles your
ScaledObjectand manages a backing HPA. It also handles the special 0↔1 transition that HPA can't do. - The metrics adapter, which implements the Kubernetes external metrics API so the HPA can read scaler values (queue length, lag, etc.) as if they were normal metrics.
The key insight: from 1→N replicas, KEDA leans on the standard HPA machinery. The unique trick is activation — going from 0→1 when an event source crosses a threshold, and back to 0 when it goes idle.
Scalers: the long tail of triggers
KEDA ships 60+ built-in scalers. A scaler knows how to query a specific system and report a metric. A sampling:
| Category | Example scalers |
|---|---|
| Queues | RabbitMQ, AWS SQS, Azure Service Bus, NATS |
| Streaming | Kafka, AWS Kinesis, Azure Event Hubs |
| Databases | PostgreSQL, MySQL, MongoDB, Redis lists |
| Metrics | Prometheus, Datadog, CloudWatch |
| HTTP | KEDA HTTP add-on (scale on request rate) |
| Cron | Time-based scaling windows |
A real ScaledObject
Scale a worker Deployment based on RabbitMQ queue depth, down to zero when the queue is empty:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: worker-scaler
spec:
scaleTargetRef:
name: deployment-worker
minReplicaCount: 0 # scale to zero when idle
maxReplicaCount: 30
cooldownPeriod: 120 # wait 120s of no activity before scaling to 0
pollingInterval: 15 # check the trigger every 15s
triggers:
- type: rabbitmq
metadata:
queueName: jobs
mode: QueueLength
value: "20" # target ~20 messages per replica
authenticationRef:
name: rabbitmq-authWhat happens:
- 1Queue is empty → KEDA keeps the Deployment at 0 replicas. No pods, no cost.
- 2Messages arrive → KEDA *activates*, scaling 0→1.
- 3Backlog grows → the backing HPA scales toward
maxReplicaCount, targeting ~20 messages per pod. - 4Backlog drains → HPA scales down; after
cooldownPeriodof no activity, KEDA scales back to 0.
ScaledJob for batch work
For work that should run to completion rather than as a long-lived service, ScaledJob spawns Kubernetes Jobs per batch of events:
apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
name: image-processor
spec:
jobTargetRef:
template:
spec:
containers:
- name: processor
image: processor:latest
restartPolicy: Never
maxReplicaCount: 50
triggers:
- type: aws-sqs-queue
metadata:
queueURL: https://sqs.../my-queue
queueLength: "5"Scale-to-zero: the headline feature and its catch
Scale-to-zero is the reason many teams adopt KEDA. Idle services cost nothing — no running pods. For dev environments, internal tools, free tiers, and bursty workloads this is enormous.
The catch is the cold start. When traffic hits a scaled-to-zero service, there's a delay: KEDA activates, the scheduler places a pod, the image pulls (if not cached), and the app boots. That first request waits for all of it. You manage this with:
- Warm node pools so scheduling and image pull are fast.
cooldownPeriodtuned so you don't flap to zero during brief lulls.- Request buffering (e.g. the KEDA HTTP add-on holds the request while the pod warms).
- Generous client/proxy timeouts so the cold-start delay doesn't surface as a 504.
We run scale-to-zero for free-tier apps on PandaStack with exactly this approach: KEDA scales idle apps to zero on preemptible nodes, and the ingress + timeout handling absorbs the cold start so the first request after idle still succeeds (just slower). It keeps the free tier genuinely free without leaving idle pods running.
HPA vs KEDA at a glance
| HPA alone | KEDA | |
|---|---|---|
| Scale on CPU/memory | Yes | Yes (via HPA) |
| Scale on queue/lag/events | No | Yes |
| Scale to zero | No | Yes |
| External metric sources | Manual adapters | 60+ built-in scalers |
| Best for | CPU-bound services | Event/queue-driven + idle-heavy |
Practical tips
- Pick the right target value. "20 messages per replica" directly controls how aggressively you scale. Too low → over-provisioning; too high → backlog.
- Mind the polling interval. Short intervals react fast but add load on the event source.
- Don't scale-to-zero latency-critical paths unless you've solved cold start. Keep
minReplicaCount: 1for those. - Combine triggers. A
ScaledObjectcan have multiple triggers; KEDA takes the max desired replica count across them.
References
- [KEDA official documentation](https://keda.sh/docs/)
- [KEDA scalers catalog](https://keda.sh/docs/latest/scalers/)
- [Kubernetes HPA documentation](https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/)
- [KEDA HTTP add-on](https://github.com/kedacore/http-add-on)
- [CNCF KEDA project page](https://www.cncf.io/projects/keda/)
---
Want scale-to-zero without writing a single ScaledObject? PandaStack runs free-tier apps with KEDA scale-to-zero out of the box. Push your code and it just runs — start free at [dashboard.pandastack.io](https://dashboard.pandastack.io).