ch09 的映像可以跑了,但**「能跑」和「能上正式環境」中間還有一段距離**。
這一章處理兩個彼此相關的目標:更小和更安全。它們相關是因為同一個原因——映像裡的每一個檔案都是潛在的漏洞。少裝一個套件,就少一個 CVE、少一個攻擊者可用的工具。
內容包括一個 Java 開發者一定要知道的坑:很多人以為換成 Alpine 就能瘦身,但 Java 在 Alpine 上有真實的效能與相容性問題。
「映像小」聽起來只是省磁碟,其實影響面大得多:
ubuntu 基底裝了幾百個套件,其中大部分你的 Java 應用根本用不到——但攻擊者用得到。curl、wget、bash、apt,攻擊者拿到執行權限後連下載後續工具都做不到。這叫做降低「入侵後可用性」。docker images myapp:1.0 # 總大小
docker history myapp:1.0 # 每一層多大、由哪個指令產生
# 最好用的工具:dive(互動式逐層瀏覽)
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock \
wagoodman/dive myapp:1.0dive 會告訴你每一層加了哪些檔案、以及一個「浪費空間」的評分(例如某層新增的檔案在後面的層被刪掉了——ch03 說過那些檔案還在)。優化前先量測,不要憑感覺。
基底決定映像 80% 的大小與漏洞數量。但對 Java 來說,最直覺的選擇不一定對。
ubuntu、debian):約 70~120 MB,含完整的套件管理與工具。方便除錯,但攻擊面最大。-slim、eclipse-temurin:17-jre-jammy):拿掉文件、locale 等非必要內容。Java 的合理預設。alpine、eclipse-temurin:17-jre-alpine):只有 5~8 MB 的極簡發行版。但對 Java 有坑,見下。gcr.io/distroless/java17-debian12):只有執行時期需要的東西——沒有 shell、沒有套件管理員、沒有任何指令工具。安全性最高。FROM gcr.io/distroless/java17-debian12
COPY --from=builder /build/target/app.jar /app.jar
USER nonroot # distroless 內建 nonroot 使用者
ENTRYPOINT ["java", "-jar", "/app.jar"]docker exec -it ... bash 完全用不了(裡面沒有 bash)。除錯得靠日誌、metrics,或用 docker debug/掛一個工具容器進同一個 namespace。-jre-jammy(有 shell 好除錯)。同一份 Dockerfile 用不同的最終階段就能切換。JRE 裡包含了整個 Java 標準函式庫,但一個典型的 Spring Boot 應用只用得到其中一部分。jlink(JDK 9 起內建)可以裁出一個只含必要模組的自訂 runtime。
# ===== 階段 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:用 jlink 裁一個最小 runtime =====
FROM eclipse-temurin:17-jdk-jammy AS jre-builder
RUN $JAVA_HOME/bin/jlink \
--add-modules java.base,java.logging,java.naming,java.sql,java.desktop,java.management,java.instrument,java.security.jgss,jdk.unsupported \
--strip-debug --no-man-pages --no-header-files --compress=2 \
--output /custom-jre
# ===== 階段 3:最終映像 =====
FROM debian:12-slim
ENV JAVA_HOME=/opt/jre
ENV PATH="$JAVA_HOME/bin:$PATH"
COPY --from=jre-builder /custom-jre $JAVA_HOME
RUN groupadd -r spring && useradd -r -g spring spring
COPY --from=builder --chown=spring:spring /build/target/*.jar /app/app.jar
USER spring
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/app.jar"]jdeps 分析你的 jar:jdeps --print-module-deps --ignore-missing-deps target/app.jarNoClassDefFoundError——不是啟動就失敗,而是某個冷門路徑被走到時才爆。用 jlink 之後務必跑完整的整合測試,不要只確認能啟動。反射與動態載入(Spring 大量使用)讓 jdeps 的靜態分析可能漏掉東西,這是 jlink 最大的風險。-jre-jammy 或 distroless 就夠了——多花的維護心力不一定划算。先量測、再優化。ch02 講過:Docker 預設不啟用 user namespace,所以容器內的 root 就是宿主機的 root。如果你的應用以 root 執行又被入侵,攻擊者等於站在你的核心前面。
RUN groupadd -r spring && useradd -r -g spring -s /sbin/nologin spring
COPY --from=builder --chown=spring:spring /build/target/*.jar /app/app.jar
USER spring-r:建立系統帳號(不需要家目錄、不能登入)。--chown 很重要:檔案複製進去時就設好擁有者,避免非 root 使用者讀不到自己的 jar。USER 之後的所有指令與容器主程式都用這個身分執行。-p 80:8080 對外映射。反正 port mapping 本來就在做轉接(ch07),毫無損失。Permission denied。解法:明確建立目錄並設好擁有者,或用 tmpfs 掛一個可寫的 /tmp。docker run \
--read-only \ # 整個檔案系統唯讀
--tmpfs /tmp:size=64m \ # 只開放必要的可寫路徑
--cap-drop=ALL \ # 丟掉所有 Linux capabilities
--security-opt=no-new-privileges \ # 禁止提權(擋 setuid 攻擊)
-m 512m --cpus 1.0 \ # 資源上限(ch02 cgroup)
myapp:1.0--cap-drop=ALL:capabilities 是把 root 權限切成細項(改系統時間、載入核心模組、綁特權埠…)。一般應用一個都不需要。真的需要某一項再用 --cap-add 單獨加回來——絕不要用 --privileged 一次全開(ch02)。--read-only:攻擊者即使進來也無法植入檔案、修改程式。securityContext(runAsNonRoot、readOnlyRootFilesystem、capabilities.drop),觀念完全相同(ch14)。ch03 證明過:寫進映像的秘密刪不掉,只是被遮住。這裡把正確與錯誤的做法一次列清楚。
ENV DB_PASSWORD=xxx:永久寫進映像設定,docker inspect 或 docker history 直接看到。COPY .env /app/ 或 COPY . . 沒排除 .env:秘密進到映像層。後面再 RUN rm 也救不回來。--build-arg PASSWORD=xxx:會留在建置歷史裡,docker history 看得到。docker-compose.yml:這份檔案會進版控,等於 commit 到 Git。① 執行時用環境變數注入(開發環境的標準做法)
# .env(務必加進 .gitignore!)
MSSQL_SA_PASSWORD=YourStrong!Passw0rd
# docker-compose.yml(進版控的是這份,裡面沒有明碼)
services:
sqlserver:
environment:
MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD}② 建置階段需要憑證 → BuildKit secret mount
RUN --mount=type=secret,id=maven_settings,target=/root/.m2/settings.xml \
mvn package -B
docker build --secret id=maven_settings,src=$HOME/.m2/settings.xml .檔案只在那一個 RUN 執行期間存在,不會留在任何一層。
③ 正式環境 → 專用的秘密管理系統
docker inspect 看得到、子行程會繼承、當機時可能被寫進錯誤報告或日誌。安全等級更高的做法是掛載成檔案(K8s Secret 支援),應用讀檔案而不是讀環境變數。Spring Boot 可以用 spring.config.import=optional:file:/run/secrets/ 讀取掛載進來的秘密檔案。docker scout cves myapp:1.0 # Docker 內建
trivy image myapp:1.0 # 業界最常用的開源工具
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:1.0 # CI 用:有高風險就失敗--exit-code 1 當成建置關卡,有 CRITICAL 就不准推上 registry。docker scout sbom myapp:1.0
syft myapp:1.0 -o spdx-json > sbom.json價值在下次爆出重大漏洞時(像 Log4Shell)能在幾分鐘內回答「我們有沒有受影響」,而不是花三天人工盤點。.dockerignore 排除 .git/.env/金鑰 ☐ 映像裡沒有任何秘密 ☐ 已通過漏洞掃描USER 非 root ☐ exec form 的 CMD/ENTRYPOINT(訊號可達) ☐ 設了記憶體與 CPU 上限-XX:MaxRAMPercentage 而非寫死 -Xmx ☐ 有 HEALTHCHECK 且設了 --start-period--read-only、--cap-drop=ALL、--security-opt=no-new-privileges| 基底 | 大小(含 JRE) | 有 shell 嗎 | 風險/代價 | 建議用途 |
|---|---|---|---|---|
| ubuntu / debian | 約 250 MB+ | 有,工具齊全 | 攻擊面最大、CVE 最多 | 不建議(除非真的需要那些工具) |
| temurin:17-jre-jammy | 約 190 MB | 有 | 中等,需定期更新 | 多數專案的合理預設 |
| temurin:17-jre-alpine | 約 170 MB | 有(ash) | musl libc:多執行緒效能落差、JNI 相容性、DNS 行為差異 | Java 專案不建議 |
| distroless/java17 | 約 190 MB | 沒有! | 無法 exec 進去除錯 | 正式環境安全性最佳解 |
| jlink 自訂 runtime | 約 120 MB | 看基底 | 模組列漏了會在執行期才爆 NoClassDefFoundError | 對映像大小有硬性要求時 |
| 做法 | 對錯 | 為什麼 |
|---|---|---|
| Dockerfile 的 ENV DB_PASSWORD=xxx | ❌ | 永久寫進映像設定,docker inspect 直接看到 |
| COPY .env 進映像 | ❌ | 留在映像層,後面 RUN rm 也救不回(ch03) |
| --build-arg PASSWORD=xxx | ❌ | 留在建置歷史,docker history 看得到 |
| 寫死在 docker-compose.yml | ❌ | 這份檔案會進版控,等於 commit 到 Git |
| .env + compose 的 ${VAR} | ✅ 開發 | .env 加進 .gitignore,版控裡沒有明碼 |
| BuildKit secret mount | ✅ 建置期 | 只在該 RUN 期間存在,不留在任何一層 |
| K8s Secret/Secrets Manager | ✅ 正式 | 可輪替、有稽核紀錄、權限可細分;掛成檔案比環境變數更安全 |
| 選項 | 作用 | 擋掉什麼 |
|---|---|---|
| USER(非 root) | 以低權限身分執行 | 入侵後直接取得宿主機 root 權限的路徑 |
| --read-only | 整個檔案系統唯讀 | 植入後門、竄改程式 |
| --tmpfs /tmp | 只開放必要的可寫路徑(記憶體) | 搭配 --read-only 使用,且暫存資料不落地 |
| --cap-drop=ALL | 丟掉所有 Linux capabilities | 改系統時間、載入核心模組等提權跳板 |
| --security-opt=no-new-privileges | 禁止行程提升權限 | 利用 setuid 執行檔提權 |
| -m / --cpus | 資源上限(cgroup) | 阻斷式攻擊、吵鬧的鄰居 |
| --privileged | ⚠️ 給予幾乎全部權限 | 什麼都擋不住——隔離形同虛設,避免使用 |
點擊卡片翻面查看答案,共 11 張。