CF OACでLambda関数URLを実行する際のハッシュ計算をService Workerで行いNext.jsをLambda関数にデプロイ
はじめに
おはようございます、加藤です。この記事では、Next.jsをLambda関数、関数URLにデプロイしCloudFront経由でアクセスする際に対処が必要なコンテンツハッシュの計算をクライアントサイドで実現する方法をお伝えします。
Lambda関数URLはNONE
、AWS_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のPUT
かPOST
メソッドでリクエストを行う場合はペイロードの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
を下記のように変更します。
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で作成します。
"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
で代替しています。
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.tsx
にscript
タグを記述し読み込ませます。
今回はReactからService Workerに干渉する必要が無いため雑にこのように設定しています。
-
public/register.js
の作成 -
public/sw.js
の作成 -
app/layout.tsx
の変更
Service Workerの実体を登録するコードを書きます。
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js", { updateViaCache: "none" });
}
PUT
およびPOST
リクエストが通過する場合に、ペイロードのSHA256ハッシュを計算しx-amz-content-sha256
ヘッダーに付与します。
対象をServer Actionsだけに絞る場合はヘッダーにnext-action
が存在するかで判断すると良さそうです。
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
を読み込みます。
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
/** @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-adapter
とENV AWS_LWA_PORT=3000
を定義する必要があります。
# 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 /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 /lambda-adapter /opt/extensions/lambda-adapter
COPY /app/next.config.mjs ./
COPY /app/.next/static ./.next/static
COPY /app/public ./public
COPY /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メソッドの許可がGET
とHEAD
のみ、ヘッダーが通過しないので設定します。
今回の検証ではキャッシュは不要なので無しに設定します。
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}`,
});
コード全体
作成されたコード全体は下記です。
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関数に簡単にデプロイすることが可能になっています。
どれぐらいのユースケースにマッチするか私にはまだ見えていませんが、機会があれば活用したいアーキテクチャパターンがまたひとつ増えました。以上です。
Discussion