アーキテクチャ概要

OxPHP は、従来の nginx + PHP-FPM スタックを置き換える単一バイナリの HTTP サーバーです。HTTP のパース、TLS 終端、ルーティング、PHP の実行、圧縮、そして可観測性を、実行時に外部依存を一切持たない単一のプロセス内で処理します。

OxPHP の仕組み

OxPHP は、2 つのランタイムレイヤーとオプションの非同期プールを、単一のプロセス内で組み合わせます。

  1. 非同期 HTTP レイヤー。 イベント駆動のネットワークレイヤーが TCP 接続を受け付け、TLS ハンドシェイクを実行し、HTTP リクエストをパースし、レスポンスを送信します。ノンブロッキング I/O を使って数千の同時接続を処理するため、1 つの遅いクライアントが他のクライアントをブロックすることは決してありません。
  2. PHP ワーカープール。 専用の PHP ワーカーのプールが、あなたの PHP スクリプトを実行します。標準モードでは、各ワーカーは一度に 1 つのリクエストを処理します。ワーカーモードでファイバー多重化を有効にすると、1 つのワーカーが複数の同時リクエストを処理できます。スクリプトが oxphp_sleep() または oxphp_async_await() を呼び出すと、ファイバーはスレッドを譲り、ワーカーは次のリクエストに切り替わります。

2 つのレイヤーは有界キューを介して通信します。PHP の実行を必要とする HTTP リクエストが到着すると、非同期レイヤーはそれをキューに入れます。空いている PHP ワーカーがそれを取り出してスクリプトを実行し、クライアントへ配信するためにレスポンスを非同期レイヤーへ返します。

非同期プールはオプションです。oxphp_async() 経由で投入されたタスクを、独自のタスクキューを持つ独立した OS スレッド上で実行し、ASYNC_WORKERS > 0 を設定すると有効になります。ワーカープールから隔離されているため、バックグラウンドタスクが HTTP リクエストの処理をブロックすることはありません。

この分離により、ネットワーク I/O(接続の受け付け、ヘッダーの読み取り、レスポンスの圧縮、静的ファイルの配信)が PHP の実行とリソースを奪い合うことは決してありません。各レイヤーは独立してスケールします。

ワーカープール

PHP ワーカープールは、いくつの PHP スクリプトを並行して実行できるかを決定します。OxPHP は 2 つのプールモードをサポートします。

静的プール

固定数のワーカーが起動時に立ち上がり、サーバーの稼働中はずっと動き続けます。これがデフォルトのモードです。

bash
PHP_WORKERS=8 # exactly 8 workers PHP_WORKERS=0 # auto-detect (default): half of available CPU cores, minimum 1

動的プール

ワーカーは需要に応じてスケールアップおよびスケールダウンします。最小数と最大数をコロンで区切って指定します。

bash
PHP_WORKERS=2:16 # start with 2, scale up to 16 under load

現在のすべてのワーカーがビジー状態になると、OxPHP は最大数まで新しいワーカーを生成します。ワーカーが PHP_WORKERS_IDLE_SECONDS(デフォルト: 30 秒)より長くアイドル状態になると、そのワーカーは最小数に向けて縮退されます。

縮退は、ワーカーが処理中のものを何も持たない瞬間に行われるため、配信中のリクエストは必ず自身のレスポンスまで実行されます。同じルールが境界も定めます。終わらないリクエスト(開いたままのストリーム、応答しなくなったピアからの読み取り)を抱えたワーカーは、そのような瞬間に決して到達せず、それを抱えている間は縮退されません。

キューとバックプレッシャー

非同期 HTTP レイヤーとワーカープールの間には有界キューが存在します。その容量はデフォルトで初期ワーカー数に 128 を掛けた値で、QUEUE_CAPACITY で上書きできます。静的プールの場合、初期数は設定されたワーカー数です。動的プール(MIN:MAX)の場合、初期数は最小値です。

満杯のキューに到着したリクエストは、即座に拒否されるわけではありません。QUEUE_WAIT_TIMEOUT_MS(デフォルト: 1000 ミリ秒)を上限としてスロットの空きを待ち、前にいるリクエストをワーカーが取り出し次第、受け入れられます。この設定値は待機の上限であって、固定の長さではありません。待機から受け入れられた処理が、既に切断したクライアントのために完了しつつある間、サーバーは予算を短縮し、キューが掃けると元に戻します。そのため過負荷時には、到着したリクエストに刻印される期限は短縮後のものになります。待機中のリクエストは到着順に受け入れられます。この予算は到着時に刻印される単一の期限であり、リクエストと共にキューの中まで持ち込まれます。期限が過ぎるとリクエストには 529 が返され、期限を過ぎたリクエストを取り出したワーカーは、それを実行せずに拒否します。その時点でクライアントが既に接続を閉じていれば、応答は代わりに 499 になります。拒否を受け取るクライアントがもういないからであり、これを 529 として数えると、過負荷の系列がクライアントがどれだけ待つ気があったかを測ることになってしまうからです。したがって予算は待機全体、つまり受け入れまでとキュー内で過ごす時間の両方を制限します。これが重要なのは、キューが十分に深いため、遅いプールでその末尾に到達するには運用者が設定するどんな予算よりもはるかに長い時間がかかるからであり、また、どのワーカーもリクエストを取り出していないキューには、期限を適用できる取り出しの瞬間そのものが存在しないからです。負荷の削減は、リクエスト到着時点のキューの深さではなく、経過した待機時間に基づいて行われます。マイクロ秒で掃けるバーストは処理され、本当に追いつけないプールは依然として負荷を削減します。

予算が制限するのは待機だけであり、実行時間は制限しません。ワーカーが期限前にリクエストを取り出せば、その後ハンドラーがどれだけ長く実行されても、予算がそれを中断することはありません。このチェックは経過時間の合計ではなく待機に対して適用されるからです。負荷時のレスポンスタイムは待機時間に実行時間を加えたものであり、予算が上限を設けるのは待機だけです。ただし待機のすべてに上限を設けます。期限が過ぎた時点でまだ待機中のリクエストは、キュースロットを待っている場合でも、キュー内でワーカーを待っている場合でも、その時点で応答されます。

QUEUE_MAX_WAITING は、同時に待機できるリクエスト数の上限です(デフォルト: 初期ワーカー数 × 128、MAX_CONNECTIONS の半分が上限)。これを超えると、受け入れは即時拒否に戻ります。待機はタダではありません。待機中のリクエストは、受け入れられるか予算が尽きるまで、接続と既にバッファ済みのボディを保持し続けます。そのため、上限のない待機セットは持続的な過負荷の下ですべての接続パーミットを消費し、accept ループをブロックし、サーバーは新しい接続に応答する代わりに取りこぼすことになります。デフォルト値は、プールが予算内に実際に受け入れられるリクエスト数の近似です。それを超えて待機しても、リソースを保持したまま拒否を先送りするだけです。MAX_CONNECTIONS / 2 の上限が制限するのは待機セットだけであり、それ自体は accept ループのための余裕にはなりません。キュー内のリクエストはワーカーが到達するか予算が尽きるまで接続を保持し、実行中のリクエストも保持しています(そのキュースロットは取り出し時点で解放済みです)。そして QUEUE_CAPACITY には接続数由来の上限がそもそもありません。したがって、PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING は MAX_CONNECTIONS を下回っている必要があります。この合計が予算に達すると、accept ループはすべてのパーミットが取られた状態で停止し、その時点で到着したクライアントは 529 ではなく無応答となります。サーバーは起動時にこれについて警告し、さらに停止が起きている最中にはその停止自体を報告します。ループが停止したときに WARN、再開したときに継続時間付きの INFO を出し(レート制限付きなので、上限に繰り返し達するサーバーが自身のログを溢れさせることはありません)、あわせて oxphp_accept_stalled ゲージと oxphp_accept_stalls_total カウンターを更新します。この 2 つはログのレート制限で抑制された停止も含めてすべての停止で更新され、この予算の外にある内部リスナーから提供されます。メトリクスリファレンスを参照してください。起動時の警告の解消は必要条件であって十分条件ではありません。PHP に到達しない接続もパーミットを保持し、それらが引き起こす停止を起動時のチェックは予測できないからです。なお、各項は接続数で数えられる一方、予算はリクエストによって消費される点に注意してください。HTTP/2 では 1 つの接続が多数のリクエストを運ぶため、h2 中心のデプロイメントは接続予算のごく一部で上限に達します。その場合は QUEUE_MAX_WAITING を明示的に設定すべきであり、合計が予算を上回る構成も正当でありえます。

待機セットは二重に制限されます。リクエスト数で数える上限は、それらのリクエストが保持するメモリについて何も語らないからです。同じ数の待機者でも、ボディのない GET ではコストはゼロ、アップロードではギガバイト単位になります。QUEUE_MAX_WAITING_BYTES(デフォルト 64 MiB)は、同時に待機しているリクエストボディのバイト数を制限します。これを超えると、ボディを持つリクエストは待機させられるのではなく即座に拒否され、ボディのないリクエストは通常どおり待機を続けます。これがカバーしない 2 つのケースがあり、いずれも待機予算より前から存在するものです。既にキューへ渡されたボディ(QUEUE_CAPACITY がリクエスト数では制限しますが、バイト数では何も制限しません)と、接続から読み取り中のボディが占有するメモリ(こちらは集約的な上限が一切ありません)です。

OxPHP が Retry-After ヘッダー付きの 529 Site is Overloaded レスポンスを返すのは、リクエストが単に遅延するのではなく処理できなくなったときです。つまり、待機予算が尽きたか、上記のいずれかの上限によって待機する場所そのものを拒否されたときです。ステータスコード 529(非標準で、Cloudflare などが使用)は、過負荷をアプリケーションエラー(500)やメンテナンス(503)と明確に区別するため、アラートやロードバランサーの設定が容易になります。QUEUE_WAIT_TIMEOUT_MS=0 を設定すると待機は無効になり、キューが満杯になった瞬間に拒否されます。

待機中のリクエストはキュースロットではなく接続を占有するため、過負荷時の同時実行数の上限は QUEUE_CAPACITY ではなく MAX_CONNECTIONS によって決まります。HTTP/2 では、各ストリームがそれぞれ自身のリクエストを運ぶため、MAX_CONNECTIONS に H2_MAX_CONCURRENT_STREAMS を掛けた値になります。待機予算はこの点を念頭に置いてサイズを決めてください。リクエストがキューに到達する時点でボディは既にバッファ済みなので、予算を長くするほど、本当に過負荷に陥ったサーバーは応答するまでに比例して多くのリクエストとそのボディをメモリに抱え込むことになります。

リクエストの流れ

静的ファイルを配信するか PHP を実行するかにかかわらず、すべてのリクエストは同じパイプラインを通過します。

graph TD
  Client(["Client"]) --> TLS["TLS termination<br/>(if configured)"]
  TLS --> Parse["HTTP parsing + Request ID"]
  Parse --> Proxy["Trusted proxy resolution<br/>(if TRUSTED_PROXIES set)"]
  Proxy --> Rate["Rate limiting check"]
  Rate --> Route{"Route resolution"}
  Route -->|Static file| Cache["File cache / disk read"]
  Cache --> Compress["Compression + Response headers"]
  Route -->|PHP request| Queue["Bounded queue<br/>(529 past the wait budget)"]
  Queue --> Worker["PHP worker executes script"]
  Worker --> Normal["Normal response"]
  Worker --> SSE["SSE streaming (chunked)"]
  Worker --> Early["Early response (finish_request)<br/>+ background work"]
  Compress --> Deliver(["Response to client"])
  Normal --> Deliver
  SSE --> Deliver
  Early --> Deliver
  1. TLS 終端。 TLS_CERT と TLS_KEY が設定されている場合、OxPHP は TLS を直接処理します。別途リバースプロキシは必要ありません。
  2. HTTP のパースとリクエストID。 リクエストがパースされ、一意のリクエストIDが生成されます(または受信した X-Request-ID ヘッダーが保持されます)。
  3. 信頼済みプロキシの解決。 TRUSTED_PROXIES が設定されていて、接続元の IP が信頼済みである場合、OxPHP は Forwarded(RFC 7239)または X-Forwarded-* ヘッダーから、実際のクライアント IP、プロトコル、ホストを抽出します。cloudflare キーワードを指定すると、転送チェーンから Cloudflare のエッジ経由で届いたことがわかるリクエストは、代わりに CF-Connecting-IP からクライアント IP を取得します。解決された IP は、レート制限やアクセスログを含む以降のすべてのステップで使用されます。信頼済みプロキシを参照してください。
  4. レート制限。 RATE_LIMIT が設定されている場合、クライアントの IP が IP ごとのリクエストカウンターと照合されます。制限を超えたリクエストは、直ちに 429 Too Many Requests レスポンスを受け取ります。
  5. ルートの解決。 URL は、設定されたルーティングモード(traditional、framework、SPA、または worker)と照合されます。結果は、静的ファイル、PHP スクリプト、または 404 のいずれかです。ワーカーモードは、他の 3 つに代わる独立したルーティングモードです。.php の ENTRY_FILE を必要とし、静的ファイルとして配信されないすべてのリクエストをそのスクリプトに送ります(ルーティングを参照)。
  6. 静的ファイル。 インメモリキャッシュ(頻繁にアクセスされるファイル用)から直接配信されるか、ディスクからストリーミングされます。OxPHP は ETag、Last-Modified、Cache-Control ヘッダーを自動的に付加します。
  7. PHP の実行。 リクエストは有界キューに入れられ、空いているワーカーによって取り出されます。キューが満杯の場合、リクエストは最大 QUEUE_WAIT_TIMEOUT_MS(デフォルト: 1000 ミリ秒)の間、スロットの空きを待ちます。その予算が尽きるとクライアントは 529 を受け取り、待機中のリクエストがすでにいずれかの上限に達している場合は直ちに 529 を受け取ります(キューとバックプレッシャーを参照)。
  8. 圧縮。 テキストベースのレスポンスは送信前に、Brotli、Zstandard、gzip のうちクライアントの Accept-Encoding が最も高く順位付けしたもので圧縮されます。同順位の場合、キャッシュされた静的ファイルには Brotli、それ以外には Zstandard が使われます(COMPRESSION_ENCODINGS とコーディングごとのレベル変数で設定可能)。
  9. SSE ストリーミング。 スクリプトが Content-Type: text/event-stream を設定するか、oxphp_stream_flush() を呼び出すと、OxPHP はストリーミングモードに切り替わります。各 flush() 呼び出しは、レスポンス全体をバッファリングすることなく、直ちにチャンクをクライアントへ送信します。ワーカーモードでは、SSE はファイバー多重化と協調して動作します。
  10. 早期レスポンス。 oxphp_finish_request() を呼び出すと、HTTP レスポンスが直ちにクライアントへ送信されます。スクリプトは接続を開いたままにすることなく、バックグラウンドで実行を続けます(ログの書き込み、キャッシュの更新、通知の送信など)。
  11. レスポンスの配信。 完成したレスポンスは接続を通じて返送され、アクセスログが有効な場合はログエントリが書き込まれます。

ワーカーモードと標準モードの比較

OxPHP は 2 つの PHP 実行モデルをサポートします。

標準モード(デフォルト)

リクエストごとに新しい PHP 環境を作成します。オートローダー、設定、データベース接続はリクエストごとに初期化され、その後に破棄されます。このモデルは、すべての PHP アプリケーションと、追加設定なしでそのまま互換性があります。

ワーカーモード

リクエストをまたいで PHP ワーカーを生かし続けます。アプリケーションは一度だけブートストラップし(オートローダーと設定を読み込み、データベース接続を確立)、その後リクエストループに入ります。リクエストの合間に、OxPHP はブートストラップされた状態を保持したまま、スーパーグローバル、出力バッファ、レスポンスヘッダーを自動的にリセットします。$_ENV は意図的な例外です。PHP のデフォルトである auto_globals_jit=1 の下ではリセットされないため、リクエストの状態ではなくワーカーの状態であり、あるリクエストの間にそこへ書き込まれた値は、そのワーカーが以降に処理するすべてのリクエストから読み取られます。スーパーグローバルを参照してください。

ワーカーモードはリクエストごとの起動オーバーヘッドを排除します。これにより、ブートストラップのコストが高いフレームワークベースのアプリケーション(Laravel、Symfony など)では、レスポンスタイムを大幅に短縮できます。

ワーカーモードを有効にするには、WORKER_MODE_ENABLED=true を設定し、ENTRY_FILE を oxphp_worker() を呼び出す PHP スクリプトに向けます。

php
<?php require __DIR__ . '/../vendor/autoload.php'; $app = new MyApp\Application(); oxphp_worker(function () use ($app) { $app->handle(); });

詳細なガイドについては、ワーカーモードを参照してください。

内部サーバー

INTERNAL_ADDR 変数が設定されている場合、OxPHP は指定されたポートで別個の HTTP サーバーを起動します。このサーバーは 3 つのエンドポイントを提供します。

エンドポイント 説明
GET /health JSON 形式のヘルスステータス(稼働時間、リクエストカウンター、接続数、ワーカーの状態)。通常運用時は 200、劣化時は 503 を返します。
GET /metrics Prometheus 形式のメトリクス — リクエストカウンター、レスポンスタイム、キュー待機時間、ワーカーの統計情報、圧縮による削減量。
GET /config JSON 形式のアクティブな設定のスナップショット。TLS のファイルパスは秘匿されます。

内部サーバーは、PHP ワーカープールや有界キューを経由しません。非同期 HTTP レイヤーから直接応答するため、PHP プールが完全に負荷状態にあってもアクセス可能なままです。このため Kubernetes のプローブに適していますが、プローブの向け先は集約エンドポイントの /health ではなく、専用のエンドポイント(/health/liveness、/health/readiness、/health/startup)にしてください。/health は再起動では解決しない状態でも 503 を返し、ドレイン中のサーバーが立てるシャットダウンフラグも追跡しないからです。ヘルスチェックを参照してください。

詳細については、内部サーバーを参照してください。

安全性

OxPHP は、本番環境でアプリケーションを確実に稼働させ続けるために、いくつかの保証を提供します。

  • リクエストの分離。 PHP スクリプトがクラッシュしたり致命的なエラーを引き起こしたりしても、影響を受けるのはその 1 つのリクエストだけです。サーバーは他のすべてのリクエストを通常どおり処理し続け、クラッシュしたワーカーは後述のとおり復帰します。
  • ワーカーの自動再生成。 OxPHP はすべての PHP ワーカーの健全性を監視し、予期せず停止したワーカーを手動での介入なしに置き換えます。静的プールは設定された数まで補充されるため、置き換えは 1 対 1 です。動的プールは最小数までしか補充されず、それを超える分として失われたワーカーは通常のスケールアップによって取り戻されます。
  • バックプレッシャー保護。 有界リクエストキューが過負荷を防ぎます。サーバーが容量に達すると、新しいリクエストは待機予算が許す間だけスロットを待ち、その後、無期限にキューイングされて連鎖的なタイムアウトを引き起こす代わりに、Retry-After ヘッダー付きの 529 レスポンスを受け取ります。
  • パストラバーサル保護。 すべての URL パスは、ファイルシステムへのアクセス前にサニタイズされます。パーセントエンコードされたトラバーサルの試み、.. セグメント、およびドキュメントルートを抜け出すパスはブロックされます。
  • グレースフルシャットダウン。 SIGTERM または SIGINT(Ctrl+C)を受け取ると、OxPHP は新しい接続の受け付けを停止し(リスニングソケットはプロセスが終了するまで開いたままです。グレースフルシャットダウンを参照してください)、終了する前に処理中のリクエストの完了を待ちます(設定可能なドレインタイムアウトまで)。

関連項目