Next.js 公式サンプルの Dockerfile を読み解いてみた

今回は、Next.js 公式のサンプルの Dockerfile を読み解いていきます。

Docker も Next.js もほぼ初心者なので間違えているところがあるかもしれません。

全体

まず Dockerfile 全体を眺めます。
https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile

Dockerfile
# Install dependencies only when needed
FROM node:16-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i; \
else echo "Lockfile not found." && exit 1; \
fi

# Rebuild the source code only when needed
FROM node:16-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1

RUN yarn build

# If using npm comment out above and use below instead
# RUN npm run build

# Production image, copy all the files and run next
FROM node:16-alpine AS runner
WORKDIR /app

ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# You only need to copy next.config.js if you are NOT using the default configuration
# COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000

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

大元のイメージ

Next.js 用のイメージを作る上での大元になるイメージをコピーしてきます。
node の公式イメージをコピー元としています。

Dockerfile
# Next.jsを動かすには node.js が必要なので公式イメージをコピー元とする
# FROMコマンド イメージ名:タグ
FROM node:16-alpine AS deps
FROM AS について

FROM ASコマンドは、マルチステージビルドの利用にて解説されています。

FROM ASFROM イメージ名:タグ AS ステージ名となっています。AS以降は自由に命名できるようです。

alpine 指定について

node のイメージのドキュメントによると、イメージのタグに alpine がついているものは、軽量版になります。なるべくイメージのサイズを小さくしたい場合に利用することになります。

本番環境では alpine を指定するのが一般的なようです。

マルチステージビルド

この Dockerfile を読み解くには マルチステージビルド への理解が重要なようです。
Next.jsプロジェクトを開発環境と本番環境のDockerで動かしてみたい【その1】でも一瞬でてきた単語です。
Dockerドキュメントのマルチステージビルドの利用を参考に、更に読み解いていきます。

また、スタンドアロンモードを利用しているので、Next.jsのスタンドアロンモードでビルドしたイメージを Cloud Run へデプロイするの記事も参考にさせていただきました。

マルチステージでは、ビルドをステージごとに分割して行います。

これまでのごくあたりまえの方法として、開発環境向けの Dockerfile を 1 つ用意し、そこにアプリケーションの構築に必要なものをすべて含めます。 そこから本番環境向けとしてスリム化したものをもう 1 つ用意して、アプリケーションそのものとそれを動かすために必要なもののみを含めるようにします。 これは「開発パターン」(builder pattern)と呼ばれてきました。

マルチステージビルド以前の Dockerfile にはFROMコマンドは1行のみだったようです。
この Dockerfile ではマルチステージビルドが利用されているのでFROMコマンドを追ってステージごとに分割してみます。

deps,builder,productionのステージがそれぞれ用意されています。

STEP.1
deps ステージ

deps は dependenciesの略語になります。(知らなかった…😖)
まずはライブラリ等の導入をしていくようです。

Dockerfile
##### deps ステージ #####
FROM node:16-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

# これ以降は /app で実行される
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i; \
else echo "Lockfile not found." && exit 1; \
fi

本番環境向けに aliphe を指定してる場合には、libc6-compatを追加することが推奨されているため、RUN apk add --no-cache libc6-compatを実行しています。

サンプルのコメントにも書いてあるようにhttps://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpineに書かれています。

One common issue that may arise is a missing shared library required for use of process.dlopen. To add the missing shared libraries to your image, adding the libc6-compat package in your Dockerfile is recommended: apk add –no-cache libc6-compat

WORKDIRコマンドで、それ移行のRUN,COPYを実行するディレクトリを変更しています。

なるほど。Next.js + Docker のビルド作業とは、app ディレクトリの中にサイトを表示するために必要なものを配置していく作業という理解で良さそう。

次にCOPYで必要なライブラリの情報を app ディレクトリの中にコピーしています。

COPYコマンド

COPYコマンドではCOPY コピー元 コピー先で指定できます。

Dockerfile
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./

ローカル上にあるライブラリ関係のファイル(package.json yarn.lock* package-lock.json* pnpm-lock.yaml*)を /appの中にコピーするという指示になります。

コピーをした後にRUNコマンドでライブラリのインストールを行います。

RUNコマンド

RUNコマンドではRUN コマンドで指定できます。

Dockerfile
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i; \
else echo "Lockfile not found." && exit 1; \
fi

if文を用いて、各ファイルが用意されている場合にのみ実行するようなコマンドが指定されています。
yarn.lockがある場合は、yarn --frozen-lockfileを実行します。(yarn.lockを生成しない指定付き)
package-lock.jsonがある場合は、npm ciを実行します。(参考: npm installとnpm ciの違いをメモする
pnpm-lock.yamlがある場合には、yarn global add pnpm && pnpm iを実行します。(pnpm。参考:pnpm の特徴

全てのファイルが見つからなかった場合には、exit 1;により、dockerのビルドごと終了してしまいます。

STEP.2
builder ステージ

builder ステージではビルドを行います。

Dockerfile
##### builder ステージ #####
FROM node:16-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN yarn build

deps ステージと同様に作業ディレクトリ /app に移動してからビルドを行います。
その時に deps ステージの情報を利用しています。

オプションで、これまでの( FROM .. AS <名前> として作成した)構築ステージをコピー元(ソース)の場所として指定するために、 COPY で –from=<名前> フラグを利用できます。これは、ユーザ自身が構築コンテキストを送る作業の替わりとなります。

つまり、COPY --from=deps /app/node_modules ./node_modulesでは、deps ステージ上に生成されたnode_modulesディレクトリを builder ステージ上にコピーしています。
「deps ステージの /app/node_modules」を「builder ステージの /app/node_modules」に持ってきています。

COPY . .コマンドで、ローカルからDocker環境の/appへディレクトリをそのままコピーしています。このコマンドにより実際に Next.js サイトを作ったものが Docker 上に丸っと移動しています。

最後にRUNコマンドで、ビルドを行なっています。

STEP.3
runner ステージ

runner ステージでは最終的なイメージに含めたいものだけの抽出を行ないます。
builder ステージではCOPY . .により、全てを含んだ状態になっていますが、そこから実際に配置するもののみを厳選してイメージ化する作業をしていくようです。

Dockerfile
FROM node:16-alpine AS runner
WORKDIR /app

ENV NODE_ENV production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000

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

deps ステージ、builder ステージと同様に作業ディレクトリ/appを指定します。

ENVコマンドで環境の指定を行なっています。

ENVコマンド

ENVコマンドではENV <キー>=<値>もしくはENV <キー> <値>で指定ができます。

NODE_ENV productionとなっているので、本番環境向けに環境変数が設定されています。

ユーザの作成

次にグループの作成とユーザの作成を行なっています。
これは、公式での説明等が見つからないので推測になってしまいますが、より安全なセキュリティにするためでしょうか…。

Dockerfile
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

builder ステージからコピー

builder ステージから public ディレクトリと package.json のみをコピーしています。
publicディレクトリにはアイコン等の公開ファイルが入っています。

docker
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json

次に builder ステージから node_modules のパッケージ(/app/.next/standaloneに入っている) と、静的ファイルをコピーしています。

standalone を指定してビルドを行なっているので、.next/standalone内に必要なパッケージが生成されています。

docker
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

この時に先ほど作成したユーザとグループを所有者として指定しています。
その後 USERコマンドにより、ユーザの変更をおこなっています。

ポートの設定

Dockerfile
EXPOSE 3000

ENV PORT 3000

EXPOSEコマンドにて、ポートの指定をおこなっています。
また同時に環境変数のポート番号も変更しています。

起動

最後に起動します。

CMDコマンドにより、server.js を起動します。

そこにstandaloneディレクトリが追加されます。ここにはアプリを動かすためのファイルが依存関係も含めてすべて入っていて、.next/standalone/server.jsを起動すれば本番アプリが動かせます。

https://zenn.dev/team_zenn/articles/nextjs-standalone-mode-cloudrun

CMDコマンド

CMDコマンドはCMD ["実行ファイル","パラメータ1","パラメータ2"]で指定します。

CMD ["node", "server.js"]では server.js を起動しています。

おわりに

とても長くなってしまいましたが、なんとなく理解ができた気がします。

マルチステージビルドによって必要なもののみを抽出してイメージ化している…という認識で良いはず。

他の方のサンプルでは、ユーザの作成をしていなかったり、deps ステージを用意せずに builder と runner ステージのみで構築されていたりと様々です。

今回のサンプルを利用し「Next.js のプロジェクトを作成して開発環境の Docker で動くようにする【その2】」の記事にて、開発環境の構築をしています。