Docker Multi-Stage Build 完整教學:映像瘦身從 1GB 壓到 50MB 的實戰技巧
為什麼 Docker 映像大小很重要
我第一次接觸 Docker 的時候,老實說完全不在意映像大小。反正能跑就好嘛,管它是 500MB 還是 2GB。直到有一天,我們團隊的 CI/CD pipeline 跑一次部署要等 15 分鐘,而且 staging 環境的硬碟空間三天兩頭就滿了,我才開始認真思考這個問題。
Docker 映像的大小會直接影響三個關鍵面向:
部署速度:映像越大,拉取(pull)的時間越長。假設你的映像是 1.2GB,在一般網路環境下光是 docker pull 就要好幾分鐘。如果你用的是 Kubernetes,每個節點都要拉一次,那個等待時間是倍數成長的。相比之下,一個 50MB 的映像幾秒鐘就搞定了。
安全性:這一點很多人會忽略。映像裡面裝的東西越多,攻擊面(attack surface)就越大。一個包含完整 OS、build tools、debug 工具的映像,潛在的漏洞數量可能是精簡映像的十倍以上。根據我的經驗,光是把映像從 Ubuntu-based 換成 Alpine-based,CVE 數量就可以從幾十個降到個位數。
成本:如果你用的是 AWS ECR、Google Container Registry 或 Docker Hub 的付費方案,映像儲存是按容量計費的。一個團隊每天推幾十個版本,每個映像 1GB 以上,一個月下來光是儲存費用就很可觀。更不用說網路傳輸的費用了。
傳統 Dockerfile 的致命問題
先來看一個很典型的「傳統寫法」,這是我在很多 Node.js 專案裡看過的 Dockerfile:
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]看起來沒什麼問題對吧?功能上確實沒問題,但這個映像打出來通常會超過 1GB。原因很簡單:
首先,node:18 這個基底映像本身就是基於 Debian 的完整 OS,裡面包含了 gcc、make、Python 等一大堆你在 production 根本用不到的建置工具。其次,npm install 會裝進所有 devDependencies,像是 TypeScript compiler、ESLint、Jest 等等,這些東西在執行階段完全不需要。最後,原始碼(src 資料夾)在 build 完之後也是多餘的,你只需要 dist 裡面的編譯結果。
簡單來說,傳統 Dockerfile 的問題就是:建置環境和執行環境混在一起。你需要各種工具來編譯和打包,但跑的時候根本不需要那些東西。就像你蓋完房子之後,不會把鷹架留在那裡吧?
在還沒有 Multi-Stage Build 之前,社群的做法通常是寫兩個 Dockerfile(一個 build 用、一個 runtime 用),或者用一個超長的 shell script 來處理。不管哪種方式都很醜,而且難以維護。
Multi-Stage Build 核心概念
Docker 從 17.05 版開始支援 Multi-Stage Build,這個功能的核心思想其實很簡單:在一個 Dockerfile 裡面定義多個 FROM 指令,每個 FROM 開始一個新的建置階段,最後只保留你真正需要的東西。
概念上就像是一條生產線:
- Stage 1(建置階段):使用完整的開發環境,安裝所有依賴、編譯程式碼、執行測試
- Stage 2(執行階段):使用精簡的 runtime 環境,只從 Stage 1 複製最終產物
你可以有任意多個階段,每個階段可以用不同的基底映像。關鍵在於 COPY --from= 這個指令,它讓你能從前面的階段「cherry-pick」你需要的檔案到當前階段。
這個設計最漂亮的地方在於:中間階段的所有東西(包括下載的套件、編譯器、暫存檔)都不會出現在最終映像裡面。Docker 只會保留最後一個 FROM 階段的內容。
基礎範例:Node.js 應用的多階段建置
讓我用一個實際的 Node.js + TypeScript 專案來示範。假設我們有一個 Express API server:
# Stage 1: 建置階段
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && \
cp -R node_modules prod_modules && \
npm ci
COPY . .
RUN npm run build
# Stage 2: 執行階段
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/prod_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]來拆解一下這裡做了什麼:
在建置階段,我們用 node:18-alpine 而不是完整的 node:18,Alpine 版本本身就小很多。然後我們做了一個小技巧:先用 npm ci --only=production 裝生產依賴,把 node_modules 備份起來,再跑一次完整的 npm ci 來裝開發依賴(TypeScript 等)。這樣我們就有一份乾淨的 production node_modules 可以用。
在執行階段,我們只複製了三樣東西:production 的 node_modules、編譯後的 dist 資料夾、以及 package.json。TypeScript 原始碼、dev dependencies、build 工具通通不帶走。
最後加上 USER node 是安全性的最佳實踐,避免用 root 跑應用。
這個做法通常能把映像從 1GB+ 壓到 150-200MB 左右。如果你的應用依賴不多,甚至可以到 100MB 以下。
如果你還不熟悉 Docker 的基本操作,建議先看看Docker 容器化部署入門這篇教學,打好基礎再來學 Multi-Stage Build 會更有感覺。
進階範例:Go 應用 + scratch 基底映像
Go 語言在 Docker 映像最佳化方面有一個天然優勢:Go 可以編譯成靜態連結的二進位檔,不需要任何 runtime。這意味著我們可以用 scratch(空映像)作為最終的基底映像,把映像大小壓到極致。
# Stage 1: 建置階段
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o /app/server ./cmd/server
# Stage 2: 執行階段
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]這邊有幾個重點:
CGO_ENABLED=0 確保編譯出來的是純 Go 的靜態連結檔,不依賴任何 C 函式庫。-ldflags='-w -s' 會去掉 debug 資訊和符號表,進一步縮小二進位檔的大小。
FROM scratch 代表一個完全空白的映像,裡面什麼都沒有——沒有 shell、沒有 ls、沒有任何東西。我們只放進去兩個檔案:SSL 憑證(讓應用可以發 HTTPS 請求)和我們的二進位檔。
用這個方法打出來的映像通常在 10-30MB 之間,取決於你的 Go 應用有多大。我手上有個 API gateway 的專案,最終映像只有 12MB。跟原本用 golang:1.22 打出來的 1.1GB 比起來,瘦了將近 99%。
不過 scratch 有個缺點:因為裡面沒有 shell,你沒辦法 docker exec 進去除錯。如果你需要偶爾進容器看看狀況,可以考慮用 Google 的 distroless 映像,它比 scratch 多了一些基本的東西,但仍然非常精簡。
最佳實踐:.dockerignore、層快取與基底映像選擇
光會寫 Multi-Stage Build 還不夠,有幾個配套的最佳實踐你也應該要知道:
.dockerignore 檔案
這是很多人會忘記的一步。沒有 .dockerignore 的話,COPY . . 會把整個專案目錄複製進去,包括 node_modules、.git、test coverage 報告等等。你的 .dockerignore 至少應該包含:
node_modules
.git
.gitignore
*.md
dist
coverage
.env*
Dockerfile
docker-compose.yml這不只影響映像大小,更重要的是影響建置快取。每次 COPY 的檔案有變動,Docker 就會從那一層開始重新建置。如果你不小心把 node_modules 也 COPY 進去了,那每次改一行程式碼都會導致整個 npm install 重跑。
層快取策略
Docker 的每一條指令都會產生一個層(layer),而 Docker 會快取沒有變動的層。善用這個機制可以大幅加速建置:
- 把不常變動的東西放前面(例如 package.json),常變動的放後面(例如原始碼)
- 先 COPY package.json 再 RUN npm install,最後才 COPY 原始碼。這樣只要 package.json 沒改,npm install 就不用重跑
- 如果需要多個 COPY 步驟,把相關的放在一起
基底映像的選擇
這裡提供一個簡單的決策框架:
| 基底映像 | 大小 | 適用場景 |
|---|---|---|
| node:18 (Debian) | ~950MB | 開發環境、需要原生模組編譯 |
| node:18-alpine | ~120MB | 大多數 production 場景 |
| node:18-slim | ~200MB | 需要 glibc 但不需要完整 OS |
| gcr.io/distroless/nodejs | ~120MB | 最高安全性需求 |
| scratch | 0MB | Go/Rust 等靜態編譯語言 |
我個人的偏好是 Alpine 用在大部分場景,Distroless 用在對安全性有特別要求的場景。scratch 只有在 Go 專案才會用。
如果你的應用需要跑多個容器,搭配 Docker Compose 多容器部署教學 可以讓你的開發和部署流程更順暢。
常見陷阱與除錯技巧
在實務中使用 Multi-Stage Build,我踩過不少坑。這裡分享幾個最常見的:
陷阱一:忘記複製必要的設定檔
這是最常見的問題。你在 Stage 2 只 COPY 了 dist 和 node_modules,但你的應用可能還需要 .env 檔案、config 資料夾、或者某些靜態資源。容器一啟動就 crash,錯誤訊息通常是 ENOENT(找不到檔案)。
解決方法:仔細列出你的應用在 runtime 需要的所有檔案,確保都有 COPY 過去。我會建議在 Dockerfile 裡面加註解標記每個 COPY 的用途。
陷阱二:Alpine 的 musl libc 相容性問題
Alpine Linux 用的是 musl libc 而不是 glibc。大部分情況下這不是問題,但某些 Node.js 的原生模組(像是 bcrypt、sharp)可能會在 Alpine 上編譯失敗或行為異常。
如果你遇到這類問題,有幾個選項:改用 node:18-slim(基於 Debian slim,用 glibc)、在 Alpine 裡安裝相容層、或者尋找純 JavaScript 的替代套件。
陷阱三:多階段建置的快取失效
如果你的 CI/CD 系統每次都用乾淨的環境(例如 GitHub Actions 的 hosted runner),Docker 的層快取可能完全無效。每次建置都要從頭來,Multi-Stage Build 的快取優勢就沒了。
解決方法:使用 Docker BuildKit 的 --cache-from 搭配 registry cache,或者用 GitHub Actions Cache 來保存 Docker 的 build cache。
除錯技巧
當映像行為不符預期時,這幾個指令很有用:
# 查看映像大小
docker images | grep your-app
# 查看每一層的大小
docker history your-app:latest
# 用 dive 工具分析映像層(推薦!)
dive your-app:latest
# 建置特定階段來除錯
docker build --target builder -t debug-stage .
docker run -it debug-stage shdocker build --target 特別實用,它讓你可以只建置到某個階段就停下來,然後進去容器裡面看看檔案結構對不對、東西有沒有編譯成功。
搭配 CI/CD 的映像建置流程
Multi-Stage Build 跟 CI/CD 搭配在一起的時候威力最大。這裡示範一個用 GitHub Actions 建置和推送映像的 workflow:
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max這裡幾個重點:
Docker Buildx:這是 Docker 的進階建置工具,支援更好的快取機制和多平台建置。基本上現在已經是標配了。
GitHub Actions Cache:cache-from: type=gha 和 cache-to: type=gha,mode=max 會利用 GitHub Actions 的快取來儲存 Docker 的建置層。這樣即使每次 CI 都是乾淨的環境,之前建置過的層還是可以被重用。mode=max 表示所有中間層都會被快取,不只是最終映像的層。
映像標籤策略:用 git commit SHA 作為標籤是個好做法,可以精確追溯每個映像對應哪個版本的程式碼。你也可以同時加上 latest 標籤方便開發環境使用。
如果你想更深入了解 GitHub Actions 的 CI/CD 流程,可以參考 GitHub Actions CI/CD 教學這篇文章,裡面有更完整的說明。
另外,建議在 CI 裡面加一個映像大小檢查的步驟,設一個上限(例如 200MB)。如果某次提交不小心讓映像膨脹了,CI 就會直接失敗,避免把肥大的映像推到 registry。
結語
Docker Multi-Stage Build 真的是我在容器化實踐中學到最實用的技巧之一。它解決的問題很明確:讓你在不犧牲開發體驗的前提下,產出精簡、安全的 production 映像。
簡單回顧一下重點:
- 傳統 Dockerfile 會把建置工具和執行環境混在一起,導致映像肥大
- Multi-Stage Build 讓你在一個 Dockerfile 裡分離建置和執行環境
- Node.js 專案搭配 Alpine 基底映像,通常可以從 1GB+ 壓到 150MB 以下
- Go 專案搭配 scratch,可以壓到 10-30MB
- 別忘了搭配 .dockerignore、層快取策略、CI/CD cache 來優化整體流程
如果你現在手上有用 Docker 部署的專案,花 30 分鐘把 Dockerfile 改成 Multi-Stage Build 吧。投資報酬率非常高,你的 CI/CD pipeline 會感謝你的。
繼續閱讀
Docker 容器化部署入門教學:後端工程師必學的環境打包術
「在我電腦上明明可以跑啊!」如果你也說過這句話,那你一定要學 Docker。這篇從零帶你搞懂容器化概念,寫出你的第一個 Dockerfile。
相關文章
Docker 容器化部署入門教學:後端工程師必學的環境打包術
「在我電腦上明明可以跑啊!」如果你也說過這句話,那你一定要學 Docker。這篇從零帶你搞懂容器化概念,寫出你的第一個 Dockerfile。
Kubernetes K8s 入門完整教學:從 Pod 到部署微服務應用的完整指南
Kubernetes(K8s)是現代雲端部署的標準工具,但入門曲線陡峭讓很多工程師望而卻步。本文從最基本的概念開始,用清楚的類比解釋 Pod、Service、Deployment 的關係,再帶你一步一步部署一個包含前端、後端和資料庫的微服務應用,讓你真正理解 K8s 的威力。
你可能也喜歡
探索其他領域的精選好文