到此為止你已經有了全部的零件知識。這一章把它們組起來,從一個普通的 Spring Boot 專案,做出一個真的能部署的映像。
作法是「一版一版改進」——先寫一個能跑但很爛的版本,然後逐一指出它的問題、逐一修好。這樣你拿到的不只是一份可以抄的 Dockerfile,而是知道每一行為什麼在那裡。
中間會特別停下來講一個 Java 專屬、而且極常見的地雷:JVM 看不到容器的記憶體限制,於是把自己撐死。(就是 ch02 那個 Exited (137)。)
假設你有一個再普通不過的 Spring Boot 專案:
myapp/
├── pom.xml
├── src/
│ └── main/
│ ├── java/com/example/myapp/MyappApplication.java
│ └── resources/application.yml
└── target/ ← mvn package 之後才會出現
└── myapp-0.0.1-SNAPSHOT.jarmvn package 產生的那個 jar 裡面已經包含了所有相依函式庫(Spring、Jackson、JDBC driver…)和一個內嵌的 Tomcat。java -jar target/myapp-0.0.1-SNAPSHOT.jar知道目標長怎樣之後,寫 Dockerfile 就只是把這三件事翻譯成指令而已。
eclipse-temurin:17-jre)。至於編譯需要的 JDK,等一下用多階段建置解決——編譯用完就丟,不進最終映像。先做出一個能動的。在專案根目錄建立 Dockerfile:
# === 第一版:可以跑,但很多問題(等一下逐一修) ===
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
COPY target/myapp-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]FROM eclipse-temurin:17-jre-jammy:以 Eclipse Temurin 的 JRE 17 為基底(jammy = Ubuntu 22.04)。用明確版本而不是 latest(ch04)。WORKDIR /app:設定工作目錄,之後的相對路徑以此為準(ch08)。COPY ...:把本機 target/ 下的 jar 複製進映像並改名 app.jar。改名是為了避免 Dockerfile 寫死版本號(不然每次改版本都要改 Dockerfile)。EXPOSE 8080:文件宣告,實際要靠 -p(ch07)。CMD [...]:exec form,讓 java 成為 PID 1,SIGTERM 收得到(ch05)。# ① 先在本機打包(第一版的缺點之一,等下會解決)
mvn clean package -DskipTests
# ② 建置映像
docker build -t myapp:1.0 .
# ③ 執行
docker run -d --name myapp -p 8080:8080 myapp:1.0
# ④ 確認
docker logs -f myapp
curl http://localhost:8080/actuator/health
# ⑤ 進去看看(觀察,不要改東西)
docker exec -it myapp bash
# ⑥ 清掉
docker rm -f myappmvn package。同事沒裝 Maven、或 JDK 版本不同,結果就不一樣——ch01 那個「在我電腦上可以跑」又回來了。這是 Java 開發者最該懂、卻最少人講清楚的一段。
-Xmx 時預設約為「可見記憶體的 1/4」)。Exited (137),docker inspect 顯示 OOMKilled: true。docker run --rm -m 512m eclipse-temurin:17-jre java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
# 應該顯示約 128 MB(512 的 1/4),而不是宿主機的 1/4容器裡通常只跑你一個應用,把 75% 留著不用太浪費。正確作法是用比例而非固定值:
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0"
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]MaxRAMPercentage 而不是寫死 -Xmx384m?JVM 除了 heap 還要用:metaspace(類別資訊)、執行緒堆疊(每條約 1 MB)、JIT 編譯的程式碼快取、直接記憶體(NIO buffer)、GC 自己的結構。
MaxRAMPercentage=75 留 25% 給這些「非 heap」開銷,是經驗上合理的起點。docker inspect myapp --format '{{.State.OOMKilled}} {{.State.ExitCode}}'
# true 137 → 確定是被 OOM Killer 殺的
docker stats myapp # 即時看「已用 / 上限」
# 讓 JVM 在真的 OOM 時留下證據
ENV JAVA_OPTS="-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof"
docker cp myapp:/tmp/heapdump.hprof ./ # 撈出來分析解決第一版的問題 ①(依賴本機 Maven)與 ②(映像大)。核心概念:在映像裡編譯,但編譯環境不進最終映像。
# syntax=docker/dockerfile:1
# ========== 第 1 階段:建置(用完就丟)==========
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
# 先只複製 pom.xml,讓依賴下載可以被快取(ch08 第一設計原則)
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn dependency:go-offline -B
# 再複製原始碼(常變動的放後面)
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn clean package -DskipTests -B
# ========== 第 2 階段:執行(這才是最終映像)==========
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
# 建立非 root 使用者(ch10 會深入)
RUN groupadd -r spring && useradd -r -g spring spring
# 只從建置階段複製「成品」,Maven、原始碼、.m2 通通不會進來
COPY --from=builder --chown=spring:spring /build/target/*.jar app.jar
USER spring
EXPOSE 8080
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0"
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]COPY --from=builder /build/target/*.jar app.jar 是整個技巧的核心:只把第一階段的「成品」複製過來。第一階段那個含 Maven、JDK、所有原始碼、200 個下載的 jar 的環境(大約 800 MB)完全不會出現在最終映像裡。$JAVA_OPTS 這個變數,而 exec form 不經過 shell 就不會展開。exec——這讓 java 取代 shell、繼承 PID 1,訊號才收得到(ch05、ch08)。少了這個 exec,前面所有的優雅關機設定都白費。ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/app.jar"]。鐵律:映像只有一份,環境差異全部靠外部注入。絕對不要為 dev / test / prod 各建一個映像——那樣你正式環境跑的,就不是你測過的那個東西。
Spring Boot 會自動把環境變數對應到設定屬性,規則很單純:點改成底線、全部大寫。
spring.datasource.url → SPRING_DATASOURCE_URL
spring.datasource.username → SPRING_DATASOURCE_USERNAME
spring.jpa.hibernate.ddl-auto → SPRING_JPA_HIBERNATE_DDLAUTO (連字號直接去掉)
server.port → SERVER_PORTdocker run -d \
-e SPRING_PROFILES_ACTIVE=prod \
-e SPRING_DATASOURCE_URL='jdbc:sqlserver://sqlserver:1433;databaseName=mydb' \
-e SPRING_DATASOURCE_USERNAME=sa \
-e SPRING_DATASOURCE_PASSWORD="$DB_PASSWORD" \
myapp:1.0sqlserver:1433 不是 localhost——ch07 那個頭號錯誤。--spring.datasource.url=...)application.yml)application-{profile}.ymlapplication.yml(只放各環境共通的預設值)application.yml 裡打包進 jar(永久留在映像層,ch03)。ENV(docker inspect 就看得到)。docker-compose.yml(這份檔案會進版控)。正確作法(由簡到嚴謹):
.env 檔(務必加進 .gitignore)+ compose 的 ${VAR} 展開。ENV TZ=Asia/Taipei(更嚴謹的做法是全系統統一用 UTC,只在顯示層轉換。)解決第一版的問題 ③(快取效率差)。這是 Spring Boot 官方提供的進階技巧。
fat jar 是單一檔案,裡面 95% 是不常變的第三方函式庫、5% 是你的程式碼。但 Docker 的層是以檔案為單位——你改一行程式碼,整個 60 MB 的 jar 雜湊就變了,那一層完全重建、推送時整個 60 MB 重傳。
java -Djarmode=layertools -jar app.jar list
dependencies ← 第三方函式庫(幾乎不變,最大)
spring-boot-loader ← Spring Boot 載入器(幾乎不變)
snapshot-dependencies ← SNAPSHOT 版依賴(偶爾變)
application ← 你自己的程式碼與資源(每次都變,最小)照這個順序複製到映像,每一個變成獨立的層——於是改程式碼時,只有最後那個幾 MB 的 application 層需要重建與推送。
# syntax=docker/dockerfile:1
# ========== 階段 1:編譯 ==========
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn dependency:go-offline -B
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn clean package -DskipTests -B
# ========== 階段 2:拆解 fat jar ==========
FROM eclipse-temurin:17-jre-jammy AS extractor
WORKDIR /extract
COPY --from=builder /build/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
# ========== 階段 3:最終映像 ==========
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
RUN groupadd -r spring && useradd -r -g spring spring
# 依「變動頻率由低到高」逐層複製 —— ch08 的第一設計原則
COPY --from=extractor --chown=spring:spring /extract/dependencies/ ./
COPY --from=extractor --chown=spring:spring /extract/spring-boot-loader/ ./
COPY --from=extractor --chown=spring:spring /extract/snapshot-dependencies/ ./
COPY --from=extractor --chown=spring:spring /extract/application/ ./
USER spring
EXPOSE 8080
ENV TZ=Asia/Taipei
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
# 注意:分層之後不能再用 -jar,要用 Spring Boot 的啟動類別
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", \
"org.springframework.boot.loader.launch.JarLauncher"]java -jar app.jar(jar 已經被拆開了),要用 JarLauncher。Spring Boot 3.2 起類別路徑是 org.springframework.boot.loader.launch.JarLauncher,3.1 以前是 org.springframework.boot.loader.JarLauncher(少了 .launch)——寫錯會噴 ClassNotFoundException。HEALTHCHECK 的 --start-period=60s 一定要留,Spring Boot 冷啟動很慢(ch05)。| 面向 | 第一版(單階段) | 第二版(多階段) | 第三版(分層 JAR) |
|---|---|---|---|
| 需要本機裝 Maven | 要 | 不用 | 不用 |
| 最終映像大小 | 約 250 MB | 約 250 MB(建置環境不進來) | 約 250 MB(層分得更細) |
| 改一行程式碼要重建什麼 | 整個 jar 那層(60 MB) | 整個 jar 那層(60 MB) | 只有 application 層(幾 MB) |
| 重新下載 Maven 依賴 | 本機自己處理 | 不用(pom 先 COPY + cache mount) | 不用 |
| 以什麼身分執行 | root ⚠️ | 非 root(spring) | 非 root(spring) |
| 推送到 registry 的量 | 整層 60 MB | 整層 60 MB | 只有幾 MB |
| 適合 | 第一次理解流程 | 多數專案的合理起點 | CI/CD 高頻部署 |
| 參數 | 作用 | 建議值 | 為什麼 |
|---|---|---|---|
| -XX:MaxRAMPercentage | heap 上限佔容器記憶體的比例 | 75.0 | 留 25% 給 metaspace、執行緒堆疊、JIT 快取等非 heap 開銷 |
| -XX:InitialRAMPercentage | 初始 heap 比例 | 50.0 | 減少啟動期間反覆擴張 heap 的開銷 |
| -Xmx(寫死) | 固定 heap 上限 | 避免使用 | 寫死的值會跟著映像跑到每個環境,無法自動適應配額 |
| -XX:+HeapDumpOnOutOfMemoryError | OOM 時自動產生 heap dump | 開啟 | 留下現場證據,配合 docker cp 撈出來分析 |
| 容器記憶體上限(-m) | cgroup 限制 | Spring Boot ≥ 512 MB | 太小會啟動即掛;JDK 10+ 會自動讀取這個值 |
點擊卡片翻面查看答案,共 11 張。