Service WorkerとCache APIによるWebサーバーの負荷軽減とテスト実装について-前編
こんにちは。ニコニコQセクションのHajime-san(GitHub)です。
当記事では所属部署で開発に携わっている「ニコニコオーディション」の既存システムにService WorkerとCache APIを用いて、
Webサーバーからのレスポンスを一定期間保存し、キャッシュ(CacheStorage
)からレスポンスを返すことによってWebサーバーへの負荷軽減を実現するまでの経緯や実装などを紹介します。
オーディションシステムの紹介
システムの概要
まず初めに本稿の主題となるシステムが抱える課題について、先ほどサラッと単語が登場した「ニコニコオーディション」というシステムの前提があるとより理解が明瞭になるのでこちらを紹介します。 ニコニコオーディションとは、ニコニコ生放送のギフト機能を利用した投票イベントの集計システムです。
- 配信者が特設ページなどからオーディションにエントリーする
- 配信者がニコニコ生放送で番組を開始する
- 視聴者から番組にギフト演出が贈られた際に、条件に応じてオーディションのスコアを加算する
- オーディション期間中の合計スコアに応じて順位を決定する
というサイクルを回すことによって以下のような狙いを達成したいというシステムになります。
- ニコニコ生放送を活性化させる
- 配信者の配信モチベーションにつなげる
- 視聴者が配信者を応援するという文化を促進する
システムの構成
また、システムがどういった技術で動いているのかというのも後述の課題解決にあたって重要となりますのでこちらも簡潔に説明します。 ニコニコオーディションのシステムは現在、ほとんど独立した(一部他サービスと連携している部分があります)インフラ・バックエンドサービス・フロントエンドで構成されています。
オーディションの運用は主にニコニコインフォを活用しており、当該ページにてオーディションの概要やルールなどを記載しつつ、
下記の3つの要素に関してはオーディションシステムが配信しているページをiframe
を使って埋め込んでいます。
- 配信者がエントリーするボタン
- オーディションにエントリーしている配信者が放送中のイベント番組
- オーディション期間中の合計スコアのランキング
これらのiframe
がオーディションシステムのWebサーバーと通信し、その結果を表示するという独立したフロントエンドになっています。
オーディションシステムが抱える課題
さて、前置きが長くなってしまいましたがようやくここで本稿の課題につながります。 ニコニコオーディションには前述の通り実施期間が存在すること、及び合計スコアのランキングが表示されていることにより、 オーディションの終了期限が迫ると配信者あるいは視聴者がブラウザのリロードなどを駆使して小まめにランキングを確認したくなるという事象が、 オーディションシステムを運用するにつれ観測されるようになりました。
このリロードによる短期間でのリクエスト数の増加に関して、iframe
に関連するフロントエンドの静的リソースはCDNを経由して配信されているのでこちら側の負荷は現状問題となっていないのですが、
オーディションの盛り上がりによってはバックエンドサービスを運用するWebサーバー及びデータベースサーバーへの負荷が一時的にかなり高くなってしまうという課題が浮かび上がりました。
サーバー構成などにおいても対策は取っているものの、この事象はオーディション終了間際の数分間で急激にピークを迎えるためサーバーのオートスケーリングでは間に合わない可能性が高く、オーディションごとにどの程度リクエストが増加するかの予測がしにくいため事前のスケールアウトも見通しが立てづらいといった状況でした。
技術選定などの調査
要件に関して
そこで、バックエンドサービスではなくフロントエンドでこの一時的なリクエスト数の増加に対して何か対策を打つことが出来ないか、その調査からスタートするというタスクが筆者に任されたという経緯になります。
具体的な要件としては下記の通りです。
- それぞれの
iframe
で必ず呼び出されるエンドポイントが存在し、このエンドポイントのレスポンスは高頻度に内容が更新されるわけではない - 3つ
iframe
が設置されるとすると、当該ニコニコインフォにアクセスして上から下までページを閲覧すると仮定した場合は3回そのエンドポイントが呼び出されることになる - あくまで短時間のリクエスト数増加への対策のため、レスポンスを何かしらでキャッシュする場合に長時間キャッシュする必要はない
- 特定の行動をトリガーとし、そのタイミングでは即座に新しいレスポンスを表示したい
技術選定に関して
こうした前述の要件をもとに、具体的な技術選定や実装のイメージを膨らませていくことになります。
候補としてはlocalStorage
を駆使したりService Worker
を利用する方法などがフロントエンドでデータストレージを利用した状態管理が可能になるのではないかなとヒントを頂いており、
前者のlocalStorage
の場合は既存のコードでfetchする際にlocalStorage
の状態を管理したりする必要がありそうでなかなか大幅な改修になりそうなことが懸念でした。
後者のService Worker
に関しては、これまでアプリケーション構築で利用した経験は無かったものの、
最近のフロントエンド開発でmswを用いる機会が多かったので、「なんかリクエストをプロキシ出来るやつ」もしくは「push通知で使うやつ」ぐらいの認識はありました。
このように思索するうちに、要件を整理すると「(半)永続ストレージにアクセスが可能なプロキシサーバー」のようなシステムがまさにうってつけなのではないかと思い、Service Worker
について調査を進めていくことになりました。
Service Workerの仕様と実装への道のり
ここで改めてService Workerの仕様について掘り下げていくことになります。
1からここに書くと中々の文章量になりそうなので掻い摘んで取り上げることになりますが、
Service Worker
をプロダクト運用する上で特に重要だと筆者が感じたポイントを挙げます。
スコープという概念
スコープという概念があり、これに従ってブラウザで発生したリクエストをフック出来るかどうかが決定されます。
本件のオーディションシステムはニコニコインフォなどの別サービス上にUIが設置されています。可能であれば本件のような改修で設置先サービス側に対して追加の実装を行なったり、あるいは不具合・不整合が出ないことを理想とします。
例えばニコニコインフォのページURLを
https://blog.nicovideo.jp/niconews/000000.html
と想定した際に、
<iframe src="https://audition.nicovideo.jp">
(実際はhttps://audition.nicovideo.jp/index.html
にリライトされてリクエストしている想定)というiframe
が設置されていて
https://audition.nicovideo.jp/service-worker.js
(スコープは { scope: "./" }
とします)
がユーザーのブラウザに登録され、このService Worker
がページコントロール可能にあるとき、
そこからhttps://audition.nicovideo.jp/api/foo
のようなリクエストが発生した場合にリクエストをフックすることが出来ます。
仮にhttps://blog.nicovideo.jp/script.js
からhttps://audition.nicovideo.jp/api/foo
にリクエストを投げるような事があったとしても、スコープの違いによりこちらのリクエストはhttps://audition.nicovideo.jp/service-worker.js
として登録されたService Worker
にはフックされません。
今日日、おおよそのブラウザでサポートされていること
仮にユーザーがService Worker
をサポートしていないブラウザを使用している場合でも
if('serviceWorker' in navigator === false) return
のようなチェックを挟むことで登録に際してエラーが起きることは無いですし、 そもそもそのブラウザを利用しているのはかなり稀なケースであると言えるので、ここをカバーすることは意識しないこととしました。
Service Worker
スクリプトの更新に関して
メインのスクリプトと同じく、Service Worker
スクリプトの実装に変更を追加してデプロイするとなった際に、こちらの中身も同時に更新されることが期待されます。
ブラウザの仕様としてService Worker
が永続化されないように前回の更新から24時間経過した際はブラウザキャッシュを無視して、ネットワークにスクリプトの更新確認を行います。
通常、JavaScriptやCSSなどのアセットファイルは長期間のCache-Controlヘッダー
が付与されていることが多いですが、Service Worker
スクリプトに関してはデプロイ後に可能な限り速やかに中身が入れ替わることを期待して短期間のCache-Control
ヘッダーを設定しました。
更新に際してService Worker
スクリプトのファイル名に変更を加える手法は、以前にブラウザへインストールされているものと別扱いされてしまうので避けるべきのようです。
また、上記の手順でスクリプトが更新されても実際に使われるのは「稼働中のService Worker
がコントロールしているすべてのページが閉じられたとき」と下記リンクにあります。
さらに、ユーザーがタブを開いたままでもスクリプトの更新があった際にリロードしたら確実に更新してほしい場合、activate
イベントへ遷移させるため、install
イベント内に下記のコードを実装します。
self.addEventListener('install', (event) => {
event.waitUntil(self.skipWaiting());
});
CacheStorageの更新に関して
キャッシュの命名について、CacheStorage.open()
の引数の変数名をcacheName
(文字列型)とした時、メインスクリプトおよびService Worker
スクリプトに更新があった際に更新前のcacheName
を参照しながらCache API
を利用すると意図しない挙動になる可能性があるのでデプロイ時に更新したいです。
こちらはビルド時のタイムスタンプを文字列の接尾辞として一意の値となるようにすることで、常に最新のcacheName
を参照しながら動作するようにしました。
デプロイによってcacheName
が更新された際、ユーザーの操作するブラウザのCacheStorageに更新前のcacheName
に紐づくキャッシュが残っている可能性があり、もう参照されないデータであるので確実に消去したいです。
こちらはService Worker
のactivate
イベント内にて、更新前のcacheName
に紐づくキャッシュ削除処理を実装しつつ、ページコントロールを有効にします。
self.addEventListener('activate', (event) => {
const cachesToKeep = ['prefix/${BUILD_TIME_TIMESTAMP}'];
// 更新前のcacheNameに紐ずくキャッシュ削除処理
event.waitUntil(
caches.keys().then((keyList) =>
Promise.all(
keyList.map((key) => {
if (!cachesToKeep.includes(key)) {
return caches.delete(key);
}
})
)
)
);
// ページコントロールを有効にする
event.waitUntil(self.clients.claim());
});
つまるところ、「ユーザーがタブを開きっぱなしでもリロードがあれば最新スクリプトに入れ替える」および「最新スクリプトに入れ替わった際、古いキャッシュを削除する」を期待する場合、 installイベント内の記述とactivateイベント内の記述は双方揃って初めて機能することとなります。
実装
ここで実際に稼働しているService Worker
スクリプトの実装内容ですが、大まかに下記のようになります。
- キャッシュしたいエンドポイントからのリクエストが来た際
- 初回はキャッシュが存在しないのでWebサーバーにリクエストし、レスポンスヘッダーにキャッシュの有効期限を判別するための独自ヘッダーを付与し、
CacheStorage
に保存しておきます。 - その後はキャッシュが存在しなおかつ有効期限内であれば
CacheStorage
に保存したレスポンスを返却します。
- 初回はキャッシュが存在しないのでWebサーバーにリクエストし、レスポンスヘッダーにキャッシュの有効期限を判別するための独自ヘッダーを付与し、
- キャッシュをパージ(消去)したいエンドポイントからのリクエストが来た際
CacheStorage
をクリアしつつWebサーバーにリクエストを通します。
このキャッシュ機構によりユーザーがブラウザをリロードで頻繁に画面を更新しようとした際に、Webサーバーへのリクエスト数が膨れあがらないことを期待するということになります。
業務上の時系列と本稿のテキストの時系列の間で順序が前後してしまったのですが、実際の業務では当初Googleが開発しているService Worker
のライブラリWorkboxを利用することで、ドキュメントやテストが担保されたものにしようと考えていました。
しかし、その段階では前述のService Workerの仕様と実装への道のりで紹介したような
Service Worker
の根本的な理解が無い状態であり、実装者である筆者がブラックボックスを抱えたままで良いのかどうかを検討した結果、ライブラリは用いずに仕様を紐解きながらスクラッチで書くこととしました。
効果の検証
実際にこちらを本番環境にデプロイした後の効果ですが、1日単位で下記の計算式
キャッシュしたいエンドポイントへのリクエスト数 / Webサーバーへの総リクエスト数
をもとに実装前と比較しておおよそ10~20%
、リクエスト数を削減することが出来ました。
感想
本稿の課題と直接関連はないのですが、Service Worker
によってヒットしたキャッシュはレイテンシーを大幅に短縮する副次的な効果も生み出しています。
また、今振り返るとどの程度のリクエスト削減が可能なのか試算を提示するなどは、今後意識出来ると良いなと思いました。
ここまでのService Worker
の実装内容およびその仕様を踏まえた上で、Service Worker
スクリプトに対するテストについて次回の記事でお話しさせていただきます。
株式会社ドワンゴでは、様々なサービス、コンテンツを一緒につくるメンバーを募集しています。 ドワンゴに興味がある。または応募しようか迷っている方がいれば、気軽に応募してみてください。