はじめに
仕事でCDKを触っていまして,CDKスタックに統合できるWebアプリが必要になりました。
この記事は,Next.jsで作るWebアプリをCDKでビルド・デプロイまでやってしまうための記事です。
githubのリポジトリをcloneしてcdk deployすればNext.jsアプリが立ち上がるようになります。また,cdk watchした状態でファイルを変更するとCDKスタックやアプリのビルドが走り自動デプロイされます。
コードは以下のgithubリポジトリにあります:
https://github.com/liveinvalley/cdk-next-app
AWS Route53で独自ドメインのHostedZoneを管理していれば,lib/cdk-next-app-stack.tsのdomainNameとhostNameを書き換えるだけでcreate-next-appした状態のNext.jsが立ち上がります。
要求事項
Webアプリは独自ドメインで動かし,かつSSL対応させる必要があります。
速度は必要ありませんが,費用を抑える必要があります。
スタックの管理はごく少人数で行うので,cdk deployでAWSリソース作成やWebアプリのビルド,証明書やDNS登録を行えるようにします。
構成の方針
Next.jsのサーバレス化のために,Lambda Web Adapter を使います。
Webアプリのコンテナイメージを作り,これを動かすLambdaを作成します。
証明書を取得し,API Gateway にカスタムドメインを設定します。
Route53を設定して,カスタムドメインでAPI Gatewayにアクセスできるようにします。
前提条件
CDKコマンドを使える状態にしておく必要があります。また,Docker が動いている必要があります。
スタック外でAWSに事前設定が必要なリソースはHostedZoneです。
AWSでドメインを取得するか,HostedZoneを作成してネームサーバを所有ドメインのレジストラに設定しておきます。
作成手順
CDKスタック作成
以下のコマンドでスタックを作成します。cdkコマンドはディレクトリを作らないので,先に作っておきます。
[localhost:~] mkdir cdk-next-app && cd cdk-next-app
[localhost:~/cdk-next-app] cdk init app --language typescript
Applying project template app for typescript
# Welcome to your CDK TypeScript project
This is a blank project for CDK development with TypeScript.
The `cdk.json` file tells the CDK Toolkit how to execute your app.
## Useful commands
* `npm run build`   compile typescript to js
* `npm run watch`   watch for changes and compile
* `npm run test`    perform the jest unit tests
* `npx cdk deploy`  deploy this stack to your default AWS account/region
* `npx cdk diff`    compare deployed stack with current state
* `npx cdk synth`   emits the synthesized CloudFormation template
Initializing a new git repository...
warning: in the working copy of '.gitignore', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of '.npmignore', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'README.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'bin/cdk-next-app.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'cdk.json', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'jest.config.js', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'lib/cdk-next-app-stack.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'package.json', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'test/cdk-next-app.test.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'tsconfig.json', LF will be replaced by CRLF the next time Git touches it
Executing npm install...
npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported
✅ All done!
binディレクトリ内にあるtypescriptファイル(上の例だとbin/cdk-next-app.ts)でアカウントとリージョンを設定します。ここでは,awsコマンドの設定に合わせています。
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { CdkNextAppStack } from '../lib/cdk-next-app-stack';
const app = new cdk.App();
new CdkNextAppStack(app, 'CdkNextAppStack', {
  /* 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 */
});
カスタムタグを付与したコンテナイメージのビルド・デプロイができるように,cdk-docker-image-deploymentをCDKスタックに追加します。
[localhost:~/cdk-next-app] npm install cdk-docker-image-deployment
added 1 package, and audited 382 packages in 8s
50 packages are looking for funding
  run `npm fund` for details
found 0 vulnerabilities
Next.js アプリ作成
スタックのディレクトリ内でNext.jsアプリを作成します。create-next-appは指定したプロジェクト名でディレクトリを作ってくれます(この例だとwebapp)。アプリの設定はお好みです。
[localhost:~/cdk-next-app] npx create-next-app@latest
√ What is your project named? ... webapp
√ Would you like to use TypeScript? ... Yes
√ Would you like to use ESLint? ... Yes
√ Would you like to use Tailwind CSS? ... Yes
√ Would you like your code inside a `src/` directory? ... Yes
√ Would you like to use App Router? (recommended) ... Yes
√ Would you like to use Turbopack for `next dev`? ... Yes
√ Would you like to customize the import alias (`@/*` by default)? ... No
Creating a new Next.js app in /home/user/cdk-next-app/webapp.
Using npm.
Initializing project with template: app-tw
Installing dependencies:
- react
- react-dom
- next
Installing devDependencies:
- typescript
- @types/node
- @types/react
- @types/react-dom
- postcss
- tailwindcss
- eslint
- eslint-config-next
- @eslint/eslintrc
added 369 packages, and audited 370 packages in 38s
139 packages are looking for funding
  run `npm fund` for details
found 0 vulnerabilities
Success! Created webapp at /home/user/cdk-next-app/webapp
コンテナのサイズを小さくするために,next.config.tsを編集します。
nextConfigでstandalone設定を有効にします。
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
  /* config options here */
  output: "standalone",
};
export default nextConfig;
ここまでで,一度 Next.jsアプリを起動してみます(webappディレクトリ内で)。
[localhost:~/cdk-next-app/webapp] npm run dev
npm run dev
> webapp@0.1.0 dev
> next dev --turbopack
   ▲ Next.js 15.1.2 (Turbopack)
   - Local:        http://localhost:3000
   - Network:      http://192.168.1.6:3000
 ✓ Starting...
 ✓ Ready in 1693ms
このアプリを,CDKでビルド・デプロイできるようにしていきます。
最初に,キャッシュフォルダを作ってからnodeを起動するスクリプトを作ります。
#!/bin/bash -x
[ ! -d '/tmp/cache' ] && mkdir -p /tmp/cache
exec node server.js
次に,Next.js アプリをコンテナ化するためのDockerfileを作ります。
Lambda Web Adapter を組み込むことで,Next.js の出力をAPI Gateway(Lambdaプロキシ統合)の期待するフォーマットに合わせます。
FROM public.ecr.aws/docker/library/node:22.11.0-slim as builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build
FROM public.ecr.aws/docker/library/node:22.11.0-slim as runner
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.8.4 /lambda-adapter /opt/extensions/lambda-adapter
ENV PORT=3000 NODE_ENV=production
ENV AWS_LWA_ENABLE_COMPRESSION=true
WORKDIR /app
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/run.sh ./run.sh
RUN ln -s /tmp/cache ./.next/cache
RUN chmod +x ./run.sh
CMD exec ./run.sh
ここまでで,Next.jsアプリをコンテナ化する準備ができました。
コンテナ化とLambdaへのデプロイは,CDKで行います。
CDKスタックに組み込み
ドメイン名をdomainNameに設定します。
また,Next.jsアプリに設定したい名前をhostNameに設定します。
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as apigateway from 'aws-cdk-lib/aws-apigateway'
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as route53_targets from 'aws-cdk-lib/aws-route53-targets';
import * as deployment from 'cdk-docker-image-deployment'
import * as path from 'path'
export class CdkNextAppStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    // ホスト名・ドメイン
    const domainName = 'example.com';
    const hostName = 'www';
    const fqdn = hostName + '.' + domainName;
    // ECRにプッシュするイメージにつけるタグ
    const tag = 'code-server-custom';
    // 既存のRoute53 HostedZoneをルックアップ
    const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', {
      domainName: domainName,
    });
    // ACMで証明書を作成
    // Route53のHostedZoneを指定して自動取得
    const certificate = new cdk.aws_certificatemanager.Certificate(this, "Certificate", {
      domainName: fqdn,
      validation: cdk.aws_certificatemanager.CertificateValidation.fromDns(hostedZone),
    });
    // ECRリポジトリ作成
    // スタック削除時に自動削除
    const repository = new cdk.aws_ecr.Repository(this, 'Repository', {
      imageTagMutability: cdk.aws_ecr.TagMutability.MUTABLE,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      emptyOnDelete: true,
    });
    // webapp ディレクトリでイメージをビルドしてECRにデプロイ
    // カスタムタグを付与
    const imageDeployment = new deployment.DockerImageDeployment(this, 'DockerImageDeployment', {
      source: deployment.Source.directory(path.join('.', 'webapp')),
      destination: deployment.Destination.ecr(repository, {
        tag: tag,
      }),
    });
    // デプロイしたイメージをもとにLambda作成
    // イメージがプッシュされていないとエラーになるので,DockerImageDeploymentに依存させる
    const imageFunction = new lambda.DockerImageFunction(this, 'DockerImageFunction', {
      code: lambda.DockerImageCode.fromEcr(repository, {
        tagOrDigest: tag,
      }),
    });
    imageFunction.node.addDependency(imageDeployment);
    // API Gateway作成(Lambda統合プロキシ)
    // カスタムドメインとその証明書を付与
    const api = new apigateway.LambdaRestApi(this, 'LambdaRestApi', {
      handler: imageFunction,
      domainName: {
        certificate: certificate,
        domainName: fqdn,
      },
      binaryMediaTypes: [
        '*/*',
      ],
    });
    // Route53 にAレコード作成
    const aRecord = new route53.ARecord(this, 'ARecord', {
      zone: hostedZone,
      recordName: hostName,
      target: route53.RecordTarget.fromAlias(new route53_targets.ApiGateway(api)),
    });
  }
}
スタックでしていることは以下の通りです:
- HostedZoneをルックアップして取得。証明書取得やAレコード登録に使います。
 - 証明書作成
 - コンテナリポジトリ作成
 - コンテナイメージデプロイ。webappフォルダを指定して,イメージのビルドとリポジトリへのプッシュを行います。
 - Lambda作成。ビルドしたイメージを動かします。
 - API Gateway作成。Lambdaを起動します。カスタムドメインを設定します。binaryMediaTypesの指定は改善したほうがいいかもしれません。
 - Aレコード作成。API Gatewayに設定したカスタムドメインと同じ名前でDNSクエリに応答できるようになります。
 
CDKスタックデプロイ
以下のコマンドを実行します。アプリのビルドやコンテナイメージの作成は自動で行われます。初回実行時はコンテナイメージのキャッシュがないのですごく遅い(10~20分くらいかかるかも)ですが2回目から早くなります。
[localhost:~/cdk-next-app] cdk deploy
コマンドが無事に終了すると,指定したFQDNでNext.jsアプリが動いているはずです。
また,以下のコマンドを実行すると,ファイル変更をウォッチして自動デプロイしてくれます。
[localhost:~/cdk-next-app] cdk watch
Node.jsアプリだけだとローカルで動かすのが速いですが,デプロイ結果を見たくて色々触っているときは便利ですね。
参考資料
Web上にたくさんの方々が参考になる資料をあげておられます。
