🧀

CDKを使ってNext.jsをS3+CloudFrontの構成にデプロイする

2023/01/15に公開約11,900字

はじめに

CloudFrontを使ってNext.jsなどでビルドされたコンテンツを配信するときに、
S3のアクセス設定を「公開」にしたくない時はないでしょうか?
(↓ S3のアクセス設定が公開になっている状態)

フロントエンドのコンテンツは配信されることが目的のため、
S3のアクセス設定が「公開」のままでも意図されたものであれば問題はないです。
ただ、S3のバケットが「公開」になっているのは精神衛生上よくないと思います。

いくつかの記事でも言及されているように、
CloudFront + S3でNext.jsのコンテンツを配信する際には以下の2つの方法が主流かと思います。

  1. S3を「静的Webサーバー/公開」に設定して、CloudFrontから配信する
  2. S3は「非公開」設定のまま、Lambda@Edgeを利用してCloudFrontから配信する

今回は2の方法を採用しつつ、それをCDKで実装できるようにします。
加えて、セキュリティも少し意識してコンテンツ配信をするようにします。

やりたいこと・対象読者・構成・料金

やりたいこと

やりたいことは、
「S3は非公開設定のままCloudFront経由でNext.jsのコンテンツを配信すること」です。
加えて、セキュリティも高めつつ配信されるようにします。

対象読者

以下の内容を実行したい人におすすめです。

  • CDKを使ってCloudFront + Next.jsの構成を作りたい
  • セキュリティも高めつつCloudFrontでの配信を実施したい

構成

Lambda@Edgeを利用した構成と挙動のイメージは以下の図の通りです。

Lambda@Edgeで末尾にindex.htmlを付与してリソースを取得しに行くことで、
ユーザーがNext.jsのコンテンツを取得することができるようになっています。

一方で、Lambda@Edgeを挟まない場合は、以下のようになります。

S3にリソースを探しに行く際に、存在しないパスを参照してエラーになってしまいます。
そのため、S3に参照する前にLambda@Edgeでの処理が必要となります。

料金

今回は、以下のAWSのサービスを利用して構築します。

  • Lambda@Edge
  • CloudFront
  • S3
  • Route53
  • ACM

コンテンツのサイズが小さくアクセス頻度も低い場合は、コンテンツ配信はほぼ無料です。
Route53のホストゾーン取得のみ料金がかかります。

デプロイ環境

  • macOS: 13.1
  • Node.js: 16.15
  • AWS CDK: 2.60.0

コード

このリポジトリにコードを載せています。
実際にデプロイする場合の細かいコードなどはリポジトリを参照してください。
また、本記事でもCloudFrontの部分とLambda@Edgeの挙動に関して少し解説します。

Lambda@EdgeをJavaScriptとTypeScriptの2通りの実装方法を載せています。
コードの差異はほぼありませんが、デプロイの準備などが異なります。
どちらでも良い場合はデプロイ時の手間な少ないJavaScriptでの実装がお勧めです。
一方で、Lambda@Edgeを使い回す場合や、管理をしたい場合はTypeScript版の方がお勧めです。

Lambda@EdgeのコードをJavaScriptで実装する

JavaScriptで作成する場合は、以下のコードのLambda@Edgeを作成すれば実現できます。
処理内容としては、リクエストURIの末尾にindex.htmlを付与するだけになります。

infrastructure/lib/lambda/js_edge/rewrite-trailing-slash/index.js
exports.handler = (event, context, callback) => {
  const request = event.Records[0].cf.request;
  const uri = request.uri;
  // ファイル名 ("/" で区切られたパスの最後) を取得
  const filename = request.uri.split("/").pop();

  if (uri.endsWith("/")) {
    request.uri = request.uri.concat("index.html");
  } else if (filename) {
    if (!filename.includes(".")) {
      // ファイル名に拡張子がついていない場合、 "/index.html" をつける
      request.uri = request.uri.concat("/index.html");
    }
  }
  console.log(`uri: ${uri} -> ${request.uri}`)
  callback(null, request);
}

JavaScriptの場合は、以下のCDKのコードでLambda@Edgeを作成することができます。

infrastructure/lib/CloudFrontOaiStack.ts
// ~~前略~~

    const dir = path.resolve(__dirname, 'lambda', 'js_edge', 'rewrite-trailing-slash')
    const rewriteTrailingSlashVersion = new aws_cloudfront.experimental.EdgeFunction(this, "edge-origin-request", {
      code: aws_lambda.Code.fromAsset(dir),
      functionName: "origin-request",
      handler: `index.handler`,
      runtime: aws_lambda.Runtime.NODEJS_16_X,
      memorySize: 512,
      timeout: Duration.seconds(5),
      architecture: aws_lambda.Architecture.X86_64,
    })

// ~~後略~~

Lambda@EdgeのコードをTypeScriptで実装する

一方でTypeScriptで作成したい場合は、以下のコードで実現できます。
(当然ながらコードそのものは、JavaScriptとほとんど差はないです。)
ただ、この場合は事前のデプロイが必要になります(詳細はリポジトリを参照のこと)。

infrastructure/lib/lambda/ts_edge/rewrite-trailing-slash/index.ts
import {CloudFrontRequestHandler} from "aws-lambda";

export const handler: CloudFrontRequestHandler = async (event) => {
  const request = event.Records[0].cf.request;
  const uri = request.uri;
  // ファイル名 ("/" で区切られたパスの最後) を取得
  const filename = uri.split("/").pop();

  if (uri.endsWith("/")) {
    request.uri = request.uri.concat("index.html");
  } else if (filename) {
    if (!filename.includes(".")) {
      // ファイル名に拡張子がついていない場合、 "/index.html" をつける
      request.uri = request.uri.concat("/index.html");
    }
  }
  return request
}

TypeScriptの場合は、以下のCDKのコードでLambda@Edgeを参照しています。
一見、JavaScriptの場合と比べると短く楽に見えますが、
SSMからLambdaのARNを読み取るために、事前に別Stackでデプロイをする必要があります。
デプロイする別Stackの詳細はこの記事をご覧ください。

infrastructure/lib/CloudFrontOaiStack.ts
// ~~前略~~

    // Lambda@Edge
    const rewriteTrailingSlashParam = aws_ssm.StringParameter.fromStringParameterAttributes(this, 'rewriteTrailingSlashParam', {
      parameterName: `/${prefix}/${params.environment}/${params.lambdaEdgeStackId}/rewriteTrailingSlash`,
    }).stringValue;
    const rewriteTrailingSlashVersion = aws_lambda.Version.fromVersionArn(this, "rewriteTrailingSlashVersion", rewriteTrailingSlashParam)

// ~~後略~~

セキュリティを高めるレスポンスヘッダの付与

CloudFrontから配信する際に、レスポンスヘッダを付与することで
ある程度セキュリティを高めることができます。
Amazonが提供している既存のものもあり、そちらを使うこともできます。
ただ、SCPを自分で設定する場合は、以下のような実装を行う必要があります。

infrastructure/lib/CloudFrontOaiStack.ts
// ~~前略~~
    const responseHeadersPolicy = new aws_cloudfront.ResponseHeadersPolicy(this, "custom-rhp", {
      responseHeadersPolicyName: "custom-rhp",
      securityHeadersBehavior: {
        contentSecurityPolicy: {
          contentSecurityPolicy: `object-src 'self'; img-src 'self'; script-src 'self' 'nonce-${params.next.nonce}'; base-uri 'self'; form-action 'none'; frame-ancestors 'none'`,
          override: true
        },
        contentTypeOptions: {override: true},
        frameOptions: {
          frameOption: aws_cloudfront.HeadersFrameOption.SAMEORIGIN,
          override: true
        },
        referrerPolicy: {
          referrerPolicy: aws_cloudfront.HeadersReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN,
          override: true
        },
        strictTransportSecurity: {
          accessControlMaxAge: Duration.seconds(15768000),
          override: true
        },
        xssProtection: {
          protection: true,
          modeBlock: true,
          override: true
        }
      }
    })
// ~~後略~~

デプロイ

実際にデプロイしてみて挙動を確認してみます。
JavaScriptとTypeScriptとでデプロイの手順が少し異なります。
本記事では、簡単なJavaScript版のみのデプロイ手順を紹介します。

1. NextをビルドしてS3にアップロード

まずは、Nextをビルドします。
ビルドする前に、以下の2つの設定を追記します。

next.config.js
const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
+ trailingSlash: true,
+ images: {unoptimized: true}
}

module.exports = nextConfig

package.jsonに以下の変更を追記します。

package.json
{
  "name": "frontend",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
+   "export": "NODE_ENV=production next build && next export -o build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@types/node": "18.11.9",
    "@types/react": "18.0.25",
    "@types/react-dom": "18.0.9",
    "eslint": "8.28.0",
    "eslint-config-next": "13.0.5",
    "next": "13.0.5",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "typescript": "4.9.3"
  }
}

それが終わったら、以下のコマンドを実行してbuildディレクトリにファイルが出力されるようにします。

# `frontend/`ディレクトリ
$ npm run export

nextのファイル出力後に、以下のコマンドでS3にコンテンツをアップロードします。

# `frontend/`ディレクトリ
$ aws s3 sync build s3://{YOUR_BUCKET_NAME}

2. Route53などの設定

Route53にドメインを設定して、ACMで証明書を発行しておきます。
必要なのは、Route53のホストゾーンと、CloudFrontに設定したいドメインに対する証明書です。
詳細はこの記事をご覧ください。

3. CDKをデプロイ

デプロイするために、パラメータファイルをコピーします。

# `infrastructure/`ディレクトリ
$ cp lib/params.example.ts lib/params.ts

パラメータに設定を反映していきます。

infrastructure/lib/params.ts
import {ICloudFrontOaiStack} from './CloudFrontOaiStack';
import {ILambdaEdgeStack} from './LambdaEdgeStack';
import {
  Environment
} from 'aws-cdk-lib';

- const newlyGenerateS3BucketBaseName: string = "newly-generate-s3-bucket-base-name"
+ const newlyGenerateS3BucketBaseName: string = "YOUR_BUCKET_NAME"
- const accountId: string = "00001111222"
+ const accountId: string = "YOUR_AWS_aacount"
- const domain: string = "your.domain.com"
+ const domain: string = "YOUR_DOMAIN_COM"
const subDomain: string = `app.${domain}`
const subDomain: string = `app.${domain}`

export const paramsCloudFrontOaiStack: ICloudFrontOaiStack = {
  s3: {
    bucketName: newlyGenerateS3BucketBaseName,
  },
  cloudfront: {
-   certificate: `arn:aws:acm:us-east-1:${accountId}:certificate/{unique-id}`,
+   certificate: "YOUR_ACM_ARN",
    route53DomainName: domain,
    route53RecordName: subDomain
  },
  next: {
    // fronteond/pages/_document.tsxのnonceと一致させる
    nonce: "aGVsbG93b3JsZAo="
  },
  environment: "prod",
  lambdaEdgeStackId: "example-lambda-edge"
}

Lambda@EdgeがJavaScriptで実装されているStackをデプロイします。
以下のコマンドでデプロイできます。

# `infrastructure/`ディレクトリ
$ cdk deploy example-cloudfront-oai-js

このコマンド1つでus-east-1ap-northeast-1の2リージョンにデプロイされます。
そのため、2回デプロイ承認が求められます。

CloudFrontのデプロイには20分ほど時間がかかる場合があります。

もしも以下のようなエラーが出た場合には、

 => ERROR [internal] load metadata for public.ecr.aws/sam/build-nodejs16.x:latest                                                                                                                                1.8s
 => [auth] aws:: sam/build-nodejs16.x:pull token for public.ecr.aws                                                                                                                                              0.0s
------
 > [internal] load metadata for public.ecr.aws/sam/build-nodejs16.x:latest:
------
failed to solve with frontend dockerfile.v0: failed to create LLB definition: unexpected status code [manifests latest]: 403 Forbidden

以下のコマンドで、ログインするとデプロイができるようになります。

$ aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws

4. OAIをS3バケットに設定

S3のバケットポリシーにCloudFrontのOriginAccessIdentityを受け入れる設定を追加します。
(ここは手動で行います。)
今回新規作成したCloudFrontの設定から、「オリジン」→「編集」を押下します。

その編集画面で「はい、バケットポリシーを自動で更新します」を選択して、
「変更を保存」を押下します。

Next.jsのコンテンツを置いたS3のバケットポリシーが、
以下のように更新されていることを確認します。


挙動の確認

CloudFrontの動作確認

実際に今回作成したサイトにアクセスして、ちゃんと動作していればOKです。
ちなみに、サンプルには/exampleのページが作成されているので、
https://{your.domain.com}/exampleにアクセスして、
ちゃんとページが表示されるか確認してください。

セキュリティの確認

このサイトにてコンテンツ配信時のセキュリティ項目の確認をすることができます。

今回作成したドメインの入力してチェックを開始すると5分ほどで結果が出力されます。
スコアはA+にすることができました。

テストスコアの各項目の詳細に関しては、MDNのサイトを確認してください。

おわりに

CDKを使ってNext.jsをS3+CloudFrontの構成にデプロイしました。
この構成を利用する方の参考になれば嬉しいです。

参考

GitHubで編集を提案

Discussion

ログインするとコメントできます