TL;DR: Dockerfile でマルチステージビルドを使い、「ビルド用ステージ」と「実行用ステージ」を分けると devDependencies やビルドツールが最終イメージに残らなくなる。あるプロジェクトでは 2GB 弱 → 1GB 以下と半分以下にできた。


なぜイメージサイズを小さくしたいのか

Docker イメージが小さいと、こんなメリットがあります:

  • デプロイが速くなる — イメージの pull にかかる時間が短くなるので、CI/CD パイプラインやオートスケール時のコンテナ起動が速くなる
  • ストレージコストが減る — レジストリ(ECR、GCR など)の保存容量とデータ転送量を節約できる
  • セキュリティリスクが下がる — 不要なパッケージやツールが入っていないほど、攻撃対象となる面(アタックサーフェス)が小さくなる

問題: ビルド用の依存がイメージに残る

Node.js アプリを Docker でビルドするとき、こんな感じのシングルステージ Dockerfile を書くことってありますよね。

FROM node:24-slim

WORKDIR /app
# 依存定義ファイルを先にコピー(レイヤーキャッシュ活用)
COPY package*.json ./
# devDependencies を含む全依存をインストール
RUN npm ci
COPY . .
# アプリをビルド
RUN npm run build

EXPOSE 3000
CMD ["node", "dist/index.js"]

これだと npm ci で TypeScript、webpack、ESLint、テストライブラリなど devDependencies もすべてインストールされて、それがそのまま最終イメージに残ります。ビルドが終わったらもう要らないのに、もったいないですよね。

解決: マルチステージビルド

Docker のマルチステージビルドを使えば、ビルド用ステージと実行用ステージを分離できます。

# ステージ1: ビルド
FROM node:24-slim AS builder

WORKDIR /app
COPY package*.json ./
# npm キャッシュをマウントして再ビルドを高速化
RUN --mount=type=cache,id=npm,target=/root/.npm \
    npm ci
COPY . .
RUN npm run build

# ステージ2: 実行用(新しいイメージとして作成)
FROM node:24-slim

WORKDIR /app
# ビルド成果物だけを builder ステージからコピー
COPY --from=builder /app/dist ./dist
COPY package*.json ./
# 本番依存のみインストール(devDependencies は含まない)
RUN --mount=type=cache,id=npm,target=/root/.npm \
    npm ci --omit=dev
EXPOSE 3000
CMD ["node", "dist/index.js"]

やっていることはシンプルです:

  1. builder ステージで全依存をインストールして npm run build を実行する
  2. 実行用ステージではビルド成果物(dist/)だけをコピーし、npm ci --omit=dev で本番依存のみインストールする

builder ステージの node_modules(devDependencies 含む)は最終イメージに入りません。

pnpm の場合

pnpm を使っている場合はこうなります。

# ステージ1: ビルド
FROM node:24-slim AS builder

# corepack で pnpm を有効化
RUN corepack enable pnpm
WORKDIR /app
COPY pnpm-lock.yaml package.json ./
# pnpm store をキャッシュして再ビルドを高速化
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build

# ステージ2: 実行用(新しいイメージとして作成)
FROM node:24-slim

RUN corepack enable pnpm
WORKDIR /app
COPY pnpm-lock.yaml package.json ./
# 本番依存のみインストール(devDependencies は含まない)
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile --prod
# ビルド成果物だけを builder ステージからコピー
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]

ポイント:

  • corepack enable pnpm で pnpm を有効化する
  • --mount=type=cache で pnpm store をビルドキャッシュとしてマウントする。再ビルド時にパッケージの再ダウンロードが省略されるので、CI でのビルド時間短縮にも効果がある
  • 実行用ステージでは --prod フラグで本番依存のみインストールする

イメージサイズの比較

構成 イメージサイズ
シングルステージ ~450 MB
マルチステージ ~180 MB

※ Express + TypeScript の小規模アプリの場合の目安です。プロジェクトによって異なります。

私のあるプロジェクトでは、シングルステージで 2GB 弱だったイメージがマルチステージにしたら 1GB 以下に削減できました。devDependencies が重いプロジェクトほど効果は大きいです。

まとめ

Dockerfile を2ステージに分けるだけで、不要な開発依存がイメージから消えてサイズが大幅に減ります。やることは AS builder を付けて、次のステージで COPY --from=builder するだけなので、既存の Dockerfile にも導入しやすいですね。

dependencies に入れるべきものを devDependencies に入れていて、開発環境では動くけど本番環境で動かない……みたいなトラブルにだけは気をつけてください。


余談

それにしても npm のライブラリってなんであんなに容量を食うんですかね。 ちょっとしたプロジェクトでも node_modules が平気でギガ単位のディスク容量を消費してくるの、どうにかならないんでしょうか。 あの巨大な node_modules と付き合わないといけないの無駄の極みですよ、複数プロジェクトやっているとマシンのディスク容量ゴリゴリ削られるわけで。 それでpnpmなんですけど、それもDockerイメージに入れると結局サイズが大きくなっちゃうんですよね。せめてビルドツールだけでも軽量化してくれればいいのに。 ほんと滅びないかな…

参考リンク