🍕

Next.jsのISRを独自に構築する ~ Cloudflare Workers編(Cache APIの注意点) ~

2022/06/25に公開

Next.jsにはIncremental Static Regeneration(以降: ISR)というレンダリングの仕組みが存在します。ところがこれを利用するにはVercel上でNext.jsを使用しないと完璧な動作をしないことは知られています。ですが、このISRの仕組みは従来のSSRよりサーバの処理コストはもちろん、キャッシュという仕組み上レスポンスにも非常に効果のあるものです。
今回はこのISRを独自に構築するための技術を記事として起こしていきます。

本記事の続きとなるものはこちらに記載しておりますので、合わせて御覧ください。
https://zenn.dev/aiji42/articles/988ad0ab2446f3

【前提条件】

  • ISRが何という説明は本記事では行いません。
  • システム構成上、1記事ですべてを説明するにはボリュームが大きいので複数記事に分けさせて頂きます。
  • 本記事で詳細するシステム構成には一部成約が存在します。
    • 使用するNext.jsもしくはそれに準ずるものは getServerSideProps などのサーバサイドの処理で取得したデータでレンダリングをしない。
    • Cloudflare Workersなどサービスにロックインする機構が存在します。もし別サービスで置き換えれるならそちらはご自身で検討下さい。

結論

Cloudflare Workersを使用することでISRの実現は可能。しかし、Cloudflare WorkersでのNext.jsのISRに限りなく近づける場合にはCache APIは向かない。そもそもCloudflare WorkersのCache APIは同一キーでもキャッシュがヒットしない場合がある。
キャッシュヒット率を考えるなら fetch だったり、Cloudflare KV、Durable Objectsを使用することをオススメします。

システムの全体像

Cloudflare Workersの役割

この構成でCloudflare Workersは レンダリングした結果を保持する というのはISRを実現する上での役割となります。その役割を行うための方法と注意点を以下に記載します。

キャッシュの保持

まずCloudflare Workersでリクエストをキャッシュする方法は大きく分けて2つ存在します。1つ目はオリジンへの fetch 処理自体にキャッシュを行ってもうらう方法です。2つ目は fetch したリクエストをキャッシュとして保存するCache APIを使用する方法です。どちらも言葉で書くとよくわからないと思いますのでコードサンプルを交えて説明します。

fetch 自体でキャッシュする方法

Cloudflare Workersのページにサンプルがありますので、これを使って説明します。

大事な部分はこちらです。

  let response = await fetch(request, {
    cf: {
      // Always cache this fetch regardless of content type
      // for a max of 5 seconds before revalidating the resource
      cacheTtl: 5,
      cacheEverything: true,
      //Enterprise only feature, see Cache API for other plans
      cacheKey: someCustomKey,
    },
  });

まず、ここにJavaScriptやNode.jsでリクエストを行う fetch というものが存在します。しかしこの fetch ですが、通常の fetch とは違い、 cf というオプションを渡しているのがわかると思います。これはCloudflare Workers上で使用する fetch に渡せるオプションです。渡せるオプションの種類についてはこちらに詳しく書かれています。
上記の例では、「5秒」間「すべてのリクエスト[1]」を「指定したキャッシュキー」でレスポンスをキャッシュとして保存するというオプションを渡しています。なのでこの条件でキャッシュヒットする場合はオリジンへの fetch は行わずキャッシュを返します。逆にキャッシュヒットしない場合はオリジンへアクセスし、そのレスポンスをキャッシュとして生成します。こちらの処理は一見問題ないのですが、Next.jsのISRを実現するという点では若干おしいです。Next.jsのISRの動作として、キャッシュが存在する場合はそのままキャッシュを返すという点は同じなのですが、キャッシュが存在しない場合の動作が異なります。Next.jsのISRはキャッシュに有効期限を設け、「キャッシュがない」ではなく、「キャッシュの有効期限が切れている」という意味になります。で、その場合の動作は一旦クライアントには有効期限が切れているキャッシュを返しますが、裏側ではキャッシュの再生成を行います。要は Next.jsのISRは常にクライアントへはキャッシュを返すのでキャッシュが切れている場合の時のレスポンス遅延がない のです。そういう点でCloudflare Workersの fetch 自体のキャッシュはおしいですが、要件を叶えれていません。

Cache APIを使用してキャッシュする方法

Cloudflare Workersで行えるキャッシュは fetch だけではありません。ドキュメントを見るとこちらようなCache APIというその名のついたものも存在します。ここでも大事な部分だけ抜粋して説明します。

  // Construct the cache key from the cache URL
  const cacheKey = new Request(cacheUrl.toString(), request);
  // Check whether the value is already available in the cache
  // if not, you will need to fetch it from origin, and store it in the cache
  // for future access
  let response = await cache.match(cacheKey);

  if (!response) {
    ~~省略~~
    // If not in cache, get it from origin
    response = await fetch(request);
    ~~省略~~
    // Store the fetched response as cacheKey
    // Use waitUntil so you can return the response without blocking on
    // writing to cache
    event.waitUntil(cache.put(cacheKey, response.clone()));
  } else {
    console.log(`Cache hit for: ${request.url}.`);
  }
  return response;

まずこの処理ですが、 cache.match でキャッシュが存在するかを確認します。ここでキャッシュがあればそれをレスポンスとして返す処理になっています。 cache.match でキャッシュが存在しない場合は fetch を行い、そのレスポンスを cache.put で保存するということをやっています。 event.waitUntil は詳しく説明しませんが、非同期で処理実施するものと思って頂いて大丈夫です。この cache.match などで使用するCache APIですが、こちらもキャッシュの有効期限を設定することができます。それはレスポンスのヘッダーの Cache-ControlExpires がそれにあたります。なのでオリジンに fetch した結果のレスポンスのヘッダーを修正することでCache APIに保存するキャッシュの有効期限を自由に設定することが可能です。しかし、 Cache-ControlExpires は優先度があり両方設定される場合は有効期限としては Cache-Control にて制御されます。
これだけを見ると fetch によるキャッシュと動作はさほど変わらずNext.jsのISRを実現するにはこれも要件に合わなそうな感じがします。しかし、先程説明した Cache-ControlExpires の優先度からISRのキャッシュと同様の設定が可能です。例えば Expires 側にはキャッシュの有効期限となる実有効期限を設定し、 Cache-Control には非常に長い時間(仮に100年とか)設定しておくとISRのようなキャッシュを有効にしつつ、実有効期限が切れているので更新するということができそうです。

Cache APIを使用する場合の注意点

さきほどの説明からCache APIを使用することでNext.jsのISRと同等のキャッシュの仕様が実装できそうな雰囲気があります。しかし、この Cache APIに非常に大きな落とし穴が存在します

Cache APIは保存することで必ずヒットするわけではない

表題だけ見るとキャッシュ保存はしっかりできないのか?と思われがちですが、そうではありません。Cache APIはキャッシュを正常に保存はしますが、保存する場所に問題があるのです。このドキュメントに以下のように記載されています。

The Cache API is available globally but the contents of the cache do not replicate outside of the originating data center. A GET /users response can be cached in the originating data center, but will not exist in another data center unless it has been explicitly created.

すごく乱暴に意訳すると「Cache APIで保存したデータは動作したデータセンターにのみ保存されて、他のデータセンターへは同期しません」ということが書かれています。どういうことかというとCloudflare Workersはいわゆるエッジコンピューティングと言われるものなのですが、このエッジコンピューティングというのは色々な拠点が存在します。仮にこの日本でも複数拠点あり、データセンターとイコールというわけではないですが、現時点でCloudflareは日本にデータセンターを4箇所持っています。そうなるとこの Cache APIで保存したキャッシュは動作したデータセンターでは保存され、取り出すことが可能なのですが、他のデータセンターでは読み出すことができません。要はCache APIだけではキャッシュとしての役割が行えていないのです

キャッシュの保持にはKVかDurable Objectsを使用する

ここまで長々とCloudflareのキャッシュについて書きましたが、じゃあNext.jsのISRは実現できないの?というとそうではありません。キャッシュを実現するには保存先をまだまだ選べます。それがCloudflare KVだったりDurable Objectsです。どちらがどうという詳しい説明はしませんが、今回の要件ではCloudflare KVで実現可能だと考えています。

Cloudflare KVへのキャッシュの保存で以下のように行えばISRのキャッシュ仕様が実現可能です。

const restoreResponse = ({ body, headers }: { body: string, headers: HeadersInit }) => {
  return new Response(body, { headers: cacheResponseJson.headers })
}

const createCache = (cacheKey: Request, response: Response) => {
  await RESPONSE_CACHE_KV.put(
    `${cacheKey.url}`,
    JSON.stringify({
      headers: headerToHash(response), // ヘッダーを連想配列に変換して保存する
      body: await response.text(),
      cacheTtl: new Date(cacheTtl.toISOString()).getTime(),
    }),
    {
      expirationTtl: 60 * 60 * 24 * 365
    }
  )
}

const cacheResponseJson = await RESPONSE_CACHE_KV.get<{
  headers: HeadersInit,
  body: string,
  cacheTtl: string,
}>(
  `${cacheKey.url}`,
  { type: 'json' }
)

let response
let isCreateCache = false
if (!cacheResponseJson) {
  response = await fetch(request)
  isCreateCache = true
} else {
  response = restoreResponse(cacheResponseJson)
  if (new Date().getTime() > cacheResponseJson.cacheTtl) {
    isCreateCache = true
  }
}

if (isCreateCache) {
  event.waitUntil(createCache(cacheKey, response ?? await fetch(request)))
}

これはCloudflare KVを使用した例ですが、Cloudflare KVへの保存をある程度長くしておき、実際の有効期限はキャッシュデータと一緒に保存しておきます。そうすることで取り出した際に実際の有効期限が切れていた場合にオリジンへのリクエストを実施してキャッシュを再作成するということが可能になります。

最後に

Cloudflare Workersを使用する上でのCache APIにおける注意点を書かせていただきました。もちろんこの問題は Tiered Cache というものを使用するとキャッシュヒット率が上がり症状自体は緩和されますが、それでもデータセンター間の同期は行わないので完全とは言えません。Cloudflare Workersでキャッシュを行う場合は要件にあったキャッシュが行えるかどうかしっかり理解して使用することをオススメします。

この記事では触れていないのですがNext.jsが生成するSPAを完全なページHTMLとしてキャッシュしていますが、GraphQL部分とのデータ整合性も必要です。それについても別途記事を作成する予定です。

参考資料:
CloucdflareのTiered Cache(階層型キャッシュ)を有効にしてキャッシュヒット率を高めてみた

脚注
  1. Cloudflareではデフォルトでキャッシュするものの中に content-typeapplication/html のものはキャッシュしない設定になっている ↩︎

Discussion