Nem todo processo .NET precisa de um servidor HTTP. Quando você precisa processar filas, executar tarefas agendadas, sincronizar dados ou monitorar recursos em background, carregar o ASP.NET Core inteiro é desperdício. O Worker Service existe exatamente para isso: uma aplicação .NET leve, focada em background processing, sem o pipeline HTTP de controllers, middlewares e roteamento.
Este guia mostra como construir Worker Services de produção — desde a diferença entre IHostedService e BackgroundService, passando por deploy como serviço Windows e systemd, até health checks sem depender de HTTP.
IHostedService vs BackgroundService
O ponto de entrada para qualquer background task no .NET é a interface IHostedService:
public interface IHostedService
{
Task StartAsync(CancellationToken cancellationToken);
Task StopAsync(CancellationToken cancellationToken);
}
O host chama StartAsync ao iniciar e StopAsync quando recebe sinal de encerramento (SIGTERM, Ctrl+C, Windows Service stop). A implementação mais comum é via classe abstrata BackgroundService, que gerencia o ciclo de vida do loop:
public abstract class BackgroundService : IHostedService, IDisposable
{
private Task? _executingTask;
private CancellationTokenSource? _stoppingCts;
public virtual Task StartAsync(CancellationToken cancellationToken)
{
_stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_executingTask = ExecuteAsync(_stoppingCts.Token);
return _executingTask.IsCompleted ? _executingTask : Task.CompletedTask;
}
protected abstract Task ExecuteAsync(CancellationToken stoppingToken);
public virtual async Task StopAsync(CancellationToken cancellationToken)
{
if (_executingTask == null) return;
_stoppingCts!.Cancel();
await Task.WhenAny(_executingTask, Task.Delay(Timeout.Infinite, cancellationToken));
}
}
Use IHostedService diretamente quando precisar de controle fino sobre o StartAsync (por exemplo, registrar consumidores de fila antes de sinalizar que o host está pronto). Use BackgroundService na grande maioria dos casos — você implementa apenas ExecuteAsync e o runtime cuida do resto.
Criando o projeto
O template de Worker Service cria um projeto sem o pacote Microsoft.AspNetCore.App, usando apenas o host genérico:
dotnet new worker -n MeuWorker
cd MeuWorker
O Program.cs gerado usa CreateDefaultBuilder:
var builder = Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
services.AddHostedService<Worker>();
});
await builder.Build().RunAsync();
Ou, com o estilo moderno de CreateApplicationBuilder disponível a partir do .NET 7:
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();
A diferença prática: CreateApplicationBuilder usa a nova API de configuração unificada (sem a "magia" do WebApplication.CreateBuilder), é levemente mais rápido e alinha-se com a direção futura do .NET. Para novos projetos, prefira-o.
Worker genérico: processamento de fila
O cenário mais comum: ler mensagens de uma fila e processá-las em loop. Veja um worker que consome de um Channel<T> interno (ou substitua por RabbitMQ, Azure Service Bus, etc.):
public sealed class RelatorioWorker : BackgroundService
{
private readonly ILogger<RelatorioWorker> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly Channel<RelatorioRequest> _channel;
public RelatorioWorker(
ILogger<RelatorioWorker> logger,
IServiceScopeFactory scopeFactory,
Channel<RelatorioRequest> channel)
{
_logger = logger;
_scopeFactory = scopeFactory;
_channel = channel;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Worker iniciado");
await foreach (var request in _channel.Reader.ReadAllAsync(stoppingToken))
{
await ProcessarAsync(request, stoppingToken);
}
_logger.LogInformation("Worker encerrado com segurança");
}
private async Task ProcessarAsync(RelatorioRequest request, CancellationToken ct)
{
// Crie um scope por mensagem para obter serviços Scoped (DbContext, etc.)
await using var scope = _scopeFactory.CreateAsyncScope();
var relatorioService = scope.ServiceProvider.GetRequiredService<IRelatorioService>();
try
{
await relatorioService.GerarAsync(request, ct);
_logger.LogInformation("Relatório {Id} gerado com sucesso", request.Id);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Falha ao gerar relatório {Id}", request.Id);
// Aqui: enviar para DLQ, incrementar contador de retries, etc.
}
}
}
Note o uso de IServiceScopeFactory: workers são Singleton (vivem junto com o host), mas serviços como DbContext são Scoped. Injetar um DbContext diretamente num worker causa o famoso erro "Cannot consume scoped service from singleton". Crie sempre um escopo por unidade de trabalho.
Worker com schedule: Quartz vs PeriodicTimer
Para tarefas periódicas, o .NET 6+ trouxe PeriodicTimer — uma alternativa idiomática ao Task.Delay em loop:
public sealed class LimpezaWorker : BackgroundService
{
private readonly ILogger<LimpezaWorker> _logger;
private readonly IServiceScopeFactory _scopeFactory;
public LimpezaWorker(ILogger<LimpezaWorker> logger, IServiceScopeFactory scopeFactory)
{
_logger = logger;
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var removidos = await db.Logs
.Where(l => l.CriadoEm < DateTime.UtcNow.AddDays(-30))
.ExecuteDeleteAsync(stoppingToken);
_logger.LogInformation("{Count} logs removidos", removidos);
}
}
}
Vantagens do PeriodicTimer sobre Task.Delay: não há drift (o intervalo é absoluto, não relativo ao fim do processamento) e o CancellationToken encerra o loop sem lançar exceção — WaitForNextTickAsync simplesmente retorna false.
Para schedules mais complexos (tipo cron: "toda segunda às 8h", "primeiro dia do mês"), a biblioteca Quartz.NET com a extensão Quartz.Extensions.Hosting é a solução mais madura do ecossistema.
Deploy como serviço Windows
Adicione o pacote:
dotnet add package Microsoft.Extensions.Hosting.WindowsServices
E habilite no builder:
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddWindowsService(options =>
{
options.ServiceName = "Neryx Worker";
});
builder.Services.AddHostedService<RelatorioWorker>();
Publique o executável e instale com sc.exe:
# Publicar
dotnet publish -c Release -r win-x64 --self-contained -o C:\Services\MeuWorker
# Instalar
sc.exe create NeryxWorker binPath="C:\Services\MeuWorker\MeuWorker.exe"
sc.exe start NeryxWorker
# Parar e remover
sc.exe stop NeryxWorker
sc.exe delete NeryxWorker
Com AddWindowsService, o host responde corretamente ao SCM (Service Control Manager): START, STOP e PAUSE são roteados para StartAsync/StopAsync do host. O graceful shutdown respeita o HostOptions.ShutdownTimeout configurado.
Deploy como serviço Linux (systemd)
Adicione o pacote:
dotnet add package Microsoft.Extensions.Hosting.Systemd
Habilite:
builder.UseSystemd(); // ou Services.AddSystemd()
Crie o arquivo de unit em /etc/systemd/system/meuworker.service:
[Unit]
Description=Neryx Worker Service
After=network.target
[Service]
Type=notify
ExecStart=/opt/meuworker/MeuWorker
Restart=always
RestartSec=5
User=meuworker
WorkingDirectory=/opt/meuworker
Environment=DOTNET_ENVIRONMENT=Production
Environment=DOTNET_GCConserveMemory=9
[Install]
WantedBy=multi-user.target
O Type=notify é importante: com UseSystemd(), o host envia sd_notify("READY=1") ao systemd quando todos os IHostedService terminam o StartAsync. Isso significa que o systemctl start meuworker só retorna depois que o worker está realmente pronto para processar.
sudo systemctl daemon-reload
sudo systemctl enable meuworker
sudo systemctl start meuworker
sudo systemctl status meuworker
Health checks sem HTTP
Workers não têm endpoints HTTP por padrão, mas ainda precisam expor saúde para Kubernetes, balanceadores e ferramentas de monitoramento. A solução: health checks via arquivo em disco ou porta TCP mínima.
Opção 1: arquivo de heartbeat (liveness probe)
public sealed class HeartbeatWorker : BackgroundService
{
private readonly string _healthFilePath = "/tmp/worker-healthy";
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
// Escreve timestamp; ausência do arquivo = unhealthy
await File.WriteAllTextAsync(
_healthFilePath,
DateTime.UtcNow.ToString("O"),
stoppingToken);
}
// Ao encerrar, remove o arquivo
if (File.Exists(_healthFilePath))
File.Delete(_healthFilePath);
}
}
No Kubernetes, configure o probe:
livenessProbe:
exec:
command:
- test
- -f
- /tmp/worker-healthy
initialDelaySeconds: 10
periodSeconds: 60
failureThreshold: 3
Opção 2: endpoint HTTP mínimo via ASP.NET Health Checks
Se você quiser um endpoint /health real sem expor toda a infraestrutura HTTP, adicione apenas o pacote de health checks:
builder.Services.AddHealthChecks()
.AddCheck<FilaHealthCheck>("fila")
.AddNpgSql(builder.Configuration.GetConnectionString("Default")!);
// Expõe /health numa porta separada (ex: 8080) sem controllers
builder.Services.AddHostedService<HealthCheckHttpWorker>();
Existe também o pacote AspNetCore.HealthChecks.UI que funciona sem o pipeline completo do ASP.NET, mas para a maioria dos casos o arquivo de heartbeat é suficiente e muito mais simples.
Múltiplos workers em paralelo
Registre múltiplos AddHostedService — o host os inicia em paralelo:
builder.Services.AddHostedService<ProcessadorPedidosWorker>();
builder.Services.AddHostedService<ProcessadorNotificacoesWorker>();
builder.Services.AddHostedService<HeartbeatWorker>();
builder.Services.AddHostedService<MetricsWorker>();
Para escalar horizontalmente o mesmo worker (múltiplas instâncias do mesmo tipo), use um factory pattern:
// Registra 3 instâncias do mesmo worker em partições diferentes
for (int i = 0; i < 3; i++)
{
var particao = i;
builder.Services.AddSingleton<IHostedService>(sp =>
{
var logger = sp.GetRequiredService<ILogger<FilaWorker>>();
var scopeFactory = sp.GetRequiredService<IServiceScopeFactory>();
return new FilaWorker(logger, scopeFactory, particao);
});
}
Graceful shutdown e draining
O ponto crítico em qualquer worker de produção: ao receber SIGTERM, o host cancela o stoppingToken e aguarda o HostOptions.ShutdownTimeout (padrão: 5 segundos). Se o processamento de uma mensagem demorar mais que isso, a tarefa é abortada.
Configure um timeout maior para workers que processam tarefas longas:
builder.Services.Configure<HostOptions>(options =>
{
options.ShutdownTimeout = TimeSpan.FromSeconds(30);
});
No worker, implemente o draining adequado:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await foreach (var msg in _channel.Reader.ReadAllAsync(stoppingToken))
{
// stoppingToken cancelado: ReadAllAsync encerra o foreach
// O item atual será processado até o fim (não usa stoppingToken internamente)
await ProcessarAsync(msg, CancellationToken.None); // <-- sem timeout individual
}
}
catch (OperationCanceledException)
{
// Normal: host foi encerrado
_logger.LogInformation("Worker drenado, encerrando");
}
}
Métricas com System.Diagnostics.Metrics
Exponha métricas nativas para o Prometheus/DataDog sem dependências extras:
public sealed class ProcessadorWorker : BackgroundService
{
private static readonly Meter _meter = new("MeuWorker.Processador");
private static readonly Counter<long> _processadas =
_meter.CreateCounter<long>("mensagens_processadas_total");
private static readonly Histogram<double> _duracao =
_meter.CreateHistogram<double>("mensagem_duracao_ms");
private static readonly ObservableGauge<int> _filaGauge;
private readonly Channel<Mensagem> _channel;
static ProcessadorWorker()
{
// Registrado como static para sobreviver ao escopo do worker
}
public ProcessadorWorker(Channel<Mensagem> channel)
{
_channel = channel;
_meter.CreateObservableGauge(
"fila_tamanho",
() => _channel.Reader.Count);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var msg in _channel.Reader.ReadAllAsync(stoppingToken))
{
var sw = Stopwatch.StartNew();
await ProcessarAsync(msg, stoppingToken);
_processadas.Add(1, new TagList { { "status", "ok" } });
_duracao.Record(sw.Elapsed.TotalMilliseconds);
}
}
}
Quando usar Worker Service vs Hangfire vs outros
| Cenário | Solução recomendada |
|---|---|
| Loop contínuo / fila interna | Worker Service com BackgroundService |
| Schedule tipo cron complexo | Quartz.NET ou Hangfire (recorrentes) |
| Jobs disparados por usuário com retry/UI | Hangfire |
| Fila externa (RabbitMQ, SQS, Service Bus) | Worker Service + MassTransit/AWSSDK |
| ETL periódico simples | Worker Service com PeriodicTimer |
| Processamento serverless / esporádico | Azure Functions / AWS Lambda |
Conclusão
O Worker Service é a opção mais leve e idiomática para background processing no .NET quando você não precisa de HTTP. Com BackgroundService, PeriodicTimer, injeção de escopo por mensagem e deploy nativo como serviço Windows ou systemd, você tem uma solução de produção robusta em menos de 200 linhas de código.
Os pontos que mais custam em produção — graceful shutdown, saúde sem endpoint HTTP e métricas observáveis — têm soluções idiomáticas no próprio runtime. Não é preciso framework externo para o caso básico: o host genérico já cobre 90% dos cenários.
Se o seu worker precisar escalar horizontalmente além de um único processo, o próximo passo natural é adicionar uma fila externa (RabbitMQ, Azure Service Bus) e múltiplas réplicas do serviço — cada uma competindo pela mesma fila. O código do worker muda muito pouco; apenas o adaptador de transporte muda.
Precisa resolver isso na prática?
Se você tem um processo legado que precisa ser extraído para um worker resiliente, ou quer estruturar background processing de forma que escale sem virar caos, podemos ajudar com uma sessão de revisão arquitetural.
Falar com um especialista →