Article

🐳 JavaのDockerイメージを軽くする:マルチステージビルドを実務向けに整理する

docker, java, maven, springboot, buildkit

Java / Spring Boot のアプリケーションを Docker 化するとき、最初は次のような Dockerfile でも動きます。

FROM eclipse-temurin:17-jdk
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests
ENTRYPOINT ["java", "-jar", "target/app.jar"]

ただし、この構成では アプリをビルドするための JDK や Maven 関連ファイルまで実行環境側に残りやすい という問題があります。

そこで使いたいのが Docker の マルチステージビルド(multi-stage build) です。

この記事では、Java 17 + Maven を例に、

  • マルチステージビルドとは何か
  • なぜ本番イメージを分けるのか
  • JavaでのDockerfile例
  • Dockerのキャッシュを壊しにくい書き方
  • BuildKit の cache mount
  • 実務でハマりやすいポイント

を整理します。

マルチステージビルドとは

マルチステージビルドは、1つの Dockerfile の中に複数の FROM を書き、ビルド環境と実行環境などを分離する仕組みです。

Docker公式ドキュメントでも、各 FROM が新しいビルドステージを開始し、COPY --from=... で別ステージから必要な成果物だけをコピーできると説明されています。

https://docs.docker.com/build/building/multi-stage/

イメージすると次のようになります。

builder stage
  JDK
  Maven
  ソースコード
      ↓
   app.jar
      ↓ COPY --from=builder
runtime stage
  JRE
  app.jar

本番イメージには、コンパイルにしか使わないソースコードやビルドツールを持ち込まないようにできます。

Javaでの基本形

Spring Boot アプリを Maven Wrapper でビルドする例です。

# syntax=docker/dockerfile:1

FROM eclipse-temurin:17-jdk AS builder
WORKDIR /app

COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN chmod +x mvnw

COPY src/ src/
RUN ./mvnw package -DskipTests

FROM eclipse-temurin:17-jre AS runtime
WORKDIR /app

COPY --from=builder /app/target/*.jar app.jar

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

重要なのは次の2ステージです。

builder

FROM eclipse-temurin:17-jdk AS builder

AS builder でステージに名前を付けています。

ここではコンパイルが必要なので JDK を使用します。

runtime

FROM eclipse-temurin:17-jre AS runtime

こちらは完成したJARを実行するステージです。

そして、

COPY --from=builder /app/target/*.jar app.jar

で builder ステージから成果物だけをコピーします。

Docker公式も、ステージに名前を付けて COPY --from=<name> を使う方法を紹介しています。番号で --from=0 と書くこともできますが、名前を使えばステージの並び順を変更しても壊れにくくなります。

JDKとJREの違い

簡単に整理すると、

  • JDK(Java Development Kit): Javaをコンパイルするためのツールを含む開発環境
  • JRE(Java Runtime Environment): Javaアプリケーションを実行するための環境

です。

ビルド時にはJDKが必要でも、完成済みJARを動かすだけならコンパイラは通常必要ありません。

そのため、

ビルド → JDK
実行    → JRE

と役割を分けるのが分かりやすい設計です。

Docker公式のJava向けマルチステージ例でも、JDKのbuilderステージとJREのfinalステージを分けています。

https://docs.docker.com/get-started/docker-concepts/building-images/multi-stage-builds/

COPY . . を最初に置かない

Dockerビルドを遅くしやすい書き方があります。

COPY . .
RUN ./mvnw package

Dockerのビルドキャッシュでは、あるレイヤーが変更されると、それより後ろのレイヤーも再ビルド対象になります。

Docker公式にも、変更頻度の低い重い処理を前に、頻繁に変わる処理を後ろに置くことがキャッシュ最適化の基本として説明されています。

https://docs.docker.com/build/cache/optimize/

そのため、依存関係の定義とソースコードを分けてコピーする方法があります。

COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN chmod +x mvnw
RUN ./mvnw dependency:go-offline

COPY src/ src/
RUN ./mvnw package -DskipTests

pom.xml が変わらなければ、ソースコードだけを修正した際に依存関係取得のレイヤーを再利用できる可能性が高くなります。

ただし dependency:go-offline を入れれば必ず速くなる、というわけではありません。依存関係の構成やCI環境によって効果は変わるので、実際のビルド時間を計測して判断するのが安全です。

BuildKitのcache mountを使う

さらに BuildKit では RUN --mount=type=cache を利用できます。

BuildKit は現在の Docker で使われるビルドバックエンドです。Docker Desktop と Docker Engine では標準のbuilderとして利用されています。

https://docs.docker.com/build/buildkit/

Mavenならローカルリポジトリ ~/.m2 をキャッシュできます。

# syntax=docker/dockerfile:1

FROM eclipse-temurin:17-jdk AS builder
WORKDIR /app

COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN chmod +x mvnw

COPY src/ src/

RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw package -DskipTests

FROM eclipse-temurin:17-jre AS runtime
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

cache mount は通常のDockerレイヤーキャッシュとは少し違います。

Docker公式では、cache mount はビルドをまたいでパッケージキャッシュを保持でき、レイヤーを再実行する場合でも変更されていないパッケージを再利用できる仕組みとして説明されています。

https://docs.docker.com/build/cache/optimize/#use-cache-mounts

Mavenの場合、依存ライブラリが保存される /root/.m2 を対象にすることで、毎回すべてをダウンロードする処理を減らせます。

CIでは外部キャッシュも検討する

ローカルでは同じbuilderを使い続けるためキャッシュが効いていても、CIでは毎回新しいrunnerが起動することがあります。

この場合、builder内部だけのキャッシュでは次回のジョブに引き継げないことがあります。

BuildKitには外部キャッシュを読み書きする仕組みがあり、docker buildx build では、

--cache-from
--cache-to

を利用できます。

Docker公式も、CI/CDのようにbuilderが一時的な環境では外部キャッシュが特に有効と説明しています。

https://docs.docker.com/build/cache/optimize/#use-an-external-cache

つまり、

Dockerfile内のキャッシュ最適化
        +
CI間での外部キャッシュ

は別々に考える必要があります。

--targetで途中のステージまでビルドできる

マルチステージビルドでは、特定ステージでビルドを止めることもできます。

docker build --target builder -t my-app-builder .

このコマンドの意味は、

  • docker build: Dockerイメージをビルドする
  • --target builder: builder ステージまでを対象にする
  • -t my-app-builder: 作成したイメージに my-app-builder という名前を付ける
  • .: 現在のディレクトリをビルドコンテキストとして使う

です。

Docker公式では、特定ステージのデバッグや、debug / testing / production のように用途別ステージを用意するケースが紹介されています。

よくあるハマりどころ

1. JARの名前が想定と違う

COPY --from=builder /app/target/app.jar app.jar

と固定しているのに、実際には、

my-service-1.0.0-SNAPSHOT.jar

が生成されているケースです。

pom.xmlfinalName を固定するか、生成物を確認してDockerfileと合わせます。

2. testsをどこで実行するか曖昧になる

./mvnw package -DskipTests

はテスト実行をスキップします。

CIですでに、

./mvnw test

を実行しているならDockerイメージ作成時に省略する設計もあります。

一方、Dockerビルドだけで品質確認まで完結させたいなら、安易に -DskipTests を付けるべきではありません。

「どの工程がテストの責任を持つか」を決めることが重要です。

3. ビルドステージに秘密情報をCOPYする

最終ステージにコピーしなければ何を置いても安全、とは考えない方がよいです。

APIキーや認証情報をソースツリーに置いて COPY . . する設計自体を避けます。

ビルド時に秘密情報が必要なら、BuildKitのsecret mountなど、秘密情報をイメージやキャッシュへ残しにくい仕組みを検討します。

4. latestだけに頼る

FROM eclipse-temurin:17-jre

のようなタグは便利ですが、再現性をより重視する環境では、利用するベースイメージのタグやdigestをどこまで固定するかをチームで決めておくと安全です。

まずはこの形から始める

Javaアプリなら、まず次の構造にするだけでも責務がかなり分かりやすくなります。

# syntax=docker/dockerfile:1

FROM eclipse-temurin:17-jdk AS builder
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN chmod +x mvnw
COPY src/ src/
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw package -DskipTests

FROM eclipse-temurin:17-jre AS runtime
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

ポイントは、単に「イメージサイズを小さくするテクニック」と考えないことです。

マルチステージビルドを使うと、

何がビルドに必要なのか
何が実行時に必要なのか

をDockerfile上で明確に分離できます。

本番コンテナに必要なものを考え直すきっかけになるので、Java / Spring Bootをコンテナ化するときは最初に検討したい構成です。

参考資料