🔄

@neshca/cache-handler を利用した Custom Next.js Cache Handler の実装

2024/12/16に公開

はじめに

こんにちは、READYFOR のテックリード兼フロントエンドエンジニアの菅原(@kotarella1110)です!
最近、Next.js App Router アプリケーションの実運用化に向けた PoC を実施しました。
この中で構築したアプリケーションはセルフホスト環境にデプロイするようにしており、Custom Next.js Cache Handler を使って Redis にキャッシュを保存するようにしました。Custom Next.js Cache Handler の実装には @neshca/cache-handler というライブラリを利用しています。実際にこれらを使用してみると、Next.js 標準のキャッシュ挙動との違いや、考慮すべき課題がいくつか明らかになりました。
そこで本記事では、これらの課題を踏まえつつ、Custom Next.js Cache Handler の概要や @neshca/cache-handler を利用した実装時のポイント、注意点など紹介できればと思います。
セルフホスト環境で Next.js キャッシュの活用を検討している方の参考になれば幸いです!

PoC の概要

背景

READYFOR はクラウドファンディングのプラットフォームを運営しており、フロントエンドアーキテクチャはモノリシックな Rails アプリケーションに React on Rails を組み込み、View 上で React コンポーネントレンダリングする構成です。数年前、フロントエンド分離戦略 を経て、一部の領域は Next.js Pages Router の Static Exports を使用した SPA へ移行されましたが、SSR が必要なページを含む未移行の領域では依然として React on Rails が使用されています。
今回、この未移行の領域を、Next.js App Router で SPA 化することを決定しました。[1]
しかし、READYFOR では SSR が必要なページを含む SPA の構築・運用経験が無かったため、技術的な不確実性が高い状況でした。例えば、React on Rails は ExecJS を用いて SSR を行うので Node.js サーバーが不要でしたが、今回新たに Node.js サーバー(Next.js サーバー)の構築・運用が必要になります。
そこで、Next.js App Router アプリケーションの実運用化に向けた PoC を SRE チームの @shmokmt さんと共に実施しました。

インフラ構成

READYFOR では、主に AWS を活用してインフラを構築しています。そのため、今回の PoC でも既存のインフラとの親和性を重視し、Next.js をセルフホストする構成を採用しました。
Next.js サーバーは Amazon ECS を使用してデプロイし、Next.js キャッシュの保存先として Amazon ElastiCache for Redis を使用しています。

インフラ構成図

パッケージのバージョン

本記事で取り上げる PoC で使用した主要なパッケージのバージョンは以下の通りです。これらのバージョンに基づいて解説を進めるため、最新のバージョンでは挙動や設定が異なる可能性がある点にご留意ください。

  • Next.js: 14.2.15
  • @neshca/cache-handler: 1.7.4

セルフホスト環境における Next.js のキャッシュ

Next.js のキャッシュ(Data Cache 及び Full Route Cache)は、デフォルトでファイルシステム(ディスク)に保存されます。
Vercel にデプロイする場合、キャッシュはファイルシステムではなく永続ストレージに自動的に保存されるため、インフラレイヤーで特別な対応は不要です。
一方、セルフホスト環境において複数のインスタンスが稼働するケースが一般的であり、ファイルシステムキャッシュはインスタンス間で共有されないため、アクセスするインスタンスによって異なるキャッシュが使用されるという問題が発生します。
このキャッシュを共有して一貫性を確保するためには、Custom Next.js Cache Handler を利用し、Redis などのキャッシュストアにキャッシュを保存するよう設定する必要があります。

Custom Next.js Cache Handler を利用した Redis へのキャッシュの保存

Next.js の Cache Handler は、get / set / revalidateTag の3つのメソッドを含むインターフェースに沿ってクラスを実装する必要があります。
これらのメソッドに、Redis へのキャッシュの取得、保存、再検証の処理を自前で実装する必要があります。

https://nextjs.org/docs/app/building-your-application/deploying#configuring-caching

これらを実装するのは面倒大変なので、公式の Custom Next.js Cache Handler の Example でも使用されている @neshca/cache-handler というライブラリが非常に便利です。

https://caching-tools.github.io/next-shared-cache/

実装例

@neshca/cache-handler を利用した実装例は以下の通りです。この例は、PoC で実際に使用している Cache Handler をベースにしています。
実装の中には、個人的に解説したいポイントがいくつか含まれていますので、それらについては次のセクションで説明します。ここで解説されなかった部分については、@neshca/cache-handler の公式ドキュメントをご参照ください。

next.config.js
module.exports = {
  generateBuildId: () => process.env.GIT_HASH ?? null,
  cacheHandler: process.env.NODE_ENV === "production" ? require.resolve("./cache-handler.js") : undefined,
  cacheMaxMemorySize: 0, // disable default in-memory caching
};
cache-handler.js
const { CacheHandler } = require("@neshca/cache-handler");
const createRedisHandler = require("@neshca/cache-handler/redis-strings").default;
const createLruHandler = require("@neshca/cache-handler/local-lru").default;
const { createClient } = require("redis");
const { PHASE_PRODUCTION_BUILD } = require("next/constants");
const Sentry = require("@sentry/nextjs");

CacheHandler.onCreation(async (context) => {
  let client;
  // 解説1
  // Opt out the cache on build. https://github.com/caching-tools/next-shared-cache/issues/284
  if (PHASE_PRODUCTION_BUILD !== process.env.NEXT_PHASE) {
    try {
      // Create a Redis client.
      client = createClient({
        url: process.env.REDIS_URL ?? "redis://localhost:6379",
      });

      client.on("reconnecting", () => {
        console.warn(`Reconnecting to Redis server...`);
      });

      // Redis won't work without error handling. https://github.com/redis/node-redis?tab=readme-ov-file#events
      client.on("error", (e) => {
        Sentry.captureException(e);
        console.error("Redis error", e);
      });
    } catch (error) {
      Sentry.captureException(error);
      console.warn("Failed to create Redis client:", error);
    }
  }

  if (client) {
    try {
      console.info("Connecting Redis client...");

      // Wait for the client to connect.
      // Caveat: This will block the server from starting until the client is connected.
      // And there is no timeout. Make your own timeout if needed.
      await client.connect();
      console.info("Redis client connected.");
    } catch (error) {
      Sentry.captureException(error);
      console.warn("Failed to connect Redis client:", error);
      console.warn("Disconnecting the Redis client...");
      // Try to disconnect the client to stop it from reconnecting.
      client
        .disconnect()
        .then(() => {
          console.info("Redis client disconnected.");
        })
        .catch(() => {
          console.warn(
            "Failed to quit the Redis client after failing to connect.",
          );
        });
    }
  }

  /** @type {import("@neshca/cache-handler").Handler | null} */
  let handler = null;
  if (client?.isReady) {
    // Create the Redis Handler if the client is available and connected.
    handler = await createRedisHandler({
      client,
      keyPrefix: `${context.buildId ?? "prefix"}:`, // 解説2
      timeoutMs: 1000,
    });
  } else {
    // Fallback to LRU handler if Redis client is not available.
    // The application will still work, but the cache will be in memory only and not shared.
    handler = createLruHandler();
    console.warn(
      "Falling back to LRU handler because Redis client is not available.",
    );
  }

  return {
    handlers: [handler],
    ttl: {
      estimateExpireAge: (staleAge) => staleAge * 3, // 解説3
    },
  };
});

module.exports = CacheHandler;

解説1: ビルド時キャッシュを無効にする

Redis クライアントは生成時に Redis サーバーとのコネクションを確立します。セルフホスト環境での Next.js アプリケーションのデプロイにおいて、ビルド時に Redis サーバーにアクセスできないケースが一般的だと思います。例えば、PoC では以下のようなデプロイプロセスになっています。

  1. GitHub Actions で Next.js アプリをビルド
  2. ビルド成果物で起動するサーバー用の Docker イメージを Amazon ECR にプッシュ
  3. Amazon ECS にデプロイ

この場合、当然ですが GitHub Actions から Redis サーバーへ直接アクセスできません。[2]
このような環境でビルド時キャッシュが有効なままだと、ビルドフェーズで Redis サーバーに接続できずビルドが失敗するため、ビルド時キャッシュを無効にする必要があります。

そのため、実装例ではビルドフェーズで Redis クライアントを生成しないようにしています。これにより、local-lru ハンドラーがフォールバックとして利用され、ビルドフェーズでは Redis ではなくメモリにキャッシュが保存されるようになります。

cache-handler.js
  if (PHASE_PRODUCTION_BUILD !== process.env.NEXT_PHASE) {
      try {
        client = createClient({
          url: process.env.REDIS_URL ?? "redis://localhost:6379",
        });
        // ...
      }
    } catch (error) {
      // ...
    }
  }
  // ...
  let handler = null;
  if (client?.isReady) {
    // ...
  } else {
    // Fallback to LRU handler if Redis client is not available.
    // The application will still work, but the cache will be in memory only and not shared.
    handler = createLruHandler();
    console.warn(
      "Falling back to LRU handler because Redis client is not available.",
    );
  }

このように、ビルド時キャッシュを無効にした場合、ビルド時にキャッシュされる予定だった Data Cache 及び Full Route Cache は初回リクエスト時に生成されるようになります。

一方で、Redis サーバーにアクセス可能なデプロイ環境では、上の条件式を削除することでビルド時キャッシュが有効になります。ただし、注意点として、ビルド時にキャッシュされるのは Data Cache のみで、Full Route Cache はキャッシュされず Redis に保存されません。

したがって、ビルド時キャッシュの有無にかかわらず、Next.js 標準のビルド時キャッシュの挙動とは異なる動作になることを理解しておく必要があります。

もし、ビルド時に生成された Data Cache 及び Full Route Cache を Redis に保存したい場合は @neshca/cache-handler が提供する registerInitialCache API を使用する必要があります。これについては、後述の「ビルド時の Static Rendering が無駄になる」に詳細を記載しています。

解説2: ビルド毎に異なるキャッシュを利用する

解説1で説明したように、registerInitialCache API を使用しない限り、ビルド時に生成された Full Route Cache を Redis に保存できないため、デプロイ毎に Full Route Cache は更新されません。つまり Full Route Cache のデプロイ時にキャッシュがクリアされるという特性が失われます
これにより、アプリケーションのページを修正してデプロイした場合でも、古いビルドの Full Route Cache が再利用されると、デプロイ前のページがユーザーに配信されてしまいます。
これを回避するためには、ビルドごとに独立したキャッシュを使用する設定が必要です。そのための方法として、ハンドラーのオプションの keyPrefix を活用できます。keyPrefix は Redis に保存されるキャッシュキーにプレフィックスを付与するためのオプションです。このプレフィックスにビルド固有の値を設定することで、ビルドごとにキャッシュを分離できます。

実装例では、context から Next.js のビルドに対応する buildId を取得することができるため、この buildIdkeyPrefix に設定しています。この設定により、各ビルドで異なるプレフィックスが付与されるため、キャッシュの共有を防ぐことができます。

cache-handler.js
CacheHandler.onCreation(async (context) => {
  // ...
  handler = await createRedisHandler({
    client,
    keyPrefix: `${context.buildId ?? "prefix"}:`,
    timeoutMs: 1000,
  });
  // ...
});

ただし、Full Route Cache だけでなく Data Cache もビルド間でキャッシュが共有されなくなるため、Data Cache のデプロイ間でキャッシュが維持されるという特性が失われてしまうことに注意しましょう。

registerInitialCache API を使用する場合は、デプロイ後もキャッシュが適切に更新されようになります。この場合、keyPrefix を指定しない、または固定文字列を指定してキャッシュを共有する設定にしても問題ないでしょう。

解説3: TTL パラメーターを指定する

@neshca/cache-handlerTTL パラメーターがデフォルト設定では、キャッシュの有効期間が経過した後にキャッシュが削除されてしまいます。[3]そのため、Next.js 標準の挙動である「キャッシュの有効期間が過ぎた後の最初のリクエストでは、STALE なデータを返してバックグラウンドでデータの再検証」はされず、キャッシュの有効期間が過ぎた後の最初のリクエストでは常に新しいデータを取得する分遅延が発生します。

Next.js 標準の Time-based Revalidation では、以下の図のように、キャッシュの有効期間が経過した後の最初のリクエストに対してキャッシュされた STALE なデータが返され、バックグラウンドでデータの再検証(Revalidation)が行われた後にキャッシュを更新(SET)します。

Time-based Revalidation がどのように機能するかを示す図

引用元: https://nextjs.org/docs/app/building-your-application/caching#time-based-revalidation

一方、@neshca/cache-handler では、TTL パラメーターがデフォルトのままだと、有効期間が経過するとキャッシュ自体が削除されます。これは、キャッシュの有効期間が Redis キャッシュの TTL として設定され、キャッシュの期限切れとして削除されるためです。そのため、キャッシュの有効期間が過ぎた後の最初のリクエストではキャッシュが存在しない(MISS)ため、最新のデータが取得してキャッシュを更新(SET)します。

これを Next.js 標準の Time-based Revalidation と同じような挙動にするためには、TTL パラメーターのestimateExpireAge オプションを指定して、キャッシュの期限切れ期間をキャッシュの有効期間よりも長く設定する必要があります。

実装例では、estimateExpireAge オプションを指定してキャッシュの期限切れ期間を設定しています。この期限切れ期間はキャッシュの有効期間の3倍になるように設定しています。[4]

cache-handler.js
CacheHandler.onCreation(async () => {
  // ...
  handlers: [handler],
    ttl: {
      estimateExpireAge: (staleAge) => staleAge * 3,
    },
});

例えば、以下のようにキャッシュの有効期間 5秒に設定されている場合、キャッシュの期限切れ期間は15秒(5秒×3)となります。

fetch("https://...", { next: { revalidate: 5 } });

そのため、以下のような挙動となります。

状態 時間範囲
HIT 0秒〜5秒 キャッシュデータが返されます。
STALE 5秒〜15秒 この間にリクエストがあると、キャッシュデータを返されつつ、バックグラウンドで再検証が行われます。
MISS 15秒〜 STALE の状態でリクエストがない場合は、キャッシュが期限切れとなり削除されます。この後のリクエストでは、新しいデータを取得してキャッシュが更新されます。

補足として、@neshca/cache-handler のバージョン 1.9.0 からは estimateExpireAge のデフォルトが (staleAge) => staleAge から (staleAge) => staleAge * 1.5 になりました。これにより、TTL パラメーターがデフォルトのままでも、Next.js 標準の Time-based Revalidation の挙動と同じようになりました。

https://github.com/caching-tools/next-shared-cache/pull/879

ただし、デフォルトでは、キャッシュの期限切れ期間がキャッシュの有効期間の1.5倍なので、STALE なデータが返される期間が比較的短めです。
そのため、estimateExpireAge の適切な値を検討して設定することが重要です。

注意点

ビルド時の Static Rendering が無駄になる

「解説1: ビルド時キャッシュを無効にする」で説明した通り、Redis サーバーにアクセス可能な環境でビルド時キャッシュを有効にしても、キャッシュされるのは Data Cache のみで、Full Route Cache はキャッシュされません。この動作は、@neshca/cache-handler ではなく Next.js の仕様、もしくは制限によるものみたいです。
Next.js はビルド時に静的なページを Static Rendering し、その結果を Full Route Cache として保存しますが、この処理では Cache Handler を経由しないため、Redis に保存されません。具体的には、キャッシュは Cache Handler の set メソッドを通して保存されますが、ビルド時に Full Route Cache を保存する際にはこの set メソッドが呼び出されず、ファイルシステム上に保存されます。実際に確認すると、Data Cache は .next/cache ディレクトリに保存されず Redis に保存されますが、Full Route Cache は .next/server/app/**/*.{html,rsc,meta} に保存されていることがわかります。
その結果、デプロイ直後のリクエストでは、ビルド時に Static Rendering で生成されたキャッシュを利用できず、レンダリングのコストが発生します。つまり、ビルド時の Static Rendering が無駄になります。

この問題に対処するため、@neshca/cache-handlerregisterInitialCache API を提供しています。 優秀ですね。この API を Instrumentation 経由で呼び出すことで、Next.js サーバーの起動時に、ビルド時ので生成されたファイルシステム上のキャッシュを Redis に保存できます。
詳しい手順については公式ドキュメントをご参照ください。

https://caching-tools.github.io/next-shared-cache/usage/populating-cache-on-start

更に、ビルドプロセス中にファイルシステム上に生成された Data Cache についてもサーバー起動時に Redis に保存できます。つまり、この API を活用することでビルド時に Redis にアクセスできるデプロイ環境を用意する必要もなくなります。

Redis モジュールが使用できない環境では redis-stack ハンドラーは使用できない

Next.js のキャッシュ周りの検証中に redis-stack ハンドラーを使用していましたが、AWS 環境にデプロイした際、以下の箇所でエラーが発生しました。

https://github.com/caching-tools/next-shared-cache/blob/88010c21bb7f4a3680cc571cf500cb01747408f9/packages/cache-handler/src/handlers/redis-stack.ts#L58-L67

Error: ERR unknown command 'FT.CREATE', with args beginning with: 'idx:tags-xxxxx' 'ON' 'JSON' 'TEMPORARY' '31536000' 'SCHEMA' '$.tags'

このエラーは、FT.CREATE コマンドが RedisSearch モジュールを必要とするために発生します。redis-stack ハンドラーは RedisJSONRedisSearch モジュールを使用しますが、これらのモジュールは Redis Ltd. によって開発され、ライセンスの制約によりクラウドプロバイダーが提供する多くのマネージド Redis サービスではサポートされていません。 Amazon ElastiCache for Redis もこれに該当します。

そのため、Redis モジュールが使用できない環境では redis-strings ハンドラーを使用する必要があります。 このハンドラーは Redis の基本機能のみを利用し、モジュールへの依存がないため幅広い環境で動作します。

@neshca/cache-handler は Next.js v15 をサポートしていない

自分の方で確認した際、@neshca/cache-handler は Next.js v15 をサポートしておらず、On-demand Revalidation API が正常に動作しない状況でした。
そのため、Next.js v15 を使用する場合、以下の PR で対応が進められている正式なサポートのリリースを待つ必要があります。

https://github.com/caching-tools/next-shared-cache/pull/846

今後検証したいこと

PoC では対応していないものの、実運用前に確認すべきポイントを以下に記載します。

Graceful Shutdown の対応

サーバーが終了シグナル(SIGTERMSIGINT)を受け取った際に、安全にサーバーを終了する Graceful Shutdown の対応が必要であることを SRE チームの @shmokmt さんから教えていただきました。

以下の PR を見ると、Next.js サーバーは処理中のリクエストを完了させた後に停止する仕組みが実装されていそうです。

https://github.com/vercel/next.js/pull/60059

しかし、今回の構成では Cache Handler の利用に伴い、Next.js サーバー起動後に Redis とのコネクションが常時維持されます。このため、終了シグナルを受け取った際に Redis のコネクションを適切に切断する処理が必要になります。そのため、自前で Graceful Shutdown を実装する必要がありそうです。

Pages Router には公式で Graceful Shutdown 実装例のドキュメントがありますが、App Router では未提供です。

https://nextjs.org/docs/pages/building-your-application/deploying#manual-graceful-shutdowns

軽く調べたところ、App Router の場合は Instrumentation を使用して実装可能そうでした。

https://github.com/vercel/next.js/issues/51404

したがって、以下のような実装が必要になると考えています(動かして確認すらしていない雑な例)。ただし、この自前実装の場合、処理中のリクエストを完了させてから Next.js サーバーを停止する動作も含める必要があるかどうかは、まだ Next.js のコードを詳しく追っていないため不明です。

instrumentation.ts
import redis from "@/lib/redis";

export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    if (process.env.NEXT_MANUAL_SIG_HANDLE) {
      const handleShutdown = () => {
        if (redis?.status !== "end") {
          redis.disconnect();
        }
        process.exit(0);
      };
      process.on('SIGTERM', handleShutdown);
      process.on('SIGINT', handleShutdown);
    }
  }
}

Graceful Shutdown が期待通りに動作しているかを確認する手順については @shmokmt さんから提案をいただいたので備忘録として残しておきます。

  1. ローカル環境で Next.js サーバーのコンテナを立ち上げる。
  2. hey コマンドでコンテナが落ちない程度に Next.js サーバーに継続的に数分程度リクエストを送り続ける。
  3. 別のターミナルを開いて、ローカルホストから kill -TERM {Next.js サーバーのコンテナの PID} を送信する。
  4. Next.js サーバーでエラーが発生していないことを確認する(Sentry 等にエラーが来てないことを確認)

時間を見つけて、この検証を進めていきたいと考えています。

おわりに

セルフホスト環境での Next.js のキャッシュの一貫性を確保したい場合は Custom Next.js Cache Handler の実装が必須です。特に、@neshca/cache-handler を利用することで Redis へのキャッシュの保存を簡単に実現できます。しかし、これらを活用するにあたっては考慮すべき課題がいくつか存在します。
これらの課題を正しく理解するためには、ドキュメントを読むだけでなく、実際に手を動かして挙動を確認したり、ライブラリのコードを追って仕組みを掘り下げることが大切です。Next.js の標準キャッシュとは異なる動作を把握しておくことで、開発や運用時のトラブルを未然に防ぐことができます。

今回は Custom Next.js Cache Handler に焦点を当てて解説しましたが、PoC で行った Next.js App Router アプリケーションにおける Datadog を活用したロギングやトレーシングについても、多くの課題や工夫がありました。これらについても、いつか改めてお伝えしたいと考えています。

それでは、良い Next.js ライフを!👋


明日の READYFOR Advent Calendar 2024 の 17日目は @terraphic さんによる記事です。お楽しみに!

脚注
  1. 本記事では Custom Next.js Cache Handler に焦点を当てているため、SPA 化の背景や理由については割愛します。 ↩︎

  2. AWS CodeBuild を使えば、Redis を配置した VPC 内でビルドしてアクセス可能な環境を構築できそうですが、手間がかかりますし、デプロイ環境は GitHub Actions に統一したいため AWS CodeBuild は使用しませんでした。 ↩︎

  3. バージョン 1.8.1 までの挙動です。 ↩︎

  4. キャッシュの有効期間である next.revalidate で指定した値が exstimateExpireAge の引数(staleAge)に渡されます。estimateExpireAge の返り値がキャッシュの期限切れ期間になります。 ↩︎

GitHubで編集を提案
READYFORテックブログ

Discussion