FRESH! でサーバサイドエンジニアをしています @hori_ryota です。
今回は FRESH! における Web パフォーマンス改善の一環として、静的アセット配信の効率化に取り組みました。
実装工数が少なくそれなりに高い効果を上げられたので、参考になれば幸いです。
概要(やったこと)
今回は CI を含めた開発フロー、インフラ整備の領域で Web のパフォーマンス改善に貢献できればいいなと思い、以下の改善を行いました。割りと新しい(前例が少ない)技術を上手いこと取り入れられたかなーと思っています。
Cache-Control の Immutable Extension の適用
Cache-Control: Immutable
は、 Conditional GET
(リロード時に有効なキャシュを持っていてもサーバに更新確認をするリクエスト。 304 のステータスコードが返る)を防ぐためのヘッダです。導入することでリクエスト数の削減が期待できます。
Service Worker による静的アセットのキャッシュ
主にクライアント側の対応ですが、リクエストイベントをフックして挙動を上書きすることができます。 Immutable Extension と同じくキャッシュされたリソースへのリクエストを防げるのでリクエストの削減が可能です。
FRESH! は既に HTTPS で配信されおり、アセットのキャッシュと破棄をしやすい構成だったこともあり(後述)、サーバー側の対応はファイルの配置程度でしたが、静的アセット配信の効率化に関わるので挙げています。
静的リソースの zopfli と brotli による圧縮
zopfli
と brotli
は Google が発表したオープンソースの圧縮アルゴリズムです。これまでも gzip では圧縮をしていましたが、新しいアルゴリズムを用いることで更なる高圧縮を図れます。
FRESH! の静的アセット事情
具体的な対応内容の前に、前提として FRESH! の Web アプリケーションサーバの構成を紹介します。
サーバ構成
マイクロサービスを基本思想とし、 Web (ブラウザ) のリクエストを返すサーバの構成はこのようになっています。
それぞれ Docker コンテナで構成されています。 Web フロントエンドからのリクエストは Nginx から Node.js のコンテナを経由して Golang のアプリケーションコンテナへリクエストしています。
静的アセットへのリクエスト(今回の効率化対象)
静的アセットは Node.js のリポジトリで管理しています。静的ファイルへのアクセスで Node.js へリクエストさせるのはもったいないので以下の対応を行っています。
- Node.js の静的アセット用ディレクトリを Nginx と共有する( Docker の Data volumes 機能)
- 静的アセットへのリクエストは Nginx だけで処理する
また、静的アセットの更新時にキャッシュクリアを行うため、静的アセットの URL にはハッシュ( Docker イメージ作成時に生成する)を含める仕組みを整備しました。
対応内容
観点としてはリクエストの削減とペイロードの削減に大別されます。
Cache-Control の Immutable Extension の適用
リクエストの削減のため、 Cache-Control: immutable
ヘッダの対応を行いました。ブラウザは有効な( max-age
に満たない)キャッシュを持っていてもリロード時には更新確認( Conditional GET
)をサーバに投げるため、ステータスコード 304 のアクセスが発生します。
Cache-Control: immutable
はこの更新確認を不要とし、サーバへのリクエストを節約するためのヘッダです。以下のリンクが詳しいのでご参照ください。 Facebook では全リクエストの 60% が更新確認リクエストだったとのことで、大きな効果があったようです。
Cache-Control の Immutable 拡張によるリロード時のキャッシュ最適化 | blog.jxck.io
実装は静的アセットへのリクエストに Cache-Control: immutable
をつけるだけなので簡単です。今回は Nginx でさくっと対応しました。
location path_to_assets {
# 色々(省略)
add_header Cache-Control immutable;
}
以下が対応しているブラウザです。
Firefox が 49.0
から対応しているようです。上記を見る限り Safari は未対応となっていますが、測定(後述)をしてみたところ効果は出ているような結果も見受けられます。少なくとも Safari の Technology Preview 版には実装されている ようです。また、実装した後に気づきましたが、 最新版の Chrome では Cache-Control: immutable
の有無に関わらず、独自実装でキャッシュされるようになっていました。
Service Worker による静的アセットのキャッシュ
リクエストの削減のために Service Worker の導入を行いました。 Service Worker の実装については FRESH! Web パフォーマンス改善 〜クライアントサイド編〜 が公開されているので、そちらをご覧ください。今回の対応で目的とする効果は、 Cache-Control: immutable と同じく 304 リクエストの削減です。
サーバサイドの対応は Nginx のルーティングに service-worker.js
を追加するだけです。
location ~* ^/(service-worker.js)$ {
# 色々(省略)
alias /path_to_assets/$1;
}
という感じでしょうか。
注意点としては、 Service Worker は配置された URL 以下のみを対象として作動するので全リクエストに適用するにはルート直下に配置する必要があります。 FRESH! では service-worker.js
も他の静的アセットと同様に Node.js のリポジトリで管理しているのでルート直下に専用のルーティングを追加しました。
対応しているブラウザは以下です。
Can I use… Support tables for HTML5, CSS3, etc
- Chrome/40
- Firefox/44
- Opera/27
が対応しているようです。また、モバイルでも
- Android Browser/56
- Chrome for Android/57
- Firefox for Android/52
- etc.
と Safari, Edge, IE 以外の主要ブラウザが対応しているかなーという状況。
静的リソースの zopfli による圧縮
ペイロードの削減のため、 zopfli
による静的リソースの圧縮を行いました。 zopfli
は Google が2013年に発表したオープンソースの圧縮アルゴリズムです。
大きな特徴としては
- Deflate 互換
- gzip による圧縮に比べ
3-8%
ほどの圧縮率向上 - gzip より 数十〜数百倍レベルの CPU 時間を要する
というところです。
Deflate というのは圧縮アルゴリズムの1つで、有名なところだと zip, zlib, gzip で使われているアルゴリズムです。つまり Deflate 互換ということは、ざっくり言えば gzip 互換です。クライアントが gzip に対応していれば zopfli で圧縮されたファイルも解凍できます。また、解凍コストもほとんど変わらないため、圧縮側(サーバ側)の工夫だけで恩恵を受けられそうです。
zopfli は高圧縮が可能な反面、圧縮には時間を要します。 CPU への負荷も考慮し、 gzip のようにオンザフライで都度レスポンスを圧縮するのではなく、事前に圧縮しておく方針にしました。 FRESH! では CircleCI で Docker イメージを作成しているので、 Docker イメージ作成のタイミングで圧縮しています。
ディレクトリ構成に依りますが、以下のようなコマンドで圧縮できるでしょう。
ls public/*.js public/*.css public/*.map | xargs -I{} zopfli {}
デフォルトでは元ファイルと同じディレクトリに .gz
が後置された圧縮ファイルが生成されます。
CircleCI などの CI で使用する場合、 zopfli を都度インストールするには時間がかかるので、コンパイル済の zopfli バイナリをキャッシュした方が良さそうです。今回は、キャッシュが切れたときにビルドするのももったいないのでファイルストレージにバイナリを配置してダウンロードできるようにしました。
machine:
environment:
PATH: "${HOME}/mybin:${PATH}"
dependencies:
cache_directories:
- "${HOME}/mybin"
pre:
- |
if [[ ! -x ${HOME}/mybin/zopfli ]]; then
mkdir -p ${HOME}/mybin
aws s3 cp s3://bucket-for-mybin/bin/zopfli ${HOME}/mybin/zopfli
chmod +x ${HOME}/mybin/zopfli
fi
さて、生成したファイルを返すサーバ側の実装ですが、前述の通り gzip 互換なので FRESH! では特に対応不要でした。未対応の場合は Nginx の設定に以下のディレクティブを追加します。
gzip_static on;
これでリクエストされたファイルに対し .gz
がついているファイルが存在すれば優先して返してくれるようになります。 gzip 互換なので古いガラケーなどのブラウザでもない限り対応していると認識しています。
静的リソースの brotli による圧縮
更なるペイロードの削減のため、 brotli
による静的リソースの圧縮を行いました。 brotli
は zopfli
と同じくGoogle が開発したオープンソースのデータ圧縮ライブラリです。
大きな特徴としては
- Deflate 非互換
- zopfli に比べて
20-26%
ほどの圧縮率向上 - 高圧縮時の圧縮速度は zopfli と同じくらい
です。
Deflate 非互換なので対応ブラウザが限られ、サーバー側でも Nginx の設定が必要ではありますが、高い圧縮率は魅力的です。対応もサーバーのみでオプトインに実施できるので大きな改修なしに導入できます。
高圧縮の brotli は、 zopfli と同じく圧縮に時間を要するためにオンザフライは厳しいため、 zopfli と同じく先に圧縮したファイルを用意しておきます。
ls public/*.js public/*.css public/*.map | xargs -I{} bro --quality 10 --input {} --output {}.br
拡張子は .br
です。
bro
コマンドのインストールは google/brotli からソースコードを落とし、 README.md に従えばOKです。 FRESH! では zopfli と同じくファイルストレージにバイナリを配置して管理しています。
生成したファイルを返すサーバ側の実装ですが、 Nginx 用のモジュールが公開されているのでこちらを使用します。 Nginx のビルド時に、
git clone https://github.com/google/ngx_brotli --recursive
で適当なディレクトリにソースコードを落とし、 configure 時に
./configure --add-module=/path/to/ngx_brotli # -- other options
と module のオプションを追加して組み込みます。そして、 gzip 対応と同じように以下のディレクティブを設定します。
brotli_static on;
これで Accept-Encoding: br
のヘッダ付きでリクエストされたファイルに対し .br
がついているファイルが存在すれば優先して返してくれるようになります。Accept-Encoding: br
は brotli 対応しているブラウザでは勝手につけてくれるので何もしなくてOKです。
以下が対応しているブラウザです。
Can I use… Support tables for HTML5, CSS3, etc
- Chrome/50
- Firefox/44
- Opera/38
- Edge/15
が対応しているようです。モバイルは
- Android Browser/56
- Chrome for Android/57
- Firefox for Android/52
でした。 Safari の今後に期待ですね。
効果測定
リクエストの削減についての効果測定
FRESH! は AWS を使用しているので ALB へのアクセスログから効果測定をしてみます( Amazon Athena 便利!)。
とある対応前のログ1日分( before
)と 対応後のログ1日分( after
)から、 before にしかない UserAgent を抽出してみます。
流したクエリ
SELECT *
FROM
(SELECT count(*) AS count,
sum(sent_bytes) AS sent_bytes,
user_agent
FROM before
WHERE elb_status_code = '304'
AND url LIKE '%path_to_assets%'
GROUP BY user_agent) AS before
LEFT JOIN
(SELECT count(*) AS count,
sum(sent_bytes) AS sent_bytes,
user_agent
FROM after
WHERE elb_status_code = '304'
AND url LIKE '%path_to_assets%'
GROUP BY user_agent) AS after
ON before.user_agent = after.user_agent
WHERE after.user_agent is null
ORDER BY before.count DESC limit 10
結果( limit 10
で多いもの10件)
count sent_bytes user_agent
14454 1670274 "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:52.0) Gecko/20100101 Firefox/52.0"
5703 645611 "Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0"
5435 634856 "Mozilla/5.0 (Windows NT 6.3; WOW64; rv:52.0) Gecko/20100101 Firefox/52.0"
3588 436297 "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:52.0) Gecko/20100101 Firefox/52.0"
2936 305822 "Mozilla/5.0 (iPad; CPU OS 10_2_1 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) GSA/24.1.151204851 Mobile/14D27 Safari/602.1"
2431 252632 "Mozilla/5.0 (iPhone; CPU iPhone OS 10_2_1 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) GSA/24.1.151204851 Mobile/14D27 Safari/602.1"
1802 327016 "Mozilla/5.0 (iPhone; CPU iPhone OS 10_2_1 like Mac OS X) AppleWebKit/602.4.6 (KHTML, like Gecko) Mobile/14D27 YJApp-IOS jp.co.yahoo.ipn.appli/4.6.21"
1759 198111 "Mozilla/5.0 (Windows NT 5.1; rv:52.0) Gecko/20100101 Firefox/52.0"
1705 188638 "Mozilla/5.0 (Windows NT 6.0; rv:52.0) Gecko/20100101 Firefox/52.0"
1583 175580 "Mozilla/5.0 (Windows NT 10.0; rv:52.0) Gecko/20100101 Firefox/52.0"
Firefox/52
系と Safari/602
系の 304 リクエストが丸っと削減できたことが確認できました。 before の静的アセットリクエストについてまとめると以下の表になります。
項目 | count | sent_bytes |
---|---|---|
削減304 | 117,051 | 14,849,311 |
304 | 1,420,446 | 177,853,716 |
304以外 | 7,603,471 | 371,513,334,028 |
削減304は after で削減された UserAgent から推測した、削減されるであろう値です。リクエスト数について、削減304 / 全304 = 約8%
なので、 Cache-Control: immutable
に対応しているブラウザはまだ少数派なようですね。
さすがに 304 リクエストは1リクエストが 約180 byte
なので帯域のインパクトは小さいですが、 304 / 全リクエスト数 = 約16%
なことから、ブラウザの対応が進めばリクエスト数は大きく削減されそうです。
以上をまとめると、
- 対応ブラウザの304リクエストは撲滅できた
- Chrome は既に304リクエストを送らずにキャッシュする挙動がデフォルトになっているので対応不要
- 今のところは対応ブラウザが少数派なので効果は限定的ではある
- FRESH! の場合は静的アセットのリクエスト中、 約16% が 304 なのでブラウザが対応すればリクエスト数の削減に貢献してくれそう
となりました。
ペイロードの削減についての効果測定
zopfli の圧縮率
計測時点の FRESH! の静的アセットから2ファイルをサンプルとしてご紹介します。
392K app.css
85K app.css.gz_gzip
80K app.css.gz_zopfli
1.2M app.js
257K app.js.gz_gzip
243K app.js.gz_zopfli
_gzip
が gzip
コマンドで圧縮したもの、 _zopfli
が zopfli
コマンド(オプションなし)で圧縮したものです。両ファイルとも zopfli/gzip
が 94-95% となっており、 5% の圧縮ができていることが確認できます。
brotli の圧縮率
zopfli の結果と併記してご紹介します。
392K app.css
73K app.css.br
85K app.css.gz_gzip
80K app.css.gz_zopfli
1.2M app.js
195K app.js.br
257K app.js.gz_gzip
243K app.js.gz_zopfli
zopfli 比で app.css
が 91% 、 app.js
が 80% 程度まで圧縮することができました。大きなファイルほど効果が高そうですね。
FRESH! でのペイロード削減
FRESH! の app.js
で検証したところ、約60%のアクセスが brotli に対応できていました。Can I use… のブラウザシェアでも 56.75% と記載されているので妥当なところでしょうか。
静的アセットへのリクエストの転送量が約 400 GB だとすると( FRESH! のとある日より)
- brotli
- 15%の削減ができたものとする
400GB * 60% * 15% = 36GB
- zopfli
- 5%の削減ができたものとする
400GB * 40% * 5% = 8GB
の転送量が削減できたと計算できます。合わせると全体で 11% の削減ですね。 zopfli については圧縮の試行回数を増やせるのでファイルによっては更に削減できる可能性もあります。
以上
以上が今回行った静的アセット系データの効率化によるパフォーマンス改善とその結果です。
改めて成果をまとめると、
Cache-Control: immutable
- ヘッダを追加するだけなので実装が簡単(すごく)
- Service Worker が対応できない Safari が対応できそうなのは大きなメリット
- Service Worker
service-worker.js
を配置するのでブラウザ側の対応は必要- 304リクエスト削減以外でも色々と使えそうで期待
Cache-Control: immutable
で対応していないモバイルブラウザへの対応があるので併せて対応すると良さそう
- zopfli
- データ量が 5% 削減
- gzip 互換でクライアント側は何もしないでいいの最高
- minify するフローで組み込めばよさそう
- brotli
- データ量が 15% 削減(20% 削減できるという情報があったのでもっといけるかも?)
- ブラウザシェア的には 60% くらいが対応(2017年4月現在)
- Nginx 側で
Accept-Encoding: br
に対応する必要があるが、 module があるので難しくはない - zopfli と同じく minify するフローで組み込んでおけばよさそう
という感じです。
どの対応も実装工数はとても少なく作業量に対してとてもコスパが良い改善でした。静的アセットへのリクエストを減らし、更にオンザフライでなく事前に圧縮ファイルを配置することで、 Nginx の負荷低減も兼ねられたのでよかったです。
パフォーマンス改善はユーザー体験の改善にも直結する部分なので、今後とも取り入れられるものはどんどん取り入れて改善を図っていきたい所存です。