アーキテクチャ概要

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() を呼び出すと、ファイバーはスレッドを譲り、ワーカーは次のリクエストに切り替わります。
  3. 非同期プール(オプション)。oxphp_async() 経由で投入されたタスク用の独立した OS スレッドです。ASYNC_WORKERS > 0 を設定すると有効になります。ワーカープールから隔離されているため、バックグラウンドタスクが HTTP リクエストの処理をブロックすることはありません。

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

この分離により、ネットワーク 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 ミリ秒)を上限としてスロットの空きを待ち、前にいるリクエストをワーカーが取り出し次第、受け入れられます。待機中のリクエストは到着順に受け入れられます。この予算は到着時に刻印される単一の期限であり、リクエストと共にキューの中まで持ち込まれます。期限が過ぎた後にワーカーが到達したリクエストは、実行されるのではなく取り出し時点で拒否されます。つまり予算は受け入れまでの前半だけでなく待機全体を制限するのであり、これが重要なのは、キューが十分に深いため、遅いプールでその末尾に到達するには運用者が設定するどんな予算よりもはるかに長い時間がかかるからです。負荷の削減は、リクエスト到着時点のキューの深さではなく、経過した待機時間に基づいて行われます。マイクロ秒で掃けるバーストは処理され、本当に追いつけないプールは依然として負荷を削減します。

予算が制限しないのは実行時間です。負荷時のレスポンスタイムは、待機時間(受け入れ待ち、次いでキュー内)にハンドラーの実行時間を加えたものであり、予算は後者について何も関知しません。

QUEUE_MAX_WAITING は、同時に待機できるリクエスト数の上限です(デフォルト: 初期ワーカー数 × 128、MAX_CONNECTIONS の半分が上限)。これを超えると、受け入れは即時拒否に戻ります。待機はタダではありません。待機中のリクエストは、受け入れられるか予算が尽きるまで、接続と既にバッファ済みのボディを保持し続けます。そのため、上限のない待機セットは持続的な過負荷の下ですべての接続パーミットを消費し、accept ループをブロックし、サーバーは新しい接続に応答する代わりに取りこぼすことになります。デフォルト値は、プールが予算内に実際に受け入れられるリクエスト数の近似です。それを超えて待機しても、リソースを保持したまま拒否を先送りするだけです。MAX_CONNECTIONS / 2 の上限が制限するのは待機セットだけであり、それ自体は accept ループのための余裕にはなりません。キュー内のリクエストはワーカーが到達するまで接続を保持し、実行中のリクエストも保持しています(そのキュースロットは取り出し時点で解放済みです)。そして QUEUE_CAPACITY には接続数由来の上限がそもそもありません。したがって、PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITINGMAX_CONNECTIONS を下回っている必要があります。この合計が予算に達すると、accept ループはすべてのパーミットが取られた状態で停止し、その時点で到着したクライアントは 529 ではなく無応答となります。サーバーは起動時にこれについて警告します。警告の解消は必要条件であって十分条件ではありません。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_CONNECTIONSH2_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_CERTTLS_KEY が設定されている場合、OxPHP は TLS を直接処理します。別途リバースプロキシは必要ありません。
  2. HTTP のパースとリクエストID。 リクエストがパースされ、一意のリクエストIDが生成されます(または受信した X-Request-ID ヘッダーが保持されます)。
  3. 信頼済みプロキシの解決。 TRUSTED_PROXIES が設定されていて、接続元の IP が信頼済みである場合、OxPHP は Forwarded(RFC 7239)または X-Forwarded-* ヘッダーから、実際のクライアント IP、プロトコル、ホストを抽出します。解決された IP は、レート制限やアクセスログを含む以降のすべてのステップで使用されます。信頼済みプロキシを参照してください。
  4. レート制限。 RATE_LIMIT が設定されている場合、クライアントの IP が IP ごとのリクエストカウンターと照合されます。制限を超えたリクエストは、直ちに 429 Too Many Requests レスポンスを受け取ります。
  5. ルートの解決。 URL は、設定されたルーティングモード(traditional、framework、または SPA)と照合されます。結果は、静的ファイル、PHP スクリプト、または 404 のいずれかです。ワーカーモードが有効な場合、解決されたスクリプトを PHP がどのように実行するかは変わりますが、ルートの解決そのものは変わりません。
  6. 静的ファイル。 インメモリキャッシュ(頻繁にアクセスされるファイル用)から直接配信されるか、ディスクからストリーミングされます。OxPHP は ETagLast-ModifiedCache-Control ヘッダーを自動的に付加します。
  7. PHP の実行。 リクエストは有界キューに入れられ、空いているワーカーによって取り出されます。キューが満杯の場合、クライアントは直ちに 529 を受け取ります。
  8. 圧縮。 クライアントが Accept-Encoding: br を送信した場合、テキストベースのレスポンスは送信前に Brotli で圧縮されます(COMPRESSION_LEVEL で設定可能)。
  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 はブートストラップされた状態を保持したまま、スーパーグローバル、出力バッファ、レスポンスヘッダーを自動的にリセットします。

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

ワーカーモードを有効にするには、WORKER_MODE_ENABLED=true を設定し、ENTRY_FILEoxphp_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 プールが完全に負荷状態にあってもアクセス可能なままです。このため、/health は Kubernetes の liveness/readiness プローブに適しています。

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

安全性

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

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

関連項目