Scaling Workers with KEDA on Queue Length
CPU is a poor scaling signal for a worker fleet, because a worker blocked on a slow API is idle by every CPU measure while the backlog grows behind it. This guide implements queue-driven scaling from Horizontal Worker Scaling, part of Backend Frameworks & Worker Scaling.
Problem Statement
Your workers scale on CPU with a 70% target. Most jobs wait on HTTP calls, so CPU sits at 15% no matter how deep the queue gets, and the HPA never adds a replica. During the morning burst the backlog reaches 40,000 messages and takes ninety minutes to clear, while Kubernetes has ample idle capacity. You need the backlog itself to drive replica count, with sensible behaviour when the burst ends.
Prerequisites
- Kubernetes 1.27+ with KEDA 2.12+ installed (
keda-operatorin thekedanamespace). - A worker Deployment that drains cleanly on
SIGTERM, per zero-downtime worker deploys on Kubernetes โ scaling down is a shutdown. - Broker credentials available as a Kubernetes secret.
- Measured p50 job duration; the target value is derived from it.
Step 1 โ Pick the Metric That Reflects Backlog
| Broker | KEDA scaler | Metric | Caveat |
|---|---|---|---|
| Redis (list) | redis |
listLength |
One list per queue; Celery's Redis keys are plain lists |
| Redis (stream) | redis-streams |
pending entries | Counts unacknowledged, not waiting |
| RabbitMQ | rabbitmq |
queueLength or messageRate |
Length excludes unacked by default |
| AWS SQS | aws-sqs-queue |
ApproximateNumberOfMessages |
Add NotVisible to include in-flight |
| Prometheus | prometheus |
any query | The escape hatch: use for oldest-age scaling |
Depth is the usual choice, but it is a lagging signal for slow jobs. If a job takes two minutes, a queue of ten messages is a twenty-minute backlog, and depth-based scaling will under-react. For those workloads scale on oldest-message age via the Prometheus scaler instead.
Step 2 โ Write the ScaledObject
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: worker-media
spec:
scaleTargetRef:
name: worker-media
minReplicaCount: 2 # never zero for a latency-sensitive queue
maxReplicaCount: 40 # bounded by the downstream, not by the cluster
pollingInterval: 15 # how often KEDA reads the metric
cooldownPeriod: 300 # wait before scaling to minReplicaCount
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # react to bursts immediately
policies:
- type: Percent
value: 100 # at most double per step
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 300 # ignore dips for 5 minutes
policies:
- type: Pods
value: 2 # remove at most 2 pods per minute
periodSeconds: 60
triggers:
- type: redis
metadata:
addressFromEnv: REDIS_URL
listName: media
listLength: "30" # target messages PER REPLICA โ see step 3
enableTLS: "false"
The asymmetry between scaleUp and scaleDown is deliberate and is most of what makes autoscaling behave. Scaling up fast costs a few extra pods for a few minutes; scaling down fast interrupts in-flight jobs and then immediately needs the capacity back.
Step 3 โ Derive the Target from Job Duration
listLength is the target backlog per replica, and KEDA divides total depth by it to get the desired replica count. Setting it by intuition is how fleets end up oscillating.
def target_per_replica(p50_seconds: float, concurrency: int,
drain_target_seconds: float = 60) -> int:
"""Messages per replica such that a full backlog drains in ~drain_target."""
throughput_per_replica = concurrency / p50_seconds # jobs/sec
return max(1, int(throughput_per_replica * drain_target_seconds))
target_per_replica(0.4, concurrency=8) # 1200 โ fast jobs, big target
target_per_replica(30, concurrency=4) # 8 โ slow jobs, small target
A slow-job queue needs a small target: with 30-second jobs, a target of 100 means each replica has a 50-minute backlog before another is added. Getting this backwards โ a large target on slow jobs โ is the most common KEDA misconfiguration.
Step 4 โ Bound maxReplicaCount by the Real Constraint
Kubernetes is rarely the limit. A downstream database, a connection pool, or a third-party quota usually is, and an autoscaler that ignores them converts a queue backlog into a downstream outage.
def max_replicas(db_max_connections: int, conns_per_worker: int,
api_quota_rps: float, job_rps_per_worker: float) -> int:
by_db = (db_max_connections * 0.7) // conns_per_worker # leave 30% for the app
by_api = api_quota_rps / job_rps_per_worker
return int(min(by_db, by_api))
max_replicas(200, conns_per_worker=10, api_quota_rps=100, job_rps_per_worker=5)
# โ 14 (API-bound, not database-bound)
Record which constraint bound the number. When someone later asks to raise maxReplicaCount, the answer is either "raise the quota first" or "yes", and the difference matters.
Step 5 โ Handle Scale-to-Zero Carefully
minReplicaCount: 0 is attractive for rare batch queues and dangerous for anything latency-sensitive: KEDA's polling interval plus pod scheduling plus application start-up is typically 30โ90 seconds of cold start on the first message.
minReplicaCount: 0
cooldownPeriod: 600 # keep the last pod for 10 minutes after idle
triggers:
- type: aws-sqs-queue
metadata:
queueURL: https://sqs.eu-west-1.amazonaws.com/1234/nightly-reports
queueLength: "5"
awsRegion: eu-west-1
scaleOnInFlight: "false" # do not count in-flight toward scale-up
Use it for nightly batch, never for a queue with an interactive SLO. scaleOnInFlight: false matters when scaling to zero: counting in-flight messages keeps a replica alive that has nothing waiting for it.
Step 6 โ Make Scale-Down a Clean Drain
spec:
template:
spec:
terminationGracePeriodSeconds: 120 # > p99 job duration
containers:
- name: worker
lifecycle:
preStop:
exec:
command: ["sh", "-c", "kill -TSTP 1 && sleep 5"]
Step 7 โ Stop Oscillation Before It Starts
Oscillation โ the fleet growing and shrinking every few minutes โ wastes capacity, multiplies redeliveries, and makes every latency graph unreadable. It has three usual causes and each has a specific fix.
The first is a target value close to steady-state depth, so normal jitter crosses the threshold repeatedly. Set the target so that steady-state depth sits well below one replica's worth, and let the stabilisation window absorb the rest.
The second is scaling down faster than the work drains. A pod removed mid-job releases its messages back to the queue, depth rises, and the autoscaler adds a replica โ a loop that is entirely self-inflicted. The 300-second scaleDown.stabilizationWindowSeconds plus a generous grace period is the fix, and it is why those two settings must be tuned together rather than separately.
The third is competing autoscalers. A ScaledObject and a hand-written HPA on the same Deployment fight, each undoing the other's decision every polling interval. KEDA creates and owns its own HPA; delete any pre-existing one.
# Watch a burst end-to-end: the desired replica count is what KEDA computed
kubectl get scaledobject worker-media -w
kubectl get hpa keda-hpa-worker-media -w
kubectl describe scaledobject worker-media | tail -20 # last scaling decisions
Verification
- A burst scales up within a minute. Inject 20,000 messages; replicas should begin rising within one polling interval and reach the computed target within a couple of minutes.
- Depth drains at the expected rate. Time-to-drain should approximate
depth รท (replicas ร concurrency รท p50); a large discrepancy means the target value is wrong. - Scale-down is clean. During the wind-down, the redelivery counter must not move.
- No oscillation. Over an hour of steady traffic, replica count should change no more than a few times.
Gotchas & Edge Cases
RabbitMQ queueLength excludes unacked messages by default. A fleet with a large prefetch holds much of the backlog invisibly, so KEDA under-scales. Use mode: QueueLength with value sized accordingly, or scale on messageRate.
Celery's Redis keys are not always plain lists. Priority emulation appends a suffix to the key name, so listName: celery misses messages on celery\x06\x163. Verify with redis-cli --scan --pattern 'celery*'.
KEDA scaling to zero hides the queue from your alerts. With no consumer, a stuck message produces no worker logs at all. Keep the oldest-age alert running independently of replica count.
Multiple triggers take the maximum, not the sum. A ScaledObject with both a depth trigger and an age trigger scales to whichever demands more replicas, which is usually what you want โ but it also means a misconfigured trigger can hold the fleet high on its own. Check each trigger's computed target separately when a fleet refuses to scale down.
Cluster autoscaling adds another minute. If scaling replicas requires new nodes, real scale-up latency is KEDA's interval plus node provisioning. Keep headroom or use overprovisioning pods.
Scaling on age needs the Prometheus scaler and a different target. Depth-based targets scale with throughput; age-based targets encode a latency objective directly, which is usually what the SLO actually says. A trigger of max(queue_oldest_message_age_seconds) > 30 with a target of 30 adds replicas whenever the head of the queue is older than the objective allows, and it behaves correctly for slow jobs where depth is a poor proxy. The cost is a dependency on Prometheus being available for scaling decisions, so keep a depth-based trigger alongside it as a fallback โ KEDA takes the maximum across triggers.
The metric is polled, not streamed. With pollingInterval: 30 the autoscaler is up to thirty seconds behind reality. For bursty interactive queues, poll every 10โ15 seconds and accept the extra broker load.
Related
- Horizontal Worker Scaling โ the scaling model and capacity arithmetic behind these settings.
- Auto-scaling Celery workers on Kubernetes โ the Celery-specific metrics and manifests.
- Zero-downtime worker deploys on Kubernetes โ the drain that every scale-down depends on.
- SLOs & Alerting for Job Queues โ deriving the utilisation ceiling that bounds the target value.