Spring BootをスリムなDocker Imageにパッケージング

1. 概要

Spring BootをDockerにパッケージングすることは簡単です。Imageを小さく、高速に構築し、安全に実行するには、いくつかの追加ステップが必要です。一般的な「3行」のDockerfileでは、imageは通常数百MBの大きさになり、コードを1行修正するたびにJAR全体を再度pushする必要があり、プロセスはrootユーザーで実行されます。

このセクションでは、素朴なDockerfileからmulti-stage Dockerfileへ移行し、layered jarと最小化されたJREを使用します。その後、Buildpacksを代替案として使用する方法を見ていき、最後に構築したimageを検証するTestcontainersテストを記述します。

2. サンプルアプリケーション

order-serviceという小規模なサービスを使用します。これには1つのRESTエンドポイントとActuatorがhealth checkに含まれています。

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
</dependencies>
@SpringBootApplication
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

@RestController
@RequestMapping("/orders")
class OrderController {

    record Order(long id, String customer, BigDecimal total) {}

    @GetMapping("/{id}")
    Order find(@PathVariable long id) {
        return new Order(id, "Nguyen Van A", new BigDecimal("250000"));
    }
}

コマンド./mvnw packageはtarget/order-service-1.0.0.jarファイルを生成します。これはあなたのコードと全ての依存ライブラリを含む「fat jar」です。

3. 素朴なDockerfileとその問題

多くの人が最初に書くバージョンは次のようになります。

FROM eclipse-temurin:21
COPY target/order-service-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

このDockerfileは動作しますが、4つの問題があります。

  • Base imageが大きすぎます。 eclipse-temurin:21はJDK全体を含みます。コンパイラ、jshell、javadocなどです。実行時にはこれらは不要です。
  • 全体が1つのレイヤーです。 Fat jarはおよそ20~30MBですが、あなたのコードはわずか数百KB程度です。1行のコードを修正するたびに、その30MBのレイヤー全体が変更されるため、再ビルドと再pushが必要になります。
  • ビルドマシンに依存します。 Imageは事前にビルドされたJARファイルが必要です。各マシンのJDKとMavenのバージョンが異なる可能性があります。
  • rootユーザーで実行されます。 アプリケーションが侵害された場合、攻撃者はcontainer内でroot権限を持ちます。

サイズを確認します。

docker build -t order-service:naive .
docker images order-service
REPOSITORY      TAG     IMAGE ID       SIZE
order-service   naive   3f1c9a7e2b10   ~480MB

具体的な数字はbase imageのバージョンによって異なりますが、「hello world」サービスに対して半GB近くというのは多すぎます。

4. Layered jarを使用したMulti-stage build

4.1. Layered jarとは

Spring Boot 2.3以降、fat jarは変更頻度に基づいて複数のレイヤーに事前に分割されています。

レイヤー 内容 変更頻度
dependencies リリースバージョンのライブラリ まれ
spring-boot-loader Spring Boot Class loader 非常にまれ
snapshot-dependencies -SNAPSHOTライブラリ 時々
application あなたのコードとリソース 頻繁に

各部分をimageにコピーします。個別のCOPYコマンドを使用し、変更が少ないものから頻繁に変更されるものの順序で実行します。Dockerは上位のレイヤーをキャッシュします。コードを修正する場合、applicationレイヤーだけが再ビルドされて再pushされます。

Spring Boot 3.3以降、jarmode=toolsを使用してレイヤーを抽出できます。

java -Djarmode=tools -jar target/order-service-1.0.0.jar extract --layers --destination extracted
ls extracted
application  dependencies  snapshot-dependencies  spring-boot-loader

applicationディレクトリには、「スリム化された」order-service-1.0.0.jarファイルが含まれています。このファイルはdependenciesレイヤー内のlib/ディレクトリを参照します。レイヤーを結合した後、通常通りjava -jarを実行できます。

注意: Spring Boot 3.2以前では、構文はjava -Djarmode=layertools -jar app.jar extractです。その場合、entrypointは-jarではなくorg.springframework.boot.loader.launch.JarLauncherです。

4.2. 完全なDockerfile

# ---------- Stage 1: Mavenでビルド ----------
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

# ---------- Stage 2: レイヤーを抽出 ----------
FROM eclipse-temurin:21-jre-alpine AS extract
WORKDIR /extract
COPY --from=build /workspace/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted

# ---------- Stage 3: ランタイム ----------
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app

COPY --from=extract /extract/extracted/dependencies/ ./
COPY --from=extract /extract/extracted/spring-boot-loader/ ./
COPY --from=extract /extract/extracted/snapshot-dependencies/ ./
COPY --from=extract /extract/extracted/application/ ./

USER spring:spring
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"

HEALTHCHECK --interval=30s --timeout=3s --start-period=30s \
  CMD wget -qO- http://localhost:8080/actuator/health || exit 1

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

各部分の説明:

  • Stage build :COPY pom.xmlを実行してから、srcをコピーする前にdependency:go-offlineを実行します。コードを修正する場合、Dockerはダウンロード済みの依存関係を含むレイヤーを再利用します。Maven Centralから全てを再度ダウンロードする必要がありません。
  • Stage extract :JARを4つのレイヤーディレクトリに分割します。このstageは中間段階であり、最終imageには表示されません。
  • Stage runtime :21-jre-alpineを使用し、Alpine Linux上のJREのみが含まれます。
  • 4つのCOPYコマンド :各コマンドがDockerレイヤーを作成し、変更が少ないものから頻繁に変更されるものの順序になっています。「スリム化された」JAR(app.jar)がapplicationにあり、lib/ディレクトリはdependenciesにあるため、レイヤーを結合した後、/appはapp.jar + lib/構造になります。
  • USER spring:spring :Javaプロセスはrootユーザーで実行されなくなります。
  • JAVA_TOOL_OPTIONS :JVMはこの変数を自動的に読み込みます。MaxRAMPercentage=75.0はheapをcontainerのメモリ制限の最大75%に設定します。ExitOnOutOfMemoryErrorはメモリ不足時にJVMを終了させ、orchestratorがcontainerを再起動できるようにします。
  • HEALTHCHECK :AlpineにはBusyBoxのwgetが付属しているため、curlを追加でインストールする必要はありません。
  • ENTRYPOINT形式(JSON配列) :javaがPID 1になり、containerが停止時にSIGTERM信号を受け取ります。これにより、Spring Bootはgraceful shutdownを実行できます。

4.3. .dockerignoreを忘れずに

buildコマンドは現在のディレクトリ全体(build context)をDockerデーモンに送信します。不要なものは除外しましょう。

target/
.git/
.idea/
*.iml
.mvn/wrapper/maven-wrapper.jar
Dockerfile

このファイルがないと、target/または.git/ディレクトリでcontextが数百MBになる可能性があります。毎回のbuildが著しく遅くなります。

4.4. 結果

docker build -t order-service:1.0.0 .
docker images order-service
docker history order-service:1.0.0 --format "{{.Size}}\t{{.CreatedBy}}" | head -8
REPOSITORY      TAG     IMAGE ID       SIZE
order-service   1.0.0   9b2e44d1c0af   ~200MB
order-service   naive   3f1c9a7e2b10   ~480MB

0B      ENTRYPOINT ["java" "-jar" "app.jar"]
...
45kB    COPY /extract/extracted/application/ ./
0B      COPY /extract/extracted/snapshot-dependencies/ ./
290kB   COPY /extract/extracted/spring-boot-loader/ ./
22MB    COPY /extract/extracted/dependencies/ ./

Imageは半分以上小さくなりました。さらに重要なのは、applicationレイヤーがわずか数十KBに減少したことです。OrderControllerを修正してビルドを再実行する場合、そのレイヤーだけが変更されます。registryへのpushはわずか数秒で完了します。

5. 代替案:Cloud Native Buildpacks

Dockerfileを自分で維持したくない場合、Spring BootのMaven pluginはBuildpacksを使用してimageを直接構築できます。

./mvnw spring-boot:build-image \
  -Dspring-boot.build-image.imageName=shop/order-service:1.0.0

pom.xmlで永続的な設定を行うこともできます。

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <image>
            <name>shop/order-service:${project.version}</name>
            <env>
                <BP_JVM_VERSION>21</BP_JVM_VERSION>
            </env>
        </image>
    </configuration>
</plugin>

Buildpacksは上記で手動で行ったことを自動で行います。レイヤーの分割、JREの選択、非rootユーザーでの実行、JVMのメモリ計算。Dockerfileは不要で、Dockerデーモンが実行されているだけで十分です。

しかし、制御性は低くなります。Base imageはbuildpackが決定し、builder imageのダウンロードが必要なため最初のビルドは遅く、Dockerfileの変更に比べて深いカスタマイズは難しくなります。迅速に標準化したいチームはBuildpacksを使用すべきです。詳細な制御が必要なチーム(例えば、セキュリティレビュー済みの内部base imageを使用する場合)はDockerfileを使用すべきです。

6. TestcontainersでImageをテストする

起動できない「コンパクト」imageは意味がありません。Dockerfileからimageを構築し、containerを実行して正しく動作することを確認するテストを記述できます。

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>

Spring Boot BOMは両方のライブラリのバージョンを管理しているため、<version>を宣言する必要はありません。

@Testcontainers
class OrderServiceImageIT {

    static final ImageFromDockerfile IMAGE =
            new ImageFromDockerfile("order-service-it", false)
                    .withDockerfile(Path.of("./Dockerfile"));

    @Container
    static final GenericContainer<?> APP = new GenericContainer<>(IMAGE)
            .withExposedPorts(8080)
            .waitingFor(Wait.forHttp("/actuator/health").forStatusCode(200))
            .withStartupTimeout(Duration.ofMinutes(2));

    @Test
    void shouldServeOrdersAndReportHealthy() throws Exception {
        String baseUrl = "http://" + APP.getHost() + ":" + APP.getMappedPort(8080);
        HttpClient client = HttpClient.newHttpClient();

        HttpResponse<String> response = client.send(
                HttpRequest.newBuilder(URI.create(baseUrl + "/orders/42")).build(),
                HttpResponse.BodyHandlers.ofString());

        assertThat(response.statusCode()).isEqualTo(200);
        assertThat(response.body()).contains("\"id\":42", "Nguyen Van A");
    }

    @Test
    void shouldNotRunAsRoot() throws Exception {
        var result = APP.execInContainer("id", "-u");

        assertThat(result.getStdout().trim()).isNotEqualTo("0");
    }
}

このテストは以下を実行します。

  • ImageFromDockerfile はプロジェクトのルートディレクトリのDockerfileからimageを構築します。Build contextはDockerfileを含むディレクトリです。パラメータfalseはテスト後にimageを保持し、次回のビルドがキャッシュにより高速化されます。
  • Wait.forHttp("/actuator/health") はcontainerが起動するだけでなく、アプリケーションが実際に準備完了するまで待機します。
  • 最初のテスト はhostのランダムにマップされたポート経由でエンドポイントを呼び出し、返されたJSONを検証します。
  • 2番目のテスト はcontainer内でid -uを実行します。誰かが誤ってUSER spring:spring行を削除した場合、結果は0になりテストは即座に失敗します。

クラス名にサフィックスITを付け、maven-failsafe-pluginで実行します。このテストはimageも構築するため数十秒かかり、定期的なユニットテストでの実行には適していません。

7. よくあるエラーと適用しない方が良い場合

  • ENTRYPOINTをshell形式でjava -jar app.jarとして使用する。 その場合、/bin/shがPID 1となり、SIGTERMをJVMに転送しません。タイムアウト後、containerは強制終了されgraceful shutdownが失われます。常にJSON配列形式を使用してください。
  • -Xmxを固定値に設定する。 -Xmx2gを設定してもcontainerの制限が1 GBの場合、containerはスタックトレースなしでOOM-killedされます。Heapがlimitに応じてスケーリングされるようMaxRAMPercentageを使用してください。
  • AlpineとNativeライブラリ。 Alpineはglibcではなくmuslを使用します。Native codeを持つ一部のライブラリ(Netty native transport、一部のドライバー、画像処理ライブラリなど)はエラーが発生したり遅くなったりする可能性があります。その場合は、eclipse-temurin:21-jre-jammyまたは-nobleに切り替えてください。Imageは数十MB大きくなりますが、より安定しています。
  • COPYレイヤーが間違った順序である。 dependenciesの前にapplicationをコピーすると、あらゆるコード変更がdependencyレイヤーのキャッシュを無効にします。Layered jarのすべての利点が失われます。
  • Base imageにlatestタグを使用する。 今日のビルドと来週のビルドが異なるimageになる可能性があります。21.0.5_11-jre-alpineなどの特定のタグにpinするか、完全に再現可能なビルドが必要な場合はdigestにpinしてください。

注意: さらに小さいimageが必要な場合、jdepsとjlinkを使用してアプリケーションに必要なモジュールのみを含むJREを作成するか、GraalVMを使用してnativeにコンパイルできます。どちらの方法も大幅なサイズ削減が可能ですが、ビルドが大幅に複雑になり、reflectionが存在する場合は脆くなります。Imageサイズまたはスタートアップ時間が実際に問題となる場合(例えば、serverlessまたはscale-to-zeroの場合)のみ適用すべきです。

8. 結論

このセクションでは、3行のDockerfileをmulti-stage Dockerfileに変換しました。その手順は以下の通りです。

  • containerでビルドしてbuild machineの依存性をなくす
  • コード変更時に変更されるレイヤーのみが再ビルドされるようにlayered jarを分割する
  • Imageサイズを削減するためJRE Alpineを使用する
  • 非rootユーザーで実行し、health checkを含め、containerのメモリ制限に基づいてJVMを構成する

Buildpacksはdockerfileが不要な代替案であることも学びました。最後に、Testcontainersテストは、変更後もimageが起動でき、rootで実行されていないことを確認するのに役立ちます。

spring-bootdockerjavadevopscontainer