良いDockerfileを書く:Spring Bootアプリケーション向けマルチステージビルド

1. 概要

急いで書いたDockerfileは、ほとんど数百MBのイメージを生成します。このイメージにはJDK、Maven、ソースコード、.m2ディレクトリがすべて含まれます。コード行を1つ修正するたびに、Dockerは依存関係全体をゼロから再度ダウンロードします。

本稿では、マルチステージビルドを使用してビルドステップと実行ステップを分離する方法を説明します。この記事の終わりに、Spring Boot 3.x(Java 21)アプリケーション向けのDockerfileを書くことができるようになります。そのDockerfileは小さいイメージ、キャッシュによる高速なリビルド、専用ステージでのテスト実行、非rootユーザーでのプロセス実行を実現します。

2. 単一ステージDockerfileの問題

注文状態を返すエンドポイントを持つ小さなアプリケーションを例に使用します:

package com.nonla.orders;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

@RestController
class OrderController {

    @GetMapping("/orders/{id}/status")
    String status(@PathVariable long id) {
        return id > 0 ? "CONFIRMED" : "UNKNOWN";
    }
}

pom.xmlの依存関係はweb と test のみです:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

「動作させる」ためのDockerfileは通常、次のようになります:

FROM maven:3.9-eclipse-temurin-21
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
CMD ["java", "-jar", "target/orders-0.0.1-SNAPSHOT.jar"]

このDockerfileには3つの問題があります:

  • 重いイメージ:最終イメージにはMaven、完全なJDK、ソースコード、.m2キャッシュ全体が含まれています。
  • 不十分なキャッシュ:COPY . .はmvn packageの前にあります。.javaファイルを1つ変更するだけで、このレイヤーが無効化され、すべての依存関係が再度ダウンロードされます。
  • 大きな攻撃面:ビルドツールとソースコードがproductionコンテナに残っています。

3. マルチステージビルドの基本

マルチステージビルドを使うと、1つのDockerfileに複数のFROMコマンドを持つことができます。各FROMは新しいステージを開始します。後のステージはCOPY --from=<stage name>を使用して前のステージからファイルをコピーできます。最後のステージだけが最終イメージになります。中間ステージは破棄されます。

3.1. ビルドステージと実行ステージ

# Stage 1: build
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN mvn -B package -DskipTests

# Stage 2: runtime
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /workspace/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

各部分の説明:

  • AS buildは最初のステージに名前を付けます。この名前をCOPY --from=buildで使用します。
  • ビルドステージはMavenとJDKを持つイメージを使用してコンパイル、jarをパッケージ化します。
  • 実行ステージはeclipse-temurin:21-jreを使用します。このイメージはJREのみを持ち、javacやMavenはありません。
  • COPY --from=buildは最終イメージにjarファイルのみをコピーします。
  • ENTRYPOINTはexec形式(JSONの配列)です。これによりjavaがPID 1として実行され、コンテナが停止時にSIGTERMシグナルを受け取ります。

3.2. 結果の比較

ビルドしてサイズを確認:

docker build -t orders:single -f Dockerfile.single .
docker build -t orders:multi .
docker images orders

結果の例(実際の数値はイメージバージョンと依存関係に依存します):

REPOSITORY   TAG      SIZE
orders       single   ~560MB
orders       multi    ~290MB

残りの大部分のストレージはJREとベースOSです。eclipse-temurin:21-jre-alpineのようなより軽いベースイメージを使用して、さらに削減できます。ただし、アプリケーションがネイティブライブラリを使用している場合は慎重にテストしてください。

4. レイヤーキャッシュを活用する

各RUN、COPYコマンドはレイヤーを作成します。Dockerはコマンドと入力が変更されていない場合、レイヤーを再利用します。レイヤーが変更されると、その後のすべてのレイヤーは再度ビルドする必要があります。したがって、一般的な原則は変更が少ないものを先に、変更が多いものを後に配置することです。

4.1. 依存関係ダウンロードステップの分離

pom.xmlはソースコードよりもはるかに変更が少ないです。最初にコピーして、別のレイヤーで依存関係をダウンロードします:

FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B package -DskipTests

これでOrderController.javaのみを編集する場合、Dockerはdependency:go-offlineレイヤーを再利用します。ビルドログは以下のようになります:

 => CACHED [build 3/5] COPY pom.xml .
 => CACHED [build 4/5] RUN mvn -B dependency:go-offline
 => [build 5/5] COPY src ./src

注意:dependency:go-offlineは常にpackageステップでMavenが必要とするプラグインの100%をダウンロードするわけではありません。いくつかのプラグインは後で依然としてダウンロードされる可能性があります。ただ、結果はずっと良いです。

4.2. BuildKitでのキャッシュマウント

BuildKit(現在のDockerのデフォルト)は--mount=type=cacheをサポートします。マウントされたディレクトリはビルド間で保持されますが、イメージには含まれません:

# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests
  • # syntax=docker/dockerfile:1行は新しいDockerfile構文を有効にします。
  • /root/.m2はbuilderでキャッシュされます。pom.xmlが変更されても、Mavenはすべてを再度ダウンロードするのではなく、新しい依存関係のみをダウンロードします。

両方の手法を組み合わせることができます。CIでは、キャッシュマウントはビルダーがビルド間で保持されている場合にのみ効果があります。そうでない場合は、セクション4.1でのレイヤー分離とレジストリキャッシュの組み合わせがより信頼できるアプローチです。

5. Spring Boot jarのレイヤー分離

Spring BootのファットjarはあなたのコードとMavenの数十MBのライブラリの両方を含みます。jar全体をイメージにコピーすると、コードを編集するたびに、数十MBの新しいレイヤーが作成されます。レジストリはそのレイヤーを保存し、サーバーはすべてを再度プルする必要があります。

Spring Bootは、変更頻度に応じてjarを複数のレイヤーに抽出することをサポートしています。Spring Boot 3.3以降では、jarmode=toolsを使用できます:

# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests

FROM eclipse-temurin:21-jre AS extract
WORKDIR /extract
COPY --from=build /workspace/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination layers

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=extract /extract/layers/dependencies/ ./
COPY --from=extract /extract/layers/spring-boot-loader/ ./
COPY --from=extract /extract/layers/snapshot-dependencies/ ./
COPY --from=extract /extract/layers/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

主要なポイント:

  • extractステージはjarを4つのディレクトリに抽出します:dependencies、spring-boot-loader、snapshot-dependencies、application。
  • 4つのCOPYコマンドは4つのレイヤーを作成し、最も変更が少ないものから最も変更が多いものへと並びます。
  • コードを編集すると、applicationレイヤーだけが変更されます。このレイヤーは通常、数百KBのみです。
  • --launcherオプションはlauncherを保持します。したがって、ENTRYPOINTは-jarではなくJarLauncherを呼び出します。

注意:Spring Boot 3.0–3.2の場合、古い構文java -Djarmode=layertools -jar app.jar extractを使用します。launcherのパッケージはバージョン間で異なります:3.2以降はorg.springframework.boot.loader.launch.JarLauncher、それ以前はorg.springframework.boot.loader.JarLauncherです。誤ったクラス名を使用すると、コンテナ起動時にClassNotFoundExceptionが発生します。

6. 専用ステージでのテスト実行

ビルドステージで-DskipTestsを設定するとビルドが高速化されますが、テスト前のイメージをレジストリにプッシュしたくないでしょう。マルチステージを使うと、テストを別のステージに分離し、--targetで必要なステージを選択できます。

JUnit 5とAssertJを使用したコントローラーのテスト(spring-boot-starter-testに既に含まれている):

package com.nonla.orders;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    void givenPositiveId_whenGetStatus_thenConfirmed() throws Exception {
        String body = mockMvc.perform(get("/orders/42/status"))
                .andReturn().getResponse().getContentAsString();

        assertThat(body).isEqualTo("CONFIRMED");
    }

    @Test
    void givenZeroId_whenGetStatus_thenUnknown() throws Exception {
        String body = mockMvc.perform(get("/orders/0/status"))
                .andReturn().getResponse().getContentAsString();

        assertThat(body).isEqualTo("UNKNOWN");
    }
}

testステージをビルドステージに基づいて追加します:

FROM build AS test
RUN --mount=type=cache,target=/root/.m2 mvn -B test

CIでは、テストを実行してから実行時イメージをビルドします:

docker build --target test -t orders:test .
docker build -t orders:1.0.0 .

最初のコマンドはtestステージまでのみビルドします。テストが失敗すると、RUNコマンドはゼロ以外の終了コードを返し、ビルドが停止します:

[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
ERROR: failed to solve: process "/bin/sh -c mvn -B test" did not complete successfully: exit code: 1

2番目のコマンドは最後のステージをビルドします。BuildKitでは、testステージが最終ステージによって参照されていないため、スキップされます。productionイメージはテストを再度実行する必要がありません。キャッシュのおかげで、共有ステージも再度ビルドされません。

注意:Docker(例えばTestcontainers)を必要とするintegration testは、docker build内で実行しないでください。ビルド環境にはDocker daemonがありません。このタイプのテストはCI内の外部で実行してください。

7. セキュリティ:非rootユーザーと.dockerignore

7.1. rootで実行しない

デフォルトでは、コンテナのプロセスはrootとして実行されます。アプリケーションが悪用された場合、攻撃者はコンテナ内でroot権限を持ちます。実行時ステージで専用ユーザーを作成しましょう:

FROM eclipse-temurin:21-jre
RUN groupadd --system spring && useradd --system --gid spring spring
WORKDIR /app
COPY --from=extract --chown=spring:spring /extract/layers/dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/spring-boot-loader/ ./
COPY --from=extract --chown=spring:spring /extract/layers/snapshot-dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/application/ ./
USER spring:spring
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

クイックチェック:

docker run --rm --entrypoint id orders:1.0.0
uid=999(spring) gid=999(spring) groups=999(spring)

具体的なuid/gidは異なる可能性がありますが、0ではありません。Alpineイメージの場合、groupaddとuseraddの代わりにaddgroup -S spring && adduser -S spring -G springを使用します。

7.2. .dockerignoreを追加

ビルドコンテキストは、docker buildに渡すディレクトリ全体です。フィルター処理しない場合、Dockerはtarget/、.git/、IDE設定ファイルもbuilderに送信します。これはビルドを遅くし、キャッシュを破壊し、機密情報を漏らす可能性があります。

target/
.git/
.idea/
*.iml
.vscode/
.env
Dockerfile*

このファイルをプロジェクトのルートディレクトリに配置します。Dockerfileの隣です。

8. よくあるエラー

  • COPY . .が早すぎる:依存関係キャッシュを破壊します。pom.xmlを最初にコピーし、srcを後でコピーしましょう。
  • 実行時ステージでJDKを使用:動作しますが、数十〜数百MB余分です。productionは通常、JREのみが必要です(jcmdなどの診断ツールが必要な場合を除く)。
  • 複数のjarに一致するワイルドカード:COPY --from=build /workspace/target/*.jar app.jarは、target/に*-plain.jarや*-sources.jarがある場合エラーになります。pom.xmlで<finalName>を固定するか、補助的なartifactを無効にしましょう。
  • ENTRYPOINTでShell形式:ENTRYPOINT java -jar app.jarは/bin/sh -cを通じて実行されます。この場合、javaはSIGTERMを受け取らないため、Springはgracefullyにシャットダウンできず、タイムアウト後にコンテナがkillされます。
  • ベースイメージのlatestタグ:今日のビルドと来月のビルドが異なる可能性があります。バージョンを固定してください(例:eclipse-temurin:21-jre)。または、完全な再現が必要な場合はdigestを固定します。
  • ARG/ENVにシークレットを入れる:値はdocker historyで漏らされる可能性があります。プライベート依存関係をダウンロードするためのトークンが必要な場合、RUN --mount=type=secretを使用してください。

**複雑にしないでいいのは何時?**小さなスクリプトまたは個人の開発環境のみで使用されるイメージの場合、1ステージで十分です。それ以外の場合、チームが既にmvn spring-boot:build-image(Cloud Native Buildpacks)またはJibを使用している場合、これらのツールは既にレイヤー分離と非rootを処理しています。手でDockerfileを書く必要はありません。

9. まとめ

マルチステージビルドは「ビルド環境」と「実行環境」を明確に分離します。最終イメージは、アプリケーションが実際に必要とするもののみを含みます。適切なレイヤー順序、キャッシュマウント、Spring Boot jarレイヤー分離、専用テストステージ、非rootユーザーと組み合わせることで、ビルドが高速で、イメージが小さく、セキュアなDockerfileが得られます。

セクション3のDockerfileから始めることができます。その後、各改善を段階的に適用し、各ステップ後のビルド時間とイメージサイズを測定して、改善の効果を確認しましょう。

dockerdockerfilemulti-stage buildspring bootjava