IIS再起動後に.NET 8 Web APIが200 OKなのにレスポンスボディが空になる原因と対策

IIS 上の .NET 8 Web API で、IIS リセット後や長時間アイドル復帰後の「最初の1回だけ」HTTP 200 OK なのに JSON ボディが空になる――そんな不可解な症状に遭遇したら、IIS 設定を変えても直らないことがあります。本記事では再現条件、原因の考え方、そして即効性のある回避策を整理します。

目次

現象の整理:IIS 再起動/長時間アイドル後の「初回だけ」200 OK なのにレスポンスボディが空

今回のポイントは、「エラーではないように見えるのに、クライアントがデータを受け取れない」ことです。ステータスコードは 200 OK なので監視やアラートにも乗りにくく、ユーザー側では「たまに空データが返る」とだけ見えるため、原因特定が難しくなります。

観点内容
環境.NET 8 / ASP.NET Core Web API / IIS(Windows Server)
発生タイミングIIS リセット直後、アプリプール再起動直後、長時間アイドル後の最初のリクエスト
HTTP ステータス200 OK
レスポンスボディ空(JSON が返らない/クライアント側では body が空文字や 0 バイトに見える)
サーバー側ログ返すべきデータは生成できているように見える(結果オブジェクトは存在している)
IIS 設定の変更Start Mode: AlwaysRunning、Idle Time-out: 0 にしても改善しない

この手の症状は「最初の1回だけ」という条件が付くことが多く、アプリ起動直後の初期化(JIT、DI 構築、設定読み込み、DB 初回接続、キャッシュ初期化など)とレスポンス書き込みのタイミングが重なったときに顕在化しやすいのが特徴です。

まず切り分けで確認したいこと

本題の解決策(明示的にシリアライズして返す)に入る前に、再現条件と現象を「観測可能な形」にしておくと、対策の効果確認や再発防止が格段に楽になります。以下は、短時間でできる優先度の高い切り分けです。

確認項目狙い具体的な方法見るべきポイント
レスポンスヘッダー「本当にボディが 0 なのか」を確定するブラウザ開発者ツール / curl / Postman でレスポンスヘッダーを確認Content-Length が 0 になっていないか、Transfer-Encoding: chunked の場合にチャンクが送られているか
同一 API を複数回呼ぶ「初回だけ」の再現を確認アプリプール再起動後に 1 回目と 2 回目を連続で呼ぶ2 回目以降は JSON が返るのか(再現性が高いほど対策評価が容易)
クライアント側の受信コード「受け取ったのに捨てている」可能性を排除HttpClient のログ、レスポンスストリームの読み取り、例外を確認タイムアウト/キャンセルで途中破棄されていないか、gzip 展開エラーが出ていないか
サーバー側の例外ログヘッダー送出後の例外を疑うアプリのログレベルを引き上げ、例外ミドルウェアを確認「レスポンス開始後に例外」や「書き込み中断」の痕跡がないか
レスポンス圧縮初回だけ圧縮/展開でコケるケースを切る一時的に ResponseCompression を無効化、または Accept-Encoding を外して試す圧縮を切ると空ボディが消えるなら、圧縮パスの問題が濃厚

curl で「ヘッダーは出ているがボディがない」を確認する例

GUI ツールよりも、まずは curl で機械的に確認すると状況が読みやすくなります。特に -i(ヘッダー表示)と -v(詳細)を付けると、ボディが 0 バイトなのか、途中で接続が切れているのかを判断しやすくなります。

curl -i -v https://example.com/api/sample

「初回だけ 200 OK でボディ空」は、ネットワーク的には「ヘッダーは出たが、ボディが送られない/途中で途切れた」状態に近いことがあります。まずは Content-Length / Transfer-Encoding / Content-Encoding といった基本情報を押さえると、疑うべき範囲を狭められます。

なぜ「return Ok(result)」で起きて、「Content(json)」で回避できることがあるのか

ASP.NET Core(.NET 8 の Web API を含む)では、return Ok(result) のようにオブジェクトを返すと、内部的には 結果オブジェクト(ObjectResult) が作られ、最終的な JSON への変換とレスポンスボディへの書き込みは、MVC の出力フォーマッター(output formatter)がパイプラインの後段で実行します。

ここで重要なのが次の2点です。

  • ヘッダー送出のタイミングと、ボディ書き込みのタイミングがズレる(先に 200 が確定してヘッダーが出てから、JSON シリアライズと書き込みが行われる)
  • 初回アクセス時は初期化が多い(JIT、依存解決、設定読み込み、初回 DB 接続、各種キャッシュ、圧縮モジュールなど)

この条件が重なると、たとえば次のような「症状としては 200 だが、ボディが空になる」パターンが発生し得ます。

  • 出力フォーマッターの初期化中に例外が発生し、ヘッダーは 200 のままボディ書き込みが完了しない
  • レスポンス圧縮(gzip/br)が初回だけ初期化失敗し、ボディが破棄される/送出されない
  • レスポンス開始後にキャンセル(RequestAborted)が入って書き込みが中断される
  • IIS と ASP.NET Core の統合(ANCM)周りで、初回だけパイプラインが揺らぎ、書き込みが途中で止まる

「ヘッダーが先に確定してしまう」こと自体は仕様に近い動きです。問題は、その後段で何かが起きたときに ステータスコードを 500 に差し替えられないことです。レスポンスが開始した後に例外が起きると、フレームワークはステータスやヘッダーを変えられず、結果としてクライアントからは「200 なのに本文がない(または途中で切れた)」ように見えることがあります。

一方で Content(json, "application/json") は、コントローラー内で JSON 文字列を確定させてから返します。これにより、

  • 「オブジェクト → JSON 変換」の遅延を避けられる
  • 出力フォーマッターの複雑な分岐(コンテントネゴシエーション、メディアタイプ、エンコーディング等)を通らない
  • 少なくとも、アクションの時点でシリアライズ例外が表面化し、握りつぶされにくくなる

といった理由で、結果として「初回だけ空ボディ」という症状を回避できることがあります。

解決策:返却直前に明示的に JSON へシリアライズして Content() で返す

結論として、返却直前に明示的に JSON へシリアライズし、その JSON 文字列を Content() で返すことで解消するケースがあります。最小の変更で試せるため、再現性がある環境ではまず最初に当てたい手です。

[HttpGet]
public async Task<IActionResult> Get()
{
    var result = await _service.GetDataAsync();

    // 明示的に JSON 文字列化してから返す
    var json = System.Text.Json.JsonSerializer.Serialize(result);
    return Content(json, "application/json");
}

運用での安定性を優先するなら、Content-Type に charset を付けたり、シリアライズ設定を固定したりすると「環境差での揺れ」を抑えられます。

[HttpGet]
public async Task<IActionResult> Get()
{
    var result = await _service.GetDataAsync();

    var options = new System.Text.Json.JsonSerializerOptions
    {
        PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
        // 必要に応じて null の扱い、Enum の文字列化、日時形式などを固定
        // DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        // Converters = { new JsonStringEnumConverter() }
    };

    var json = System.Text.Json.JsonSerializer.Serialize(result, options);
    return Content(json, "application/json; charset=utf-8");
}

ポイント:この変更で「何が変わる」のか

  • 初回の重い処理の影響を、出力フォーマッターから切り離せる(結果としてボディが安定する)
  • シリアライズ例外が出るなら、その場でログに残しやすい(「200 だけ返って実際は失敗」の状態を作りにくい)
  • “返すべき文字列”が確定してからレスポンスを書くため、途中中断が起きても検知しやすい

この回避策が特に効きやすいケース

経験上、次のような条件が重なると「Content で返す」対策が刺さりやすいです。

  • 初回だけ不具合が出て、2 回目以降は安定する
  • サーバー側ではデータ生成はできているが、クライアントが受け取れない
  • エラーページや例外が表に出ず、監視上は 200 として扱われてしまう
  • レスポンス圧縮やコンテントネゴシエーションなど、出力パスに要素が多い

逆に、根本原因がネットワーク機器(リバースプロキシや WAF、ロードバランサ)やクライアント側の受信処理にある場合は、この対策だけでは改善しないこともあります。その場合でも「サーバー側で JSON を確定して返す」ことで、問題の切り分け材料にはなります。

アプリ実装で疑うべきパターン:レスポンスボディを触るミドルウェア

初回だけ空ボディが出るとき、意外と多いのが「レスポンスボディを横取りしてログに残す」系のミドルウェアです。例えば、

  • レスポンスを一度メモリ(MemoryStream)に書かせてから、本来のストリームへコピーする
  • 例外時だけコピーされず、結果的にボディが空になる
  • 初回だけストリームの差し替えや初期化が間に合わない

といった実装ミスが、まさに「200 なのに本文なし」を作ります。カスタムミドルウェアを入れている場合は、初回時に Response.Body を差し替えていないか、最後に確実に元へ戻しているか、コピー漏れがないかを確認してください。

注意点:Content() で返すときに知っておきたい落とし穴

便利な回避策ではありますが、Ok(object) と比べると挙動が変わる点があります。想定外の差分を作らないよう、次の点は押さえておくと安全です。

項目Ok(result) の世界Content(json) の世界対処の考え方
コンテントネゴシエーションAccept ヘッダー等に応じて自動調整基本的に固定(常に JSON 文字列を返す)API を JSON 固定で提供するなら問題になりにくい
JSON オプショングローバル設定(AddJsonOptions 等)が効く自分で options を渡さないと反映されないグローバルと合わせたい場合は options を共有する
レスポンスサイズストリーム書き込みで段階的に送られることもある一度文字列化するためメモリ使用量が増える巨大レスポンスの場合は別の根本対策(ウォームアップ等)も検討
レスポンスヘッダー一部はフレームワークが整備必要なヘッダーを自分で足す余地があるキャッシュ制御や ETag などが必要なら明示する

IIS / ホスティング設定で確認したいポイント

「.NET 8 Web API を IIS で運用」している場合、IIS 側の設定やホスティング形態の違いでも挙動が変わります。次の項目は、空ボディ問題の再発防止にも効きやすい確認ポイントです。

  • アプリケーションプールの .NET CLR バージョン:ASP.NET Core は通常「マネージド コードなし(No Managed Code)」で運用します
  • リサイクル設定:定期リサイクルが入っていると「初回相当」が意外なタイミングで発生します
  • 急なメモリ増加や CPU スパイク:初回処理が重すぎると、タイムアウトや切断の引き金になります

IIS 側でできる安定化策:ウォームアップ(Preload / Application Initialization)

「初回だけ」問題の王道対策は、初回アクセスの重い初期化を、ユーザーのリクエストにぶつけないことです。IIS にはアプリを事前に起動しておく仕組みがあり、適切に設定すると“初回リクエストだけ不安定”を減らせます。

Preload Enabled を有効化する

  • 対象サイトの Preload Enabled を有効にする
  • アプリケーションプールの Start Mode を AlwaysRunning にする

これにより、IIS がサイトの起動を前倒ししやすくなります(ただし環境や構成によっては、これだけではアプリ内部の初期化まで完了しない場合もあります)。

Application Initialization で“実際の URL”にウォームアップを投げる

Windows の機能として Application Initialization を追加し、起動時に特定 URL を叩くように設定すると、アプリの初期化をさらに前に進められます。ポイントは「ルート(/)を叩くだけ」ではなく、

  • DB 参照が走る
  • DI 解決が一通り行われる
  • JSON シリアライズが発生する

といった、“問題が出やすい経路”を意図的に温めることです。

設定のイメージとしては、web.config で Application Initialization の初期化ページを指定します(構成や環境で差が出るため、まずは検証環境で動作確認してください)。

<system.webServer>
  <applicationInitialization doAppInitAfterRestart="true">
    <add initializationPage="/api/warmup" />
  </applicationInitialization>
</system.webServer>

アプリ側でできる安定化策:初回コストを意識して潰す

IIS のウォームアップは強力ですが、「初回の中で何が重いか」を把握して潰していくと再発率がさらに下がります。特に .NET 8 の Web API は依存関係が多くなりがちなので、初回処理を観測して対策する価値があります。

よくある“初回だけ重い”原因

  • EF Core の初回クエリ(接続確立、メタデータ構築、モデル確定)
  • 外部 API の初回接続(DNS、TLS ハンドシェイク、HTTP/2 の初回確立)
  • 静的コンストラクタやシングルトンの初期化(大きな設定読み込み、正規表現のコンパイルなど)
  • JSON の初回シリアライズ(ジェネリック型のメタ情報構築、リフレクション、ソースジェネレータ有無)
  • レスポンス圧縮の初期化(辞書作成、プロバイダ初期化)

「ウォームアップ用の軽量エンドポイント」を用意する

本番運用での現実解として、ヘルスチェックとは別に「依存先を実際に触る」ウォームアップ用 API を用意し、IIS 起動直後やデプロイ直後に 1 回叩く運用にするケースもあります。例えば、

  • DB に軽い SELECT を 1 回投げる
  • 設定値や DI を一通り解決する
  • 代表的なレスポンス型を 1 回 JSON 化して捨てる

といった処理を短時間で終えるように作ると、ユーザーリクエストに初回コストが乗りにくくなります。

ログと監視:200 OK の裏で起きていることを見える化する

「200 OK なのにボディが空」は、アプリが例外を投げていても気づきにくい症状です。再発時にすぐ原因へ到達できるよう、ログと監視もセットで整備しておくのがおすすめです。

アプリ側ログで押さえたい観測点

  • アクション開始〜終了の時間(初回だけ極端に遅くないか)
  • シリアライズ直前のデータ件数、サイズ感(0 件ではないか)
  • 例外(特にレスポンス開始後の例外)
  • RequestAborted(クライアント切断)発生の有無

IIS 側で押さえたい観測点

  • IIS のログ(sc-status は 200 でも、cs-bytes / sc-bytes が極端に小さくないか)
  • Failed Request Tracing(FREB)で、どの段階で処理が途切れているか
  • アプリプールのリサイクル履歴(思ったより頻繁に落ちていないか)

開発・検証環境で役立つ stdout ログ

検証環境であれば、ASP.NET Core Module の stdout ログを一時的に有効化して「初回だけ何が起きているか」を掘る手もあります。ログが肥大化しやすいため、有効化したら必ず元に戻す運用が前提です。

ここまでやっても原因が掴めない場合でも、「Content() で返す」対策を入れておくと、少なくとも“初回だけ空ボディ”というユーザー影響を先に止血できます。その上で、ウォームアップや圧縮設定などの根本対策を段階的に当てていくと、安全に改善できます。

レスポンス圧縮(Compression)と Accept-Encoding 周りの注意

レスポンス圧縮は帯域削減に有効ですが、初回だけ空ボディが出るケースでは、圧縮の初期化や例外が絡んでいることがあります。特に次の条件が重なると、切り分け対象として優先度が上がります。

  • クライアントが Accept-Encoding: gzip, br を送る
  • サーバーが ResponseCompression を有効にしている
  • プロキシや CDN が間に入り、圧縮・解凍やヘッダーを書き換える

切り分けとしては、一時的に圧縮を無効化する、または 特定クライアントだけ Accept-Encoding を外して試すのが効果的です。圧縮が原因なら、空ボディの再現条件が変わる(出なくなる/頻度が落ちる)ことで見えてきます。

再発防止のチェックリスト

最後に、「初回だけ 200 OK なのにボディが空」を再発させないためのチェックポイントをまとめます。運用・環境ごとに当てはまるものから順に適用してください。

分類チェック目的
API 実装初回だけ不安定な API は、返却直前に JSON を明示シリアライズして Content() で返す空ボディの止血、シリアライズ例外の表面化
IISPreload Enabled / AlwaysRunning を見直す起動の前倒し
IISApplication Initialization でウォームアップ URL を叩く初回コストをユーザーから隔離
ミドルウェアレスポンスボディ差し替え系(ログ取得等)の実装を見直す「200 で空ボディ」を作る典型ミスを潰す
ミドルウェア例外ハンドリング(UseExceptionHandler 等)で「レスポンス開始後の例外」をログに残す200 の裏の失敗を見える化
圧縮ResponseCompression と Accept-Encoding を切り分け、必要なら設定を調整初回だけ圧縮で落ちるパターンを排除
外部依存DB/外部 API/キャッシュなど、初回接続をウォームアップで先に済ませる初回だけ遅い・不安定を減らす

よくある質問

Ok(result) に戻したいのですが、根本原因が直れば戻せますか?

可能です。まずは Content() で止血してユーザー影響を止め、ログやトレースで根本原因(圧縮、例外、IIS 統合、初回初期化など)を特定できた段階で、標準の Ok(result) に戻す流れが現実的です。戻す場合は、IIS リセット直後の連続リクエストで必ず再現確認を行ってください。

Minimal API の場合でも同じ考え方ですか?

同じです。「オブジェクトを返してフレームワークが後段で JSON 化する」パスで問題が出るなら、先に JSON を確定させて文字列として返す回避策は有効になり得ます。Minimal API でも、必要に応じて JsonSerializer.Serialize して返す方針は取れます。

AlwaysRunning と Idle Time-out: 0 でも起きるのはなぜ?

アプリプールが落ちない設定にしても、アプリ内部の初回処理(JIT、初回接続、初回シリアライズ、圧縮の初期化など)は残ります。また、デプロイや設定変更、IIS モジュールの再読み込み、サーバー再起動などで「初回相当」が発生することもあります。IIS 設定だけで消えない場合は、アプリ側の初回コストとレスポンス書き込みの相性を疑うのが近道です。

まとめ:初回だけ空ボディは「JSON 確定の前倒し」と「初期化の前倒し」で潰す

IIS 上の .NET 8 Web API で、IIS 再起動や長時間アイドル後に「最初のリクエストだけ 200 OK なのにレスポンスボディが空」になる症状は、出力フォーマッターの遅延実行と初回初期化が絡んで起きることがあります。まずは 返却直前に JSON へ明示シリアライズして Content() で返す対策でユーザー影響を止め、そのうえで Preload / Application Initialization、ログ強化、圧縮設定の切り分けなどを段階的に適用すると、安定運用に近づけます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次