Core Concepts · How it works
16 min read- How it works
- The problem KEDA solves
- Step 1 — What gets installed
- Step 2 — The two-phase scaling model (the central mental model)
- Step 3 — Scalers and the ScaledObject
- Step 4 — Why HTTP needs an add-on
- Step 5 — Cold start and cooldown
- Step 6 — KEDA vs HPA vs Knative (judgment)
- Step 7 — The consumer nuance: can a queue consumer scale to zero?
- Step 8 — What it buys you (and the cost-honesty caveat)
- Step 9 — How KEDA connects to the right-sizing story
How it works
The problem KEDA solves
The built-in HPA is the only autoscaler most teams reach for, and it has two hard limits baked in:
| HPA limitation | Consequence |
|---|---|
| Scales only on CPU/memory (resource metrics) | Can't react to the signal that actually represents work — a queue backlog, a Kafka consumer lag, an HTTP request backlog |
minReplicas floor is 1 | A pod that does nothing all night still holds a full replica's RAM and scheduling reservation — it can never reach 0 |
So for an I/O-bound worker draining a queue, CPU is a poor proxy for load, and for a low-traffic admin API the floor of 1 wastes memory on a constrained node. KEDA (Kubernetes Event-Driven Autoscaling) — a CNCF graduated project — adds exactly the two things HPA lacks: event/metric-driven scaling and scale-to-zero. It does not rip out HPA; it builds on top of it.
A: The HPA (1) scales only on CPU/memory so it cannot react to event sources like queue depth, Kafka consumer lag, or an HTTP request backlog, and (2) has a minimum of 1 replica so it can never scale a workload to zero. KEDA adds both missing capabilities — event/metric-driven scaling and scale-to-zero — and it is a CNCF graduated project. Crucially it does not replace HPA; it wraps it (see the two-phase model).
Step 1 — What gets installed
Installing KEDA adds a small set of components (total footprint ~50–100 Mi):
| Component | Role |
|---|---|
keda-operator | The controller. Watches ScaledObject / ScaledJob CRDs and owns the 0↔1 transition — it directly sets a Deployment's replicas to 0 when idle and back to 1 when work appears. |
keda-operator-metrics-apiserver | A metrics adapter implementing the Kubernetes external-metrics API, so a standard HPA can read event-source metrics (queue length, Prometheus query result, etc.). |
| Admission webhooks | Validate ScaledObject/ScaledJob resources on create/update. |
The split matters: the operator is what makes scale-to-zero possible (HPA can't), while the metrics-apiserver is what lets the ordinary HPA do the 1→N scaling on a non-CPU metric.
A: KEDA installs the keda-operator (the controller that watches ScaledObject/ScaledJob CRDs), the keda-operator-metrics-apiserver (a metrics adapter implementing the Kubernetes external-metrics API so a standard HPA can read event-source metrics), and admission webhooks for validation — total footprint ~50–100 Mi. The operator is what makes scale-to-zero possible: it owns the 0↔1 transition and directly sets a Deployment's replicas to 0 when idle and back to 1 when work appears — something the HPA, floored at 1, cannot do.
Step 2 — The two-phase scaling model (the central mental model)
This is the single most important thing to understand about KEDA. Scaling happens in two distinct phases owned by two different things:
idle load grows
replicas = 0 ──────────────►
│ │
┌────▼─────────────────────┐ ┌────────────▼───────────────┐
│ 0 → 1 (ACTIVATION) │ │ 1 → N │
│ owned by KEDA OPERATOR │ hand-off │ owned by a standard HPA │
│ uses the scaler's │ ───────────► │ that KEDA CREATES │
│ ACTIVATION threshold │ │ reads the metric via │
│ after cooldownPeriod │ │ KEDA's metrics-apiserver │
│ idle → back to 0 │ │ scales 1..maxReplicaCount │
└──────────────────────────┘ └────────────────────────────┘
- 0→1 (activation) is done by the KEDA operator itself, based on a scaler's activation threshold. After
cooldownPeriod(default 300s) below that threshold, it scales back to 0. This is the part HPA cannot do. - 1→N: once there is ≥1 replica, KEDA creates a standard HPA and hands off. The HPA reads metrics through KEDA's metrics-apiserver and scales between 1 and
maxReplicaCount. KEDA wraps HPA; it does not replace it.
This is why each scaler has two numbers: an activation threshold (the 0→1 decision, owned by the operator) and a target value (the 1→N driver, owned by the HPA).
A: 0→1 (activation) is owned by the KEDA operator itself: it watches the scaler and, when the scaler's activation threshold is crossed, directly sets the Deployment from 0 to 1 replica — the thing HPA cannot do. Once there is ≥1 replica, 1→N is handed off to a standard HPA that KEDA creates, which reads the event metric via KEDA's metrics-apiserver and scales between 1 and maxReplicaCount. So KEDA wraps HPA rather than replacing it. When the workload goes idle (below the activation threshold for cooldownPeriod, default 300s), the operator scales it back to 0.
A: No — they drive the two different phases. The activation threshold is the 0→1 decision owned by the KEDA operator: "is there any work, so wake from zero?" The target value is the 1→N driver owned by the HPA: "given we're already running, how many replicas keep the metric at this target?" They are deliberately separate so you can, for example, activate as soon as a single message appears (activation = 1) but then scale so each replica handles, say, 20 queued messages (target = 20). Conflating them is a common misread of a ScaledObject.
Step 3 — Scalers and the ScaledObject
KEDA ships 60+ scalers — adapters that know how to read a metric from a specific source:
| Scaler | Scales on |
|---|---|
rabbitmq | queue length (or message rate) |
kafka | consumer-group lag |
prometheus | the result of an arbitrary PromQL query |
redis | list / stream length |
cron | a time schedule (scale up during business hours) |
aws-sqs-queue | approximate number of messages |
| …60+ more | Azure Service Bus, GCP Pub/Sub, NATS, MongoDB, etc. |
You wire one up with a ScaledObject that ties a target Deployment to triggers:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: order-consumer
spec:
scaleTargetRef:
name: order-consumer # the Deployment to scale
minReplicaCount: 0 # 0 = scale-to-zero
maxReplicaCount: 20
cooldownPeriod: 300 # seconds idle before scaling back to 0
pollingInterval: 15 # how often KEDA polls the scaler
triggers:
- type: rabbitmq
metadata:
queueName: orders
queueLength: "20" # TARGET: ~20 msgs per replica (drives 1→N)
activationQueueLength: "1" # ACTIVATION: ≥1 msg wakes it from 0
host: amqp://guest:guest@rabbitmq:5672/
Key fields: minReplicaCount (0 enables scale-to-zero), maxReplicaCount (the 1→N ceiling), cooldownPeriod (idle time before returning to 0), pollingInterval (how often KEDA polls the scaler — the resolution of the 0→1 decision), and triggers[] with each scaler's metadata including its activation and target thresholds.
ScaledObject define, and what do minReplicaCount, maxReplicaCount, cooldownPeriod, and pollingInterval each control?A: A ScaledObject ties a target Deployment (scaleTargetRef) to one or more triggers[] (scalers, each with its own metadata, activation, and target). minReplicaCount is the floor — set it to 0 to enable scale-to-zero. maxReplicaCount is the 1→N ceiling the HPA scales up to. cooldownPeriod (default 300s) is how long the workload must stay below the activation threshold before KEDA scales it back to 0. pollingInterval is how often KEDA polls the scaler — effectively the resolution/latency of the 0→1 activation decision.
queueLength: "20" and activationQueueLength: "1". Explain what each value does.A: activationQueueLength: "1" is the activation threshold the KEDA operator uses for the 0→1 decision — as soon as one message is on the queue, it wakes the consumer from zero. queueLength: "20" is the target the HPA uses for 1→N — it aims for roughly 20 queued messages per replica, so a backlog of 200 messages drives toward ~10 replicas (capped at maxReplicaCount). So one message lifts you off zero; the depth of the backlog then sets how many replicas drain it.
Step 4 — Why HTTP needs an add-on
Most scalers read a metric that exists whether or not the app is running: a RabbitMQ queue has a length even if zero consumers are up, and a Prometheus query can be evaluated against historical series. So KEDA can poll those at zero replicas and decide to wake the workload.
HTTP is fundamentally different: at 0 pods there is nothing to receive the request and therefore nothing to produce a metric. The request itself is the signal, and the thing that would emit the signal is asleep. You need something in the request path that is always up. That is the keda-http-add-on (footprint ~30–60 Mi):
- an interceptor proxy that buffers the incoming request, signals KEDA to scale 0→1, waits for the pod's readiness probe, then forwards the buffered request — and counts pending requests as the 1→N metric;
- an external scaler feeding those pending-request counts to KEDA;
- the
HTTPScaledObjectCRD (host/path → Deployment, min/max replicas, target pending requests).
You integrate it by pointing the service's Ingress backend at the interceptor instead of directly at the app Service:
apiVersion: http.keda.sh/v1alpha1
kind: HTTPScaledObject
metadata:
name: my-api
spec:
hosts: ["my-api.example.com"]
scaleTargetRef:
name: my-api # Deployment
service: my-api # the app's Service
port: 8080
replicas:
min: 0
max: 10
scalingMetric:
requestRate:
targetValue: 100 # pending/req-rate target drives 1→N
# Ingress backend → the interceptor Service, NOT my-api directly
A: A queue/Prometheus metric exists whether or not the app is running — KEDA can poll the queue length or run the PromQL query at 0 replicas and decide to wake the workload. HTTP has no such out-of-band metric: at 0 pods nothing receives the request, so nothing produces a metric, yet the request is the signal. The http-add-on puts an always-up interceptor in the request path that buffers the request, signals KEDA to scale 0→1, waits for readiness, then forwards the buffered request, and counts pending requests as the 1→N metric. You wire it by pointing the Ingress backend at the interceptor instead of the app Service. A queue consumer needs none of this because the queue already exposes a pollable depth.
A: (1) an interceptor proxy in the request path that buffers the incoming request, signals KEDA to scale 0→1, waits for the pod's readiness probe, then forwards the buffered request (and counts pending requests as the 1→N metric); (2) an external scaler that feeds those pending-request counts to KEDA; and (3) the HTTPScaledObject CRD that maps a host/path to the Deployment with min/max replicas and a target pending-request value. Integration = point the service's Ingress backend at the interceptor Service instead of pointing it directly at the app's Service, so all traffic flows through the interceptor. Footprint ~30–60 Mi.
Step 5 — Cold start and cooldown
When a scaled-to-zero HTTP service gets its first request after being idle, that request pays the cold-start cost: KEDA must schedule the pod → the container starts → the readiness probe passes (~2–5s for a typical .NET app, less with ReadyToRun/AOT which trims JIT warm-up). The interceptor buffers that request within an activation timeout, so the caller experiences a slow 200, not a 502. Warm requests (a replica already up) pass straight through with no penalty.
On the way down: after cooldownPeriod below the activation threshold, KEDA scales back to 0. So cooldownPeriod is a trade-off — too short and a bursty-but-idle-in-gaps service flaps 0↔1 repeatedly (re-paying cold start each time); too long and you hold a replica's RAM longer than necessary.
A: It pays the cold-start cost: KEDA schedules the pod, the container starts, and the readiness probe must pass (~2–5s for .NET, less with ReadyToRun/AOT). The interceptor buffers that first request within an activation timeout and only forwards it once the pod is ready, so the caller gets a slow 200 instead of a 502 (which is what they'd get hitting a backend with zero endpoints). Subsequent warm requests pass straight through. Reduce the cold-start penalty with ReadyToRun/AOT, faster readiness probes, and — if the latency is unacceptable on a hot path — a minReplicaCount: 1 to keep one warm.
Step 6 — KEDA vs HPA vs Knative (judgment)
These three are constantly confused; the distinctions are interview-grade:
| HPA | CPU / memory (or custom via an adapter) | No — min 1 | none (built in) | The baseline; the floor of 1 and CPU-only signals are exactly what KEDA fixes |
|---|---|---|---|---|
| KEDA | 60+ event sources + scale-to-zero | Yes | ~50–100 Mi (lightweight, wraps HPA) | Operator owns 0↔1, creates an HPA for 1→N |
| Knative Serving | request-driven | Yes | heavy control plane: activator + autoscaler + a per-pod queue-proxy sidecar | Also scales to zero, but its control plane (and a sidecar on every pod) can cost more than it saves on a small/tight node |
The judgment call: on a small or memory-constrained node, Knative's control plane and per-pod queue-proxy sidecar can consume more memory than the idle services it puts to sleep would free — so KEDA's http-add-on is preferred there. Knative earns its weight when you want a full request-driven serverless platform (revisions, traffic splitting, automatic concurrency-based scaling) at scale, not when you're just trying to free RAM on one tight box.
A: HPA scales on CPU/memory only and has a floor of 1 — it cannot scale to zero or react to event sources. KEDA adds event-driven scaling on 60+ sources plus scale-to-zero, and is lightweight (~50–100 Mi) because it wraps HPA — the operator owns 0↔1 and creates a standard HPA for 1→N. Knative Serving also scales to zero but ships a much heavier control plane (activator, autoscaler, and a queue-proxy sidecar on every pod). On a small/tight node that control plane and per-pod sidecar can cost more memory than the idle services it sleeps would free, so KEDA's http-add-on is preferred there. Knative is worth its weight when you want a full request-driven serverless platform (revisions, traffic splitting) at scale.
Step 7 — The consumer nuance: can a queue consumer scale to zero?
A naive worry: "you must never scale a message-queue consumer to zero, because a sleeping consumer stops draining its queue and the backlog grows unbounded." That is *true only if you scale it on the wrong trigger* — e.g. a CPU or cron trigger unrelated to the queue. In that case the consumer at 0 has no way to know messages are piling up.
But KEDA's *idiomatic pattern is to scale the consumer on the queue itself — the rabbitmq queueLength scaler (or Kafka lag, SQS depth, etc.). Because KEDA polls the queue depth out-of-band even at 0 replicas, the consumer sleeps at 0 and wakes the instant a message arrives (activation threshold ≥ 1). So with the right scaler, a queue consumer absolutely can scale to zero — it scales 0→N on queue depth, draining proportionally to the backlog and sleeping when empty. The simplest conservative first pass* is still minReplicaCount: 1 (never sleep), but it is not a hard rule — it's the safe default before you trust the queue scaler.
What is a hard rule: stateful workloads — databases, brokers, caches — are never KEDA scale-to-zero targets. Their value is being continuously available with their state; sleeping them breaks the system, not just latency.
A: It's a half-truth. A consumer scaled to zero by a non-queue trigger (CPU, cron) would stop draining its queue and let the backlog grow unbounded — at 0 it has no way to notice work. But KEDA's idiomatic pattern is the queue scaler (rabbitmq queueLength, Kafka lag, SQS depth): KEDA polls the queue depth out-of-band even at 0 replicas, so the consumer sleeps at 0 and wakes the moment a message arrives (activation ≥ 1), then scales 0→N on backlog depth. So with the *right scaler a consumer can scale to zero. The conservative first pass is still minReplicaCount: 1, but it's a default, not a law. The genuine hard rule is that stateful workloads — databases, brokers, caches — are never scale-to-zero targets*.
Step 8 — What it buys you (and the cost-honesty caveat)
The payoff: an idle workload drops to 0 replicas, which frees *0 RAM and 0 scheduling reservation — the pod stops consuming memory and stops reserving capacity the scheduler would otherwise hold against its requests. On a constrained node, scaling several idle long-tail services to zero is the density lever that lets other workloads schedule (this is exactly the strongest lever in the node-rightsizing module, where a constrained K3s box was OOM-killing pods because memory was 95% requested*).
The caveat to teach explicitly: scale-to-zero gives 0 RAM per idle container, not a $0 hardware bill. A self-hosted always-on node costs the same whether idle or full — the hardware is paid for regardless. Literal $0-at-idle only exists on per-request serverless (Cloud Run, AWS Lambda, Fly.io auto-stop), where you genuinely stop being billed when there's no traffic. So on self-hosted infra, scale-to-zero is a density/headroom play (free RAM for other workloads), not a billing play.
A: It frees *0 RAM and 0 scheduling reservation per idle container — the pod stops using memory and* stops reserving capacity the scheduler holds against its requests — which lets other workloads schedule on a constrained node (the density lever). But it is not a $0 bill: a self-hosted always-on node costs the same idle or full, so the hardware spend doesn't move. Literal $0-at-idle only exists on per-request serverless (Cloud Run, Lambda, Fly.io auto-stop). So on self-hosted infra, scale-to-zero is a headroom/density play, not a billing one — keep the always-paid platform self-hosted and scale-to-zero its idle services; push genuinely bursty, isolated workloads to serverless if you actually want to stop paying at idle.
Step 9 — How KEDA connects to the right-sizing story
KEDA is the strongest, safest lever in the broader node-rightsizing playbook for a memory-constrained node. There, a single small K3s box (4 vCPU, 7.9 GB, no swap) was OOM-killing pods because memory was 95% requested, and long-running .NET services accumulated working set with uptime (a fresh content-api at 94 MB vs 284 MB aged). The validated levers were: lower the container memory limit, periodic restart, app-level reduction, and scale-to-zero with KEDA — which sidesteps accumulation entirely because a pod at 0 replicas accumulates nothing. KEDA is how you implement "idle = 0 RAM" without an app change.
A: On a constrained node where memory is the binding constraint (requests near allocatable, long-running services accumulating working set with uptime), the right-sizing levers are: lower the container memory limit, periodic restart, app-level reduction, and scale-to-zero with KEDA. KEDA is the safest because it needs no application change and it sidesteps the accumulation problem entirely — a pod at 0 replicas accumulates nothing (no caches, no LOH growth, no broker buffers), and frees both its RAM and its scheduling reservation so other workloads can schedule. It's the cleanest way to make "idle = 0 RAM" real for low-traffic / long-tail services.