Linux & IT ノート

Docker イメージを軽くする:マルチステージビルドと最適化テクニック

管理人 約9分で読めます

ここまでの連載で、Dockerfile の書き方、ボリューム・ネットワーク、Docker Compose と、コンテナを動かす基礎を一通り学んできました。 しかし実際に docker images で一覧を見てみると、自分でビルドしたイメージが1GB近くある、なんてことがよくあります。

イメージが大きいと何が困るか。デプロイのたびにプル・プッシュに時間がかかる、ストレージをどんどん食っていく、そして不要なツールやライブラリが含まれるぶん攻撃面(アタックサーフェス)が広がる——という問題が出てきます。 この記事では、イメージを小さく・シンプルに保つための手法をまとめます。

まずイメージの現状を確認する

最適化の前に、今のイメージがどのくらいの大きさなのか、何がレイヤーとして積まれているのかを確認します。

# イメージ一覧とサイズを確認する
docker images

# 特定イメージのレイヤー構成を確認する
docker history myapp:latest

docker history を使うと、各レイヤーがどの命令から作られ、どのくらいサイズを占めているかがわかります。「どの RUN が一番重いか」を特定するのに役立ちます。

より詳細なレイヤー分析には dive というオープンソースツールがあります(https://github.com/wagoodman/dive)。各レイヤーで追加・削除されたファイルをインタラクティブに確認できるため、「何が大きいのか」を掘り下げるときに便利です。

軽量ベースイメージを選ぶ

イメージサイズの大部分はベースイメージが決めます。同じ Node.js でもバリアントによってサイズが大きく異なります。

バリアント特徴
node:22Debian ベースのフル版。最大。開発ツールが揃っている
node:22-slimDebian ベースだが余計なパッケージを省いた軽量版
node:22-alpineAlpine Linux ベース。最小に近いサイズ

slim は Debian ベースなので glibc が使われており、ほとんどの npm パッケージとの互換性に問題がありません。迷ったら slim が無難な選択肢です。

alpine はサイズが最小ですが、musl libc という glibc とは異なる C ライブラリを使っています。ネイティブアドオンを含む npm パッケージや、glibc 依存のバイナリが動かないケースがあります。動作確認なしに本番へ持っていくのはリスクがあるので注意してください。

マルチステージビルド

イメージが肥大化する大きな原因の一つがビルドツールです。 TypeScript のコンパイラ、テストフレームワーク、Webpack などは「ビルドするために必要なもの」であって、「実行するためには不要なもの」です。これらをそのまま最終イメージに含めると、無駄な重さと攻撃面を抱え込むことになります。

マルチステージビルドは、1つの Dockerfile に複数の FROM を書き、ビルドステージと実行ステージを分ける仕組みです。ビルド成果物だけを最終イメージにコピーし、ビルドツールは捨てられます。

Node.js アプリを例に、TypeScript をコンパイルする構成で書いてみます。

# ──── ビルドステージ ────
FROM node:22-slim AS builder
WORKDIR /app

# 依存パッケージをインストール(開発用含む)
COPY package.json package-lock.json ./
RUN npm ci

# ソースコードをコピーしてビルド
COPY . .
RUN npm run build


# ──── 実行ステージ ────
FROM node:22-slim AS runner
WORKDIR /app

# 本番依存のみインストール
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# ビルドステージから成果物だけコピーする
COPY --from=builder /app/dist ./dist

ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000

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

COPY --from=builder がポイントです。builder ステージで作った dist/ ディレクトリだけを最終イメージにコピーしています。TypeScript コンパイラや devDependencies は最終イメージに入りません。

RUN をまとめてレイヤーを減らす

Dockerfile の各命令は1つのレイヤーを生成します。apt-get updateapt-get install を別の RUN で書くと、それぞれがレイヤーになり、さらにキャッシュのゴミも残りやすくなります。

# 悪い例:レイヤーが増え、apt のキャッシュが残る
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean

# 良い例:1つの RUN にまとめ、キャッシュを同じレイヤー内で削除する
RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

--no-install-recommends は推奨パッケージを省略するオプションで、不要なパッケージが引き込まれるのを防ぎます。rm -rf /var/lib/apt/lists/* で apt のキャッシュを同じレイヤー内で削除することで、イメージにキャッシュが残りません。

.dockerignore でビルドコンテキストを絞る

第1回で触れた .dockerignore ですが、最適化の観点でも重要です。ビルドコンテキストに含まれるファイルは COPY . . でコンテナ内に入ってしまう可能性があります。

node_modules
dist
.git
*.log
.env
coverage
.cache

node_modules はコンテナ内で npm ci するので送る必要がなく、.git はビルドに関係なく、.env は機密情報です。これらを除外することでビルドが速くなり、意図しないファイルの混入も防げます。

レイヤーキャッシュを活かす命令の順序

第1回でも取り上げましたが、最適化の観点から改めて整理します。

FROM node:22-slim
WORKDIR /app

# 変わりにくい:package.json を先にコピーして依存をインストール
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# 変わりやすい:ソースコードは後でコピー
COPY . .

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

package.json が変わらない限り npm ci のレイヤーはキャッシュが使われます。ソースコードを変えるたびに数分かかる npm ci が走るようでは開発体験が悪くなります。「変わりにくいものを上に、変わりやすいものを下に」の原則を守るだけで、日常の開発ループが大幅に速くなります。

まとめ

  • docker history <イメージ> でレイヤー構成を確認。dive ツールでさらに深掘りできる
  • ベースイメージは slim(Debian ベース、互換性が高い)か alpine(最小だが musl libc の注意が必要)を用途に応じて選ぶ
  • マルチステージビルドでビルドツールと実行環境を分離し、成果物だけを最終イメージに持ち込む
  • apt-get の呼び出しは1つの RUN にまとめ、同じレイヤー内でキャッシュを削除する
  • .dockerignorenode_modules.git.env などを除外する
  • package.json のコピーとインストールをソースコードのコピーより前に置いてキャッシュを活かす

これで連載「Docker 実践入門」は完結です。

  • #1 Dockerfile:イメージとコンテナの関係、FROM / COPY / RUN / CMD の使い方、レイヤーキャッシュと .dockerignore の基礎
  • #2 ボリューム・ネットワーク:コンテナを消してもデータを残す名前付きボリューム、コンテナ名で通信できるユーザー定義ネットワーク
  • #3 Docker Composecompose.yaml 1ファイルで複数コンテナを定義・一括管理、.env による機密情報の外出し
  • #4 イメージ最適化(この記事):slim / alpine の選択、マルチステージビルド、レイヤー削減、キャッシュの活用

ここまでの内容を押さえておけば、ローカル開発環境の構築からイメージの本番投入まで一通り対応できます。次のステップとしては、Kubernetes などのコンテナオーケストレーションや、CI/CD パイプラインへの組み込みがあります。