Jamstackを「愚直に」実現しようと、静的HTMLを毎回一気に生成すると「問題」が出てくる。 その解決方法の一つとして、Next.jsの ISR(Incremental Static Regeneration) というテクニックが注目されている。これは Stale-While-Revalidate(以後SwR) と呼ばれる「キャッシュ戦略」に基づいている。
このSwRはISRだけに限った話ではない。昔からあるより大きい問題に対する答えである。
注目したいのは、HTTPヘッダCache-Control
の拡張にstale-while-revalidate
があることだ。このヘッダを利用することで上記したJamstackの問題を「ISR以外の方法」で解決することが出来る。ISRを運用に乗せるにはエッジサーバーにVercelを使うしかないが、SwRヘッダに対応しているCDNを利用することで、ISRと似た挙動を再現出来る。
ここからがキモだ。SwRはVarnishでも実現できるのだ。
今回はSSG(Staci Site Generator)とキャッシュ一般に潜む問題をまとめ、 それに対する戦略「SwR」を実現する手段としてのVarnishを紹介する。
JamstackはMovableTypeみたいだし、今度はVarnishが出てきた。
SSGの問題
Jamstackは予め書き出したページを静的に配信するのが特徴である。 具体的にはNext.jsなどのSSGが生成したHTMLをVercelやNetlifyなどのCDNに乗せる。
これを「愚直に」やると問題がある。以下の場合だ。
- ページ生成に時間がかかる
- ページ数が多い
- 更新頻度が高い
Next.jsでは ISR(Incremental Static Regeneration) という機能でそれを解決しようとしている。
例えば、ページ数が多いサイトだったとする。最初に全部のページを出力したり、コンテンツの変更があったら毎度全てのページを更新するのは大変である。そこでISRでは、SwRの戦略を利用するのだ。
revalidate
で指定した秒数を超えた場合にバックグラウンドで新しいページを生成する。
分かりやすい説明はこの後にする。
キャッシュにおけるThundering Herd問題
Jamstackに限らず、巨大な問題として「Thundering Herd問題」がある。キャッシュにおけるThundering Herd問題は以下の通りだ。
通常、キャッシュに格納されるデータは、それぞれ単一の生存時間をもっています。問題は、頻繁にアクセスされるキャッシュデータがエクスパイアした際に発生します。データがエクスパイヤした瞬間から、並行に走る複数のアプリケーションロジックがミスヒットを検知し、いずれかのプロセスがキャッシュデータを格納するまでの間、同一のリクエストが多数、バックエンドに飛んでしまうのです。
RailsやLaravelなどなんでもよいが、 SSR(Server Side Rendering) するWebアプリがある。ページ生成に時間がかかるのでどこからしらでキャッシュするとしよう。 TTL(Time To Live) を設定するキャッシュの仕組みだ。ロジックは以下の擬似コードで表現出来る。
cache = get(key)
if !cache { // キャッシュがなければ
cache = generate() // 時間がかかる
set(key, cache, 60) // TTLを60秒に指定する
}
return cache
TTLを過ぎてキャッシュがなくなった時に、再度データを生成することになる。これはだいたいの場合、機能する。ただし、アクセスが多いアプリだったり、ページ生成に時間が長くかかったりするとThundering Herd問題が発生する。キャッシュがない状態でアクセスが来て、大量のページ生成が走ってしまう。その結果、アプリのリソースを食いつぶして、最悪ハングしてしまうことだってある。
回避方法は以下のようにいくつかある。
Thundering Herd を避ける方法としては、memcached や MySQL を使って排他制御をするとか、 expire 時間が到達する前にランダムな時間をずらして投機的に再計算するという方法があります。
また、少しサーバー構成を複雑にしていいのであれば、Webサーバー以外でバックグラウンドに処理を実行して、そこで定期的にキャッシュを更新するという方法もあります。
Stale-While-Revalidate戦略
SSG、Thundering Herdの問題を解決するキャッシュ戦略の一つがSwRである。文章で書くとこうなる。
キャッシュが切れたら、バックグラウンドでキャッシュを問い合わせつつ(validate)、その間は古いキャッシュ(stale)を返して、コンテンツが返ってきたらキャッシュを更新する。
今回はSwRの実現として、HTTPヘッダの例とVarnishのGraceモードについて紹介する。
Cache-Control
HTTPヘッダのCache-Control
の拡張としてstale-while-revalidate
が使える。
クライアントが古いレスポンスを受け入れ、新しいレスポンスをバックグラウンドで非同期にチェックすることを示します。 seconds の値は、クライアントが古いレスポンスを受け入れる時間を示します。
これをmax-age
と組み合わせて以下のように書く。
Cache-Control: max-age=60, stale-while-revalidate=120
max-age
はTTLに相当し、キャッシュを60秒間保持することを意味する。
60秒が過ぎたらstale-while-revalidate
で指定した120秒間、古い(stale)キャッシュを返す。
そのまま120秒が経過したら、古いキャッシュ自体もなくなる。
新しいコンテンツが返ってきたらそのヘッダにしたがってキャッシュすることになる。
Varnish
VarnishのVersion4.x以降ではGraceモードという機能がある。これがSwRを実現する。
以下の方法でGraceモードに出来る。
- 設定ファイル
.vcl
で指定する - バックエンドのアプリのHTTPヘッダ
Cache-Control
でmax-age
とstale-while-revalidate
で指定する
.vcl
で記述するgrace
は、Cache-Controlヘッダのstale-while-revalidate
と同じ意味を持つ。
分かりやすいようにgrace
の値を長めの1時間に、TTLを短めの5秒にする。
こうするとISRの挙動により近づけられる。
.vcl
の記述は以下だ。
sub vcl_backend_response {
set beresp.ttl = 5s;
set beresp.grace = 3600s;
}
HTTPヘッダでやるならバックエンドのアプリが以下のヘッダを返せばよい。
Cache-Control: max-age=5, stale-while-revalidate=3600
図とデモ
さて、この挙動を頑張って図にしてみた。初めてFigma使ったのにダサくなった。でも分かりやすいと思う。
挙動を確認するためにデモを用意した。
- TTL => 5s
- Grace => 3600s
- ページ生成にかかる時間 => 10s
という条件である。
- 初回アクセス時は10秒待つ
- 1番目のページがキャッシュされている
- 5秒経つとキャッシュが消える
- 古いキャッシュを返す
- Varnishはアプリに1度だけページを取りに行く(バックエンドに2番目のアクセスがいく)
- 2番目のページが生成されたらそれを返す
- キャッシュを更新する
以下のアニメーションGIFでこの流れが確認出来ると思う。 ちょっと長いが見てもらいたい。
デモで作ったアプリは以下に貼った。
https://gist.github.com/yusukebe/8f367414afa20000b9652b02d2d06182
その他
書くの疲れたので箇条書きにする。
- fastlyはVarnishを内部で使ってる(と思われる)。少なくとも設定はVCLで行う。
- 他に、バックエンドアプリが返すHTTPヘッダの
stale-while-revalidate
を解釈するCDNにはGoogle Cloud CDN、Cloudflareがある。 - この戦略を使ったReactのユーザーエージェントライブラリに「SWR」というものがある。
- Webアプリのキャッシュはユーザーになるべく近い方でキャッシュする、という原則が一応ある
まとめ
以上、Jamstack、キャッシュにまつわる問題とStale-While-Revalidateという戦略。 さらに、Varnishで実現する方法「Graceモード」の紹介をした。
Next.js+VercelのISRや他CDNを使えばSwRの戦略を実現出来るかも知れない。 ただ、VarnishでもSwRはサポートされているし、Varnishの方が気軽に導入出来るケースが多い。
もし既存のシステムにJamstackもしくはそれ相応の構成を導入しようとしてる場合は、 Varnishの活用も考慮にいれるといかもしれない。
でも、
それってもはやJamstackなのか? Varnish出てきた時点で懐かしい!って思う人いるだろうしw
またもや懐かしい匂いがした。