Dockerfile 進階多階段建構

基礎複習:Builder + Runtime 模式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Stage 1: 建構
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /app
COPY . .
RUN ./mvnw package -q -DskipTests

# Stage 2: 執行(只複製 jar,不含 JDK 與 source code)
FROM eclipse-temurin:21-jre-jammy
COPY --from=builder /app/target/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

多階段建構的目的:最終 image 只含執行所需的檔案,不含原始碼、建構工具、測試依賴。


Cache Mount:加速重複建構

每次 docker build 若依賴沒有改變,Maven/npm 都會重新下載套件,非常耗時。--mount=type=cache 讓套件管理器的 cache 跨 build 持久化。

Maven

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /app
COPY pom.xml mvnw ./
COPY .mvn .mvn
# 先只複製 pom.xml,cache mount 讓 ~/.m2 跨 build 保留
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw dependency:go-offline -q

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

先 COPY pom.xml 再下載依賴,只有 pom.xml 改變時才重新下載,source 變更不影響依賴層的 cache。

npm / pnpm

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./

# npm cache
RUN --mount=type=cache,target=/root/.npm \
    npm ci --prefer-offline

# pnpm cache
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile

COPY . .
RUN pnpm build

Gradle

1
2
3
4
5
RUN --mount=type=cache,target=/root/.gradle \
    ./gradlew dependencies --no-daemon -q

RUN --mount=type=cache,target=/root/.gradle \
    ./gradlew bootJar --no-daemon -q

Secret Mount:機密不進 Layer

不要把機密寫入 image layer(即使之後 RUN rm ~/.npmrc,layer history 仍保留):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 錯誤:機密殘留在 layer history
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc \
    && npm install \
    && rm ~/.npmrc
# docker history 或 docker save 仍可找到 .npmrc

# 正確:secret 只在 build 期間存在,不進 image
RUN --mount=type=secret,id=npmtoken \
    NPM_TOKEN=$(cat /run/secrets/npmtoken) \
    npm config set //registry.npmjs.org/:_authToken "${NPM_TOKEN}" \
    && npm install
1
2
3
4
# 建構時傳入 secret
docker build --secret id=npmtoken,src=./npm-token.txt -t my-app .
# 或從環境變數
docker build --secret id=npmtoken,env=NPM_TOKEN -t my-app .

私有 Maven Repository:

1
2
3
RUN --mount=type=secret,id=maven-settings,dst=/root/.m2/settings.xml \
    --mount=type=cache,target=/root/.m2/repository \
    ./mvnw package -q -DskipTests

Distroless:最小攻擊面

gcr.io/distroless 系列 image 不含 shell、套件管理器、不必要的系統工具,大幅減少攻擊面:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw package -q -DskipTests

# 無 shell,無套件管理器
FROM gcr.io/distroless/java21-debian12
COPY --from=builder /app/target/app.jar /app.jar
# ENTRYPOINT 必須用 exec form(JSON array),不能用 shell form
ENTRYPOINT ["java", "-jar", "/app.jar"]

注意:distroless 無 sh,docker exec -it container bash 會失敗。偵錯需用:

1
2
3
# 用 debug variant(含 busybox shell)
docker run --rm gcr.io/distroless/java21-debian12:debug sh
# 或在建構時加入 debug stage

jlink:自訂最小 JRE

jlink 可以從 JDK 模組中抽出應用實際需要的部分,生成自訂 JRE:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
FROM eclipse-temurin:21-jdk AS jlink-builder
# 先分析 jar 需要哪些模組
RUN java -jar /opt/java/openjdk/lib/ct.sym 2>/dev/null; \
    jdeps --ignore-missing-deps \
          --print-module-deps \
          --multi-release 21 \
          app.jar > /tmp/modules.txt || true

# 建構自訂 JRE(已知模組清單)
RUN jlink \
    --add-modules java.base,java.logging,java.net.http,java.sql,java.management,jdk.crypto.ec \
    --output /jre-custom \
    --strip-debug \
    --no-header-files \
    --no-man-pages \
    --compress=2

# 最終 image:基於 slim debian,只含自訂 JRE
FROM debian:12-slim
COPY --from=jlink-builder /jre-custom /opt/jre
COPY --from=builder /app/target/app.jar /app.jar
ENV PATH="/opt/jre/bin:$PATH"
ENTRYPOINT ["java", "-jar", "/app.jar"]

各 Image 大小比較

基礎 Image大約大小
eclipse-temurin:21-jdk-jammy~460 MB
eclipse-temurin:21-jre-jammy~260 MB
eclipse-temurin:21-jre-alpine~190 MB
gcr.io/distroless/java21-debian12~145 MB
debian:12-slim + jlink 自訂 JRE~90–120 MB
scratch + jlink 靜態 binary(GraalVM native)~50 MB

小 image 帶來的優勢:

  • 更快 pull:CI/CD 縮短部署時間
  • 更小攻擊面:無不必要的二進位檔
  • 更便宜:Container Registry 儲存費用

ARG vs ENV

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# ARG:只在 build 階段存在,不進入最終 image
ARG APP_VERSION=1.0.0
ARG BUILD_DATE

# ENV:在 image 和 container 執行時都存在
ENV JAVA_OPTS="-Xmx512m -Xms256m"
ENV APP_HOME=/app

# 使用 ARG
RUN echo "Building version: ${APP_VERSION}"

# ARG 可以傳給 ENV(讓 build-time 值進入 runtime)
ARG GIT_COMMIT
ENV GIT_COMMIT_SHA=${GIT_COMMIT}
1
2
# 傳入 ARG
docker build --build-arg APP_VERSION=2.0.1 --build-arg GIT_COMMIT=$(git rev-parse HEAD) -t my-app .

安全注意:ARG 值雖然不在最終 image 的 env 中,但會出現在 docker history 的 build 命令裡。不要用 ARG 傳遞機密,改用 --mount=type=secret。

1
2
3
4
# ARG 值可在 docker history 看到
docker history my-app
# IMAGE          CREATED BY
# ...            /bin/sh -c #(nop) ARG APP_VERSION=2.0.1  ← 可見

完整 Spring Boot 生產 Dockerfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# Stage 1: 下載依賴(利用 cache layer)
FROM eclipse-temurin:21-jdk-alpine AS deps
WORKDIR /app
COPY pom.xml mvnw ./
COPY .mvn .mvn
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw dependency:go-offline -q

# Stage 2: 建構
FROM deps AS builder
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw package -q -DskipTests
# Spring Boot Layered Jar
RUN java -Djarmode=tools -jar target/app.jar extract --layers --destination target/extracted

# Stage 3: 執行(distroless)
FROM gcr.io/distroless/java21-debian12 AS runtime
ARG APP_VERSION=unknown
ARG GIT_COMMIT=unknown
LABEL org.opencontainers.image.version="${APP_VERSION}"
LABEL org.opencontainers.image.revision="${GIT_COMMIT}"

WORKDIR /app
# Layered jar:dependencies 變動較少,先 COPY 利用 Docker cache
COPY --from=builder /app/target/extracted/dependencies/ ./
COPY --from=builder /app/target/extracted/spring-boot-loader/ ./
COPY --from=builder /app/target/extracted/snapshot-dependencies/ ./
COPY --from=builder /app/target/extracted/application/ ./

EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Spring Boot Layered Jar 讓 application layer(最常變動)與 dependencies layer(不常變動)分開,大幅提升 CI 建構時的 Docker layer cache 命中率。


小結

  • --mount=type=cache 讓 Maven/npm 跨 build 重用套件,節省數分鐘建構時間。
  • --mount=type=secret 是傳遞機密的唯一安全方式,不進 layer history。
  • distroless 是生產環境的首選 runtime image,無 shell 攻擊面小。
  • jlink 可進一步縮小 JRE,適合對 image size 有要求的場景。
  • ARG 只給 build-time 使用,機密永遠用 secret mount,不用 ARG 或 ENV。

留言討論