pnpm & Monorepo な環境のマルチステージビルドの書き方

2023/10/17に公開

pnpm のドキュメントには Working with Docker というページがあり、Monorepo の例も用意されています。

ただ、この例動かないんですよね。Dockerfile の解説をしつつ、動くように修正していきます。

手っ取り早く最終版を知りたい人は目次から最後まで飛んでください。

GitHub にもコードを置いています。

https://github.com/sun-yryr/pnpm-monorepo-docker

解説パート

Example 2: Build multiple Docker images in a monorepo を見ていきます。

※ 前提として、app1, app2common に依存しているとします。

2 ~ 4 行目

ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable

Corepack 経由で pnpm をインストールし、後々キャッシュで使うために PNPM_HOME を設定しています。

5, 6 行目

COPY . /app
WORKDIR /app


全部コピーしています。あくまで例なのでこうなっていると思いますが、実際は頻繁に変更されるものは最後にコピーしましょう。ベストプラクティスは以下のページに書かれています。

https://docs.docker.jp/engine/userguide/eng-image/dockerfile_best-practice.html#add-copy

8 ~ 13 行目

FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile

FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run -r build

dev-dependencies と dependencies をそれぞれインストールしています。ここで pnpm のキャッシュを使っています。
また、ビルドもしています。

15 行目 ~ 最後

FROM base AS common
COPY --from=prod-deps /app/packages/common/node_modules/ /app/packages/common/node_modules
COPY --from=build /app/packages/common/dist /app/packages/common/dist

FROM common AS app1
COPY --from=prod-deps /app/packages/app1/node_modules/ /app/packages/app1/node_modules
COPY --from=build /app/packages/app1/dist /app/packages/app1/dist
WORKDIR /app/packages/app1
EXPOSE 8000
CMD [ "pnpm", "start" ]

FROM common AS app2
COPY --from=prod-deps /app/packages/app2/node_modules/ /app/packages/app2/node_modules
COPY --from=build /app/packages/app2/dist /app/packages/app2/dist
WORKDIR /app/packages/app2
EXPOSE 8001
CMD [ "pnpm", "start" ]

最終段階として、それぞれのパッケージで必要な dist フォルダと node_modules をコピーしてきます。

ここがダメで、pnpm はシンボリックリンクを使って node_modules を構築します。そのため、node_modules をコピーする際には、シンボリックリンクを展開する必要があります。

実際にこのままビルドするとリンクのみがコピーされてしまいます。

$ docker build . --target app1 --tag app1:latest
$ docker run --rm -it app1:latest bash
---------
root@xxxxx:/app/packages/app1# ls -la node_modules/
total 12
drwxr-xr-x 2 root root 4096 Oct 16 09:03 .
drwxr-xr-x 1 root root 4096 Oct 16 09:03 ..
lrwxrwxrwx 1 root root   12 Oct 16 09:03 common -> ../../common
lrwxrwxrwx 1 root root   65 Oct 16 09:03 date-fns -> ../../../node_modules/.pnpm/date-fns@2.30.0/node_modules/date-fns

# ../../common 等は存在しない

これを解決するために、今回は pnpm のコマンドである pnpm deploy を使用します。

改良パート

pnpm deploy

https://pnpm.io/ja/cli/deploy

(deploy の方のドキュメントには Monorepo での使用法として書かれているんですよね。)

これを使うと、ドキュメントにある通り node_modules を含む全ファイルがコピーされます。最終ステージではこのコマンドの結果をコピーするだけで済むようになります。

また、--prod オプションを使うことで dependencies のみをコピーできます。なので、 pnpm install は1回で済みます。

$ pnpm deploy --prod deploy/app1
$ tree -aL 2 deploy/app1
app1
|-- dist
|   `-- main.js
|-- node_modules
|   |-- .pnpm
|   |-- common -> .pnpm/file+packages+common/node_modules/common
|   `-- date-fns -> .pnpm/date-fns@2.30.0/node_modules/date-fns
|-- package.json
|-- src
|   `-- main.ts
`-- tsconfig.json

不要な ts ファイルなどを除去する

実行には dist, node_modules, package.json (pnpm run をする場合のみ) が必要なので、これ以外をコピーしないことで容量の削減ができます。ついでにやっちゃいましょう。

まとめ・修正版 Dockerfile

FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app

FROM base AS build
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
COPY . .
RUN pnpm run -r build
# 1. Root store に依存しない node_modules を作成する
RUN pnpm --filter app1 deploy --prod deploy/app1
RUN pnpm --filter app2 deploy --prod deploy/app2

# 本番
# 2. ベースイメージは base なので不要なファイルが含まれていない
FROM base AS app1
COPY --from=build /app/deploy/app1/node_modules/ /app/packages/app1/node_modules
COPY --from=build /app/deploy/app1/dist /app/packages/app1/dist
COPY --from=build /app/deploy/app1/package.json /app/packages/app1/package.json
WORKDIR /app/packages/app1
EXPOSE 8000
CMD [ "pnpm", "start" ]

FROM base AS app2
COPY --from=prod-deps /app/deploy/app2/node_modules/ /app/packages/app2/node_modules
COPY --from=build /app/deploy/app2/dist /app/packages/app2/dist
COPY --from=build /app/deploy/app2/package.json /app/packages/app2/package.json
WORKDIR /app/packages/app2
EXPOSE 8001
CMD [ "pnpm", "start" ]

ちゃんとリンク先がある状態でコピーされています。

$ docker build . --target app1 --tag app1:latest
$ docker run --rm -it app1:latest bash
---------
root@xxxxx:/app/packages/app1# ls -la node_modules/
total 12
drwxr-xr-x  3 root root 4096 Oct 16 10:10 .
drwxr-xr-x  1 root root 4096 Oct 16 10:11 ..
drwxr-xr-x 65 root root 4096 Oct 16 10:10 .pnpm
lrwxrwxrwx  1 root root   46 Oct 16 10:10 common -> .pnpm/file+packages+common/node_modules/common
lrwxrwxrwx  1 root root   43 Oct 16 10:10 date-fns -> .pnpm/date-fns@2.30.0/node_modules/date-fns

実行もできる。

$ docker run --rm -it app:latest

> app1@1.0.0 start /app/packages/app1
> node dist/main.js

common
app1
株式会社ゆめみ

Discussion