この記事では、Dockerのマルチステージビルドを原理から解説します。「なぜイメージが小さくなるのか」「FROMが2つあるのに最終イメージは1つになるのはなぜか」という疑問に、Java+Mavenの実例コードを使って答えます。
Dockerイメージのレイヤー構造(Union File System)の基礎から、シングルステージとの比較、builder/runnerステージの役割分担、docker historyによる確認方法まで、順を追って丁寧に解説します。
Dockerfileを自分で書けるようになりたい方、本番イメージのサイズを削減したい方に最適です。
こちらも合わせて
この記事で得られること
- Dockerイメージのレイヤー構造(Union File System)を理解できる
- シングルステージとマルチステージの違いを具体的に把握できる
- なぜマルチステージでイメージが小さくなるのかを原理から理解できる
- builderステージが「app.jarを作るための一時的な作業場」にすぎない理由がわかる
- FROMが2つあるのに最終イメージが1つになる仕組みを理解できる
- Java+MavenのマルチステージDockerfileを自分で書けるようになる
- docker images/docker historyでステージの実態を確認できる
まずは基礎:Dockerイメージのレイヤー構造
マルチステージビルドを理解する前に、Docker イメージがどのような構造をしているかを知っておく必要があります。
Dockerイメージ = 読み取り専用レイヤーの積み重ね
Docker イメージは「レイヤー(Layer)」という差分ファイルシステムの積み重ねでできています。Dockerfile の命令(RUN・COPY・ADD)がそれぞれ1つのレイヤーを生み出します。

| Dockerfile命令 | レイヤー | 追加サイズ(目安) |
|---|---|---|
FROM eclipse-temurin:21-jdk | Layer 1: ベース | 約480MB |
RUN apt-get install maven | Layer 2 | +130MB |
COPY pom.xml . | Layer 3 | +1KB |
RUN mvn dependency:go-offline | Layer 4: 依存ライブラリ | +80MB |
COPY src ./src | Layer 5 | +500KB |
RUN mvn package | Layer 6: .jar+キャッシュ | +10MB |
これらのレイヤーがすべて積み重なり、最終的なイメージサイズは約700MB以上になります。
Dockerはこのレイヤー構造を実現するために Union File System(OverlayFS) を使っています。複数の読み取り専用レイヤーを「透過的に重ね合わせ」、コンテナからは一つのファイルシステムに見えるようにする技術です。
重要:一度作ったレイヤーは消せない
ここが肝心なポイントです。あるレイヤーで追加したファイルを、別のレイヤーで削除してもイメージのサイズは減りません。削除操作自体が新しいレイヤーとして追加されるだけで、元のレイヤーはそのままディスクに残るためです。
# ❌ これではイメージサイズは縮まらない
FROM ubuntu
RUN apt-get install -y build-essential # Layer A: +200MB 追加
RUN apt-get remove -y build-essential # Layer B: 削除の「差分」のみ追加
# → 最終サイズ = ベース + Layer A のデータ + Layer B の差分 ≈ 変わらない!⚠️ ファイル削除の罠
別の RUN 命令でファイルを削除しても、そのファイルが存在したレイヤーはイメージに残り続けます。機密情報(APIキーなど)を Dockerfile に書いて後で削除しても、履歴から読み取れてしまいます。マルチステージビルドを使えばこの問題も根本的に解決できます。
マルチステージビルドとは何か
マルチステージビルドとは、1つのDockerfileの中に複数の FROM 命令を記述し、ビルドフェーズごとに異なるベースイメージを使う手法です。シングルステージビルドが抱える問題を根本から解決します。

| 観点 | シングルステージ | マルチステージ |
|---|---|---|
| 最終イメージの内容 | JDK+Maven+ソース+.jarがすべて入る | .jarとJREだけ |
| ビルドツールの扱い | 本番イメージに残る | 中間ステージに閉じ込め |
| イメージサイズ(目安) | 600〜800MB | 100〜200MB(JREベース) |
| セキュリティ | 不要なツールが攻撃面になる | 本番に不要なツールが入らずセキュア |
| 管理 | Dockerfile 1ファイルで完結 | Dockerfile 1ファイルで完結 |
なぜイメージが小さくなるのか?
ここが最も重要な概念です。マルチステージビルドでイメージが小さくなる理由を、図で確認しましょう。

builderステージで蓄積した「JDKの全ファイル・Maven本体・ダウンロードした依存ライブラリのキャッシュ・コンパイル中間ファイル」は、最終イメージのどのレイヤーにも含まれません。
COPY --from=builder で取り出されるのは app.jar というファイル1つだけです。builderステージのレイヤーそのものは最終イメージに引き継がれないため、JDKやMavenのサイズが最終イメージに影響しないのです。
Javaで確認:マルチステージ版 Dockerfile
実際のJava+Mavenプロジェクトで使用するマルチステージDockerfileの例です。Stage 1でビルドを行い、Stage 2で実行用の軽量イメージを作成します。
# ══ Stage 1: ビルドステージ ══
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /app
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw dependency:go-offline -B # 依存ダウンロード(キャッシュ活用)
COPY src ./src
RUN ./mvnw package -DskipTests -B # app.jar 生成
# ══ Stage 2: 実行ステージ(最終イメージ) ══
FROM eclipse-temurin:21-jre-alpine AS runner
WORKDIR /app
COPY --from=builder /app/target/app.jar app.jar # app.jar だけコピー
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
# 📦 最終イメージサイズ: 約 170MB(JRE-alpine ベース)pom.xml を先にコピーしてから mvn dependency:go-offline を実行することで、ソースコードだけを変更した場合に依存ライブラリのダウンロードキャッシュが再利用されます。ビルドの高速化に有効なテクニックです。
疑問解消:FROMが2つあるのに、イメージはいくつ作られる?
「同じDockerfileにFROMが2つ書いてある。イメージも2つ作られるの?」という疑問はよくあります。答えは「ビルド中は2つのステージが動くが、手元に残る名前付きイメージは最後のステージの1つだけ」です。
各ステージの役割を整理すると次のとおりです。
| ステージ | 役割 | 含まれるもの |
|---|---|---|
| builder | app.jarを生成するための一時的な作業場 | JDK・Maven・ソースコード・依存ライブラリキャッシュ |
| runner | 本番用の軽量イメージ(最終イメージ) | JRE(最低限のランタイム)+ app.jar のみ |
docker build -t myapp:latest . を実行した後の docker images の出力は次のようになります。
$ docker images
REPOSITORY TAG IMAGE ID SIZE
myapp latest a1b2c3d4e5f6 170MB ← runner(本命・名前あり)
<none> <none> f6e5d4c3b2a1 700MB ← builder(残骸・タグなし)builderステージはタグなしの「dangling image(ダングリングイメージ)」として残ります。これは次回ビルド時のキャッシュ再利用のために保持されますが、不要な場合は削除できます。
docker images の SIZE 列は「仮想サイズ」を表示します。複数のイメージが共通レイヤーを共有している場合、ディスク上では1つ分しか使用しないにもかかわらず SIZE には重複カウントされることがあります。実際のディスク使用量は docker system df で確認しましょう。
builderの残骸を確認・削除するコマンドは以下のとおりです。
# ① dangling image だけを表示
docker images -f dangling=true
# ② dangling image を一括削除(builder の残骸を消す)
docker image prune
# ③ 手動で削除したい場合
docker rmi f6e5d4c3b2a1 # IMAGE ID を指定
# ④ 実際のディスク使用量を確認
docker system df3ステップで整理すると、マルチステージビルドの流れは次のようになります。
- builderステージを実行する:JDK・Maven・ソースコードを全部詰め込んで
mvn packageを走らせ、app.jarを作る。「目的のためだけに存在する使い捨て作業場」。 - app.jarだけをrunnerステージへ渡す:
COPY --from=builder /app/target/app.jar app.jarで完成品だけを取り出す。ビルドツール・ソース・キャッシュは一切持ち込まない。 - runnerステージが最終イメージになる:
docker build -t myapp:latest .の結果、myapp:latestタグがつくのはrunnerステージのイメージだけ。builderはタグなし(dangling image)として残り、不要ならdocker image pruneで削除できる。
docker history でレイヤーを確認
docker history コマンドで最終イメージのレイヤー履歴を確認すると、builderステージのレイヤーが一切含まれていないことが実際に確かめられます。
$ docker history myapp:latest
IMAGE CREATED BY SIZE
a1b2c3d4... ENTRYPOINT ["java","-jar","app.jar"] 0B
e5f6g7h8... USER appuser 0B
i9j0k1l2... RUN addgroup -S appgroup ... 4.1kB
m3n4o5p6... COPY --from=builder target/app.jar app.jar 18.5MB ← app.jar のみ!
q7r8s9t0... WORKDIR /app 0B
u1v2w3x4... eclipse-temurin:21-jre-alpine (base) 148MB ← JRE のみ
# 合計 ≈ 170MB ← JDK も Maven も含まれない!
# ※ builder ステージのレイヤーは一切表示されないレイヤーキャッシュを活用した最適な Dockerfile の書き順
マルチステージビルドでは、レイヤーキャッシュを意識した命令の順序が重要です。変更頻度が低いものを先に書くことで、二回目以降のビルドが大幅に高速化します。
# syntax=docker/dockerfile:1
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /app
# ① 変更頻度: 低 ─ pom.xml のみ先にコピー
# → pom.xml が変わらなければ② のキャッシュが使われる
COPY pom.xml mvnw ./
COPY .mvn .mvn
RUN ./mvnw dependency:go-offline -B # ② 重い処理(キャッシュ利用推奨)
# ③ 変更頻度: 高 ─ ソースを後でコピー
# → ソース変更時は③以降だけ再実行(①②はキャッシュ済み)
COPY src ./src
RUN ./mvnw package -DskipTests -B # ④ ビルド本体
FROM eclipse-temurin:21-jre-alpine AS runner
WORKDIR /app
COPY --from=builder /app/target/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]💡 キャッシュのポイント
Docker はレイヤーをハッシュで管理し、入力(ファイル内容・命令)が変わっていなければキャッシュを再利用します。pom.xml を先にコピーして依存解決を済ませておけば、ソースコードだけを変更した場合に Maven の重いダウンロード処理をスキップできます。
イメージサイズ比較:Javaの場合
ベースイメージの選択によってもサイズは大きく変わります。マルチステージビルドと組み合わせることで最大約80%の削減が可能です。
| 構成 | ベースイメージ | サイズ(目安) |
|---|---|---|
| シングルステージ | eclipse-temurin:21-jdk-jammy | 約700MB |
| マルチステージ(JRE) | eclipse-temurin:21-jre-jammy | 約200MB |
| マルチステージ(JRE+Alpine) | eclipse-temurin:21-jre-alpine | 約170MB |
| マルチステージ(Distroless) | gcr.io/distroless/java21 | 約130MB |
わかりやすく例えると:工場の製造ラインと出荷ライン
マルチステージビルドのイメージが掴みにくい場合は、工場の製造ラインに例えると理解しやすくなります。
🏭 製造ライン(builderステージ)
大型の旋盤、溶接機、切断機などの大きな機械がずらりと並んでいます。これらの機械を使って製品(app.jar)を作ります。出来上がった製品を店に送るとき、これらの機械を一緒に箱詰めすることはありません。
📦 出荷ライン(runnerステージ)
できあがった製品だけをきれいな箱に入れて出荷します。箱の中には製品(app.jar)だけが入っており、製造機械(JDK・Maven)は含まれません。お客様(本番サーバー)に届くのはコンパクトな箱だけです。
まとめ
- DockerイメージはRUN/COPY命令ごとに差分レイヤーが積み重なる(Union File System)
- 一度追加したレイヤーは後で削除しても容量は減らない → マルチステージで根本解決
- builderステージは「app.jarを作るだけの使い捨て作業場」と考えるとわかりやすい
COPY --from=builderでapp.jarだけをrunnerステージへ引き渡す- FROMが2つあっても、タグ付きイメージになるのは最後のステージ(runner)だけ
- builderステージはタグなしdangling imageとして残り
docker image pruneで削除できる - Javaの場合:JDK+Maven(〜700MB)→ JRE+app.jar(〜170MB)に削減可能
docker historyでレイヤー確認するとbuilderステージが含まれていないことがわかる- pom.xmlを先にコピーしてキャッシュ活用すると、ソース変更時のビルドが高速になる


コメント