容器化技術 ch10 映像瘦身與安全強化:從能跑到能上線
下一章→
CH 10 打包主線

映像瘦身與安全強化:從能跑到能上線

基底映像的選擇(Alpine 的 Java 陷阱)非 root 執行最小權限秘密管理漏洞掃描與 SBOM

ch09 的映像可以跑了,但**「能跑」和「能上正式環境」中間還有一段距離**。

這一章處理兩個彼此相關的目標:更小和更安全。它們相關是因為同一個原因——映像裡的每一個檔案都是潛在的漏洞。少裝一個套件,就少一個 CVE、少一個攻擊者可用的工具。

內容包括一個 Java 開發者一定要知道的坑:很多人以為換成 Alpine 就能瘦身,但 Java 在 Alpine 上有真實的效能與相容性問題。

「映像小」聽起來只是省磁碟,其實影響面大得多:

  1. 部署更快:擴容或滾動更新時,每個節點都要拉映像。1 GB 對 200 MB,在需要同時啟動 20 個實例時是分鐘級的差距。對 Auto Scaling(AWS ch03)這種「尖峰來了要立刻長出機器」的場景尤其關鍵。
  2. 攻擊面更小:這才是重點。映像裡每個套件都可能有 CVE。一個 ubuntu 基底裝了幾百個套件,其中大部分你的 Java 應用根本用不到——但攻擊者用得到。
  3. 入侵後的破壞受限:如果映像裡沒有 curl、wget、bash、apt,攻擊者拿到執行權限後連下載後續工具都做不到。這叫做降低「入侵後可用性」。
  4. 成本更低:registry 儲存費、跨區傳輸費,在規模化後不是小數字。

先量出來:你的映像大在哪?

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.0

dive 會告訴你每一層加了哪些檔案、以及一個「浪費空間」的評分(例如某層新增的檔案在後面的層被刪掉了——ch03 說過那些檔案還在)。優化前先量測,不要憑感覺。

基底決定映像 80% 的大小與漏洞數量。但對 Java 來說,最直覺的選擇不一定對。

四個層級的選擇

  • ① 完整發行版(ubuntu、debian):約 70~120 MB,含完整的套件管理與工具。方便除錯,但攻擊面最大。
  • ② 精簡發行版(-slim、eclipse-temurin:17-jre-jammy):拿掉文件、locale 等非必要內容。Java 的合理預設。
  • ③ Alpine(alpine、eclipse-temurin:17-jre-alpine):只有 5~8 MB 的極簡發行版。但對 Java 有坑,見下。
  • ④ Distroless(gcr.io/distroless/java17-debian12):只有執行時期需要的東西——沒有 shell、沒有套件管理員、沒有任何指令工具。安全性最高。
🚨 Alpine 的 Java 陷阱(很多教學不會告訴你)
Alpine 用的是 musl libc 而不是主流的 glibc。對 Java 的實際影響:
• 效能差異:musl 的記憶體配置器在多執行緒情境下表現不如 glibc,高併發的 Java 應用可能出現明顯的效能落差。
• 原生函式庫相容性:任何依賴 JNI 的函式庫(部分影像處理、加解密、資料庫驅動的原生元件)可能直接無法載入。
• DNS 解析行為不同:musl 的解析器在某些設定下行為與 glibc 不一致,曾造成難以除錯的連線問題。
• 省下的空間不如預期:JRE 本身就佔大部分體積,換成 Alpine 從約 190 MB 降到約 170 MB——省不到 15%,卻換來一堆不確定性。

結論:Java 專案不要為了瘦身盲目換 Alpine。要瘦身,用下一張卡的 jlink 或 distroless 更有效也更安全。

Distroless:安全性的最佳解

FROM gcr.io/distroless/java17-debian12
COPY --from=builder /build/target/app.jar /app.jar
USER nonroot                       # distroless 內建 nonroot 使用者
ENTRYPOINT ["java", "-jar", "/app.jar"]
  • 優點:沒有 shell 就沒有 shell 注入、沒有套件管理員就無法安裝惡意工具、CVE 數量極少。
  • 代價:docker exec -it ... bash 完全用不了(裡面沒有 bash)。除錯得靠日誌、metrics,或用 docker debug/掛一個工具容器進同一個 namespace。
  • 建議:正式環境用 distroless,開發環境用 -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"]
  • 成果:自訂 runtime 約 50~70 MB(完整 JRE 約 190 MB),最終映像可以壓到 120 MB 上下。
  • 怎麼知道要加哪些模組?用 jdeps 分析你的 jar:
    jdeps --print-module-deps --ignore-missing-deps target/app.jar
🚨 模組列少了會怎樣?應用會在執行到某個功能時才噴 NoClassDefFoundError——不是啟動就失敗,而是某個冷門路徑被走到時才爆。用 jlink 之後務必跑完整的整合測試,不要只確認能啟動。反射與動態載入(Spring 大量使用)讓 jdeps 的靜態分析可能漏掉東西,這是 jlink 最大的風險。
⭐ 要不要用 jlink?如果你的痛點是「部署慢、映像太大」,值得。如果只是一般內部服務,用 -jre-jammy 或 distroless 就夠了——多花的維護心力不一定划算。先量測、再優化。

ch02 講過:Docker 預設不啟用 user namespace,所以容器內的 root 就是宿主機的 root。如果你的應用以 root 執行又被入侵,攻擊者等於站在你的核心前面。

而多數映像的預設就是 root。這是所有容器安全清單上的第一條,也是最容易做到的一條。

Debian/Ubuntu 基底的寫法

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 之後的所有指令與容器主程式都用這個身分執行。

兩個常見的卡關點

  • 無法綁定 1024 以下的埠:Linux 規定只有 root 能監聽特權埠。解法:讓應用監聽 8080,靠 -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:攻擊者即使進來也無法植入檔案、修改程式。
  • 這些設定在 K8s 裡對應 securityContext(runAsNonRoot、readOnlyRootFilesystem、capabilities.drop),觀念完全相同(ch14)。

ch03 證明過:寫進映像的秘密刪不掉,只是被遮住。這裡把正確與錯誤的做法一次列清楚。

❌ 四種錯誤(每一種都真實發生過)

  1. Dockerfile 的 ENV DB_PASSWORD=xxx:永久寫進映像設定,docker inspect 或 docker history 直接看到。
  2. COPY .env /app/ 或 COPY . . 沒排除 .env:秘密進到映像層。後面再 RUN rm 也救不回來。
  3. --build-arg PASSWORD=xxx:會留在建置歷史裡,docker history 看得到。
  4. 寫死在 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 Swarm secrets、Kubernetes Secret(掛成檔案或環境變數,ch14)。
  • 雲端服務:AWS Secrets Manager/Parameter Store(AWS ch10)、Azure Key Vault、HashiCorp Vault。
  • 優勢是可輪替、有存取稽核紀錄、權限可細分——環境變數做不到這些。
🚨 環境變數本身也不是完美方案: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 用:有高風險就失敗
  • 掃描的是基底映像的系統套件與你的應用依賴(Maven 的 jar 也掃得到)。
  • 接進 CI:把 --exit-code 1 當成建置關卡,有 CRITICAL 就不准推上 registry。
  • 掃描是持續的事:今天沒漏洞的映像,下週可能因為新公布的 CVE 就有了。正式環境的映像要定期重掃、定期重建。

② 讓基底映像更新變成例行公事

  • 矛盾點:ch04 說要「釘死版本」以保證可重現,但釘死了就不會拿到安全修補。
  • 解法是把「更新」變成一個明確的、有人負責的動作:用 Dependabot/Renovate 自動偵測基底映像有新版就送 PR,走正常的測試與審查流程合併。可重現性與安全性同時滿足。

③ 簽章與 SBOM(進階,但 SRE 該知道)

  • 簽章(cosign / Sigstore):digest 保證「內容沒被改」,但不能證明「是誰做的」。簽章補上來源證明,部署時可以要求「只允許執行有我們簽章的映像」。
  • SBOM(軟體物料清單):一份「這個映像裡有哪些套件與版本」的清單。
    docker scout sbom myapp:1.0
    syft myapp:1.0 -o spdx-json > sbom.json
    價值在下次爆出重大漏洞時(像 Log4Shell)能在幾分鐘內回答「我們有沒有受影響」,而不是花三天人工盤點。

上線前檢查清單

映像
☐ 基底釘明確版本(不是 latest) ☐ 用 JRE 不是 JDK ☐ 多階段建置,建置環境不進最終映像
☐ .dockerignore 排除 .git/.env/金鑰 ☐ 映像裡沒有任何秘密 ☐ 已通過漏洞掃描

執行
☐ USER 非 root ☐ exec form 的 CMD/ENTRYPOINT(訊號可達) ☐ 設了記憶體與 CPU 上限
☐ -XX:MaxRAMPercentage 而非寫死 -Xmx ☐ 有 HEALTHCHECK 且設了 --start-period
☐ 日誌印到 stdout 且設了輪替 ☐ 設定與秘密由外部注入
☐ 進階:--read-only、--cap-drop=ALL、--security-opt=no-new-privileges
Java 基底映像選擇對照
基底大小(含 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⚠️ 給予幾乎全部權限什麼都擋不住——隔離形同虛設,避免使用

練習題 點選選項查看解析

0 / 9
01 / 9
為什麼 Java 專案不建議為了瘦身改用 Alpine 基底?
A Alpine 不支援 Java
B Alpine 用 musl libc 而非 glibc:多執行緒下記憶體配置效能較差、依賴 JNI 的原生函式庫可能無法載入、DNS 解析行為不同;而且 JRE 本身佔大部分體積,實際只省不到 15%
C Alpine 映像更新太頻繁
D Alpine 不能建立非 root 使用者
解析
這是很多教學不會提的坑:換 Alpine 省不到 15% 空間,卻換來一堆不確定性(尤其高併發效能與 JNI 相容性)。要瘦身,jlink 自訂 runtime(約 120 MB)或 distroless 更有效也更安全。
02 / 9
Distroless 映像的最大特點與代價分別是?
A 體積最小;但啟動較慢
B 只含執行時期必要內容,沒有 shell、沒有套件管理員,CVE 極少;代價是 docker exec 進不去除錯(裡面沒有 bash)
C 自動更新安全修補;但需付費
D 支援所有語言;但設定複雜
解析
沒有 shell 就沒有 shell 注入,沒有套件管理員攻擊者就無法安裝後續工具——這叫降低「入侵後可用性」。實務建議:正式環境用 distroless,開發環境用 -jre-jammy(有 shell 好除錯),同一份 Dockerfile 換最終階段即可。
03 / 9
映像小的最重要好處是什麼?
A 省磁碟空間
B 攻擊面更小——每個套件都可能有 CVE,而且入侵後攻擊者連 curl、bash、apt 都沒有可用,無法下載後續工具
C 程式執行更快
D 支援更多 CPU 架構
解析
省空間只是附帶效果。安全性(攻擊面與入侵後可用性)與部署速度(擴容時每個節點都要拉映像)才是主要價值。優化前先用 docker history 或 dive 量測,不要憑感覺。
04 / 9
以非 root 使用者執行容器時,為什麼應用無法監聽 80 埠?該怎麼解決?
A Docker 限制了埠的使用;需要加 --privileged
B Linux 規定只有 root 能綁定 1024 以下的特權埠;解法是讓應用監聽 8080,用 -p 80:8080 對外映射
C 需要修改映像的 EXPOSE 設定
D 必須改用 host 網路模式
解析
port mapping 本來就在做轉接(ch07),所以這個限制毫無損失——外面看到的還是 80。千萬不要為了綁特權埠而回頭用 root 或 --privileged,那是把門拆掉來解決鑰匙的問題。
05 / 9
建置時需要私有 Maven 倉庫的憑證(settings.xml),正確做法是?
A COPY settings.xml 進去,用完再 RUN rm 刪掉
B 用 BuildKit 的 RUN --mount=type=secret,檔案只在該 RUN 執行期間存在,不會留在任何一層
C 用 ENV 設定憑證內容
D 用 --build-arg 傳入
解析
選項 A 是 ch03 的經典錯誤:層不可變,rm 只是 whiteout 遮蔽,密碼還在下層。ENV 會永久留在映像設定,--build-arg 會留在建置歷史。只有 secret mount 真正做到不落層。
06 / 9
--cap-drop=ALL 的作用是什麼?
A 禁止容器使用網路
B 丟掉所有 Linux capabilities(root 權限被切分出的細項,如改系統時間、載入核心模組、綁特權埠)——一般應用一個都不需要,真的需要再單獨 --cap-add 加回
C 限制容器的 CPU 使用
D 刪除容器內所有使用者帳號
解析
這是最小權限原則的實踐。相對地,--privileged 是一次全開,等於隔離形同虛設(ch02)。這些設定在 K8s 裡對應 securityContext 的 capabilities.drop,觀念完全相同。
07 / 9
「釘死基底映像版本」與「定期更新以修補漏洞」看似矛盾,實務上怎麼解決?
A 改用 latest 標籤自動取得更新
B 把更新變成明確且有人負責的動作:用 Dependabot/Renovate 自動偵測新版並送 PR,走正常測試與審查流程合併——可重現性與安全性同時滿足
C 每次部署前手動檢查一次
D 只在出現重大漏洞時才更新
解析
用 latest 會失去可重現性(ch04),完全手動則會被遺忘。自動化 PR 讓「更新」進入正常的工程流程:有紀錄、有測試、有審查。這也是「安全是流程不是一次性檢查」的具體實踐。
08 / 9
SBOM(軟體物料清單)的主要價值是什麼?
A 減少映像體積
B 記錄映像內所有套件與版本,讓下次爆出重大漏洞(如 Log4Shell)時能在幾分鐘內回答「我們有沒有受影響」,而不是花三天人工盤點
C 加密映像內容
D 自動修復漏洞
解析
SBOM 解決的是「事件響應速度」問題。搭配簽章(cosign/Sigstore)補上來源證明——digest 只保證內容沒被改,簽章才能證明是誰做的。這兩項是 SRE 職涯上會被問到的供應鏈安全題目。
09 / 9
關於用環境變數傳遞密碼,下列敘述何者正確?
A 環境變數是最安全的方式,沒有缺點
B 比寫進映像好,但仍有風險:docker inspect 看得到、子行程會繼承、當機時可能被寫入錯誤報告;更安全的做法是掛載成檔案讓應用讀取
C 環境變數完全無法被讀取
D 環境變數只能在開發環境使用
解析
安全是分等級的:寫進映像(最糟)< 環境變數(可接受)< 掛載成檔案(較佳)< 專用秘密管理系統(最佳,可輪替、有稽核)。Spring Boot 可用 spring.config.import=optional:file:/run/secrets/ 讀取掛載進來的秘密檔案。

點擊卡片翻面查看答案,共 11 張。

QUESTION
映像小的四個好處?
點擊翻面
ANSWER
① 部署/擴容更快(每個節點都要拉)
② 攻擊面更小(每個套件都是潛在 CVE)
③ 入侵後可用性低(沒有 curl/bash 可用)
④ 儲存與傳輸成本低
點擊翻回
QUESTION
Alpine 對 Java 的四個坑?
點擊翻面
ANSWER
musl libc 而非 glibc:
① 多執行緒記憶體配置效能較差
② JNI 原生函式庫可能載不起來
③ DNS 解析行為不同
④ 只省不到 15% 空間(JRE 本身才是大宗)
點擊翻回
QUESTION
Distroless 的優缺點?
點擊翻面
ANSWER
優:沒 shell、沒套件管理員、CVE 極少 → 安全性最佳
缺:docker exec 進不去除錯
建議:正式用 distroless,開發用 -jre-jammy
點擊翻回
QUESTION
jlink 能做什麼?風險是?
點擊翻面
ANSWER
裁出只含必要模組的自訂 runtime(190MB → 50~70MB)
用 jdeps --print-module-deps 分析需要哪些模組
⚠️ 模組漏了會在執行期才噴 NoClassDefFoundError(反射/動態載入尤其危險)
點擊翻回
QUESTION
非 root 執行的寫法與兩個卡關點?
點擊翻面
ANSWER
RUN groupadd -r spring && useradd -r -g spring spring
COPY --chown=spring:spring ... / USER spring
卡點①:不能綁 <1024 埠 → 監聽 8080 + -p 80:8080
卡點②:寫入權限不足 → 明確建目錄設擁有者或掛 tmpfs
點擊翻回
QUESTION
執行時的五個安全強化選項?
點擊翻面
ANSWER
--read-only(檔案系統唯讀)
--tmpfs /tmp(只開必要可寫路徑)
--cap-drop=ALL(丟掉所有 capabilities)
--security-opt=no-new-privileges(禁提權)
-m / --cpus(資源上限)
點擊翻回
QUESTION
秘密的四種錯誤放法?
點擊翻面
ANSWER
❌ Dockerfile ENV(inspect 看得到)
❌ COPY .env 進映像(層裡刪不掉)
❌ --build-arg(留在 history)
❌ 寫死在 compose 檔(進版控)
點擊翻回
QUESTION
秘密的三種正確放法?
點擊翻面
ANSWER
① 開發:.env(gitignore)+ compose ${VAR}
② 建置期:BuildKit --mount=type=secret
③ 正式:K8s Secret / Secrets Manager(可輪替、有稽核)
最佳:掛成檔案而非環境變數
點擊翻回
QUESTION
漏洞掃描怎麼接進流程?
點擊翻面
ANSWER
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:1.0
有高風險就讓 CI 失敗、不准推 registry
且要定期重掃重建(今天乾淨不代表下週乾淨)
點擊翻回
QUESTION
「釘死版本」與「定期更新」怎麼並存?
點擊翻面
ANSWER
用 Dependabot / Renovate 自動偵測基底新版並送 PR
走正常測試與審查流程合併
→ 可重現性與安全性同時滿足
點擊翻回
QUESTION
簽章與 SBOM 各解決什麼?
點擊翻面
ANSWER
簽章(cosign/Sigstore):digest 只證明內容沒被改,簽章證明「是誰做的」
SBOM:套件清單,下次爆 Log4Shell 級漏洞時幾分鐘內回答「我們有沒有中」
點擊翻回