---
title: "Spring BootのDockerfileの書き方｜マルチステージ・レイヤー展開・AOTキャッシュを4.1.1で検証"
url: "https://www.issoh.co.jp/tech/details/1929/"
published: 2024-02-07
updated: 2026-10-12
categories: ["Spring"]
publisher: "株式会社一創"
---

# Spring BootのDockerfileの書き方｜マルチステージ・レイヤー展開・AOTキャッシュを4.1.1で検証

Spring BootのDockerfileの古い例には、今は取得できない `openjdk` のタグを指定しているものが多く、そのまま写すと1行目の `FROM` で止まります。`openjdk` イメージのGA版タグが、Docker Hubから消えているためです。この記事では、Spring Boot 4.1.1（2026年10月時点の最新GA）とJava 25で、2026年10月12日に実際にビルドが通ったDockerfileだけを載せます。先に完成形を示し、続けて各行の理由と、MavenとGradleで分かれる箇所、本番で効く実行時の設定を説明します。

## まとめ

- **ベースイメージ**：`openjdk:17-jdk-alpine` や `openjdk:21` はmanifestがHTTP 404を返します。`eclipse-temurin:25-jre` などへ置き換えます。
- **骨格**：ビルドステージ（Maven＋JDK）と実行ステージ（JRE）に分けます。依存のダウンロードは `RUN --mount=type=cache` でビルドをまたいで残します。
- **レイヤー展開**：`java -Djarmode=tools -jar application.jar extract --layers` でuber jarをほどきます。旧構文の `layertools` は4.1.0で削除済みで、4.1.1では `Error: Unsupported jarmode 'layertools'` になります。
- **起動高速化**：Java 25以降はAOTキャッシュです。後述の検証では起動が約4.1〜4.6秒から約1.8秒に縮みました。展開後のjarでないと効きません。
- **実行時**：UIDで非root化、`-XX:MaxRAMPercentage` でヒープの割合を指定（既定は25%）、ヘルスチェックはcurlに頼らない（`eclipse-temurin` のJREイメージにはcurlもwgetも入っていません）。

## 完成形のDockerfile（Maven・Java 25）

単一モジュールのMavenプロジェクトのルートに置く構成です。[Spring Initializr](https://start.spring.io/) で作ったSpring Boot 4.1.1の雛形（Spring Web＋Actuator・Java 25）を、Docker 29.5.2とbuildx v0.38.0でビルドして動作を確かめています。

```
# syntax=docker/dockerfile:1
# ---- ビルドステージ ----
FROM maven:3.9-eclipse-temurin-25 AS build
WORKDIR /build
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B -q dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B -q -DskipTests package \
 && cp target/*.jar application.jar \
 && java -Djarmode=tools -jar application.jar extract --layers --destination extracted

# ---- 実行ステージ ----
FROM eclipse-temurin:25-jre
RUN groupadd --system --gid 10001 spring \
 && useradd --system --uid 10001 --gid 10001 --create-home spring
WORKDIR /application
COPY --from=build /build/extracted/dependencies/ ./
COPY --from=build /build/extracted/spring-boot-loader/ ./
COPY --from=build /build/extracted/snapshot-dependencies/ ./
COPY --from=build /build/extracted/application/ ./
# AOTキャッシュの訓練実行（Java 25以降）
RUN java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar application.jar
USER 10001:10001
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"
EXPOSE 8080
ENTRYPOINT ["java", "-XX:AOTCache=app.aot", "-jar", "application.jar"]
```

```
docker build -t demo:0.1.0 .
docker run --rm -p 8080:8080 -m 1g demo:0.1.0
```

同じ条件（colima上のx86\_64 VM、`-m 1g` によるメモリ上限1GiB）で3回ずつ起動したときの値です。この雛形での値なので、依存の多いアプリでは起動時間も短縮幅も変わります。

| 項目                     | 値            |
| ---------------------- | ------------ |
| 起動時間（AOTキャッシュあり）       | 1.791〜1.831秒 |
| 起動時間（同じイメージでキャッシュ指定なし） | 4.113〜4.611秒 |
| dependencies層          | 21.9MB       |
| application層           | 12.3kB       |
| AOTキャッシュ（app.aot）層     | 57.3MB       |

起動時間はログの `Started DemoApplication in ... seconds` の値です。アプリのコードを1行直して再ビルドすると、依存の層の `COPY` はキャッシュ扱い（CACHED）になり、作り直されたのは `application` 層と訓練実行の層だけでした。以下、上から分解して説明します。

## openjdkのGA版タグ削除とビルド失敗

[Docker Hubのopenjdkリポジトリ](https://hub.docker.com/%5F/openjdk)には「This image is officially deprecated and all users are recommended to find and use suitable replacements ASAP.」という告知があり、「The only tags which will continue to receive updates beyond July 2022 will be Early Access builds」と続きます。リポジトリ自体は更新が続いているので、Docker Hubを開くと現役に見えます。しかし残っているのはEarly Accessのタグだけで、旧記事が使っていたGA版のタグはmanifestを返しません。

| イメージ:タグ                | manifest応答 | 中身             |
| ---------------------- | ---------- | -------------- |
| openjdk:17-jdk-alpine  | 404        | なし             |
| openjdk:21             | 404        | なし             |
| openjdk:27-ea          | 200        | 27-ea+34       |
| openjdk:28-ea-slim     | 200        | 28-ea+18       |
| eclipse-temurin:25-jre | 200        | jdk-25.0.4.1+1 |

2026年10月12日にDocker Registry API（registry v2）へ問い合わせた結果です。`FROM openjdk:17-jdk-alpine` は**pullに失敗してビルドが止まります**。次のコマンドでHTTPステータスを確かめられます。

```
TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/openjdk:pull" \
  | python3 -c "import json,sys;print(json.load(sys.stdin)['token'])")

curl -s -o /dev/null -w "%{http_code}\n" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/vnd.oci.image.index.v1+json" \
  https://registry-1.docker.io/v2/library/openjdk/manifests/17-jdk-alpine
```

Early Accessタグは開発中のJDKなので、本番の置き換え先にはなりません。告知は代替の例として amazoncorretto、eclipse-temurin、ibm-semeru-runtimes、ibmjava、sapmachine を挙げています。ディストリビューションの比較と配布手順は[Spring BootアプリのDockerデプロイ手順](/tech/details/2297/)にまとめてあります。

## ベースイメージの選定基準

### eclipse-temurinとLibericaの違い

この記事は `eclipse-temurin` を使い、[Spring Boot公式ドキュメントのDockerfile例](https://docs.spring.io/spring-boot/reference/packaging/container-images/dockerfiles.html)は `bellsoft/liberica-openjre-debian:25-cds` を使っています。後者はDocker Official Imageの枠外なので、「公式イメージのみ」という社内ルールがあると公式例をそのまま写せません。対応CPUにも差があり、Apple SiliconのMacとx86\_64のサーバーで同じイメージを使うなら、両方のアーキテクチャがあるかを先に見ます（registry v2のマニフェストで確認）。

| イメージ:タグ                                 | 圧縮サイズ（amd64） | 対応アーキテクチャ                         |
| --------------------------------------- | ------------ | --------------------------------- |
| eclipse-temurin:25-jre                  | 120.1MB      | amd64・arm64・ppc64le・s390x         |
| eclipse-temurin:25-jdk                  | 153.6MB      | amd64・arm64・ppc64le・s390x         |
| bellsoft/liberica-openjre-debian:25-cds | 128.1MB      | amd64・arm64                       |
| maven:3.9-eclipse-temurin-25            | 176.3MB      | amd64・arm64・ppc64le・riscv64・s390x |

複数アーキテクチャのイメージをCIで作る設定は[GitHub ActionsでDockerイメージをビルドする](/tech/details/16943/)で扱っています。

### ENTRYPOINT上書き時のCA証明書取り込み

`eclipse-temurin` の既定 `Entrypoint` は `["/__cacert_entrypoint.sh"]` で、コンテナ内の `/certificates` に置いたCA証明書を、環境変数 `USE_SYSTEM_CA_CERTS` が設定されているときにtruststoreへ取り込むラッパーです（[Docker Hubのeclipse-temurinの説明](https://hub.docker.com/%5F/eclipse-temurin)）。自前の `ENTRYPOINT` はこれを上書きするので、この仕組みで社内CAを入れる環境では `ENTRYPOINT ["/__cacert_entrypoint.sh", "java", "-jar", "application.jar"]` のように前段を残します。

### イメージタグの固定と更新管理

`25-jre` のようなメジャー版タグは中身が入れ替わります。2026年8月19日の `25-jre` は `JAVA_VERSION=jdk-25.0.3+9` でしたが、10月12日には `jdk-25.0.4.1+1` になり、圧縮サイズも111.4MBから120.1MBに増えました。再現性が要るなら `eclipse-temurin:25.0.4.1_1-jre` のようにパッチ版まで、監査対象ならダイジェストまで固定します。ただし8月に固定した `25.0.3_9-jre` は今もpullできる代わりに、10月時点では2世代前のJVMです。Renovateなどの更新ボットか月1回の見直しとセットにしないと、固定は古いJVMを使い続ける理由になります。

## マルチステージによるビルド環境と実行環境の分離

### jarコピーの最小構成とマルチステージ構成

ビルド済みのjarが `target/` にあれば、4行で動きます。

```
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY target/*.jar application.jar
ENTRYPOINT ["java", "-jar", "application.jar"]
```

ただしjarのビルドが手元の環境に依存します。マルチステージ構成にすると、Mavenとソースはビルドステージに閉じ込め、実行ステージにはjarだけを渡せます。

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

FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=build /build/target/*.jar application.jar
ENTRYPOINT ["java", "-jar", "application.jar"]
```

実行ステージをJREにするだけで、ベースの圧縮サイズは `maven:3.9-eclipse-temurin-25` の176.3MBから `eclipse-temurin:25-jre` の120.1MBへ下がり、Mavenのローカルリポジトリとソースツリーも最終イメージに残りません。Spring Boot 4.1.1の[システム要件](https://docs.spring.io/spring-boot/system-requirements.html)はJava 17以上26以下、Maven 3.6.3以上、Gradleは8.14以上の8.x系か9.x系です。ベースイメージのJavaは、この公式の対応範囲と、`pom.xml` の `java.version` の両方を満たすものを選びます。Spring Bootの各版のサポート期限は[Spring Bootバージョン一覧とサポート期限](/tech/details/5172/)で確認できます。

### Maven依存のキャッシュマウント

`COPY pom.xml` と `COPY src` を分けているのは、Dockerが命令単位でキャッシュを使うからです。ソースだけを直した再ビルドでは `mvn dependency:go-offline` がキャッシュから復元されます。`COPY . .` の1行にすると、ソースを触るたびに依存を取り直します。ただしこの方式でも、`pom.xml` を1行変えれば全依存の再ダウンロードになります。そこで完成形では、BuildKitのキャッシュマウント（`RUN --mount=type=cache,target=/root/.m2`）でMavenのローカルリポジトリをビルドの外に置いています。[Dockerのドキュメント](https://docs.docker.com/build/cache/optimize/)はこれを「persistent package cache」と呼び、レイヤーを作り直しても新しいパッケージだけを取りに行くと説明しています。キャッシュの中身はイメージに入りません。

BuildKitが動いていないと、ここで止まります。buildxプラグインが無いDocker 29.5.2は旧ビルダーで動き、`the --mount option requires BuildKit` で失敗しました。colimaやLinuxで素のDocker Engineを入れた環境では `docker buildx version` を先に確認します。BuildKitの仕組みとキャッシュの種類は[BuildKitとは](/tech/details/15701/)、Macでの環境構築は[Colimaとは？MacでDocker Desktopを外す導入手順と設定の落とし穴](/tech/details/17842/)で扱っています。

### .dockerignoreによるビルドコンテキストの除外

[ルートの .dockerignore](https://docs.docker.com/build/concepts/context/) にマッチしたファイルは、ビルダーへ送られる前に除外されます。ビルドをコンテナ内で行う完成形なら、手元のビルド成果物も除外できます。

```
.git/
.idea/
.vscode/
target/
build/
.gradle/
*.iml
```

手元のjarを `COPY target/*.jar` する最小構成やデバッグ用では、このCOPYが失敗します。その場合は `Dockerfile.debug.dockerignore` のようにDockerfile名を前置した除外ファイルを置くと、ルートの `.dockerignore` より優先されます。命令そのものの意味は[Dockerfileとは｜書き方・主要命令・ベストプラクティスを解説](/tech/details/3315/)で個別に解説しています。

## Gradleで書くときの違い

Spring Boot公式のDockerfile例には「Adjust this to ‘build/libs/\*.jar’ if you’re using Gradle」というコメントがあります。ところが、Gradleで `./gradlew build` を実行すると `build/libs` にjarが2本並びます。

```
$ ./gradlew build
$ ls build/libs
demo-0.0.1-SNAPSHOT-plain.jar
demo-0.0.1-SNAPSHOT.jar
```

`-plain.jar` は依存を含まない通常のjarです。[Spring BootのGradleプラグインのドキュメント](https://docs.spring.io/spring-boot/gradle-plugin/packaging.html)によると、`bootJar` タスクを設定すると `jar` タスクの分類子が `plain` になり、実行可能jarと通常のjarが同時に作られます。この状態で `COPY build/libs/*.jar application.jar` と書くと、ビルダーによって結果が変わります。

- 旧ビルダー（`DOCKER_BUILDKIT=0`）は、[Dockerfileリファレンス](https://docs.docker.com/reference/dockerfile/)の「複数のソースを指定したらコピー先はスラッシュで終わるディレクトリでなければならない」という規則どおりエラーで止まります。
- buildx v0.38.0のBuildKitではエラーにならず、2本が同じファイル名へ順に上書きされました。名前順で `-plain.jar` が先、実行可能jarが後に来るため実行可能jarが残り、起動もします。仕様として保証された動作ではありません。

```
Step 3/4 : COPY build/libs/*.jar application.jar
When using COPY with more than one source file, the destination must be a directory and end with a /
```

BuildKitで動いているのは並び順のおかげにすぎません。`tasks.named("jar") { enabled = false }` で通常のjarを作らないようにするか、Dockerfile側で `bootJar` タスクだけを実行します。

```
// build.gradle（Groovy DSL）
tasks.named("jar") {
    enabled = false
}
```

ビルドステージをGradleで書くと次の形です。`gradle:9-jdk25` イメージはrootで動き `GRADLE_USER_HOME` を設定していないので、キャッシュの置き場は `/root/.gradle` になります。実行ステージは完成形と同じです。

```
FROM gradle:9-jdk25 AS build
WORKDIR /build
COPY settings.gradle build.gradle ./
COPY src ./src
RUN --mount=type=cache,target=/root/.gradle gradle bootJar --no-daemon \
 && cp build/libs/*.jar application.jar \
 && java -Djarmode=tools -jar application.jar extract --layers --destination extracted
```

## jarmode=toolsによるレイヤー展開

### extractが作る4つのディレクトリ

Spring Bootのuber jarは依存ライブラリとアプリのクラスを1ファイルに詰めています。そのままCOPYすると全体が1レイヤーになるため、Spring Bootはjarをほどくモードを持っています。

```
$ java -Djarmode=tools -jar application.jar

Usage:
  java -Djarmode=tools -jar application.jar

Available commands:
  extract      Extract the contents from the jar
  list-layers  List layers from the jar that can be extracted
  help         Help about any command
```

`extract --layers` は `dependencies`・`spring-boot-loader`・`snapshot-dependencies`・`application` の4ディレクトリを作ります。変更頻度の低い順に別々の `COPY` で積むと、アプリだけを直したときに作り直される層を `application` 層に限定できます（AOTキャッシュを足した完成形では、訓練実行の層も作り直しと転送の対象になります）。先の実測では依存の層21.9MBに対してアプリの層は12.3kBでした。この雛形では `spring-boot-loader` と `snapshot-dependencies` は空です（SNAPSHOT版の依存が無いため）。

### 展開後のjar名は元のファイル名のまま

公式例が `COPY ${JAR_FILE} application.jar` でいったん名前を変えてから展開しているのには理由があります。`extract` が作る起動用jarは、**展開元のjarと同じファイル名**になります。完成形の `cp target/*.jar application.jar` を省くと起動用jarは `demo-0.0.1-SNAPSHOT.jar` になり、訓練実行の `RUN java ... -jar application.jar` が `Error: Unable to access jarfile application.jar` で失敗しました。[AOTキャッシュのドキュメント](https://docs.spring.io/spring-boot/reference/packaging/aot-cache.html)も、展開後に元の名前の `my-app.jar` を起動する例になっています。ビルドとレイヤー展開を1つのステージにまとめるときは、展開前に名前をそろえておきます。

### Spring Boot 4.1.0でのlayertools削除と移行先

ネット上の例には `-Djarmode=layertools` がまだ多く残っています。版によって動いたり止まったりします。

| Spring Bootの版                 | jarmode=layertools | jarmode=tools |
| ----------------------------- | ------------------ | ------------- |
| 2.3.0〜3.2.x                   | 使用可                | 未提供           |
| 3.3.0〜4.0.x（2026年10月時点は4.0.8） | 使用可（互換のため残置）       | 使用可           |
| 4.1.0以降                       | 削除済み               | 使用可           |

境目はソースで確定できます。`spring-boot-jarmode-tools` の `META-INF/spring.factories` を見ると、v4.0.8は `LayerToolsJarMode` と `ToolsJarMode` の2つを登録し、v4.1.1は `ToolsJarMode` だけです。4.1.1のjarで旧構文を実行した結果が次の出力です。

```
$ java -Djarmode=layertools -jar demo-0.0.1-SNAPSHOT.jar list
Error: Unsupported jarmode 'layertools'
```

3.5系や4.0系のうちは旧構文でも動くので気付きにくく、4.1へ上げた時点でイメージのビルドが落ちます。上げる前にリポジトリ全体へ `grep -r layertools` をかけておくと安全です。

## AOTキャッシュによる起動時間の短縮

### Java 25以降の訓練実行

Spring Bootのドキュメントは「Spring Boot supports the AOT cache for Java 25 and above.」としています。訓練実行（training run）でクラスのロードやリンクの結果をファイルに保存し、本番起動でそれを読み込む仕組みです。

```
# 訓練実行：起動して初期化が終わった時点で終了する
RUN java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar application.jar

# 本番起動：焼いたキャッシュを読ませる
ENTRYPOINT ["java", "-XX:AOTCache=app.aot", "-jar", "application.jar"]
```

`-Dspring.context.exit=onRefresh` はアプリケーションコンテキストのリフレッシュが終わった時点でプロセスを終わらせる指定です。これが無いと訓練実行がサーバーとして起動したままになり、ビルドが終わりません。訓練実行中には `[warning][aot] class ... cannot be archived` という警告が数行出ますが、一部のクラスがキャッシュに入らなかったという通知で、ビルドは成功します。

ドキュメントには「You have to use the cache file with the extracted form of the application, otherwise it has no effect.」とあり、展開していないuber jarに指定してもエラーにならないまま効果がゼロになります。キャッシュが使われているかは起動ログから判別しにくいので、[JEP 483](https://openjdk.org/jeps/483)が示す `-XX:AOTMode=on` を付けると、キャッシュを使えないときにJVMがエラーで終了します。完成形のイメージではこの確認が終了コード0で通り、存在しないファイルを指定すると `An error has occurred while processing the AOT cache` で止まりました。

```
docker run --rm --entrypoint java demo:0.1.0 \
  -XX:AOTCache=app.aot -XX:AOTMode=on -Dspring.context.exit=onRefresh -jar application.jar
```

キャッシュを再利用できるのは「アプリを更新せず、同じJavaのバージョンを使う限り」です。訓練実行をDockerfileの中に置けば、この2条件をビルドのたびに満たせます。ただし訓練実行は `docker build` の中でアプリを起動するので、初期化中にDBなど外部サービスへ接続するアプリは、接続先が無くて失敗することがあります。[Spring BootのAOTキャッシュのhow-to](https://docs.spring.io/spring-boot/how-to/aot-cache.html)は、訓練実行のときだけ外部への接続を止める設定を入れるよう案内しています。

### Java 17・21でのCDS設定

Java 17や21で運用しているなら、AOTキャッシュの前身であるCDS（Class Data Sharing）を使います。手順は同じで、フラグと出力ファイル名が変わるだけです。

```
RUN java -XX:ArchiveClassesAtExit=application.jsa -Dspring.context.exit=onRefresh -jar application.jar
ENTRYPOINT ["java", "-XX:SharedArchiveFile=application.jsa", "-jar", "application.jar"]
```

なお公式ドキュメントは、AOTキャッシュのページでは「Java 25 or above」、DockerfileのページのCDS節では「If you’re using Java 24 or later, please use AOT cache instead of CDS.」と、異なる境界を書いています。サポート範囲を明示しているのは前者なので、本記事は25で区切っています。

## 本番で効く実行時の設定

### 非root実行とexec形式のENTRYPOINT

`USER` を指定しなければ、コンテナのプロセスはrootで動きます。`eclipse-temurin` 側もユーザーを設定していないので、明示的に落とします。`groupadd` を先に実行しているのは、`useradd --system` がGIDを自動で割り当て、`--uid 10001` だけではGIDが10001になる保証が無いためです。`USER` を数値のUIDで書くと、Kubernetesの `runAsNonRoot` がユーザー名を解決できずに起動を拒否する問題も避けられます。

アプリのファイルにはあえて `--chown` を付けていません。所有者はrootのままでプロセスはUID 10001なので、アプリは自分のjarやAOTキャッシュを読めても書き換えられません。`USER` を訓練実行の後ろに置くのは、`app.aot` の書き出しをroot権限で行うためです。

`ENTRYPOINT` はJSON配列（exec形式）で書きます。[Dockerfileリファレンス](https://docs.docker.com/reference/dockerfile/)によると、シェル形式では `/bin/sh -c` の子プロセスとして起動し、javaがPID 1にならず `docker stop` のSIGTERMを受け取りません。SIGTERMが届かなければSpring Bootのグレースフルシャットダウンは始まらず、猶予時間のあとSIGKILLで強制終了されます。停止順序の設計は[グレースフルシャットダウンとは？SIGTERM受信後の停止順序と猶予時間の設計](/tech/details/16202/)で扱っています。

### コンテナのメモリ上限とJVMヒープ比率

JVMはコンテナのメモリ上限を認識してヒープの上限を決めます。[JDK 25のjavaコマンドのマニュアル](https://docs.oracle.com/en/java/javase/25/docs/specs/man/java.html)にある通り、`-XX:MaxRAMPercentage` の既定値は25%です。`-m 512m`（512MiB）のコンテナでは、既定の最大ヒープはその4分の1の128MiBです。

```
docker run --rm -m 512m --entrypoint java eclipse-temurin:25-jre \
  -XX:+PrintFlagsFinal -version | grep " MaxHeapSize "
```

| コンテナの上限 | 既定（25%） | MaxRAMPercentage=75 |
| ------- | ------- | ------------------- |
| 512MiB  | 128MiB  | 384MiB              |
| 2GiB    | 512MiB  | 1,536MiB            |

完成形では `ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"` とし、`docker run -e` やKubernetesのマニフェストから値を変えられるようにしています。起動時に `Picked up JAVA_TOOL_OPTIONS: ...` と1行出るのは正常です。残りはメタスペースやスレッドスタックなどヒープ以外に使われるので、75%から上げすぎるとコンテナごとOOMで落ちます。GCの選び方とログの見方は[Javaのガベージコレクション（GC）とは](/tech/details/3084/)にまとめています。

### curl未導入イメージでのヘルスチェック

Dockerfileに `HEALTHCHECK CMD curl -f http://localhost:8080/actuator/health` と書く例をよく見ますが、`eclipse-temurin:25-jre` ではこれが動きません。イメージ内にcurl・wget・ncのいずれも存在しないためです。[Adoptiumのイメージ定義](https://github.com/adoptium/containers/blob/main/25/jre/ubuntu/noble/Dockerfile)では、JDKのダウンロードに使ったwgetを同じ `RUN` の中で削除しています。

Kubernetesなら、Actuatorのプローブ用エンドポイントをマニフェストに書きます。[Spring Bootのドキュメント](https://docs.spring.io/spring-boot/reference/actuator/endpoints.html)は `/actuator/health/liveness` と `/actuator/health/readiness` を挙げています。

```
livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
```

ComposeでHEALTHCHECKが要るなら、実行ステージに `apt-get install curl` を足すか、既存のJREで動くチェック用クラスを同梱するかを、追加パッケージの更新管理とチェック処理の保守のどちらを引き受けるかで選びます。エンドポイントの公開範囲の設定は[Spring Boot Actuatorの設定｜exposure.includeとaccessの使い分け](/tech/details/1931/)を参照してください。

## コンテナ内のSpring Bootへのデバッガ接続

### JDWPポートを開くDockerfileとCompose

JVMでJDWPエージェントを有効にしてポートを外へ出します。本番用とは別に開発用のDockerfileを作ります。

```
# Dockerfile.debug（開発用・本番には使わない）
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY target/*.jar application.jar
EXPOSE 8080 5005
ENTRYPOINT ["java", \
  "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005", \
  "-jar", "application.jar"]
```

要点は `address` の書き方です。[JPDAの接続仕様](https://docs.oracle.com/en/java/javase/25/docs/specs/jpda/conninv.html)では「If <host> is empty, the local loopback address is used.」とされ、`address=5005` だけだとコンテナ内のループバックで待ち受けるため、ホスト側のIDEから接続できません。ホスト部を `*` にするとワイルドカードアドレスで待ち受けます。`suspend=n` はデバッガの接続を待たずに起動する指定です。

Compose側はデバッグポートを足すだけです。トップレベルの `version` は書きません。[Composeのドキュメント](https://docs.docker.com/reference/compose-file/version-and-name/)は「It is only informative and you’ll receive a warning message that it is obsolete if used.」としています。

```
name: springboot-debug
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.debug
    ports:
      - "8080:8080"
      - "127.0.0.1:5005:5005"
```

公開先を `127.0.0.1` に限定しているのは、JDWPに認証が無いからです。`"5005:5005"` と書いて全インターフェースに開くと、同じネットワークの誰でも任意のコードを実行できます。

### VS Codeのリモートアタッチ設定

VS CodeではExtension Pack for Javaに含まれるDebugger for Javaを使い、`.vscode/launch.json` にアタッチ構成を足します。

```
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "java",
      "name": "Attach to Docker",
      "request": "attach",
      "hostName": "localhost",
      "port": 5005,
      "projectName": "demo"
    }
  ]
}
```

[VS CodeのJavaデバッグのドキュメント](https://code.visualstudio.com/docs/java/java-debugging)では、`projectName` はワークスペースに複数のJavaプロジェクトがある場合に必須で、欠くと式の評価と条件付きブレークポイントが動かないことがあるとされています。エディタごとコンテナに寄せたいなら[Dev Container（devcontainer）のメリットとは](/tech/details/3728/)の構成のほうが素直です。

## Dockerfileを書かない選択

**イメージの中身に要件が無いなら、Dockerfileを書かないほうが手間は少なく済みます**。Spring Bootのビルドプラグインは、Cloud Native Buildpacks（CNB）でjarから直接OCIイメージを作れます。

```
mvn spring-boot:build-image
```

[Mavenプラグインのドキュメント](https://docs.spring.io/spring-boot/maven-plugin/build-image.html)によると、既定のビルダーは `paketobuildpacks/builder-noble-java-tiny:latest` で、「For security reasons, images build and run as non-root users.」とある通り非rootで動きます。初回はビルダーイメージのpullが入るので、手書きの `docker build` より時間がかかることがあります。AOTキャッシュも、Java 25以降でビルド時の環境変数 `BP_JVM_AOTCACHE_ENABLED=true` を渡せばbuildpackが訓練実行まで行います。

手書きのDockerfileが必要になるのは、CNB非互換のベースイメージ（distrolessなど）に固定したい場合や、ネイティブライブラリやフォントを足す場合のように、イメージの構成を直接決めたいときです。なお `build-image` は「requires access to a Docker daemon」と明記されています。Dockerfileのビルドにもビルダーは要るので、デーモンを置けないCIでは、[独立したBuildKit](https://docs.docker.com/build/builders/)を用意するか、レジストリへ直接出力する[Jib](https://github.com/GoogleContainerTools/jib)（`jib-maven-plugin`、2026年10月時点の最新は3.5.2）を選びます。JibはSpring Bootのレイヤー定義とは別のレイヤー分けになるため、本記事のレイヤー展開とは併用しません。作ったイメージをECSやEKSなどの本番基盤へ載せる設計は、一創の[インフラ構築（AWS・Google Cloud・Azure）](/service/system/aws/)で支援しています。コンテナの起動・停止コマンドの使い分けは[Dockerコンテナ起動コマンドの使い分け](/tech/details/3935/)、コンテナの隔離の仕組みは[Dockerの仕組みを図解](/tech/details/2661/)が参考になります。

## よくある質問

### Spring BootのDockerfileでopenjdkイメージが使えなくなったのはなぜですか？

Docker Hubの `openjdk` が公式に非推奨となり、GA版タグのmanifestが削除されたためです。2026年10月12日時点で `openjdk:17-jdk-alpine` や `openjdk:21` はHTTP 404を返し、応答するのは `27-ea` のようなEarly Accessタグだけです。`eclipse-temurin` などへ置き換えます。

### jarmodeのlayertoolsとtoolsはどう違いますか？

`tools` が現行の構文で、`layertools` は旧構文です。`tools` はSpring Boot 3.3.0で導入され、互換のために残っていた `layertools` は4.1.0で削除されました。4.1.1で旧構文を実行すると `Error: Unsupported jarmode 'layertools'` で止まります。展開コマンドは `java -Djarmode=tools -jar application.jar extract --layers` です。

### Spring Bootをマルチステージビルドにすると何が速くなりますか？

マルチステージ化は実行イメージからビルドツールを外す手段で、アプリの実行速度は変わりません。実行ステージからJDKとMavenが消えるため、ベースだけで176.3MBが120.1MBになり、転送量が減ります。ビルド時間はキャッシュの置き方で決まります。起動を速くしたい場合は、AOTキャッシュ（Java 25以降）かCDSを別に組み込みます。

### Dockerfileを書かずにSpring Bootのイメージを作れますか？

作れます。`mvn spring-boot:build-image`（Gradleは `bootBuildImage`）でCloud Native Buildpacksがjarからイメージを作り、既定のビルダーは `paketobuildpacks/builder-noble-java-tiny:latest` です。生成されるイメージは非rootで動きます。Dockerデーモンへのアクセスが必要なので、デーモンを使えないCIでは、レジストリへ直接出力するJibか、独立したBuildKitでDockerfileをビルドする構成を選びます。

### コンテナの中で動くSpring Bootにデバッガをつなぐには何が必要ですか？

JVMの起動オプションに `-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005` を加え、ポート5005をコンテナの外へ公開します。`address` のホスト部を省くとループバックだけで待ち受けるため、`*` を明示します。JDWPは無認証なので、公開先は `127.0.0.1:5005:5005` のようにローカルに限定してください。

## 関連記事

- [Spring BootアプリのDockerデプロイ手順｜jar作成からコンテナ起動・本番配布まで](/tech/details/2297/)
- [Dockerfileとは｜書き方・主要命令・ベストプラクティスを解説【2026年版】](/tech/details/3315/)
- [BuildKitとは｜Dockerの新ビルドバックエンドの仕組み・キャッシュ/secret/マルチアーキと採用判断を実装者目線で解説](/tech/details/15701/)
- [Spring Boot Actuatorの設定｜exposure.includeとaccessの使い分け【4.1対応】](/tech/details/1931/)
- [GitHub ActionsでDockerイメージをビルドする｜push先とキャッシュ・マルチアーキの設計](/tech/details/16943/)

---

出典: [Spring BootのDockerfileの書き方｜マルチステージ・レイヤー展開・AOTキャッシュを4.1.1で検証](<https://www.issoh.co.jp/tech/details/1929/>)（株式会社一創）
