KEDA¶
Who this page is for: in plain English, KEDA scales your workloads on things the built-in autoscaler can't see - queue depth, Kafka lag, a cron schedule - and can scale them all the way down to zero. This page is production depth, aimed at readers already comfortable with Pods, Services, and Deployments. New to Kubernetes? Start with the Beginner track.
KEDA (Kubernetes Event-Driven Autoscaling) extends the Kubernetes HPA to scale workloads based on external event sources - Kafka consumer lag, queue depth, Prometheus metrics, HTTP request rate, cron schedules, and dozens more - including scaling all the way to zero.
The standard HPA only scales on CPU and memory. For event-driven workloads, those signals arrive too late or don't reflect the actual backlog. A Kafka consumer might be idle (low CPU) but sitting behind 10,000 unprocessed messages. KEDA surfaces that backlog as the scaling signal.
Architecture¶
flowchart TD
KEDA[KEDA Operator] --> |reads| SO[ScaledObject\nor ScaledJob]
KEDA --> |creates/manages| HPA[HorizontalPodAutoscaler]
KEDA --> |queries| Scaler[External scaler\nKafka / Redis / SQS / Prometheus...]
Scaler --> |metric value| KEDA
HPA --> |scales| Workload[Deployment / StatefulSet\nor Jobs]
KEDA doesn't replace the HPA - it generates and manages HPA objects on your behalf. The KEDA operator translates external metric values into ExternalMetrics API objects that the HPA's scaling loop can consume. This means KEDA integrates with standard Kubernetes cluster autoscaler behavior.
How KEDA and the HPA actually fit together¶
This mechanism is worth understanding precisely, because it's the source of most "why isn't KEDA scaling" confusion.
keda-operatorwatches ScaledObjects and polls each scaler atpollingInterval.keda-operator-metrics-apiserverregisters itself with the Kubernetes API aggregation layer as an implementation of theexternal.metrics.k8s.ioAPI group. It does not push metrics anywhere - it serves them on demand.- When you create a ScaledObject, KEDA creates a standard
HorizontalPodAutoscalerobject namedkeda-hpa-<scaledobject-name>(or the name you set inadvanced.horizontalPodAutoscalerConfig.name), withscaleTargetRefpointed at your workload and metric sources of typeExternal, referencingkeda-operator-metrics-apiserveras the backing API. - The stock Kubernetes controller manager's HPA controller runs its normal reconciliation loop (every 15s by default,
--horizontal-pod-autoscaler-sync-period) and calls the external metrics API to get the current value - exactly the same code path it uses for a hand-written HPA againstmetrics.k8s.ioorcustom.metrics.k8s.io. - The HPA controller computes desired replicas and scales the Deployment/StatefulSet itself. KEDA never touches
spec.replicasdirectly for Deployment-backed ScaledObjects - the HPA does.
Because of this, kubectl get hpa -n production shows the KEDA-managed HPA like any other:
$ kubectl get hpa -n production
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
keda-hpa-order-processor Deployment/order-processor 10500m/100 (avg) 0 50 6
kubectl describe hpa keda-hpa-order-processor shows the same ScalingActive/AbleToScale conditions you'd see debugging any HPA, plus events when it can't reach the external metrics API. This is your first stop when replicas seem stuck - if the HPA shows unable to get external metric, the problem is between the HPA controller and keda-operator-metrics-apiserver, not in your scaler logic.
One consequence: KEDA respects the same HPA-level constraints as everything else. minReplicaCount: 0 is special-cased - KEDA temporarily removes the HPA (or sets minReplicas: 1 and pauses it) when scaling to zero, because the HPA API itself cannot express zero, then recreates/resumes it once idleReplicaCount/activation conditions are met and a scale-up is needed. This is also why ScaledObject deletion during a scale-to-zero window can occasionally leave things in a slightly odd state - check kubectl get hpa after deleting a ScaledObject that was at zero.
ScaledObject¶
A ScaledObject ties a workload to one or more scalers:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: order-processor
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: order-processor
pollingInterval: 15 # check scaler every 15s
cooldownPeriod: 60 # wait 60s after last event before scaling to zero
minReplicaCount: 0 # allow scale-to-zero
maxReplicaCount: 50
idleReplicaCount: 0 # if no messages, scale to 0 (overrides minReplicaCount)
triggers:
- type: kafka
metadata:
bootstrapServers: kafka.platform:9092
consumerGroup: order-processor
topic: orders
lagThreshold: "100" # scale up when lag exceeds 100 messages per replica
offsetResetPolicy: latest
authenticationRef:
name: kafka-trigger-auth
lagThreshold: "100" means KEDA targets: desiredReplicas = ceil(currentLag / lagThreshold). With 1,000 messages queued and threshold 100, KEDA targets 10 replicas. With 0 messages and idleReplicaCount: 0, KEDA scales to zero.
pollingInterval and cooldownPeriod, precisely¶
These two fields are the most commonly misunderstood in the spec:
pollingInterval(default 30s) is how often the KEDA operator queries the scaler (Kafka, Redis, Prometheus, etc.) to compute the external metric value. This is independent of the HPA controller's own sync loop - KEDA writes the fresh value to the metrics API server, and the HPA controller picks it up on its own cadence (default every 15s). A shortpollingIntervaldoesn't make the HPA react faster than its own sync period; it just keeps the value KEDA serves current.cooldownPeriod(default 300s) only applies to the scale-to-zero transition. It's the time KEDA waits, after the trigger last reported an "active" state, before scaling the Deployment down tominReplicaCount(typically 0). It does not apply to normal scale-down between nonzero replica counts - that's governed entirely by the HPA's own scale-down behavior (see below). A shortcooldownPeriodon a bursty queue causes rapid zero-to-one-to-zero cycling; a long one keeps a pod warm (and billed) longer than necessary after the last event.
In short: pollingInterval controls how fresh the signal is, cooldownPeriod controls how eager KEDA is to remove the last replica.
Tuning scale-up/down rate with HPA behavior¶
The standard HPA behavior block controls stabilization windows and the rate of change for both directions, and it's the correct lever for taming flapping - KEDA just exposes it under advanced:
spec:
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # wait 5m of sustained low demand before scaling down
policies:
- type: Percent
value: 25 # remove at most 25% of replicas per period
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0 # scale up immediately, no debounce
policies:
- type: Pods
value: 4 # add at most 4 pods per period
periodSeconds: 30
selectPolicy: Max
stabilizationWindowSeconds on scaleDown is the single most effective fix for consumer workloads that flap between N and N-1 replicas as lag oscillates around the threshold - it makes the HPA look back over the window and use the highest recommended replica count seen in that period before scaling down. Leave scaleUp unstabilized (or nearly so) for latency-sensitive queues; you almost always want to add capacity fast and remove it slowly.
TriggerAuthentication¶
Credentials for scalers are separated from the ScaledObject using TriggerAuthentication:
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: kafka-trigger-auth
namespace: production
spec:
secretTargetRef:
- parameter: sasl
name: kafka-credentials
key: sasl-mechanism
- parameter: username
name: kafka-credentials
key: username
- parameter: password
name: kafka-credentials
key: password
- parameter: tls
name: kafka-credentials
key: tls
Use ClusterTriggerAuthentication for credentials shared across namespaces (e.g., a single AWS IAM role or cloud credentials). TriggerAuthentication is namespaced and only visible to ScaledObjects in the same namespace; ClusterTriggerAuthentication is cluster-scoped and referenced the same way from any namespace via authenticationRef.kind: ClusterTriggerAuthentication.
Secret-based vs identity-based auth¶
KEDA supports several authentication provider types inside TriggerAuthentication.spec, and the choice matters for security posture, not just convenience:
secretTargetRef- pulls values out of a Kubernetes Secret, as shown above. Simplest to set up, but it's a long-lived credential sitting in etcd (and in Secret sprawl across namespaces if you're not careful with RBAC).env- reads from an environment variable on the KEDA operator pod itself. Rarely used outside quick tests; it centralizes the credential but ties it to the operator's own pod spec.podIdentity- delegates to the cloud provider's identity mechanism instead of a static credential:
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: aws-sqs-auth
namespace: production
spec:
podIdentity:
provider: aws # or azure-workload, gcp, aws-eks (deprecated, removal planned for KEDA v3)
With provider: aws (the current IRSA-based provider) the ScaledObject's target workload's ServiceAccount carries the IAM role annotation, and KEDA's own operator pod (or, depending on scaler, the workload itself) assumes that role via the metadata/token exchange - no AWS access keys ever touch a Secret. The older aws-eks provider value still works but is deprecated and slated for removal in KEDA v3; new configs should use aws. azure-workload does the equivalent with Entra Workload Identity federation for Azure Service Bus, Event Hubs, and Blob Storage scalers; gcp does it with Workload Identity for Pub/Sub and Cloud Monitoring-based scalers. (The aws-kiam provider referenced in older KEDA docs/tutorials no longer exists - it was deprecated years ago in favor of aws-eks and has since been removed entirely.)
Prefer pod/workload identity for every cloud-native trigger (SQS, Service Bus, Pub/Sub, Azure Monitor, Cloud Monitoring). Reach for secretTargetRef for self-hosted systems that only support static credentials (self-managed Kafka with SASL, RabbitMQ, self-hosted Prometheus behind basic auth). Never put long-lived cloud keys in a Secret when the platform offers pod identity - it's strictly more auditable and rotates automatically.
Common scalers¶
Kafka - consumer lag mechanics¶
triggers:
- type: kafka
metadata:
bootstrapServers: kafka.platform:9092
consumerGroup: my-consumer
topic: my-topic
lagThreshold: "50"
activationLagThreshold: "5" # below this, don't activate from zero
allowIdleConsumers: "false"
scaleToZeroOnInvalidOffset: "false"
The Kafka scaler connects to the brokers as an admin/consumer client and, for every partition of the topic, computes lag = latestOffset - committedOffset from the consumer group's committed offsets. It sums lag across all partitions the group owns, then applies the same ceil(totalLag / lagThreshold) formula. activationLagThreshold is a separate, usually lower, threshold that governs whether KEDA activates the workload at all from zero - this avoids waking up a consumer for a handful of stray messages while still using lagThreshold for how aggressively to scale once active.
A crucial constraint: you cannot usefully scale a Kafka consumer group beyond its partition count. If orders has 12 partitions, replica 13 onward sits idle - Kafka only assigns one consumer per partition per group. Set maxReplicaCount to the partition count (or lower, if you want each pod to handle multiple partitions). allowIdleConsumers: true disables the scaler's own partition-count cap if you deliberately want more replicas than partitions for other reasons (e.g. hot standby), but it won't get you more parallelism.
RabbitMQ and Azure Service Bus (queue-length scalers)¶
triggers:
- type: rabbitmq
metadata:
protocol: amqp # or "http" to use the management API instead
queueName: orders
mode: QueueLength # or MessageRate
value: "20" # target messages (or rate) per replica
authenticationRef:
name: rabbitmq-auth
The amqp protocol mode polls the queue directly for messages_ready; the http mode calls the RabbitMQ management API, which additionally exposes rates and per-vhost detail but requires the management plugin enabled. Both compute desiredReplicas = ceil(queueLength / value) the same way as the other queue scalers.
triggers:
- type: azure-servicebus
metadata:
queueName: orders
messageCount: "50"
namespace: my-servicebus-namespace
authenticationRef:
name: azure-servicebus-auth # typically podIdentity: azure-workload
Azure Service Bus works identically in shape - active message count on the queue (or topic subscription) divided by messageCount per replica. All three queue-length scalers (Redis, RabbitMQ, Service Bus) share the same mental model: pick a target "items per replica" and let KEDA do the division.
Redis Lists (queue depth)¶
triggers:
- type: redis
metadata:
address: redis.platform:6379
listName: job-queue
listLength: "20" # target: one replica per 20 items
authenticationRef:
name: redis-auth
AWS SQS¶
triggers:
- type: aws-sqs-queue
metadata:
queueURL: https://sqs.us-east-1.amazonaws.com/123456789/my-queue
queueLength: "10"
awsRegion: us-east-1
authenticationRef:
name: aws-keda-auth
Use IRSA (IAM Roles for Service Accounts) with ClusterTriggerAuthentication to avoid long-lived credentials.
Prometheus¶
Scale on any Prometheus metric:
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring:9090
metricName: active_transactions
query: |
sum(active_transactions{namespace="production"})
threshold: "100" # one replica per 100 active transactions
This is the most flexible scaler. Any signal you can express in PromQL - queue depth, active sessions, p99 latency above a threshold, error rate - can drive scaling. The scaler executes query against serverAddress on every pollingInterval tick, using the Prometheus HTTP API's instant-query endpoint, and treats the single scalar result as the current metric value. Because the query is arbitrary PromQL, you can encode logic KEDA has no native concept of - a ratio of two metrics, a rate() over a window, a query scoped with topk(). The tradeoff is that KEDA doesn't validate the query beyond "did it return one number"; a query returning a vector with multiple series causes scaler errors, so sum() or otherwise reduce to a scalar defensively.
Cron¶
Scale on a schedule (useful for batch windows or predictive pre-warming):
triggers:
- type: cron
metadata:
timezone: America/Chicago
start: "0 7 * * 1-5" # weekdays 7am
end: "0 20 * * 1-5" # weekdays 8pm
desiredReplicas: "5"
Outside the start/end window the cron trigger reports zero - it doesn't hold the previous value, it actively contributes 0 to the max-across-triggers calculation. That matters when cron is combined with a reactive trigger (see Multi-trigger behavior): cron acts as a floor during the window and simply drops out entirely outside it, letting the other trigger take over. Multiple cron triggers on a single ScaledObject create overlapping scale-up windows, and KEDA takes the max desired replicas among them just like any other multi-trigger case.
HTTP - the KEDA HTTP Add-on¶
The HTTP Add-on is a separate installable component from core KEDA (its own Helm chart, kedacore/keda-add-ons-http), purpose-built for scaling synchronous HTTP services - including all the way to zero - without dropping requests during cold start. Core KEDA's http scaler type only exists once the add-on's CRDs and interceptor are installed. The add-on is still pre-1.0 and explicitly not GA, though it's considered stable enough for production use with the caveat that minor breaking changes can still land before v1.0.
flowchart LR
Client[Client request] --> Interceptor[HTTP Add-on\ninterceptor proxy]
Interceptor --> |queue depth metric| Scaler[HTTP Add-on\nexternal scaler]
Scaler --> |external scaler gRPC| KEDA[KEDA operator]
KEDA --> HPA[HorizontalPodAutoscaler]
HPA --> |scales 0 to N| Backend[Backend Deployment]
Interceptor -. holds request until ready .-> Backend
The add-on's API is the HTTPScaledObject CRD (http.keda.sh), which declares both what traffic the interceptor should route and what metric drives scaling. KEDA's own ScaledObject is generated for you from it - you don't write one by hand for HTTP scaling:
apiVersion: http.keda.sh/v1alpha1
kind: HTTPScaledObject
metadata:
name: api-frontend
namespace: production
spec:
hosts:
- api.example.com
scaleTargetRef:
name: api-frontend # the Deployment to scale
kind: Deployment
service: api-frontend # the Service the interceptor forwards to
port: 8080
replicas:
min: 0
max: 20
scaledownPeriod: 300
scalingMetric:
requestRate:
targetValue: 100 # requests/sec per replica
The add-on is pre-1.0 and its CRD fields have changed across releases - scalingMetric, replicas, and scaledownPeriod in particular have moved or been renamed over time. Check the CRD reference in the http-add-on repository's docs/ directory for the exact schema shipped with the version you install rather than copying a manifest from any blog post, this page included.
The mechanism: traffic for the host is routed through the interceptor, a proxy the add-on deploys, based on the HTTPScaledObject's hosts rules. When the backend is at zero replicas, the interceptor doesn't 502 - it holds the incoming request in a queue and simultaneously reports demand to the add-on's external scaler, which KEDA queries as an external scaler and turns into a metric the HPA acts on like any other KEDA metric. KEDA scales the Deployment from 0 to 1, and once a replica passes readiness, the interceptor forwards the held request (subject to a configurable timeout, after which it fails the request rather than holding forever). Once at least one replica is running, the interceptor also tracks in-flight/pending request counts to feed the requestRate or concurrency scaling metric for scale-out beyond one replica. This solves the "first request problem" that a naive scale-to-zero HTTP setup has: without an interceptor, the first request after scale-to-zero simply fails because there's nothing to route to yet.
ScaledJob¶
ScaledJob manages Jobs instead of Deployments. Each "unit of work" spawns its own Job, rather than scaling a long-running consumer.
apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
name: image-processor
namespace: production
spec:
jobTargetRef:
parallelism: 1
completions: 1
template:
spec:
restartPolicy: Never
containers:
- name: processor
image: my-org/image-processor:v1.2.0
command: ["python", "process_one.py"]
pollingInterval: 10
maxReplicaCount: 30 # max concurrent Jobs
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
scalingStrategy:
strategy: accurate # create one Job per pending message
triggers:
- type: rabbitmq
metadata:
protocol: amqp
queueName: image-processing
mode: QueueLength
value: "1" # one Job per message
authenticationRef:
name: rabbitmq-auth
Use ScaledJob when:
- each message is a discrete unit of work with its own completion state
- you need Job-level tracking (completion, failure history)
- the processing time is long enough that sharing a Deployment adds overhead
Use ScaledObject (Deployment) when:
- consumers are long-running, stateful, or maintain connections (Kafka consumers hold partition leases)
- rapid scale events would create too many Jobs
Scale-to-zero patterns¶
Scale-to-zero reduces cost for workloads with intermittent traffic. The tradeoff is cold-start latency.
Design considerations:
- Image pull time: pre-pull images to nodes or use lightweight base images. With large images, cold start can take 30-60 seconds.
- Application startup: readiness probes determine when the pod is ready to receive traffic. Optimize your startup path.
- The first message problem: for Kafka, the consumer must join the consumer group before it can read. With minReplicaCount: 0, the first message has no consumer. KEDA detects the lag and starts scaling, but there's a polling delay (pollingInterval). Set this low (5-10s) for latency-sensitive queues.
- Graceful shutdown: consumers should finish processing their current message before exiting. Use preStop hooks and terminationGracePeriodSeconds.
Pause autoscaling¶
Temporarily disable autoscaling without removing the ScaledObject:
This freezes the replica count at 2. Remove the annotation to resume:
Useful during deployments or when you want to drain a queue without scaling up.
Multi-trigger behavior¶
When a ScaledObject has multiple triggers, KEDA takes the maximum desired replica count across all triggers:
triggers:
- type: kafka
metadata:
lagThreshold: "100" # with 500 lag → 5 replicas
- type: prometheus
metadata:
threshold: "50" # with 200 active sessions → 4 replicas
Result: KEDA targets max(5, 4) = 5 replicas. This is the right behavior - you want to keep up with the most demanding signal.
Operational patterns¶
Monitor KEDA metrics: KEDA exposes its own Prometheus metrics at :2222/metrics - keda_scaler_metrics_value, keda_scaler_active, keda_scaled_object_errors. Alert on scaler errors.
Scaler connectivity: if a scaler can't reach its external system (Kafka is down, Redis is unreachable), KEDA logs an error and holds the last known scale. It does not scale to zero on scaler failure. Test this behavior before relying on it for availability.
HPA coexistence: if you already have an HPA for CPU scaling, KEDA can manage it. Use advanced.horizontalPodAutoscalerConfig to set HPA behavior (stabilization windows, scale-up/down rates).
Fallback: KEDA supports a fallback block - if the scaler fails N consecutive times, use a fixed replica count as the fallback:
Troubleshooting¶
Start with the operator logs.
Look for error getting metric value, scaler error, or authentication failures. The operator logs every scaler poll failure, including the underlying error from the target system (connection refused, auth denied, DNS failure) - this is almost always more informative than anything visible on the ScaledObject status.
Check ScaledObject status and conditions:
Look at status.conditions: Active (is any trigger currently reporting activity), Fallback (is KEDA using the fallback replica count because the scaler is failing), and Ready. A ScaledObject stuck with Ready=False usually means the HPA it should be managing was never created or was deleted out from under it - check for a duplicate HPA name conflict (another HPA already using keda-hpa-<name>) or an RBAC issue preventing the operator from creating HPAs.
Confirm the external metrics API is actually being queried. This is the step people skip. Query it directly the same way the HPA controller does:
kubectl get --raw "/apis/external.metrics.k8s.io/v1beta1/namespaces/production/s0-kafka-order-processor" | jq .
If this returns a value, the metrics pipeline (keda-operator-metrics-apiserver → API aggregation layer) is healthy and the problem is downstream in the HPA controller or workload. If it errors (no metrics returned, service unavailable), check that the APIService for external.metrics.k8s.io is available (kubectl get apiservice v1beta1.external.metrics.k8s.io) and points at a healthy keda-operator-metrics-apiserver Service/pod.
Common "stuck at minReplicas" causes:
- Scaler misreports zero activity - e.g. a Kafka
consumerGroupname typo means the scaler queries a group with no lag, because it genuinely doesn't exist yet. KEDA sees "no lag" and correctly does nothing. activationLagThreshold/activation threshold set too high - the trigger has real but small values that never cross the activation bar, so the workload never leaves zero even thoughlagThresholdalone would have scaled it.- A competing HPA already exists for the same
scaleTargetRef- Kubernetes allows only one HPA per target; KEDA's HPA silently fails to reconcile, or the pre-existing one wins depending on creation order.kubectl get hpa -A -o wideand look for duplicates. - RBAC: the KEDA operator's ServiceAccount lacks permission to read the Secret referenced by
TriggerAuthentication, or lacksget/liston the external resource. Check operator logs forforbidden. - Paused via annotation - someone set
autoscaling.keda.sh/paused-replicas(see above) and forgot to remove it.kubectl get scaledobject order-processor -o jsonpath='{.metadata.annotations}'catches this in five seconds. - cooldownPeriod masking activity - if you're testing scale-to-zero and see it holding at 1 replica longer than expected, that's
cooldownPeriodworking as designed, not a bug.
Common mistakes¶
- Setting
pollingIntervalvery low "for responsiveness" without realizing the HPA controller's own sync period (15s default) is the actual bottleneck for reaction speed - and a too-low polling interval just hammers the external system (Kafka admin API, cloud API rate limits) for no benefit. - Forgetting the Kafka partition ceiling. Setting
maxReplicaCountabove the topic's partition count wastes pod scheduling and gives a false sense of headroom - those extra replicas never receive a partition assignment. - No
scaleDown.stabilizationWindowSeconds, leading to visible flapping on any workload whose metric oscillates near the threshold. This is the single most common "KEDA is unstable" complaint, and it's an HPAbehaviorconfig issue, not a KEDA bug. - Long-lived cloud credentials in a Secret when the cluster already has IRSA/Workload Identity available - adds an unnecessary rotation burden and audit gap for no operational benefit.
- Assuming scaler failure scales to zero. It doesn't - KEDA holds the last known scale on scaler error (unless
fallbackis configured), which is the safe default but surprises people expecting fail-safe-to-zero behavior. - Deploying the HTTP Add-on and expecting core KEDA's generic scaler semantics - they're related, but the add-on's
HTTPScaledObjectCRD and interceptor proxy are their own moving parts with their own failure modes (interceptor pod down = every request to a zero-replica service hangs until its timeout). - Copying an HTTP Add-on manifest without checking which add-on version it targets. The add-on is pre-1.0 and its CRD schema has changed between minor releases; a manifest that worked a few releases ago may be silently rejected or partially ignored. Diff against the CRD reference for the version you actually installed.
- Treating
cooldownPeriodas a general "don't scale down" knob. It only governs the transition to zero; ordinary scale-down between nonzero replica counts is entirely the HPA'sbehavior.scaleDownpolicy.
Source Links¶
- KEDA documentation
- KEDA scalers reference
- ScaledObject specification
- ScaledJob specification
- TriggerAuthentication and identity providers
- kedacore/keda on GitHub and its releases
- KEDA HTTP Add-on
- CNCF project page: KEDA
- Kubernetes: HorizontalPodAutoscaler and scaling behavior