Web アセット圧縮における Gzip vs Brotli vs Zstd
WebアセットのBrotli、gzip、zstdの使い分けを整理。圧縮レベルがサイズに与える影響と、静的ファイルの事前圧縮も解説。
静的なテキストアセットには Brotli、ユニバーサルなフォールバックには gzip、サーバーと CDN の両方を自分で制御できる動的レスポンスには zstd を使いましょう。
数年前にビルドツールやホスティングのデフォルト設定がそれを選んだまま、アルゴリズムやレベルを誰も見直していないために、いまだに gzip で配信されているバンドルは少なくありません。この記事の残りでは、静的コンテンツと動的コンテンツがなぜ別の問題なのか、なぜ圧縮レベルのほうがアルゴリズム名よりも結果を大きく左右することが多いのか、ビルド時にプリコンプレスする方法、ブラウザとサーバーがどのようにネゴシエートするのか、そして何を非圧縮のまま残すべきかを扱います。
要点
- 静的な JavaScript と CSS には最大レベルの Brotli が正解です。圧縮が遅いことのコストは、リクエストごとではなくビルド時に一度だけ支払われるからです。
- zstd は動的レスポンスに向いています。Cloudflare の計測では、サイズ面では Brotli に近い水準を保ちつつ、圧縮速度は Brotli より 42% 高速でした。平均圧縮率は gzip が 2.56:1、zstd が 2.86:1、Brotli が 3.08:1 でした。
- レベルのダイヤルはアルゴリズム名と同じくらい重要です。高速寄りのデフォルトレベルをそのままにして Brotli に切り替えても、削減量の一部を取りこぼします。
- nginx の
gzip_comp_levelのデフォルトは 1、ngx_brotli のbrotli_comp_levelのデフォルトは 6 で、いずれも最大値ではありません。 - エンコーディングのネゴシエーションはすべて
Accept-EncodingヘッダーとContent-Encodingヘッダーの中で行われるため、アルゴリズムの切り替えはサーバーまたは CDN の設定であり、アプリケーションコードの変更ではありません。
Brotli vs Gzip vs Zstd: 推奨構成
静的な JavaScript と CSS のデフォルトとしては Brotli が正解です。圧縮はビルド時に一度だけ行われるため、遅い最上位レベルを使ってもリクエスト時のコストはゼロです。
gzip はフォールバックとしてスタックに残ります。どのブラウザも Accept-Encoding に gzip を挙げるからです。何においてもベストな選択ではありませんが、決して失敗しない唯一の選択肢です。
zstd は動的レスポンスの担当です。動的レスポンスでは圧縮コストがリクエストごとに支払われますが、zstd は Brotli に近い圧縮率をわずかな CPU 時間で達成します。
静的 vs 動的: 圧縮コストはいつ支払われるのか
アルゴリズムの選択とは、CPU コストをいつ支払うかの選択です。静的バンドルはリリースごとに一度圧縮され、何千回も配信されるため、重要なのは圧縮率だけで、圧縮速度は関係ありません。リクエストごとにレンダリングされる HTML ページはヒットのたびに圧縮されるため、圧縮速度が圧縮率と同等に、レイテンシとサーバーコストの問題になります。
Cloudflare の 2024年9月の Zstandard サポート発表 が、動的ケースの数値を提供しています。2024年第3四半期のテストでは、Free プランのトラフィックを 24 時間にわたり Brotli から zstd に切り替えました。zstd はデフォルトのレベル 3、Brotli と gzip のレベルは明示されていません。このとき zstd は Brotli より 42% 高速に圧縮し、サイズもほぼ同等の水準に達しました。計測された平均圧縮率は gzip が 2.56:1、zstd が 2.86:1、Brotli が 3.08:1 で、Cloudflare 自身の結論は、zstd は HTML を含む動的レスポンスに適しているというものでした。
判断: 圧縮率だけが基準となる場面では Brotli が勝ちます。これはすべての静的アセットに当てはまります。圧縮がリクエストごとに走る場面では zstd が勝ちます。
圧縮レベルはアルゴリズムと同じくらい重要
どのアルゴリズムにもレベルのダイヤルがあり、多くの場合、レベルはアルゴリズムを切り替えるよりも結果を大きく動かします。Brotli は ngx_brotli でレベル 0 から 11、gzip は nginx で 1 から 9、zstd CLI は 1 から 19(デフォルトは 3)で、さらに --ultra の背後に 20 から 22 があります。
ダイヤルが結果をどれだけ動かすかは入力に完全に依存するため、他人のバンドルについて公開されているビフォー・アフターの数値は、あなた自身のバンドルについてほとんど何も教えてくれません。ブラウザ内で任意のレベルの gzip、Brotli、zstd で圧縮できる compression comparison tool に自分のファイルを通し、サイズを自分で比較してみてください。
多くのサイトが高速側の設定で配信されている理由は、デフォルト値にあります。nginx の gzip_comp_level のデフォルトは 1 で、これはレベル 1 から 9 のうち最速です。ngx_brotli の brotli_comp_level のデフォルトは 0 から 11 のうち 6 です。どちらも最大値ではなく、どちらもオンザフライ圧縮には妥当な値ですが、静的アセットにはまさに不適切な文脈です。どのレベルを選んでも、そのコストは圧縮する側が支払います。zstd プロジェクト自身の README は、どのレベルで生成されたファイルであってもデコード速度はほぼ同じだと指摘しており、これは zlib や lzma にも当てはまります。
判断: レベルは意図的に設定しましょう。プリコンプレスするものはすべて最大レベル、リクエストごとに圧縮するものは中間レベルです。
ビルド時にアセットをプリコンプレスする方法
プリコンプレスとは、ビルドステップが各アセットの隣に .br、.gz、必要なら .zst の兄弟ファイルを書き出し、サーバーがリクエスト時に何も圧縮せずに一致するファイルを選ぶという仕組みです。
brotli -q 11 app.js # writes app.js.br; source kept by default
gzip -9 -k app.js # writes app.js.gz; -k keeps the source
zstd -19 app.js # writes app.js.zst; source kept by default
brotli CLI はデフォルトで入力を保持し、品質 0 から 11 を指定する -q を受け付けます。GNU gzip では元ファイルをそのまま残すために -k または --keep が必要です。
nginx で兄弟ファイルを配信するには、2 つのディレクティブが必要です。
load_module modules/ngx_http_brotli_static_module.so;
http {
gzip_static on; # serves .gz siblings; module needs --with-http_gzip_static_module
brotli_static on; # serves .br siblings; default off
gzip_vary on; # adds Vary: Accept-Encoding; default off
}
ngx_http_gzip_static_module はデフォルトではビルドされません。brotli_static は ngx_brotli に由来します。nginx 自体は zstd モジュールを提供していません。サードパーティの zstd-nginx-module が .zst 兄弟ファイル用の zstd_static ディレクティブを追加しますが、デフォルトはオフで、これがない場合、静的ファイルに対する zstd は CDN 側の設定になります。
CDN はプリコンプレス済みファイルをパススルーします。Cloudflare の 圧縮に関するドキュメント によれば、訪問者のブラウザがサポートしており、レスポンスを書き換える機能(Rocket Loader、Email Address Obfuscation、Polish など)が有効になっていない場合、オリジンの content-encoding: br または gzip をそのまま維持します。オリジンへのリクエストは accept-encoding: br, gzip で行われるため、オリジンの zstd はパススルーされません。Akamai の Brotli Support behavior はオリジンで圧縮された Brotli を配信・キャッシュし、br を受け入れないクライアントには非 Brotli バリアントを返し、エッジ側での圧縮は一切行いません。
判断: ビルド時に最大レベルで圧縮し、兄弟ファイルを配信するようサーバーまたは CDN を設定しましょう。
ブラウザとサーバーはどのようにエンコーディングを合意するのか
ブラウザはデコードできるエンコーディングを Accept-Encoding に列挙し、サーバーまたは CDN が 1 つを選んでレスポンスに Content-Encoding のラベルを付けます。このやり取りにアプリケーションコードは一切関与しません。
GET /app.3f2a1b.js HTTP/1.1
Accept-Encoding: gzip, deflate, br, zstd
HTTP/1.1 200 OK
Content-Encoding: br
Vary: Accept-Encoding
br は HTTP content-coding レジストリにおける Brotli のトークンで、RFC 7932 section 13 で定義されています。Vary: Accept-Encoding は、gzip しか要求していないクライアントに Brotli のボディを渡さないよう共有キャッシュに指示します。nginx では gzip_vary on を設定しない限り、このヘッダーは省略されます。
サイトが現在何を配信しているかを確認するには、バンドルファイルを 1 つリクエストし、返ってくる単一のヘッダーを読みます。
curl -sI -H 'Accept-Encoding: gzip, br, zstd' https://example.com/app.js | grep -i content-encoding
同じ情報は DevTools の Network パネルのレスポンスヘッダーにも表示されます。
フォールバックの順序はブラウザのサポート状況が決めます。Brotli は現行のすべての主要ブラウザでサポートされています。caniuse によると zstd は Chrome と Edge が 123 以降、Firefox が 126 以降、Opera が 109 以降、Safari が 26 以降でサポートされており、デスクトップ版 Safari は部分サポートとされています。Accept-Encoding から zstd を省くブラウザには、単に Brotli か gzip が配信されるだけです。障害モードは存在せず、あるのはフォールバックだけです。
判断: アルゴリズムの切り替えは設定変更であり、フォールバックチェーンがその安全性を担保します。
圧縮すべきでないものは何か
JPEG、MP4、WOFF2 を再圧縮するのは、削減量がほぼゼロなのに両端で CPU を無駄にする行為です。これらのフォーマットは内部で既に圧縮済みだからです。WOFF2 は最も明快なケースです。RFC 7932 section 1.2 には、この RFC が定義するフォーマットが WOFF 2.0 に組み込まれていると記録されており、つまり .woff2 ファイルは既に Brotli の出力なのです。
もう 1 つの除外対象は、非常に小さなレスポンスです。数十バイトを下回ると、エンコーディングのオーバーヘッドが削減量を上回ります。そのため nginx の gzip_min_length と ngx_brotli の brotli_min_length はデフォルトで 20 バイトになっており、Cloudflare は gzip では 48 バイト以上、Brotli と zstd では 50 バイト以上のレスポンスのみを圧縮します。
ここでの結論は、ブラウザが実際に受け取るファイル、つまり数キロバイトから数メガバイトまでのファイルに当てはまります。Brotli が数分かかるというベンチマークは、Content-Encoding を通じて転送されることのない数百メガバイト級のファイルに由来するものです。
判断: テキスト(HTML、CSS、JavaScript、JSON、SVG)を圧縮し、メディア、フォント、極小レスポンスはスキップしましょう。
まとめ
アルゴリズムの問いには既に答えが出ています。ビルド時に圧縮するものは最大レベルの Brotli、リクエストごとに圧縮するものは zstd、そしてすべてのクライアントが理解できる最低ラインとして gzip です。一方、レベルの問いは、ほとんどのスタックが一度も問うたことのないものです。自分のバンドルに対して curl チェックを実行し、エンコーディングを確認してサイズからレベルを推測してみてください。その答えが「高速デフォルトの gzip」だったなら、Brotli でのプリコンプレスはビルドステップ 1 つとサーバーディレクティブ 2 つで実現できます。
FAQ
ブラウザが Brotli をサポートしているのに、サイトがいまだに gzip を送ってくるのはなぜですか?
ほぼすべてのケースは 3 つの原因で説明できます。1 つ目は、Chrome と Firefox が Accept-Encoding に 'br' を提示するのは HTTPS 上のみであるため、プレーン HTTP のリクエスト(ほとんどの localhost 開発環境を含む)は gzip にフォールバックすることです。2 つ目は、サーバーに Brotli モジュールがロードされていないことです。nginx には ngx_brotli が必要で、これは組み込みではありません。3 つ目は、Cloudflare のような CDN が、すでに content-encoding: gzip とラベル付けされたオリジンのレスポンスを Brotli にトランスコードせずパススルーすることです。
Content-Encoding における 'deflate' と 'gzip' の違いは何ですか?
どちらも RFC 1951 の同じ DEFLATE アルゴリズムを運んでおり、違いはラッパーだけです。HTTP においては 'deflate' は RFC 1950 の zlib フォーマット(2 バイトのヘッダーと Adler-32 チェックサム)を意味し、'gzip' は CRC-32 のトレーラーを持つ RFC 1952 のコンテナを意味します。初期のサーバーやブラウザは 'deflate' という名前で生の DEFLATE を送ることがあり、クライアントに推測を強いていました。そのため gzip が信頼できる選択肢となり、deflate はほとんど提供する価値がありません。
Node.js は Brotli と zstd の圧縮をネイティブにサポートしていますか?
はい。組み込みの node:zlib モジュールが、サードパーティパッケージなしで gzip、deflate、br、zstd の content-encoding を実装しています。Brotli は Node.js 11.7.0 以降、zlib.brotliCompress と zlib.createBrotliCompress を通じて提供されています。Zstandard は Node.js 23.8.0 で zlib.zstdCompress と zlib.createZstdCompress とともに登場し、Node.js 24.6.0 では zstd API に辞書サポートが追加されました (https://nodejs.org/en/blog/release/v24.6.0)。これらのリリースより前に作られた圧縮ミドルウェアは gzip のみをネゴシエートする可能性があるため、実際に出力される content-encoding を確認してください。