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 のログを追い込んでいったところ、以下のような流れになっていることがわかりました。
- PHP 処理中に
Undefined array keyなどの Warning が発生 -
display_errors = On(またはデフォルト)により、その Warning 文が レスポンス本文に混ざる - その後に
header('Content-Type: application/json')や JSON ボディを出力 - 結果、FastCGI レスポンスの先頭に Warning テキストが混入し、Nginx が
upstream sent invalid headerと判定して 502 Bad Gateway を返す。
PHP から見れば「ただ Warning を出しただけ」でも、Nginx から見ると「プロトコル違反のレスポンス」になってしまう場合がある、ということです。
「成功することもある」理由はシンプルで、Warning が発生しないデータパターンのときは JSON が壊れないからでした。
対策: 徹底した「出力の無毒化」
API の世界では、意図しない出力(Warning、Notice、BOM、include ファイル末尾の改行など)は すべて毒 です。
これを防ぐために、コード側で以下の制御を行います。
-
display_errorsを無効化: Warning / Notice をレスポンスに出さない(ログへ出す) -
出力バッファリング(
ob_start()): すべての出力を一度バッファに溜める -
バッファの完全クリア(
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_format は http ブロック内に記述します。
これを見て、
-
$statusが 502 -
$upstream_statusが 200
となっている場合、「PHP は正常終了(200)したつもりだが、Nginx が受け取ったレスポンスが不正」というパターンが濃厚です。Warning 混入や BOM 混入を疑いましょう。
まとめ
PoC だからこそ、
- サクッと PHP 1 ファイルで API をしゃべらせる
-
display_errors = Onのまま開発する
という構成になりがちですが、Nginx 経由で叩き始めると一気に「502 の沼」にハマることがあります。
「Warning はログへ、レスポンスはバッファリングして最後にクリーンな JSON だけを出す」
この処理を挟むだけで、原因不明の 502 エラーに悩まされる時間は激減します。
Discussion