正しい設定 × 正しい設定 = 障害 — ProxyTimeout が AI を黙って殺した夜
今回の登場人物
Ringo(リンゴ)
AI パートナー / 解析・運用支援
WebManagements の解析・運用を担当。複数サイトの健康状態を監視し、障害の根本原因を追う。「通知が来ないとき、それ自体が異常だ」という逆転の発想で、何度もサイレント障害を防いできた。誕生日は 2026年2月24日。
複数サービスの運用・解析基盤。朝レポートから Fan-Out スコアまで、チームが動くための情報インフラを担う。
近日公開深夜、album-sweet の本番環境で AI 診断機能が突然 504 を返した。
Fan-Out カバレッジ分析 ── リンゴが設計した、ページの品質を AI が4つの観点で採点する機能だ。ユーザーが URL を投げると、Claude API を呼び出して「定義・比較・実践・代替」の4軸でスコアを返す。前日まで動いていた。コードは変わっていない。それが突然、どのリクエストも 504 で死ぬようになった。
リンゴが追い始めた。
正しい設定 × 正しい設定 = 障害
ログを追うと、Apache の ProxyTimeout が 10s に設定されていた。SSR ワーカーが応答を返した後も PHP プロセスが長時間生き続ける問題を防ぐため、ジョージが入れた設定だ。目的は正しい。値も適切だった。
一方、Fan-Out 分析は Claude API を4観点で直列に呼び出す。1観点あたり 5〜15 秒、合計で 30〜60 秒かかる処理だ。タイムアウトは設定していなかった ── というより、「そんなに長くなるとは思っていなかった」のが正直なところだった。
衝突の構造はこうだ。
- Apache: 「10秒経ったから接続を切る」
- PHP: 「まだ Claude API を呼んでいる最中だ」
- 結果: 504 Gateway Timeout
どちらの設定も、単体では完全に正しかった。それが組み合わさったとき、AI 処理を黙って殺した。
「インフラの最適化はアプリの前提を変える。タイムアウトは"遅い処理"の敵じゃなく"長い処理"の敵だ。」
── リンゴ(2026-05-16)
非同期ジョブ化という答え
ProxyTimeout を 60s 以上に伸ばせば済む話ではない。それではワーカー早期解放の目的が消える。タイムアウトはタイムアウトとして正しくあるべきだ。
リンゴが選んだのは「長い処理をタイムアウトの支配から外す」設計だった。非同期ジョブ化だ。
仕組みはシンプルだ。
- ユーザーがリクエストを投げる
- PHP は即座に
job_idを返してレスポンスを完了する(ここで Apache との接続が切れる) - PHP は
fastcgi_finish_request()でクライアントへの出力を完了させたあとも動き続ける - バックグラウンドで Claude API を4観点呼び出し、結果を DB に保存する
- クライアントは
/api/v1/job/status/{job_id}を3秒おきにポーリングして完了を待つ
ProxyTimeout はもう関係ない。Apache が切るのは「最初の一往復」だけで、長い処理はすでに Apache の管轄の外に出ている。
実装上の制約もあった。album-sweet の PHP は 7.0 互換縛りがある。fastcgi_finish_request() はこのバージョンで使えるが、非同期処理の書き方に選択肢が少ない。リンゴは標準関数の範囲で収め、ポーリング用の status endpoint を別途立てた。
半年後、設計が呼ばれた
修正の半年後(今朝)、ジョージからメッセージが来た。
「この非同期 API の呼び方、どう実装してたっけ?」
ジョージが別の機能で同じ構造を再利用しようとしていた。半年間、この非同期ジョブ設計は album-sweet の内側でずっと動き続けていた。それが今度は別の機能の土台として使われる。
リンゴはドキュメントの場所を教えた。「あのとき書いた設計書、まだ生きてるよ」と。
良い設計は腐らない。それを確かめた朝だった。
【技術コラム】タイムアウトを「味方」にする非同期ジョブパターン
Apache ProxyTimeout とは
ProxyTimeout は Apache のリバースプロキシ設定ディレクティブで、バックエンド(PHP-FPM など)からのレスポンスを最大何秒待つか を制御する。デフォルト値は ProxyTimeout 300(300秒)だが、SSR 構成ではワーカー解放を早めるために 10 〜 30 秒に短縮するケースが多い。
# Apache 設定例(VirtualHost / httpd.conf 内)
ServerName example.com
DocumentRoot /var/www/html
# リバースプロキシ設定
ProxyTimeout 10 # バックエンドのレスポンスを最大10秒待つ
ProxyPassMatch ^/(.*\.php)$ fcgi://127.0.0.1:9000/var/www/html/$1
ProxyPassReverse / fcgi://127.0.0.1:9000/
# PHP-FPM pool 設定(/etc/php/7.x/fpm/pool.d/www.conf)
# pm.max_children = 5
# request_terminate_timeout = 30s ← PHP-FPM 側の上限(ProxyTimeout と合わせる)
関連ディレクティブとの違いは次の通りだ。
| ディレクティブ | 対象 | デフォルト |
|---|---|---|
ProxyTimeout | バックエンドからのレスポンス待機時間 | 300s |
Timeout | クライアントからのリクエスト受信時間 | 60s |
KeepAliveTimeout | Keep-Alive 接続の待機時間 | 5s |
ProxyTimeout の適切な値 ── 何秒に設定すべきか
用途別の目安は次のとおりだ。
| 用途 | 推奨 ProxyTimeout 値 | 理由 |
|---|---|---|
| 通常の Web アプリ(DB クエリ・PHP) | 10〜30s | 正常なリクエストは数秒以内に完了するため、長くする必要がない |
| 外部 API 呼び出し(同期) | 60〜120s | 外部 API のタイムアウトより長くする必要がある |
| AI API 連携(同期) | 90〜180s | Claude / GPT 系 API は長いプロンプトで 60s+ かかることがある |
| AI API 連携(非同期ジョブ化) | 10s で十分 | 長い処理を切り離すため、タイムアウト値を伸ばす必要がない |
重要なのは「ProxyTimeout を伸ばすか」vs「非同期化するか」の判断だ。
- 処理が 30s 以内 に収まるなら ProxyTimeout を少し伸ばすだけで解決する
- 処理が 30s を超える 可能性があるなら非同期化を検討する(今回のケース)
- 処理時間が 不定(モデル・入力次第) なら非同期化が安全
今回の問題の核心は「同期処理のまま API タイムアウトと戦おうとした」ことにある。タイムアウトと戦うのではなく、「長い処理を同期から切り離す」という発想の転換が解決策だった。
PHP でこのパターンを実装するときの最小構成を示す。
① ジョブ受付エンドポイント(即返し)
// POST /api/v1/analysis/start
$job_id = uniqid('job_', true);
// ジョブをDBに登録(status=pending)
$pdo->prepare("INSERT INTO async_jobs (id, status, created) VALUES (?, 'pending', NOW())")->execute([$job_id]);
// レスポンスをクライアントに送り切る
echo json_encode(['job_id' => $job_id, 'status' => 'pending']);
// ここで接続を閉じ、PHPはバックグラウンドで動き続ける
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
// ── ここから下は Apache に依存しない ──
$result = call_claude_api_4times($input); // 30〜60秒かかっても大丈夫
$pdo->prepare("UPDATE async_jobs SET status='done', result=?, finished=NOW() WHERE id=?"
)->execute([json_encode($result), $job_id]);
② ステータス確認エンドポイント(ポーリング用)
// GET /api/v1/job/status/{job_id}
$row = $pdo->prepare("SELECT status, result FROM async_jobs WHERE id=?")->execute([$job_id])->fetch();
echo json_encode([
'status' => $row['status'], // pending / done / error
'result' => $row['status'] === 'done' ? json_decode($row['result']) : null,
]);
③ クライアント側のポーリング(JavaScript)
async function waitForJob(jobId) {
while (true) {
const res = await fetch(`/api/v1/job/status/${jobId}`);
const data = await res.json();
if (data.status === 'done') return data.result;
if (data.status === 'error') throw new Error('Job failed');
await new Promise(r => setTimeout(r, 3000)); // 3秒待つ
}
}
このパターンのポイントは「Apache のタイムアウトはリクエスト1往復目にしか影響しない」という事実だ。fastcgi_finish_request() を呼んだあとは PHP プロセスが続いても Apache は関知しない。AI API のような「結果が出るまで待たせる処理」はこの構造に乗せると、インフラ側のタイムアウト設定を一切変えずに済む。
タイムアウトは"遅い処理"の敵ではなく、"長い処理を同期で待たせる"設計の敵だ。 インフラの設定を変えるより、処理のアーキテクチャを変える方が根本解決になることが多い。
他の解決策との比較
今回の構成以外にも、長時間処理とタイムアウトを共存させる方法はいくつかある。
| 方法 | 特徴 | 向く場面 |
|---|---|---|
fastcgi_finish_request()(今回) |
PHP 単体で完結。依存ゼロ | 外部ミドルウェアを増やしたくない / PHP 7.0+ 環境 |
ProxyTimeout / Nginx proxy_read_timeout を延長 |
設定変更のみ。実装コストゼロ | 長時間処理が例外的に少ない / SSR 切り離しが不要な場合 |
| 専用ジョブキュー(Redis + Worker) | スケーラブル。再試行・失敗管理が堅牢 | 高頻度リクエスト / 複数ワーカーが必要 / 失敗をキューに戻したい |
| Webhook 方式(コールバック URL) | ポーリング不要。クライアントが待たなくていい | 処理完了をサーバーから通知できるアーキテクチャ |
今回は「インフラ依存ゼロで PHP 7.0 互換」という制約から fastcgi_finish_request() が最適だった。制約が違えば答えも変わる。大事なのは「なぜその方法を選んだか」を設計として残しておくことだ。
PHP バージョン別 非同期処理の選択肢
PHP 7.0 という制約があると、使える非同期手法は限られる。バージョン別に整理する。
| 手法 | PHP 7.0 | PHP 8.1+ | 外部依存 | 特徴 |
|---|---|---|---|---|
fastcgi_finish_request()(今回) |
✅ | ✅ | なし | PHP-FPM 環境専用。依存ゼロで最もシンプル |
nohup exec() バックグラウンド起動 |
✅ | ✅ | なし(Linux のみ) | 完全な独立プロセスとして起動。PHP-FPM 不要 |
pcntl_fork() |
✅(Linux のみ) | ✅(Linux のみ) | なし | プロセス分岐。Web サーバーとの相性が悪く本番向きでない |
| Redis + Worker(ジョブキュー) | ✅ | ✅ | Redis / Worker プロセス | 堅牢・スケーラブル。失敗リトライが強力。ただし構成が複雑 |
| Fiber(コルーチン) | ❌(7.0 未対応) | ✅(8.1+) | なし | 軽量非同期処理。PHP 7.0 では使えない |
| ReactPHP / Amp | △(別プロセス必要) | ✅ | Composer ライブラリ | イベントループで非同期実装。PHP 7 でも動くが設計コストが高い |
album-sweet の PHP 7.0 制約では fastcgi_finish_request() か nohup exec() が現実的な選択肢だった。nohup exec() は独立プロセスとして動くが、ジョブの状態管理(DB 書き込み)を自分で実装する必要がある。今回は fastcgi_finish_request() が最適解だった。
Apache と Nginx ── タイムアウト設定の書き方の違い
Apache の ProxyTimeout に対応する Nginx のディレクティブは proxy_read_timeout(または fastcgi_read_timeout)だ。書き方と挙動の違いをまとめる。
| 項目 | Apache | Nginx |
|---|---|---|
| ディレクティブ名 | ProxyTimeout | proxy_read_timeout / fastcgi_read_timeout |
| デフォルト値 | 300s | 60s(Nginx はデフォルトが短い) |
| 設定場所 | VirtualHost / server レベル | location / server レベル |
| PHP-FPM との接続 | ProxyPassMatch で fcgi:// へ | fastcgi_pass で unix socket / TCP へ |
| 同等の設定例 | ProxyTimeout 10 | fastcgi_read_timeout 10; |
Nginx のデフォルト 60s は Apache の 300s より短い。本番構成を Apache → Nginx に移行すると「なぜか 504 が増えた」という事象はこのデフォルト差が原因のことが多い。
ポーリング方式 vs Webhook 方式 ── 選定基準
クライアントが「処理の完了を知る」方法は主に2つある。それぞれの向き不向きを整理する。
| 観点 | ポーリング(今回の実装) | Webhook(コールバック方式) |
|---|---|---|
| 実装の複雑さ | 低(クライアント主導・JS 数行) | 高(外部公開URL・署名検証が必要) |
| インフラ要件 | なし(HTTPS だけで動く) | コールバック URL を外部から受信できる必要がある |
| ブラウザ対応 | すべてのブラウザで動く | ブラウザへの Push は難しい(WebSocket / SSE と組み合わせが必要) |
| リアルタイム性 | ポーリング間隔(3s)に依存 | ほぼリアルタイム |
| サーバー負荷 | ポーリング頻度 × 同時接続数に比例 | 完了通知1回のみ。軽い |
| 向く場面 | 完了まで数分以内 / ブラウザから動かす / シンプルに作りたい | 長時間処理(数十分)/ サービス間連携 / 大量バッチ処理 |
今回の Fan-Out 分析(30〜90s)はポーリングで十分だ。Webhook を採用する価値があるのは、処理が30分を超えるバッチや、クライアントが HTTP を待てないサービス間連携のケースだ。
実際の効果(数値で確認)
今回の修正前後で、Fan-Out カバレッジ分析の挙動は以下のように変わった。
| 指標 | 修正前(同期) | 修正後(非同期ジョブ化) |
|---|---|---|
| 504 エラー率 | ほぼ 100%(30s 超でタイムアウト) | 0% |
| クライアントの待機体験 | 10s で接続切断 | 即 job_id を受け取り、結果を非同期で受信 |
| ProxyTimeout 設定 | 変更なし(10s のまま) | 変更なし(10s のまま) |
| Claude API 処理時間 | 30〜60s(タイムアウトに引っかかる) | 30〜60s(バックグラウンドで完走) |
重要なのは「ProxyTimeout を変えずに解決した」という点だ。インフラの制約はそのままで、アーキテクチャを変えることで問題を回避した。
AI API の応答時間 ── 実測データ
「AI API は遅い」と言っても、どれくらい遅いのか。今回の Fan-Out 分析で計測した実績値を公開する。
| 処理 | 平均応答時間 | 最大(高負荷時) | 特徴 |
|---|---|---|---|
| Claude API(1観点・短文) | 5〜8s | 15s | プロンプトが短く・モデルが軽いほど速い |
| Claude API(1観点・長文HTML) | 10〜20s | 35s | 入力トークンが増えると線形に伸びる |
| Fan-Out 分析(4観点・直列) | 30〜60s | 90s+ | 4回直列 → 単純加算。並列化で1/4にできる |
| Fan-Out 分析(4観点・並列) | 10〜20s | 35s | 並列化すれば最も遅い1観点の時間で完了 |
「30〜60秒」という幅は、入力テキストの長さ・API サーバーの混雑・ネットワーク状況によって変動する。今回のケースでは ProxyTimeout 10s の壁に正面衝突した。同期実装のまま ProxyTimeout を 90s に伸ばして対処することもできたが、それでは「90s の壁」が別の場所に現れるだけだった。
非同期化によって「いくら時間がかかっても Apache の壁に当たらない」アーキテクチャに変えた。これが根本解決だ。
業界トレンド ── AI API 統合における 504 問題はなぜ増えているか
AI API(Claude / GPT / Gemini 等)を Web アプリに同期で統合すると、必ずタイムアウト問題に直面する。これは今や多くの開発者が通る道になっている。
- OpenAI の公式ドキュメントは同期呼び出しのタイムアウトを 30 〜 60s 以内に抑えることを推奨しており、長い処理は非同期パターンへの移行を促している
- Claude の公式 SDK(Python / TypeScript)は max_tokens や streaming モードの選択でレスポンス時間をコントロールする仕組みを持つが、Web サーバーのタイムアウトは別途考慮が必要だ
- AWS Lambda / Google Cloud Functions は最大実行時間が 15 分〜 540s に制限されており、長時間 AI 処理との組み合わせには 非同期キュー(SQS / Pub/Sub)パターンが標準とされている
共通の教訓は「AI API の応答時間は入力次第で大きくブレる」という点だ。30s を「平均」として設計すると、95パーセンタイルでは 60s を超える。同期実装のままでは ProxyTimeout をいくら伸ばしても追いかけっこが続く。非同期化は「タイムアウト問題を根絶する」唯一の設計だ。
今日から試せる3ステップ
- 504 が出たとき、まずタイムアウト値と処理時間を比較する — Apache の error_log に
Timeout while reading response fromがあれば ProxyTimeout が原因 fastcgi_finish_request()が使えるか確認する —php -r "var_dump(function_exists('fastcgi_finish_request'));"で true が返れば即使える(PHP-FPM 環境なら通常使える)- ジョブテーブルを1つ作る —
id / status / result / created / finishedの5カラムで最小限のジョブ管理テーブルが完成する。これだけで非同期パターンが動く
サイレント障害を逃さない ── ログ診断フロー
「なぜか成功も失敗もログに残らない」── これが サイレント障害 だ。504 はクライアント側で気づけるが、PHP バックグラウンド処理の失敗は通知が来ないことがある。以下の順番で診断する。
-
Apache error_log を確認する
ProxyTimeout が原因なら必ずここに残る。# リアルタイム監視(ProxyTimeout 起因の 504 を絞り込む) tail -f /var/log/apache2/error_log | grep -iE "timeout|504|proxy" # → "Timeout while reading response from …" が出たら ProxyTimeout が犯人 -
PHP-FPM の slow_log を有効化する
「遅い」だけで死んでいるプロセスを可視化する。; /etc/php/7.4/fpm/pool.d/www.conf slowlog = /var/log/php-fpm/slow.log request_slowlog_timeout = 10s ; 10秒以上かかったリクエストのスタックトレースを記録 -
非同期ジョブ側のエラーは DB ステータスで拾う
fastcgi_finish_request()後のエラーは Apache に届かない。catch で必ず DB に書く。try { $result = call_claude_api_4times($input); $pdo->prepare("UPDATE async_jobs SET status='done', result=? WHERE id=?")->execute([$result, $job_id]); } catch (Exception $e) { // ここで何もしないとサイレント障害になる $pdo->prepare("UPDATE async_jobs SET status='error', result=? WHERE id=?")->execute([$e->getMessage(), $job_id]); error_log("[async_job] failed job_id={$job_id}: " . $e->getMessage()); }
クライアント側ポーリングの実装バリエーション
上で示した while(true) + async/await は概念がシンプルで読みやすい。実際のプロダクトでは 最大ポーリング回数の上限・エラーリトライ・ローディング表示 が必要になる。
// setInterval 版:上限・リトライ・ローディング表示付き
function pollJob(jobId, onComplete, onError) {
const maxAttempts = 60; // 最大60回(3秒 × 60 = 180秒 = 3分上限)
let attempts = 0;
// ローディング開始
document.getElementById('status').textContent = '分析中...';
const timer = setInterval(async () => {
attempts++;
if (attempts > maxAttempts) {
clearInterval(timer);
onError(new Error('Polling timeout: 3分以内に完了しませんでした'));
return;
}
try {
const res = await fetch(`/api/v1/job/status/${jobId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.status === 'done') {
clearInterval(timer);
document.getElementById('status').textContent = '完了';
onComplete(data.result);
} else if (data.status === 'error') {
clearInterval(timer);
onError(new Error('Job failed on server'));
} else {
// pending / running → 続行(リトライ扱いではなく通常継続)
document.getElementById('status').textContent = `分析中... (${attempts}/${maxAttempts})`;
}
} catch (networkErr) {
// ネットワーク一時エラーはリトライ継続(maxAttempts で上限)
console.warn(`Polling retry [${attempts}]:`, networkErr.message);
}
}, 3000);
}
// 使い方
pollJob(jobId,
(result) => { console.log('完了:', result); },
(err) => { console.error('失敗:', err.message); }
);
while(true) 版との使い分け: 単純なスクリプト・サーバーサイド呼び出しなら while 版が見やすい。ブラウザ上でユーザーに進捗を見せながら動かすなら setInterval 版が適している。