AWS上のNext.js App RouterとCDNキャッシュ利用の課題と解決策

こんにちは。 Findy Toolsの開発をしている林です。

私たちのプロジェクトではフロントエンドのフレームワークにNext.js App Routerを使っており、AWSのECSへデプロイして運用しています。 そして、一部のレンダリングの処理が重いページのキャッシュを実装する際に、直面した課題と解決策を紹介します。

Next.jsのキャッシュ機構について

まず、Next.jsのキャッシュ機構について簡単に説明します。

Next.jsではサーバサイドで使えるキャッシュ機構が次の3種類あります。

  • Request Memoization
    • 同一リクエストの中で外部APIリクエストをメモ化する
  • Data Cache
    • 外部APIのレスポンスなどを一定時間キャッシュする
  • Full Route Cache
    • HTMLおよびRSC Payloadをユーザセッションまたは共通で一定時間キャッシュする

キャッシュの細かい仕様に関しては詳しくは公式ドキュメントを参照してください。

Building Your Application: Caching | Next.js

また、Full Route Cacheでは revalidatePath APIで任意のタイミングでキャッシュを削除できます。

Functions: revalidatePath | Next.js

今回実現したいこと

今回の要件は、一部の処理の重いページのレンダリング結果を丸ごとキャッシュしてレスポンスを高速化したいというものです。 これには、Findy Toolsはメディアサイトという側面もあり、多くのページは更新頻度がそれほど高くなく、フルページでのキャッシュがやりやすいという背景もあります。

さらに、ログインが必要なユーザ固有の情報はすべてクライアントフェッチをしているため、ユーザごとのキャッシュを考慮する必要もありませんでした。

また、エンドユーザーが利用するフロントエンドアプリケーションの他に、コンテンツ管理用のアプリケーションがあります。そして、この管理用アプリケーションからコンテンツが更新された際に、フロントエンド側にすぐに反映させたいという要件もありました。

補足:

現状のFindy Toolsのインフラ構成図は次の図のようになっています。 すべてAWS上に構築されており、CloudFrontを経由してNext.jsへリクエストを送っています。

また、コンテンツ管理用のアプリケーション(Rails)もECSへデプロイしています。

Findy Tools インフラ構成図

2ヶ月でリリースしたFindy Toolsの技術選定の裏側 - Findy Tools より

課題と解決策

今回の要件を実現するにあたり、直面した2つの課題と解決策をそれぞれ紹介します。

課題1: Next.jsの機能では要件に合わない

まず、ページのレンダリング結果を丸ごとキャッシュしたかったので、Full Route Cacheを検討しました。

しかし、セルフホスティングのNext.jsではキャッシュをメモリと.next/cache ディレクトリに保存しているため、ECSのコンテナを水平スケールして複数台でリクエストを受ける構成にすると、キャッシュの一貫性がなくなります。

負荷軽減目的のキャッシュで、一貫性が問題にならない場合はこれでも良いですが、キャッシュの削除をする revalidatePath が全てのコンテナで実行されず、一部で古いキャッシュが残るのは今回の要件を満たせません。

このため、ドキュメントにはコンテナオーケストレーションプラットフォームではRedisやS3のような外部のストレージにキャッシュを保存するようcache-handlerを実装する必要があると紹介されています。

Building Your Application: Deploying | Next.js

Data CacheやFull Route Cacheをフルに活用したい場合には、カスタムのcache-handlerを用意しても良いと思いますが、今回の要件にはオーバースペックすぎると判断しこれは見送りました。

また、Next.jsのFull Route Cacheを使った場合にもレスポンスヘッダーに Cache-Control: stale-while-revalidate が付与されるため、 削除の際にcache-handlerのデータと後術するCDNのキャッシュの2つを削除する必要もあり、複雑になるということもFull Route Cacheを見送った理由の1つです。

解決策1: CloudFrontのみでキャッシュ

私たちの構成ではNext.jsへのリクエストは静的ファイルを配信するため、CDNであるCloudFrontを経由させています。

そこで、Next.jsのアプリケーションレベルではキャッシュをせず、CDNでレンダリングされたHTMLやRSC Payloadをキャッシュすることにしました。

当初、Next.js App Routerでは特定のページにカスタムのCache-Controlヘッダーを付与するにはmiddlewareの処理で対処する必要がありましたが、v14.2.10 からPagesRouterと同様に next.config.jsheaders() で指定できるようになりました。

Add ability to customize Cache-Control by ijjk · Pull Request #69802 · vercel/next.js

next.config.js で特定のパスにCache-Controlヘッダーを設定するコードの例を以下に示します。

module.exports = {
  async headers() {
    return [
      {
        source: '/path/:slug',
        headers: [
          {
            key: 'Cache-Control',
            value: 's-maxage=86400, stale-while-revalidate',
          },
        ],
      }
    ]
  },
}

これで、CloudFrontの設定でオリジンのCache-Controlヘッダーを元にキャッシュするよう設定すると、CDNでレスポンスをキャッシュができます。

コンテンツが更新された際のキャッシュの削除は、CloudFrontのAPIで特定のパスのキャッシュを削除するようにコンテンツ管理用アプリケーションから呼び出しています。

課題2: エラーページがキャッシュされる

CloudFrontではHTTPレスポンスコードが404や一部の5xxの場合も、Cache-Controlヘッダーに基づいてキャッシュされる仕様になっています。

CloudFront がオリジンからの HTTP 4xx および 5xx ステータスコードを処理する方法 - Amazon CloudFront

特に私たちのプロジェクトでは、公開前のコンテンツのURLにアクセスされることがあり、404ページがキャッシュされてしまうという課題がありました。

公開前にキャッシュの削除をすれば良いのですが、時間を指定して公開するケースではキャッシュの削除が複雑になってしまいます。

一見するとNext.jsでバックエンドのAPIから情報を取得して、404やエラーの時はCache-Controlヘッダーをno-cacheに指定すれば解決しそうなのですが、Next.jsではレスポンスをストリーミングしており、リクエストの最初に処理されるmiddlewareやnext.config.jsのheadersの設定を条件に基づいて変更できません。

これに関してはページごとにカスタムヘッダーを設定できるような generateHeaders() というAPIが必要か、という議論がNext.jsのGitHub Discussionsで行われています。

App Router Custom Header Use Cases · vercel/next.js · Discussion #58110

解決策2: Lambda@Edgeを用いたCache-Controlヘッダー制御

Next.jsではCache-Controlヘッダーを条件に基づいて変更することが出来ないため、別のアプローチとしてLamda@EdgeでステータスコードによってCache-Controlヘッダーを書き換えることにしました。

Lamda@EdgeはオリジンとCloudFrontのキャッシュ(Regional Edge Cache)の間で動作し、リクエストやレスポンスを操作することが出来ます。

次のコードはオリジンのNext.jsのレスポンスを受け取り、ステータスコードが4xx, 5xx系の時にCache-Contorolヘッダーをno-cacheに上書きしてCloudFrontへレスポンスを返すような例です。

'use strict';

export const handler = async (event, context, callback) => {
  const response = event.Records[0].cf.response;
  const headers = response.headers;

  if(response.status >= 400 && response.status <= 599) {
    headers['cache-control'] = [{
        key:   'Cache-Control', 
        value: "private, no-cache, no-store, max-age=0, must-revalidate"
      }];
  }
  
  callback(null, response)
};

これにより、Next.jsのステータスコードによってCache-Controlヘッダー書き換え、CloudFrontにキャッシュしないということが実現可能になりました。

まとめ

この記事では、App Routerを用いた場合の「コンテンツ更新の際のキャッシュ削除」という課題に対して次の工夫をして、エラーレスポンスまで含めた柔軟なキャッシュ制御を実現しました。

  • AWS CloudFrontを用いてApp Routerのレスポンスをキャッシュ
  • Lambda@Edgeを用いてCache-Controlヘッダーを書き換え

また、レンダリングの重いページや更新頻度の低いページをCloudFrontから返すことで、レスポンスタイムが改善され、ユーザ体験の向上やサーバ負荷の軽減も達成できました。

Next.js App Routerは一部の機能においてまだ発展途上ですが、将来的に、セルフホスティングやCDNでのキャッシュが扱いやすくなることに期待ですね。

ファインディでは一緒に会社を盛り上げてくれるメンバーを募集中です。興味を持っていただいた方はこちらのページからご応募お願いします。

herp.careers