【Hono】APIレスポンスをgzip圧縮して本番でビフォーアフターを計測する
今回はHonoで作ったAPIのレスポンスをgzip圧縮して、本番環境でビフォーアフターを計測していきます。
前提
検証環境は以下です。
- Node.js 22.21.1
- Hono 4.12.5
- ECS Fargate(cpu 256 / memory 512MB)
- Aurora Serverless v2(min 0 ACU / max 4 ACU)
- ALB → ECSの構成で、APIの前段にCDNは置いていません
計測はChromeのDevToolsのNetworkタブで行います。対象はタスク一覧ページが叩くGET /api/tasksで、約300件のタスクを返します。
現状を確認する
タスクが300件を超えたあたりから一覧ページの表示が遅くなってきたので、まずはレスポンスヘッダを確認します。
Request URL: https://api.example.com/api/tasks
Status Code: 200 OK
content-length: 866481
content-type: application/json
content-lengthが866481、つまり約850KBあります。
さらにレスポンスヘッダにcontent-encodingがありません。リクエストヘッダを見るとブラウザは以下を送っています。
accept-encoding: gzip, deflate, br, zstd
ブラウザ側は「圧縮して送ってくれてよい」と申告しているのに、サーバが非圧縮のまま返している状態でした。
AWSのALBは圧縮機能を持っていないため、アプリケーション側で圧縮しない限りこのままになります。ローカル開発では件数が少なく気付かなかったのですが、本番で件数が増えて顕在化した形です。
圧縮前のTimingを計測
圧縮を入れる前に、Networkタブのtasksの行をクリックしてTimingタブで内訳を確認しておきます。
No throttling(PC回線)の場合
Waiting for server response 656.19 ms
Content Download 116.76 ms
781.80 ms
Fast 4Gの場合
Waiting for server response 296.67 ms
Content Download 874.18 ms
1.17 s
PC回線ではサーバ応答待ち(TTFB)が大半を占めていて、ダウンロードは117msしかありません。一方Fast 4Gではダウンロードが874msと大半を占めています。
圧縮で縮むのはダウンロードの部分なので、この時点で「PCではあまり変わらないかもしれない」という予想はできます。
圧縮ミドルウェアを追加する
Honoにはcompressミドルウェアが同梱されています。Web標準のCompressionStreamを使うため、Node.js 22であれば追加のパッケージは不要です。
エントリーポイントに以下を追加します。
import { compress } from 'hono/compress' // 追加
const app = new Hono()
app.use('*', secureHeaders())
app.use('*', compress()) // 追加
app.use('*', logger())
compressはawait next()の後、つまりハンドラが応答を作った後に動きます。Honoのミドルウェアは先に登録したものほど外側になるので、他のミドルウェアより前に登録して「最後に圧縮する」順序にしています。
Varyヘッダを追加する
hono/compressはVaryヘッダを付けないので、自分で追加します。
app.use('*', compress())
// ↓追加↓
app.use('*', async (c, next) => {
await next()
c.res.headers.append('Vary', 'Accept-Encoding')
})
圧縮を入れると同じURLに対して圧縮版と非圧縮版の2種類のレスポンスが存在することになります。CDNやプロキシなどの中間キャッシュがこれを混同しないよう、Vary: Accept-Encodingで「Accept-Encodingの値もキャッシュキーに含める」と伝えておきます。
今回の構成ではAPIの前にキャッシュがないのですぐに問題にはなりませんが、後からCDNを入れたときに原因がわかりにくい不具合になるので、圧縮とセットで入れておくのが良さそうです。
setではなくappendを使っているのは、corsミドルウェアが付けるVary: Originを上書きしないためです。
ローカルで確認
curlでAccept-Encodingを付けてリクエストします。
$ curl -s -H 'accept-encoding: gzip' -o body.gz -D - http://localhost:8787/api/tasks
HTTP/1.1 200 OK
content-encoding: gzip
vary: Origin, Accept-Encoding
Transfer-Encoding: chunked
content-encoding: gzipが付き、varyにAccept-Encodingが追加されています。Content-Lengthが消えてTransfer-Encoding: chunkedになっているのは、ストリーム圧縮で最終サイズが事前にわからないためで、正常な挙動です。
サイズを比較します。
圧縮なし: 22403 bytes
gzip: 4570 bytes
ローカルの26件のデータでは約1/5になりました。
本番で計測する
デプロイ後、同じ手順で計測します。
No throttling(PC回線)
| 項目 | ビフォー | アフター |
|---|---|---|
| Size | 850kB | 278kB |
| Waiting for server response | 656ms | 636ms |
| Content Download | 117ms | 210ms |
| Time(3回計測) | 781ms | 987ms / 1.38s / 1.09s |
転送量は850kBから278kBと約1/3になりました。ローカルでは1/5だったので、本番のデータは思ったより圧縮が効きにくかったようです。
一方でTimeは縮まっていません。Content Downloadが117msから210msに伸びています。
Fast 4G
| 項目 | ビフォー | アフター |
|---|---|---|
| Size | 850kB | 278kB |
| Waiting for server response | 297ms | 613ms |
| Content Download | 874ms | 319ms |
| Time | 1.17s | 934ms |
こちらはContent Downloadが874msから319msと約64%短縮されました。転送量の削減率(67%)とほぼ一致しています。
Waiting for server responseが297msから613msに増えていますが、これはビフォーの計測でも656msと297msでばらついていたので、圧縮とは別の要因(Aurora Serverless v2のACUスケールやDB接続のウォームアップ)だと思われます。
PC回線で遅くなった理由
hono/compressは以下のようにストリームで圧縮しています。
const stream = new CompressionStream(encoding)
ctx.res = new Response(ctx.res.body.pipeThrough(stream), ctx.res)
サーバが圧縮しながら送出するため、圧縮にかかるCPU時間はブラウザから見るとContent Downloadに計上されます。
今回のECSタスクはcpu 256(0.25vCPU)で動いています。このサイズで850KBをgzipすると100ms単位の時間がかかるため、PC回線での元々のダウンロード時間(117ms)を上回ってしまったようです。
圧縮のCPUコストは一般的には無視できると言われますが、最小サイズのFargateでははっきり見えるようです。
まとめ
圧縮を入れたことで、モバイル相当の回線ではダウンロード時間が約1/3になりました。一方でPC回線ではサーバ応答待ちが支配的なため圧縮の効果はなく、圧縮のCPU時間の分だけむしろ少し遅くなりました。
圧縮はネットワークがボトルネックのときにのみ有効なので、入れる前にTimingタブでTTFBとContent Downloadの内訳を確認しておくのが良さそうです。
次は、一覧ページで表示していないdescriptionなどの長文カラムを一覧用のSELECTから外して、サーバ応答待ちと圧縮対象のバイト数を同時に減らすことを試してみようと思います。
参考