はじめに
こんにちは。elephantnodeと申します。
普段は都内で社内情シス+Web系エンジニアとして業務しています。
勤め先でNext.jsを使ったWebサイトをAWS LambdaとLambda Web Adapterを使ってサーバレスに動かしてみたので、構築した手順についてご紹介します。
AWSへのデプロイにはAWS SAMを利用します。
またデプロイしたあとにCloudFrontでOAC(オリジンアクセスコントロール)とWAFを利用して、Lambdaを保護しながら公開する方法もやってみましたのでそちらもご紹介します。
AWS Lambda Web Adapterとは
LambdaでWEBアプリを実行するためのオープンソースソフトウェアツールです。
AWS Lambda Web Adapterは、「Webフレームワークで作ったアプリ」を入れたコンテナを、そのままAWS Lambdaで動かせるようにするLambda拡張ツールです。
対応しているWebフレームワークはExpress.js、Next.js、Flask、ASP.NET、Laravelなど、コンテナで動かせるものはほとんど動かせるようです。
もともとAWS Lambdaはコンテナで動いているのですが、入出力インターフェースが専用設計なので、WEBアプリケーションのコンテナは通常動作しません。
しかしAWS Lambda Web Adapterを利用すると、Lambdaへ送信されたイベントをHttpリクエストに変換してウェブアプリケーションへ渡し、その反対にウェブアプリケーションのレスポンスをLabmbdaへ返すように動作します。LambdaをWEBサーバーにしてしまうのですね。
こちらがGithubのリポジトリです。
サードパーティのハックかと思ってましたが、本家のアプリ!!
構築環境について
手元のマシンはmacですが、使用したSAMやNext.jsは下記のような構成でした。
パッケージ | バージョン |
---|---|
AWS SAM CLI | 1.100.0 |
node.js(ローカル) | v20.11.1 |
node.js(コンテナ) | v22.11.0-slim |
docker | version 26.1.4, build 5650f9 |
Next.js | 15.0.2 |
AWS SAM CLIのインストールや使い方はこちらの方の記事がおすすめです。
Next.jsのインストールについてはNext.jsのDocsが良いと思います。
手順
Next.jsアプリケーションを作成
まずはローカル環境にNext.jsのアプリケーションを構築します。
npx create-next-app@latest
Need to install the following packages:
create-next-app@15.0.2
Ok to proceed? (y) y
✔ What is your project named? … sample-next-app-sam
✔ 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
<<省略>>
Success! Created sample-next-app-sam at *****/sample-next-app-sam
ほとんどそのままEnterしてます。
❯ tree -L 2
.
└── sample-next-app-sam
├── README.md
├── next-env.d.ts
├── next.config.ts
├── node_modules
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
├── src
├── tailwind.config.ts
└── tsconfig.json
4 directories, 8 files
プロジェクトが作成されます。
Next.js にstandalone buildの設定
AWS lambda
はデプロイパッケージのサイズに250MBのクオータ制限があるため、最小限の内容でビルドしたいので、プロジェクトフォルダのnext.config.ts
にoutput:standalone
を設定します。
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone" //追加
};
export default nextConfig;
ここでアプリケーションをローカル環境でビルドして、出来上がった.nextフォルダにstandaloneフォルダがあるか確認します。
npm run build
> sample-next-app-sam@0.1.0 build
> next build
▲ Next.js 15.0.2
Creating an optimized production build ...
✓ Compiled successfully
✓ Linting and checking validity of types
✓ Collecting page data
✓ Generating static pages (5/5)
✓ Collecting build traces
✓ Finalizing page optimization
Route (app) Size First Load JS
┌ ○ / 5.46 kB 105 kB
└ ○ /_not-found 897 B 101 kB
+ First Load JS shared by all 99.7 kB
├ chunks/215-f207ea7968f9b6d8.js 45.2 kB
├ chunks/4bd1b696-23516f99b565b560.js 52.6 kB
└ other shared chunks (total) 1.88 kB
○ (Static) prerendered as static content
tree -L 1 -d -a ./.next
./.next
├── cache
├── diagnostics
├── server
├── standalone
├── static
└── types
.nextフォルダにstandaloneフォルダが作成されていますね。
run.shを作成
コンテナ実行時にサーバー内でキャッシュディレクトリを作成しつつ、サーバーを起動させるシェルスクリプトをプロジェクトのディレクトリに配置します。run.shの名前でファイルを作成します。
#!/bin/bash -x
[ ! -d '/tmp/cache' ] && mkdir -p /tmp/cache
exec node server.js
Dockerfile作成
Next.jsアプリケーションをビルドして動かすコンテナ用のDockerfileをプロジェクトディレクトリに作成します。
使用するコンテナイメージはAWSのECR Public Galleryから選びます。nodeの22.11.0-slimをイメージにしてみました。
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
最初のコンテナでビルドして、必要なファイルのみ実行コンテナに移して、最後に先程のrun.shを実行してサーバーを動かします。
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.8.4 /lambda-adapter
ここでLambda Web Adapterをインストールしています。たったこれだけで導入できるので、非常にシンプルですね。
template.yaml作成
AWS SAM CLIでデプロイするためのtemplate.yaml
をプロジェクトフォルダに作成します。
Lambda Web AdapterのGithubリポジトリにはNext.js用のサンプルがありますが、API Gatewayを使うパターン
と、コンテナを使わないzip方式
、関数URL(Function URLs)を利用してAWS Lambda Response Streaming
が使えるパターンと三種類の実装方式が紹介されています。
今回はOACに対応するようにLambda 関数URL(Function URLs)を利用する方式にします。
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
nextjs lambda streaming response
lambda streaming response SAM template using nextjs
Globals:
Function:
Timeout: 300
Resources:
StreamingNextjsFunction:
Type: AWS::Serverless::Function
Properties:
MemorySize: 512
PackageType: Image
Architectures:
- x86_64
Environment:
Variables:
AWS_LWA_INVOKE_MODE: response_stream
FunctionUrlConfig:
AuthType: NONE
InvokeMode: RESPONSE_STREAM
Metadata:
DockerTag: v1
DockerContext: ./
Dockerfile: Dockerfile
Outputs:
StreamingNextjsFunctionOutput:
Description: "Streaming Nextjs Function ARN"
Value: !GetAtt StreamingNextjsFunction.Arn
StreamingNextjsFunctionUrlOutput:
Description: "nextjs streaming response function url"
Value: !GetAtt StreamingNextjsFunctionUrl.FunctionUrl
FunctionUrlConfig
でInvokeMode: RESPONSE_STREAM
を指定し、環境変数のAWS_LWA_INVOKE_MODE
にもresponse_stream
が設定してあります。
OACは後ほど実装するので、最初はFunctionUrlConfigのAuthType
はNONE
にしています。ここは後ほど変更します。
Response Streamingについては、AWSのブログの記事で詳しく解説されていますが、この記事内では割愛させていただきます。
プロジェクトのファイル構成
ここまでのプロジェクトのトップ階層にあるファイル構成です。
tree -L 1 -a
.
├── .eslintrc.json
├── .git
├── .gitignore
├── .next
├── Dockerfile
├── README.md
├── next-env.d.ts
├── next.config.ts
├── node_modules
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
├── run.sh
├── src
├── tailwind.config.ts
└── tsconfig.json
AWS SAMでビルドとデプロイ
AWS SAM CLIでビルドします。
sam build --use-container
<<省略>>
Build Succeeded
Built Artifacts : .aws-sam/build
Built Template : .aws-sam/build/template.yaml
Commands you can use next
=========================
[*] Validate SAM template: sam validate
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {{stack-name}} --watch
[*] Deploy: sam deploy --guided
ビルド中に、Next.jsのビルドを行っている部分も表示されます。無事にビルドできたので、AWSへデプロイします。初回のみ--guided
をオプションにして質問形式で設定していきます。
sam deploy --guided
Configuring SAM deploy
======================
Looking for config file [samconfig.toml] : Not found
Setting default arguments for 'sam deploy'
=========================================
Stack Name [sam-app]: StreamingNextjs
AWS Region [ap-northeast-1]:
#Shows you resources changes to be deployed and require a 'Y' to initiate deploy
Confirm changes before deploy [y/N]: y
#SAM needs permission to be able to create roles to connect to the resources in your template
Allow SAM CLI IAM role creation [Y/n]: y
#Preserves the state of previously provisioned resources when an operation fails
Disable rollback [y/N]: n
StreamingNextjsFunction Function Url has no authentication. Is this okay? [y/N]: y
Save arguments to configuration file [Y/n]: y
SAM configuration file [samconfig.toml]:
SAM configuration environment [default]:
Looking for resources needed for deployment:
Managed S3 bucket: aws-sam-cli-managed-default-samclisourcebucket-1h7wayipdynpc
A different default S3 bucket can be set in samconfig.toml and auto resolution of buckets turned off by setting resolve_s3=False
Image repositories: Not found.
#Managed repositories will be deleted when their functions are removed from the template and deployed
Create managed ECR repositories for all functions? [Y/n]: y
Saved arguments to config file
Running 'sam deploy' for future deployments will use the parameters saved above.
The above parameters can be changed by modifying samconfig.toml
Learn more about samconfig.toml syntax at
https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html
<<省略>>
CloudFormation outputs from deployed stack
-------------------------------------------------------------------------------------------------------------------------------------------------------------
Outputs
-------------------------------------------------------------------------------------------------------------------------------------------------------------
Key StreamingNextjsFunctionOutput
Description Streaming Nextjs Function ARN
Value arn:aws:lambda:ap-northeast-1:*******
Key StreamingNextjsFunctionUrlOutput
Description nextjs streaming response function url
Value https://*******.lambda-url.ap-northeast-1.on.aws/
-------------------------------------------------------------------------------------------------------------------------------------------------------------
Successfully created/updated stack - StreamingNextjs in ap-northeast-1
デプロイが成功すると、Lambda関数のARNと、関数URL(Function URLs)が出力されます。
ARNには関数名が入っていますので、控えておきます。
出力された関数URLにブラウザからアクセスします。
Next.jsのアプリが表示されれば、デプロイ成功です。LambdaがWebサーバーとして機能して、Next.jsのアプリが動いています。
CloudFrontとOACの設定
続いて、デプロイしたLambdaにCloudFrontでOAC(オリジンアクセスコントロール)を設定していきます。
Lambdaの関数URLはデフォルトでPublicになっていますが、ランダムな長めの文字列になっていて、URLが漏れない限りは部外者が実行してしまうケースはなかなか無いと思いますし、Lambda側で処理するイベントを知らないことには実行しても何かが返ってくることは考えにくいことです。
しかし、Lambda Web Adapterを使うと、アプリを表示するたびに実行されます。Webアプリは攻撃される可能性も当然高くなってくるので、DDoS攻撃などがあれば、高額請求が発生する可能性も!
そのため、関数URLはCloudFrontを前において、アクセスできるオリジンを限定(OAC)して、直接実行されないように変更します。
またCloudFrontは構築時に簡易版のWAFも設定できるので、そちらも設定していきます。
こちらの記事を参考にさせていただきました。
CloudFront ディストリビューションの作成
先ほどコピーしたLambda 関数URLからドメイン部分のみ(https://のスキームや最後のスラッシュなどは除く
)を抜き出してOrigin domainに設定します。
その後、少し下のOrigin access controlの右側のCreate new OACをクリックします。
OACの作成画面が表示されますので、名前を入力し、Createを選択します。
作成が終わると構築後に案内するCLIコマンドでLambdaのポリシーをアップデートしなさいとアラートが表示されます。
パブリックで公開するので、WAFも設定します(追加料金)。
本当はアプリケーションごとに攻撃手口への対策が異なるので、AWS WAFでACLを設定したいところですが、取り急ぎ標準のもので。
ここまでで一旦、ディストリビューションを作成します。
画面が切り替わると、Lambda functionのポリシーをアップデートするためのCLI Commandをコピーできるボタンが表示されますので、これをクリックしてクリップボードに保存します。
aws lambda add-permission \
--statement-id "AllowCloudFrontServicePrincipal" \
--action "lambda:InvokeFunctionUrl" \
--principal "cloudfront.amazonaws.com" \
--source-arn "arn:aws:cloudfront::*************" \
--region "ap-northeast-1" \
--function-name <YOUR_FUNCTION_NAME>
こちらがクリップボードにコピーされた内容です。<YOUR_FUNCTION_NAME>
の部分を先程のデプロイした結果のFunction ARNにある、Lambdaの関数名に変更して、コンソールから実行します。
関数名はAWSのWEBコンソールから直接、Lambda関数を探しても良いと思います。
コマンド実行後にLambdaの設定>アクセス権限からポリシーを確認すると、CloudFrontからの実行を有効にするポリシーが追加されています。
この状態になったら、CloudFrontが発行したディストリビューションドメインにブラウザからアクセスしてみます。
CloudFrontのURLでアプリケーションが表示できました。
Lambda FunctionのAWS_IAM認証をONにする
CloudFrontからのアクセスはできましたが、このままだと、Lambdaの関数URL(Function URLs)はまだアクセスできる状態にあるので、AWS SAMのtemplate.yamlを変更して、IAM認証をONにします。
Resources:
StreamingNextjsFunction:
Type: AWS::Serverless::Function
Properties:
MemorySize: 512
PackageType: Image
Architectures:
- x86_64
Environment:
Variables:
AWS_LWA_INVOKE_MODE: response_stream
FunctionUrlConfig:
AuthType: AWS_IAM
InvokeMode: RESPONSE_STREAM
Metadata:
DockerTag: v1
DockerContext: ./
Dockerfile: Dockerfile
AuthTypeをAWS_IAMに設定して、ビルドしてデプロイします。デプロイ完了後にLambdaのコンソールから設定>関数URLの情報を見ると認証タイプがAWS_IAMになります。
アクセス権限のポリシーもCloudFrontからの実行権限のみになっています。
この状態でLambdaの関数URLにブラウザからアクセスすると、"Forbidden"がメッセージとして返却され、アクセスができなくなっています。
無事、OACが設定されて、CloudFrontからのアクセスに集約することができました!
最低限のセキュリティもできたので、Route 53でCloudFrontにカスタムドメインでエイリアスを作ればオリジナルドメインで運用できるサイトが出来上がりです。
なぜLambdaでNext.jsを?
Next.jsを運用するのであれば本家のVercelを使うか、AWSを利用するなら、AWS Amplifyを利用する方法がまずは検討候補になると思います。
今回のプロジェクトで利用する外部のAPIが、接続元IPアドレス制限があったので、IPを付与できる中間のAPIを構築する必要がありました。最初はLambdaにVPCを設定し、Cloudfront + S3 + Lambda
という構成でSPAのお手軽Webアプリケーションを作ることを検討していました。
よくある構成ですね。この構成でもCloudFrontがありますし、API Gatewayがあるので、OACもWAFも実装できます。
以前はVueを使って構築していましたが、
- 標準でtailwind.cssが使える
- SSR(サーバーサイドレンダリング)が使える
- 技術情報が豊富
ということでNext.jsを使ってみたかったのです。
そこでまずはVue.jsを置き換えたアプリを作成したところ、S3を使った構成ではNext.jsは静的ビルドしかできず、APP routerも使えないということがわかり、他の方法を模索したところ、Lambda Web Adapterを知りました。
結果的に最終的な形はこの様になっています。
CloudFrontとLambdaだけ(WAFは自動生成されてますが)です。だいぶシンプルに管理できそうです。
料金について
最後に料金ですが、Lambdaは実行した回数と、実行時間で料金が発生します。無料利用枠もあり、1 か月あたり 100 万件のリクエスト、1 か月あたり 400,000 GB 秒のコンピューティング時間までは無料で使用できます。
仮に100万リクエストで、1アクセスあたり1.2秒程度(1200ms)で512MBのメモリを割り当てたマシンで動かした見積もりが下記でした。
割り当てたメモリ量: 512 MB x 0.0009765625 GB (MB 単位) = 0.5 GB
割り当てられたエフェメラルストレージの量: 512 MB x 0.0009765625 GB (MB 単位) = 0.5 GB
料金の計算
1,000,000 リクエスト x 1,200 ミリ秒 x 0.001 ミリ秒から秒への変換係数 = 1,200,000.00 合計コンピューティング (秒)
0.50 GB x 1,200,000.00 秒 = 600,000.00 合計コンピューティング (GB-s)
600,000.00 GB-s - 400000 無料利用枠 GB-s = 200,000.00 GB-s
Max (200000.00 GB-s, 0 ) = 200,000.00 合計請求対象 GB-s
Tiered price for: 200,000.00 GB-s
200,000 GB-s x 0.0000166667 USD = 3.33 USD
合計階層コスト = 3.3333 USD (1 か月のコンピューティング料金)
1 か月のコンピューティング料金: 3.33 USD
1,000,000 リクエスト - 1000000 無料利用枠のリクエスト = 0 1 か月の請求対象リクエスト
Max (0 1 か月の請求対象リクエスト, 0 ) = 0.00 1 か月の合計請求対象リクエスト
1 か月のリクエスト料金: 0 USD
0.50 GB - 0.5 GB (追加料金なし) = 0.00 関数あたりの GB 請求可能エフェメラルストレージ
エフェメラルストレージの月額料金: 0 USD
Lambda のコスト - 無料利用枠をご利用の場合 (毎月): 3.33 USD
3.33 USD! 本当ですかね・・・
無料枠を使い切っていると、
割り当てたメモリ量: 512 MB x 0.0009765625 GB (MB 単位) = 0.5 GB
割り当てられたエフェメラルストレージの量: 512 MB x 0.0009765625 GB (MB 単位) = 0.5 GB
料金の計算
1,000,000 リクエスト x 1,200 ミリ秒 x 0.001 ミリ秒から秒への変換係数 = 1,200,000.00 合計コンピューティング (秒)
0.50 GB x 1,200,000.00 秒 = 600,000.00 合計コンピューティング (GB-s)
Tiered price for: 600,000.00 GB-s
600,000 GB-s x 0.0000166667 USD = 10.00 USD
合計階層コスト = 10.00 USD (1 か月のコンピューティング料金)
1 か月のコンピューティング料金: 10.00 USD
1,000,000 リクエスト x 0.0000002 USD = 0.20 USD (1 か月のリクエスト料金)
1 か月のリクエスト料金: 0.20 USD
0.50 GB - 0.5 GB (追加料金なし) = 0.00 関数あたりの GB 請求可能エフェメラルストレージ
エフェメラルストレージの月額料金: 0 USD
10.00 USD + 0.20 USD = 10.20 USD
Lambda のコスト (毎月): 10.20 USD
10.20 USDでした。WEBサーバーであれば、国内サービスと比較しても悪くない金額ですね。そもそも使い切るかどうか分かりませんが。
しかし、気になるはLamda HTTPレスポンスストリーミングをつかったリクエストです。
1回あたりに返却されるデータサイズの平均が6MBまでは無料ですが、大きめの画像などが入っていると、課金対象になるようです。
仮に平均が10MBで100万回レスポンスストリーミングが発生するとこうなりました。
10 MB - 6 MB (特定のレスポンスペイロードに使用されていない容量) = 4.00 MB (呼び出しあたりの請求対象となる処理されたバイト数)
Max (4.00000000 MB, 0 ) = 4.00 MB (請求対象)
4.00 MB x 0.0009765625 GB (MB 単位) = 0.00390625 GB (呼び出しあたりの請求対象となる処理されたバイト数)
1,000,000 リクエスト x 0.00390625 GB/呼び出し = 3,906.25 GB (請求対象の合計)
3,906.25 GB - 100 GB/月 (無料利用枠) = 3,806.25 GB (請求対象の合計)
Max (3806.25 GB, 0 ) = 3,806.25 GB (請求対象の合計)
3,806.25 GB x 0.008 USD = 30.45 USD (1 か月あたりに処理されるバイトの料金)
Lambda HTTP レスポンスストリーミングのコスト (毎月): 30.45 USD
Lambda関数における通常のペイロードは最大6MBまでですが、Streamingを有効にすると20MBまでサポートされるようになり、大きな画像などを扱えば、うっかりすると超過してしまいそうです。
Next.js側でレスポンスストリーミングを利用した実装は今のところ予定にはありませんが、将来実装を考える際には十分に注意する必要がありそうです。
追記
ここで紹介した実装方法はRESPONSE_STREAMの使用が前提になっていて、レスポンスは全てストリーミングとして処理されるようです。
template.yamlでInvokeMode: RESPONSE_STREAMをはずして実装したところ、webサーバーとして動作しませんでした。
この実装方法によるペイロードのサイズには十分にご注意ください。
また、ここでは含めていませんが、CloudFrontの料金やWAFなども追加で加算されてきますし、VPCを構築していれば、そちらの料金もかかってきます。
おわりに
いままでバッチ処理や自作API程度にしか使っていなかったAWS LambdaがWebサーバーとして使えるとは驚きでした。
ログモニタリングの課題や、WAFの細かな調整、キャッシュマネジメントなども課題はありますが、まずは小さなプロジェクトで試していきたいと思います。
知見のある方から、お気づきの点などご指摘いただけると幸いです。
ここまでお読みいただき、ありがとうございました。
Next.jsでmicrocmsも使ってみたので、そちらも記事にする予定。
参考記事