nginxでgzip onを書いただけではJSONは圧縮されない
一覧画面の表示が遅いという指摘を受けて調査したところ、リクエスト本数と1リクエストあたりの転送量が支配的でした。一番大きい条件では434件で1,847KBのJSONを返しています。
それ自体は「返しすぎ」の話なのですが、レスポンスヘッダを見ると content-encoding が付いていませんでした。nginxの設定を読み直すと、アプリケーションのserverブロックにgzipの指定が1行もない。それでいてHTMLだけは content-encoding: gzip が付いています。
前提として、環境は1 vCPUのPaaS上でnginx 1.28.0とPHP-FPMが動く構成です。/etc/nginx/nginx.conf には手を入れておらず、ディストリ既定のものをほぼそのまま使っていました。
Debian系の既定 nginx.conf は gzip on; だけが有効で gzip_types はコメントアウトされています。HTMLだけ圧縮されていた理由はここでした。
gzip_types の既定値は text/html のみ
nginxの公式ドキュメントはこう書いています。
Enables gzipping of responses for the specified MIME types in addition to “text/html”. Responses with the “text/html” type are always compressed. (指定したMIMEタイプについて、“text/html” に加えてgzip圧縮を有効にする。“text/html” のレスポンスは常に圧縮される) — nginx documentation「Module ngx_http_gzip_module — gzip_types」
text/html は常に圧縮対象で、それ以外は明示的に列挙しないと圧縮されません。gzip on を書けば全部圧縮されると思い込んでいると、JSONもJavaScriptもCSSも非圧縮のまま流れ続けます。今回がその状態でした。
適用前の実測はこうです。ブラウザでレスポンスヘッダを確認しました。
| リソース | Content-Type | Content-Encoding |
|---|---|---|
| APIのJSON | application/json | なし |
| ビルド済みJavaScript | application/javascript | なし |
| HTMLページ | text/html | gzip |
圧縮レベルはどこまで上げるべきか
gzipには1から9の圧縮レベルがあり、nginxでは gzip_comp_level で指定します。既定値は1です。
gzipはLZ77とハフマン符号の組み合わせで、一度出てきたバイト列が再び現れたら「何バイト前にある何バイトぶんと同じ」という参照に置き換えます。圧縮レベルは、この「同じものを探す手間」をどこまでかけるかの指定です。低いほど探索を早めに切り上げるのでCPUを使わない代わりに取りこぼしが増え、高いほど念入りに探すのでよく縮む代わりに時間がかかります。展開側の処理は形式が同じなのでレベルによらず変わりません。つまりレベル選びは、サーバーのCPU時間をどれだけ払って転送量を買うかという話になります。
どこに置くかは実測で決めました。実レスポンス(1,891,086バイト)を圧縮レベル別に計測しています。時間は3回試行の最小値です。
| 圧縮レベル | 圧縮後 | 元比 | 圧縮時間 |
|---|---|---|---|
| 1 | 190KB | 10.3% | 5ms |
| 3 | 173KB | 9.4% | 6ms |
| 5 | 124KB | 6.7% | 10ms |
| 6 | 114KB | 6.2% | 12ms |
| 9 | 109KB | 5.9% | 18ms |
レベル1でも1割まで縮み、レベル5とレベル9の差は15KBしかありません。APIのJSONは categoryName や totalAmount といったキー名が行数分——今回なら434回——繰り返されるので、雑に探しても同じ文字列がすぐ見つかります。伸びしろが最初から圧縮率側に振り切れているぶん、レベルを上げてもCPUだけが増えていきます。今回はレベル5にしました。
素通りしていたのはAPIだけではなかった
gzip_types が未指定ということは、静的ファイルも同じく素通りしています。そちらも測りました。圧縮後はレベル5の値です。
| リソース | 元 | 圧縮後 | 元比 |
|---|---|---|---|
| favicon.ico | 199,749B | 123,718B | 61.9% |
| manifest.json | 13,172B | 1,438B | 10.9% |
目を引いたのは favicon.ico です。199KBあって、しかも全ページで読まれるのに非圧縮でした。ICO形式は内部にPNGを持つため圧縮率は控えめですが、それでも74KB減ります。一覧画面の重さを追っていて見つけた設定漏れが、全ページに効く削減も連れてきた形です。
そもそも199KBのfaviconを配信していること自体が別の問題なのですが、それはそれとして直す話になります。
gzip_min_length を書いてもAPIには効かない
もうひとつ、設定を書く前に確かめたことがあります。gzip_min_length は「これより小さいレスポンスは圧縮しない」という下限の指定です。数十バイトのレスポンスは圧縮してもgzipのヘッダとフッタのぶんだけ逆に増えるので、無駄打ちを避けるために入れます。
ただし公式ドキュメントにこう書かれています。
The length is determined only from the “Content-Length” response header field. (長さは “Content-Length” レスポンスヘッダからのみ判定される) — nginx documentation「Module ngx_http_gzip_module — gzip_min_length」
Content-Lengthが無いレスポンスは長さを判定しようがないため、サイズに関わらず圧縮対象になります。そしてSymfonyの Response::prepare() はContent-Lengthを付与しないので、PHP-FPM経由のレスポンス——つまりAPIのJSON——はchunked転送になり、下限の判定をすり抜けます。
実データでも確認できました。gzip_min_length 1024 を指定しているのに、展開後0.1KBしかない集計APIのレスポンスにも content-encoding: gzip が付いています。
では困るかというと、困りません。すり抜けた結果として起きるのは、数百バイトのレスポンスが数十バイト太ることだけです。アプリケーション側でContent-Lengthを付ければ判定は効くようになりますが、そのためにはレスポンス全体を組み立ててから送ることになり、最初の1バイトが返るまでが遅くなります。数十バイトのために払うコストではありません。設定はそのままにして、「この指定が効くのは静的ファイルだけ」と設定ファイルのコメントに書き添えるにとどめました。
入れた設定
serverブロックに追加したのは18行です。
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types
application/javascript
application/json
application/xml
image/svg+xml
image/x-icon
text/css
text/javascript
text/plain
text/xml;
それぞれの意図です。
| 設定 | 意図 |
|---|---|
| gzip_comp_level 5 | 1 vCPUを考慮し、圧縮率とCPUコストのバランスで選定 |
| gzip_vary on | プロキシやCDNでAccept-Encodingごとにキャッシュを分ける |
| gzip_proxied any | プロキシ経由のリクエストでも圧縮する |
| gzip_min_length 1024 | 静的ファイルの極小レスポンスを対象から外す |
| text/html を書かない | 常に対象であり、書くと duplicate MIME type の警告になる |
MIMEタイプは実際に配信しているものだけを並べました。application/manifest+json と application/rss+xml は該当ファイルが無いので入れていません。font/woff2 は既に圧縮済みなので入れません。text/javascript と application/javascript を両方入れているのは、nginx 1.21.3でJavaScriptの既定MIMEタイプが変わっており、どちらで配信されても拾いたいからです。
結果
検証環境の接続先を本番DBに切り替え、gzip未適用の本番環境と比較しました。展開後サイズと表示行数が一致しているので、同一データでの比較になっています。
一覧画面を開いたときの数字です。
| 指標 | gzipなし | gzipあり | 差 |
|---|---|---|---|
| TTFB | 1,335ms | 777ms | 42%短縮 |
| 最終API完了 | 5,578ms | 2,914ms | 48%短縮 |
| 転送量 | 397.2KB | 69.2KB | 82.6%削減 |
| 展開後サイズ | 391.7KB | 391.4KB | 一致 |
TTFBはTime To First Byteの略で、リクエストを送ってからレスポンスの最初の1バイトが返るまでの時間です。本文をすべて受け取るまでの時間ではないので、転送量が減っても直接は縮まりません。むしろ圧縮するぶんの処理が先に挟まる可能性すらあります。それでも42%短縮しているのは、一覧画面が同時に何本ものAPIを叩いており、先行するリクエストの転送が早く終わったぶん後続の待ち時間が減ったからではないでしょうか。gzip単体の効果として見るなら、TTFBよりも転送量の行を見るのが妥当です。
転送量の82%削減はgzipの効果として確実です。ただし時間の短縮幅は割り引いて見る必要があります。本番は業務時間中で実ユーザーのアクセスがあり、検証環境は計測者だけという負荷差があるうえ、そもそも別インスタンスです。時間差のすべてがgzip由来とは言えません。
本番反映後はAPI全体で393.2KB → 69.5KB(82.3%削減)、ビルド済みアセットで875.6KB → 12.1KB(98.6%削減)。vary: Accept-Encoding も付いていました。
どこまで入れておいて得か
判断の軸は、圧縮に使うCPU時間と、削減した転送量に対応する送出時間のどちらが大きいかです。
今回のレスポンスなら圧縮コストは計測マシンで10ms、遅いvCPUを見込んで3倍としても30ms程度。対して転送量は1,847KBから124KBに減ります。帯域を10Mbpsと仮定すると1,847KBの送出に約1.5秒、124KBなら0.1秒。桁が違うので圧縮する側が明確に有利です。この関係は帯域が細いほど、レスポンスが大きいほどgzip有利に傾きます。逆に極めて高速な内部ネットワークで小さなレスポンスを大量に返す構成なら、圧縮コストが相対的に目立ちます。
効くケースと効かないケースを整理するとこうなります。
| 条件 | 効くか | 理由 |
|---|---|---|
| JSON、JS、CSS、SVG、XML、CSV | 効く | 同じ文字列の繰り返しが多い |
| 数十KB以上のレスポンス | 効く | 削減の絶対値が大きい |
| JPEG、PNG、WebP、woff2、zip | 効かない | 既に圧縮済みでCPUだけ消費する |
| 数十バイトのレスポンス | 逆効果 | ヘッダとフッタで20バイト程度増える |
| CPUが常時飽和、帯域は余裕 | 避ける | トレードオフが逆向きになる |
もうひとつ、gzipにはBREACH攻撃という論点があります。HTTPS上で、秘密値と攻撃者が制御できる入力が同一レスポンスに含まれる場合に限って成立するものです。今回はHTMLが既定で既に圧縮されていて新たに増えるリスクはほぼないこと、APIのリソースクラスを検索してCSRFトークンなどの秘密値をJSONに出していないことを確認したうえで進めました。条件を確認せずに「gzipは危険」と言うのも、条件を知らずに全部圧縮するのも、どちらも雑だと思います。
さらに詰めるなら手は2つあります。静的ファイルはビルド時に .gz を作っておいて gzip_static で配信すれば、リクエストのたびに圧縮する必要がなくなります。もうひとつはBrotliで、gzipの後発にあたる圧縮方式です。HTTPの仕組みはgzipと同じで、クライアントが Accept-Encoding: br を送り、サーバーが Content-Encoding: br を返します。主要なブラウザは対応済みで、同じテキストならgzipより1割から2割小さくなります。ただしnginxで使うにはモジュールの追加が必要で、標準のパッケージのままでは有効にできません。今回はそこまで手を広げていません。
確認のしかた
同じ手順で再現できるよう書いておきます。
適用の有無はブラウザのレスポンスヘッダで Content-Encoding を見ます。転送量はPerformance APIで測れます。ブラウザに標準で入っているAPIなので、対象ページを開いた状態でDevToolsのコンソールに次を貼れば動きます。transferSize が実際に流れたバイト数、decodedBodySize が展開後のバイト数で、両者の差が圧縮の効果です。
performance.getEntriesByType('resource')
.filter((entry) => entry.name.includes('/api/'))
.map((entry) => ({
name: entry.name,
transferSize: entry.transferSize,
decodedBodySize: entry.decodedBodySize,
}));
nginxの設定は反映前に構文だけ確認できます。nginx -t は指定した設定ファイルを読んで構文を検証するだけのコマンドで、サーバーを起動もリロードもしません。
nginx -t -c /path/to/test.conf
ひとつ注意があります。sites-available/default のような設定は server {} から始まる断片で、単体では nginx -t に通りません。http {} で包んでから渡す必要があります。
まとめ
gzip onだけではHTMLしか圧縮されない。gzip_typesに列挙しないとJSONもJSもCSSも素通りする- 圧縮レベルはCPU時間で転送量を買う設定。1から5で十分で、9まで上げても圧縮率はほとんど変わらずCPUだけ増える
gzip_min_lengthはContent-Lengthからしか判定できない。chunkedで返るPHP-FPM経由のレスポンスはすり抜けるので、実質は静的ファイル向けの設定になる- 効果の確認は転送量と展開後サイズの差で見る。時間の変化は環境差が混ざる
転送量が8割減っても、434件で1,847KBを返している事実は変わりません。gzipは効いたぶんだけ問題を見えにくくもするので、返す量そのものを減らす話は別で進めます。