Start Debugging

Aspire 13.5.4 で Kafka ヘルスチェックがプローブのたびに producer をリークしなくなりました

Aspire 13.5.4 より前は、AddKafka リソースに対する AppHost のヘルスチェックが実行のたびに新しい Confluent の producer を作成し、誰も破棄しませんでした。そのためポーリングスレッドが積み上がり、AppHost が CPU を消費していました。この修正と、その背後にある HealthCheckRegistration.Factory の落とし穴は、独自のヘルスチェックにも当てはまります。

Aspire 13.5.4 は 2026-09-15 にリリースされました。AppHost で AddKafka を呼び出しているなら、適用する価値のあるパッチリリースです。13.5.3 までは、Aspire.Hosting.Kafka が登録する Kafka ヘルスチェックが実行のたびにまったく新しい Confluent.Kafka の producer を作成し、それを一度も破棄していませんでした。producer はそれぞれ独自のポーリングスレッドを起動するため、長時間動作する AppHost には SafeKafkaHandle.Poll で止まったスレッドが少しずつ溜まっていきました。issue #20091 の報告では、macOS 上の AppHost が CPU 350-400% に張り付き、スレッドサンプリングのトレースではサンプリングされた 1,934 スレッドのうち 1,901 が Kafka のポーリングループ内にありました。

AppHost の dotnet run を午後いっぱい開いたままにして、ファンが回り出した理由を不思議に思ったことがあるなら、これが有力な原因です。

producer はどこから来ていたのか

ホスティング統合は HealthCheckRegistration の factory の中でチェックを構築していました。

var healthCheckRegistration = new HealthCheckRegistration(
    healthCheckKey,
    sp =>
    {
        var options = new KafkaHealthCheckOptions();
        options.Configuration = new ProducerConfig();
        options.Configuration.BootstrapServers = connectionString
            ?? throw new InvalidOperationException("Connection string is unavailable");
        return new KafkaHealthCheck(options);
    },
    failureStatus: default,
    tags: default);
builder.Services.AddHealthChecks().Add(healthCheckRegistration);

HealthCheckService はチェックを実行するたびにこの factory を呼び出します。KafkaHealthCheck は producer を遅延作成し、Dispose() で解放しますが、factory が返すオブジェクトはコンテナーから解決されたものではなく new で生成されたものでした。ヘルスチェックのランナーは実行ごとに DI スコープを作成して破棄しますが、スコープが破棄するのは自分自身が作成したインスタンスだけです。チェックの Dispose() を呼ぶものは誰もいなかったため、プローブのたびに producer とポーリングスレッドが 1 つずつ残されていきました。

このコードは、Xabaril のヘルスチェックパッケージにある AddKafka(...) を意図的に避けていました。このヘルパーはシングルトンを登録するため、Kafka リソースが 2 つあると factory が最後に追加されたリソースの接続文字列を読んでしまうからです (Xabaril #2298)。この回避策は構成のバグを直した代わりに、有効期間のバグを持ち込みました。

修正: チェックの所有者を DI にする

PR #20092 (13.5.4 には #20094 としてバックポート) は、Kafka リソースごとにキー付きシングルトンを 1 つ登録し、factory がそれを解決するようにしています。

builder.Services.AddKeyedSingleton<KafkaHealthCheck>(healthCheckKey, (sp, _) =>
{
    var options = new KafkaHealthCheckOptions();
    options.Configuration = new ProducerConfig();
    options.Configuration.BootstrapServers = connectionString
        ?? throw new InvalidOperationException("Connection string is unavailable");
    return new KafkaHealthCheck(options);
});

var healthCheckRegistration = new HealthCheckRegistration(
    healthCheckKey,
    sp => sp.GetRequiredKeyedService<KafkaHealthCheck>(healthCheckKey),
    failureStatus: default,
    tags: default);

"{name}_check" をキーにすることで各リソースの bootstrap servers が分離され、producer はプローブ間で再利用され、AppHost のシャットダウン時にルートコンテナーが破棄します。実際の Kafka 8.2.0 ブローカー 2 台を使った PR の計測では、84 回のヘルスチェック実行で 13.5.3 はチェックのインスタンス 84 個とポーリングスレッド 84 本を生み、修正後はインスタンス 2 個とスレッド 2 本、破棄後に残ったスレッドは 0 本でした。

アップグレード

AppHost プロジェクトの Aspire パッケージを更新します。

<PackageReference Include="Aspire.Hosting.Kafka" Version="13.5.4" />

AppHost のコード変更は不要で、パブリック API の変更もありません。同じリリースでは、リージョンが自動選択された場合の DevTunnel の失敗 (13.3 で入ったリグレッション) も修正され、エミュレーターだけを使う AppHost では使われない azure-environment リソースが非表示になり、IAwsRadiusProviderBuilderIAzureRadiusProviderBuilder に実験的診断 ASPIRERADIUS003 が付きました。これらのインターフェースを直接参照している場合、新たな warning-as-error として現れる可能性があります。

自分のヘルスチェックにも同じパターンがないか確認する

このバグは Kafka 固有のものではありません。new SomethingDisposable(...) を返す HealthCheckRegistration の factory はどれも、プローブごとに 1 インスタンスをリークします。ヘルスチェック publisher の既定の周期である 30 秒で計算すると、チェック 1 つあたり 1 日に 2,880 個のオブジェクトがリークします。Aspire が今やっているようにチェックを DI に登録して factory で解決するか、高コストなクライアント (producer、接続、HttpClient) をシングルトンに置き、チェック自体は安価でステートレスなオブジェクトにしてください。まだ 13.5 系を使っているなら、WithTerminal() に関する以前の記事 で 13.5 のダッシュボードのその他の変更を紹介しています。

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る