Next.js を AWS Lambda の関数 URL を使って公開する (v13.4 分追記)
(v13.4?から起動方法が変わったので、末尾に簡単に追記しました。以下の内容はそれ以前のものです)
ふとしです。
Next.js 入門中です。
Next.js を AWS Lambda に乗せたくなりましたが、ぐぐると「まず Amplify?Serverless? をインストールします」などと述べられ、ウッ、となってしまったので、そういうのを使わないメモを残しておきます。
- AWS Lambda で動くようにする
- AWS Lambda に適した設定・構成にする
の 2 点がポイントです。
どのような App で試したか
Next.js ということで外部からのデータ取得から SSR する構成にしました。GitHub の Rest API にタダ乗りして、Home では最新何件かのリポジトリを一覧し、リンク先で詳細を表示するという構成です。
/index.tsx
import axios from "axios";
import type { GetServerSideProps, NextPage } from "next";
import Link from "next/link";
const Home: NextPage<{ repos: RepoSummary[] }> = ({ repos }) => {
return (
<ul>
{repos.map(({ owner: { login }, name, full_name, description }) => (
<li key={full_name}>
<h1>
<Link href={`/${login}/${name}`}>
<a>{name}</a>
</Link>
</h1>
<p>{description}</p>
</li>
))}
</ul>
);
};
export default Home;
type RepoSummary = {
name: string;
full_name: string;
owner: {
login: string;
};
description: string;
};
export const getServerSideProps: GetServerSideProps = async () => {
const { data: repos } = await axios.get<RepoSummary[]>(
"https://api.github.com/repositories"
);
return {
props: {
repos,
},
};
};
/[owner]/[name].tsx
import axios from "axios";
import { GetServerSideProps, NextPage } from "next";
import Link from "next/link";
const Repo: NextPage<{ detail: RepoDetail }> = ({
detail: {
name,
owner: { login },
description,
html_url,
},
}) => {
return (
<div>
<h1>{name}</h1>
<h2>{login}</h2>
<p>{description}</p>
<p>
<a href={html_url}>link</a>
</p>
<p>
<Link href="/">
<a>home</a>
</Link>
</p>
</div>
);
};
export default Repo;
type RepoDetail = {
name: string;
owner: {
login: string;
};
html_url: string;
description: string;
};
export const getServerSideProps: GetServerSideProps = async ({
params: { owner, repo } = {},
}) => {
const { data: detail } = await axios.get<RepoDetail>(
`https://api.github.com/repos/${owner}/${repo}`
);
return {
props: {
detail,
},
};
};
AWS Lambda で動くようにする
AWS Lambda の関数として使用するということで next start
による起動は使えません。
ハンドラーを取得する
公式に AWS Lambda 用の構成は特に記載されていませんが、Advanced Features: Custom Server | Next.js をよむと Node.js でよくみる req
と res
を受けとるハンドラーを作る方法がわかります。
const app = next({ dev, hostname, port })
const handle = app.getRequestHandler()
// 略
await handle(req, res, parsedUrl)
これを使えば next start
以外の方法でも Next.js を好きなように起動できます。
AWS Lambda のイベントを req に変換し res を AWS Lambda 用の返り値に変換する
AWS Lambda の関数 URL を使った場合、関数が受けとる引数は http.IncomingMessage
のようなリクエストではありませんし、関数から返さなくてはならない返り値は http.ServerRespose
ではありません。
手作業で変換しても良いのですが Express や Nuxt.js を AWS Lambda で起動する時にほぼポン付け使えるすばらしい変換用ライブラリ serverless-http
があるので、それを使います。
Node.js でよくみる req
と res
を受けとるハンドラーを引数として渡すと、AWS Lambda の関数としてそのまま使える関数にラップして返してくれる関数を提供しています。
serverless-http とともに AWS Lambda 用のファイルをつくる
zip してそのまま上げても動くように index.js
を作成して以下のとおり。
const next = require("next");
const serverless = require("serverless-http");
const nextHandler = next({}).getRequestHandler();
module.exports.handler = serverless(nextHandler);
AWS Lambda にデプロイできるようにする
AWS Lambda には zip 解凍後サイズが 250MB までという制限があります。今回のものだと、開発中そのままの状態だとすでに 210MB 超あり、まぁそのままでもいけますが将来的に危険です。
そこで zip 用のコマンドに yarn install --production
を含め、開発時やビルド時にのみ必要なライブラリを排除して体を軽くできるようにしておきます。
これで大体 110MB になってしばらくは安心できるサイズになりました。あとは経過観察しつつ開発を勧めましょう。
これを zip にし、AWS Lambda にアップロードして関数 URL を有効にすると、ひとまず Next.js が SSR しつつ画面遷移も問題なくできつつで起動できました。うれしいですね。
AWS Lambda に適した設定・構成にする
さて、無事 Next.js は動きましたが、ログを見ると以下のようなファイルすべてを AWS Lambda 関数が配給しているのがわかります。
単なるファイル配給に関数呼び出し料金がかかっているということで、これは嬉しくありません。しかもこれは同時アクセスが一気に生じるので、同時アクセスクォータに簡単にひっかかる可能性まであります。やばい。
CDN にファイルを逃がす
そこでファイル類は CDN などに逃します。設定方法はドキュメントに従います。
const isProd = process.env.NODE_ENV === "production";
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: false,
assetPrefix: isProd ? "https://lambda-web-assets.o296.com" : "",
};
module.exports = nextConfig;
next.config.js
の内容は起動時評価なので NODE_ENV
はビルド時ではなく AWS Lambda の環境変数に設定しなくてはならないことに注意しましょう。
getServerSideProps を getInitialProps に
これで無駄な呼び出しが減ったかと思いきや、ページ遷移を起こしたログを見てみると、GitHub API へのアクセスではなく、AWS Lambda にある Next.js へのアクセスが発生していることがわかります。
これは従来の getInitialProps
がサーバーサイドとクライアントサイドで動かなければならなかったのを getServerSideProps
では常にサーバーサイドで動くようにすることによりうんぬんかんぬんという善意から生まれた挙動です。
しかし 1 回いくらの AWS Lambda 呼び出し回数減らそうとしているときには特に嬉しくありません。そこで従来の getInitialProps
に変更し、SSR 以降は AWS Lambda の出番がなくなるようにします。
Home.getInitialProps = async () => {
const { data: repos } = await axios.get<RepoSummary[]>(
"https://api.github.com/repositories"
);
return {
repos,
};
};
Repo.getInitialProps = async ({ query: { owner, repo } }) => {
const { data: detail } = await axios.get<RepoDetail>(
`https://api.github.com/repos/${owner}/${repo}`
);
return {
detail,
};
};
これで SSR 以後の AWS Lambda 呼び出しはなくなりました。
財布にやさしそうですね。
おわりに
というわけで AWS Lambda 関数 URL に Next.js に乗せて、思いつくままに調整をしてみました。調整が妥当かどうかはよくわかりませんが、思いついた調整はできたので満足です。
v13.4 に関する追記
v13.4 では yarn install --production
をしても 400MB 超のファイルが含まれるようになり普通にはデプロイできなくなりましたが、standalone
ビルドで無理なくファイルサイズを抑えられるようになりました。
また、AWS Lambda 起動用の index.js
の書き方も変わりましたので、簡単にですがデプロイして動作するだけの追記をしておきます。
next.config.js
standalone
モードにします。
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
assetPrefix:
process.env.NODE_ENV === "production"
? "アセット用のどこか"
: "",
};
module.exports = nextConfig;
index.js
next({}).getRequestHandler()
で得られるハンドラーは standalone
モードではそのままでは様々なエラーが出るようになりました。
今回からは NextServer
を使うようになったみたいです。(みたいです、というのはドキュメントからは記載を発見できなかったが、ぐぐったところどうやらそのようになったということが推測されたからです)
// Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './server.edge' is not defined by "exports" 対策
process.env.__NEXT_PRIVATE_PREBUNDLED_REACT = "experimental";
const path = require("path");
const NextServer = require("next/dist/server/next-server").default;
const serverless = require("serverless-http");
// .next 以下に生成されるコンフィグを記録したファイル。
const nextConfig = require("./.next/required-server-files.json").config;
const nextServer = new NextServer({
hostname: "localhost",
port: 3000,
dir: path.join(__dirname),
dev: false,
conf: nextConfig,
});
const nextHandler = nextServer.getRequestHandler();
module.exports.handler = serverless(nextHandler);
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './server.edge' is not defined by "exports"
対策
構成により出るみたいです。たぶん fetch
を使うと出る。
v13.4.19 でまだ未解決っぽいんですが issue は close されており よくわかりませんでした。
issues のコメント通りダイレクトにソースコードを書き換えるか process.env
に直接 "experimental"
を指定することでエラーは出なくなりました。(AWS Lambda は _
始まりの環境変数を設定させてくれない)
server.edge
は react-dom v18.3 から含まれるようでしたが v18.3 を使おうとすると別のエラーが出てよくわかりませんでした。
デプロイ用のパッケージを用意する
standalone
ビルドでは .next/standalone
以下に生成されたファイルのみで動作するようになりますが、AWS Lambda での起動にはそれに加えて serverless-http
が必要になります。
メインプロジェクトの方の package.json
に含めても無視されるので、serverless-http
を含んだデプロイ用パッケージの中に .next/standalone
をコピーして起動するという手段を取ります。
以下のように構成したプロジェクトを zip 化して AWS Lambda にデプロイします。
/
├── .next
│ └── standalone <- .next/standalone からディレクトリ全体をコピー
│ ├── index.js <- AWS Lambda 用ハンドラーを作成して入れる
│ ...
└── package.json <- yarn add serverless-http
追記のおわりに
NextServer
での起動にいきつくまで苦労しましたが standalone
ビルドは無駄が省かれる分ファイルサイズも小さく良さそうでした。
Discussion