Worker Services

Build a queue consumer on Host.CreateApplicationBuilder with no HTTP, give each message its own DI scope, and run it in a container.

Syntaxvar builder = Host.CreateApplicationBuilder(args); builder.Services.AddHostedService<OrderPlacedConsumer>(); await builder.Build().RunAsync();

A worker service is a .NET process with a host but no web server: a queue consumer, an outbox relay, a nightly export. It gets the same DI, configuration, logging and graceful shutdown as an API, without Kestrel, routing or an open port.

The template

dotnet new worker -n Orders.Worker creates a project on the Microsoft.NET.Sdk.Worker SDK with a package reference to Microsoft.Extensions.Hosting, a Worker class deriving from BackgroundService, and this Program.cs:

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();

Configuration works as in an API: appsettings.json, appsettings.{Environment}.json, environment variables, command line. The environment comes from DOTNET_ENVIRONMENT; a worker ignores ASPNETCORE_ENVIRONMENT, so a manifest copied from the API leaves the worker running as Production with the wrong settings file.

Anatomy of a consumer

  • One loop in ExecuteAsync: receive, process, acknowledge, repeat until stoppingToken is cancelled.
  • One DI scope per message, created with IServiceScopeFactory.CreateAsyncScope(). The worker is a singleton; a DbContext injected into its constructor would live for days and grow a change tracker without limit.
  • Acknowledge after success. Brokers redeliver unacknowledged messages, so a crash means a retry, not a loss. That makes delivery at-least-once, and handlers must be idempotent.
  • Per-message error handling. Catch, log and dead-letter a poison message. An exception that escapes ExecuteAsync applies BackgroundServiceExceptionBehavior.StopHost (the default): the host logs it and stops the whole process, which is better than a worker that silently stopped consuming while the pod looks healthy.

Running it in a container

QuestionAPIWorker
Base imageaspnet:10.0runtime:10.0 (no ASP.NET Core)
Port and Service8080, a Kubernetes ServiceNone
HealthHTTP probesProcess exit plus restart, or a small health endpoint
ScalingCPU or request rateQueue depth; replicas compete for messages

The decision teams face is health checking. A crashed worker exits and is restarted, but a stuck one (a deadlocked loop, a lost broker connection) looks alive. If that matters, host the worker in a WebApplication that only maps /health endpoints on a port, use aspnet:10.0, and keep everything else the same.

For systemd or Windows Services, the Microsoft.Extensions.Hosting.Systemd and Microsoft.Extensions.Hosting.WindowsServices packages add AddSystemd() and AddWindowsService(), which wire the host lifetime to the service manager.

What goes wrong in production

  • A catch with no delay around a broker that is down: a hot loop at 100% CPU writing a log line per iteration. Back off.
  • Ignoring stoppingToken while waiting for work: the worker never stops and is SIGKILLed on every deploy. Passing it into the message's own work abandons that message instead; give it a bounded token.
  • A captive scoped service in the worker's constructor, caught only by ValidateScopes in Development.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Consume OrderPlaced messages from SQS and write invoices with a scoped repository per message, with no web server in the process.
  • Deploy an outbox relay as a two-replica Deployment on the runtime:10.0 image with DOTNET_ENVIRONMENT and no Service.
  • Add a liveness endpoint to a shipment consumer so Kubernetes restarts it when the polling loop stops making progress.

More examples

Dockerfile for a worker

There is no <code>EXPOSE</code> because nothing listens. The environment name is not baked in: the Deployment sets <code>DOTNET_ENVIRONMENT</code>, and when it is unset the host runs as Production. The <code>runtime:10.0</code> image is smaller than <code>aspnet:10.0</code> and has less to patch; switch to <code>aspnet</code> only if the worker hosts health endpoints with Kestrel. <code>USER $APP_UID</code> runs as the non-root <code>app</code> user (UID 1654) that the official images include.

Example · dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY Orders.Worker/Orders.Worker.csproj Orders.Worker/
RUN dotnet restore Orders.Worker/Orders.Worker.csproj
COPY . .
RUN dotnet publish Orders.Worker/Orders.Worker.csproj --no-restore -o /app

# runtime, not aspnet: a worker does not need the ASP.NET Core shared framework.
FROM mcr.microsoft.com/dotnet/runtime:10.0
WORKDIR /app
COPY --from=build /app .
# No ENV DOTNET_ENVIRONMENT here: the Deployment sets it (env: DOTNET_ENVIRONMENT),
# so one image runs as Staging in staging. Unset, the host defaults to Production.
USER $APP_UID
ENTRYPOINT ["dotnet", "Orders.Worker.dll"]

Kubernetes Deployment with no Service

No ports, no Service, no HTTP probes: if the process exits, Kubernetes restarts it. The two replicas compete for messages on the same queue. The grace period is longer than the API's because a message in progress may take longer than a request, and the shutdown timeout is set below it so the host finishes before SIGKILL.

Example · yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders-worker
  namespace: shop
spec:
  replicas: 2
  selector:
    matchLabels:
      app: orders-worker
  template:
    metadata:
      labels:
        app: orders-worker
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: worker
          image: registry.example.com/shop/orders-worker:2.3.0
          env:
            - name: DOTNET_ENVIRONMENT
              value: Production
            - name: DOTNET_SHUTDOWNTIMEOUTSECONDS
              value: "45"
            - name: Broker__QueueName
              value: orders-placed
          resources:
            requests:
              cpu: 100m
              memory: 192Mi
            limits:
              memory: 384Mi

A worker that also answers health probes

A pure worker can only signal trouble by exiting. A consumer stuck on a dead connection keeps running and looks healthy. Hosting it in a slim <code>WebApplication</code> with one liveness endpoint, backed by a heartbeat the loop updates, lets Kubernetes restart a stuck instance. The trade-off is the <code>aspnet</code> base image and an open port.

Example · csharp
using Microsoft.Extensions.Diagnostics.HealthChecks;

var builder = WebApplication.CreateSlimBuilder(args);

builder.Services.AddSingleton<ConsumerHeartbeat>();
builder.Services.AddHostedService<ShipmentConsumer>();
builder.Services.AddHealthChecks()
    .AddCheck<ConsumerHeartbeatCheck>("consumer", tags: ["live"]);

var app = builder.Build();

// The only HTTP surface: probes on the pod port, not exposed through a Service.
app.MapHealthChecks("/health/live");

app.Run();

sealed class ConsumerHeartbeat
{
    private long _lastBeatTicks = DateTimeOffset.UtcNow.UtcTicks;
    public void Beat() => Interlocked.Exchange(ref _lastBeatTicks, DateTimeOffset.UtcNow.UtcTicks);
    public TimeSpan SinceLastBeat => DateTimeOffset.UtcNow - new DateTimeOffset(Interlocked.Read(ref _lastBeatTicks), TimeSpan.Zero);
}

sealed class ConsumerHeartbeatCheck(ConsumerHeartbeat heartbeat) : IHealthCheck
{
    public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken ct) =>
        Task.FromResult(heartbeat.SinceLastBeat < TimeSpan.FromMinutes(2)
            ? HealthCheckResult.Healthy()
            : HealthCheckResult.Unhealthy($"No poll for {heartbeat.SinceLastBeat.TotalSeconds:F0}s"));
}

sealed class ShipmentConsumer(ConsumerHeartbeat heartbeat) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            heartbeat.Beat();   // a loop that hangs stops beating and fails the liveness probe
            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }
}

Discussion

  • Be the first to comment on this lesson.