😸

CF OACでLambda関数URLを実行する際のハッシュ計算をService Workerで行いNext.jsをLambda関数にデプロイ

2024/10/04に公開

はじめに

おはようございます、加藤です。この記事では、Next.jsをLambda関数、関数URLにデプロイしCloudFront経由でアクセスする際に対処が必要なコンテンツハッシュの計算をクライアントサイドで実現する方法をお伝えします。
Lambda関数URLはNONEAWS_IAMという2つのアクセスコントロールを持ちます。NONEはパブリック公開でAWS_IAMはIAM署名されたリクエストがlambda:InvokeFunctionUrlを許可されている場合のみ受け付けます。どちらの場合でもCloudFrontを経由してアクセスすることが出来ますが、関数URLが漏洩した際にCloudFrontをバイパスされてしまいます。
CloudFrontの主目的であるキャッシュ用途では、ユーザー側の操作によってバイパスされることを許容できるかもしれません。ただし、Lambda関数には適用できないWeb Application FirewallをCloudFrontに提供し経由することで適用することが目的である場合はバイパスされることは許容できません。
AWS_IAMの場合はCloudFront Origin Access Controlを使用することで、リクエストに対してIAM署名を行うことができます。ただし、HTTPのPUTPOSTメソッドでリクエストを行う場合はペイロードのSHA256ハッシュを計算しx-amz-content-sha256ヘッダーに付与する必要があります。https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-lambda.html
これを実現するためにはLambda@Edgeまたはクライアントで対処する必要があります。

Lambda@Edgeはus-east-1リージョンにデプロイしたLambda関数のレプリケーションが各リージョンにレプリケーションされ実行されます。これは私の観測範囲で要求されがちな、「日本リージョンにデータ操作および保存を限定したい」を満たすことが出来ません。(日本からのリクエストは原則日本リージョンのCloudFrontへアクセスし、日本リージョンのLambda関数が実行されるが保証はできないと理解しています。)

なので、残された手段はクライアントで対処することです。多くの場合ではペイロードからハッシュを計算しヘッダーに付与することは容易です。ですが、今回はNext.js(App Router)でServer Actionsを使えることを検証したいです。
解決手段に悩んでいたところ、同僚のMaxMEllon氏に「Service Worker使えばいけるはず!」と教えてもらい、実践したところうまく動作したので共有します。

注意事項

構築

コード全体はこちらで公開しています。一部記事とファイル名などが異なる箇所があります。

https://github.com/intercept6/nextjs-with-lambda-web-adapter

構成図

構成図

フロントエンド

最終的なツリー構造
frontend
├── app
│   ├── favicon.ico
│   ├── globals.css
│   ├── layout.tsx
│   ├── form.tsx
│   ├── page.tsx
├── Dockerfile
├── next.config.mjs
├── next-env.d.ts
├── node_modules
├── package.json
├── package-lock.json
├── postcss.config.mjs
├── public
│   ├── register.js
│   └── sw.js
├── README.md
├── tailwind.config.ts
└── tsconfig.json

プロジェクトを生成

作業ディレクトリを作成し、npm createでNext.jsのコードを生成します。その後、作成されたディレクトリに移動し依存関係をインストールします。

npm create next-app@14.2.13
✔ What is your project named? … frontend
✔ 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 to use `src/` directory? … No
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … No

cd frontend
npm clean-install

デフォルトデザインの削除

  • app/fontsディレクトリを削除します
  • globals.cssから上から3行の@tailwind *以外を削除します
  • app/layout.tsxを下記のように変更します。
app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja">
      <body>
        {children}
      </body>
    </html>
  );
}

todoを登録する仕組みを作る

Server Actionsが動いていることを確認する為にtodoを登録できるデモアプリを作成します。

  • app/form.tsxの作成
  • app/page.tsxの変更

登録完了後にinputをリセットしたいのでform部分はClient Componentで作成します。

app/form.tsx
"use client";

import { useRef } from "react";

type Props = {
  action: (formData: FormData) => Promise<void>;
};

export function Form({ action }: Props) {
  const ref = useRef<HTMLFormElement>(null);

  return (
    <form
      ref={ref}
      action={async (formData) => {
        await action(formData);
        ref.current?.reset();
      }}
      className="max-w-md mx-auto flex flex-col space-y-4"
    >
      <label className="flex flex-col">
        <span className="mb-1 text-gray-700">タイトル:</span>
        <input
          type="text"
          name="title"
          className="border border-gray-300 p-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
      </label>
      <label className="flex flex-col">
        <span className="mb-1 text-gray-700">説明:</span>
        <input
          type="text"
          name="description"
          className="border border-gray-300 p-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
      </label>
      <button
        type="submit"
        className="bg-blue-500 text-white p-2 rounded hover:bg-blue-600 transition duration-300 ease-in-out"
      >
        送信
      </button>
    </form>
  );
}

登録されたtodoを一覧表示します。簡単のため、データベースはSetで代替しています。

app/page.tsx
import { revalidatePath } from "next/cache";
import { v7 as uuid } from "uuid";
import { Form } from "./form";

export const dynamic = "force-dynamic";

type Todo = {
  id: string;
  title: string;
  description: string;
  status: "active";
  createdAt: Date;
};

const dummyDB = new Set<Todo>();

async function handleSubmit(formData: FormData) {
  "use server";

  const title = formData.get("title") as string;
  const description = formData.get("description") as string;
  const id = uuid();

  dummyDB.add({
    id,
    title,
    description,
    status: "active",
    createdAt: new Date(),
  });

  revalidatePath("/todos");
}

export default async function Home() {
  const result = Array.from(dummyDB);
  return (
    <div className="max-w-4xl mx-auto p-4">
      <table className="w-full border-collapse mb-8 rounded-lg overflow-hidden">
        <thead>
          <tr className="bg-blue-500 text-white">
            <th className="p-3 text-center">ID</th>
            <th className="p-3 text-center">タイトル</th>
            <th className="p-3 text-center">説明</th>
            <th className="p-3 text-center">状態</th>
            <th className="p-3 text-center">作成日</th>
          </tr>
        </thead>
        <tbody className="[&>*:nth-child(odd)]:bg-gray-100 [&>*:nth-child(even)]:bg-white">
          {result.map((todo) => (
            <tr key={todo.id}>
              <td className="p-3 border-t border-gray-200">{todo.id}</td>
              <td className="p-3 border-t border-gray-200">{todo.title}</td>
              <td className="p-3 border-t border-gray-200">
                {todo.description}
              </td>
              <td className="p-3 border-t border-gray-200">{todo.status}</td>
              <td className="p-3 border-t border-gray-200">
                {todo.createdAt.toISOString()}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      <Form action={handleSubmit} />
    </div>
  );
}

ここまで作成できたら、npm run devで開発者モードでNext.jsが立ち上がりtodoが登録できることを確認してください。
登録先は単なるSetなのでコードを変更しリロードが走ると消えます。

Service Workerの作成

リクエストをService Workerを経由しContent Hashをヘッダーに付与します。Service Workerを作成し、これをapp/layout.tsxscriptタグを記述し読み込ませます。
今回はReactからService Workerに干渉する必要が無いため雑にこのように設定しています。

  • public/register.jsの作成
  • public/sw.jsの作成
  • app/layout.tsxの変更

Service Workerの実体を登録するコードを書きます。

public/register.js
if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/sw.js", { updateViaCache: "none" });
}

PUTおよびPOSTリクエストが通過する場合に、ペイロードのSHA256ハッシュを計算しx-amz-content-sha256ヘッダーに付与します。
対象をServer Actionsだけに絞る場合はヘッダーにnext-actionが存在するかで判断すると良さそうです。

public/sw.js
self.addEventListener("fetch", async (event) => {
  if (event.request.message === "PUT" || event.request.method === "POST") {
    event.respondWith(
      (async function () {
        const originalRequest = event.request;
        const body = await originalRequest.clone().text();
        const hash = await calculateSHA256(body);

        const modifiedRequest = new Request(originalRequest, {
          headers: new Headers(originalRequest.headers),
        });
        modifiedRequest.headers.set("x-amz-content-sha256", hash);

        return fetch(modifiedRequest);
      })()
    );
  }
});

self.addEventListener("install", () => {
  console.log("installed");
  self.skipWaiting();
});

self.addEventListener("activate", () => {
  console.log("activated");
  clients.claim();
});

async function calculateSHA256(message) {
  const msgBuffer = new TextEncoder().encode(message);
  const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  const hashHex = hashArray
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
  return hashHex;
}

register.jsを読み込みます。

app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "Next.js with Lambda Web Adapter Example",
  description:
    "Next.jsをLambda Web Adapterを使ってLambda関数で実行するサンプルアプリケーション",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja">
      <head>
        <script src="/register.js" defer />
      </head>
      <body>{children}</body>
    </html>
  );
}

ここまで作成できたら、npm run devで開発者モードでNext.jsが立ち上がりtodoが登録できる、そのリクエストがService Workerによってx-amz-content-sha256ヘッダーが付与されていることを確認してください。
Chromeの場合はのDev Toolsを立ち上げ、NetworkタブでリクエストがService Workerを経由していることを確認できます。

ネットワーク

ビルド

  • next.config.mjsの変更
  • Dockerfileの作成

スタンドアロンで出力したいのでoutput: "standalone"を設定します。
Next.jsはデフォルトではCSRF攻撃を防ぐために同一オリジン以外をブロックします。リクエストがCloudFrontを経由すると不一致を起こすのでexperimental.serverActions.allowedOrigins: ["*.cloudfront.net"]を設定します。今回は検証の為なので極端にゆるく設定しています。https://nextjs.org/docs/app/api-reference/next-config-js/serverActions

ヘッダーはこちらを参考に設定しています。https://nextjs.org/docs/app/building-your-application/configuring/progressive-web-apps#8-securing-your-application

next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "standalone",
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          {
            key: "X-Content-Type-Options",
            value: "nosniff",
          },
          {
            key: "X-Frame-Options",
            value: "DENY",
          },
          {
            key: "Referrer-Policy",
            value: "strict-origin-when-cross-origin",
          },
        ],
      },
      {
        source: "/sw.js",
        headers: [
          {
            key: "Content-Type",
            value: "application/javascript; charset=utf-8",
          },
          {
            key: "Cache-Control",
            value: "no-cache, no-store, must-revalidate",
          },
          {
            key: "Content-Security-Policy",
            value: "default-src 'self'; script-src 'self'",
          },
        ],
      },
    ];
  },
  experimental: {
    serverActions: {
      allowedOrigins: ["*.cloudfront.net"],
    },
  },
};

export default nextConfig;

スタンドアロンビルドのNext.jsをビルドと起動する定義を作成します。
Lambda Web Adapterを使用するのでCOPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.8.4 /lambda-adapter /opt/extensions/lambda-adapterENV AWS_LWA_PORT=3000を定義する必要があります。

Dockerfile
# Install dependencies only when needed
FROM node:18-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# Rebuild the source code only when needed
FROM node:18-alpine AS builder
WORKDIR /app
COPY . .
COPY --from=deps /app/node_modules ./node_modules
RUN npm run build

# Production image, copy all the files and run next
FROM node:18-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

ENV AWS_LWA_PORT=3000

COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.8.4 /lambda-adapter /opt/extensions/lambda-adapter

COPY --from=builder /app/next.config.mjs ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./

CMD ["node", "server.js"]

docker build -t ${YOUR_TAGNAME} .でコンテナイメージを作成し、docker run -p 3000 ${YOUR_TAGNAME}でコンテナを立ち上げブラウザでアクセスでき、todoが登録できることを確認してください。

ここまで動作を確認できればフロントエンドの構築は完了です。

AWS

プロジェクトを作成

再び作業ディレクトリに戻り、awsディレクトリを作成し移動してCDKのコードを生成します。

mkdir aws
cd aws
npx cdk init app --language=typescript

リソースを作成

lib/aws-stack.tsのコンストラクタ内にリソースを定義していきます。

Lambda関数の作成

フロントエンドのディレクトリを参照し、Lambda関数を定義します。検証なのでECRリポジトリを明示的に作成せず、CDKが自動で生成してくれるリポジトリを使います。
関数URLをAWS_IAM認証で作成します。

const frontendFunction = new cdk.aws_lambda.DockerImageFunction(
  this,
  "FrontendFunction",
  {
    code: cdk.aws_lambda.DockerImageCode.fromImageAsset("../frontend"),
    architecture: cdk.aws_lambda.Architecture.ARM_64,
    memorySize: 2048,
    timeout: cdk.Duration.minutes(5),
  }
);
const functionUrl = frontendFunction.addFunctionUrl({
  authType: cdk.aws_lambda.FunctionUrlAuthType.AWS_IAM,
});

CloudFront Distributionの作成

Distributionを作成し、関数URLをオリジンに設定します。
デフォルトではHTTPメソッドの許可がGETHEADのみ、ヘッダーが通過しないので設定します。
今回の検証ではキャッシュは不要なので無しに設定します。

const distribution = new cdk.aws_cloudfront.Distribution(
  this,
  "Distribution",
  {
    defaultBehavior: {
      origin: new cdk.aws_cloudfront_origins.FunctionUrlOrigin(functionUrl),
      allowedMethods: cdk.aws_cloudfront.AllowedMethods.ALLOW_ALL,
      cachePolicy: cdk.aws_cloudfront.CachePolicy.CACHING_DISABLED,
      originRequestPolicy:
        cdk.aws_cloudfront.OriginRequestPolicy
          .ALL_VIEWER_EXCEPT_HOST_HEADER,
    },
  }
);
new cdk.CfnOutput(this, "DistributionURL", {
  value: `https://${distribution.domainName}`,
});

CloudFron Origin Access Controlの作成

OACを作成しLambda関数URLの実行を許可します。L2コンストラクトが存在しないのL1を使い定義しています。
下記でwatany氏がプルリクエストを作成してくれています、圧倒的感謝。

https://github.com/aws/aws-cdk/pull/31339

const cfnOriginAccessControl =
  new cdk.aws_cloudfront.CfnOriginAccessControl(
    this,
    "OriginAccessControl",
    {
      originAccessControlConfig: {
        name: "Origin Access Control for Lambda Functions URL",
        originAccessControlOriginType: "lambda",
        signingBehavior: "always",
        signingProtocol: "sigv4",
      },
    }
  );

const cfnDistribution = distribution.node
  .defaultChild as cdk.aws_cloudfront.CfnDistribution;
cfnDistribution.addPropertyOverride(
  "DistributionConfig.Origins.0.OriginAccessControlId",
  cfnOriginAccessControl.attrId
);

frontendFunction.addPermission("CloudFrontLambdaIntegration", {
  principal: new cdk.aws_iam.ServicePrincipal("cloudfront.amazonaws.com"),
  action: "lambda:InvokeFunctionUrl",
  sourceArn: `arn:aws:cloudfront::${
    cdk.Stack.of(this).account
  }:distribution/${distribution.distributionId}`,
});

コード全体

作成されたコード全体は下記です。

lib/aws-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";

export class Common extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const frontendFunction = new cdk.aws_lambda.DockerImageFunction(
      this,
      "FrontendFunction",
      {
        code: cdk.aws_lambda.DockerImageCode.fromImageAsset("../frontend"),
        architecture: cdk.aws_lambda.Architecture.ARM_64,
        memorySize: 2048,
        timeout: cdk.Duration.minutes(5),
      }
    );

    const functionUrl = frontendFunction.addFunctionUrl({
      authType: cdk.aws_lambda.FunctionUrlAuthType.AWS_IAM,
    });

    const distribution = new cdk.aws_cloudfront.Distribution(
      this,
      "Distribution",
      {
        defaultBehavior: {
          origin: new cdk.aws_cloudfront_origins.FunctionUrlOrigin(functionUrl),
          allowedMethods: cdk.aws_cloudfront.AllowedMethods.ALLOW_ALL,
          cachePolicy: cdk.aws_cloudfront.CachePolicy.CACHING_DISABLED,
          originRequestPolicy:
            cdk.aws_cloudfront.OriginRequestPolicy
              .ALL_VIEWER_EXCEPT_HOST_HEADER,
        },
      }
    );
    new cdk.CfnOutput(this, "DistributionDnsName", {
      value: `https://${distribution.domainName}`,
    });

    const cfnOriginAccessControl =
      new cdk.aws_cloudfront.CfnOriginAccessControl(
        this,
        "OriginAccessControl",
        {
          originAccessControlConfig: {
            name: "Origin Access Control for Lambda Functions URL",
            originAccessControlOriginType: "lambda",
            signingBehavior: "always",
            signingProtocol: "sigv4",
          },
        }
      );

    const cfnDistribution = distribution.node
      .defaultChild as cdk.aws_cloudfront.CfnDistribution;
    cfnDistribution.addPropertyOverride(
      "DistributionConfig.Origins.0.OriginAccessControlId",
      cfnOriginAccessControl.attrId
    );

    frontendFunction.addPermission("CloudFrontLambdaIntegration", {
      principal: new cdk.aws_iam.ServicePrincipal("cloudfront.amazonaws.com"),
      action: "lambda:InvokeFunctionUrl",
      sourceArn: `arn:aws:cloudfront::${
        cdk.Stack.of(this).account
      }:distribution/${distribution.distributionId}`,
    });
  }
}

あとは、npm run cdk deployすればデプロイが行われます。データの永続化をダミーとしてLambda上でやっているので、複数起動した場合やLambda関数が終了するとデータが消えます。

おまけ DrizzleでAurora Data APIを使った永続化

こちらのブランチでDrizzleを使いAurora PostgreSQLに対してData APIを使うことで、非VPC Lambda関数からAuroraにアクセスしているサンプルもあるのでよければこちらも試してみてください。https://github.com/intercept6/nextjs-with-lambda-web-adapter/tree/withdb

あとがき

HTTPでLambda関数にアクセスする為には従来からAPI GatewayやApplication Load Balancerが存在しました。なので、Lambda関数URLとCloudFrontの連携は全く新しいものを生み出したわけではありません。
しかし、API Gatewayはデフォルト29秒のタイムアウト制限があり今までは問題が無かったが後述のニーズによってこの様子が変わってきました。ALBは作成するにはVPCが必須なので管理が必要なリソースが一気に増えてしまいコンパクトにLambda関数を使いたい要件にはマッチしませんでした。
また、Lambda関数の強みとして唯一レスポンスストリーミングが行えます。これは生成AIのレスポンスを逐次レスポンスするといった最近特に多いニーズにマッチしています。

このブログではさらっと流していますが、Lambda Web Adapterによって既存のWeb Application Frameworkによって構築されたソフトウェア、今回の場合でいうとNext.jsに対して最低限の変更を行うだけでLambda関数に簡単にデプロイすることが可能になっています。

どれぐらいのユースケースにマッチするか私にはまだ見えていませんが、機会があれば活用したいアーキテクチャパターンがまたひとつ増えました。以上です。

東急URBAN HACKS

Discussion