mcpField
B
background-services

background-services

github.com/wshaddix開発8685 回インストール35 週間前に更新
リポジトリ

background-services について

ASP.NET Core アプリケーションのバックグラウンド処理を実装するためのスキルです。ホステッドサービスのライフサイクルと、キュー、スケジュール・定期実行、信頼性の高いメッセージ処理、レート制限、正常な終了のパターンを説明します。

主な機能

  1. 01BackgroundService と IHostedService の指針
  2. 02ライフサイクルとキャンセル処理
  3. 03Channel ベースのタスクキュー
  4. 04Cron と定期実行のパターン
  5. 05Outbox と正常終了のパターン

活用シーン

  1. 01リクエスト外でのキュー処理
  2. 02スケジュールまたは定期タスクの実行
  3. 03永続化による信頼性の高いメッセージ処理

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill background-services

インストール後、Claude Code で/background-servicesを入力して読み込まれたか確認します。

詳細

ASP.NET Core のバックグラウンドサービス

背景

バックグラウンドサービスは、リクエストパイプラインから処理を切り離し、キューを処理し、スケジュールされたタスクを実行するうえで不可欠です。実装が不適切なバックグラウンドサービスは、データ損失、孤立したジョブ、リソースリークにつながる可能性があります。これらのパターンにより、本番アプリケーションで信頼性が高く、可観測性に優れ、状況に応じて適切に処理を縮退できるバックグラウンド処理を実現できます。


BackgroundService と IHostedService の比較

機能BackgroundServiceIHostedService
目的長時間実行するループまたは継続的な処理起動/終了時のフック
メソッド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 の重要なルール
  1. 必ずスコープを作成する -- BackgroundService はシングルトンとして登録されます。スコープ付きサービスを直接ではなく、IServiceScopeFactory を注入します。
  2. 必ず例外を処理する -- 既定では、ExecuteAsync 内で処理されない例外が発生すると、ホストが停止します。ループ本体を try/catch で囲みます。
  3. 必ず停止トークンを尊重する -- stoppingToken.IsCancellationRequested を確認し、すべての非同期呼び出しにトークンを渡します。
  4. 空の状態やエラー時には待機する -- 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:ホストサービスのライフサイクル

起動シーケンス
  1. 登録された各サービスについて、IHostedService.StartAsync が登録順に呼び出されます
  2. StartAsync の完了後に BackgroundService.ExecuteAsync が呼び出されます(並行して実行されるため、ホストは完了を待ちません)
  3. すべての 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);
        }
    }
}
シャットダウンシーケンス
  1. IHostApplicationLifetime.ApplicationStopping がトリガーされます
  2. ホストは、各ホストサービスの StopAsync を逆の登録順で呼び出します
  3. BackgroundService の場合、停止トークンがキャンセルされ、その後 StopAsync は ExecuteAsync の完了を待ちます
  4. 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);
    }
}

エージェントで注意すべき点

  1. スコープ付きサービスを BackgroundService のコンストラクターに注入しないでください。これらはシングルトンです。必ず IServiceScopeFactory を使用してください。
  2. バックグラウンド処理に Task.Run を使用しないでください。ライフサイクルの適切な管理と正常なシャットダウンのために、BackgroundService を使用してください。
  3. OperationCanceledException を握りつぶさないでください。そのまま伝播させるか、停止トークンを再確認してください。
  4. Thread.Sleep を使用しないでください。await Task.Delay(duration, stoppingToken) または PeriodicTimer を使用してください。
  5. 登録を忘れないでください。AddHostedService<T>() が必要です。インターフェイスを実装するだけでは何も起こりません。

参考資料

コメント