Docker:Image Optimisation
從 Layer Caching、Multi-stage builds 到 Distroless,整理 Docker Image 又快又小又安全的優化方式,思考的像是資深工程師
在上一篇整理了容器化的底層原理(OCI、Namespaces、Cgroups)與 Docker 架構之後,這篇文章往實務面走:如何優化 docker Image 建得快、體積小、跑得安全。這些直接關係到 CI/CD 的效率、部署速度與儲存成本,更是成為資深工程師的必經之路。
Image 最佳化:又快又小又安全
Layer Caching(層級快取)
Dockerfile 裡的每一個 RUN、COPY、ADD 都會產生一個唯讀層(Layer)。Docker 在建置時,只要發現該層的指令與相關檔案沒有改變,就會直接使用快取(Cache),跳過重新執行。
但快取是鏈狀失效的:只要某一層失效,它之後的所有層都會跟著重建。
變動越不頻繁的指令放越前面。先 COPY package.json 並執行 npm install,然後才 COPY . .(原始碼)。只要沒改 package.json,每次改 code 都不用重新下載幾百 MB 的套件。
FROM node:22-alpine
WORKDIR /app
# 套件清單很少變動 → 放前面,快取可以長期存活
COPY package.json pnpm-lock.yaml ./
RUN npm install
# 原始碼天天改 → 放後面,失效時只重建這層之後的部分
COPY . .
CMD ["node", "server.js"]如果反過來先 COPY . . 再安裝套件,那麼每一次 commit 都會讓 npm install 重跑,CI/CD 自然越來越慢。
Multi-stage Builds(多階段建置)
Multi-stage 一直都是被強調可以省時間的方法,因為可以把編譯階段時間省下來,但大部分專案不一定有多個程式語言混合,所以這部分我自己還沒真的體會到。
在 Dockerfile 裡寫多個 FROM。在第一個階段(Build stage)編譯程式碼,在第二個階段(Run stage)只把編譯好的二進制檔(Binary)複製過來。
# --- Build stage:包含完整的 Go toolchain(數百 MB)---
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .
# --- Run stage:只帶走 Binary ---
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]編譯環境留在第一階段,最終 Image 只剩下執行檔本身。
Base Image 選擇:Alpine vs. Distroless
| 特性 | Alpine | Distroless(Google 推出) |
|---|---|---|
| 體積 | 約 5MB 的極簡 Linux | 更極端,只有應用程式的執行期依賴 |
| Shell / 套件管理 | 有 /bin/sh 與 apk | 連 Shell 和 apt/apk 都沒有 |
| C 函式庫 | musl libc(非標準 glibc) | 基於 Debian 的 glibc |
| 除錯難度 | 可以 docker exec 進去下指令 | 無法進入互動式 Shell(有 :debug tag 可用) |
| 攻擊面 | 小 | 極小,大幅減少 CVE 漏洞 |
Alpine 底層使用 musl libc 而非標準的 glibc,有時候會遇到 C 語言編譯套件的相容性問題(Python 的 wheel、Node 的 native module 是常見災區)。
Distroless 裡面連 Shell(/bin/sh)和套件管理工具(apt/apk)都沒有。意味著駭客即使攻進 Container,也無法下指令作怪——攻擊面的縮小不只是體積問題,更是資安防線。
安全性:權限的最小化
預設情況下,Docker Container 裡面的 User 是 Root。
Non-root User
如果 Container 被駭客入侵,且發生容器逃逸(Container Breakout),駭客就會直接擁有 Host 機器的 Root 權限。解法是在 Dockerfile 建立並切換到一般使用者:
# Alpine 系列
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser# Debian/Ubuntu 系列
RUN addgroup --system app && adduser --system --group app
USER appPrinciple of Least Privilege(最小權限原則)
Container 應該只擁有它絕對需要的權限。兩個常用手段:
-
移除所有 Linux Capabilities:
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE my-app -
唯讀檔案:
docker run --read-only --tmpfs /tmp my-app
--read-only 搭配 --tmpfs 是常見組合:根檔案系統鎖死,但保留 /tmp 給應用程式寫暫存檔。
儲存與網路(Storage & Networking)
儲存:Bind Mount vs. Volume
| 特性 | Bind Mount | Volume |
|---|---|---|
| 本質 | 直接掛載 Host 上的絕對路徑(如 /home/user/app) | 由 Docker 完全管理(通常在 /var/lib/docker/volumes/) |
| 可移植性 | 高度依賴 Host 的目錄結構與權限,不可移植 | 跨平台,生命週期獨立於 Container |
| 效能 | 一般(macOS/Windows 上有檔案系統轉換損耗) | 較佳 |
| 適合場景 | 本地開發:Host 改 code,Container 馬上生效 | 正式環境:資料庫持久化(Production Database) |
情境題解析
「如果 CI/CD pipeline build image 越來越慢,而且最後產出的 image 高達 2GB,會從哪些方向下手優化?」
這題其實就是上面所有觀念的綜合應用,可以依「慢 → 層數 → 體積 → 根本」的順序展開:
- 首先檢查 Dockerfile
檢查 Dockerfile,將安裝 Dependencies 的步驟與複製原始碼的步驟分開,確保頻繁改動的 Code 不會破壞套件下載的 Cache。
- 接著檢查
RUN指令
將多個 RUN apt-get update && apt-get install ... 合併為同一層,並在同一層內清除快取(如 rm -rf /var/lib/apt/lists/*),避免無用的中繼檔案殘留在最終 Image 中:
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*- 導入 Multi-stage build
把編譯環境(包含 SDK、編譯工具)留在第一階段,最終只將編譯後的執行檔放入乾淨的 Runtime 環境中——這通常能把幾 GB 的 Image 縮減到幾十 MB。
- 檢視 Base Image 的選擇
與其使用肥大的 ubuntu 或 node:latest,評估切換到 node:alpine 或 Distroless。這不僅能進一步縮小體積、加速部署,還能順便減少 CVE 漏洞,提升系統安全性。
總結
| 目標 | 手段 |
|---|---|
| 快 | Layer Caching:少變動的層放前面、.dockerignore 縮小 build context |
| 小 | Multi-stage builds、合併 RUN 並清快取、精簡 Base Image |
| 安全 | Non-root User、--cap-drop=ALL、--read-only、Distroless 移除 Shell |