はじめに
本記事では、Next.jsとAWS CDKを1つのリポジトリで管理し、ローカル開発環境および本番環境向けのDockerイメージを構築する方法を解説します。具体的には、
ディレクトリ構成の設計
Next.jsのセットアップ
Dockerを用いたローカル開発環境の構築
本番環境向けDockerイメージの作成
AWS CDKのセットアップ
を順を追って説明していきます。これにより、開発環境の一貫性を保ちながら、スムーズに本番環境へデプロイできるようになります。
AWS CDKとは
AWS CDK(Cloud Development Kit)は、コードを使ってAWSリソースを定義・管理できる インフラ管理ツールです。
通常、AWSのインフラはAWSマネジメントコンソールや**CloudFormation(YAML/JSON)**で設定しますが、AWS CDKを使うと、TypeScriptやPythonなどのプログラミング言語で定義できるのが特徴です。
特徴
-
プログラムでインフラを管理
AWSリソース(S3、Lambda、ECSなど)をコードで定義できる。
if文やループを使って動的なリソース作成が可能。 -
CloudFormationのラッパー
AWS CDKは内部的にCloudFormationのテンプレートを生成する。
手書きのYAML/JSONより簡潔に書ける。 -
マルチ言語対応
TypeScript、JavaScript、Python、Java、C# などの言語をサポート。 -
CI/CDと統合しやすい
cdk deploy コマンドでAWSにデプロイ可能。
GitHub ActionsやAWS CodePipelineと組み合わせやすい。
基本構成
AWS CDKでは、主に以下の概念を使ってリソースを定義します。
概念 | 説明 |
---|---|
App | CDKのエントリーポイント(cdk.jsonで定義) |
Stack | CloudFormationのスタック(1つのスタックに複数のリソースを定義) |
Construct | AWSリソースの最小単位(S3、Lambda、ECSなど) |
メリット
-
コード管理ができる
Gitでバージョン管理ができ、変更履歴が追いやすい。 -
ローカル開発がしやすい
cdk synth コマンドでCloudFormationのYAMLをプレビュー可能。 -
開発者に馴染みやすい
TypeScriptやPythonで記述できるため、フロントエンド・バックエンド開発者でも使いやすい。 -
再利用しやすい
**Construct(コンポーネント)**を作れば、他のプロジェクトでも使い回せる。
システム構成
今回はVPC+ALB+ECS(Fargate)+ECRの構成を作成し、Next.jsのコンテナを公開します。
なおDockerfileを用いてビルドしたイメージをECRにプッシュし、そのイメージを用いてECSタスクを起動する処理についても、全てCDKで自動化します。
Next.jsのセットアップ
Next.jsのプロジェクトとCDKのプロジェクトを1つのリポジトリで管理するために下記のディレクトリ構成とします。
apps
配下: Next.jsのプロジェクトcdk
配下: CDKのプロジェクト
mkdir nextjs-ecs-cdk
cd nextjs-ecs-cdk
mkdir apps
mkdir cdk
Next.jsのセットアップを行います。
cd apps
npx create-next-app@14.2.0 .
デフォルトの設定のままでOKです。
ローカルでの開発用Dockerイメージ
ローカルでNext.jsを起動するためのDockerファイルを作成します。
# 開発環境用
FROM node:20-alpine
WORKDIR /app
# package.json と package-lock.json をコピー
COPY package.json package-lock.json ./
RUN npm install
EXPOSE 3000
CMD ["npm", "run", "dev"]
次にdocker-compose
ファイルを作成します。
services:
nextjs-dev:
build:
context: .
dockerfile: dev.Dockerfile
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
environment:
- NODE_ENV=development
stdin_open: true
tty: true
services:
nextjs-dev:
- services: Docker Compose で管理するサービスを定義します。
- nextjs-dev: Next.js の開発環境を構築するコンテナの名前 です。
- docker-compose up を実行すると、この名前のコンテナが作成されます。
build:
context: .
dockerfile: dev.Dockerfile
context: .
- ビルドの基準となるディレクトリ を指定します。
- . は カレントディレクトリ(docker-compose.dev.yml がある場所)を示します。
dockerfile: dev.Dockerfile
- 使用する Dockerfile を指定 します。
- dev.Dockerfile を使うことで、開発用の設定(npm run dev で起動)を適用できます。
ports:
- "3000:3000"
- ホストの 3000 番ポートとコンテナの 3000 番ポートを紐付ける 設定です。
- npm run dev を実行すると、ホストのブラウザから http://localhost:3000 でアクセスできる ようになります。
- 3000:3000 の形式は ホストポート:コンテナポート です。
volumes:
- .:/app
- /app/node_modules
- volumes はコンテナとホスト間でファイルを同期するための設定 です。
- ホスト側のファイル変更をコンテナに即時反映 できるので、開発環境では必須です。
- .:/app
- ホストのカレントディレクトリ(.)をコンテナの /app にマウント します。
- つまり、ホストで編集したソースコードが即座にコンテナ内に反映される 仕組みです。
- /app/node_modules
- node_modules はコンテナ内で管理し、ホストと分離するためにこの設定を入れます。(ホスト環境とコンテナ環境で異なるバイナリが混在する問題を回避するため)
- :/app/node_modules とは書かず、/app/node_modules にすることで 「空のボリューム」として扱われ、ホストの node_modules をコンテナと共有しないようにできる。
理由:
- ホストとコンテナで OS が異なるため、依存関係のバイナリが合わないことがある。
- 開発環境の node_modules がコンテナ内の node_modules と競合しないようにするため。
stdin_open: true
tty: true
stdin_open: true
- 標準入力を開いたままにする 設定です。
- Next.js の npm run dev は標準入力を使用するため、開発環境では stdin_open: true にしておくと安定します。
tty: true
- コンテナ内でターミナルを有効化 します。
- docker-compose up で起動したとき、Ctrl + C でコンテナを簡単に終了できるようになります。
下記コマンドでNext.jsを起動します。
docker-compose -f docker-compose.dev.yml up
http://localhost:3000
にアクセスし画面が表示されることを確認します。
簡単ではありますがSSRが有効になっていることを確認するためにサーバーサイドで時刻を取得しレンダリングするようコードを修正します。
export const dynamic = "force-dynamic"
export default async function Home() {
const now = new Date().toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" })
return (
<main>
<h1>現在の時刻(JST):</h1>
<p>{now}</p>
</main>
);
}
ホットリロードでコードが自動で反映されます。
時刻が表示されましたら、ブラウザをリロードするとサーバーサイドで再レンダリングされ時刻が変化します。
本番環境用Dockerイメージ
次にECRにプッシュするためのDockerイメージを作成します。
ビルドをしてNext.jsを起動する形となります。
# 本番環境用
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build
# 実行用のコンテナを用意(軽量化のためマルチステージビルド)
FROM node:20-alpine
WORKDIR /app
COPY /app ./
EXPOSE 3000
CMD ["npm", "run", "start"]
こちらのDockerfileも正常に動くか念の為ローカル環境で動作確認を行います。
cd apps
docker build -t nextjs-app-production .
docker run -d -p 3000:3000 --name nextjs-container-production nextjs-app-production
コンテナが起動しましたら、http://localhost:3000
にアクセスし画面が表示されることを確認します。
動作確認が終わりましたらコンテナは削除して問題ありません。
CDKのセットアップ
Next.js側の設定が終わったのでCDKの設定を行います。
cdkのコマンドがローカルで使えるよう下記のコマンドでインストールします。
npm install -g aws-cdk
CDKのセットアップとECRにイメージをプッシュするためのライブラリのインストールを行います。
cd cdk
cdk init app --language=typescript
npm i cdk-docker-image-deployment
cdk/lib/cdk-stack.ts
ファイルは削除し新たにdocker-image-deployment.ts
を用意してDockerイメージをECRにデプロイするための設定を行います。
import {Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
import { Repository } from 'aws-cdk-lib/aws-ecr';
import * as imagedeploy from 'cdk-docker-image-deployment';
import { Construct } from 'constructs';
import * as path from 'path';
export class DockerImageDeploymentStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
//**************************************************** */
// ECR
//**************************************************** */
const repository = new Repository(this, 'NextjsEcrRepo', {
repositoryName: 'nextjs-app',
removalPolicy: RemovalPolicy.RETAIN,
});
new imagedeploy.DockerImageDeployment(this, "DeployDockerImage", {
source: imagedeploy.Source.directory(
path.join(__dirname, '../../', 'apps')
),
destination: imagedeploy.Destination.ecr(repository, {
tag: 'latest',
}),
});
}
}
コードの解説になります。
const repository = new Repository(this, 'NextjsEcrRepo', {
repositoryName: 'nextjs-app',
removalPolicy: RemovalPolicy.RETAIN,
});
- ECR(Elastic Container Registry)を作成し、リポジトリ名を nextjs-app に指定。
- removalPolicy: RemovalPolicy.RETAIN により、CDKスタック削除時もECRリポジトリが保持される。
new imagedeploy.DockerImageDeployment(this, "DeployDockerImage", {
source: imagedeploy.Source.directory(
path.join(__dirname, '../../', 'apps')
),
destination: imagedeploy.Destination.ecr(repository, {
tag: 'latest',
}),
});
- cdk-docker-image-deployment を使用して、ローカルディレクトリ apps から nextjs-app リポジトリに latest タグでDockerイメージをデプロイ。
source:
でDockerfileの保存されているディレクトリを指定します。
エントリポイントに先ほどのスタックを実行するようコードを修正します。
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { DockerImageDeploymentStack } from '../lib/docker-image-deployment';
const app = new cdk.App();
new DockerImageDeploymentStack(app, 'DockerImageDeploymentStack');
ディレクトリ構成がやや複雑かと思いますので下記に貼り付けておきます。
tree -I node_modules -I .git -I .next --dirsfirst -a
.
├── apps
│ ├── app
│ │ ├── favicon.ico
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── public
│ │ ├── next.svg
│ │ └── vercel.svg
│ ├── .eslintrc.json
│ ├── .gitignore
│ ├── Dockerfile
│ ├── README.md
│ ├── dev.Dockerfile
│ ├── docker-compose.dev.yml
│ ├── next-env.d.ts
│ ├── next.config.mjs
│ ├── package-lock.json
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── tailwind.config.ts
│ └── tsconfig.json
└── cdk
├── bin
│ └── cdk.ts
├── lib
│ └── docker-image-deployment.ts
├── test
│ └── cdk.test.ts
├── .gitignore
├── .npmignore
├── README.md
├── cdk.json
├── jest.config.js
├── package-lock.json
├── package.json
└── tsconfig.json
CDKでリソース作成の設定ができましたので実行に移ります。
まずはcdk bootstrap
でCDKが必要なリソースを準備しておく 必要があります。
具体的には下記のリソースが作成されます。
- CDK のデプロイ用 S3 バケット(CDK アセットを保存する)
- CDK の IAM ロール(デプロイ時に必要な権限を AWS に付与)
- CDK の ECR リポジトリ(コンテナを使用する場合)
cdk bootstrap
次にデプロイ処理を行います。
cdk deploy
デプロイが正常に終了しました。
AWSコンソールでECRにイメージがプッシュされたことを確認できます。
ECSの作成
※差分が大きいためファイル全体を記載します。
import {
Stack,
StackProps,
RemovalPolicy,
aws_ec2 as ec2,
aws_ecs as ecs,
aws_logs as logs,
aws_iam as iam,
aws_elasticloadbalancingv2 as elbv2,
Duration,
} from 'aws-cdk-lib';
import { Repository } from 'aws-cdk-lib/aws-ecr';
import * as imagedeploy from 'cdk-docker-image-deployment';
import { Construct } from 'constructs';
import * as path from 'path';
export class DockerImageDeploymentStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
//**************************************************** */
// ECR
//**************************************************** */
const repository = new Repository(this, 'NextjsEcrRepo', {
repositoryName: 'nextjs-app',
removalPolicy: RemovalPolicy.RETAIN,
});
new imagedeploy.DockerImageDeployment(this, "DeployDockerImage", {
source: imagedeploy.Source.directory(
path.join(__dirname, '../../', 'apps')
),
destination: imagedeploy.Destination.ecr(repository, {
tag: 'latest',
}),
});
//**************************************************** */
// VPC
//**************************************************** */
const vpc = new ec2.Vpc(this, 'NextjsVpc', {
maxAzs: 2,
});
//**************************************************** */
// ECS
//**************************************************** */
const cluster = new ecs.Cluster(this, 'NextjsCluster', {
vpc,
clusterName: 'nextjs-cluster',
});
const logGroup = new logs.LogGroup(this, "LogGroup", {
logGroupName: '/aws/ecs/nextjs-cluster',
removalPolicy: RemovalPolicy.DESTROY,
});
//**************************************************** */
// ALB(Application Load Balancer)
//**************************************************** */
const albSecurityGroup = new ec2.SecurityGroup(this, "AlbSecurityGroup", {
vpc,
allowAllOutbound: true,
});
albSecurityGroup.addIngressRule(
ec2.Peer.anyIpv4(), // 外部からの HTTP アクセスを許可
ec2.Port.tcp(80),
"Allow HTTP traffic from the internet"
);
const alb = new elbv2.ApplicationLoadBalancer(this, 'alb', {
internetFacing: true, //インターネットからのアクセスを許可するかどうか指定
loadBalancerName: 'nextjs-alb',
securityGroup: albSecurityGroup, //作成したセキュリティグループを割り当てる
vpc
});
const listener = alb.addListener('Listener', {
port: 80,
open: true,
});
//**************************************************** */
// Fargate Service
//**************************************************** */
const serviceSecurityGroup = new ec2.SecurityGroup(this, "ServiceSecurityGroup", {
vpc,
allowAllOutbound: true,
});
serviceSecurityGroup.addIngressRule(
albSecurityGroup,
ec2.Port.tcp(3000),
"Allow traffic from ALB"
);
const taskDefinition = new ecs.FargateTaskDefinition(this, "TaskDef", {
cpu: 256,
memoryLimitMiB: 512,
family: 'nextjs-task-family',
taskRole: new iam.Role(this, 'TaskRole', {
assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com')
}),
});
taskDefinition.addContainer("NextjsContainer", {
containerName: 'nextjs-container',
image: ecs.ContainerImage.fromEcrRepository(repository, "latest"),
portMappings: [{ containerPort: 3000 }],
logging: ecs.LogDrivers.awsLogs({
streamPrefix: `container`,
logGroup,
}),
command: ["npm", "run", "start"],
});
const service = new ecs.FargateService(this, "FargateService", {
cluster,
serviceName: 'nextjs-service',
taskDefinition: taskDefinition,
desiredCount: 2,
assignPublicIp: true,
securityGroups: [serviceSecurityGroup],
});
listener.addTargets('EcsTargetGroup', {
port: 3000,
protocol: elbv2.ApplicationProtocol.HTTP,
targets: [service],
healthCheck: {
path: "/",
interval: Duration.seconds(30),
},
});
}
}
具体的にどのようなリソースを作成しているか詳細を記載します。
VPC
const vpc = new ec2.Vpc(this, 'NextjsVpc', {
maxAzs: 2,
});
- VPC(仮想ネットワーク)を作成し、最大2つのアベイラビリティゾーン(AZ)に展開。
ECS Cluster
const cluster = new ecs.Cluster(this, 'NextjsCluster', {
vpc,
clusterName: 'nextjs-cluster',
});
- ECSクラスター nextjs-cluster を作成し、上で定義した vpc 内に配置。
ロググループ
const logGroup = new logs.LogGroup(this, "LogGroup", {
logGroupName: '/aws/ecs/nextjs-cluster',
removalPolicy: RemovalPolicy.DESTROY,
});
- CloudWatch Logsのロググループを作成。
- removalPolicy: RemovalPolicy.DESTROY により、スタック削除時にロググループも削除されます。
ALB(Application Load Balancer)
const albSecurityGroup = new ec2.SecurityGroup(this, "AlbSecurityGroup", {
vpc,
allowAllOutbound: true,
});
albSecurityGroup.addIngressRule(
ec2.Peer.anyIpv4(),
ec2.Port.tcp(80),
"Allow HTTP traffic from the internet"
);
- ALB用のセキュリティグループを作成し、外部からのHTTPアクセス(ポート80)を許可。
const alb = new elbv2.ApplicationLoadBalancer(this, 'alb', {
internetFacing: true,
loadBalancerName: 'nextjs-alb',
securityGroup: albSecurityGroup,
vpc
});
- ALBをインターネットからアクセス可能に設定。
const listener = alb.addListener('Listener', {
port: 80,
open: true,
});
Fargate
const serviceSecurityGroup = new ec2.SecurityGroup(this, "ServiceSecurityGroup", {
vpc,
allowAllOutbound: true,
});
serviceSecurityGroup.addIngressRule(
albSecurityGroup,
ec2.Port.tcp(3000),
"Allow traffic from ALB"
);
- Fargate用セキュリティグループを作成し、ALBからのポート 3000 のアクセスを許可。
const taskDefinition = new ecs.FargateTaskDefinition(this, "TaskDef", {
cpu: 256,
memoryLimitMiB: 512,
family: 'nextjs-task-family',
taskRole: new iam.Role(this, 'TaskRole', {
assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com')
}),
});
- Fargateのタスク定義を作成し、CPU 256 、メモリ 512MiB を設定。
taskDefinition.addContainer("NextjsContainer", {
containerName: 'nextjs-container',
image: ecs.ContainerImage.fromEcrRepository(repository, "latest"),
portMappings: [{ containerPort: 3000 }],
logging: ecs.LogDrivers.awsLogs({
streamPrefix: `container`,
logGroup,
}),
command: ["npm", "run", "start"],
});
- ECRに保存したイメージを使用し、コンテナ nextjs-container を追加。
- npm run start でNext.jsアプリを起動。
const service = new ecs.FargateService(this, "FargateService", {
cluster,
serviceName: 'nextjs-service',
taskDefinition: taskDefinition,
desiredCount: 2,
assignPublicIp: true,
securityGroups: [serviceSecurityGroup],
});
- Fargateサービス nextjs-service を作成し、2つのタスクを起動。
listener.addTargets('EcsTargetGroup', {
port: 3000,
protocol: elbv2.ApplicationProtocol.HTTP,
targets: [service],
healthCheck: {
path: "/",
interval: Duration.seconds(30),
},
});
- ALBのターゲットグループに nextjs-service を登録。
/
でヘルスチェックを実施。- 30秒間隔でチェック。
動作確認
まずはAWSコンソールでリソースが正しく作成されているか確認します。
- ECS Cluster
StatusがActiveになっています。
- ECS Service
サービスに紐づく2つのタスクが起動していることが確認できます。
- ECS Task
CPU: 0.25、メモリ:0.5GBで作成されています。
ログを確認するとNext.jsを起動していることがわかります。
- ロードバランサー
80番ポートで受け付けるようになっており、インターネット経由でアクセスできるDNSが定義されています。
ロードバランサーがターゲットとしている2台のコンテナのIPアドレスとポートになります。
アベイラビリティゾーンがそれぞれAとCに分かれていることが確認できます。
リソースマッピングを確認すると80番で受け取ったリクエストをNext.jsのコンテナに送っていることが確認できます。
DNSに記載のアドレスをコピーしてブラウザからアクセスします。
今回はhttpでアクセスするよう設定しましたが、ACM(AWS Certificate Manager)でSSL証明書を取得することでhttpsでリクエストを受け取ることも可能です。
さいごに
本記事では、Next.jsとAWS CDKを組み合わせたプロジェクトのセットアップ方法について解説しました。特に、Dockerを活用してローカル開発環境と本番環境の構築を行い、CDKを用いてAWSへデプロイする準備を進めることで、スケーラブルなアーキテクチャを実現できます。
今後は、ECS上にデプロイするためのCDKコードの実装や、デプロイパイプラインの構築についても掘り下げていくと、より実用的な環境を整えることができます。引き続き、効率的な開発環境を構築し、スムーズなデプロイを目指していきましょう。
関連する技術ブログ
Next.jsとAuth.jsで認証機能を実装するチュートリアル
2024/09/1310分で完成。AWS Amplify公式テンプレートを使ったNext.jsアプリの簡単デプロイ手順
2024/11/05Next.jsでのメール認証処理の実装ガイド:アカウント登録からトークン検証まで
2024/05/10Next.jsでのメール認証処理の実装ガイド:トークン検証からログイン画面へのリダイレクト処理までの詳細解説
2024/05/13Next.jsを活用したGitHubとGoogleのOAuth認証実装完全ガイド — スムーズなユーザーログインの実現方法
2024/06/11Next.jsでログイン画面を作ってメールアドレス/パスワードでログインできるようにする
2024/02/27Next.jsとmicroCMSで作るブログ:ヘッドレスCMSによるコンテンツ管理と表示
2024/12/16Next.js と Auth.js を使ったログイン状態に応じたアクセス制御の実装
2024/03/02