1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【GCPリフト&シフト戦記 #2】Windowsタスクスケジューラからの脱却。複数台で競合しない「非同期キュー」バッチの構築

1
Posted at

はじめに

こんにちは。オンプレミスのC#資産をクラウドへリフト&シフトする連載の第2回です。
前回はWebアプリのセッションを外部化し「ステートレス」に矯正しましたが、今回はインフラ運用で最も闇が深い「バッチ処理のモダナイズ」がテーマです。

「毎日深夜2時に、WindowsタスクスケジューラでEXEファイルを叩く」
オンプレミス時代、バッチ処理といえばこの運用が当たり前でした。しかし、この牧歌的な仕組みをそのままクラウド(VMの複数台構成)に持ち込むと、データの二重処理や、永遠に終わらない「ゾンビタスク」という恐ろしい現象を引き起こします。

本記事では、時間起動のレガシーバッチを、.NETの BackgroundService を使った「非同期ジョブキュー」方式へ進化させた設計と実装を共有します。

image.png

【前提環境】

  • C# / .NET 8 (Worker Service)
  • Google Cloud Platform (GCP)
  • Compute Engine (MIGによる複数台構成)
  • SQL Server

1. 時間起動バッチの崩壊と「ゾンビタスク」の発生

【起結】⏱ 10秒まとめ:本章の結論
クラウドで複数台のバッチサーバーを動かす場合、「時間で一斉に起動する」方式は競合を引き起こします。DBを介した「ジョブキュー(ポーリング)」方式へ転換し、1つの処理は必ず1台だけが担当するように設計する必要があります。

【承】🌩 課題:こんなこと困るよね。難しいよね。
クラウド移行に伴い、バッチサーバーも可用性を高めるためにMIG(マネージドインスタンスグループ)で2台構成にしました。そして深夜2時、タスクスケジューラが両方のサーバーで同時にEXEをキックしました。
結果はどうなったか? 2台のサーバーが全く同じデータを同時に取得し、顧客に同じメールを2通送り、DBの同じレコードを更新しようとしてデッドロックによるエラーが多発しました。
さらに悪いことに、クラウドのVMは基盤のメンテナンス等で前触れなく再起動されることがあります。バッチ処理の途中でVMが落ちると、DB上は「実行中」となっているのに、実際には誰も処理していない「永遠に終わらないゾンビタスク」が大量発生してしまいました。

【転】💡 解決策:採用理由と他の選択肢
この地獄を抜け出すためのアーキテクチャ設計(ADR)です。

  • 選択肢A: Cloud Scheduler + Cloud Run でサーバーレス化する
    • 見送り理由: クラウドネイティブな理想形ですが、既存のC#バッチ処理が巨大であり、処理時間制限(当時最大1時間)に収まらない重いジョブが存在しました。また、一気にアーキテクチャを変えるのは移行リスクが高すぎると判断しました。
  • 採用案: DBテーブルを使ったジョブキュー + BackgroundService による常駐化
    • 採用理由: Windowsタスクスケジューラを廃止し、.NETの BackgroundService を使ってアプリを常駐(サービス化)させます。実行すべきタスクをDBのテーブル(キュー)に登録しておき、各サーバーが数秒おきに「自分にできる仕事はないか?」と監視(ポーリング)する方式です。DBの行ロック(排他制御)を利用することで、複数台でも「1つのジョブを確実に1台だけが取得する」ことが担保できます。

【結】💻 具体例・サンプルコード

以下は、Worker.cs(BackgroundService)内で、キューからジョブを取り出し、起動時のゾンビクリーンアップを行う実装の一部です。

// Worker.cs
public class Worker : BackgroundService
{
    private readonly IServiceProvider _serviceProvider;
    private readonly string _workerId;

    public Worker(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
        // 自分自身が誰かを特定するため、マシン名をWorkerIDにする
        _workerId = $"{Environment.MachineName}";
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using (var scope = _serviceProvider.CreateScope())
        {
            var queueService = scope.ServiceProvider.GetRequiredService<IJobQueueService>();

            // ★起動時の工夫: 自分が過去に「実行中」にしたまま死んだジョブ(ゾンビ)を初期化する
            await queueService.CleanupZombieJobsAsync(_workerId);
        }

        while (!stoppingToken.IsCancellationRequested)
        {
            using (var scope = _serviceProvider.CreateScope())
            {
                var queueService = scope.ServiceProvider.GetRequiredService<IJobQueueService>();

                // ★DBの排他ロック(UPDLOCK等)を使って、安全にジョブを1つ取得する
                var job = await queueService.DequeueAsync(_workerId);

                if (job != null)
                {
                    // ジョブの実行処理(次章で詳述)
                }
            }
            // 仕事がない時は少し休む(ポーリング)
            await Task.Delay(1000, stoppingToken);
        }
    }
}

🔰 初心者向け解説:ジョブキューとポーリングとは?
「ジョブキュー」は、回転寿司の注文レーンです。お客さん(トリガー)が「まぐろ」と注文を書いた紙をレーンに流します。「ポーリング」とは、厨房にいる複数の板前さん(VM)が、レーンを数秒ごとにチラチラ見て「あ、注文が来てる!」と確認する行動です。
1人の板前さんがその紙を取れば(排他制御)、他の板前さんは同じ注文を作らなくて済みます。

2. SemaphoreSlimによる並列度制御とスコープ管理

【起結】⏱ 10秒まとめ:本章の結論
Worker内で複数のバッチを非同期で並列処理させる場合、SemaphoreSlimで「同時実行できる最大数」を制御し、ジョブごとに CreateScope() でDIコンテナ(DBコンテキストなど)を独立させることが必須です。

【承】🌩 課題:こんなこと困るよね。難しいよね。
「ジョブキュー方式にしたぞ!これでどんどん仕事を取ってきて Task.Run で並列に処理させれば爆速だ!」
そう思って実装したところ、今度はDBのコネクションプール枯渇エラーや、「別のジョブのデータが混ざって保存される」という恐怖の例外(DbContextの並行アクセス違反)が発生しました。
Task.Run は無限にスレッドを生成してしまうため、重いバッチが10個同時に走るとCPUやメモリが一瞬でパンクします。また、BackgroundService は「シングルトン(アプリ全体で1つ)」として動くため、その中で生成されたDBコンテキストを複数のスレッドで使い回すと、スレッドセーフでないEntity Framework Coreは即座に悲鳴を上げます。

【転】💡 解決策:採用理由と他の選択肢
安全に並列処理を行うための設計です。

  • 選択肢A: Task.Run を使わず、1つずつ直列(await)で処理する
    • 見送り理由: 最も安全ですが、1つのバッチが1時間かかると、その後ろに溜まっている短いバッチも1時間待たされることになり、リソースの無駄遣いになります。
  • 採用案: SemaphoreSlim による同時実行枠の制御と、スレッドごとの IServiceScope 生成
    • 採用理由: 「このサーバーは同時に3つのバッチまでしか処理しない」というルールを SemaphoreSlim(入場ゲート)で物理的に制御します。さらに、スレッド(Task)を起動した直後に専用のDIスコープを切ることで、ジョブごとに完全に独立したDbContextを渡すことができ、データ混入を完璧に防ぎます。

【結】💻 具体例・サンプルコード

入場制限(SemaphoreSlim)と、独立したスコープ(CreateScope)を組み合わせた安全な並列実行コードです。

// Worker.cs
public class Worker : BackgroundService
{
    // ★追加: 同時実行する最大ジョブ数 (並列度) インフラのスペックに合わせて設定
    private readonly int _maxDegreeOfParallelism = 3;
    private readonly IServiceProvider _serviceProvider;

    public Worker(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // ★セマフォ(入場ゲート)を作成。定員は3名。
        using var semaphore = new SemaphoreSlim(_maxDegreeOfParallelism);

        while (!stoppingToken.IsCancellationRequested)
        {
            // ★空き枠ができるまで待機(定員オーバーならここでブロックされる)
            await semaphore.WaitAsync(stoppingToken);

            bool isTaskStarted = false;
            try
            {
                using var scope = _serviceProvider.CreateScope();
                var queueService = scope.ServiceProvider.GetRequiredService<IJobQueueService>();
                var job = await queueService.DequeueAsync("Worker-1");

                if (job != null)
                {
                    // ★ジョブの処理を別スレッド(Task)に切り離し、whileループはすぐに次へ進む
                    _ = Task.Run(async () =>
                    {
                        // ⚠️超重要: スレッドごとに完全に独立した新しいスコープを作成する
                        using var taskScope = _serviceProvider.CreateScope();
                        var taskJobRunner = taskScope.ServiceProvider.GetRequiredService<IJobRunner>();

                        try
                        {
                            // 独立したDbContextを使って安全に実行
                            await taskJobRunner.RunJobAsync(job);
                        }
                        finally
                        {
                            // ★処理が終わったら、セマフォの枠を1つ解放(次のジョブが入れるようになる)
                            semaphore.Release();
                        }
                    }, stoppingToken);

                    isTaskStarted = true;
                }
            }
            finally
            {
                // ジョブが無かった場合は、無駄打ちになった枠を即座に返却
                if (!isTaskStarted) semaphore.Release();
            }

            await Task.Delay(1000, stoppingToken);
        }
    }
}

🔰 初心者向け解説:SemaphoreSlim(セマフォ)とScope(スコープ)とは?
「セマフォ」は、遊園地の人気アトラクションの「入場制限ゲート」です。定員が3人なら、4人目は誰かが出てくるまでゲートの外で待たされます。これでサーバーのパンクを防ぎます。
「スコープ」は、「自分専用の調理器具セット」です。同じキッチン(アプリ)でも、スタッフ(スレッド)ごとに別々のまな板や包丁(DbContext)を渡すことで、ハンバーグとケーキの材料が混ざる大事故を防ぎます。

終わりに

クラウドにおけるバッチ処理のモダナイズは、「時間起動からキュー駆動へ」のパラダイムシフトです。
オンプレ時代の「定時に実行する」という縛りを捨て、非同期で状態を管理する仕組みを自前で実装するのは泥臭い作業ですが、一度作ってしまえば複数台構成でも安全にスケールアウトできる強力な基盤となります。

次回(第3回)は、「脱・ファイルサーバーと機密情報のクラウド化(Shift編)」をお届けします。クラウドでファイルをどう扱うか、そしてAPIキーの直書きからどうやって卒業したかを解説します。

📖 単語一覧 (Glossary)

  • タスクスケジューラ: Windows OSに標準搭載されている、指定した日時にプログラムを自動実行する機能。クラウドのスケールアウト構成とは相性が悪い。
  • ゾンビタスク: 処理の途中でサーバーがクラッシュした等の理由で、「実行中」のステータスのまま誰にも処理されず永遠に残ってしまったタスクのこと。
  • 冪等性(べきとうせい): 同じ処理を1回実行しても、途中で失敗してリトライで2回実行しても、最終的な結果(DBの状態など)が全く同じになるような設計のこと。分散システムでは非常に重要。
  • SemaphoreSlim: .NETに用意されている、リソースに同時にアクセスできるスレッドの数を制限するためのクラス。軽量なセマフォ。
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?