ねののお庭。

かりかりもふもふ。

【C#】.NET Worker Service の health check。

.NET Worker Service 便利ですよね。 Generic Host が最高という話でもある。

この記事では dotnet new worker コマンドを叩いて作成したテンプレのような、Generic Host を用いた console application における health check のお話をしたいと思います。 web application であれば /healthz のような health check 用のエンドポイントを生やしてあげれば良いだけなんですけどね、console application だと別のアプローチを取る必要があったりします。

基本方針

web application であれば health check 用の WebAPI を生やして、health check する側がそのエンドポイントを叩くような形になります。 しかしながら先ほども書いた通り dotnet new worker コマンドを叩いて作成したテンプレは console application ですから、Web API を生やすぞとはなりません。

なのでどうやって health check するべきなのだろうか、とか思うわけです。 Kubernetes を使っている場合、health check はどうするかというと livenessProbe を使うわけですが、公式ドキュメントにピンポイントな事が書いてあります。

livenessProbe:
    exec:
    command:
    - cat
    - /tmp/healthy
    initialDelaySeconds: 5
    periodSeconds: 5

なるほど、healthy だったら何かファイルを作っておき、unhealthy だったらそのファイルが存在しないようにすれば良さそう。

問題はそれをどう実現するかなのですが、それには Microsoft.Extensions.Diagnostics.HealthChecks を使っていきます。

Microsoft.Extensions.Diagnostics.HealthChecks

Generic Host には簡単に health check の仕組みを導入する事ができます。 そのためのパッケージが Microsoft.Extensions.Diagnostics.HealthChecks です。 このパッケージは dotnet new webapi などで作る web 系のテンプレートにはデフォルトで組み込まれていますが、dotnet new worker で作成する worker テンプレートには組み込まれていないので、追加しましょう。

dotnet add package Microsoft.Extensions.Diagnostics.HealthChecks

Microsoft.Extensions.Diagnostics.HealthChecks を用いた Generic Host における health check についてはここでは深く触れませんが、オススメのドキュメント等は以下の通り。

health check では背後にあるミドルウェアに対して疎通確認をします。 よく用いられるサービスについては AspNetCore.Diagnostics.HealthChecks が各ミドルウェア用にパッケージを用意してくれているので、それを用いると良いでしょう。今回の例では、PostgreSQL と Redis を使っていたとしましょう。

// 使ってるサービス等に合わせてパッケージを追加
dotnet add package AspNetCore.HealthChecks.Npgsql
dotnet add package AspNetCore.HealthChecks.Redis

これらのパッケージを追加する事で、以下のようなコードが書けるようになります。

var host = Host.CreateDefaultBuilder(args)
    .ConfigureServices(static (context, services) =>
    {
        services.AddHostedService<Worker>();

        // Npgsql や StackExchange.Redis の構成は省略

        services.AddHealthChecks()
            .AddNpgSql(serviceProvider => serviceProvider.GetRequiredService<NpgsqlDataSource>())
            .AddRedis(serviceProvider => serviceProvider.GetRequiredService<IConnectionMultiplexer>());
    })
    .Build();

host.Run();

これで health check 用のサービスが追加されましたが、これだけだと何の意味もありません。 web application であれば、app.UseHealthChecks("/healthz") とかを http pipeline に追加する事で /healthz に HTTP リクエストがあった際に登録した health check が走り、その結果をリスポンスにのせるわけですが、今回の場合は console application なので同様の手段はとれません。 healthy だったら /tmp/healthy みたいなファイルが存在するような状態を作りたいしたいわけですが、どうすればいいのでしょう?

答えは health check を内部で定期実行し、その結果に応じて /tmp/healthy みたいなファイルを作ったり消したりするようにすれば良いわけです。 Microsoft.Extensions.Diagnostics.HealthChecks はそれを実現するための機能が備わっています。 それが IHealthCheckPublisher です。

IHealthCheckPublisher

使い方は簡単。 IHealthCheckPublisher を実装したクラスと、そのオプションである HealthCheckPublisherOptions を設定すればいいだけです。 HealthCheckPublisherOptions では、どの程度の周期で health check を行い、それを publish するかを設定します。

var host = Host.CreateDefaultBuilder(args)
    .ConfigureServices(static (context, services) =>
    {
        services.AddHostedService<Worker>();

        // Npgsql や StackExchange.Redis の構成は省略

        services.AddHealthChecks()
            .AddNpgSql(serviceProvider => serviceProvider.GetRequiredService<NpgsqlDataSource>())
            .AddRedis(serviceProvider => serviceProvider.GetRequiredService<IConnectionMultiplexer>());

        services.Configure<HealthCheckPublisherOptions>(options =>
        {
            // 初回の health check / publish  を起動から 5 秒遅延させる
            options.Delay = TimeSpan.FromSeconds(5);
            // 30 秒毎に health check / publish をする
            options.Period = TimeSpan.FromSeconds(30);
        });

        // HealthCheckPublisherOptions により定期的に health check が走り
        // その結果が IHealthCheckPublisher.PublishAsync(...) に渡される
        services.AddSingleton<IHealthCheckPublisher, HealthCheckPublisher>();
    })
    .Build();

host.Run();

IHealthCheckPublisher を実装した HealthCheckPublisher はこんな感じ。 PublishAsync が定期的に呼ばれる事になります。

internal class HealthCheckPublisher : IHealthCheckPublisher, IDisposable
{
    private readonly string _path = "/tmp/healthy";

    public Task PublishAsync(HealthReport report, CancellationToken cancellationToken)
    {
        if (report.Status is not HealthStatus.Healthy)
        {
            if (File.Exists(_path))
            {
                File.Delete(_path);
            }

            return Task.CompletedTask;
        }

        if (!File.Exists(_path))
        {
            using var _ = File.Create(_path);
        }

        return Task.CompletedTask;
    }

    public void Dispose()
    {
        if (File.Exists(_path))
        {
            File.Delete(_path);
        }
    }
}

これで「application が healthy だったら何かファイルを作っておき、unhealthy だったらそのファイルが存在しないようする」という状況を作り出すことができました。 そのため Kubernetes の livenessProbe で定期的に /tmp/healthy を cat して health check する、みたいな事ができるようになりました。

まとめ

dotnet new worker コマンドを叩いて作成したテンプレのような Generic Host を用いた console application で health check がしたければ Microsoft.Extensions.Diagnostics.HealthChecks パッケージを追加し、IHealthCheckPublisher を実装したクラスと、そのオプションである HealthCheckPublisherOptions を設定しましょう。 そうすることで health check を application 自身が定期的に実行し、その結果を IHealthCheckPublisher.PublishAsync(...) に渡してくれるようになるので、実装次第で様々な事が実現できます。 今回の例では Kubernetes の livenessProbe で cat を用いた health check をするために、IHealthCheckPublisher.PublishAsync(...) 内で HealthReport.Status が healthy ならファイルが存在し、unhealthy ならファイル存在しなようにする実装をしましたが、HealthReport の結果を別のサービスに HTTP で投げるといった事も実現できます。