💸

Next.js を AWS Lambda の関数 URL を使って公開する (v13.4 分追記)

2022/06/04に公開

(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 でよくみる reqres を受けとるハンドラーを作る方法がわかります。

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 でよくみる reqres を受けとるハンドラーを引数として渡すと、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