Next.jsのstandaloneでのCI/CDについて考えてみる
Next.jsをミニマムかつコンテナでデプロイする方法について考えてみたので残しておきます。ステップバイステップの説明は動画を視聴していただけると幸いです。
とにかくソースを見たいという方は、以下に格納していますので合わせてご確認ください。
はじめに
Next.jsの最適な本番環境へのデプロイには工夫が必要です。やり方はいろいろとありますし、使っているツール郡によっても微妙に気をつける点が違ってたりして試行錯誤が必要だったりします。
以下の条件を今回は満たしたいと思いました:
- コンテナイメージでデプロイしたい
- マルチステージビルドを駆使してミニマムなイメージにしたい
- Next.js自体もなるべくミニマムにしてコンテナイメージに入れたい
- ビルドは1回で、環境依存の値は環境変数で対応したい
ということで調べてると見つけたのがNext.jsの output
の方法の standalone
というもの。
これを使うと node_modules
も含めた本当に最低限の成果物が作られます。最終的には node server.js
を実行するだけでOKになるのです。(npmも不要)
これとコンテナイメージのマルチステージビルドを駆使すればいい感じなる気がしたので挑戦することにしました。最近では個人的にTurborepoを使っているので、Turborepoも組み合わせて実現していきます。
目指すもの以下のような感じになります。
ソリューションの概要
- Turborepo + Next.jsのセットアップ
- Next.jsのstandalone output設定
- Dockerfileのマルチステージビルド
- Docker Composeでの動作確認
- GitHub Actionsでのコンテナイメージ自動デプロイ
- 環境変数の動的設定の検証
Turborepo + Next.jsのセットアップ
動画: https://www.youtube.com/watch?v=3zLi0iNjmf8&t=260s
まずはTurborepoを使ってNext.jsのスターターを作成します。ちょうど良いスターターが用意されているのでこれは非常に簡単です。
これにより、複数のアプリケーションとパッケージを含む新しいTurborepoが作成されます。必要なパッケージのインストールやキャッシュの設定が自動で行われます。
以下のパッケージが最初から構成されています。
apps
- docs
- web
packages
- eslint-config
- typescript-config
- ui
standalone outputの設定
動画: https://www.youtube.com/watch?v=3zLi0iNjmf8&t=566s
Next.jsのnext.config.mjs
ファイルでstandalone outputを設定します。
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone"
};
export default nextConfig;
これにより、 next build
したときに最小限の成果物が生成され、軽量なデプロイが可能になります。
Dockerfileのマルチステージビルド
動画: https://www.youtube.com/watch?v=3zLi0iNjmf8&t=742s
次に、マルチステージビルドを使用してDockerイメージを構築します。これにより、必要最低限のファイルのみを含む軽量なイメージを作成できます。スターターのおかげで最初から docs
と web
アプリという2つがあるのですが、今回は web
アプリをデプロイできるようにしたいと思います。
これもTurborepoでかなりそのまま使えるDockerfileが用意されているので、それをベースに組み立てることができます。(今回は pnpm
を使いたかったので、そのあたりは修正しています)
このDockerfileでは、必要な依存関係をインストールし、プロジェクトをビルドし、最終的なイメージを作っています。
ポイントだけもう少し詳しく見ていきます。
builderでbuildに必要なファイルの最適化
RUN turbo prune web --docker
pnpm
も使っていることからこの turbo prune
を実施することで、 web
アプリとして必要なファイルだけに最適化できます。
installerではビルドのキャッシュを最大限活かす
# First install the dependencies (as they change less often)
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=builder /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN pnpm install --frozen-lockfile
この手順が意外と大事で、依存ライブラリを独立してまずはインストールするようにします。コンテナイメージのビルドをするとき、関係するファイルに変更が加わるとキャッシュが使えなくなるので、まずは変更の少ないパッケージファイルだけを持ってきて pnpm install
を実施するのがポイントです。
最後にrunnerに必要なファイルだけ持ってくる
COPY --from=installer /app/apps/web/next.config.mjs .
COPY --from=installer /app/apps/web/package.json .
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
CMD node apps/web/server.js
installer
のステージで役者が揃っているので、あとは runner
ステージで必要なものだけ持ってくることになります。基本的には .next/standalone
で動作するのですが、 .next/static
だったりNext.jsで public
ディレクトリに入れているものをCDNに置かない人は、別途で自分でコピーしてくる必要があります。
Docker Composeでの動作確認
動画: https://www.youtube.com/watch?v=3zLi0iNjmf8&t=1228s
次に、docker-compose.yml
を使って、ビルドしたDockerイメージが正しく動作するか検証します。
version: '3'
services:
web:
build:
context: .
dockerfile: ./apps/web/Dockerfile
ports:
- "3100:3100"
environment:
- PORT=3100
docker compose up
コマンドでローカルで動作確認を行います。ブラウザでhttp://localhost:3100
にアクセスし、正常に動作することを確認します。
GitHub Actionsでの自動デプロイ
動画: https://www.youtube.com/watch?v=3zLi0iNjmf8&t=1314s
コンテナイメージがビルドされて、動作確認もとれたのでこれをCIに乗せます。GitHub Actionsを使ってDockerイメージをGitHub Container Registryにプッシュしてみましょう。(レジストリはお好みで)
GitHubに参考にできるワークフローがあるのでそれを使います。
このワークフローでは、コードのプッシュやPR生成時、リリースが作られたタイミングに自動でDockerイメージをビルドし、レジストリにプッシュします。
環境変数の動的設定
動画: https://www.youtube.com/watch?v=3zLi0iNjmf8&t=1824s
Next.jsでは、ビルド時に NEXT_PUBLIC_
から始まる環境変数がソースに埋め込まれる仕様があります(ブラウザ用の環境変数)。
そのため、単純に考えるとデプロイ環境ごとにビルドを行わないといけません。ただしビルド時間がそれなりにあることや、検証環境と本番環境で同じイメージを使いたいと思ったときにこれはかなり微妙になります。
これを回避するために、Dockerfileとスクリプトを使って、環境変数を動的に設定する方法を紹介します。
現象のおさらい
動画: https://www.youtube.com/watch?v=3zLi0iNjmf8&t=1912s
ブラウザ用の環境変数をNext.jsで使ってみます。
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
// ...
<div>
<p className={styles.description}>
The API URL is: <code>{apiUrl}</code>
</p>
</div>
では動的に値が設定ができるか検証してみます:
version: '3'
services:
web:
build:
context: .
dockerfile: ./apps/web/Dockerfile
ports:
- "3100:3100"
environment:
- PORT=3100
- NEXT_PUBLIC_API_URL=http://api:9999/api
docker compose up
しても http://api:9999/api
が表示されませんこれだと設定できない。
なぜなら NEXT_PUBLIC_
環境変数は next build
するときに置換されるから。
これだとイメージを一つ作って、環境ごとに設定を環境変数で変える、という運用ができません。そのためにdev, staging, productionでそれぞれコンテナイメージをビルドしないといけないのはかなりつらいです。
回避策の構築
動画: https://www.youtube.com/watch?v=3zLi0iNjmf8&t=2177s
まず、Dockerfileで環境変数のプレースホルダー(起動時に置換できるような文字列)を設定し、置換用のスクリプトを起動するようにします。
FROM node:18-alpine AS alpine
<略>
FROM base AS builder
WORKDIR /app
COPY . .
RUN turbo prune web --docker
FROM base AS installer
# Set placeholder environment variables in Dockerfile
ARG NEXT_PUBLIC_API_URL=PLACEHOLDER_NEXT_PUBLIC_API_URL
WORKDIR /app
COPY /app/out/json/ .
COPY /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN pnpm install --frozen-lockfile
COPY /app/out/full/ .
RUN turbo run build --filter=web...
FROM alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
COPY /app/apps/web/next.config.mjs .
COPY /app/apps/web/package.json .
COPY /app/apps/web/.next/standalone ./
COPY /app/apps/web/.next/static ./apps/web/.next/static
COPY /app/apps/web/public ./apps/web/public
COPY /app/apps/web/scripts ./apps/web/scripts
CMD sh apps/web/scripts/start.sh
次に、環境変数を置換するスクリプト(replace-vars.sh)を作成します。このスクリプトはDockerイメージ起動時に呼び出され、プレースホルダーを実際の環境変数に置き換えます。
このスクリプトは以下の記事をベースにしています
#!/bin/sh
# Define a list of mandatory environment variables to check
MANDATORY_VARS="NEXT_PUBLIC_API_URL"
# Define a list of optional environment variables (no check needed)
OPTIONAL_VARS=""
# Check if each mandatory variable is set
for VAR in $MANDATORY_VARS; do
if [ -z "$(eval echo \$$VAR)" ]; then
echo "$VAR is not set. Please set it and rerun the script."
exit 1
fi
done
# Combine mandatory and optional variables for replacement
ALL_VARS="$MANDATORY_VARS $OPTIONAL_VARS"
# Find and replace PLACEHOLDER values with real values
find apps/web/public apps/web/.next -type f -name "*" |
while read file; do
for VAR in $ALL_VARS; do
echo "replacing PLACEHOLDER_$VAR with $(eval echo \$$VAR)"
if [ ! -z "$(eval echo \$$VAR)" ]; then
sed -i "s|PLACEHOLDER_$VAR|$(eval echo \$$VAR)|g" "$file"
fi
done
done
最後に、起動スクリプト(start.sh)を作成し、Dockerfileでそれを呼び出します。
#!/bin/sh
# Replace runtime env vars and start next server
sh apps/web/scripts/replace-vars.sh &&
node apps/web/server.js
COPY /app/apps/web/scripts ./apps/web/scripts
CMD sh apps/web/scripts/start.sh
これで、stagingやproduction、あるいはdev環境でフロント側の値も動的に変えることができます。
おわりに
本記事では、Next.jsプロジェクトの効率的なCI/CDパイプラインの構築方法を紹介しました。細かく説明するととんでもない文量になるので、かなり割愛してます。実際にどのようなことをしているか、については動画を見ていただけたらと思います。
Turborepoを使ったプロジェクトのセットアップから、Dockerによる軽量なイメージの構築、環境変数の動的設定、GitHub Actionsを使った自動デプロイまで(正確にはコンテナイメージのpushまで)実現しています。この方法を使えば、本番環境でのデプロイがよりシンプルかつ効率的になるかと思います。
Discussion