pnpm workspace + standaloneモードでビルドしたNext.jsのDockerイメージを作る
初めに
pnpm workspace上のstandaloneでビルドしたNext.jsアプリのDockerイメージを作成してみたので、残しておきます。
Next.jsのstandaloneについて
通常では、dependencies書かれているすべてのパッケージがnode_modulesに配置されるため、イメージ容量が肥大化してしまう問題がありました。
一方、standaloneモードでビルドするとnode_modulesから必要なファイルのみディレクトリにコピーすることができます。これにより、node_modulesがアプリケーションの実行に必要なファイルのみになるので、イメージサイズを大幅に削減することができます!
以下を設定してstandaloneモードを有効にします。
module.exports = {
output: 'standalone',
}
ディレクトリ構成
turborepo等で採用されている構成をとっています。
- apps
- my-app
- package.json
etc... - Dockerfile
- package.json
- my-app
- packages
- other-app
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- Dockerfile // 開発サーバー用のDockerfile
- .npmrc
分け方としては、apps以下にサーバーとして起動されるアプリを配置、packages以下はappsで使われる共通のパッケージを配置しています。
ポイントとしては、appsディレクトリごとにDockerfileを配置することで、独立性を保ち、処理が煩雑化しないようにしています。今回は/apps/my-app/Dockerfileを使っていきます
全体像
まずは全体的なファイルです。マルチステージビルドを使っています。
各ステージに分けて説明していきます。
apps/my-app/Dockerfile
FROM node:20.12.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
# pnpmを有効化
RUN corepack enable
FROM base AS deps
WORKDIR /app
# pnpm installに必要なファイルをコピー
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ./apps/my-app/package.json /app/apps/my-app/package.json
COPY ./packages/ /app/packages/
RUN pnpm install --frozen-lockfile
FROM base AS builder
WORKDIR /app
# depsステージでインストールしたnode_modulesをコピー
COPY /app/node_modules ./node_modules
# ビルド対象のディレクトリをコピー
COPY ./apps/my-app ./apps/my-app
COPY /app/packages ./packages // depsステージからコピーすることで、node_modulesも含まれた状態でコピーされる
COPY /app/.npmrc ./
# pnpm deployを使ってシンボリックリンクを展開してprunedディレクトリにコピー
# https://pnpm.io/ja/cli/deploy
RUN pnpm --filter=@monorepo/my-app deploy /pruned
WORKDIR /pruned
RUN pnpm --filter=@monorepo/my-app build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# prunedディレクトリにビルドしたファイルをコピー
COPY /app/apps/my-app/public ./public
COPY /pruned/.next/standalone ./
COPY /pruned/.next/static ./.next/static
CMD [ "node", "server.js" ]
各ステージの説明
1.baseステージ
FROM node:20.12.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
# pnpmを有効化
RUN corepack enable
pnpmの有効化とpnpmのパスの設定を行うステージです。他のステージでもこの設定が使われます。
2.depsステージ
FROM base AS deps
WORKDIR /app
# pnpm installに必要なファイルをコピー
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ./apps/my-app/package.json /app/apps/my-app/package.json
COPY ./packages/ /app/packages/
RUN pnpm install --frozen-lockfile
pnpm installコマンドで依存関係を作成するステージです。node_modulesのみ作成したいのでそれに必要なpackage.jsonのみをCOPYしています。
ただ、packagesディレクトリは量が増えていくたびにpackage.jsonを書いていのが億劫だったため、ディレクトリごとコピーしています。
3.builderステージ
FROM base AS builder
WORKDIR /app
# depsステージでインストールしたnode_modulesをコピー
COPY /app/node_modules ./node_modules
# ビルド対象のディレクトリをコピー
COPY ./apps/my-app ./apps/my-app
COPY /app/packages ./packages // depsステージからコピーすることで、node_modulesも含まれた状態でコピーされる
COPY /app/.npmrc ./
# pnpm deployを使ってシンボリックリンクを展開してprunedディレクトリにコピー
# https://pnpm.io/ja/cli/deploy
RUN pnpm --filter=@monorepo/my-app deploy /pruned
WORKDIR /pruned
RUN pnpm --filter=@monorepo/my-app build
アプリケーションをビルドします。
fontawsomeのpro版を入れているので、auth_tokenをARG + ENVで環境変数に設定います。
--from=
を使ってdepsステージで作成したnode_modulesを使ってビルドをおこないます。
pnpmは、シンボリックリンクでnode_modulesを構築しているため、シンボリックリンクを展開する必要があります。なので、ビルド前にdeployコマンドを実行しシンボリックリンクを実ファイルに展開した後、buildコマンドを実行しています。
※ここをあんまり理解できておらずハマりました。
またこのステージでNODE_ENVをproductionにするとうまくビルドできない場合があったので、しないほうがいいかもしれません。
4.runnerステージ
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# prunedディレクトリにビルドしたファイルをコピー
COPY /app/apps/my-app/public ./public
COPY /pruned/.next/standalone ./
COPY /pruned/.next/static ./.next/static
CMD [ "node", "server.js" ]
実際にアプリを起動するためのステージです。環境変数をproductionにしたり、builderステージで作成したディレクトリをコピーしています。
注意点として、publicディレクトリはデフォルトではコピーされないため、明示的にかいてあげる必要があります。
思想的にはCDNで処理されるべきものであるというNext.jsの思想からそうなっているようです。
またstandaloneモードでビルドした場合は起動コマンドがnext start
ではなく、node server.js
になるため、そこも変更する必要があります。
ビルドしてみる
以下のコマンドでビルドしてイメージのサイズを確認してみます。
$ docker build -f apps/my-app/Dockerfile --no-cache . --target runner --tag myapp:latest --progress=plain
ビルドしたイメージを確認してみます。
$ docker images
$ myapp latest 57da62e0863b 19 seconds ago 250MB
250MBでかなり軽量なイメージとなっていることがわかります。すごいですね。
終わりに
pnpm workspace + Next.jsのモノレポ構成でDockerイメージを構築した経験がなく、まだまだ最適化はできそうな感じはしますが、なんとかビルドすることができました。
詰まったポイント
1つのDockerfileでappsディレクトリ以下のアプリ全てに対応しようとしていて、--build-argが増えたり、Dockfileが煩雑化してしまっていた。
解決方法
appsディレクトリごとにDockerfileを配置することで回避。これによりNode.jsのアップデート等もアプリごとに行えるようになりました。
pnpm deploy -> buildの流れの理解不足
pnpmがシンボリックリンクで動作し、deployして実ファイルがコピーされるという挙動をしっかり理解しておらず、
アプリの起動に失敗してしまっていた。
解決方法
docker buildの--targetの引数をbuilderに変えた後、docker run -it my-app /bin/bash
でコンテナに入ってディレクトリの状態を確認しながら進める
参考リンク
Discussion