一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

Hangfireで実現する.NETアプリのバックグランドジョブ

この記事は一休.comアドベントカレンダー2018の19日目です。

qiita.com


ある程度の規模のウェブアプリケーションであれば、応答性能を損なうことなく複雑な業務処理を完遂させたい場面が出てきます。 このような場合、処理をある程度の粒度で切り出して、応答を返すプロセスとは別のプロセスで処理する、という方法が考えられます。 例えば、予約完了処理の中でメール送信部分だけを別のプロセスで処理する、といった具合です。 ASP.NETでこのようなバックグランドジョブ処理を実現するには、どのような方法があり得るでしょうか。

この記事では、.NET環境で利用できるバックグラウンドジョブのライブラリであるHangfireについて、当社での利用事例を簡単に紹介します。

Hangfireを導入した経緯

当社のサービスにも当然、上述したようなバックグラウンドジョブのニーズがありました。

ASP.NETでバックグラウンドジョブの処理を実現する場合、公式に提供されているQueueBackgroundWorkItemを使うという手段があります。 これなら導入は非常に簡便です。しかし、大事な処理をバックグラウンドジョブで実行することを考えると、ジョブのステータス(キューイング、処理中、処理正常完了、処理失敗)やジョブの実行履歴の管理も行いたい、と考えたました。
この場合、QueueBackgroundWorkItemを採用するなら、その部分は自前で作らなければなりません。
また、クラウドが提供するキュー処理のサービスを使う方法もあり得ますが、この場合も、ジョブのステータスやジョブの実行履歴の管理は自前で作らなければなりません。 Hangfireは、ジョブストレージとジョブの管理機構が組み込まれており、かつ、既存の.NETのコードを大きく変えることなく利用できるという利点があります。 作者によれば

Hangfire is a .NET Framework alternative to Resque, Sidekiq, delayed_job, Celery.

だそうです。 以上の点を総合的に加味して、Hangfireを活用することに決めました。

構成

f:id:s-tokutake:20181219123844p:plain

Hangfireを使う場合、アプリケーションの構成は大きく以下のふたつがあり得ます。

  • バックグラウンドジョブ処理を行いたいウェブアプリケーションの内部にバックグラウンドジョブサーバを動作させる。
  • バックグラウンドジョブ処理を行いたいウェブアプリケーションとバックグラウンドジョブサーバを完全に分離する。

当社では、完全に分離する構成にしました。内部にバックグラウンドジョブサーバを動作させる場合、ウェブアプリのプロセスの再起動とジョブの処理の状態を気にする必要があるため、完全に分離したほうが運用が簡単だと判断しました。

上記の図の通り、ジョブキューのストレージにはElasticache Redisを使っています。Hangfireは標準ストレージとしてSQL Serverを使いますが、Redisのほうがより高性能であるというベンチマーク結果を考慮し、Redisにしました。

また、ジョブのステータスや各種管理ができるDashboardは、Hangfireを使うウェブアプリともバックグラウンドジョブサーバとも別のサーバに構築しました。

実装例

実装自体はとても簡単です。

namespace Sample.Service.JobQueue
{

    /// <summary>
    /// バックグラウンドジョブ導入のためのサンプルクラス
    /// </summary>
    public class JobQueueSampleService 
    {

        public void DoSomething(string param1)
        { 
            JobQueueClient.Enqueue(() => DoAsBackgroundJob(param1));  
        }

        /// <summary>
        /// このメソッドをキューに詰める。
        /// </summary>
        /// <param name="param1">
        /// Hangfireは、パラメータをJsonでシリアライズしてストレージに詰める。
        /// </param>
        [Hangfire.AutomaticRetry(Attempts = 3)] // <== リトライ回数をキューに詰めるメソッドの属性で回数を指定する。指定しないと10回リトライ。リトライしないなら0回にしておく。
        public void DoAsBackgroundJob(string param1)
        {
             // --- do something  
        }
    }

    public class JobQueueClient
    {

        private static readonly Lazy<IBackgroundJobClient> _cachedClient = new Lazy<IBackgroundJobClient>(() => new BackgroundJobClient());

        public static string Enqueue(Expression<Action> methodCall)
        {
            var client = _cachedClient.Value;

            return client.Create(methodCall, new EnqueuedState(QueueName));
        }
    }
}

Hangfireは、メソッドをジョブキューにエンキューする、という仕組みになっています。↑のコードでは、 JobQueueClient.Enqueue(() => DoAsBackgroundJob(param1)); で、 DoAsBackgroundJobメソッドでジョブとしてジョブストレージに保存しています。
エンキューすることで、メソッドのシグネチャやパラメータ、そのメソッドが属するアセンブリ名などがジョブストレージに保存されます。バックグラウンドジョブサーバは、この情報をデキューして、リフレクションを使って、保存されたメソッドを呼び出します。
リフレクションを使うので、Hangfireのクライアントとなるウェブアプリとバックグラウンドジョブサーバは同じアセンブリを参照する必要があります。

※Hangfireには、 BackgroundJob.Enqueue というエンキュー処理のためのわかりやすいインターフェースがあるのですが、これは後述する理由により、使いませんでした。

工夫点

前述した通り、Hangfireのクライアントとなるウェブアプリとバックグラウンドジョブサーバは同じアセンブリを参照する必要があります。 実運用でこれを実現しようとすると、次の2点を考える必要があります。

  • 毎日複数回デプロイされるウェブアプリケーションとバックグラウンドジョブサーバのアセンブリを一致させる必要がある。
  • ウェブアプリケーションのデプロイサイクルよりもLong Runningなジョブでもきちんと処理を完遂させる必要がある。
    • それも、エンキューしたウェブアプリケーションと同じアセンブリを参照しているバックグラウンドジョブサーバで処理を完遂させる必要がある。

このふたつを実現する実現するために、以下のふたつの手段を採用しました。

  • Hangfireの名前付きキューを利用する。
  • バックグラウンドジョブサーバはWindows Serviceとして稼働させ、同じアセンブリを参照しているウェブアプリケーションが古いバージョンになっても動かし続ける。

Hangfireの名前付きキューを利用する。

Hangfireは、キューに名前を付けることができます。ある名前のキューに詰められたジョブはその名前のキューを処理するように指定されたバックグラウンドジョブサーバでしか処理されません。キュー名は通常、次のようにキューに詰めるメソッドの属性で指定します。

[Queue("testqueue")]
public void SomeMethod() { }

そして、バックグラウンドジョブサーバ側ではサーバを初期化するときに、キューの名前を指定します。以下のように。

var options = new BackgroundJobServerOptions
{
    Queues = new[] { "testqueue" }
};

app.UseHangfireServer(options);
// or
using (new BackgroundJobServer(options)) { /* ... */ }

これで、testqueue というキューに詰めたメソッドは、↑のサーバでしか処理されなくなります。

さて、あるバージョンのアセンブリを参照しているウェブサーバがキューに詰めたメソッドは同じバージョンのアセンブリを参照しているバックグラウンドジョブサーバで処理させたい という要件は、以下を実現すれば満たせそうです。

  • 同じバージョンのアセンブリを参照しているウェブサーバとバックグラウンドジョブサーバは同じキュー名を利用する。そのキュー名はビルド単位で生成され完全にユニークである。

ビルド単位で生成され完全にユニークな値として、ビルドバージョンの番号がありました。そこでこの番号をキュー名にすることでこの要件を満たすことができました。

Untitled (1).png

ただし、Hangfireのサンプルなどで一番よく見かける BackgroundJob.Enqueue では、キュー名をメソッドの属性としてしか指定できません。つまり、動的に設定できないのです。調べてみると、BackgroundJobClient クラスのCreateメソッドを直接使うことでキュー名を動的に指定できることがわかりましたので、以下のようなBackgroundJobClient をラップしたクラスを作りEnqueueメソッドを実装しました。

  public class JobQueueClient
    {

        private static readonly Lazy<IBackgroundJobClient> _cachedClient = new Lazy<IBackgroundJobClient>(() => new BackgroundJobClient());

        public static string Enqueue(Expression<Action> methodCall)
        {
            var client = _cachedClient.Value;

            return client.Create(methodCall, new EnqueuedState(QueueName));
        }
    }
}

バックグラウンドジョブサーバはWindows Serviceとして稼働させ動かし続ける。

新しいウェブアプリがデプロイされても古いバックグラウンドジョブサーバが動作していれば、デプロイサイクルよりもLong Runningなジョブもきちんと処理できます。 これは、シンプルに バックグラウンドジョブサーバはWindows Serviceとして実装し、新しいバージョンのバックグラウンドジョブサーバをデプロイをしても、古いバージョンのバックグラウンドジョブサーバは停止しないようにしました。 バックグラウンドジョブサーバをWindows Service として動かす方法は、公式サイトにも開設されています。しかし、このやり方には従わずに、Topshelfを使ってServiceにしました。Windows Serviceのインストール、アンインストール、開始、停止が簡単に制御できるためです。

バックグラウンドジョブサーバのデプロイはAWS Codedeployを使います。 Codedeployのafter installのhookで、バックグラウンドジョブサーバをWindows Serviceとしてインストールし、開始をしています。 このとき、配置するフォルダをデプロイバージョンごとに変えることで、既存のServiceを動かし続けながら、新しいバージョンのServiceを稼働させることができます。

appspec.ymlは↓のようにします。

version: 0.0
os: windows
files: 
  - source: /
    destination: d:/latest
hooks:
  AfterInstall:
    - location: /after-install.bat

after-install.batは、以下の通り。

rem $TARGET_FOLDER を ビルド時にビルドバージョンに書き換える。
xcopy D:\latest D:\$TARGET_FOLDER\ /E /Y /I /Q
D:\$TARGET_FOLDER\bin\BackgroundJob.ProcessingServer.exe install
D:\$TARGET_FOLDER\bin\BackgroundJob.ProcessingServer.exe start

ただし、このままだと永遠にWindows Serviceが増え続けてしまいます。対策として、日次で1日以上古くなったバージョンのバックグラウンドジョブサーバを停止するようにしました。

まとめ

当社ではHangfireをプロダクション環境の業務処理で活用し始めてまだ日が浅いです。しかし、現時点では、問題なく動作しています。 今後は、短い間隔で動かしているバッチ処理をウェブアプリのバックグラウンドジョブに切り替えていく、という使い方も想定しています。

この記事の筆者について

  • システム本部CTO室所属の 徳武 です。
  • サービスの技術基盤の開発運用、宿泊サービスの開発支援を行なっています。