30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

動的コンテンツのキャッシュを最適化するプッシュ型アーキテクチャ

エッジサーバからのレスポンスは速い。
コンテンツを CDN のエッジサーバにキャッシュしてそれを返すようにするだけで、ウェブサイトの速度は目に見えて改善される。
特に、リクエストの度にサーバで動的に生成されるコンテンツの場合、キャッシュを利用することで大きな恩恵を受けられる。パフォーマンスが改善されるだけでなく、オリジンサーバの負荷軽減にもつながる。

しかしコンテンツを動的に生成するということは、リクエストの度に生成されるコンテンツが変わる可能性があるということであり、キャッシュを利用するのが難しい。全てのリクエストに対して同じコンテンツが生成されるのであれば、わざわざリクエストの度に生成する必要はないからだ。事前にコンテンツを用意しておいてそれを返せばよい。ビルド時にコンテンツを生成する SSG(Static Site Generation)などがその一例。
リクエストの度にコンテンツが変化する可能性がある以上、安易にキャッシュすることはできない。事前にキャッシュされたコンテンツを返してしまった場合、それは本来提供すべきだったコンテンツとは異なっている可能性がある。
キャッシュの生存期間を長くすればするほど、古いコンテンツがいつまでもクライアントに提供され続けてしまうリスクが高まる。

サーバサイドレンダリング(以下、SSR)も、リクエストの度にサーバでコンテンツを生成しそれを返す手法である。
何らかの理由でリクエストに先立ってコンテンツを用意するのが難しく、それでいてクライアントサイドレンダリング(以下、CSR)も採用できない、あるいはしたくない場合に、SSR が採用される。
SSR においても、エッジサーバにキャッシュさせることで高速化や負荷軽減のメリットを享受できる。特に SSR が返すのは HTML であり、メリットが大きい。HTML はウェブページを表示するための起点であり、クライアントは HTML の内容に基づいて、サブリソース(画像やスタイルシートなど)へのリクエストを開始する。HTML の取得が遅れれば遅れるほど後続のリクエストの開始が遅れるため、HTML を高速に返せる意義は大きい。
しかし既に述べたように、キャッシュを使うということは古いコンテンツを返してしまう恐れがあるということであり、最新のデータに基づいた HTML を返せるという SSR のメリットが損なわれてしまう。しかも先程述べたように HTML の内容に基づいてサブリソースへのリクエストが行われるため、HTML の内容が古かった場合、現在は存在していないサブリソースにリクエストを送ってしまう可能性もある。

つまり、動的コンテンツこそキャッシュによる恩恵が大きいが、動的だからこそキャッシュすることが難しい、というジレンマがある。
できるだけキャッシュの生存期間を長くして再利用性を高めたいが、鮮度の落ちたコンテンツの提供は避けたい、というジレンマはキャッシュ全般が抱えていることだが、動的コンテンツではそれがより顕著になる。

Edge Worker とそれによって操作可能なキーバリューストアを使うことで、このジレンマを解決できる可能性がある、というのがこの記事の主題。
最初に明確にしておくが、「素晴らしいアーキテクチャを思い付いた、みんなも使おう!」という話ではない。
Edge Worker を使えばこれまでとは違ったキャッシュ設計が可能になるかもしれない、という実験のようなものである。

だが Edge Worker によって設計の可能性が広がるのは事実だと思うし、できるだけ多くのコンテンツをキャッシュすることがパフォーマンス上重要であることも間違いない。

まずアーキテクチャの概要を説明し、その後、サンプルアプリを使って具体例を示す。

コンテンツの更新をオリジンサーバからエッジサーバにプッシュする

このアーキテクチャはオリジナルのアイディアではなく、mizchi さんが以下のスライドで提案されている内容が元になっている。

光を超えるためのフロントエンドアーキテクチャ - Speaker Deck

この発表内容に興味があり、理解を深めるために簡単なものでいいから自分で実装してみよう、というのがそもそもの出発点。もちろん本稿の内容は私なりの解釈であり、mizchi さんが主張したかった内容とは乖離している可能性がある。また、スライドではキャッシュのパージを行っているが、本稿ではパージするのではなくキャッシュ内容の更新を行っている。

前述の通り、常に最新のコンテンツを返せるというのが、SSR のメリットである。リクエストの度にコンテンツを生成することで、これを実現させている。
だがよく考えてみると、必ずしも毎回コンテンツを生成する必要はない。前回の生成結果とは異なるコンテンツが生成されるときにのみ、再生成を行えばよいはずである。

例えば、リクエスト時の曜日に基づいたコンテンツを返すウェブページがあったとする。リクエストを受け取ったサーバは、曜日に基づいてページのレンダリングを行い、それをクライアントに返す。月曜日にアクセスすれば「月曜日:可燃ごみ」のようなコンテンツを返し、火曜日にアクセスすれば「火曜日:資源ごみ」のようなコンテンツを返す。
コンテンツを動的に生成しているわけだが、リクエストの度に毎回生成する必要は全くない。一度コンテンツを生成したら、あとはそれをキャッシュさせ、日付が変わるまではそのキャッシュを使い続ければよい。そして日付が変わるタイミングでキャッシュの生存期間が切れるようにしておけば、日付が変わったタイミングでまたコンテンツの生成が行われる。

このように、生成されるコンテンツの内容が変わるタイミングが予め分かっていれば、動的に生成されるコンテンツであってもキャッシュするのはそれほど難しくない。
では、変化するタイミングを事前に予測できないコンテンツの場合は、どうすればよいのか。例えば、ブログサービス。記事の編集はユーザーによって任意のタイミングで行われるから、予測できない。1 時間後に内容が大きく書き換わるかもしれないし、1 年以上更新されないかもしれない。このようなコンテンツにおいて、「コンテンツの新鮮さ」と「キャッシュの効率的な利用」を両立させるにはどうしたらよいのか。

コンテンツを変化させる要素をモニタリングして、その要素が変化したときにコンテンツの再生成とキャッシュの更新を行うことで、両立できる。これが、本稿で紹介するアーキテクチャの基本的な発想である。

「コンテンツを変化させる要素」が何であるかは、コンテンツによって異なる。ブログ記事で言えば、記事の本文や著者名、コメントなどが該当するだろう。
これらが変化した場合、生成されるブログ記事も変化する。だが変化していない場合は、何度リクエストしても、同じブログ記事が生成される。つまり、最新のコンテンツを返すために毎回生成を行う必要はなく、記事の本文、著者名、コメントが変更されたときにのみ、コンテンツの生成を行えばよいことになる。
話を簡単にするために「本文」に限って話を進めると、データベースに保存されている本文が更新されたらコンテンツの生成を行うコードを書いておく。そしてさらに、新しく生成したコンテンツをエッジサーバに送信するコードも書いておく。こうすることで、動的なコンテンツでも効率的にキャッシュを利用することが可能になる。

従来のキャッシュの仕組みがプル型であるのに対して、このアーキテクチャはプッシュ型だと言える。

f:id:numb_86:20210707124846p:plain:w650

プル型の問題点は、キャッシュ更新のためのアクションは常にエッジサーバが起点になるため、オリジンサーバでコンテンツが更新されたとしても、それをエッジサーバに伝える術がないことにある。エッジサーバが問い合わせてくれないと、新しいコンテンツを渡せない。つまり、エッジサーバのキャッシュの生存期間が切れるまで、古いコンテンツが提供され続けることになる。そのため、コンテンツの新鮮さを保とうとすると、キャッシュの生存期間を極端に短くしたり、そもそもキャッシュの利用を断念したりすることになる。その結果、オリジンサーバで不必要にコンテンツの生成が繰り返されることになる。

プッシュ型では、この問題点は解決される。コンテンツが更新されたらそれをオリジンサーバがエッジサーバに渡すため、古いコンテンツが提供され続ける、という事態を防ぐことができる。エッジサーバの立場からすると、コンテンツが更新されたらオリジンサーバがそれを教えてくれるわけだから、鮮度について何も気にすることなくキャッシュを配信し続ければよい。そのためキャッシュの生存期間を長く設定することが可能になり、キャッシュの効率性が高まる。

問題は、新しく生成されたコンテンツをどうやってエッジサーバに送信するのかだが、本稿では Cloudflare Workers KV を使ってそれを実現させている。
Cloudflare でなくても、キーバリューストアを扱える Edge Worker なら同じことができると思う。

Cloudflare Workers KV を使った実装

ここからは、サンプルアプリを使って具体的な内容を見ていく。

コードも公開しておいた。

github.com

話の性質上ローカル環境で試しても意味はないため、このアプリをどこかにデプロイし、Cloudflare 経由で配信する必要がある。私の場合は Heroku にデプロイして実験していた。Heroku + Cloudflare + Deno の環境構築については、以前記事を書いた。

numb86-tech.hatenablog.com

使用している技術の概要をまず示しておくと、Deno で SSR している React アプリである。
このアプリを Cloudflare 経由で配信しており、Cloudflare Workers を動かしている。そして Cloudflare Workers KV に値を保存し、それをキャッシュとして使っている。
これらの技術についてもいくつか記事を書いた。

numb86-tech.hatenablog.com

numb86-tech.hatenablog.com

numb86-tech.hatenablog.com

また、このサンプルアプリはエッジサーバへのキャッシュについてのみ扱っており、クライアントでのキャッシュについては一切考慮していない。
あくまでも本稿で紹介しているアーキテクチャを説明するためのアプリであり、無関係な要素は極力省いている。データベースも使わず、テキストファイルを読み書きして代用している。

アプリの仕様

App ページ(パスは/)と Admin ページ(パスは/admin)の 2 つのページがある。
そして、App ページには大量のアクセスがあるため、上手くキャッシュを利用したいと考えている。Admin ページは管理者用のページであるため、パフォーマンスや負荷対策については取り敢えず考えなくよいとする。

App ページは動的なコンテンツであり、状況によって返されるコンテンツが変化する。具体的には、商品の在庫の状況によって変化する。

在庫が十分にある場合。

f:id:numb_86:20210706202747p:plain:w400

在庫が残り僅かである場合。

f:id:numb_86:20210706202800p:plain:w400

在庫が存在しない場合。

f:id:numb_86:20210706202813p:plain:w400

この 3 パターンがある。

在庫は豊富にあるので当面は「十分にあるパターン」をキャッシュしてそれを提供し続けたい。だが在庫が一定の数以下になったタイミングでキャッシュを更新し、残り僅かである旨を表示させたい。そして在庫がなくなったら、それを示すコンテンツを表示させるようにしたい。

先ほど説明したアーキテクチャを採用することで、このニーズに応えることができる。
そのためにまず、「コンテンツを変化させる要素」を明確にする必要がある。次に、その要素が変化したらコンテンツを再生成し、それをエッジサーバに送るようにする。

今回のサンプルアプリの場合、「在庫の状態」が「コンテンツを変化させる要素」に該当する。在庫数そのものではないことに注意する。在庫数が1000から999に変化したとしても、在庫数は十分にあると見做され、生成されるコンテンツは変わらない。「在庫の状態」はコードのなかでinventoryStateという名前で呼称している。そして以下のロジックでinventoryStateを決定している。

  • 在庫数が 3 以上
    • 在庫が十分にあると見做しinventoryState"ENOUGH"にする
  • 在庫数が 1 か 2
    • 在庫が残り僅かだと見做しinventoryState"LITTLE"にする
  • 在庫なし
    • inventoryState"NONE"にする

このロジックはgetInventoryStateという関数に書いてある。
動作確認しやすくするために小さな数を採用しているが、現実のプロダクトではもっと大きな数を採用するだろう。

あとは、inventoryStateの変化をモニタリングして、必要に応じてコンテンツ(App ページ)の再生成とエッジサーバへの送信を行えばよい。

先程書いたように、inventoryStateは在庫数によって決まるので、在庫数が変化する場所にコードを仕込んでおけばよい。具体的には、App ページのBuyボタンを押下したときの処理と、Admin ページで在庫数を操作したときの処理に、仕込む。
それらの処理で在庫数を変更したあとに、最新の在庫を元にしたinventoryStategetInventoryStateで取得する。そしてその値を、前回のコンテンツ生成時のinventoryStateと比較する。変化していない場合、コンテンツを再生成してもまた同じコンテンツが生まれるだけで意味がない。そのため、そのまま処理を終える。変化していた場合、ユーザーに提供すべきコンテンツが変化したことを意味するので、再生成する必要がある。そして再生成した結果を、エッジサーバに送信する。

既に軽く説明したが、「エッジサーバへの送信」は Cloudflare Workers KV を使って実現している。
具体的には、公開されている API をオリジンサーバから叩いて、Cloudflare Workers KV に書き込みを行っている。サンプルアプリでいうとsendAppPageHtmlToKv関数のなかで、その処理を行っている。
そして Cloudflare Workers でリクエストを制御し、/へのリクエストはオリジンサーバに問い合わせるのではなく、Cloudflare Workers KV から値を取り出してそれをクライアントにレスポンスするようにしている。Cloudflare Workers で動かすスクリプトは/workersディレクトリに入れてある。

一連の処理をsynchronizeKvValueという関数で行っており、これを商品購入時、そして Admin ページで在庫数を操作した時に、呼び出すようにしている。

図で示すと以下のようになる。

f:id:numb_86:20210707120208p:plain:w900

Cloudflare はデフォルトでは HTML ファイルをキャッシュしないので、キャッシュの存在を考慮する必要はなく、キーバリューストアにデータがあればそれを使い、なければオリジンサーバに問い合わせればよい。また、JavaScript ファイルや画像は自動的にキャッシュされるので、これらのファイルについては明示的な操作や指定はしていない。

Cloudflare Workers KV は書き込み時に生存期間を設定できる。このアーキテクチャではコンテンツが更新された際にオリジンサーバがプッシュしてくれるため、理屈上は生存期間を無期限にしても問題ない。とはいえ人間はミスをするし、何らかのイレギュラーが発生する可能性は十分に考えられるので、生存期間を設定しておいたほうがよいだろう。
サンプルアプリでは動作確認のために生存期間を60秒に設定しているが(TTL_OF_APP_PAGE_HTML_KV)、このアーキテクチャの効果を最大化するためにはもっと長くしたほうがいい。キャッシュの鮮度を基本的には気にしなくてよい、というのがこのアーキテクチャの肝なのだから。

Cloudflare Workers KV は結果整合性なので、そこは注意する。

KV achieves this performance by being eventually-consistent. Changes are immediately visible in the edge location at which they're made, but may take up to 60 seconds to propagate to all other edge locations.

https://developers.cloudflare.com/workers/learning/how-kv-works

つまり、KV に保存されたデータが実際に使われるようになるまで、多少の時間がかかる。自分が検証していた際は、10 秒程度の遅れが発生していた。
これを許容できないケースでは、この仕組みは使えない。というよりそういったケースでは、CDN へのキャッシュ自体が使えないだろう。キャッシュは行わずに SSR か CSR を利用することになるはず。
今回のサンプルアプリにおいては、購入処理はボタンを押下したタイミングで行われるため、ページの内容が在庫の状態を正確に反映していなくてもクリティカルな問題にはならないと判断している。

Cloudflare Workers KV と Cache API の比較

Cloudflare Workers には KV の他に Cache API も用意されており、これを使うとキャッシュを操作することができる。なぜ素直にこれを使わずに、KV をキャッシュとして使うという方法を選択したのか。

単純に、Cache API には書き込みを行うための API がないためである。Cloudflare Workers スクリプトではキャッシュへの書き込みが可能だが、外部から操作するための API は用意されていない。パージを行うための API は用意されているので、それを使うことはできる。

また、永続性の問題がある。一般論として、キャッシュは生存期間が切れるまでは必ず存在する、というわけではない。使用頻度が低いキャッシュは削除されてしまう可能性がある。
Cloudflare のエッジサーバがどのような仕組みになっているのかは分からないが、キャッシュは永続性が保証されていない可能性がある。その点 KV は、予め設定しておいた生存期間が切れるか、明示的に削除するまでは、存在が保証される。

また、KV は各エッジサーバで共有されるグローバルな値だが、キャッシュは各エッジサーバに存在するローカルな値である。これも、何か違いを生むかもしれない。

Cache API のほうが優れている点ももちろんある。
Cache API はキャッシュという目的のために用意された仕組みであり、それを利用できるのはメリットである。KV は汎用的なキーバリューストアであり、キャッシュのために用意されたものではない。そのため、どのように使うのか自分で設計を考え、そして実装しなければならない。よく言えば自由であり柔軟な設定が可能になるのだが、それはそのままデメリットでもある。

プッシュ型アーキテクチャと ISR(SWR)の比較

キャッシュを利用してレスポンスを高速化しつつコンテンツの変化にも対応した手法として、ISR(Incremental Static Regeneration)がある。
ISR は要は SWR(Stale While Revalidate)であり、クライアントに対してはキャッシュを返しつつ、バックグラウンドでオリジンサーバへの問い合わせを行って最新のコンテンツを取得してキャッシュし、次のアクセスに備えるという仕組みである。これにより、レスポンスの速さとコンテンツの新鮮さをバランス良く両立させている。

だが SWR はエッジサーバがオリジンサーバに問い合わせるというプル型の仕組みであり、コンテンツの内容が変化したかどうかとは無関係にオリジンサーバへの問い合わせが発生し、コンテンツの再生成が行われる。そのため、オリジンサーバの負荷軽減は期待できない。
コンテンツの生成を最小限に抑えることができるプッシュ型アーキテクチャとはそこが異なる。

Cloudflare Workers KV をキャッシュとして使うことの欠点

既に述べたように結果整合性であり、強整合性が求められるケースでは使えない。

そして最大の欠点が、Cache API との比較でも触れたように、キャッシュのための仕組みを自分で作らないといけないということ。
既存の仕組みから降りることで柔軟さを得られるが、既存の仕組みが提供していた便利な機能を使えなくなる。その結果、今までは意識せずに済んでいた様々な問題に、自分で対処しなければならなくなるかもしれない。

例えば Cloudflare では、Set-Cookieフィールドが含まれているレスポンスはキャッシュしない仕組みになっている。設定を変えることでキャッシュさせることもできるが、その場合はSet-Cookieフィールドを削除した上でキャッシュする。そのため、Cookie がエッジサーバ上にキャッシュされてしまうことはない。

numb86-tech.hatenablog.com

しかし KV にはそのような仕組みはなく、開発者自らが対処しなければならない。
他にも、Cache-Controlとして何を設定するべきなのかなど、考えなければならない要素は数多く存在すると思う。

また、KV をキャッシュとして使うのなら、既存のキャッシュとの競合や協調について考えないといけない。予めこういったことを考慮して設計しないと、値の二重管理のようになってしまう可能性がある。
既に紹介した以下の記事では、その点にも触れている。

Cloudflare Workers KV をキャッシュとして使う - 30歳からのプログラミング