Docker の multi-stage build でイメージを一気に軽量化した話

開発用 Dockerfile のまま本番にデプロイしていたイメージを multi-stage build で整理したら、サイズが大幅に減りました。その手順と考え方をまとめます。

🙌 結論から

Docker の multi-stage build を使うと、本番イメージからビルドツールや devDependencies を除いた、すっきりしたイメージを作れます。

実際に仕事で使っていたプロジェクトで試したところ、1.2GB あったイメージが 300MB 程度まで減りました。

イメージが軽くなると、CI のデプロイ時間が短くなるだけでなく、本番環境のセキュリティリスクも下げられます。

本番に余計なツールを持ち込まないという考え方は、今後 Docker を使う上でかなり重要だと感じています。

💡 multi-stage build とは

multi-stage build は、一つの Dockerfile の中に 複数のビルドステージを記述できる Docker の機能です。

たとえば「ビルド用コンテナでコンパイルして、その成果物だけを本番用コンテナにコピーする」という流れを、一つの Dockerfile で完結できます。

以前はこれをやろうとすると、シェルスクリプトや複数の Dockerfile を組み合わせる必要がありました。

Docker 17.05 以降から使える機能なので、現在のほとんどの環境で問題なく使えます。

👀 実際の Dockerfile を書く

Node.js のアプリを例にすると、以下のような構成になります。

Before(single-stage の例)

FROM node:20

WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

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

このままだと、node_modules の devDependencies や TypeScript コンパイラなど、本番では不要なものがすべて残ります。

After(multi-stage の例)

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

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# --- 本番ステージ ---
FROM node:20-slim AS runner

WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev

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

COPY --from=builder がポイントで、前のステージで生成した成果物だけを引き継げます。

devDependencies や TypeScript のコンパイラは runner ステージには存在しないので、本番イメージがすっきりします。

✨ ビルドしてみた結果

実際に仕事で使っているプロジェクトに適用してみました。

$ docker images | grep myapp
myapp-before    latest    1.24GB
myapp-after     latest    298MB

1.24GB → 298MB と、4分の1以下まで小さくなりました。

デプロイ時の ECR へのプッシュ時間も体感でかなり速くなり、CI 全体の時間も短縮されています。

さらに、本番コンテナに余計なパッケージが入らないことで、脆弱性スキャンの結果もクリーンになりました。

本番に devDependenciestsc が残っている必要は本来なかったので、「なぜ最初からこうしなかったのか・・・」という感想でした。

一点、ビルドキャッシュの扱いには注意が必要です。

COPY . . をステージの早い段階で書くとキャッシュが効きにくくなるので、依存インストール後にソースをコピーする順番を意識すると、ビルドが速くなります。

⚡ BuildKitのキャッシュマウントでさらに速くする

レイヤーキャッシュの順番を工夫しても、package-lock.json が少し変わるたびに npm ci がフルで走り直すのが気になっていました。

これを解決してくれるのが、BuildKitの --mount=type=cache です。

# syntax=docker/dockerfile:1
FROM node:20-slim AS builder

WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY . .
RUN npm run build

--mount=type=cache,target=/root/.npm を付けると、npmのダウンロードキャッシュがビルド間で永続化されます。

通常のDockerレイヤーキャッシュは「レイヤーが変わったら丸ごと作り直し」ですが、cache mountは「レイヤーの中身が変わってもキャッシュディレクトリだけは維持される」という違いがあります。

これのおかげで、package-lock.json が変わって npm ci が再実行される場合でも、パッケージの再ダウンロードだけは省略できるようになりました(・∀・)

体感では、依存関係を1つ追加しただけのビルドが、以前は数十秒かかっていたのが数秒で終わるようになった感覚です。

一点だけ注意が必要なのが、このキャッシュはビルドを実行したマシンにローカルなものという点です。

GitHub ActionsのようなCIだと、ランナーが毎回使い捨てになることが多いので、標準の設定だとキャッシュの恩恵を受けられません。

CIで使うには docker buildx build --cache-to / --cache-from でキャッシュを外部(レジストリやGitHub Actionsのキャッシュ)にエクスポート・インポートする設定を別途組む必要があります。

「ローカル開発では --mount=type=cache だけで十分速い」「CIでも速くしたいなら buildx のキャッシュエクスポートまで踏み込む」という2段階で考えると、導入のハードルが下げやすいと思います。

パパ系Webエンジニア〜愛用ガジェット一覧 🛍️

この記事を書いている作業環境のガジェットを楽天ROOMにまとめています ✨ 在宅エンジニアのデスク作りの参考にどうぞ 🙌

👍 まとめ

multi-stage build は、Dockerfile を少し書き換えるだけで 本番イメージを大幅に軽量化できる、費用対効果がかなり高い改善 です。

本番環境のセキュリティを考えると、ビルドツールを本番イメージに含めないのは自然な考え方です。

まだ single-stage で運用しているプロジェクトがあれば、ぜひ一度試してみてほしいです。

イメージが小さくなると、デプロイも速くなり、開発体験もかなり良くなります。

※ Dockerのバージョンによって使える機能が異なります。最新情報は Docker公式ドキュメント をご確認ください👀