Service WorkerとCache APIによるWebサーバーの負荷軽減とテスト実装について-前編


こんにちは。ニコニコQセクションのHajime-san(GitHub)です。

当記事では所属部署で開発に携わっている「ニコニコオーディション」の既存システムにService WorkerCache APIを用いて、 Webサーバーからのレスポンスを一定期間保存し、キャッシュ(CacheStorage)からレスポンスを返すことによってWebサーバーへの負荷軽減を実現するまでの経緯や実装などを紹介します。

オーディションシステムの紹介

システムの概要

まず初めに本稿の主題となるシステムが抱える課題について、先ほどサラッと単語が登場した「ニコニコオーディション」というシステムの前提があるとより理解が明瞭になるのでこちらを紹介します。 ニコニコオーディションとは、ニコニコ生放送ギフト機能を利用した投票イベントの集計システムです。

  1. 配信者が特設ページなどからオーディションにエントリーする
  2. 配信者がニコニコ生放送で番組を開始する
  3. 視聴者から番組にギフト演出が贈られた際に、条件に応じてオーディションのスコアを加算する
  4. オーディション期間中の合計スコアに応じて順位を決定する

というサイクルを回すことによって以下のような狙いを達成したいというシステムになります。

  • ニコニコ生放送を活性化させる
  • 配信者の配信モチベーションにつなげる
  • 視聴者が配信者を応援するという文化を促進する

システムの構成

また、システムがどういった技術で動いているのかというのも後述の課題解決にあたって重要となりますのでこちらも簡潔に説明します。 ニコニコオーディションのシステムは現在、ほとんど独立した(一部他サービスと連携している部分があります)インフラ・バックエンドサービス・フロントエンドで構成されています。

オーディションの運用は主にニコニコインフォを活用しており、当該ページにてオーディションの概要やルールなどを記載しつつ、 下記の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にはフックされません。

SWと簡易構成図

今日日、おおよそのブラウザでサポートされていること

仮にユーザーが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 Workeractivateイベント内にて、更新前の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に保存したレスポンスを返却します。
  • キャッシュをパージ(消去)したいエンドポイントからのリクエストが来た際
    • CacheStorageをクリアしつつWebサーバーにリクエストを通します。

このキャッシュ機構によりユーザーがブラウザをリロードで頻繁に画面を更新しようとした際に、Webサーバーへのリクエスト数が膨れあがらないことを期待するということになります。

業務上の時系列と本稿のテキストの時系列の間で順序が前後してしまったのですが、実際の業務では当初Googleが開発しているService WorkerのライブラリWorkboxを利用することで、ドキュメントやテストが担保されたものにしようと考えていました。 しかし、その段階では前述のService Workerの仕様と実装への道のりで紹介したような Service Workerの根本的な理解が無い状態であり、実装者である筆者がブラックボックスを抱えたままで良いのかどうかを検討した結果、ライブラリは用いずに仕様を紐解きながらスクラッチで書くこととしました。

効果の検証

実際にこちらを本番環境にデプロイした後の効果ですが、1日単位で下記の計算式 キャッシュしたいエンドポイントへのリクエスト数 / Webサーバーへの総リクエスト数 をもとに実装前と比較しておおよそ10~20%、リクエスト数を削減することが出来ました。

感想

本稿の課題と直接関連はないのですが、Service Workerによってヒットしたキャッシュはレイテンシーを大幅に短縮する副次的な効果も生み出しています。 また、今振り返るとどの程度のリクエスト削減が可能なのか試算を提示するなどは、今後意識出来ると良いなと思いました。 ここまでのService Workerの実装内容およびその仕様を踏まえた上で、Service Workerスクリプトに対するテストについて次回の記事でお話しさせていただきます。


株式会社ドワンゴでは、様々なサービス、コンテンツを一緒につくるメンバーを募集しています。 ドワンゴに興味がある。または応募しようか迷っている方がいれば、気軽に応募してみてください。