Toute application qui dépasse le stade « API HTTP basique » finit par avoir besoin de traitements de fond : envoi d’emails, traitement de files de messages, synchronisations périodiques, calculs batch nocturnes, écoute d’événements. Dans le monde .NET, le template Worker Service apporté par .NET 6 et consolidé en .NET 10 est l’outil dédié. Il s’appuie sur l’interface IHostedService et la classe BackgroundService, hérite de toute l’infrastructure (DI, logging, configuration, health checks), et se déploie aussi bien en service systemd qu’en conteneur Kubernetes. Ce tutoriel reprend la mise en place complète d’un Worker Service en production : structure, hosted services, scheduling, intégration avec une queue, et observabilité.
Prérequis
- .NET 10 LTS installé
- Notions C# et asynchrone (cf. C# 14 features)
- Optionnel pour les exemples : RabbitMQ, Redis, ou Azure Service Bus
- Temps estimé : 90 minutes
Étape 1 — Créer un projet Worker Service
Le template officiel worker bootstrappe un projet console avec Microsoft.Extensions.Hosting, un Worker.cs minimal, et toute la plomberie DI/logging déjà configurée. C’est le point de départ standard pour tout service d’arrière-plan en 2026.
# Créer un nouveau Worker Service
dotnet new worker -n MonService.Workers
cd MonService.Workers
# Structure générée
# - Program.cs (point d'entrée avec HostBuilder)
# - Worker.cs (BackgroundService minimal)
# - appsettings.json
# - appsettings.Development.json
Le fichier Program.cs utilise le nouveau Minimal Hosting depuis .NET 6 : on construit un HostApplicationBuilder, on enregistre les services dans la DI, et on lance avec Run(). Toute la configuration (variables d’environnement, fichiers appsettings, secrets utilisateur) est branchée automatiquement.
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
builder.Services.AddHttpClient();
builder.Services.AddSingleton<IClock, SystemClock>();
var host = builder.Build();
host.Run();
Étape 2 — Implémenter un BackgroundService de base
La classe BackgroundService abstraite expose une méthode ExecuteAsync(CancellationToken stoppingToken) à overrider. Le stoppingToken est signalé quand le service reçoit un signal d’arrêt (SIGTERM en Linux, Ctrl+C en dev, arrêt SCM en Windows). Respecter ce token est crucial pour un arrêt propre.
public class Worker(ILogger<Worker> logger, IClock clock) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Worker démarré à {time}", clock.UtcNow);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await TraiterAsync(stoppingToken);
}
catch (OperationCanceledException)
{
// arrêt demandé, sortie propre
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Erreur dans le worker");
}
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
logger.LogInformation("Worker arrêté proprement");
}
private async Task TraiterAsync(CancellationToken ct)
{
// Logique métier ici
logger.LogInformation("Traitement à {time}", clock.UtcNow);
await Task.CompletedTask;
}
}
Trois disciplines critiques. Le try/catch (Exception ex) à l’intérieur de la boucle empêche qu’une seule erreur tue le worker. Le OperationCanceledException est traité séparément comme un signal légitime d’arrêt. Le Task.Delay(..., stoppingToken) permet une interruption immédiate du sleep quand l’arrêt est demandé — sans le token, on attendrait 30 secondes inutiles avant de finir.
Étape 3 — Périodicité avec PeriodicTimer
Pour des tâches strictement périodiques, PeriodicTimer (introduit en .NET 6, amélioré en .NET 10) offre une API plus expressive que Task.Delay : la prochaine itération démarre immédiatement après la précédente plutôt qu’attendre la fin + 30 s.
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await SynchroniserAsync(stoppingToken);
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
logger.LogError(ex, "Synchro échouée");
}
}
}
L’avantage : si SynchroniserAsync prend 45 secondes, la prochaine exécution démarrera 15 secondes après (1 minute – 45 s), pas 1 minute + 45 s plus tard. Pour les workers qui doivent maintenir une cadence stable, c’est le bon outil. Pour des tâches très espacées ou ponctuelles, Task.Delay reste suffisant.
Étape 4 — Plusieurs workers parallèles avec Channel
Pour traiter une file de messages avec plusieurs consommateurs parallèles dans le même processus, System.Threading.Channels.Channel<T> est la primitive idiomatique. Elle remplace les anciennes BlockingCollection avec une API entièrement async et un backpressure naturel.
public class JobQueue
{
private readonly Channel<Job> _channel =
Channel.CreateBounded<Job>(new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = false,
SingleWriter = false
});
public ChannelReader<Job> Reader => _channel.Reader;
public ChannelWriter<Job> Writer => _channel.Writer;
}
public class JobConsumer(JobQueue queue, ILogger<JobConsumer> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var job in queue.Reader.ReadAllAsync(stoppingToken))
{
try { await TraiterJob(job, stoppingToken); }
catch (Exception ex) { logger.LogError(ex, "Échec job {id}", job.Id); }
}
}
}
// Enregistrer plusieurs consommateurs dans Program.cs
builder.Services.AddSingleton<JobQueue>();
builder.Services.AddHostedService<JobConsumer>();
builder.Services.AddHostedService<JobConsumer>();
builder.Services.AddHostedService<JobConsumer>();
Le BoundedChannel à 1000 places applique du backpressure : si la queue est pleine, le producteur attend que les consommateurs vident. Cette discipline évite les explosions mémoire en cas de surcharge brutale. Pour scaler horizontalement (plusieurs processus), basculer vers RabbitMQ, Azure Service Bus, ou AWS SQS reste nécessaire.
Étape 5 — Health checks pour Kubernetes
Un worker en production doit exposer un endpoint HTTP de health check pour que Kubernetes ou Docker Compose détecte les blocages et redémarre proprement. Pour cela, on ajoute Kestrel + une route minimale à côté du BackgroundService.
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
builder.Services.AddHealthChecks()
.AddCheck<WorkerHealthCheck>("worker");
// Ajouter Kestrel pour exposer /health
builder.Services.AddSingleton<WorkerStatus>();
var app = WebApplication.CreateBuilder(args).Build(); // alternative HostingMode
// ou via WebApplicationBuilder pour les workers récents .NET 10
var webBuilder = WebApplication.CreateBuilder(args);
webBuilder.Services.AddHostedService<Worker>();
webBuilder.Services.AddHealthChecks();
var webApp = webBuilder.Build();
webApp.MapHealthChecks("/health");
webApp.MapGet("/", () => "Worker actif");
webApp.Run();
Le WorkerHealthCheck custom expose la dernière activité du worker. Si plus de 5 minutes se sont écoulées depuis le dernier traitement, on renvoie Unhealthy et Kubernetes redémarre le pod. Pour les workers à cadence longue (cron quotidien), on adapte le seuil — sinon le pod redémarre en boucle.
Étape 6 — Intégration avec une queue distante (RabbitMQ)
Pour traiter une file distante en mode push, on connecte le worker à un broker. RabbitMQ via RabbitMQ.Client 7.x reste un standard, mais Azure Service Bus, AWS SQS, ou MassTransit en façade fonctionnent identiquement.
public class RabbitConsumer : BackgroundService
{
private IConnection? _conn;
private IChannel? _channel;
public override async Task StartAsync(CancellationToken ct)
{
var factory = new ConnectionFactory
{
HostName = "rabbitmq",
UserName = "user",
Password = "pass"
};
_conn = await factory.CreateConnectionAsync(ct);
_channel = await _conn.CreateChannelAsync(cancellationToken: ct);
await _channel.QueueDeclareAsync("emails", durable: true, exclusive: false, autoDelete: false);
await base.StartAsync(ct);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var consumer = new AsyncEventingBasicConsumer(_channel!);
consumer.ReceivedAsync += async (_, ea) =>
{
try
{
var body = Encoding.UTF8.GetString(ea.Body.Span);
await TraiterMessage(body);
await _channel!.BasicAckAsync(ea.DeliveryTag, multiple: false);
}
catch
{
await _channel!.BasicNackAsync(ea.DeliveryTag, false, requeue: true);
}
};
await _channel!.BasicConsumeAsync("emails", autoAck: false, consumer: consumer);
await Task.Delay(Timeout.Infinite, stoppingToken);
}
}
Le BasicAck manuel après traitement réussi garantit qu’un message non traité (crash, exception) reste dans la queue et est rejoué après redémarrage. Le BasicNack avec requeue: true remet le message en file. Pour des messages empoisonnés (qui échouent toujours), on configure une dead-letter queue côté RabbitMQ et on les écarte après N retries.
Étape 7 — Déploiement Docker et systemd
Un Worker Service se conteneurise comme une API. Le Dockerfile standard de .NET fonctionne tel quel — l’image résultante pèse ~80 Mo (runtime + binaire). Pour un service systemd, le SDK fournit une cible publish dédiée.
# Dockerfile multi-stage
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY *.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS runtime
WORKDIR /app
COPY --from=build /app .
ENV DOTNET_ENVIRONMENT=Production
ENTRYPOINT ["dotnet", "MonService.Workers.dll"]
Pour systemd, la classe UseSystemd() ajoute le support des signaux Unix natifs et la gestion du logging via journald. Une unit file minimale (/etc/systemd/system/monworker.service) avec Type=notify et Restart=on-failure assure le redémarrage automatique sur crash.
Erreurs fréquentes
| Symptôme | Cause | Solution |
|---|---|---|
| Worker s’arrête immédiatement | Pas de boucle ou exception non capturée | Encapsuler ExecuteAsync dans try/catch et logger |
| Service ne reçoit pas SIGTERM | Token stoppingToken non respecté | Passer le token à tous les appels await |
| DbContext jeté avant Worker terminé | Scope par défaut singleton | Créer un scope explicite : using var scope = sp.CreateScope() |
| RabbitMQ déconnexions silencieuses | AutomaticRecoveryEnabled désactivé | Activer dans ConnectionFactory + handler ConnectionShutdown |
| Mémoire qui croît | HttpClient instancié à chaque itération | Utiliser IHttpClientFactory |
| Logs Docker invisibles | Buffering stdout | Ajouter DOTNET_LOGGING_FORMATTERS__JSON=true ou flush explicite |
Foire aux questions
Worker Service ou Hangfire/Quartz.NET ?
Worker Service pour les tâches continues ou périodiques simples. Hangfire/Quartz pour scheduling complexe (cron expressions, retries persistents, dashboard).
Combien d’instances par Worker ?
Dépend de la queue. Si single-consumer (séquentiel), 1. Si parallèle scaling, 1 pod par 100-500 messages/seconde + autoscale Kubernetes HPA sur CPU/queue depth.
Quel impact GC ?
Server GC par défaut sur .NET 10 (haut débit). Pour les workers très allocant, activer <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection> et profiler avec dotnet-counters.
Comment tester un BackgroundService ?
Instancier directement (pas via Host) en test, passer un CancellationTokenSource, attendre quelques itérations, asserter sur les effets. Pour les hosted complets, WebApplicationFactory + custom Host.
AOT compatible ?
Oui depuis .NET 8. Avec quelques restrictions sur la réflexion et certains providers. <PublishAot>true</PublishAot> dans le csproj donne un binaire 5-15 Mo qui démarre en < 50 ms.
Pour aller plus loin
Le worker en place, l’étape suivante est gRPC pour les communications inter-services ou BenchmarkDotNet pour mesurer la performance. Vue panoramique : C# et .NET moderne.