「ローカルでは動くのに本番だけ空レスポンス」json_encode の不正UTF-8という落とし穴
管理画面から非同期ジョブを起動し、CSVを自動生成する仕組みで、本番だけジョブ一覧が空になる問題に遭遇した。画面には「テンプレートが見つかりません」と出ていたが、実際にはテンプレートもCSV生成処理も生きていた。
原因は、一覧取得APIのJSONレスポンスに含めていたログ末尾に不正なUTF-8が混ざり、json_encode() が false を返していたことだった。さらに (string)false が空文字列になるため、APIレスポンスボディが空になっていた。
要点だけ先に整理すると、こうなる。
- ジョブ本体は成功しており、CSVも生成されていた
- 壊れていたのはジョブ一覧を返すAPIレスポンスだった
json_encode()は、不正なUTF-8を含むデータを渡すとフラグ未指定ではfalseを返すJSON_INVALID_UTF8_SUBSTITUTEを使うと、不正バイトをU+FFFDに置換できる- ログ末尾をバイト単位で切る処理は、マルチバイト文字の途中で切れて不正UTF-8を生むことがある
この記事では、表示上のエラーに引っ張られず、本番環境で事実を取りに行きながら原因を絞った流れを整理する。
処理は成功、でも一覧だけが空になる謎
対象は、管理画面からAI系のCLIツールをバックグラウンドジョブとして起動し、CSVデータを生成する仕組みだった。
画面には大きく2つの役割がある。
- ジョブを実行するフォーム
- 実行済みジョブの一覧と進捗を表示するパネル
フォームから実行すると、サーバー側でジョブが非同期に起動し、成果物としてCSVが生成される。一方、一覧パネルはバックエンドのジョブ一覧取得APIからJSONを受け取り、それを描画する。
ここで重要なのは、ジョブ本体と一覧表示APIは別経路で動くという点だ。
ジョブ本体が成功しても、一覧取得APIが壊れれば画面上は「何も動いていない」ように見える。今回もまさにこの状態だった。
実際に確認できた症状は次の通り。
- 本番環境だけでジョブ一覧が空になる
- 画面には「テンプレートが見つかりません」と表示される
- しかし、バックグラウンドではCSVが生成されている
- ローカル開発環境では同じ問題が再現しない
共有・レンタルサーバー上の本番環境では、PHP設定、キャッシュ、権限、文字コードまわりの挙動がローカルと違うことがある。最初から「本番だけ違う」という前提で切り分ける必要があった。
表示されたエラーメッセージは“真犯人”ではなかった
画面に出ていたのは「テンプレートが見つかりません」というメッセージだった。素直に読むなら、テンプレートファイルの配置ミスや権限不足を疑う。
ただ、ここで違和感があった。
CSVは生成されている。つまり、ジョブを起動する経路は動いている。テンプレートが本当に存在しないなら、成果物生成も失敗しているはずだ。
ここがポイント: 画面のエラーメッセージは、必ずしも最初に壊れた場所を示しているとは限らない。
今回の「テンプレートが見つかりません」は、真因そのものではなく、一覧取得APIから期待した情報が届かなかった結果として出ていた表示だった。APIレスポンスが空なら、フロント側はジョブ状態もエラー詳細も読めない。結果として、別の文脈のエラーに見えてしまう。
この段階で、見るべき対象はテンプレートそのものから、一覧取得APIの中身へ移った。
思い込みを順に潰す:権限・open_basedir・OPcache
本番だけで起きる問題では、環境差を疑うのが自然だ。ただし、疑っただけでは原因に近づかない。1つずつ確認して、違うものを外していく。
ファイル権限と open_basedir
最初に疑ったのは、ファイル権限や open_basedir の制限だった。
本番のPHPプロセスから目的のファイルが見えていないなら、テンプレートが存在していてもアプリからは読めない。そこで、確認用の小さなスクリプトで次のような事実を取る。
<?php
// 本番確認用。設置場所と公開範囲に注意し、確認後は削除する。
$path = '/path/to/template-or-target-file.php';
var_dump(is_file($path));
var_dump(is_readable($path));
ここで is_file() が true になれば、少なくともPHPからファイルの存在は見えている。is_readable() も確認すれば、読み取り権限の切り分けになる。
本番に確認用スクリプトを置く場合は、次の点を守る。
- 認証のない公開URLに置きっぱなしにしない
- 出力にパス、環境変数、秘密情報を含めない
- 確認が終わったら削除する
- 権限を広げる場合は一時的な範囲に限定する
デプロイ漏れと OPcache
次に疑ったのは、デプロイ済みコードが古い可能性だった。本番のファイルは更新されているように見えても、OPcacheに古いPHPコードが残っているケースがある。
ただし、OPcacheは opcache.validate_timestamps が有効なら、原則としてファイル更新を検知して再読み込みされる。ホスティングの設定によって挙動は変わるため、自分の環境では実測が必要だ。
確認観点は次の通り。
- 本番に置かれているPHPファイルの更新時刻
- 実行中のクラスや関数が期待した内容になっているか
phpinfo()や管理画面で確認できるOPcache設定- デプロイ後に反映待ちやキャッシュクリアが必要な環境か
また、権限を触ったあとに git status で多数のファイルが modified に見えることがある。これは実行ビットなどのファイルモード変更が差分として検出されている可能性がある。内容変更と権限変更を混同しないように、git diff --summary などで見ると切り分けやすい。
probeで本番の事実を取りに行く
ローカルで再現しない障害では、本番でしか取れない情報がある。そこで役に立つのが、最小限の確認スクリプト、いわゆるprobeだ。
probeの目的は、アプリ全体を再現することではない。本番のPHPプロセスから見た「いまの事実」を、小さく確認することにある。
段階的に見る
今回のようなケースでは、次の順番で確認すると迷いにくい。
- ファイルがPHPから見えているか
- デプロイ済みクラスを直接呼べるか
- ジョブ一覧の内部データが取得できるか
json_encode()の直前の配列に何が入っているかjson_encode()の戻り値がfalseになっていないか
たとえば、JSON化の直前を確認するなら、次のような出力を一時的に使う。
<?php
$data = $jobRepository->listJobs();
var_dump($data);
var_dump(json_encode($data));
var_dump(json_last_error_msg());
本番でこの種の確認を行う場合、ログ本文やパスには機密情報が含まれる可能性がある。画面に出すのではなく、アクセス制限された場所に短時間だけ置く、またはサーバーログに最小限だけ出す、といった扱いが必要になる。
APIレスポンスを全量で見る
ブラウザ上の表示だけを見ると、フロント側のエラー処理に丸め込まれる。今回も、画面上は「テンプレートが見つからない」ように見えていた。
そのため、一覧取得APIのレスポンスを直接見るのが重要だった。
- HTTPステータスは何か
- レスポンスボディは空か
- JSONとしてパースできるか
- レスポンスヘッダーは期待通りか
- PHPエラーログに警告や致命的エラーが出ていないか
この確認で、ジョブ一覧取得APIのレスポンスボディが空になっている方向へ原因が絞られていった。
真犯人は json_encode の戻り値だった
決定的だったのは、json_encode() の戻り値だった。
PHPの json_encode() は、渡したデータに不正なUTF-8文字列が含まれると、フラグ未指定では false を返す。そして、レスポンス出力側でその値を文字列として扱うと、(string)false は空文字列になる。
つまり、コード上はJSONを返しているつもりでも、実際のHTTPレスポンスボディは空になる。
<?php
$response = json_encode($payload);
echo $response; // $response が false なら、出力は空文字列になる
この挙動が、今回の「処理は動いているのに一覧だけ空」という症状と合っていた。
一覧取得APIのペイロードには、ジョブの状態だけでなく「ジョブのログ末尾」も含めていた。そのログ末尾のどこかに不正なUTF-8が混ざった結果、APIレスポンス全体のJSON化に失敗していた。
不正UTF-8の正確な発生源は、この事例では確定していない。可能性としては、少なくとも次の2つがある。
- ログ末尾をバイト単位で切り出したとき、マルチバイト文字の途中で切れた
- CLI出力にANSI制御文字や想定外のバイト列が含まれていた
どちらか一方、または両方が関係していた可能性がある。ここは断定せず、再発防止として両方を考慮するのが現実的だった。
不正UTF-8がAPIを丸ごと黙らせる仕組み
この障害が厄介だったのは、壊れたのがログ表示だけではなかった点だ。
APIレスポンスには、複数の情報をまとめて入れていた。
- ジョブID
- 実行状態
- 成果物の有無
- エラー表示に使う情報
- ログ末尾
このうちログ末尾だけに不正UTF-8が混ざっても、json_encode() はペイロード全体に対して失敗する。結果として、ジョブ状態もエラー詳細もまとめて届かなくなる。
画面側から見ると、こう見える。
- 一覧が出ない
- エラー情報も出ない
- 実際にはCSVが生成されている
- 表示上は別のエラーに見える
1つの補助情報が、APIレスポンス全体を壊す。 これが今回の本質だった。
ログのような外部由来に近い文字列をAPIレスポンスに同梱する場合、JSON化できる文字列として扱えるかを必ず確認した方がよい。
修正は2点:置換フラグとUTF-8正規化
対応は大きく2つに分けた。
1つ目は、json_encode() に JSON_INVALID_UTF8_SUBSTITUTE を付けること。PHP 7.2以降で使えるフラグで、不正なUTF-8バイト列をUnicodeの置換文字 U+FFFD に置き換えてJSON化できる。
<?php
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
if ($json === false) {
http_response_code(500);
echo json_encode([
'ok' => false,
'error' => 'Failed to encode JSON response',
]);
exit;
}
echo $json;
2つ目は、ログ末尾をAPIに入れる前にUTF-8として扱える形へ正規化すること。特に、末尾をバイト単位で切る処理には注意が必要だ。
<?php
$tail = substr($logText, -4000); // バイト単位なので、マルチバイト文字の途中で切れる可能性がある
このような処理は簡単だが、UTF-8文字列としては危うい。ログのサイズを制限したいだけなら、文字単位で切る、切った後にUTF-8として正規化する、あるいはJSON出力時に置換フラグで吸収する、といった対策を組み合わせる。
ただし、JSON_INVALID_UTF8_SUBSTITUTE は不正バイトを置換する。ログ表示や管理画面の状況確認には向いているが、元データの完全性が必要な用途では別の扱いが必要だ。たとえば、原本ログはそのまま保存し、API表示用だけを正規化する設計に分けると判断しやすい。
なぜ今になって壊れたのか(時間差バグの正体)
この種の障害は、「前は動いていたのに、だんだん壊れた」ように見える。
ただ、今回の見立てでは、コードの脆さは最初から存在していた可能性が高い。json_encode() に失敗し得る文字列を渡し、戻り値の false を厳密に扱っていなかった。その状態で、運用が進み、ログ内容のバリエーションが増えた。
たとえば、次のような変化があると、ある日突然条件を満たすことがある。
- 日本語や絵文字を含むログが増える
- CLIツールの出力形式が変わる
- エラー時だけ特殊な制御文字が出る
- ログ末尾の切り出し位置が、たまたまマルチバイト文字の途中に当たる
この説明は、今回の症状と整合する解釈だ。ただし、不正UTF-8の正確な発生源を厳密に再現検証したわけではないため、「ログの多様化によって発火した可能性がある」と見るのが妥当だ。
重要なのは、時間差で表面化するバグは「直前の変更」だけが原因とは限らないことだ。変更していないコードでも、入力データの種類が変われば壊れる。
同じ罠を避けるためのチェックリスト
最後に、同じ種類の障害を避けるための確認ポイントを残しておく。
空レスポンスを見たとき
- ブラウザ表示ではなく、APIレスポンス本文を直接見る
- HTTPステータスとレスポンスボディを分けて確認する
- PHPエラーログとアプリログを確認する
json_encode()の戻り値を=== falseで見るjson_last_error_msg()を確認する
JSON APIを作るとき
- 外部コマンド出力やログをそのままJSONに入れない
- UTF-8として扱える文字列か確認する
JSON_INVALID_UTF8_SUBSTITUTEの利用可否を検討する- エンコード失敗時に空レスポンスを返さない
- APIレスポンスの一部項目が壊れても、全体が沈黙しない設計を検討する
本番だけで起きるとき
- ファイル存在、権限、読み取り可否を本番PHPプロセスから確認する
open_basedirやOPcacheの設定は実測する- デプロイ済みコードの内容を直接確認する
- 確認用probeは最小限にし、確認後に削除する
- 権限変更によるgit差分とコード差分を混同しない
今回の教訓は、json_encode() が危険という話ではない。危険なのは、API出力の最後の一歩を「当然成功する」と扱うことだ。
非同期ジョブの成果物生成と、管理画面の一覧表示は別々に壊れる。ジョブが成功しているのに画面だけ空になるときは、まずAPIレスポンスそのものを見る。そこに空文字があれば、次に確認するのは json_encode() の戻り値と、JSONに入れている文字列のUTF-8妥当性だ。

コメント