Dockerマルチステージビルド完全解説 ─ Javaで学ぶイメージ軽量化・レイヤー構造・FROM2つの謎

Docker

この記事では、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 の命令(RUNCOPYADD)がそれぞれ1つのレイヤーを生み出します。

    Dockerfile命令レイヤー追加サイズ(目安)
    FROM eclipse-temurin:21-jdkLayer 1: ベース約480MB
    RUN apt-get install mavenLayer 2+130MB
    COPY pom.xml .Layer 3+1KB
    RUN mvn dependency:go-offlineLayer 4: 依存ライブラリ+80MB
    COPY src ./srcLayer 5+500KB
    RUN mvn packageLayer 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〜800MB100〜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つだけ」です。

    各ステージの役割を整理すると次のとおりです。

    ステージ役割含まれるもの
    builderapp.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 df

    3ステップで整理すると、マルチステージビルドの流れは次のようになります。

    1. builderステージを実行する:JDK・Maven・ソースコードを全部詰め込んで mvn package を走らせ、app.jarを作る。「目的のためだけに存在する使い捨て作業場」。
    2. app.jarだけをrunnerステージへ渡すCOPY --from=builder /app/target/app.jar app.jar で完成品だけを取り出す。ビルドツール・ソース・キャッシュは一切持ち込まない。
    3. 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を先にコピーしてキャッシュ活用すると、ソース変更時のビルドが高速になる


    参考リソース

    コメント

    タイトルとURLをコピーしました