CDKでサーバレスNext.jsアプリを立ち上げる

## はじめに

仕事で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](https://github.com/awslabs/aws-lambda-web-adapter) を使います。

Webアプリのコンテナイメージを作り，これを動かすLambdaを作成します。
証明書を取得し，API Gateway にカスタムドメインを設定します。

Route53を設定して，カスタムドメインでAPI Gatewayにアクセスできるようにします。

## 前提条件
CDKコマンドを使える状態にしておく必要があります。また，Docker が動いている必要があります。

スタック外でAWSに事前設定が必要なリソースは`HostedZone`です。
AWSでドメインを取得するか，HostedZoneを作成してネームサーバを所有ドメインのレジストラに設定しておきます。

## 作成手順

### CDKスタック作成
以下のコマンドでスタックを作成します。`cdk`コマンドはディレクトリを作らないので，先に作っておきます。
```shell-session
[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コマンドの設定に合わせています。
```typescript:bin/cdk-next-app.ts
#!/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スタックに追加します。

```shell-session
[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`）。アプリの設定はお好みです。
```shell-session
[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設定を有効にします。
```typescript:webapp/next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
  output: "standalone",
};

export default nextConfig;
```

ここまでで，一度 Next.jsアプリを起動してみます（`webapp`ディレクトリ内で）。
```shell
[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
```

ちゃんと動いたようです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738861/961b2469-e298-b73b-6213-a525423d59e9.png)

このアプリを，CDKでビルド・デプロイできるようにしていきます。
最初に，キャッシュフォルダを作ってからnodeを起動するスクリプトを作ります。

```shell:webapp/run.sh
#!/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プロキシ統合）の期待するフォーマットに合わせます。

```dockerfile:webapp/Dockerfile
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`に設定します。

```typescript:lib/cdk-next-app-stack.ts
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)),
    });
  }
}
```

スタックでしていることは以下の通りです：
1. HostedZoneをルックアップして取得。証明書取得やAレコード登録に使います。
2. 証明書作成
3. コンテナリポジトリ作成
4. コンテナイメージデプロイ。webappフォルダを指定して，イメージのビルドとリポジトリへのプッシュを行います。
5. Lambda作成。ビルドしたイメージを動かします。
6. API Gateway作成。Lambdaを起動します。カスタムドメインを設定します。binaryMediaTypesの指定は[改善したほうがいいかも](https://qiita.com/toyopilgrim/items/8faf3a3ca5a06b56a021)しれません。
7. Aレコード作成。API Gatewayに設定したカスタムドメインと同じ名前でDNSクエリに応答できるようになります。

### CDKスタックデプロイ

以下のコマンドを実行します。アプリのビルドやコンテナイメージの作成は自動で行われます。初回実行時はコンテナイメージのキャッシュがないのですごく遅い（10～20分くらいかかるかも）ですが2回目から早くなります。

```shell-session
[localhost:~/cdk-next-app] cdk deploy
```
コマンドが無事に終了すると，指定したFQDNでNext.jsアプリが動いているはずです。

また，以下のコマンドを実行すると，ファイル変更をウォッチして自動デプロイしてくれます。

```shell-session
[localhost:~/cdk-next-app] cdk watch
```

Node.jsアプリだけだとローカルで動かすのが速いですが，デプロイ結果を見たくて色々触っているときは便利ですね。

## 参考資料
Web上にたくさんの方々が参考になる資料をあげておられます。

https://qiita.com/murodon/items/d6a94c448caea9111bc6

https://zenn.dev/5t111111/articles/use-cdk-docker-image-deployment