🏴‍☠️

【NextAuth.js】Next.jsのPages RouterをCloudFrontとAPI Gatewayで稼働させる

2023/11/14に公開

はじめに / やりたいこと

Next.js(Pages Router)でNextAuth.jsを使ってログインができるアプリを、
CloudFrontとAPI Gateway上へデプロイします。
NextAuth.jsを使ってログインするアプリは、以下の拙作の記事のものを想定しています。

https://zenn.dev/gsy0911/articles/0e271401b8e5c2

Pages Routerでは、動的処理を提供するAPI Routesは/api以下で動くようになっています。
/api以外のページや、SSRをしない場合は、静的コンテンツとして出力できます。
そこで静的コンテンツをCloudFrontで配信して、動的部分の/api以下をAPI Gateway + Lambdaを使って稼働させることを目指します。

参考にしたのは以下の記事です。
参考記事中では、動的部分をAppRunnerを使っていますがやりたいことは同じです。

https://dev.classmethod.jp/articles/nextjs-static-cache/

対象読者

  • Next.jsのPages Routerを使ってフロントエンドを構築している
  • VercelやNetfilyではなくAWSのCloudFrontなどへデプロイを検討している
  • NextAuth.jsや、/pages/api以下の領域を利用している

構成

以下が今回作成するサービスの構成図です。

上述のように、動的処理をする/api/*以下はAPI Gatewayに処理を流して、
それ以外の静的なコンテンツはS3からCloudFrontを経由して配信するようにしています。

デプロイ環境

  • macOS: 14.1
  • Next.js: 13.4
  • Node.js: 18.16
  • AWS CDK: 2.105.0
  • Mantine: 6.0.20

コード

コードは以下のリポジトリにおいてあります。
本記事では紹介できない箇所もあるので、デプロイする際にはぜひご覧ください。

https://github.com/gsy0911/zenn-nextjs-authjs-cognito/tree/v4.0

フロントエンドの準備

動的処理と静的コンテンツをビルドできるように、各ファイルを以下のようにしておきます。

package.json
  "scripts": {
    "build:standalone": "ENV=production OUTPUT=standalone next build",
    "build:export": "ENV=production OUTPUT=export next build",
  },
next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  trailingSlash: true,
  output: process.env.OUTPUT
}

module.exports = nextConfig

こうしておくことで、以下のコマンドで動的処理に対応したビルドを実行できます。

$ npm run build:standalone

また、以下のコマンドで静的コンテンツを出力するビルドを実行できます。

$ npm run build:export

動的処理のためのDocker作成

まずはAPI Gateway + Lambdaで動かすためにDockerfileを作成します。
以下のサイトを参考したDockerfileを元に、Lambdaで動くようにしています。

https://qiita.com/Nozomuts/items/b3a4fd57d0413d5d3437

https://aws.amazon.com/jp/builders-flash/202301/lambda-web-adapter/?awsf.filter-name=*all

# ref: https://qiita.com/Nozomuts/items/b3a4fd57d0413d5d3437
FROM node:18 AS builder

WORKDIR /app

COPY ./frontend/zenn/package.json ./
COPY ./frontend/zenn/package-lock.json ./
RUN npm ci
COPY ./frontend/zenn ./
RUN npm run build:standalone

FROM node:18-slim AS runner
# これを挟むことでLambdaで動かせるようになる。
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.7.1 /lambda-adapter /opt/extensions/lambda-adapter
# この値はDockerなら必須のためここで固定
ENV PORT=3000
WORKDIR /app

# public と .next/static は nextjs の standalone を使う場合に含まれないため、コピーする必要がある
# https://nextjs.org/docs/advanced-features/output-file-tracing#automatically-copying-traced-files
# builderから必要なファイルだけコピーする
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/.next/standalone ./

# `next start` の代わりに `node server.js` を使用
# https://nextjs.org/docs/advanced-features/output-file-tracing#automatically-copying-traced-files
CMD ["node", "server.js"]

このファイルを元に、Dockerイメージを作成してECRへpushしておきます。

静的コンテンツのビルドとデプロイ

静的コンテンツはビルド後、以下のコマンドでS3へアップロードします。

# ディレクトリは/frontend/zennで実行する
$ aws s3 sync out s3://{S3_BUCKET_NAME}

これで前準備は完了です。

インフラの構築

今回もCDKを使ってインフラを構築します。

1つのStackでCloudFrontとAPI Gatewayのデプロイを実施します。
API Gatewayの作成と、CloudFrontのオリジンへの登録を同時にして見通しをよくするためです。

ポイントとなる箇所を抜き出して説明します。

Frontend.tsコード全体を見たい場合はこちら。

そのほかのコードを見たい場合は、リポジトリを参照してください。

Frontend.ts
import {
  aws_certificatemanager as acm,
  aws_cloudfront,
  aws_cloudfront_origins,
  aws_ecr,
  aws_iam,
  aws_lambda,
  aws_route53,
  aws_route53_targets,
  aws_s3,
  aws_ssm,
  Duration,
  Stack,
  StackProps,
} from "aws-cdk-lib";
import { Construct } from "constructs";
import { HttpLambdaIntegration } from "@aws-cdk/aws-apigatewayv2-integrations-alpha";
import {
  DomainName,
  EndpointType,
  HttpApi,
} from "@aws-cdk/aws-apigatewayv2-alpha";
import {
  IFrontendEnvironment,
  prefix,
  ssmParameterEdgeName,
} from "./constants";

export interface IFrontend {
  ecr: {
    repositoryArn: `arn:aws:ecr:ap-northeast-1:${string}:repository/${string}`;
  };
  apigw: {
    certificate: `arn:aws:acm:ap-northeast-1:${string}:certificate/${string}`;
    route53DomainName: string;
    route53RecordName: string;
  };
  lambda: {
    environment: { [key: string]: string } & IFrontendEnvironment;
  };
  s3: {
    bucketName: string;
  };
  cloudfront: {
    certificate: `arn:aws:acm:us-east-1:${string}:certificate/${string}`;
    route53DomainName: string;
    route53RecordName: string;
    cachePolicyIdForApigw: `${string}-${string}-${string}-${string}-${string}`
  };
}

export class Frontend extends Stack {
  constructor(
    scope: Construct,
    id: string,
    params: IFrontend,
    props: StackProps,
  ) {
    super(scope, id, props);

    const ecrRepositoryFrontend = aws_ecr.Repository.fromRepositoryArn(
      this,
      "frontend",
      params.ecr.repositoryArn,
    );
    const role = new aws_iam.Role(this, "lambdaRole", {
      roleName: `${prefix}-lambda-frontend-role`,
      assumedBy: new aws_iam.ServicePrincipal("lambda.amazonaws.com"),
      managedPolicies: [
        aws_iam.ManagedPolicy.fromManagedPolicyArn(
          this,
          "lambdaRoleCwFullAccess",
          "arn:aws:iam::aws:policy/CloudWatchFullAccessV2",
        ),
        aws_iam.ManagedPolicy.fromManagedPolicyArn(
          this,
          "lambdaRoleCognitoPowerAccess",
          "arn:aws:iam::aws:policy/AmazonCognitoPowerUser",
        ),
      ],
    });

    // Next.js standaloneを動かすLambdaの定義
    const handler = new aws_lambda.DockerImageFunction(this, "Handler", {
      functionName: `${prefix}-frontend-endpoint`,
      code: aws_lambda.DockerImageCode.fromEcr(ecrRepositoryFrontend),
      memorySize: 1024,
      timeout: Duration.seconds(30),
      environment: params.lambda.environment,
      architecture: aws_lambda.Architecture.ARM_64,
      retryAttempts: 0,
      role,
    });

    // カスタムドメインの設定
    const apigwCustomDomainName = new DomainName(this, "CustomDomain", {
      certificate: acm.Certificate.fromCertificateArn(
        this,
        "Certificate",
        params.apigw.certificate,
      ),
      domainName: params.apigw.route53RecordName,
      endpointType: EndpointType.REGIONAL,
    });
    // Route 53 for api-gw
    const hostedZone = aws_route53.HostedZone.fromLookup(
      this,
      "apigw-hosted-zone",
      {
        domainName: params.apigw.route53DomainName,
      },
    );
    new aws_route53.ARecord(this, "SampleARecord", {
      zone: hostedZone,
      recordName: params.apigw.route53RecordName,
      target: aws_route53.RecordTarget.fromAlias(
        new aws_route53_targets.ApiGatewayv2DomainProperties(
          apigwCustomDomainName.regionalDomainName,
          apigwCustomDomainName.regionalHostedZoneId,
        ),
      ),
    });

    // Amazon API Gateway HTTP APIの定義
    new HttpApi(this, "Api", {
      apiName: `${prefix}-frontend`,
      defaultIntegration: new HttpLambdaIntegration("Integration", handler),
      defaultDomainMapping: {
        domainName: apigwCustomDomainName,
      },
      disableExecuteApiEndpoint: true,
    });

    const s3Bucket = aws_s3.Bucket.fromBucketName(
      this,
      "sourceS3",
      params.s3.bucketName,
    );

    const s3Origin = new aws_cloudfront_origins.S3Origin(s3Bucket);
    const nextCompatibilitySsm = ssmParameterEdgeName({
      cfType: "origin-request",
      id: "nextCompatibility",
    });
    const nextCompatibilityParam =
      aws_ssm.StringParameter.fromStringParameterAttributes(
        this,
        "nextCompatibilitySsmParam",
        {
          parameterName: nextCompatibilitySsm,
        },
      ).stringValue;
    const nextCompatibilityVersion = aws_lambda.Version.fromVersionArn(
      this,
      "nextCompatibilityVersion",
      nextCompatibilityParam,
    );

    const distribution = new aws_cloudfront.Distribution(
      this,
      "frontend-distribution",
      {
        defaultBehavior: {
          origin: s3Origin,
          edgeLambdas: [
            {
              functionVersion: nextCompatibilityVersion,
              eventType: aws_cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST,
            },
          ],
          viewerProtocolPolicy:
            aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        },
        additionalBehaviors: {
          "/api/*": {
            origin: new aws_cloudfront_origins.HttpOrigin(
              params.apigw.route53RecordName,
            ),
            allowedMethods: aws_cloudfront.AllowedMethods.ALLOW_ALL,
            viewerProtocolPolicy:
              aws_cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
            // ポイント:特定のHeaderのみ許可しないと、エラーになる
            // see: https://oji-cloud.net/2020/12/07/post-5752/
            originRequestPolicy: new aws_cloudfront.OriginRequestPolicy(
              this,
              "apigw-orp",
              {
                originRequestPolicyName: `${prefix}-apigw-orp`,
                headerBehavior:
                  aws_cloudfront.OriginRequestHeaderBehavior.allowList(
                    "Accept",
                    "Accept-Language",
                  ),
                cookieBehavior:
                  aws_cloudfront.OriginRequestCookieBehavior.all(),
                queryStringBehavior:
                  aws_cloudfront.OriginRequestQueryStringBehavior.all(),
              },
            ),
            // ポイント:キャッシュを削除しないと、異なる端末からも単一のユーザーでログインしてしまう
            cachePolicy: aws_cloudfront.CachePolicy.fromCachePolicyId(
              this,
              "apigw-cp",
              params.cloudfront.cachePolicyIdForApigw,
            ),
          },
        },
        defaultRootObject: "index.html",
        certificate: acm.Certificate.fromCertificateArn(
          this,
          "certificate-cloudfront",
          params.cloudfront.certificate,
        ),
        domainNames: [params.cloudfront.route53RecordName],
        sslSupportMethod: aws_cloudfront.SSLMethod.SNI,
        minimumProtocolVersion:
          aws_cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
      },
    );
    // Route 53 for cloudfront
    const cloudfrontHostedZone = aws_route53.HostedZone.fromLookup(
      this,
      "cloudfront-hosted-zone",
      {
        domainName: params.cloudfront.route53DomainName,
      },
    );
    new aws_route53.ARecord(this, "cloudfront-a-record", {
      zone: cloudfrontHostedZone,
      recordName: params.cloudfront.route53RecordName,
      target: aws_route53.RecordTarget.fromAlias(
        new aws_route53_targets.CloudFrontTarget(distribution),
      ),
    });
  }
}

standaloneモードでNext.jsを動かすLambdaをデプロイします。
LambdaのアーキテクチャをARMにしているのは、M1 Macを使っているためです。
加えてCognitoへのアクセスが必要なので権限を付与しています。
(実際に利用する際にはCognitoへの権限は絞った方が安全です)。

Frontend.ts(一部)
(前略)
    const ecrRepositoryFrontend = aws_ecr.Repository.fromRepositoryArn(
      this,
      "frontend",
      params.ecr.repositoryArn,
    );
    const role = new aws_iam.Role(this, "lambdaRole", {
      roleName: `${prefix}-lambda-frontend-role`,
      assumedBy: new aws_iam.ServicePrincipal("lambda.amazonaws.com"),
      managedPolicies: [
        aws_iam.ManagedPolicy.fromManagedPolicyArn(
          this,
          "lambdaRoleCwFullAccess",
          "arn:aws:iam::aws:policy/CloudWatchFullAccessV2",
        ),
        aws_iam.ManagedPolicy.fromManagedPolicyArn(
          this,
          "lambdaRoleCognitoPowerAccess",
          "arn:aws:iam::aws:policy/AmazonCognitoPowerUser",
        ),
      ],
    });

    // Next.js standaloneを動かすLambdaの定義
    const handler = new aws_lambda.DockerImageFunction(this, "Handler", {
      functionName: `${prefix}-frontend-endpoint`,
      code: aws_lambda.DockerImageCode.fromEcr(ecrRepositoryFrontend),
      memorySize: 1024,
      timeout: Duration.seconds(30),
      environment: params.lambda.environment,
      architecture: aws_lambda.Architecture.ARM_64,
      retryAttempts: 0,
      role,
    });
(後略)

次に、CloudFrontの設定です。
additionalBehaviorsに設定している内容が大事なのでそれ以外のコードは省略しています。

originRequestPolicyで特定のHeaderのみを通すようにして、Cookieは全て通すようにします。
この設定をしないと、API Gatewayへのリクエストが不正なものと見なされてしまいます。
加えて、AWSがあらかじめ用意しているキャッシュを全てしないcachePolicyを付与します。
デフォルトの設定だとAPIがキャッシュされてしまい、異なる端末からでも単一のユーザーでログインしてしまうためです。

Frontend.ts(一部)
(前略)
    const distribution = new aws_cloudfront.Distribution(
      this,
      "frontend-distribution",
      {
        defaultBehavior: {
          (省略)
        },
        additionalBehaviors: {
          "/api/*": {
            origin: new aws_cloudfront_origins.HttpOrigin(
              params.apigw.route53RecordName,
            ),
            allowedMethods: aws_cloudfront.AllowedMethods.ALLOW_ALL,
            viewerProtocolPolicy:
              aws_cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
            // ポイント:特定のHeaderのみ許可しないと、エラーになる
            // see: https://oji-cloud.net/2020/12/07/post-5752/
            originRequestPolicy: new aws_cloudfront.OriginRequestPolicy(
              this,
              "apigw-orp",
              {
                originRequestPolicyName: `${prefix}-apigw-orp`,
                headerBehavior:
                  aws_cloudfront.OriginRequestHeaderBehavior.allowList(
                    "Accept",
                    "Accept-Language",
                  ),
                cookieBehavior:
                  aws_cloudfront.OriginRequestCookieBehavior.all(),
                queryStringBehavior:
                  aws_cloudfront.OriginRequestQueryStringBehavior.all(),
              },
            ),
            // ポイント:キャッシュを削除しないと、異なる端末からも単一のユーザーでログインしてしまう
            cachePolicy: aws_cloudfront.CachePolicy.fromCachePolicyId(
              this,
              "apigw-cp",
              params.cloudfront.cachePolicyIdForApigw,
            ),
          },
        },
        (省略)
      },
    );
  }
}

この内容をデプロイすればNext.jsのPages Routerを
CloudFront + API Gatewayで動かすことができます!

おわりに

実際に動かせるまで、意外と苦労しました。ただ、originRequestPolicycachePolicyさえ設定すればあっさり動きました。

App Routerが推奨されていることもあり、Pages Routerでのこの仕組みは今後使えるかわかりませんが、誰かの参考になれば幸いです。

GitHubで編集を提案

Discussion