Nodejs(Nest.js)のアプリケーションのbuildを高速化、slim化してみようの会

2023/09/02に公開

前提

DockerによるNode.jsのインストール(pull)はキャッシュされているものとする

.dockerignoreは以下の通り

node_modules
.git
.gitignore
*.md
dist
test

最初にまとめ

  1. 軽く、そんなに依存関係が多くないアプリケーションであればnpmでstaging buildでキャッシュ効かせるぐらいでよいかも
  2. RUN --mount=type=cache,target= は効果がありそうである (https://zenn.dev/kou64yama/articles/powerful-docker-build-cache)を参考
  3. いろんなものを入れ始めた場合、開発環境とか、バージョン管理で色々入れ始めたら pnpm fetch は試してもいいかもしれない(https://pnpm.io/ja/cli/fetch)
  • ただ、node_modulesの更新の場合は、少し時間がかかりやすいので注意する
npmをそのまま使ってビルド npm installを先にする npm+staging buildを使う npm+staging build+各ステージでキャッシュを効かせる node_modulesもキャッシュする pnpmを使う pnpm fetchを単純に使う pnpm fetch+--mount=type=cache,target=とかもう少しやってみる
ビルドイメージの大きさ 460MB 460MB 277.44MB 277.44MB 277.44MB 279MB 430MB 279MB
最初のビルド時間 10.4s 10.8s 11.1s 10.3s 13.6s 11.1s 20.2s 17.8s
ソースコード修正でのビルド 10.4s 3.6s 9.5s 5.6s 4.8s 4.3s 5.4s 4.2s
package.jsonのversionに変更あり 10.4s 11.5s 10.2s 10.0s 8.6s 8.7s 7.4s 8.7s

簡単な説明

RUN --mount=type=cache,target=

こいつがあると、RUN時に、特定のディレクトリをマウントしてくれる。
そして、そのディレクトリを各ビルドステージで共有できるというメリットが有る。
BuildKitが使える環境であることが前提。

corepack enable

https://nodejs.org/api/corepack.html
こいつで特定のパッケージマネージャが使えるようになる。というよりも指定できるようになるというのが正しそう。
corepack enable pnpm などのように指定するのが普通のようである。バージョンは新し目のものを持ってくるが、corepack prepare pnpm@x.y.z --activateのようにしてバージョンを固定するのもあり。
package.jsonのpackageManagerに指定すると (https://nodejs.org/api/packages.html#packagemanager) 同じように固定できる。
ただし、今回、package.jsonを先に使いたくないのでpackageManagerは使わず

pnpm fetch

こいつは package.json ではなくlockfileからインストールしてくれるというコマンド。
package.json自体の修正に対するキャッシュではなく、依存関係による修正にのみ反応するため、キャッシュが乗る確率が上がりやすくなる。
正直これで出来たものがnode_modulesに直接できるわけじゃないので注意する。

それぞれのパターンにおけるDockerfile

npmをそのまま使ってビルド
FROM node:20-slim

COPY . /app

WORKDIR /app
RUN npm i
RUN npm run build

EXPOSE 8000
CMD [ "npm", "run", "start:prod" ]

結果

  • Image SIZE: 460MB
  • 最初のビルド(10.4s)
  • ソースコード修正でのビルド(10.4s)
  • package.jsonのversionに変更ありのビルド(10.4s)

感想

  • まぁ、知ってたけどサイズも大きいし、キャッシュされない
npm installを先にする
FROM node:20-slim

WORKDIR /app

COPY package.json /app/package.json
COPY package-lock.json /app/package-lock.json
RUN npm i

COPY . /app

RUN npm run build

EXPOSE 8000
CMD [ "npm", "run", "start:prod" ]

結果

  • Image SIZE: 460MB
  • 最初のビルド(10.8s)
  • ソースコード修正のビルド(3.6s)
  • package.jsonのversionに変更ありのビルド(11.5s)

感想

  • そらそうよね。という結果。特に言うこともない
npm+staging buildを使う
FROM node:20-slim as base

WORKDIR /app
COPY . /app

FROM base as dev

RUN npm i && npm run build

FROM base as prod

RUN npm i --production

FROM node:20-slim

COPY --from=dev /app/dist /app/dist
COPY --from=prod /app/node_modules /app/node_modules

EXPOSE 8000
CMD [ "npm", "run", "start:prod" ]

結果

  • Image SIZE: 277.44MB
  • 最初のビルド(11.1s)
  • ソースコード修正のビルド(9.5s)
  • package.jsonのversionに変更ありのビルド(10.2s)

感想

  • 何かしら並行に走るから若干早くなるのかなと思った(誤差だけど
  • copyのときに差分が有るからキャッシュされていないという推測
npm+staging build+各ステージでキャッシュを効かせる
FROM node:20-slim as base

WORKDIR /app

FROM base as dev

COPY package.json /app/package.json
COPY package-lock.json /app/package-lock.json
RUN npm i

COPY . /app
RUN npm run build

FROM base as prod

COPY package.json /app/package.json
COPY package-lock.json /app/package-lock.json
RUN npm i --production

FROM node:20-slim

COPY --from=dev /app/dist /app/dist
COPY --from=prod /app/node_modules /app/node_modules

EXPOSE 8000
CMD [ "npm", "run", "start:prod" ]

結果

  • Image SIZE: 277.44MB
  • 最初のビルド(10.3s)
  • ソースコード修正のビルド(5.6s)
  • package.jsonのversionに変更ありのビルド(10.0s)

感想

  • そもそもnpm installの際に結局キャッシュされていないから再実施しているため、あんまり速度は変わらん
node_modulesもキャッシュする
FROM node:20-slim as base

WORKDIR /app

FROM base as dev

COPY package.json /app/package.json
COPY package-lock.json /app/package-lock.json
RUN --mount=type=cache,target=/root/.npm npm i

COPY . /app
RUN npm run build

FROM base as prod

COPY package.json /app/package.json
COPY package-lock.json /app/package-lock.json
RUN --mount=type=cache,target=/root/.npm npm i --production

FROM node:20-slim

COPY --from=dev /app/dist /app/dist
COPY --from=prod /app/node_modules /app/node_modules

EXPOSE 8000
CMD [ "npm", "run", "start:prod" ]

結果

  • Image SIZE: 277.44MB
  • 最初のビルド(13.6s)
  • ソースコードの修正のビルド(4.8s)
  • package.jsonのversionに変更ありのビルド(8.6s)

感想

  • --mount=type=cache,targetを使うとキャッシュがきくのがみえてきた
  • これだとコード修正するとprod copyが走るのでcopyの順番はmoduleからのほうがいいかもですね
pnpmを使う
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
COPY pnpm-lock.yaml /app/pnpm-lock.yaml
COPY package.json /app/package.json
WORKDIR /app

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
COPY . /app
RUN pnpm run build

FROM base
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/dist /app/dist
EXPOSE 8000
CMD [ "pnpm", "start:prod" ]

結果

  • Image SIZE: 279MB
  • 最初のビルド(11.1s)
  • ソースコードの修正のビルド(4.3s)
  • package.jsonのversionに変更ありのビルド(8.7s)

感想

  • 速度的には、pnpmだからなんだって感じですね。
  • そんなにpackageも多くないから、多くなってきたときに単純に早いというところで意味があるかなぐらいですかね。
  • この場合の弱点が、package.jsonを変化させたとき(versionだけの変更)に困るんですよね。
pnpm fetchを単純に使う
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app

FROM base AS fetcher
COPY pnpm-lock.yaml /app/pnpm-lock.yaml
RUN pnpm fetch ./

FROM base AS prod-deps
COPY --from=fetcher /pnpm/store /pnpm/store
COPY --from=fetcher /app/node_modules /app/node_modules
COPY --from=fetcher /app/pnpm-lock.yaml /app/pnpm-lock.yaml
COPY package.json /app
RUN pnpm install --offline --prod --frozen-lockfile

FROM base AS build
COPY --from=fetcher /pnpm/store /pnpm/store
COPY --from=fetcher /app/node_modules /app/node_modules
COPY --from=fetcher /app/pnpm-lock.yaml /app/pnpm-lock.yaml
COPY package.json /app
RUN pnpm install --offline --frozen-lockfile
COPY . /app
RUN pnpm run build

FROM base
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/dist /app/dist
COPY package.json /app
EXPOSE 8000
CMD [ "pnpm", "start:prod" ]

結果

  • Image SIZE: 430MB
  • 最初のビルド(20.2s)
  • ソースコードの修正のビルド(5.4s)
  • package.jsonのversionに変更ありのビルド(7.4s)

感想

  • SIZEがなんか全然減ってないですね。多分使い方を間違えているんでしょう。
  • 最初のbuildがとても遅い。package.jsonから作ったほうが基本早いんだろうという想像。
pnpm fetch+`--mount=type=cache,target=`とかもう少しやってみる
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app

FROM base AS fetcher
COPY pnpm-lock.yaml /app/pnpm-lock.yaml
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm fetch ./

FROM base AS prod-deps
COPY --from=fetcher /app/pnpm-lock.yaml /app/pnpm-lock.yaml
COPY package.json /app
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --offline --prod --frozen-lockfile

FROM base AS build
COPY --from=fetcher /app/pnpm-lock.yaml /app/pnpm-lock.yaml
COPY package.json /app
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --offline --frozen-lockfile
COPY . /app
RUN pnpm run build

FROM base
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/dist /app/dist
COPY package.json /app
EXPOSE 8000
CMD [ "pnpm", "start:prod" ]

結果

  • Image SIZE: 279MB
  • 最初のビルド(17.8s)
  • ソースコードの修正のビルド(4.2s)
  • package.jsonのversionに変更ありのビルド(8.7s)

感想

  • 前のやり方だと /app/node_modulespnpm install —production したところで、node_modulesが更新されなかったので、mountを利用するよう修正したらうまくいった
  • キャッシュしないように依存しているライブラリを増やした分ちょっと増えただけなので他のとおんなじぐらいslimになったっぽい
  • 最終結果の数値としては変わらんが、インストールがofflineで入るので、たくさんパッケージが有るときにより有効っぽい?
    • package.jsonがないとインストールが実行できないので、ここはキャッシュできない
  • 最初のビルドはて、多分fetcherからのコピーではなくがmountによるコピーのため早くなってるんじゃないかと想像

Discussion