Keegan Ott / dreekoSlop

.NET · RabbitMQ · KEDA · · By · 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.

A waiting message queue beside a powered-down worker station ready to wake
The useful test is not whether it reaches zero; it is whether it wakes, acknowledges and stops safely.

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.