stale-while-revalidate対応のCDNでISRのような挙動を実現する

5 min read読了の目安(約5200字

先日、Next.jsのISR(Incremental Static Regeneration)について書きました。

https://zenn.dev/catnose99/articles/8bed46fb271e44

ISRは非常に強力な機能なのですが、Next.jsをVercel以外で動かす場合にはまともに使うことができません。この記事ではその理由とCDNを使ってISRと似たような挙動を実現する方法を紹介します。

2021/04/19追記
この記事の方法で実際にしばらく運用してみて分かったのですが、ページのキャッシュが2種類生成される問題にぶつかりました。詳しくは記事後半の注意点をご確認ください。

Next.jsのISRをVercel以外で動かすのは難しい

Vercelは自社でメンテナンスしているNext.jsを簡単にデプロイできることを大きな強みとしています。Vercelにデプロイする場合、ソースコード上で決められた書き方さえすれば、Vercel側の追加設定なしでISRを利用できます。

しかし、Vercel以外のプラットフォームにデプロイするとなると途端に話がややこしくなります。

https://zenn.dev/catnose99/scraps/f1c9a98c5651f1

Next.jsのISRはキャッシュしたHTMLをファイルシステムに書き込む仕様になっているようです[1]。それゆえに

  • AWS Lambda / Cloud Functions / GAE → ファイルシステムへの書き込みに未対応
  • AWS Fargate / GKE / Cloud Run → コンテナごとにキャッシュが分散してしまう

といった問題にぶつかります。

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をセットします。

pages/index.js
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-Controlpublic, s-maxage=10, stale-while-revalidate=86400としています。

これらの値の指定により以下の挙動が期待されます。

  1. 初回リクエストでは最新のデータが表示
  2. キャッシュが作られてから約10秒間は何度リロードしても1のキャッシュが表示される(s-maxage=10
  3. 3分後にアクセスしても古いキャッシュが表示される(このとき裏でキャッシュが更新される)
  4. 次のアクセスで更新されたキャッシュが表示される

なおstale-while-revalidate=86400の部分により、キャッシュが作られてから1日(86400秒)以上経つと、キャッシュが破棄されてその次のアクセスでは最新のデータが表示されることになります。

この例ではNext.jsを使っていますが、他のフレームワークなどでもCache-Controlヘッダーを同じようにセットすればキャッシュの挙動も同じようになります。

検証結果

分かりやすさのために結果を図にしました。0:52:41といった数字はページに表示されている文字列を表しています。

イメージ通りに動きました👏

Fastlyでもいけるっぽい

自分では試していませんが、実際にFastlyで動かしているという方がいました。

https://twitter.com/itometeam/status/1371846394357673984?conversation=none

注意点

この方法を取る場合の注意点を挙げておきます。

直接ページにアクセスしたときと、他ページからの遷移時の2つのキャッシュが生成されてしまう

こちら2021/04/19に追記したものです。運用中のアプリで実際に動かしてみて分かったのですが、Next.jsでstale-while-revalidateヘッダによるCDNキャッシュを利用すると

  1. 直接ページにアクセスしたときに生成されるHTMLのキャッシュ
  2. 他ページから遷移したときに生成されるJSONのキャッシュ

の2種類が生成されてしまうことが分かりました。Next.jsではパフォーマンス面の理由から、他ページへの遷移時にページ全体のHTMLを書き換わるのではなく、一部分だけが書き換えられます。

Next.js on VercelでISRを使用すると、このようにキャッシュが分散することがありません。おそらくVercelにおいて、この問題が発生しないように色々と工夫がされているのだと思います。
(おそらくserverless-next.jsでISRに対応しようとしているようにHTMLやJSONをS3にアップロードしているんじゃないかな)

プリフェッチの挙動の違い

Next.jsではプリフェッチの挙動がgetStaticPropsgetServerSidePropsを使ったときで異なります。

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と挙動が異なる点があるかもしれません。知っている方はコメントなどで教えていただけると嬉しいです。

脚注
  1. v10時点の話なので今後変わる可能性もある。該当箇所はこのへん? https://github.com/vercel/next.js/blob/canary/packages/next/next-server/server/incremental-cache.ts ↩︎

この記事に贈られたバッジ