Next.js × AWS CDK の統合環境構築:Docker でローカル開発から本番デプロイまで

2024/05/11に公開

はじめに

本記事では、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など)

メリット

  1. コード管理ができる
    Gitでバージョン管理ができ、変更履歴が追いやすい。

  2. ローカル開発がしやすい
    cdk synth コマンドでCloudFormationのYAMLをプレビュー可能。

  3. 開発者に馴染みやすい
    TypeScriptやPythonで記述できるため、フロントエンド・バックエンド開発者でも使いやすい。

  4. 再利用しやすい
    **Construct(コンポーネント)**を作れば、他のプロジェクトでも使い回せる。

システム構成

今回はVPC+ALB+ECS(Fargate)+ECRの構成を作成し、Next.jsのコンテナを公開します。

なおDockerfileを用いてビルドしたイメージをECRにプッシュし、そのイメージを用いてECSタスクを起動する処理についても、全てCDKで自動化します。

Image from Gyazo

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です。

Image from Gyazo

ローカルでの開発用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ファイルを作成します。

docker-compose.dev.yml
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

Image from Gyazo

http://localhost:3000にアクセスし画面が表示されることを確認します。

Image from Gyazo

簡単ではありますがSSRが有効になっていることを確認するためにサーバーサイドで時刻を取得しレンダリングするようコードを修正します。

page.tsx
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>
  );
}

ホットリロードでコードが自動で反映されます。

時刻が表示されましたら、ブラウザをリロードするとサーバーサイドで再レンダリングされ時刻が変化します。

Image from Gyazo

本番環境用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 --from=builder /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

Image from Gyazo

npm i cdk-docker-image-deployment

cdk/lib/cdk-stack.tsファイルは削除し新たにdocker-image-deployment.tsを用意してDockerイメージをECRにデプロイするための設定を行います。

cdk/lib/docker-image-deployment.ts
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の保存されているディレクトリを指定します。

エントリポイントに先ほどのスタックを実行するようコードを修正します。

cdk/bin/cdk.ts
#!/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

デプロイが正常に終了しました。

Image from Gyazo

AWSコンソールでECRにイメージがプッシュされたことを確認できます。

Image from Gyazo

ECSの作成

※差分が大きいためファイル全体を記載します。

cdk/lib/docker-image-deployment.ts
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になっています。

Image from Gyazo

  • ECS Service
    サービスに紐づく2つのタスクが起動していることが確認できます。

Image from Gyazo

  • ECS Task
    CPU: 0.25、メモリ:0.5GBで作成されています。

Image from Gyazo

ログを確認するとNext.jsを起動していることがわかります。

Image from Gyazo

  • ロードバランサー
    80番ポートで受け付けるようになっており、インターネット経由でアクセスできるDNSが定義されています。

Image from Gyazo

ロードバランサーがターゲットとしている2台のコンテナのIPアドレスとポートになります。
アベイラビリティゾーンがそれぞれAとCに分かれていることが確認できます。

Image from Gyazo

リソースマッピングを確認すると80番で受け取ったリクエストをNext.jsのコンテナに送っていることが確認できます。

Image from Gyazo

DNSに記載のアドレスをコピーしてブラウザからアクセスします。

今回はhttpでアクセスするよう設定しましたが、ACM(AWS Certificate Manager)でSSL証明書を取得することでhttpsでリクエストを受け取ることも可能です。

Image from Gyazo

さいごに

本記事では、Next.jsとAWS CDKを組み合わせたプロジェクトのセットアップ方法について解説しました。特に、Dockerを活用してローカル開発環境と本番環境の構築を行い、CDKを用いてAWSへデプロイする準備を進めることで、スケーラブルなアーキテクチャを実現できます。

今後は、ECS上にデプロイするためのCDKコードの実装や、デプロイパイプラインの構築についても掘り下げていくと、より実用的な環境を整えることができます。引き続き、効率的な開発環境を構築し、スムーズなデプロイを目指していきましょう。

記事に関するお問い合わせ📝

記事の内容に関するご質問、ご意見などは、下記よりお気軽にお問い合わせください。
ご質問フォームへ

技術支援などお仕事に関するお問い合わせ📄

技術支援やお仕事のご依頼に関するお問い合わせは、下記よりお気軽にお問い合わせください。
お問い合わせフォームへ

関連する弊社の支援サービス