Next.jsのISRをEFSを用いてECSデプロイ環境で動作するようにしてみた
はじめに
みなさんこんにちは、クラウド事業本部コンサルティング部の浅野です。
Next.jsをコンテナ等のマルチノード環境で動かす場合は様々な考慮が必要ですが、その中でもISR(Incremental Static ReGeneration)を使用したい場合はキャッシュが保存されるファイルシステムをノード間で共通化してあげる必要があります。
今回はECSとEFSを使ってマルチインスタンス環境でも、Next.jsのISR(時間ベースの再検証)が正常に動作するように設定してみました。
公式ではRedisやS3等で共有することが可能と記載がありますが、EFSで共有することによって以下の利点があります。
- ファイルシステムとしてマウントできるため、Next.jsのデフォルトのファイルベースキャッシュ実装をほぼそのまま流用でき、RedisやS3のようなクライアントライブラリの追加や接続管理が不要
- NFSプロトコルによる低レイテンシのアクセスが可能で、S3のようなAPI呼び出しオーバーヘッドが少ない
- ECSタスク定義でマウントするだけで複数コンテナから透過的に共有でき、アプリケーションコードの変更を最小限に抑えられる
今回はISRやSSRなどのレンダリング動作についての解説は割愛します。詳しくは下記公式ドキュメントを参考にして下さい。
またISRは時間ベースとオンデマンドベースの再検証がありますが、今回は時間ベースの動作についてのみ検証します。
マルチノード環境で動かす際の注意点
Next.jsのキャッシュは複数種類ありますが、その内サーバー側で保存されるものに関してはデフォルトで各コンテナのファイルシステム(.next/)に保存されます。
ALBを前段に置いてスケールアウトする構成では、以下の問題が発生します。
- 各コンテナがステートレスで独立したキャッシュを持つため、インスタンス間でキャッシュ状態が異なる
- リクエストの振り分け先によって異なる状態のページが返される
- あるインスタンスでISRによる再生成が完了しても、他のインスタンスには反映されないのでページを更新したときに思い通りの挙動にならない

この問題を解決するために、Next.jsをコンテナ等のマルチ環境で動作させる場合にはカスタムキャッシュハンドラーを設定し、独自のキャッシュファイルシステムを構成して各コンテナ間でキャッシュの状態を共通化する必要があります。
キャッシュの共通化をしない場合のイメージを理解しやすくするために、ECS on Fargate にNext.js の最小構成をデプロイした場合にISRで動かしたページの挙動をお見せします。
動作環境
- ECSで「必要タスク数を2」に設定しマルチAZ環境にNext.jsをデプロイ
- 「/」ホームページにISRを設定しページ全体を10秒毎に再検証するように設定
理想: F5等で更新した際に「日付」部分が10秒毎に更新されるのが理想
現実: マルチ環境で各コンテナにリクエストが振り分けられるので更新するたびに日付が揺れる。

本記事のゴールはこの状態が解消され、アプリ側で設定した意図通りの動作になることです。
構成
インフラ構成は一般的なマルチAZ構成としてEFSファイルシステムでタスク間のキャッシュを共有します。

EFSを用いて各タスク間のキャッシュを参照する際のNext.js側の設定ファイル、タスク定義、EFSファイルシステムとの関係を図式化しました。

次セクションから実際この図になぞって設定していきます。
やってみた
プロジェクトをインフラ部分とアプリ部分に分けたことによってデプロイが複数回に分かれ複雑になりましたが、以下の順番でデプロイしています。ファイルの詳細は各セクションで記載・説明しています。
デプロイ順序
- ①
lib/demo-ecs-platform-stack.tsをデプロイ - ② アプリケーションプロジェクトを立ち上げてビルドしてECRにプッシュ
- ③
lib/demo-ecs-service-stack.tsをデプロイ
インフラ環境CDKスタック
今回はインフラ環境を用意するのに2つのスタック(プラットフォーム、サービス)に分けてデプロイしました。
`bin/demo-nextjs-cdk.ts`
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { DemoEcsPlatformStack } from '../lib/demo-ecs-platform-stack';
import { DemoEcsServiceStack } from '../lib/demo-ecs-service-stack';
const app = new cdk.App();
// プラットフォームスタック
const platformStack = new DemoEcsPlatformStack(app, 'DemoEcsPlatformStack', {
/* If you don't specify 'env', this stack will be environment-agnostic.
* Account/Region-dependent features and context lookups will not work,
* but a single synthesized template can be deployed anywhere. */
/* Uncomment the next line to specialize this stack for the AWS Account
* and Region that are implied by the current CLI configuration. */
// env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
/* Uncomment the next line if you know exactly what Account and Region you
* want to deploy the stack to. */
// env: { account: '123456789012', region: 'us-east-1' },
/* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
});
// サービススタック
const serviceStack = new DemoEcsServiceStack(app, 'DemoEcsServiceStack', {
vpc: platformStack.vpc,
cluster: platformStack.cluster,
alb: platformStack.alb,
nextjsRepo: platformStack.nextjsRepo.repositoryName,
nextjsSecurityGroup: platformStack.nextjsSecurityGroup,
nextjsTargetGroup: platformStack.nextjsTargetGroup,
nextjsLogGroup: platformStack.nextjsLogGroup,
fileSystem: platformStack.fileSystem,
accessPoint: platformStack.accessPoint
});
// スタック依存関係を明示
serviceStack.addDependency(platformStack);
プラットフォームスタック(VPC,EFS,ネットワーク関係)
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as efs from 'aws-cdk-lib/aws-efs';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as logs from 'aws-cdk-lib/aws-logs';
export class DemoEcsPlatformStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// ECRリポジトリ - Next.js用
const nextjsRepo = new ecr.Repository(this, 'NextjsRepository', {
repositoryName: 'demo-ecs-nextjs-repository',
removalPolicy: cdk.RemovalPolicy.DESTROY
});
// VPC(NAT Gateway構成)
const vpc = new ec2.Vpc(this, 'DemoVpc', {
maxAzs: 2,
natGateways: 1,
subnetConfiguration: [
{
cidrMask: 24,
name: 'Public',
subnetType: ec2.SubnetType.PUBLIC,
},
{
cidrMask: 24,
name: 'Private',
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
}
]
});
// セキュリティグループ - ALB用
const albSecurityGroup = new ec2.SecurityGroup(this, 'AlbSecurityGroup', {
vpc: vpc,
description: 'Security Group for Application Load Balancer',
allowAllOutbound: true
});
// ALB用セキュリティグループルール
albSecurityGroup.addIngressRule(
ec2.Peer.anyIpv4(),
ec2.Port.tcp(80),
'HTTP traffic from anywhere'
);
// セキュリティグループ - Next.js用
const nextjsSecurityGroup = new ec2.SecurityGroup(this, 'NextjsSecurityGroup', {
vpc: vpc,
description: 'Security Group for Next.js ECS Tasks',
allowAllOutbound: true
});
// Next.js用セキュリティグループルール(ALBからのトラフィック)
nextjsSecurityGroup.addIngressRule(
albSecurityGroup,
ec2.Port.tcp(3000),
'HTTP traffic from ALB'
);
// セキュリティグループ - EFS用
const efsSecurityGroup = new ec2.SecurityGroup(this, 'EfsSecurityGroup', {
vpc: vpc,
description: 'Security Group for EFS',
allowAllOutbound: true
});
// EFS用セキュリティグループルール(ECSタスクからのNFS通信許可)
efsSecurityGroup.addIngressRule(
nextjsSecurityGroup,
ec2.Port.tcp(2049),
'NFS traffic from ECS Tasks'
);
// EFSファイルシステム
const fileSystem = new efs.FileSystem(this, 'NextjsCacheFileSystem', {
vpc: vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
securityGroup: efsSecurityGroup,
performanceMode: efs.PerformanceMode.GENERAL_PURPOSE,
throughputMode: efs.ThroughputMode.BURSTING,
encrypted: true,
removalPolicy: cdk.RemovalPolicy.DESTROY
});
// EFSアクセスポイント
const accessPoint = new efs.AccessPoint(this, 'NextjsCacheAccessPoint', {
fileSystem: fileSystem,
path: '/nextjs-cache',
posixUser: {
uid: '1000',
gid: '1000'
},
createAcl: {
ownerUid: '1000',
ownerGid: '1000',
permissions: '755'
}
});
// CloudWatch Logs グループ - Next.js用
const nextjsLogGroup = new logs.LogGroup(this, 'NextjsLogGroup', {
logGroupName: '/ecs/demo-nextjs',
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY
});
// ECSクラスター
const cluster = new ecs.Cluster(this, 'DemoCluster', {
clusterName: 'demo-ecs-platform-cluster',
vpc: vpc
});
// ALB
const alb = new elbv2.ApplicationLoadBalancer(this, 'DemoAlb', {
vpc: vpc,
internetFacing: true,
vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
securityGroup: albSecurityGroup
});
// ALBターゲットグループ - Next.js
const nextjsTargetGroup = new elbv2.ApplicationTargetGroup(this, 'NextjsTargetGroup', {
vpc: vpc,
port: 3000,
protocol: elbv2.ApplicationProtocol.HTTP,
targetType: elbv2.TargetType.IP,
healthCheck: {
path: '/',
healthyHttpCodes: '200'
}
});
// ALBリスナー
alb.addListener('NextjsListener', {
port: 80,
defaultTargetGroups: [nextjsTargetGroup]
});
// エクスポート用のプロパティ
this.vpc = vpc;
this.cluster = cluster;
this.alb = alb;
this.nextjsRepo = nextjsRepo;
this.nextjsSecurityGroup = nextjsSecurityGroup;
this.nextjsTargetGroup = nextjsTargetGroup;
this.nextjsLogGroup = nextjsLogGroup;
this.fileSystem = fileSystem;
this.accessPoint = accessPoint;
}
public readonly vpc: ec2.Vpc;
public readonly cluster: ecs.Cluster;
public readonly alb: elbv2.ApplicationLoadBalancer;
public readonly nextjsRepo: ecr.Repository;
public readonly nextjsSecurityGroup: ec2.SecurityGroup;
public readonly nextjsTargetGroup: elbv2.ApplicationTargetGroup;
public readonly nextjsLogGroup: logs.LogGroup;
public readonly fileSystem: efs.FileSystem;
public readonly accessPoint: efs.AccessPoint;
}
サービススタック(タスク定義、サービス作成等)
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as efs from 'aws-cdk-lib/aws-efs';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
export interface DemoEcsServiceStackProps extends cdk.StackProps {
vpc: ec2.IVpc;
cluster: ecs.ICluster;
alb: elbv2.IApplicationLoadBalancer;
nextjsRepo: string;
nextjsSecurityGroup: ec2.ISecurityGroup;
nextjsTargetGroup: elbv2.IApplicationTargetGroup;
nextjsLogGroup: logs.ILogGroup;
fileSystem: efs.IFileSystem;
accessPoint: efs.IAccessPoint;
}
export class DemoEcsServiceStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: DemoEcsServiceStackProps) {
super(scope, id, props);
// タスク実行ロール
const taskExecutionRole = new iam.Role(this, 'TaskExecutionRole', {
assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
description: 'ECS Task Execution Role',
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonECSTaskExecutionRolePolicy')
]
});
// タスクロール(executeCommand用 + EFSアクセス用)
const taskRole = new iam.Role(this, 'TaskRole', {
assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
description: 'ECS Task Role for execute command and EFS access',
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')
]
});
// EFSアクセス用IAMポリシー
taskRole.addToPolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
'elasticfilesystem:ClientMount',
'elasticfilesystem:ClientWrite',
'elasticfilesystem:ClientRootAccess'
],
resources: [props.fileSystem.fileSystemArn],
conditions: {
StringEquals: {
'elasticfilesystem:AccessPointArn': props.accessPoint.accessPointArn
}
}
}));
// タスク定義 - Next.js 15向け
const nextjsTaskDef = new ecs.FargateTaskDefinition(this, 'NextjsTaskDef', {
memoryLimitMiB: 1024,
cpu: 512,
executionRole: taskExecutionRole,
taskRole: taskRole
});
// EFSボリュームをタスク定義に追加
nextjsTaskDef.addVolume({
name: 'nextjs-cache',
efsVolumeConfiguration: {
fileSystemId: props.fileSystem.fileSystemId,
transitEncryption: 'ENABLED',
authorizationConfig: {
accessPointId: props.accessPoint.accessPointId,
iam: 'ENABLED'
}
}
});
const container = nextjsTaskDef.addContainer('nextjs-container', {
image: ecs.ContainerImage.fromRegistry(`${cdk.Aws.ACCOUNT_ID}.dkr.ecr.${cdk.Aws.REGION}.amazonaws.com/${props.nextjsRepo}:latest`),
memoryLimitMiB: 1024,
portMappings: [
{
containerPort: 3000,
protocol: ecs.Protocol.TCP
}
],
environment: {
'NODE_ENV': 'production',
'PORT': '3000',
'HOSTNAME': '0.0.0.0',
'CACHE_DIR': '/cache-data'
},
logging: ecs.LogDrivers.awsLogs({
logGroup: props.nextjsLogGroup,
streamPrefix: 'nextjs'
})
});
// コンテナにEFSマウントポイントを追加(カスタムキャッシュハンドラー用)
container.addMountPoints({
sourceVolume: 'nextjs-cache',
containerPath: '/cache-data',
readOnly: false
});
// ECSサービス - Next.js(2タスク、AZ分散)
const nextjsService = new ecs.FargateService(this, 'NextjsService', {
cluster: props.cluster,
taskDefinition: nextjsTaskDef,
desiredCount: 2,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
securityGroups: [props.nextjsSecurityGroup],
serviceName: 'demo-nextjs-service',
enableExecuteCommand: true,
minHealthyPercent: 100,
maxHealthyPercent: 200,
// EFSマウントにはplatformVersion 1.4.0以上が必要
platformVersion: ecs.FargatePlatformVersion.LATEST
});
// Next.jsサービスをターゲットグループに登録
nextjsService.attachToApplicationTargetGroup(props.nextjsTargetGroup);
}
}
アプリケーションプロジェクト
動作環境
- Next.js: 15.0.3 (AppRouter)
- React: 18.3.1
- Node.js: 20 (Alpine)
- パッケージマネージャー: pnpm 10.23.0
- TypeScript: 5.9.3
- ビルド出力: standalone モード
demo-nextjs-approuter/
├── app/
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── public/
├── cache-handler.js ← カスタムキャッシュハンドラー
├── next.config.ts ← cacheHandler設定
├── Dockerfile.with-efs ← ECS用Dockerfile
├── package.json
└── tsconfig.json
① 「next.config.ts」の設定
公式ドキュメントにも記載の通り独自のキャッシュシステムを構成するためにはnext.config.tsに️「インメモリキャッシュを無効化する設定」と「カスタムキャッシュハンドラーを構成する設定」が必要です。
今回検証にて追加した設定は以下の通りです。
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheMaxMemorySize: 0, // インメモリキャッシュを無効化
cacheHandler: require.resolve("./cache-handler.js"), // カスタムキャッシュハンドラー
output: "standalone", // Docker向けにstandalone出力を有効化
};
export default nextConfig;
② 「cache-handler.js」の記載
続いて上で定義した通りcache-handler.jsとして独自キャッシュファイルシステムの挙動を定義するスクリプトを作成する必要があります。
const fs = require('fs/promises');
const path = require('path');
const CACHE_DIR = process.env.CACHE_DIR || '/cache-data';
module.exports = class CacheHandler {
constructor(options) {
this.options = options;
}
async get(key) {
try {
const filePath = path.join(CACHE_DIR, `${key}.json`);
const data = await fs.readFile(filePath, 'utf-8');
return JSON.parse(data);
} catch (e) {
if (e.code === 'ENOENT') return null;
throw e;
}
}
async set(key, data, ctx) {
const filePath = path.join(CACHE_DIR, `${key}.json`);
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, JSON.stringify({
value: data,
lastModified: Date.now(),
tags: ctx.tags,
}));
}
async revalidateTag(tags) {
tags = [tags].flat();
const files = await this.getAllCacheFiles(CACHE_DIR);
for (const file of files) {
try {
const data = JSON.parse(await fs.readFile(file, 'utf-8'));
if (data.tags?.some(tag => tags.includes(tag))) {
await fs.unlink(file);
}
} catch (e) {
// ファイル読み込みエラーは無視
}
}
}
async getAllCacheFiles(dir) {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = await Promise.all(entries.map(entry => {
const res = path.join(dir, entry.name);
return entry.isDirectory() ? this.getAllCacheFiles(res) : res;
}));
return files.flat().filter(f => f.endsWith('.json'));
} catch (e) {
if (e.code === 'ENOENT') return [];
throw e;
}
}
resetRequestCache() {}
};
コードの挙動は詳しくは説明しませんが、先ほど上記に掲載した公式ドキュメントのサンプル例とほぼ同じくキャッシュ情報をJSONベースで「セット・取得・更新」するプロセスを記載しています。
③ app/page.tsxの記述
import Image from "next/image";
export const revalidate = 10; // ISR: 10秒ごとに再検証
// 時刻を取得(ISRの動作確認用)
async function getBuildTime() {
return new Date().toISOString();
}
export default async function Home() {
const buildTime = await getBuildTime();
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-col items-center gap-12 p-16">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={36}
priority
/>
<div className="flex flex-col items-center gap-6 text-center">
<h1 className="text-5xl font-bold text-black dark:text-zinc-50">
ISR Demo Page
</h1>
<p className="text-2xl text-zinc-600 dark:text-zinc-400">
このページは10秒ごとに再検証されます。
</p>
<p className="text-3xl font-mono text-zinc-800 dark:text-zinc-200 bg-zinc-100 dark:bg-zinc-800 px-6 py-4 rounded-lg">
{buildTime}
</p>
</div>
</main>
</div>
);
}
Next.js 15系のApp Routerでは、デフォルトで動的レンダリング(リクエストごとにサーバーでレンダリング)が行われます。
export const revalidate = 10を設定することで、このページはISR(Incremental StaticRegeneration)として動作し、最初のリクエストでレンダリングされた結果が10秒間キャッシュされます。getBuildTime() で取得している時刻は、実際には「ページがレンダリングされた時刻」です。ISRが有効な場合、この時刻は以下のタイミングで更新されます:
- 初回リクエスト時にレンダリングされキャッシュに保存
- 10秒経過後のリクエストで、キャッシュを返しつつバックグラウンドで再生成
- 再生成完了後、新しい時刻がキャッシュに保存される
EFS共有環境では、「Task A」でキャッシュされたHTMLを「TaskB」でも参照できるため、どちらのタスクにリクエストが振り分けられても同じbuildTimeが表示され表記揺れを起こしません。
④ 「Dockerfile」の設定
実際にビルドした内容は下記のとおりです。ビルドイメージを軽量化するために「standalone」モードを使用することを前提にしています。
参考: Next.jsのスタンドアロンモードについてわかりやすい記事
FROM node:20-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@10.23.0 --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY /app/cache-handler.js ./
COPY /app/public ./public
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
動作確認
上記設定でデプロイし、ALBの初期DNS名でアクセスしてページを10秒ごとに更新してみます。
先ほどの動作と比較してみてください。
理想: F5等で更新した際に「日付」部分が10秒毎に更新されるのが理想
現実: 裏側でF5を連打しておりマルチAZでタスク間でリクエストが分散されていますが、キャッシュの統一化により10秒を過ぎて日時が更新される理想通りの挙動になっている(GIFのタイミングで少しわかりづらいかも。。。)

最後にまとめとして本構成のポイントをまとめます。
next.config.tsでcacheMaxMemorySize: 0を設定し、インメモリキャッシュを無効化next.config.tsでcacheHandlerにカスタムキャッシュハンドラーのパスを指定cache-handler.jsのCACHE_DIRとECSタスク定義のcontainerPathを一致させる- EFSアクセスポイントを設定し、各コンテナから同一ディレクトリを参照できるようにする
最後に
今回はNext.jsの時間ベースのISRをEFSを共有ファイルシステムとしてキャッシュシステムを構成してみました。
ECS + EFS の構成では、カスタムキャッシュハンドラーを実装することで、複数タスク間でISRキャッシュを共有し、どのコンテナにリクエストが振り分けられても一貫したレスポンスを返すことができます。RedisやS3と比較して、ファイルシステムベースの実装をそのまま活用できる点がEFSの大きな利点です。
また今回は検証しませんでしたが、オンデマンド再検証(revalidateTag, revalidatePath)についても、カスタムキャッシュハンドラー内で実装することで対応可能です。実際のプロダクション環境では、エラーハンドリングやログ出力の強化も検討してください。
今回は以上です。






