圧縮

OxPHP は HTTP レスポンスを Brotli、Zstandard、gzip のうちクライアントが受け入れるもので圧縮します。圧縮は固定されたコンテンツタイプの一覧(テキスト、JSON、XML、SVG、WebAssembly、いくつかのフォント形式)に自動的に適用されるため、アプリケーションコードを変更することなく転送サイズが削減されます。

仕組み

クライアントの Accept-Encoding ヘッダーは、リクエストがルーティングパイプラインに到達する前に一度だけ読み取られ、サーバーが提供するエンコーディングと照らし合わせて評価されます。その後の処理は、バイト列がどこから来るかによって異なります。

圧縮済みのコピーが既に保存されている、キャッシュ済みの静的ファイルには、そのコピーから直接応答します。リクエスト時には何も圧縮されず、以下のチェックも一切適用されません。キャッシュされた静的ファイルを参照してください。

それ以外のすべてのレスポンス(PHP の出力、カスタムエラーページ、保存済みコピーがまだない静的ファイル)は、レスポンスが組み立てられた後に、以下のチェックを順番に通過します。

  1. 部分レスポンスはスキップされます。 206 はエンコードされません。その Content-Range のオフセットは、エンコードされていない表現を基準にしているためです。
  2. コンテンツタイプのチェック。 レスポンスの MIME タイプが、圧縮可能なタイプの一覧から検索されます。
  3. エンコード済みのチェック。 既に Content-Encoding ヘッダーを持つレスポンスには手を加えません。
  4. サイズのチェック。 256 バイトから 3 MiB(3,145,728 バイト)までのボディのみが圧縮されます(両端を含む)。レスポンスに Content-Length ヘッダーがあればそれが使われます。PHP のレスポンスには通常このヘッダーがないため、代わりにバッファリングされたボディのサイズが測定されます。
  5. 圧縮。 ネゴシエートされたエンコーディングが、設定されたレベルで適用されます。圧縮後の出力が元のサイズよりも小さくならない場合は、代わりに非圧縮のレスポンスが送信されます。
Note

圧縮は PHP の実行後、静的ファイルの配信後、そしてカスタムエラーページへの差し替えの後に行われます。そのため、256 バイトを超えるカスタム 404 のボディは他のレスポンスと同様に圧縮されます。組み込みのエラーボディ(404 Not Found、500 Internal Server Error)は数十バイトしかなく、サイズの下限を下回ります。それ以外の場面でステータスコードが参照されることはありません。

小さなボディはインラインで圧縮されます。しきい値を超えると、非同期ランタイムを停滞させないよう、処理はブロッキングスレッドに移されます。しきい値は 64 KiB ですが、コスト曲線が急になるほどレベルが高い場合は 4 KiB に下がります。その境目は Brotli では品質 4 超、gzip ではレベル 6 超、Zstandard ではレベル 9 超です。出荷時のデフォルトでは Brotli は既に急な側にあるため、4 KiB を超える Brotli のボディは非同期スレッドの外で圧縮されます。

flowchart TD
    AE["Accept-Encoding, read once per request"]
    W["Ranked by weight. A coding the server<br/>does not offer scores zero and drops out"]
    Q{"Is a stored copy<br/>being served?"}
    A["Tie broken br, then zstd, then gzip.<br/>Built once at br 11, zstd 19, gzip 9"]
    P["Tie broken zstd, then br, then gzip.<br/>Compressed now at the configured<br/>zstd 6, br 5, gzip 6"]
    H["Content-Encoding, recomputed Content-Length,<br/>Vary, weakened ETag, no Accept-Ranges"]
    AE --> W
    W --> Q
    Q -->|yes| A
    Q -->|no| P
    A --> H
    P --> H

エンコーディングの選択

Accept-Encoding はエンコーディングを重み(RFC 9110 §12.5.3)で順位付けし、OxPHP もそのとおりに解釈します。

  • エンコーディング名は大文字と小文字を区別せずに照合され、未知のエンコーディングは無視されます。
  • br;q=0 は Brotli を拒否します。これはエンコーディングを記載しないこととは異なります。記載されていないエンコーディングは * で拾われる可能性がありますが、明示的に拒否されたエンコーディングは拾われません。
  • * は、ヘッダーで明示的に名前が挙げられていないあらゆるエンコーディングを、それに与えられた重みで対象にします。
  • 解析できない重みを持つエンコーディングはデフォルトの重みのまま扱われ、不正なパラメーターがあってもそのエンコーディングは除外されません。これは q=0 で問題になります。qvalue は小数点以下 3 桁までしか持てないため、q=0.000 はエンコーディングを拒否しますが、q=0.0000 はそもそも有効な重みではなく、完全な受け入れとして解釈されます。2 回記載されたエンコーディングは、最初の重みで読み取られます。
  • クライアントが受け入れ、かつサーバーが提供するエンコーディングのうち、最も高い重みを付けられたものが選ばれます。設定によって取り下げられたエンコーディングは順位付けの前にスコアがゼロになるため、最も好むエンコーディングをサーバーが提供していないクライアントは、非圧縮のレスポンスではなく次の候補にフォールバックします。
  • 何も受け入れないヘッダー(空、または *;q=0)の場合は、エンコードされていないレスポンスになります。OxPHP はコンテンツエンコーディングを理由に 406 を返すことはなく、identity をトークンとして読むこともありません。

ブラウザはサポートするすべてのエンコーディングを重みなしで送信するため、通常は重みが同点になります。OxPHP はこの同点を、圧縮されたバイト列がその後どう扱われるかによって決着させます。

レスポンスの種類 優先されるエンコーディング 理由
保存済みコピーから配信される Brotli、次に Zstandard、次に gzip バイト列は最大品質で一度だけ圧縮され、以降はメモリから配信されるため、サイズ以外は問題になりません。そして範囲の上限では Brotli が 3 つの中で最も小さくなります
それ以外のすべて Zstandard、次に Brotli、次に gzip バイト列はクライアントが待っている間に圧縮され、その後破棄されるため、コストはリクエストのたびに発生します。リクエストで許容できるレベルでは、Zstandard はサイズで Brotli との差が数パーセント以内に収まり、CPU は半分を大きく下回ると測定されました

1 行目が適用されるのは、保存済みコピーが存在してからです。それより前のヒットと、キャッシュするには大きすぎるすべてのファイルは 2 行目に従います。そのため、静的アセットは通常、Brotli で応答される前に Zstandard で応答されます。

この優先順位は同点を決着させるためだけのものです。br;q=0.2, gzip;q=0.9 と記述したクライアントはどちらの経路でも gzip を受け取り、そのクライアントのために作られる保存済みコピーも gzip になります。

3 つのどれも受け入れないクライアントは、エンコードされていないレスポンスを受け取ります。実際には gzip がフォールバックになります。過去 20 年間のあらゆる HTTP クライアントが gzip を受け入れる一方、Zstandard には 2024 年以降にリリースされたブラウザが必要です。

平文の HTTP なら gzip のサイトになる

ブラウザは新しいエンコーディングを、そのバージョンの新しさだけでなく接続の種類によっても制限しています。br と zstd は HTTPS では通知されますが、平文の HTTP では通知されません。TLS なしで配信されるサイトは、訪問者が何を使っていても gzip のサイトになります。

Vary: Accept-Encoding

応答内容がリクエストヘッダーに依存するため、圧縮ウィンドウ内のすべてのレスポンスは、圧縮されているかどうか、静的か動的かに関係なく Vary: Accept-Encoding を持ちます。サーバーが一部のクライアントに対してエンコードする表現は、今回のクライアントに identity のバイト列を渡している場合でも、このヘッダーによって変化します。共有キャッシュがそうでないと伝えられると、エンコーディングを要求したクライアントにその identity のバイト列を渡してしまいます。

このルールの例外は 2 つあります。

  • サーバーが全体を保持しない動的レスポンス(PHP が生成するそばからストリーミングされるもの)は Vary を持ちません。この経路はどのヘッダーでも圧縮できないため、レスポンスは実際に変化しないからです。ストリーミングされるほど大きな静的ファイルはこのグループには含まれず、ヘッダーを持ちます。同じファイルでも、エンコーディングをネゴシエートしたクライアントに対しては全体が読み込まれて圧縮されるためです。
  • 304 は Vary も Content-Encoding も持ちません。新しい表現を記述するのではなく、クライアントが既に保持している表現を更新するものだからです。

Vary は、アプリケーションが既に送信したものにマージされるのではなく、独立したヘッダー行として追加されます。そのため、Vary: Cookie を設定したレスポンスは 2 行の Vary を持って送信されます。これは規格上正当であり、OxPHP は追加の前にそのすべてを読むため Accept-Encoding が重複することはありませんが、最初の行しか読まない中継サーバーはこれを見落とします。

キャッシュされた静的ファイル

コンテンツキャッシュに収まり(1 MiB 以下)、かつ圧縮可能な(一覧にあるタイプで、256 バイト以上の)静的ファイルは、リクエストのたびではなく一度だけ圧縮されます。

  1. 1
    最初のリクエスト: キャッシュミス。 ファイルはディスクから読み込まれて保存され、そのリクエストに対しては他のレスポンスと同様に圧縮されます。
  2. 2
    次のリクエスト: ビルドの開始。 ファイルはキャッシュ済みで、このリクエストでネゴシエートされたエンコーディングの保存済みコピーがまだなければ、サーバーはバックグラウンドスレッドでのビルドを要求します。ビルドを開始したリクエストは、ビルド中に届くすべてのリクエストと同様に、リクエストごとの方法で配信されます。
  3. 3
    コピーの完成。 同じエンコーディングをネゴシエートする以降のリクエストには、保存済みコピーから応答します。

ヒットカウンターはありません。トリガーは、そのエンコーディングの保存済みコピーがない状態での最初のキャッシュヒットです。そのため、これまで gzip クライアントにしか配信されていなかったファイルは、最初に Brotli クライアントがヒットした時点で Brotli のコピーを得ます。

保存済みコピーは、設定されたリクエストごとのレベルに関係なく、各エンコーディングの最大レベル(Brotli 11、Zstandard 19、gzip 9)でビルドされます。保存済みコピーがリクエストごとのレベルを使うことはありません。専用の設定はなく、コピーがビルドされないようにする唯一の方法は、そのエンコーディング自体を取り下げることです。最大品質では、Brotli のリクエストごとのレベルと比べて通常 8〜12% 少ないバイト数になります。

各エンコーディングはそれぞれ独自の保存済みコピーを必要に応じてビルドするため、gzip クライアントにしか配信されないファイルに Brotli の圧縮コストがかかることはありません。すべてのコピーはキャッシュされたファイルのバリデーターを共有し、キャッシュされたバイト列と一緒に破棄されます。破棄されるのは、保存済みコピーの分も計上される 64 MiB のキャッシュ予算内に収めるためにエントリが追い出されたとき、または STATIC_REVALIDATE が有効でディスク上のファイルの変更を検出したときです。この設定はデフォルトで無効なので、デフォルトのデプロイでは、保存済みコピーが破棄されるのは追い出しと再起動の 2 つの場合だけです。圧縮できないバイト列はその旨が記録されて再試行されず、304 はビルドのトリガーにも保存済みコピーの配信にもなりません。

エンコーディングはファイルごとに一度切り替わる

2 つの同点決着のルールが異なるため、Brotli と Zstandard の両方を受け入れるクライアントでは、通常ファイルごとに一度エンコーディングが切り替わります。最初のヒットにはそのリクエスト用に圧縮された Zstandard で応答し、保存済みコピーが完成した後のヒットには Brotli で応答します。どちらも同じリソースの有効な表現であり、どちらも Vary を持つため、何も壊れません。また両者は 1 つの弱い ETag を共有するので、Zstandard の変種を保持しているキャッシュは再検証に成功してそれを保持し続けます。切り替わりが及ぶのは新しいクライアントであり、既にコピーを保存したクライアントではありません。

キャッシュするには大きすぎるファイル

キャッシュの上限と圧縮ウィンドウの上限の間(1 MiB〜3 MiB。フレームワークのバンドル、ソースマップ、WebAssembly モジュールがここに入ります)にあるファイルは、クライアントがエンコーディングをネゴシエートし、かつタイプが圧縮可能であれば、全体が読み込まれてリクエストごとに圧縮されます。キャッシュされず、保存済みコピーも作られないため、これはサーバーがリクエストごとに行う圧縮の中で最もコストの高いものです。

Tip

1〜3 MiB の範囲のアセットの前に CDN を置くか、1 MiB のキャッシュ上限未満に収めれば、処理は一度で済みます。

このようなファイルを読み込むと、レスポンスが書き出されるまでメモリ上に保持されるため、同時に読み込まれる数には上限があります。その上限を超えて届いたリクエストには、空きを待つのではなく、ストリーミングかつ非エンコードでファイルが配信されます。そのため、大きなアセットに持続的な負荷がかかると、圧縮されたものを受け取るクライアントとそうでないクライアントが出てきます。どちらの応答もその URL に対して正しく、いずれの場合も Vary: Accept-Encoding が付きます。

それ以外の場合、1 MiB を超えるファイルは手を加えずにディスクからストリーミングされます。圧縮できないタイプ、どのエンコーディングも受け入れないクライアント、または 3 MiB を超えるものがこれに当たります。

サイズ別の静的ファイルのコスト:

flowchart TD
    F["Static file"]
    C1{"256 B or larger?"}
    C2{"1 MiB or smaller?"}
    C3{"3 MiB or smaller?"}
    N1["Sent as-is, and cached.<br/>The framing would cost more than it saves"]
    N2["Cached, and a stored copy is built once<br/>in the background on the first cache hit"]
    N3["Read whole and compressed on every request.<br/>Too large to cache, so no copy is kept"]
    N4["Streamed from disk, never compressed.<br/>Past the compression window"]
    F --> C1
    C1 -->|no| N1
    C1 -->|yes| C2
    C2 -->|yes| N2
    C2 -->|no| C3
    C3 -->|yes| N3
    C3 -->|no| N4

この経路は、圧縮可能なタイプであることと、クライアントがエンコーディングをネゴシエートしたことを前提としています。どちらかの条件がなければ、ファイルは 1 MiB までキャッシュされ、それを超えるとストリーミングされ、範囲リクエストは常に尊重され、どこでも何もエンコードされません。

設定

Variable Default Description
COMPRESSION_ENCODINGS br,zstd,gzip サーバーが提供するエンコーディング(カンマ区切り)。br(または brotli)、zstd、gzip を受け付け、大文字と小文字は区別せず、前後の空白も許容します。ここに書いた順序は無視され、サーバーがレスポンスごとに選択します(エンコーディングの選択を参照)。値全体を off(または none)にすると圧縮を完全に無効化します。off はリスト内のトークンではないため、br,off はエラーとして拒否されます
COMPRESSION_BROTLI_LEVEL 5 Brotli の品質(0〜11)
COMPRESSION_ZSTD_LEVEL 6 Zstandard のレベル(0〜19)
COMPRESSION_GZIP_LEVEL 6 gzip のレベル(0〜9)
COMPRESSION_LEVEL (未設定) COMPRESSION_BROTLI_LEVEL の非推奨の名前。設定すると起動時に警告がログに記録されます。明示的な COMPRESSION_BROTLI_LEVEL はゼロ以外の値を上書きしますが、COMPRESSION_LEVEL=0 は上書きしません。この値は他に何が設定されていてもすべての圧縮を無効化します

エンコーディングが提供されるのは、COMPRESSION_ENCODINGS に記載され、かつそのレベルが 0 でない場合です。どちらか一方だけでそのエンコーディングは取り下げられます。取り下げられたエンコーディングは機能全体から消えます。ネゴシエートされることも、リクエストの経路で圧縮されることも、保存済みコピーとしてビルドされることもありません。

値は検証され、補正はされない

未知のエンコーディング名、範囲外のレベル、数値でないレベルは、その変数名を示すエラーで起動を中止させます。値が範囲内に丸められることも、黙って捨てられることもありません。空の代入(COMPRESSION_ENCODINGS=。ホスト側の変数が存在しないときに ${VAR:-} が生成する形)は未設定として扱われ、デフォルトに戻ります。off という意味にはなりません。

COMPRESSION_LEVEL からの移行

以前のリリースでは、エンコーディングも設定項目も 1 つだけでした。COMPRESSION_LEVEL という、デフォルトが 4 の Brotli の品質で、0 にすると圧縮が無効になるものです。この変数は両方の意味で引き続き読み取られるため、既存のデプロイはそのまま動作しますが、非推奨の警告がログに記録されます。

  • COMPRESSION_LEVEL=N(ゼロ以外)は COMPRESSION_BROTLI_LEVEL=N になります。
  • COMPRESSION_LEVEL=0 は COMPRESSION_ENCODINGS=off になります。

古い値をそのまま残した場合も、Zstandard と gzip はデフォルトで有効のままです。通常はそれが望ましい状態です。Brotli を受け入れないクライアントはこれまで非圧縮のレスポンスを受け取っていましたが、現在は gzip を受け取ります。

デフォルト値の理由

  • Brotli は 4 ではなく 5。 Brotli の品質の分岐点(ハッシャーの切り替わり)はこの 2 つの間にあります。4 では、4 KB を超える JSON や実際の minify 済みアセットにおいて、デフォルト設定の gzip よりも多いバイト数を出力し、しかもより多くの CPU を消費しました。これでは gzip より Brotli を選ぶ理由がまったくありません。5 では、測定したすべてのボディで 2 つのうち小さい方になり、CPU は gzip のおよそ 2 倍です。
  • gzip は 6。 zlib 自身のデフォルトで、収穫逓減が始まる点に近い値です。レベル 9 はおよそ 2 倍のコストで、得られるのは 1〜2% です。
  • Zstandard は、自身のデフォルトの 3 ではなく 6。 数キロバイトを超えるボディでは、レベル 6 は以前のリリースがすべてを圧縮していた Brotli の品質よりも時間がかからず、しかも出力バイト数が少ないため、それらのリリースが送信していたものと比べて何も後退しません。

レベル 9〜11 は、リクエストごとの処理よりも、オフラインまたはビルド時の圧縮に適しています。それでもキャッシュされた静的ファイルにこれらのレベルが使われるのは、そのコストが一度しか発生しないからです。

圧縮可能なコンテンツタイプ

この一覧は閉じています。プレフィックスやワイルドカードのルールはないため、一覧にないタイプは、たとえよく圧縮できるものであっても圧縮されません。一覧内での照合は寛容です。パラメーターと前後の空白は取り除かれ、タイプ自体は大文字と小文字を区別せずに比較されるため、text/html; charset=utf-8 も Application/JSON も一致します。

テキストタイプ:

  • text/html
  • text/css
  • text/plain
  • text/xml
  • text/javascript

アプリケーションタイプ:

  • application/javascript
  • application/json
  • application/xml
  • application/xhtml+xml
  • application/rss+xml
  • application/atom+xml
  • application/manifest+json
  • application/ld+json
  • application/wasm

その他のタイプ:

  • image/svg+xml
  • font/ttf
  • font/otf
  • application/x-font-ttf
  • application/x-font-opentype
  • application/vnd.ms-fontobject

圧縮されないケース

以下のいずれかの条件を満たす場合、レスポンスは圧縮されずに送信されます。

  • クライアントが Accept-Encoding ヘッダーで br、zstd、gzip のいずれも受け入れていない、重みゼロでしか受け入れていない(br;q=0, zstd;q=0, gzip;q=0)、またはそのヘッダー自体を送信していない
  • クライアントが受け入れるすべてのエンコーディングが、COMPRESSION_ENCODINGS またはレベル 0 によって取り下げられている
  • レスポンスに既に Content-Encoding ヘッダーが設定されている(例: アプリケーションが生成した圧縮済みのコンテンツ)
  • レスポンスボディが 256 バイト未満、または 3 MiB を超えている
  • レスポンスが 206 Partial Content である。範囲のオフセットはエンコードされていない表現を基準にしているため、部分的なボディがエンコードされることはありません
  • コンテンツタイプが上記の一覧に含まれていない。image/png、image/jpeg、font/woff2、application/zip は、これらのフォーマットが既に独自の圧縮を持っているため除外されています。text/event-stream は、イベントストリームがイベントごとにクライアントへ届く必要があるため除外されています。text/csv、application/pdf、image/x-icon、および Content-Type をまったく持たないレスポンスも、一覧にないため除外されます
  • サーバーがボディ全体を保持していない。oxphp_stream_flush() を使う PHP スクリプト、Server-Sent Events、送信しながらディスクから読み込まれる静的ファイルがこれに当たります。OxPHP はレスポンスを一括で圧縮します。Brotli、Zstandard、gzip はいずれもインクリメンタルにエンコードできるため、これはエンコーディングではなくサーバー側の制約です
  • パイプラインが実行される前に応答したプラグインからのレスポンスである。たとえばレート制限による 429 は、圧縮ステップより前に返されます
  • レスポンスが内部リスナー(INTERNAL_ADDR)からのものである。内部リスナーは /metrics とヘルスエンドポイントを、スクレイパーが何を受け入れるかに関係なく非エンコードで配信します

レスポンスヘッダー

サーバーがエンコードするレスポンスは、そのリクエスト用に圧縮されたものか保存済みコピーから配信されたものかに関係なく、以下のように書き換えられます。

Header Value
Content-Encoding ネゴシエートされたもの(br、zstd、gzip のいずれか)
Content-Length 圧縮後のボディサイズに更新されます
Vary Accept-Encoding が追加され、HTTP キャッシュがエンコーディングごとに別々のバージョンを保存するようにします
ETag 強いタグは W/"…" に弱められます。エンコードされたバイト列は別の表現であり、identity のバイト列と共有される強いタグがあると、クライアントがエンコードされたダウンロードを identity の断片で再開できてしまうためです
Accept-Ranges 削除されます。エンコードされたボディに対してバイトオフセットは意味を持たないためです

1 つの静的ファイルのすべてのエンコーディングは、identity のサイズと更新日時から導出されるその弱いタグを共有します。変種を区別するのは Vary であり、タグは鮮度を決めるだけです。そのため、ある変種を再検証するキャッシュは、そのうちどれを保持しているかをサーバーに伝えることができません。

範囲リクエストと圧縮

このクライアントに対して圧縮されるはずのすべてのレスポンスでは、レスポンスが組み立てられる前に範囲リクエストの処理が無効化されます。圧縮ウィンドウ内にある圧縮可能な静的ファイルは、Range と Accept-Encoding の両方を送信するクライアントには完全な 200 で応答し、そのようなクライアントからの満たせない範囲リクエストにも 416 ではなくその 200 が返されます。どのエンコーディングも受け入れないクライアントでは範囲リクエストが維持され、再開可能なダウンロードはそちらで行われます。

可観測性

メトリクスエンドポイントの 2 つのカウンターが、圧縮が実際に何をしたかを報告します。

  • oxphp_compressed_responses_total: コンテンツエンコーディングを適用して送信されたレスポンス
  • oxphp_compression_bytes_saved_total: identity のサイズから圧縮後のサイズを引いた値の合計

どちらもリクエストごとの圧縮に加えて保存済みコピーのヒットも数えるため、oxphp_requests_total との比率を見れば、「圧縮がトラフィックに届いているか」という問いに、1 回の curl -I よりも確実に答えられます。

トラブルシューティング

レスポンスが圧縮されない

そもそもクライアントが Accept-Encoding ヘッダーを送信しているか確認してください。ブラウザは送信しますが、一部の HTTP テストツールはデフォルトで送信せず、このヘッダーのないリクエストには非エンコードのレスポンスが返されます。

重みゼロはそのエンコーディングを拒否します。Accept-Encoding: br;q=0, zstd;q=0, gzip;q=0 は、ヘッダーを送信しない場合と同じく、そのリクエストの圧縮を無効化します。

curl での 確認方法:

bash
curl -H "Accept-Encoding: br, zstd, gzip" -I http://localhost/

レスポンスヘッダーに Content-Encoding が含まれているか確認してください。含まれていない場合は、以下を確認してください。

  1. COMPRESSION_ENCODINGS に要求したエンコーディングが引き続き記載されており、そのレベルが 0 でないこと
  2. レスポンスボディが 256 バイト以上、3 MiB 以下であること
  3. レスポンスの Content-Type が上記の圧縮可能な一覧に含まれていること。照合では大文字と小文字、およびパラメーターは無視されます
  4. ハンドラーがストリーミングしていないこと。oxphp_stream_flush() を呼び出す、Server-Sent Events を送信する、あるいは長さが確定する前に書き込みを始めるスクリプトは、手を加えられずにそのまま通過します。フラッシュを行うアプリケーションでは、これが最もよくある原因です
クライアントによって異なるエンコーディングが返される

ネゴシエーションはこうした結果を生むためのものであり、そのほぼすべては 3 つのケースで説明できます。ブラウザは平文の HTTP では br と zstd を通知しません。その場合は Accept-Encoding: gzip, deflate を送信し、どれほど新しいブラウザでも gzip で応答されます。コマンドラインツールは多くの場合 Accept-Encoding をまったく送信せず、非エンコードで応答されます。そして HTTPS では、zstd を送信できるほど新しいブラウザは動的レスポンスで Zstandard を受け取り、古いブラウザは Brotli を受け取ります。これらのどれも設定の誤りではなく、圧縮されたレスポンスは Vary: Accept-Encoding を持つため、キャッシュはそれらを区別して保持します。

同じ静的ファイルでエンコーディングが変わる

想定どおりの動作です。キャッシュ可能な静的ファイルへの最初のヒットは、そのリクエストに応答するために Zstandard で圧縮され、保存済みの Brotli のコピーがバックグラウンドでビルドされると、以降のヒットはそのコピーから応答されます。キャッシュされた静的ファイルを参照してください。

範囲リクエストでファイル全体が返される

クライアントが Accept-Encoding も送信している場合、圧縮ウィンドウ内の圧縮可能なファイルでは想定どおりの動作です。配信される表現はエンコードされており、identity のバイトオフセットはそれを表しません。Accept-Encoding なしの同じリクエストには 206 が返されます。レスポンスヘッダーを参照してください。

圧縮によってレスポンスが大きくなる

圧縮できないボディ(base64 の塊、ランダムな識別子のペイロード、image/svg+xml として包まれた圧縮済みの画像)は、3 つのどのエンコーディングでも元より小さくならずに返ってくることがあります。OxPHP は両者を比較し、圧縮が効果を上げなかった場合は常に元のものを送信するため、設定を変更する必要はありません。これが小さなレスポンスの問題であることはありえません。256 バイトが圧縮を試みる下限だからです。

圧縮を有効にしたらスループットが下がった

圧縮はスループットを上げることも下げることもあります。どちらに転ぶかは、圧縮を有効にする前にサーバーが何を不足させていたかによって決まり、同じサーバーの同じ設定でも、スループットはどちらの方向にも数倍動くことがあります。

飽和した回線に対しては、圧縮は得られる最大の効果です。8 分の 1 に縮むテキストレスポンスは 8 倍速く届き、消費する CPU は回線を待ってアイドル状態だったものです。

狭い非同期ランタイムに対しては、圧縮はコストになります。ハンドオフのしきい値未満のボディは非同期ワーカースレッドで圧縮され、ランタイムはデフォルトでプロセスが利用できる CPU 数の半分に設定されます。そのため、マシンの残りの部分に余力があっても、この処理はすべてのオープンな接続を担うプールから差し引かれます。これを埋め尽くすのに大した量は必要ありません。2 つのランタイムスレッドは、1.5 KB の JSON ボディを圧縮するだけで飽和します。これを見分ける症状は、スループットが下がる一方で、マシンも PHP ワーカープールも暇になることです。不足しているのはそのどちらでもないからです。

確認すべきこと: ランタイムがマシンよりも狭くなっていないか。TOKIO_WORKERS のデフォルトは利用可能なコア数の半分で、これを増やすとこの処理がマシンのより多くを使えるようになります。どれだけ増えるかはマシンや他に何が動いているかによるため、推測せずに測定してください。その際はワーカープールも測定してください。ワーカープールのスレッドはランタイムのものとは別なので、既に PHP で CPU バウンドになっているデプロイでは、ランタイムを広げても新たなサイクルが得られるのではなく、アプリケーションからサイクルを奪うことになります。

小さなボディを代わりにブロッキングプールへ送っても、これは解決しません。2 スレッドのランタイムでは、インラインで圧縮される 1.5 KB の JSON ボディはランタイムスレッドを約 7 µs 占有しますが、ブロッキングプールに渡して回収すると 13〜19 µs 占有します。処理の投入、パーキング、ウェイクアップ自体がランタイムの仕事だからです。そのランタイムのスループットは 3 分の 1 から半分にまで落ち、ランタイム上の他のすべての待ち時間は減るどころか増えます。サイズのしきい値は、このコストを考慮して設定されています。

圧縮による CPU 使用率の高騰

圧縮のコストは、どのエンコーディングでも範囲の上限付近(Brotli 8〜11、gzip 8 と 9、Zstandard 12〜19)で急激に上昇し、リクエストごとのデフォルトはそれを大きく下回る位置にあります。圧縮による CPU 消費が高いことに気づいた場合は、以下を試してください。

対処法: COMPRESSION_ZSTD_LEVEL を 3 に、COMPRESSION_GZIP_LEVEL を 4 に下げてください。3 つの中で高コストなのは Brotli ですが、COMPRESSION_BROTLI_LEVEL を 4 に下げても CPU の節約にはなりません。4 では、デフォルト設定の gzip よりも多くのバイト数を出力し、それでいて gzip よりもコストがかかるからです。代わりに COMPRESSION_ENCODINGS から br を外してください。zstd を送信できるほど新しいクライアントは gzip ではなく Zstandard にフォールバックするので、この変更による損失はありません。gzip に移るのは Zstandard なしで Brotli を受け入れるクライアント(Safari、2024 年より前のブラウザ、ほとんどの中継サーバー)だけで、実際のボディでは gzip は 2〜6% 大きくなる代わりに、CPU はおよそ半分で済みます。

キャッシュされた静的ファイルはこのコストに含まれません。その圧縮済みコピーは、どのエンコーディングが残っていてもバックグラウンドで一度だけビルドされるからです。ただし、1 MiB〜3 MiB のファイルは含まれます。これらはリクエストごとに圧縮され、キャッシュできません。キャッシュするには大きすぎるファイルを参照してください。

圧縮済みのアセットが再度圧縮される

OxPHP は元のファイルの隣にある .br、.zst、.gz ファイルを探しません。/app.js へのリクエストに /app.js.br で応答することはなく、圧縮済みファイルを直接リクエストすると、ブラウザがデコードしない不透明なダウンロードとして配信されます。静的アセットのビルド時圧縮を組み込む場所はありません。その役割はサーバー自身の保存済みコピーが、同じ最大品質で、ビルドステップなしに担います。

再圧縮のスキップが適用されるのは、既に Content-Encoding ヘッダーを持つレスポンスです。実際には、PHP スクリプトが自分でボディをエンコードし、そのヘッダーを自分で設定した場合を意味します。

Docker の例

compose.yaml
services: app: image: ghcr.io/oxphp/oxphp:0.13.0 ports: - "8080:80" volumes: - ./src:/var/www/html environment: - DOCUMENT_ROOT=/var/www/html/public - ENTRY_FILE=index.php - COMPRESSION_ENCODINGS=br,zstd,gzip - COMPRESSION_BROTLI_LEVEL=5 - COMPRESSION_ZSTD_LEVEL=6 - COMPRESSION_GZIP_LEVEL=6

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

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

prompt
OxPHP が私のアプリケーションに対して実際に何を圧縮するのかを割り出し、エンコーディングを調整してください。OxPHP ドキュメント MCP サーバー経由で `features/compression` ページを読んでください。 このリポジトリが返しているコンテンツタイプとレスポンスサイズを一通り確認し、そのうち圧縮可能な一覧や 256 B〜3 MiB の範囲から外れるものはどれかを教えてください。リクエストのたびに再圧縮される 1 MiB〜3 MiB の静的アセットを指摘し、このリポジトリが処理するトラフィックの構成に合った `COMPRESSION_ENCODINGS` とエンコーディングごとのレベルを提案してください。設定にまだ非推奨の `COMPRESSION_LEVEL` が残っている場合は、移行してください。

関連項目