🐳

pnpm workspace + standaloneモードでビルドしたNext.jsのDockerイメージを作る

2024/04/19に公開

初めに

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
  • 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 --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile

FROM base AS builder

WORKDIR /app

# depsステージでインストールしたnode_modulesをコピー
COPY --from=deps /app/node_modules ./node_modules
# ビルド対象のディレクトリをコピー
COPY ./apps/my-app ./apps/my-app
COPY --from=deps /app/packages ./packages // depsステージからコピーすることで、node_modulesも含まれた状態でコピーされる
COPY --from=deps /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 --from=builder /app/apps/my-app/public ./public
COPY --from=builder /pruned/.next/standalone ./
COPY --from=builder /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 --mount=type=cache,id=pnpm,target=/pnpm/store 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 --from=deps /app/node_modules ./node_modules
# ビルド対象のディレクトリをコピー
COPY ./apps/my-app ./apps/my-app
COPY --from=deps /app/packages ./packages // depsステージからコピーすることで、node_modulesも含まれた状態でコピーされる
COPY --from=deps /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 --from=builder /app/apps/my-app/public ./public
COPY --from=builder /pruned/.next/standalone ./
COPY --from=builder /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 でコンテナに入ってディレクトリの状態を確認しながら進める

参考リンク

https://nextjs.org/docs/pages/api-reference/next-config-js/output#automatically-copying-traced-files

SMARTCAMP Engineer Blog

Discussion