🛠️

Next.jsのstandaloneでのCI/CDについて考えてみる

2024/06/26に公開

https://youtu.be/3zLi0iNjmf8

Next.jsをミニマムかつコンテナでデプロイする方法について考えてみたので残しておきます。ステップバイステップの説明は動画を視聴していただけると幸いです。

とにかくソースを見たいという方は、以下に格納していますので合わせてご確認ください。

https://github.com/kenfdev/study-nextjs-turborepo-cicd

はじめに

Next.jsの最適な本番環境へのデプロイには工夫が必要です。やり方はいろいろとありますし、使っているツール郡によっても微妙に気をつける点が違ってたりして試行錯誤が必要だったりします。

以下の条件を今回は満たしたいと思いました:

  • コンテナイメージでデプロイしたい
    • マルチステージビルドを駆使してミニマムなイメージにしたい
    • Next.js自体もなるべくミニマムにしてコンテナイメージに入れたい
  • ビルドは1回で、環境依存の値は環境変数で対応したい

ということで調べてると見つけたのがNext.jsの output の方法の standalone というもの。

https://nextjs.org/docs/pages/api-reference/next-config-js/output

これを使うと node_modules も含めた本当に最低限の成果物が作られます。最終的には node server.js を実行するだけでOKになるのです。(npmも不要)

これとコンテナイメージのマルチステージビルドを駆使すればいい感じなる気がしたので挑戦することにしました。最近では個人的にTurborepoを使っているので、Turborepoも組み合わせて実現していきます。

目指すもの以下のような感じになります。

ソリューションの概要

  1. Turborepo + Next.jsのセットアップ
  2. Next.jsのstandalone output設定
  3. Dockerfileのマルチステージビルド
  4. Docker Composeでの動作確認
  5. GitHub Actionsでのコンテナイメージ自動デプロイ
  6. 環境変数の動的設定の検証

Turborepo + Next.jsのセットアップ

動画: https://www.youtube.com/watch?v=3zLi0iNjmf8&t=260s

まずはTurborepoを使ってNext.jsのスターターを作成します。ちょうど良いスターターが用意されているのでこれは非常に簡単です。

https://vercel.com/templates/next.js/turborepo-next-basic

これにより、複数のアプリケーションとパッケージを含む新しい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イメージを構築します。これにより、必要最低限のファイルのみを含む軽量なイメージを作成できます。スターターのおかげで最初から docsweb アプリという2つがあるのですが、今回は web アプリをデプロイできるようにしたいと思います。

これもTurborepoでかなりそのまま使えるDockerfileが用意されているので、それをベースに組み立てることができます。(今回は pnpm を使いたかったので、そのあたりは修正しています)

https://turbo.build/repo/docs/guides/tools/docker#example

この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に参考にできるワークフローがあるのでそれを使います。

https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#publishing-images-to-github-packages

このワークフローでは、コードのプッシュやPR生成時、リリースが作られたタイミングに自動でDockerイメージをビルドし、レジストリにプッシュします。

環境変数の動的設定

動画: https://www.youtube.com/watch?v=3zLi0iNjmf8&t=1824s

Next.jsでは、ビルド時に NEXT_PUBLIC_ から始まる環境変数がソースに埋め込まれる仕様があります(ブラウザ用の環境変数)。

https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables#bundling-environment-variables-for-the-browser

そのため、単純に考えるとデプロイ環境ごとにビルドを行わないといけません。ただしビルド時間がそれなりにあることや、検証環境と本番環境で同じイメージを使いたいと思ったときにこれはかなり微妙になります。

これを回避するために、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 --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

COPY --from=builder /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 --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
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/scripts ./apps/web/scripts

CMD sh apps/web/scripts/start.sh

次に、環境変数を置換するスクリプト(replace-vars.sh)を作成します。このスクリプトはDockerイメージ起動時に呼び出され、プレースホルダーを実際の環境変数に置き換えます。

このスクリプトは以下の記事をベースにしています

https://phase.dev/blog/nextjs-public-runtime-variables/

#!/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 --from=installer --chown=nextjs:nodejs /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