stale-while-revalidate対応のCDNでISRのような挙動を実現する
先日、Next.jsのISR(Incremental Static Regeneration)について書きました。
ISRは非常に強力な機能なのですが、セルフホスティングでNext.jsを動かす場合には色々と使うのが難しかったりします。この記事ではその理由とCDNを使ってISRと似たような挙動を実現する方法を紹介します。
Next.jsのISRをVercel以外で動かすのは難しい
Vercelは自社でメンテナンスしているNext.jsを簡単にデプロイできることを大きな強みとしています。Vercelにデプロイする場合、ソースコード上で決められた書き方さえすれば、Vercel側の追加設定なしでISRを利用できます。
しかし、Vercel以外のプラットフォームにデプロイするとなると途端に話がややこしくなります。
Next.jsのISRはキャッシュしたHTMLをファイルシステムに書き込む仕様になっているようです[1]。それゆえに
- AWS Lambda / Cloud Functions / GAE → ファイルシステムへの書き込みに未対応
- AWS Fargate / GKE / Cloud Run → コンテナごとにキャッシュが分散してしまう / CDNにキャッシュをのせるのが難しい
といった問題にぶつかります。
stale-while-revalidateに対応したCDNを使うという手段
Next.jsのISRはstale-while-revalidate
というキャッシュコントロールの考え方に基づいています。これはキャッシュが古くなった後も、次のリクエストではとりあえず古いキャッシュを返し、裏で非同期にキャッシュを更新するという考え方です。
嬉しいことに一部のCDNサービスではキャッシュコントロールstale-while-revalidate
に対応しており、レスポンスヘッダをいじるだけでISRと似たような挙動を実現できます。
もっと言えば、stale-while-revalidate
に対応してるCDNを通せばNext.jsでなくてもISRと似たようなことができます。(Nuxt.jsでもginでもDjangoでもRailsでもLaravelでも)
stale-while-revalidateに対応したCDNサービス
追記: Firebase Hostingが対応していることが分かりました(参考ツイート)。Firebase Hostingを使うのがいちばん楽で安価だと思います。
例えば以下のサービスが対応しています。
- Fastly
- Google Cloud CDN
- Cloudflare
CloudFrontは2021年3月時点では対応していません。これ以外にもご存知の方はコメントなどで教えていただければと嬉しいです。
※ Cloudflareの料金表を見ると「キャッシュ最小TTL有効期限」が$200 / 月のプランでも30秒以上になってるのが気になっています。この制限がs-maxage
の最小時間に該当する場合、ISRと同等のことをやろうとしても30秒は再検証されないことになります。試した方がいたら教えていただければ… 🙏
Next.js on GAE + Cloud CDN で試してみる
そんなわけで実際に試してみました。Next.jsをGoogle App Engine(GAE)にデプロイしたうえでCloud CDNを設定します。設定手順は別の記事にまとめておきました。
今回はキャッシュの更新が分かりやすいように取得した時刻をそのまま表示するページを作ります。
getServerSideProps の中で Cache-Control ヘッダをセット
Next.jsのページコンポーネントを用意します。SSGやISRをするときはgetStaticProps
を使いますが、今回はSSR(サーバーサイドレンダリング)をするためのgetServerSideProps
を使います。そのうえでレスポンスヘッダにCache-Control
をセットします。
export async function getServerSideProps({ res }) {
const date = new Date();
const currentTime = date.toLocaleString();
// 👇 ここがポイント
res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=86400");
return {
props: {
currentTime,
},
};
}
export default function Page(props) {
return (<p>{props.currentTime}</p>);
}
Cache-Control
をpublic, s-maxage=10, stale-while-revalidate=86400
としています。
これらの値の指定により以下の挙動が期待されます。
- 初回リクエストでは最新のデータが表示
- キャッシュが作られてから約10秒間は何度リロードしても1のキャッシュが表示される(
s-maxage=10
) - 3分後にアクセスしても古いキャッシュが表示される(このとき裏でキャッシュが更新される)
- 次のアクセスで更新されたキャッシュが表示される
なおstale-while-revalidate=86400
の部分により、キャッシュが作られてから1日(86400秒)以上経つと、キャッシュが破棄されてその次のアクセスでは最新のデータが表示されることになります。
この例ではNext.jsを使っていますが、他のフレームワークなどでもCache-Control
ヘッダーを同じようにセットすればキャッシュの挙動も同じようになります。
検証結果
分かりやすさのために結果を図にしました。0:52:41
といった数字はページに表示されている文字列を表しています。
イメージ通りに動きました👏
Fastlyでもいけるっぽい
自分では試していませんが、実際にFastlyで動かしているという方がいました。
注意点
この方法を取る場合の注意点を挙げておきます。
直接ページにアクセスしたときと、他ページからの遷移時の2つのキャッシュが生成されてしまう
こちら2021/04/19に追記したものです。運用中のアプリで実際に動かしてみて分かったのですが、Next.jsでstale-while-revalidate
ヘッダによるCDNキャッシュを利用すると
- 直接ページにアクセスしたときに生成されるHTMLのキャッシュ
- 他ページから遷移したときに生成されるJSONのキャッシュ
の2種類が生成されてしまうことが分かりました。Next.jsではパフォーマンス面の理由から、他ページへの遷移時にページ全体のHTMLを書き換わるのではなく、一部分だけが書き換えられます。
Next.js on VercelでISRを使用すると、このようにキャッシュが分散することがありません。おそらくVercelにおいて、この問題が発生しないように色々と工夫がされているのだと思います。
(おそらくserverless-next.jsでISRに対応しようとしているようにHTMLやJSONをS3にアップロードしているんじゃないかな)
プリフェッチの挙動の違い
Next.jsではプリフェッチの挙動がgetStaticProps
とgetServerSideProps
を使ったときで異なります。
If the page uses getStaticProps the data is prefetched. When you use getServerSideProps it is not as it would increase server load.
https://github.com/vercel/next.js/discussions/11578#discussioncomment-2997
getStaticProps
を使ったときはページのデータまでプリフェッチが行われます。一方でgetServerSideProps
を使ったときはJSファイルのみがプリフェッチされ、データまではプリフェッチされません。
今回のCDNを使った方法ではgetServerSideProps
を使うので、ページにアクセスされるまでデータのフェッチは行われないことになります。
個人的にはこれはデメリットではなく嬉しいポイントです。ISRを使用しているページのプリフェッチが大量に行われるとサーバへの負荷が大きくなるためです。
デプロイ時にCDNのキャッシュを削除した方が良さそう
ページ間で不整合を起こさないために、デプロイ時にはCDNのキャッシュを削除した方が安心だと思います。Vercelではデプロイ時にCDNのキャッシュを削除してくれます。Cloud CDNやFastlyを使う場合にも、API経由でキャッシュ削除を自動化するのが良さそうです。
もしかするとこの他にもISRと挙動が異なる点があるかもしれません。知っている方はコメントなどで教えていただけると嬉しいです。
-
v10時点の話なので今後変わる可能性もある。該当箇所はこのへん? https://github.com/vercel/next.js/blob/canary/packages/next/next-server/server/incremental-cache.ts ↩︎
Discussion