background-services について
ASP.NET Core アプリケーションのバックグラウンド処理を実装するためのスキルです。ホステッドサービスのライフサイクルと、キュー、スケジュール・定期実行、信頼性の高いメッセージ処理、レート制限、正常な終了のパターンを説明します。
主な機能
- 01BackgroundService と IHostedService の指針
- 02ライフサイクルとキャンセル処理
- 03Channel ベースのタスクキュー
- 04Cron と定期実行のパターン
- 05Outbox と正常終了のパターン
活用シーン
- 01リクエスト外でのキュー処理
- 02スケジュールまたは定期タスクの実行
- 03永続化による信頼性の高いメッセージ処理
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill background-servicesインストール後、Claude Code で/background-servicesを入力して読み込まれたか確認します。
詳細
ASP.NET Core のバックグラウンドサービス
背景
バックグラウンドサービスは、リクエストパイプラインから処理を切り離し、キューを処理し、スケジュールされたタスクを実行するうえで不可欠です。実装が不適切なバックグラウンドサービスは、データ損失、孤立したジョブ、リソースリークにつながる可能性があります。これらのパターンにより、本番アプリケーションで信頼性が高く、可観測性に優れ、状況に応じて適切に処理を縮退できるバックグラウンド処理を実現できます。
BackgroundService と IHostedService の比較
| 機能 | BackgroundService | IHostedService |
|---|---|---|
| 目的 | 長時間実行するループまたは継続的な処理 | 起動/終了時のフック |
| メソッド | ExecuteAsync をオーバーライド | StartAsync と StopAsync を実装 |
| ライフタイム | キャンセルまたはホストのシャットダウンまで実行 | StartAsync は起動時、StopAsync はシャットダウン時に実行 |
| 使用する場面 | キューのポーリング、ストリームの処理、定期的なジョブ | データベースのマイグレーション、キャッシュのウォームアップ、リソースのクリーンアップ |
パターン 1:BackgroundService の基本構造
ライフサイクル管理とキャンセルのサポートを一貫して実現するには、BackgroundService 基底クラスを使用します。
public sealed class OrderProcessorWorker(
IServiceScopeFactory scopeFactory,
ILogger<OrderProcessorWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Order processor started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var processor = scope.ServiceProvider
.GetRequiredService<IOrderProcessor>();
var processed = await processor.ProcessPendingAsync(stoppingToken);
if (processed == 0)
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing orders");
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
logger.LogInformation("Order processor stopped");
}
}
// Registration
builder.Services.AddHostedService<OrderProcessorWorker>();
BackgroundService の重要なルール
- 必ずスコープを作成する --
BackgroundServiceはシングルトンとして登録されます。スコープ付きサービスを直接ではなく、IServiceScopeFactoryを注入します。 - 必ず例外を処理する -- 既定では、
ExecuteAsync内で処理されない例外が発生すると、ホストが停止します。ループ本体を try/catch で囲みます。 - 必ず停止トークンを尊重する --
stoppingToken.IsCancellationRequestedを確認し、すべての非同期呼び出しにトークンを渡します。 - 空の状態やエラー時には待機する -- CPU を浪費する高頻度のポーリングループを避けます。停止トークンを指定して
Task.Delayを使用します。
パターン 2:起動/終了時のフックに IHostedService を使用する
起動時のフック(キャッシュのウォームアップ、マイグレーション)
public sealed class CacheWarmupService(
IServiceScopeFactory scopeFactory,
ILogger<CacheWarmupService> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
logger.LogInformation("Warming caches");
using var scope = scopeFactory.CreateScope();
var cache = scope.ServiceProvider.GetRequiredService<IProductCache>();
await cache.WarmAsync(cancellationToken);
logger.LogInformation("Cache warmup complete");
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
起動時および終了時(リソースのライフサイクル)
public sealed class MessageBusService(
ILogger<MessageBusService> logger) : IHostedService
{
private IConnection? _connection;
public async Task StartAsync(CancellationToken cancellationToken)
{
logger.LogInformation("Connecting to message bus");
_connection = await CreateConnectionAsync(cancellationToken);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
logger.LogInformation("Disconnecting from message bus");
if (_connection is not null)
{
await _connection.CloseAsync(cancellationToken);
_connection = null;
}
}
private static Task<IConnection> CreateConnectionAsync(CancellationToken ct) =>
throw new NotImplementedException();
}
パターン 3:ホストサービスのライフサイクル
起動シーケンス
- 登録された各サービスについて、
IHostedService.StartAsyncが登録順に呼び出されます StartAsyncの完了後にBackgroundService.ExecuteAsyncが呼び出されます(並行して実行されるため、ホストは完了を待ちません)- すべての
StartAsync呼び出しが完了すると、ホストはリクエストを処理できる状態になります
重要: ExecuteAsync は、呼び出し元に制御を返す前にブロックしてはいけません。ExecuteAsync 内の最初の await で、ホストに制御が戻ります。
public sealed class MyWorker : BackgroundService
{
public override async Task StartAsync(CancellationToken cancellationToken)
{
await InitializeAsync(cancellationToken);
await base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await DoWorkAsync(stoppingToken);
}
}
}
シャットダウンシーケンス
IHostApplicationLifetime.ApplicationStoppingがトリガーされます- ホストは、各ホストサービスの
StopAsyncを逆の登録順で呼び出します BackgroundServiceの場合、停止トークンがキャンセルされ、その後StopAsyncはExecuteAsyncの完了を待ちますIHostApplicationLifetime.ApplicationStoppedがトリガーされます
パターン 4:信頼性の高いメッセージングのための Outbox パターン
非同期処理の前にメッセージをデータベースへトランザクションとして保存することで、メッセージが失われないようにします。
public class OutboxMessage
{
public Guid Id { get; set; }
public required string Type { get; set; }
public required string Payload { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? ProcessedAt { get; set; }
public string? Error { get; set; }
public int RetryCount { get; set; }
}
public interface IOutboxRepository
{
Task AddAsync(OutboxMessage message, CancellationToken ct = default);
Task<IReadOnlyList<OutboxMessage>> GetPendingAsync(int batchSize, CancellationToken ct = default);
Task MarkProcessedAsync(Guid messageId, CancellationToken ct = default);
Task MarkFailedAsync(Guid messageId, string error, CancellationToken ct = default);
}
public class OrderService
{
private readonly ApplicationDbContext _db;
private readonly IOutboxRepository _outbox;
public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
{
await using var transaction = await _db.Database.BeginTransactionAsync();
try
{
var order = new Order { };
_db.Orders.Add(order);
var message = new OutboxMessage
{
Id = Guid.NewGuid(),
Type = nameof(OrderCreatedEvent),
Payload = JsonSerializer.Serialize(new OrderCreatedEvent
{
OrderId = order.Id,
CustomerEmail = request.CustomerEmail
}),
CreatedAt = DateTimeOffset.UtcNow
};
await _outbox.AddAsync(message);
await _db.SaveChangesAsync();
await transaction.CommitAsync();
return order;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}
public class OutboxProcessor : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<OutboxProcessor> _logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await using var scope = _serviceProvider.CreateAsyncScope();
var outbox = scope.ServiceProvider.GetRequiredService<IOutboxRepository>();
var publisher = scope.ServiceProvider.GetRequiredService<IEventPublisher>();
var messages = await outbox.GetPendingAsync(batchSize: 10, stoppingToken);
foreach (var message in messages)
{
try
{
await publisher.PublishAsync(message.Type, message.Payload, stoppingToken);
await outbox.MarkProcessedAsync(message.Id, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process outbox message {MessageId}", message.Id);
await outbox.MarkFailedAsync(message.Id, ex.Message, stoppingToken);
}
}
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in outbox processor");
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
}
}
パターン 5:グレースフルシャットダウンの処理
起動およびシャットダウンのシーケンスを細かく制御するには、IHostedLifecycleService を実装します。
public class GracefulWorker : BackgroundService
{
private readonly IHostApplicationLifetime _lifetime;
private readonly ILogger<GracefulWorker> _logger;
private readonly Channel<WorkItem> _workChannel;
public GracefulWorker(
IHostApplicationLifetime lifetime,
ILogger<GracefulWorker> logger)
{
_lifetime = lifetime;
_logger = logger;
_workChannel = Channel.CreateUnbounded<WorkItem>();
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_lifetime.ApplicationStopping.Register(() =>
{
_logger.LogInformation("Shutdown requested, draining work channel...");
});
await foreach (var workItem in _workChannel.Reader.ReadAllAsync(stoppingToken))
{
try
{
await ProcessWorkItemAsync(workItem, stoppingToken);
}
catch (OperationCanceledException)
{
_logger.LogWarning("Work item {WorkId} cancelled due to shutdown", workItem.Id);
throw;
}
}
}
private async Task ProcessWorkItemAsync(WorkItem item, CancellationToken ct)
{
using var activity = new Activity("ProcessWorkItem").Start();
_logger.LogInformation("Processing work item {WorkId}", item.Id);
await Task.Delay(item.Duration, ct);
_logger.LogInformation("Completed work item {WorkId}", item.Id);
}
}
public class LifecycleAwareService : IHostedLifecycleService
{
private readonly ILogger<LifecycleAwareService> _logger;
public LifecycleAwareService(ILogger<LifecycleAwareService> logger) => _logger = logger;
public Task StartingAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Service starting...");
return Task.CompletedTask;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Service start called");
return Task.CompletedTask;
}
public Task StartedAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Service started successfully");
return Task.CompletedTask;
}
public Task StoppingAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Service stopping - cleanup starting...");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Service stop called");
return Task.CompletedTask;
}
public Task StoppedAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Service stopped - cleanup complete");
return Task.CompletedTask;
}
}
ホストのシャットダウンタイムアウト
builder.Services.Configure<HostOptions>(options =>
{
options.ShutdownTimeout = TimeSpan.FromSeconds(60);
});
パターン 6:Channels との統合
BackgroundService が処理する、Channel ベースのバックグラウンドタスクキュー:
public sealed class BackgroundTaskQueue
{
private readonly Channel<Func<IServiceProvider, CancellationToken, Task>> _queue
= Channel.CreateBounded<Func<IServiceProvider, CancellationToken, Task>>(
new BoundedChannelOptions(100) { FullMode = BoundedChannelFullMode.Wait });
public ChannelWriter<Func<IServiceProvider, CancellationToken, Task>> Writer => _queue.Writer;
public ChannelReader<Func<IServiceProvider, CancellationToken, Task>> Reader => _queue.Reader;
}
public sealed class QueueProcessorWorker(
BackgroundTaskQueue queue,
IServiceScopeFactory scopeFactory,
ILogger<QueueProcessorWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (await queue.Reader.WaitToReadAsync(stoppingToken))
{
while (queue.Reader.TryRead(out var workItem))
{
try
{
using var scope = scopeFactory.CreateScope();
await workItem(scope.ServiceProvider, stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Error executing queued work item");
}
}
}
}
}
// Registration
builder.Services.AddSingleton<BackgroundTaskQueue>();
builder.Services.AddHostedService<QueueProcessorWorker>();
パターン 7:Cron 式によるスケジュール済みジョブ
外部依存なしで信頼性の高い Cron ベースのスケジューリングを行うには、NCrontab を使用します。
public class ScheduledJobService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<ScheduledJobService> _logger;
private readonly CrontabSchedule _schedule;
private DateTime _nextRun;
public ScheduledJobService(IServiceProvider serviceProvider, ILogger<ScheduledJobService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
_schedule = CrontabSchedule.Parse("0 2 * * *");
_nextRun = _schedule.GetNextOccurrence(DateTime.Now);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var now = DateTime.Now;
if (now >= _nextRun)
{
try
{
await ExecuteJobAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Scheduled job failed");
}
_nextRun = _schedule.GetNextOccurrence(now);
}
var delay = _nextRun - DateTime.Now;
if (delay > TimeSpan.Zero)
{
await Task.Delay(delay, stoppingToken);
}
}
}
private async Task ExecuteJobAsync(CancellationToken ct)
{
await using var scope = _serviceProvider.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<IDailyReportService>();
await service.GenerateDailyReportAsync(ct);
}
}
パターン 8:PeriodicTimer による定期処理
より正確な定期実行には、Task.Delay ではなく PeriodicTimer を使用します。
public sealed class HealthCheckReporter(
IServiceScopeFactory scopeFactory,
ILogger<HealthCheckReporter> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
using var scope = scopeFactory.CreateScope();
var reporter = scope.ServiceProvider.GetRequiredService<IHealthReporter>();
await reporter.ReportAsync(stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Health check report failed");
}
}
}
}
パターン 9:レート制限を伴うキューベースの処理
public class RateLimitedProcessor : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<RateLimitedProcessor> _logger;
private readonly Channel<EmailRequest> _channel;
private readonly SemaphoreSlim _semaphore;
public RateLimitedProcessor(
IServiceProvider serviceProvider,
ILogger<RateLimitedProcessor> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
_channel = Channel.CreateUnbounded<EmailRequest>();
_semaphore = new SemaphoreSlim(5, 5);
}
public ChannelWriter<EmailRequest> Writer => _channel.Writer;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var processingTasks = new List<Task>();
await foreach (var request in _channel.Reader.ReadAllAsync(stoppingToken))
{
await _semaphore.WaitAsync(stoppingToken);
var task = ProcessWithReleaseAsync(request, stoppingToken);
processingTasks.Add(task);
if (processingTasks.Count > 100)
{
processingTasks.RemoveAll(t => t.IsCompleted);
}
}
await Task.WhenAll(processingTasks);
}
private async Task ProcessWithReleaseAsync(EmailRequest request, CancellationToken ct)
{
try
{
await ProcessEmailAsync(request, ct);
}
finally
{
_semaphore.Release();
}
}
private async Task ProcessEmailAsync(EmailRequest request, CancellationToken ct)
{
await using var scope = _serviceProvider.CreateAsyncScope();
var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
try
{
await emailService.SendAsync(request, ct);
_logger.LogInformation("Email sent to {Recipient}", request.To);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send email to {Recipient}", request.To);
}
await Task.Delay(TimeSpan.FromMilliseconds(100), ct);
}
}
アンチパターン
// BAD: No cancellation token handling
public class BadProcessor : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (true) // Never stops!
{
await DoWorkAsync();
}
}
}
// GOOD: Proper cancellation handling
public class GoodProcessor : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await DoWorkAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
// BAD: Swallowing all exceptions
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await DoWorkAsync();
}
catch (Exception)
{
// Silently swallowed - service appears healthy but isn't working!
}
}
}
// GOOD: Log and continue with backoff
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await DoWorkAsync(stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Work failed");
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
}
}
// BAD: Using scoped services without creating scope
public class BadService : BackgroundService
{
private readonly ApplicationDbContext _db; // Scoped service in singleton!
public BadService(ApplicationDbContext db) => _db = db;
protected override async Task ExecuteAsync(CancellationToken ct)
{
var data = await _db.Orders.ToListAsync(ct); // Will fail after first request!
}
}
// GOOD: Create scope for each unit of work
public class GoodService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
public GoodService(IServiceProvider sp) => _serviceProvider = sp;
protected override async Task ExecuteAsync(CancellationToken ct)
{
await using var scope = _serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var data = await db.Orders.ToListAsync(ct);
}
}
// BAD: Blocking async code
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
DoWork().Wait(); // Blocks thread!
}
return Task.CompletedTask;
}
// GOOD: Use async/await throughout
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await DoWorkAsync(stoppingToken);
}
}
エージェントで注意すべき点
- スコープ付きサービスを BackgroundService のコンストラクターに注入しないでください。これらはシングルトンです。必ず
IServiceScopeFactoryを使用してください。 - バックグラウンド処理に
Task.Runを使用しないでください。ライフサイクルの適切な管理と正常なシャットダウンのために、BackgroundServiceを使用してください。 OperationCanceledExceptionを握りつぶさないでください。そのまま伝播させるか、停止トークンを再確認してください。Thread.Sleepを使用しないでください。await Task.Delay(duration, stoppingToken)またはPeriodicTimerを使用してください。- 登録を忘れないでください。
AddHostedService<T>()が必要です。インターフェイスを実装するだけでは何も起こりません。