.NET · RabbitMQ · KEDA · · By Keegan Ott · 4 min read
Scale a .NET RabbitMQ worker to zero with KEDA
This is the smallest version I would trust as a starting point: manual acknowledgement after processing, bounded prefetch, a cancellable consumer, and credentials kept out of the ScaledObject.
1. Create the worker
dotnet new worker -n QueueWorker
cd QueueWorker
dotnet add package RabbitMQ.Client --version 7.*
Set RABBITMQ_URI and QUEUE_NAME through
configuration. The queue should already exist; I prefer topology to be
owned by deployment rather than whichever worker happens to start
first.
2. Consume with an acknowledgement boundary
public sealed class Worker(IConfiguration config) : BackgroundService
{
IConnection? connection; IChannel? channel; string? consumerTag;
protected override async Task ExecuteAsync(CancellationToken stop)
{
var factory = new ConnectionFactory {
Uri = new(config["RABBITMQ_URI"]!),
AutomaticRecoveryEnabled = true,
ClientProvidedName = "queue-worker"
};
connection = await factory.CreateConnectionAsync(stop);
channel = await connection.CreateChannelAsync(cancellationToken: stop);
await channel.BasicQosAsync(0, 8, false, stop);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (_, delivery) => {
var body = delivery.Body.ToArray(); // copy before handler returns
try {
await ProcessAsync(body, stop);
await channel.BasicAckAsync(delivery.DeliveryTag, false, stop);
} catch (OperationCanceledException) when (stop.IsCancellationRequested) {
await channel.BasicNackAsync(delivery.DeliveryTag, false, true);
}
};
consumerTag = await channel.BasicConsumeAsync(
config["QUEUE_NAME"]!, false, consumer, stop);
await Task.Delay(Timeout.Infinite, stop);
}
public override async Task StopAsync(CancellationToken token) {
if (channel is not null && consumerTag is not null)
await channel.BasicCancelAsync(consumerTag, cancellationToken: token);
await base.StopAsync(token);
if (channel is not null) await channel.DisposeAsync();
if (connection is not null) await connection.DisposeAsync();
}
}
Keep ProcessAsync idempotent. A connection can fail after
the work commits but before RabbitMQ receives the acknowledgement, so
redelivery is normal. RabbitMQ Client 7 also requires copying or
deserialising delivery.Body before the handler returns.
3. Deploy one worker, initially asleep
apiVersion: apps/v1
kind: Deployment
metadata: { name: queue-worker }
spec:
replicas: 0
selector: { matchLabels: { app: queue-worker } }
template:
metadata: { labels: { app: queue-worker } }
spec:
terminationGracePeriodSeconds: 45
containers:
- name: worker
image: registry.example/queue-worker:1.0.0
env:
- { name: QUEUE_NAME, value: work.items }
- name: RABBITMQ_URI
valueFrom: { secretKeyRef: { name: rabbitmq-worker, key: uri } }
4. Give KEDA separate broker credentials
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata: { name: rabbitmq-scaler }
spec:
secretTargetRef:
- { parameter: host, name: rabbitmq-keda, key: uri }
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata: { name: queue-worker }
spec:
scaleTargetRef: { name: queue-worker }
minReplicaCount: 0
maxReplicaCount: 10
pollingInterval: 5
cooldownPeriod: 60
triggers:
- type: rabbitmq
metadata:
protocol: amqp
queueName: work.items
mode: QueueLength
value: "8"
activationValue: "1"
authenticationRef: { name: rabbitmq-scaler }
value: "8" means roughly one replica per eight ready
messages. It matches the consumer prefetch here only as a starting
hypothesis; tune it from processing time and backlog age. AMQP queue
length does not count unacknowledged deliveries. KEDA’s HTTP mode can,
but requires the management endpoint and different credentials.
5. Prove the shutdown path
kubectl apply -f deployment.yaml -f scaling.yaml
kubectl get pods -w
# publish 25 test messages, then while work is active:
kubectl delete pod -l app=queue-worker
# verify interrupted work is redelivered and processed once logically
I also test a poison message, broker restart, a backlog beyond
maxReplicaCount, and a scale-down while every consumer
has unacknowledged work. Watch queue ready/unacked counts, processing
latency, redeliveries and pod termination time. CPU is not the demand
signal here; the queue is.
What I would change for production
Add a dead-letter policy, readiness tied to the subscription,
OpenTelemetry spans carrying message IDs, a proper idempotency store,
PodDisruptionBudget where minimum availability matters, and TLS. Keep
scaler credentials read-only. If startup is slow or the first message
is latency-sensitive, use minReplicaCount: 1;
scale-to-zero is a trade, not a badge.
API details checked against the current KEDA RabbitMQ scaler, RabbitMQ .NET client, and .NET Worker Service documentation.