maybe daily dev notes

私の開発日誌

Next.jsをLambda + API Gatewayでサーバーレス化する (standaloneモード)

これは AWS LambdaとServerless Advent Calendar 2022 の記事です。

Next.jsをホスティングする手段の一つとして、Standaloneモードで動かす方法があります。 コンテナ1個で動かせるため非常にお手軽な選択肢で、GCPのCloud RunやAWSのApp Runnerなどで動かす例を見ることも多いです。

この記事では、AWS Lambda + API Gatewayのサーバーレス鉄板構成でNext.js standaloneモードを公開する方法を紹介します。巷ではあまり見かけない構成だったので、新しい選択肢となることに期待したいです。この構成は趣味運用でできる限り費用を抑えたい方にもオススメです。

方法

具体的な方法はCloud RunなどでNext.jsを動かす従来の方法とほぼ同じです。Lambdaで動かすための面倒な工夫やコードの変更はそれほど必要ありません。もしすでにStandaloneのコンテナがあるのでしたら、それに数行の変更を加えるだけです。

まずは nextjs.config.js に以下の行を追加します:

const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
+   output: 'standalone',
};

次にDockerfileを見てみましょう。基本的にはCloud RunApp Runner用のDockerfileがそのまま使えます。記述に見覚えがある方も多いんではないでしょうか。

# Multi-stage buildでNext.jsをビルド
FROM node:16 AS builder
WORKDIR /build
COPY package*.json ./
RUN npm ci
COPY . ./
RUN npm run build

# ベースイメージの変更
FROM amazon/aws-lambda-nodejs:16

# Lambda Web Adapterのインストール
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.5.0 /lambda-adapter /opt/extensions/lambda-adapter
ENV PORT=3000

COPY --from=builder /build/next.config.js ./
COPY --from=builder /build/public ./public
COPY --from=builder /build/.next/static ./.next/static
COPY --from=builder /build/.next/standalone ./

# ベースイメージ変更に伴う調整
ENTRYPOINT ["node"]
CMD ["server.js"]

何点かポイントがあるので説明します:

1. Lambda Web Adapterのインストール

今回は AWS Lambda Web Adapterを利用しています。こちらはコンテナのWebアプリをほぼそのままLambdaで動かせるようにするツールです。

github.com

細かい話は省きますが、Lambda Web AdapterはLambda Extensionとして動作するので、 /opt/extensions に必要なファイルをコピーすることでインストールされます。 そのためには、以下の1行を追加すればよいです:

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

基本的にはこれで良いのですが、追加で環境変数経由でいくつかの設定をしています。今回は以下だけで十分だと思いますが、一応他にも設定項目はあるので適宜ご確認ください。

  • PORT: Next.jsサーバーが動作するポート番号

また、Lambda Web Adapterの仕組みについては後日記事を公開予定です。乞うご期待! (追記: 公開しました) これを使えば大抵のコンテナWebアプリは同じようにLambdaで動かせるので、とてもオススメです。App Runnerを使わずに済む場合も増えるんじゃないかと思っています。

2. ベースイメージの変更

※ これはオプショナルな変更です。コールドスタート時間を軽減させたい方は試してみてください。

従来の方法では、ベースイメージとして node など公式のNode.jsイメージを使うことが多いです。 一方Lambdaの場合は、 amazon/aws-lambda-nodejs のように他の多くのLambdaユーザーもよく使うコンテナイメージを利用するのがオススメです。

FROM amazon/aws-lambda-nodejs:16

理由: これは私個人の推測に基づく情報にはなりますが、Lambdaがコンテナイメージをキャッシュする仕組みの都合上、利用者の多いイメージをベースにするほうがコールドスタート時の性能が良くなるようでした。この動画この動画を見ると、Lambdaのイメージキャッシュはベースイメージはユーザー間でcommonなことが仮定されています。このため、他のユーザーはあまり使わないイメージをベースにするとキャッシュヒット率が下がり、コールドスタート時間が長引くのではと思います。

amazon/aws-lambda-nodejs にも nodenpm など基本的なコマンドはインストールされているので、多くの場合そこまで抜本的な変更にはならないはずです。

また、この変更に伴って ENTRYPOINTCMD も変更しています。本来は CMD ["node", "sever.js"] だけで済ませたいところですが、ベースイメージの ENTRYPOINT を上書きしたいのでこのようにしています。

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

とはいえこれ以外のベースイメージでも問題なく動作はする ので、面倒な方はそのままでも良いでしょう。かなり雑な体感値ですが、コールドスタート時間は amazon/aws-lambda-nodejs だと1秒程度、node だと最大3秒程度になることがある感じでした。3秒でも十分短いと思う人もいると思います。

以上2点の変更だけで、AWS Lambda上で動くNext.js standaloneイメージを作成することができました。

デプロイする

後はデプロイするだけですが、デプロイはAWS CDKを使うのが手っ取り早いです。 以下の簡単なコードで、上記のLambda関数とそれにブラウザからアクセスするためのAmazon API Gateway HTTP APIをデプロイできます。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Duration } from 'aws-cdk-lib';
import { HttpLambdaIntegration } from '@aws-cdk/aws-apigatewayv2-integrations-alpha';
import { DockerImageCode, DockerImageFunction } from 'aws-cdk-lib/aws-lambda';
import { HttpApi } from '@aws-cdk/aws-apigatewayv2-alpha';
import { Platform } from 'aws-cdk-lib/aws-ecr-assets';

export class Frontend extends cdk.Stack {
  readonly endpoint: string;

  constructor(scope: Construct, id: string, props: cdk.StackProps) {
    super(scope, id);

    // Next.js standaloneを動かすLambdaの定義
    const handler = new DockerImageFunction(this, 'Handler', {
      code: DockerImageCode.fromImageAsset('./frontend', {
        platform: Platform.LINUX_AMD64,
      }),
      memorySize: 256,
      timeout: Duration.seconds(30),
    });

    // Amazon API Gateway HTTP APIの定義
    new HttpApi(this, 'Api', {
      apiName: 'Frontend',
      defaultIntegration: new HttpLambdaIntegration('Integration', handler),
    });
  }
}

諸々含めたサンプルコードはこちらに公開しています。Next.js 13をCDKでLambdaにデプロイする例です。簡単なのでぜひお試しください。

github.com

この構成のメリット

この構成のメリットは、非常に手軽にNext.jsをAWS上でホストできることです。サーバーレス構成なのでもちろんVPCは必要ないですし、リクエストが来ていない時に料金は発生しません。

また、Lambda + API Gatewayの構成は成熟していて、開発体験も良いです。CDKのhotswap機能を使えばLambdaのデプロイは10秒程度で終わります。WAFやCloudFrontとの統合、カスタムドメインの設定なども容易です。

Next.jsをStandaloneでAWSにデプロイする選択肢としては非常に良いのではと考えています。

まとめ

Next.js standaloneをAWS Lambda上で動かす方法を紹介しました。そのまま動くレポジトリもあるので、ぜひお試しください!

追記 1) Lambda Web Adapterの性能については、簡易的なものですが、こちらの記事でまとめました。

aws.amazon.com

追記 2) 実際にこの構成で開発していたのですが、一つ不都合があったので解決策を共有します。

Next.jsは、SSRや最適化後の画像など諸々の中間生成物を、実行時に 作業ディレクトリ/.next/ にキャッシュするようです。 この際、AWS Lambdaでは /tmp 以外のディレクトリが読み込み専用のため、書き込みに失敗してしまいます。 これでもユーザー視点では正常にページは返されますが、キャッシュが効かずパフォーマンスが落ちる可能性があります。

キャッシュを効かせるためには、.next ディレクトリを /tmp にsymlinkすれば良いです。 (今のところ、Next.jsはキャッシュディレクトリの場所を指定できません Issue #10111。 (/tmpにNext.jsの実行ファイルをすべてコピーし、そこで動かすという選択肢もあります。しかしコピーによりコールドスタート時間が悪化する懸念があるため、symlinkの方が良いでしょう。)

これは以下の変更で実現可能です:

まず、Dockerfile内でsymlinkを作成します。

#(中略)
COPY --from=builder /build/.next/standalone ./

# これを追加
RUN ln -s /tmp/cache ./.next/cache

次にLambda実行時に/tmp内のディレクトリを再作成するよう、エントリーポイントとなるシェルスクリプトを作成します。これでなぜ動くのか正直よくわからないのですが、Lambda Web Adapter公式でも利用されているワークアラウンドとなります (参考)。

#!/bin/bash -x

[ ! -d '/tmp/cache' ] && mkdir -p /tmp/cache

exec node server.js
# run.sh として保存

最後にDockerfileからrun.shを起動するように変更します。

#(中略)
COPY --from=builder /build/run.sh ./run.sh

ENTRYPOINT ["sh"]
CMD ["run.sh"]

やや面倒になってきますね。 しかしこれはやらなくても、ユーザー視点では問題なく動作はします。「雑に動かす」という当初の意図に沿うのであれば、そのままでも問題ないでしょう。 いずれにせよ /tmp はLambdaのライフサイクルに沿った短命かつ局所的なデータなので、キャッシュとしても効きは悪いかもしれません。次の追記3にも書いていますが、CDNでのキャッシュに頼りましょう。

追記 3) Lambda Web Adapter + LambdaでホストしたNext.jsアプリの前段にCDNであるCloudFrontを配置しキャッシュを効かせるというアイデアが、こちらの記事で実現されています。

aws.amazon.com

これなら静的なコンテンツがキャッシュされるため、より大きな規模のワークロードでも実用的なレベルに達すると思われます。ぜひお試しください。

追記 4) CloudFrontを前段に配置し、またtRPCやSSRを含む、より実用的なサンプルアプリを公開しました。こちらもぜひご覧ください。

github.com