Kubernetes Autoscaling with KEDA: Production Guide
Kubernetes’ built-in HPA scales on CPU and memory, but most real workloads should scale on business metrics: queue depth, request latency, or event count. Kubernetes autoscaling KEDA fills this gap by scaling based on 60+ external event sources, including the ability to scale to zero. This guide covers architecture, trigger configuration, and production tuning so that you can move beyond resource-utilization heuristics and scale on the signals that actually correlate with user-facing demand.
Why HPA Alone Isn’t Enough
HPA works for CPU-bound workloads, but most microservices are I/O-bound. A Kafka consumer might use 10% CPU while its queue backs up to millions. HPA sees low CPU and does nothing. Moreover, HPA can’t scale to zero — you always pay for at least one replica. KEDA solves both problems by acting as a metrics adapter and a controller. Under the hood, KEDA does not replace the HorizontalPodAutoscaler; instead, it generates and manages one on your behalf, feeding it external metrics through the Kubernetes external metrics API. Consequently, you keep the battle-tested HPA control loop while gaining access to event sources HPA was never designed to read.
The distinction matters operationally. Because KEDA owns the generated HPA, you should never hand-edit that object — the operator reconciles it back. Instead, you express intent declaratively in a ScaledObject, and KEDA translates triggers into metric targets. When a workload sits at zero replicas, the HPA cannot act (HPAs require at least one pod to compute utilization), so KEDA itself watches the trigger and activates the first replica. After activation, the generated HPA takes over for steady-state scaling.
Kubernetes Autoscaling KEDA: Kafka Consumer Lag
Event-driven consumers are the canonical use case. The example below scales an order processor on Kafka consumer lag rather than CPU. Notice two thresholds: lagThreshold drives steady-state scaling math, while activationLagThreshold governs the zero-to-one transition. This separation prevents a single stray message from waking an idle deployment every few minutes.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: order-processor
spec:
scaleTargetRef:
name: order-processor
minReplicaCount: 0 # Scale to zero when idle
maxReplicaCount: 50
cooldownPeriod: 300
pollingInterval: 15
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 25
periodSeconds: 60
scaleUp:
policies:
- type: Pods
value: 10
periodSeconds: 60
triggers:
- type: kafka
metadata:
bootstrapServers: kafka:9092
consumerGroup: order-processors
topic: orders
lagThreshold: "100"
activationLagThreshold: "5"
The arithmetic is worth understanding. KEDA divides total partition lag by lagThreshold to derive the desired replica count, then clamps it between min and max. With a lag of 1,000 and a threshold of 100, KEDA targets 10 replicas. However, a critical constraint applies to Kafka specifically: you cannot usefully exceed the partition count, because each partition is consumed by exactly one consumer in the group. Therefore, set maxReplicaCount at or below your partition count, and provision partitions for your peak throughput rather than expecting KEDA to overcome a partitioning ceiling.
Scaling on Prometheus Metrics
For request-driven services, latency and throughput are better signals than CPU. The Prometheus scaler lets you scale on any PromQL query, which means you can target SLO-relevant metrics directly. Combining multiple triggers is additive: KEDA evaluates each trigger, computes a desired replica count for each, and takes the maximum. Thus a service can scale up either because p99 latency degraded or because request rate climbed — whichever demands more capacity wins.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: api-server
spec:
scaleTargetRef:
name: api-server
minReplicaCount: 2
maxReplicaCount: 20
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus:9090
metricName: http_p99_latency
query: |
histogram_quantile(0.99,
sum(rate(http_request_duration_seconds_bucket{
service="api-server"}[2m])) by (le))
threshold: "0.5" # Scale when p99 > 500ms
- type: prometheus
metadata:
serverAddress: http://prometheus:9090
metricName: http_rps
query: sum(rate(http_requests_total{service="api-server"}[1m]))
threshold: "1000"
One caveat: a query that scales on latency can create a feedback loop. If latency rises because the database is the bottleneck, adding pods may worsen contention rather than relieve it. Consequently, latency-based triggers work best when the bottleneck is genuinely the stateless tier, and you should pair them with a throughput trigger that reflects offered load rather than a symptom of saturation downstream.
Cron-Based Pre-Scaling
Reactive scaling always lags demand because metrics must accumulate before the controller responds. For predictable traffic — business hours, a daily batch window, a marketing email blast — proactive cron triggers warm capacity ahead of the spike. The cron scaler sets a replica floor during a window, and because triggers are additive, it composes cleanly with reactive scalers that handle the unexpected.
triggers:
# Business hours: minimum 10 replicas
- type: cron
metadata:
timezone: America/New_York
start: 0 8 * * 1-5
end: 0 20 * * 1-5
desiredReplicas: "10"
# Lunch rush: minimum 20
- type: cron
metadata:
timezone: America/New_York
start: 0 11 * * 1-5
end: 0 14 * * 1-5
desiredReplicas: "20"
# Also scale on CPU for unexpected spikes
- type: cpu
metricType: Utilization
metadata:
value: "70"
ScaledJobs for Batch Processing
Not every workload is a long-running deployment. For tasks that process one message to completion and then exit — video transcoding, report generation, ML inference batches — a ScaledJob is the right primitive. Whereas a ScaledObject adjusts replicas of a persistent deployment, a ScaledJob creates one Kubernetes Job per unit of work and lets each run to completion. This avoids the awkwardness of a deployment whose pods finish their work but linger until the next scale-down evaluation.
apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
name: video-transcoder
spec:
jobTargetRef:
template:
spec:
containers:
- name: transcoder
image: video-transcoder:latest
resources:
requests: { cpu: "2", memory: "4Gi" }
restartPolicy: Never
maxReplicaCount: 20
triggers:
- type: rabbitmq
metadata:
host: amqp://rabbitmq:5672
queueName: video-jobs
queueLength: "1"
Because each Job owns its message lifecycle, partial failures are isolated and retries become a Job-level concern with explicit backoff. In production, teams typically pair ScaledJobs with a dead-letter queue so poison messages do not spawn an endless churn of failing Jobs.
When NOT to Use KEDA, and Trade-offs
KEDA is not free of cost or risk, and scale-to-zero in particular carries an honest trade-off. The first request after an idle period pays a cold-start penalty: KEDA must detect the trigger, schedule a pod, pull the image, and wait for readiness. For latency-sensitive synchronous APIs, that delay can violate an SLO, so a minReplicaCount of one or two is usually wiser than zero. Scale-to-zero shines for asynchronous, queue-backed work where a few seconds of activation latency is invisible to users.
Steady, predictable load is another case where KEDA adds little. If a service runs at a constant request rate around the clock, a fixed replica count or plain CPU-based HPA is simpler to reason about and one fewer component to operate. Additionally, every external scaler depends on its metric source being available: if Prometheus is down or Kafka is unreachable, KEDA cannot compute a target and the deployment holds at its current count. Therefore, treat your metric backend as a scaling dependency and alert on KEDA’s own health metrics, not just the workload’s.
Production Tuning
Set cooldownPeriod to 300+ seconds to prevent thrashing. Use stabilizationWindowSeconds to smooth metrics spikes. Set activationThreshold to prevent unnecessary scale-from-zero events. Always set resource requests so the cluster autoscaler can provision nodes — KEDA can request 50 pods, but without a node autoscaler and adequate requests, those pods stay Pending. Test scaling under load before production, and validate behavior with synthetic traffic that mimics real spike shapes rather than a smooth ramp. For deeper observability, scrape KEDA’s metrics-adapter and operator metrics so you can correlate scaling decisions with the underlying trigger values.
Key Takeaways
For further reading, refer to the AWS documentation and the Google Cloud documentation for comprehensive reference material.
- Scale on business signals — queue lag, latency, throughput — not just CPU and memory
- Reserve scale-to-zero for asynchronous work; keep a warm floor for latency-sensitive APIs
- Combine cron pre-scaling with reactive triggers, since KEDA takes the maximum across triggers
- Use ScaledJobs for run-to-completion batch tasks and isolate poison messages with a DLQ
- Treat the metric backend as a scaling dependency and monitor KEDA’s own health
Kubernetes autoscaling KEDA enables event-driven scaling based on real business metrics. With 60+ triggers and scale-to-zero capability, KEDA handles virtually any scaling scenario. Start with one ScaledObject on your most variable workload, tune the parameters, and expand from there.
In conclusion, Kubernetes autoscaling KEDA is an essential capability for modern, event-driven systems. By applying the patterns and practices covered in this guide — additive triggers, careful activation thresholds, and honest trade-offs around cold starts — you can build scaling behavior that tracks real demand rather than a proxy for it. Start with the fundamentals, iterate on your configuration, and continuously measure results against actual traffic to ensure you are getting the most value from these approaches.