🛠️

PHP で JSON API を開発中に Nginx が "502 Bad Gateway" を返す(成功することもある)怪現象の正体

に公開

PHP で簡易的な JSON API を開発しているときに、処理自体は完了しているはずなのに Nginx が 502 Bad Gateway を返す現象に遭遇しました。

しかも、毎回ではなく 「成功するときもある」 という厄介なパターン。
「再現性が低いからこそ原因が追いづらい」という、いちばん面倒なタイプです。

調査の結果、原因は 「PHP の Warning メッセージが標準出力に混入し、HTTP レスポンス(ヘッダや JSON ボディ)を壊していた」 ことでした。

この記事では、その原因と、「API として絶対に JSON を壊さないための出力制御」 を紹介します。

背景: PoC 用に「とりあえず動く API サーバー」が欲しかった

今回はガチ本番システムではなく、PoC(Proof of Concept/検証用)でサクッと試したいという文脈でした。

  • フルスタックなフレームワークを入れるのは重い
  • とりあえず 1 ファイルの PHP で API をしゃべらせたい
  • PHP初心者だけど、AIがいるからイケるでしょ

というノリで、

  • PHP ファイル 1 枚で簡易 API サーバー(ルーティングやレスポンスを直書き)
  • それを Nginx から叩く構成

を組んでいました。

PoC だとつい、display_errors = On のまま Warning / Notice が画面に出ても「まぁ検証だし」で気にしないとなりがちですが、そのまま Nginx 経由で API にすると一気に本番レベルの厳しさを食らいます。

環境と症状

  • 構成: Nginx + PHP(簡易的な JSON API)
  • 現象: 特定の API リクエストで 502 Bad Gateway
  • 特徴:
    • 常にエラーではなく、200 OK が返ることもある
    • アプリケーションログを見ると、処理は最後まで到達している

つまり、

アプリ側は「やり切ったつもり」なのに、Nginx から見ると「壊れたレスポンス」になっている
という状態でした。

原因: 標準出力への Warning 混入

Nginx の error_log と PHP のログを追い込んでいったところ、以下のような流れになっていることがわかりました。

  1. PHP 処理中に Undefined array key などの Warning が発生
  2. display_errors = On(またはデフォルト)により、その Warning 文が レスポンス本文に混ざる
  3. その後に header('Content-Type: application/json') や JSON ボディを出力
  4. 結果、FastCGI レスポンスの先頭に Warning テキストが混入し、Nginx が upstream sent invalid header と判定して 502 Bad Gateway を返す。

PHP から見れば「ただ Warning を出しただけ」でも、Nginx から見ると「プロトコル違反のレスポンス」になってしまう場合がある、ということです。

「成功することもある」理由はシンプルで、Warning が発生しないデータパターンのときは JSON が壊れないからでした。

対策: 徹底した「出力の無毒化」

API の世界では、意図しない出力(Warning、Notice、BOM、include ファイル末尾の改行など)は すべて毒 です。

これを防ぐために、コード側で以下の制御を行います。

  1. display_errors を無効化: Warning / Notice をレスポンスに出さない(ログへ出す)
  2. 出力バッファリング(ob_start(): すべての出力を一度バッファに溜める
  3. バッファの完全クリア(ob_end_clean(): JSON を出力する直前に、それまでに溜まった「ゴミ」を捨てる

解決コード例

PoC の 1 ファイル PHP API でも、この構成にしておけば安心です。

<?php // ← 1行目は必ずここから(前に空白や改行、BOMがあると ob_start 前に出力されてしまいます)

// ==============================
// 1. エラー出力をレスポンスに混ぜない
// ==============================
// コード側で画面出力を無効化(php.iniの設定を上書き)
ini_set('display_errors', 0);

// エラーはログへ(パスは環境に合わせてください)
ini_set('log_errors', 1);
ini_set('error_log', __DIR__ . '/php-error.log');

// ==============================
// 2. 出力バッファリング開始
// ==============================
// この時点より後の echo / Warning はすべてバッファに溜まる
$initialObLevel = ob_get_level();
ob_start();

// CORS / Content-Type は成功時・エラー時どちらでも付けておく
header('Access-Control-Allow-Origin', '*');
header('Access-Control-Allow-Headers', 'Content-Type');
header('Content-Type', 'application/json; charset=UTF-8');

try {
    // ==============================
    //  メイン処理
    // ==============================

    // 外部ファイル読み込み(ここで BOM や余計な改行があってもバッファされる)
    require_once __DIR__ . '/functions.php';

    // 何らかの処理
    // $data = ...; 

    // もしここで Warning が出ても、バッファに溜まるだけで Nginx には届かない

    $response = [
        'status' => 'success',
        'data'   => ['message' => 'Hello World'],
    ];

    // 日本語や URL を読みやすく保つオプション
    $json = json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

    if ($json === false) {
        throw new Exception('JSON encode failed: ' . json_last_error_msg());
    }

    // ==============================
    // 3. ここまでの出力をすべて捨てる
    // ==============================
    // 意図しない echo や Warning、BOM などを消去
    while (ob_get_level() > $initialObLevel) {
        ob_end_clean();
    }

    // ==============================
    // 4. クリーンな JSON だけを出力
    // ==============================
    echo $json;

} catch (Throwable $e) {
    // 例外発生時もバッファを掃除してからエラーJSONを返す
    while (ob_get_level() > $initialObLevel) {
        ob_end_clean();
    }

    http_response_code(500);
    
    // エラー詳細はログへ、レスポンスはシンプルに
    error_log((string) $e);
    
    echo json_encode([
        'error' => 'Internal Server Error',
    ]);
}

ポイント

  • while (ob_get_level() > ...): フレームワークや他のライブラリがバッファリングしている可能性も考慮し、自分が開いた分のバッファだけを確実に閉じます。
  • json_encode のオプション: デバッグ時にログを見やすくするため、ユニコードエスケープしない設定を入れています。

補足: Nginx ログでの見分け方

同じ現象にハマったとき、Nginx のログ設定を少し変えておくと切り分けが早くなります。

access_log に upstream status を出す

log_format main '... $status $upstream_status ...';

log_formathttp ブロック内に記述します。

これを見て、

  • $status502
  • $upstream_status200

となっている場合、「PHP は正常終了(200)したつもりだが、Nginx が受け取ったレスポンスが不正」というパターンが濃厚です。Warning 混入や BOM 混入を疑いましょう。

まとめ

PoC だからこそ、

  • サクッと PHP 1 ファイルで API をしゃべらせる
  • display_errors = On のまま開発する

という構成になりがちですが、Nginx 経由で叩き始めると一気に「502 の沼」にハマることがあります。

「Warning はログへ、レスポンスはバッファリングして最後にクリーンな JSON だけを出す」
この処理を挟むだけで、原因不明の 502 エラーに悩まされる時間は激減します。

Discussion