タイムアウト

OxPHP は、低速なクライアントや暴走するリクエストから保護するために、2 つの独立したタイムアウトを適用します。ヘッダータイムアウトは、サーバーレベルで接続フェーズを保護します。PHP の実行時間は、PHP 自身の max_execution_time ini ディレクティブ (および set_time_limit() ランタイム関数) によって制限されます。この上限には、PHP-FPM と異なる点が 1 つあり、見落としがちです。OxPHP はスレッドセーフ (ZTS) な PHP ビルドで動作し、そこでは上限が CPU 時間ではなくウォールクロック時間を計測します。CPU 時間ではなくウォールクロック時間 を参照してください。

仕組み

各リクエストは、次のフェーズを通過します。

  1. 接続の受け入れ — ヘッダータイムアウトが開始します。OxPHP は、クライアントが HTTP ヘッダーの完全なセットを送信するのを待ちます。
  2. ヘッダーの受信 — ヘッダータイムアウトが終了します。リクエストは PHP ワーカーにディスパッチされます。
  3. PHP がリクエストを処理 — アプリケーションコードは、スレッドごとの POSIX タイマーとしてセットされる PHP 自身の max_execution_time のもとで実行されます。上限に達すると、リクエストはキャンセルされ、統一された Request cancelled (timeout) の致命的エラーが発生します。ワーカーモードでは、各リクエストが独自の上限を保持します。ワーカーが他のリクエストを抱えている間に引き受けたリクエストも同様です。また、待機中で中断している間に上限に達したリクエストは、再開した時点で終了させられます (ワーカーモード → リクエストの期限 を参照)。
  4. レスポンスの送信 — keep-alive 接続では、このサイクルはステップ 1 から繰り返されます。
graph TD
  A["TCP connect<br/>(+ TLS handshake if enabled)"] -->|HEADER_TIMEOUT_SECONDS| B["Headers received"]
  B -->|max_execution_time| C["Response sent"]
  C -->|next request, keep-alive| A

keep-alive 接続では、両方のタイムアウトが接続内の各リクエストに対して独立して適用されます。

Note

TLS が有効な場合、ヘッダータイムアウトは、TCP 接続が受け入れられた時点ではなく、TLS ハンドシェイクが完了した後に開始します。

ヘッダータイムアウトは、クライアントがヘッダーを 1 バイトずつ送信して接続を無期限に開いたままにする slowloris 型の攻撃から保護します。

PHP の実行時間の計測は、完全に PHP に委譲されています。max_execution_time を超過すると、OxPHP の統一されたキャンセルパスは次を実行します。

  • ユーザーランドのコードが原因を検出できるように、connection_status() & PHP_CONNECTION_TIMEOUT を設定します。
  • PHP-FPM とまったく同じように、すべての register_shutdown_function() コールバックを実行します。
  • HTTP 504 Gateway Timeout を返し、エラーログに Request cancelled (timeout) というメッセージを書き込みます。

CPU 時間ではなくウォールクロック時間

max_execution_time が計測するのは、リクエストが生存している時間であり、CPU をどれだけ消費したかではありません。待機に費やした時間はすべてカウントされます。遅いデータベースクエリ、別のサービスへの HTTP 呼び出し、ソケットに対するブロッキングな fread()、sleep() などです。

これは PHP-FPM とは異なり、その違いは OxPHP ではなく PHP のビルドに由来します。スレッドセーフ (ZTS) ビルドは、多数の PHP インタープリターを 1 つのプロセスのスレッドとして実行するために OxPHP が必要とするもので、上限をモノトニッククロック上のスレッドごとの POSIX タイマーとしてセットします。一方、PHP-FPM が使う非スレッドセーフビルドは、setitimer(ITIMER_PROF) でセットします。これはプロセスが CPU 上にある間しか進まないタイマーです。「データベースの待ち時間は max_execution_time にカウントされない」というおなじみの経験則はここから来ています。これは PHP-FPM では成り立ちますが、OxPHP では成り立ちません。

その影響は、既存のアプリケーションを移行するときに表面化します。

リクエスト: レポートクエリの待機 25 秒、レンダリング 2 秒 30 秒の上限に対して計上される時間
PHP-FPM (非スレッドセーフビルド) 約 2 秒 (上限には事実上到達しない)
OxPHP (スレッドセーフビルド) 27 秒 (遅いクエリ 1 つで上限を使い切りうる)
PHP-FPM からの移行

PHP-FPM では一度も max_execution_time に達しなかったアプリケーションが、ディレクティブの値を変えていないのに、OxPHP への移行後に最も遅いエンドポイントで 504 を返し始めることがあります。上限は、リクエストが待機するものすべてを含めたリクエスト全体のレイテンシに合わせて設定し、外部システムを待ってブロックするエンドポイントでは引き上げてください。

キャンセル時のステータスコード

OxPHP は、いくつかの異なる理由でリクエストをキャンセルします。それぞれは、汎用的な 500 ではなく、実際の状況を反映したワイヤーステータスにマッピングされます。

原因 ステータス 備考
max_execution_time / set_time_limit() の超過 504 Gateway Timeout サーバー側の実行時間の枯渇。
サーバーのグレースフルシャットダウンによってリクエストがドレインされた 503 Service Unavailable Retry-After: 5 を追加し、クライアントが復旧済みまたは置き換え後のインスタンスに対して再試行するようにします。
クライアントがリクエストの途中で接続を閉じた 499 nginx スタイルの「Client Closed Request」。接続はすでに失われているため、このステータスはアクセスログとメトリクスにのみ現れ、ワイヤーに書き込まれることはありません。クライアント起因の中断を非 5xx として表面化させ、サーバーエラーのアラートを汚さないようにします。

キャンセルの原因はこの 3 つだけです。スーパーバイザーがしきい値を超えて実行中であると検知したリクエストは、oxphp_worker_stuck_total と oxphp_worker_long_running_total を通じて報告され、そのまま実行が続けられます。サーバーがそれをキャンセルすることはありません。

ERROR_PAGES_DIR に 500.html しか含まれていない場合は、504.html、503.html、そして (任意で) 499.html を追加し、キャンセルの原因を問わずスタイル付きのページが一貫するようにしてください。

設定

変数 デフォルト 説明
HEADER_TIMEOUT_SECONDS 5 接続が受け入れられてからリクエストヘッダーを受信するまでの最大秒数。slowloris 攻撃から保護します。0 は特別扱いされません — hyper に 0 秒のタイムアウトとして渡され、即座に発火します。タイムアウトを無効にするには、0 を設定するのではなく、変数を未設定にしてください

PHP の実行時間は、OxPHP の環境変数ではなく php.ini を介して設定します。

php.ini
; php.ini max_execution_time = 30

あるいは、スクリプトごとに実行時に設定します。

php
set_time_limit(60); // 60 seconds from now set_time_limit(0); // disable for this request

推奨値

シナリオ ヘッダータイムアウト max_execution_time
API サーバー 5s 30s
一般的な Web 配信 5s 60s
ファイルアップロード 10s 300s
SSE / ロングポーリング 5s 0 (無効化、スクリプトごとに設定)

これらの値は、アプリケーションの特性に基づいて調整してください。SSE エンドポイントでは、max_execution_time をグローバルに無効化するのではなく、ストリーミングスクリプトの先頭で set_time_limit(0) を呼び出してください。

トラブルシューティング

クライアントが予期せず「Request cancelled (timeout)」を伴う 504 を受け取る

スクリプトが完了する前に、PHP の実行時間の上限が発火しました。

これらのリクエストが PHP-FPM では問題なく、OxPHP への移行後に失敗し始めた場合、原因はおそらく CPU 時間ではなくウォールクロック時間 で説明したタイマーのセマンティクスです。リクエストがデータベースやアップストリームサービスを待つ時間も上限にカウントされます。

対処: 該当するスクリプトの max_execution_time を引き上げるか、実行時に set_time_limit($seconds) を呼び出して延長してください。

php
// at the top of a slow script set_time_limit(300);

接続を無期限に開いたままにする必要がある SSE またはストリーミングエンドポイントでは、そのスクリプトのタイマーを無効にしてください。

php
set_time_limit(0);
ヘッダーが到着する前に接続が切断される

ヘッダータイムアウトが、高レイテンシの回線上や低速なロードバランサーの背後にいるクライアントにとって短すぎます。

対処: ヘッダータイムアウトを増やしてください。

bash
HEADER_TIMEOUT_SECONDS=15
スクリプト変更後の最初のリクエストが OPcache によってタイムアウトする

OPcache の再コンパイルは、ファイル変更後の最初のリクエストにレイテンシを追加します。これは、ファイル数の多い開発環境でより多く発生します。開発中は max_execution_time を引き上げるか、無効にしてください。

Docker の例

compose.yaml
services: app: image: ghcr.io/oxphp/oxphp:0.13.0 ports: - "8080:80" environment: HEADER_TIMEOUT_SECONDS: "5" volumes: - ./app:/var/www/html:ro - ./php.ini:/usr/local/etc/php/conf.d/zz-app.ini:ro

php.ini の内容は次のとおりです。

php.ini
max_execution_time = 30

ベストプラクティス

  • 本番環境で max_execution_time = 0 をグローバルに設定しないでください。無期限の接続を必要とする SSE またはロングポーリングのエンドポイントがある場合を除きます。スクリプトごとに set_time_limit(0) を使うことを推奨します。
  • API サーバーではより短い上限を使ってください。 API は応答時間が予測可能です。30 秒の max_execution_time は、通常のトラフィックに影響を与えることなく、スタックしたリクエストを素早く捕捉します。スクリプトが行う CPU 処理ではなく、最も遅い下流の依存先に合わせて設定してください。待機時間もカウントされるためです。
  • レート制限と組み合わせてください。 タイムアウトは個々のリクエストを保護し、レート制限は大量のリクエストから保護します。両者を組み合わせることで、低速な攻撃と高速な攻撃の両方のパターンをカバーできます。

エージェント向けプロンプト

これらのプロンプトは、OxPHP ドキュメント MCP サーバー を接続したエージェントを前提としています — 必要なページはエージェントが自分で読み取ります。

prompt
私のいちばん長いリクエストに合わせて OxPHP のタイムアウトを設定してください。OxPHP ドキュメント MCP サーバー経由で `features/timeouts` ページを読んでください。 このリポジトリで実際にいちばん長く走るもの (アップロード、レポート、エクスポート、そしてデータベースやアップストリームサービスの待機。上限はウォールクロック時間を数えるため) を見つけたうえで、運用すべきヘッダータイムアウトと `max_execution_time` を教えてください。グローバルな上限の下では動かせないものについては、スクリプトごとの上書きも併せて教えてください。

関連項目