Azure Container Apps と Dapr を組み合わせると、マイクロサービス間通信や Durable Functions 連携がとても書きやすくなります。一方で、オートスケール(スケールアウト/スケールイン)が発生するタイミングだけ Dapr 呼び出しがランダムに失敗し、ログが真っ赤になる……という相談も少なくありません。本記事では、その正体と具体的な対策を「設計・コード・運用」の観点から詳しく整理します。
Azure Container Apps × Dapr で起きる「スケール時だけ不安定」問題とは
まず、症状を整理します。Azure Container Apps 上のアプリから Dapr サイドカー経由でサービス呼び出しを行っていると、普段は問題ないのに、負荷が上がってスケーリングが発生した瞬間だけ以下のようなエラーが増えるケースがあります。
- 突然、一部のリクエストが
Connection refusedやタイムアウトで失敗する - リトライすると通るが、ログ上はエラーが大量に出る
- Durable Functions のオーケストレーションでも同じ時間帯にスパイクが出る
典型的なパターンをまとめると、次の表のようになります。
| タイミング | コンテナ状態 | Dapr サイドカー状態 | よく出る症状 |
|---|---|---|---|
| スケールアウト直後 | アプリはすぐ起動し外部からのトラフィックを受け始める | まだ起動中・初期化中でヘルシーでない | Dapr 経由の呼び出しが 503 や接続エラーで落ちる |
| スケールイン中/直前 | アプリはまだ処理を継続中 | 先にサイドカーが終了してしまう | 途中から急に Dapr 呼び出しだけ失敗し始める |
| 激しいスパイク時 | コンテナの増減が頻発(スラッシング) | 複数インスタンスで同じ現象が同時多発 | 短時間に失敗率が跳ね上がり、Durable Functions なども巻き込まれる |
ポイントは、「アプリのプロセス」と「Dapr サイドカーのプロセス」は別コンテナであり、それぞれのライフサイクルが完全に同期しているわけではない、という点です。この“わずかなズレ”が、スケーリング時だけ表面化していると考えられます。
根本原因:コンテナと Dapr サイドカーのライフサイクルがずれている
Azure Container Apps では 1 つのアプリ(コンテナ)に対して、Dapr サイドカーが別コンテナとして横に立ちます。インスタンスが増えるとき・減るときの動きを時間軸でイメージすると、次のようなズレが発生しがちです。
スケールアウト時のズレ
- アプリコンテナは比較的早く立ち上がり、リクエストを受け付け始める
- しかし Dapr サイドカーは、その後で起動しコンポーネント読み込みや名前解決を実施
- アプリ側が「Dapr がもう使える」と思い込んで呼び出すと、まだ初期化中でエラー
つまり、「Dapr が完全にヘルシーになったことをアプリが確認する仕組み」がないと、起動直後はある程度の割合で失敗が混じるのはある意味必然です。
スケールイン時のズレ
- スケールインでインスタンスが削減される際、Dapr サイドカー側が先に停止に入る場合がある
- アプリ側はまだ処理中で、Dapr 経由の呼び出しを投げる
- その瞬間だけ「隣のサイドカーがいない」状態となり、接続エラーが発生
さらに、オートスケールのパラメーター設定がシビアすぎると、伸び縮みが短時間に何度も起きる「スラッシング」状態になり、上記の非同期が一気に顕在化します。
このため、根本原因は次の 2 つに整理できます。
- アプリが「Dapr が使えるタイミング」と「もう使えないタイミング」を知らない
- スケールの頻度が高すぎて、この問題があちこちで同時多発している
ここからは、これらを抑え込むための具体的な対策を、優先度順に見ていきます。
対策1:起動時に Dapr ヘルスチェック完了まで待つ
最も効果が高く、ほぼ必須と言えるのが「起動直後に Dapr がヘルシーになるまで処理を始めない」仕組みを入れることです。
Dapr のヘルスエンドポイントをポーリングする
Dapr には、サイドカーの状態を確認するためのヘルスエンドポイントがあります。HTTP の場合、デフォルトで次のような URL になります。
http://localhost:<DAPR_HTTP_PORT>/v1.0/healthz
アプリ起動時に、以下のような流れを実装します。
- DAPR_HTTP_PORT 環境変数からポート番号を取得する(なければ 3500 などのデフォルト)
/v1.0/healthzに対して一定間隔で HTTP GET を投げる- HTTP 200 が返ってきたら「Dapr Ready」とみなす
- そのタイミングまで、アプリは外向け処理を開始しない
.NET の簡易サンプル(コンソールアプリ/Minimal API 想定)は次のようになります。
static async Task WaitForDaprAsync(ILogger logger, CancellationToken ct)
{
var daprHttpPort = Environment.GetEnvironmentVariable("DAPR_HTTP_PORT") ?? "3500";
var daprHealthUrl = $"http://127.0.0.1:{daprHttpPort}/v1.0/healthz";
using var client = new HttpClient
{
Timeout = TimeSpan.FromSeconds(2)
};
var maxAttempts = 60; // 最大 2 分程度待つイメージ
for (var i = 1; i <= maxAttempts; i++)
{
try
{
var res = await client.GetAsync(daprHealthUrl, ct);
if (res.IsSuccessStatusCode)
{
logger.LogInformation("Dapr is healthy. (attempt {Attempt})", i);
return;
}
}
catch (Exception ex) when (i < maxAttempts)
{
logger.LogWarning(ex, "Dapr is not ready yet. (attempt {Attempt})", i);
}
await Task.Delay(TimeSpan.FromSeconds(2), ct);
}
throw new Exception("Dapr did not become healthy within timeout.");
}
アプリ内の「Ready フラグ」で外部トラフィックを制御する
さらに一歩踏み込むなら、アプリ内に「Ready フラグ」を持たせ、Dapr のヘルスチェックを通過するまで外部からのリクエストを受け付けない/503 で即時返す、といった制御を行うと安定度が上がります。
.NET Minimal API の例をイメージすると次のような構成です。
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<AppReadyState>();
var app = builder.Build();
app.MapGet("/health/ready", (AppReadyState state) =>
state.IsReady ? Results.Ok("READY") : Results.StatusCode(503));
app.MapPost("/do-something", async (AppReadyState state) =>
{
if (!state.IsReady) return Results.StatusCode(503);
// ここから先で Dapr 呼び出しを行う
});
var readyState = app.Services.GetRequiredService<AppReadyState>();
await WaitForDaprAsync(app.Logger, app.Lifetime.ApplicationStopping);
readyState.IsReady = true;
await app.RunAsync();
public class AppReadyState
{
public bool IsReady { get; set; }
}
このように、アプリの Ready 状態を自前で管理し、Dapr が準備できていない間はそもそも Dapr 呼び出しを行わないようにすることで、起動直後のエラーを大きく減らせます。
Container Apps の Readiness / Startup Probe と連動させる
さらに安定させるには、上記の /health/ready を Azure Container Apps の Readiness Probe に設定します。これにより、Ready エンドポイントが 200 を返すまでは、Azure 側がそのインスタンスにトラフィックを流しません。
構成ファイル例(イメージ)は次の通りです。
probes:
- type: readiness
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
このように、「アプリ内の Ready フラグ」→「/health/ready」→「Container Apps の Readiness Probe」がパイプラインとしてつながることで、Dapr 起動完了前にトラフィックが流れ込むのを防げます。
対策2:スケールイン時の graceful shutdown と Dapr 呼び出しの停止
次に、スケールインやデプロイによる停止のタイミングで、Dapr サイドカーが先に落ちてしまう問題を緩和します。ここで重要なのは、アプリが SIGTERM(またはプラットフォームからの終了シグナル)を適切に扱い、「新規リクエストを止める」「進行中処理をドレインする」という 2 段階の動きを取ることです。
終了フラグ(Draining フラグ)を立てて新規受付をブロック
スケールインでコンテナが停止される際、Azure Container Apps はコンテナに対して終了シグナルを送ります。このタイミングで、アプリ側では以下のような処理を行うのが理想です。
- 終了シグナルを受信したら、アプリ内の「Draining フラグ」を
trueにする - Draining 中は、新規リクエストに対して 429(または 503)+ Retry-After を返す
- 既に処理中のタスクについては、一定時間まで完了を待つか、キャンセルポリシーに従って中断する
.NET の例では、IHostApplicationLifetime や IHostApplicationBuilder の ApplicationStopping を購読してフラグを切り替えることがよくあります。
public class DrainState
{
public bool IsDraining { get; set; }
}
public static class DrainExtensions
{
public static void AddDrainHandling(this WebApplication app)
{
var drain = app.Services.GetRequiredService<DrainState>();
var lifetime = app.Lifetime;
lifetime.ApplicationStopping.Register(() =>
{
app.Logger.LogInformation("Start draining...");
drain.IsDraining = true;
});
}
}
これを利用して、API の入り口で Draining を判定します。
app.MapPost("/do-something", async (DrainState drain) =>
{
if (drain.IsDraining)
{
return Results.StatusCode(429); // または 503
}
// Dapr 呼び出しを含む処理
});
こうすることで、スケールイン開始後に新たな Dapr 呼び出しが増えることを防ぎ、「サイドカーが先に落ちた状態で新規タスクが大量に失敗する」という最悪パターンを避けられます。
進行中処理の「待つか諦めるか」を決めておく
Draining 中の進行中処理は、アプリの性質に応じて次のようなポリシーを決めておきます。
- 短時間で終わる処理:可能な限り完了まで待つ(完了したほうがユーザー体験が良い)
- 時間のかかる処理:一定時間でタイムアウトし、Durable Functions やキューなど上位レイヤーから再実行してもらう
いずれにせよ、「勝手に途中で死ぬ」のではなく、「意識的に止める/諦める」設計にすることが重要です。
対策3:リトライ戦略の強化とエラー分類
起動/終了の保護を入れても、ネットワーク由来の一時的なエラーや、スケールタイミングとわずかにかぶるケースまではゼロにできません。そこで次に大事になるのが、リトライ戦略の設計です。
指数バックオフ+ジッターでスパイクを抑える
「失敗したら即リトライ」を繰り返すと、短時間にリトライが集中し、逆にサーバー側を詰まらせる原因になります。一般的には「指数バックオフ+ジッター」が推奨されます。
- 1 回目:100ms 待って再試行
- 2 回目:200~400ms のランダム待ち
- 3 回目:400~800ms のランダム待ち
- …といった形で、待ち時間を増やしつつランダム化する
.NET では Polly を使うと、次のようにシンプルに書けます(イメージ)。
var retryPolicy = Policy
.Handle<HttpRequestException>()
.OrResult<HttpResponseMessage>(r => !r.IsSuccessStatusCode)
.WaitAndRetryAsync(
retryCount: 5,
sleepDurationProvider: attempt =>
TimeSpan.FromMilliseconds(100 * Math.Pow(2, attempt - 1))
+ TimeSpan.FromMilliseconds(Random.Shared.Next(0, 100)),
onRetry: (outcome, timespan, attempt, context) =>
{
logger.LogWarning("Retry {Attempt} after {Delay} due to {Reason}",
attempt, timespan, outcome.Exception?.Message ?? outcome.Result.StatusCode.ToString());
});
エラーの種類ごとにメトリクスを分けて観測する
「とにかく失敗したらリトライ」では、どこで何が起きているのか見えづらくなります。少なくとも次のくらいの粒度で失敗を分類し、メトリクスとして可視化しておくことをおすすめします。
| カテゴリ | 例 | 原因の傾向 |
|---|---|---|
| 接続エラー | Connection refused, DNS error | サイドカー未起動・ネットワーク断 |
| タイムアウト | TaskCanceledException など | 相手サービスの遅延・過負荷 |
| アプリケーションエラー | 4xx/5xx レスポンス | ビジネスロジック/バグ/入力値問題 |
接続エラーやタイムアウトがスケールタイミングでだけ急増しているのであれば、「ライフサイクル非同期」が原因候補としてかなり濃くなります。
冪等性を確保してリトライに強くする
リトライ戦略を強くするほど、「同じリクエストが複数回送られる」前提で設計する必要が出てきます。ID ベースの更新や一意制約、重複チェックなどを駆使して、同じ処理が 2 回実行されても破綻しないようにしておきましょう。
対策4:Durable Functions から Dapr を呼ぶときの作法
質問の中では「Durable Functions(ZIP 処理のオーケストレーション)でも同様のスパイクが出る」とありました。ここでは Durable Functions と Dapr を組み合わせる際の基本的な作法を整理します。
オーケストレーター関数から直接 Dapr を呼ばない
Durable Functions のオーケストレーターは「決定的」(deterministic)である必要があり、外部 I/O を直接行うことが推奨されていません。Dapr のサービス呼び出しも外部 I/O なので、オーケストレーターから直接呼ぶのは NG です。
代わりに、次のような構成が基本パターンになります。
- オーケストレーターからアクティビティ関数を呼び出す
- アクティビティ関数の中で Dapr 経由の HTTP / gRPC 呼び出しを行う
- アクティビティ関数内で前述のリトライポリシーやタイムアウトを実装する
こうすることで、「スケール由来の一時的なエラー」はアクティビティ関数内部のリトライで吸収し、Durable Functions のステートマシン全体には影響を与えづらくできます。
大きな ZIP 処理はタスクを分割し、同時実行数を制限する
大量のファイルを ZIP 化するような重い処理を 1 つのアクティビティで抱え込むと、そのアクティビティが複数同時実行されたときに、Dapr 経由のバックエンド負荷が一気に跳ね上がります。結果としてスケーリングが激しく発生し、今回のようなライフサイクル問題を誘発しやすくなります。
- ZIP 対象を小さなチャンクに分割したアクティビティにする
- オーケストレーター側で同時実行の上限を制御する
- バックエンドの処理能力に合わせて、必要であればキューやバッファを挟む
このように、Durable Functions の設計自体も「スケールを穏やかにする」よう工夫することで、Container Apps × Dapr の不安定さを抑え込むことができます。
対策5:Container Apps のスケール設定を見直してスラッシングを抑える
ここまでアプリ側の工夫を見てきましたが、プラットフォーム側(Container Apps)のスケール設定も非常に重要です。特に、スケーリングが頻発している環境では、多少リソースを多めに確保してでも安定を優先するほうが結果としてコストパフォーマンスが良くなるケースが多々あります。
minReplicas を 0 にしない(常時ウォームにする)
スケールアウト時の「初回起動」の影響を減らすには、最低インスタンス数 minReplicas を 1 以上にしておくのが有効です。これにより、常に少なくとも 1 台は Dapr サイドカーが起動済みの状態を維持でき、コールドスタートとサイドカー立ち上げ待ちの回数が減ります。
スケールアウト/インのしきい値とクールダウンを緩やかにする
HTTP リクエスト数やキュー長などをトリガーにスケーリングしている場合、「わずかな変動でもすぐスケール」してしまう設定だと、インスタンス数が上下に激しく振れてしまいます。代表的な調整ポイントは次の通りです。
| 項目 | 説明 | 安定化の方向性 |
|---|---|---|
| minReplicas | 最小インスタンス数 | 0 ではなく 1 以上にする |
| maxReplicas | 最大インスタンス数 | バックエンドが耐えられる範囲に制限する |
| ターゲット並行数 | 1 インスタンスあたりのリクエスト数目標 | あまり低くしすぎず、少し高めに設定して過度なスケールアウトを防ぐ |
| クールダウン/安定化ウィンドウ | スケールアウト/イン後、次の判断までの待ち時間 | 値を大きめにして、「落ち着いてから判断」するようにする |
「常にギリギリのリソースで運用する」よりも、「少し余裕を持って穏やかにスケールする」ほうが、結果的にはエラーも少なく運用コストも読みやすくなります。
監視と診断:どこを見れば原因を絞り込めるか
実運用環境で既に問題が起きている場合、「どこに何を見に行くか」を整理しておくとトラブルシュートが早くなります。代表的な観点をまとめました。
Dapr サイドカーのログとアプリログを突き合わせる
- サイドカーの起動ログ:いつからヘルシーになったか
- サイドカーの終了ログ:いつからリクエストを受けなくなったか
- アプリ側の Dapr 呼び出しログ:どのタイミングで接続エラーが出ているか
この 3 つのタイムスタンプを並べてみると、「起動直後の 30 秒だけエラーが出ている」「スケールイン開始から 10 秒後に急に失敗率が上がる」といったパターンが見えてきます。
Container Apps のスケールイベント/リビジョンイベントを見る
スケールイベント(インスタンス数の増減)やリビジョンのロールアウト情報と、アプリ・Dapr のエラー発生時刻を重ね合わせると、次のような仮説検証がしやすくなります。
- 毎回スケールアウト時にだけエラーが出ているか
- 特定のリビジョンに切り替わったときだけ症状が出ていないか
- スケールインのたびに同じようなエラーパターンが出ていないか
メトリクスと SLO ベースのしきい値チューニング
最終的には、「どれくらいの失敗率なら許容するのか」という SLO(サービスレベル目標)を決め、それを基準にリトライやスケール設定をチューニングしていくことになります。
- Dapr 呼び出しの成功率・レイテンシのメトリクス
- インスタンス数・CPU・メモリ・リクエスト数の推移
- リトライ回数・サーキットブレーカーの開閉回数
これらをダッシュボード化しておくと、「最近はスケールが安定しているから minReplicas を下げてみよう」「失敗率が許容値を超えているのでリトライ回数を増やす代わりにスケールアウトを早めよう」といった判断がしやすくなります。
実装パターン例:最小構成の「待つ+止める」コードをイメージする
ここまでの内容を踏まえて、「アプリ起動時に Dapr を待ち、終了時には新規受付を止める」という最小構成のイメージを簡単な疑似コードでまとめてみます。
class AppState
{
public bool IsReady { get; set; } // Dapr 準備完了済みか
public bool IsDraining { get; set; } // 終了処理中か
}
async Task MainAsync()
{
var state = new AppState();
// 終了シグナルのハンドリング
Console.CancelKeyPress += (_, __) => state.IsDraining = true;
// 起動時に Dapr を待つ
await WaitForDaprAsync();
state.IsReady = true;
// HTTP サーバー開始
await StartWebServerAsync(async context =>
{
if (!state.IsReady)
{
context.Response.StatusCode = 503;
return;
}
if (state.IsDraining)
{
context.Response.StatusCode = 429;
return;
}
// ここから先で Dapr を呼び出す
await CallViaDaprAsync();
});
}
実際のプロジェクトではフレームワークやライブラリを使うためもう少し複雑になりますが、考え方としてはこの 3 行で表現できます。
- 起動時:Dapr ヘルスチェックが OK になるまで待つ
- 動作中:Ready かつ Draining でないときだけ Dapr を呼び出す
- 終了時:Draining フラグを立てて新規受付を止めつつ、進行中処理をドレインする
この「待つ+止める」の 2 点をしっかり実装しておくだけでも、スケールタイミングに起因する Dapr の不安定さはかなり改善されます。
まとめ:ライフサイクル非同期を前提にした設計で、Container Apps × Dapr を安定運用する
Azure Container Apps と Dapr を組み合わせた構成では、スケールアウト/スケールインのたびにアプリコンテナと Dapr サイドカーのライフサイクルが微妙にずれます。この“ずれ”自体は避けられないものの、アプリ側でそれを前提にした設計・実装を行えば、実運用上ほとんど問題にならないレベルまで抑え込むことが可能です。
改めて、本記事で紹介した対策を整理すると次の通りです。
- 起動時に Dapr のヘルスチェック完了まで待つ
・/v1.0/healthzをポーリングし、ヘルシーになるまで外向け処理を始めない
・アプリ内の Ready フラグと Readiness Probe を連動させる - スケールイン/終了時の graceful shutdown を徹底する
・SIGTERM など終了シグナルを捕まえて Draining フラグを立てる
・Draining 中は新規リクエストを 429/503 でブロックする - リトライ戦略と冪等性で一時的なエラーを吸収する
・指数バックオフ+ジッター、サーキットブレーカーを導入する
・エラーを分類し、メトリクスとして可視化する - Durable Functions やバッチ処理の設計を見直す
・オーケストレーターから直接 Dapr を呼ばず、アクティビティで I/O を行う
・重い処理は分割し、同時実行数を制限する - Container Apps のスケール設定を安定寄りに調整する
・minReplicas を 0 にしない・クールダウンを長めにとる
・最大インスタンス数やターゲット並行数のバランスを取る
これらを組み合わせることで、「スケールアウト直後に Dapr がまだ準備できていない」「スケールイン中にサイドカーが先に落ちる」といった問題を大きく減らし、Durable Functions を含むマイクロサービス全体の安定度を高めることができます。
もし現在、Azure Container Apps 上で Dapr 呼び出しのエラーがスパイクしているようであれば、まずは「起動時の Dapr ヘルスチェック待ち」と「Draining フラグによる graceful shutdown」の 2 点から着手してみてください。そのうえで、リトライ戦略やスケール設定、Durable Functions の設計を少しずつ磨いていけば、ログが真っ赤になるような事態は着実に減らせるはずです。

コメント