第 2 章:從既有容器化 app 出發 — 優化 Docker image
這一章在做什麼? 我們不重教 Docker,而是把前一份教學留下的 code/Dockerfile(已經是 multi-stage)再精煉一步:換成更小、更安全的最終基底(distroless)、壓縮編譯出的執行檔、補上之後會用到的中繼標籤。做完這章,你的 image 體積會明顯變小,CI build 與部署都更快。
為什麼一開始先做這件事? 因為這顆 image 是後面所有章節的「貨物」——CI 會 build 它、推到 registry、部署到雲端與 K8s。貨物越小,每一次搬運(pull/push/啟動)都越快、攻擊面越小、雲端費用也越省。先把地基的這塊磨利,後面一路受益。
前置:你已有
code/(第 1 章確認過能跑)。本章所有檔案路徑都在code/底下。
步驟 1:先搞懂「image 分層」與為什麼體積重要
一個 Docker image 是由一層層唯讀的檔案系統疊起來的。Dockerfile 裡幾乎每一個指令(FROM、COPY、RUN…)都會產生一層。這帶來兩個關鍵特性:
- 層快取(layer cache):build 時,只要某一層的輸入沒變,Docker 就直接用快取、不重跑。這就是為什麼「相依套件」要跟「原始碼」分開 COPY——改 code 時不必每次重抓套件。
- 體積會累加:每一層都佔空間。最終 image 的大小 ≈ 所有層的總和。
為什麼要在意大小?
| 影響 | 大 image | 小 image |
|---|---|---|
| CI build / push 時間 | 慢 | 快 |
| 部署 / 擴容時 pull 速度 | 慢(每個節點都要拉) | 快 |
| 攻擊面(裡面的工具/函式庫越多越危險) | 大 | 小 |
| registry / 雲端儲存費用 | 高 | 低 |
🔁 前端類比:就像你會在意 JS bundle 大小(越小載入越快)。容器 image 是「後端的 bundle」,同樣的道理:越小越好。
distroless/scratch之於 image,就像 tree-shaking + minify 之於前端 bundle。
步驟 2:量測現狀(優化前)
先 build 目前的 image、記下大小,等等好對照。
cd code
docker build -t nlt-api:before .
docker images nlt-api:before
預期輸出類似(SIZE 你的數字會不同):
REPOSITORY TAG IMAGE ID CREATED SIZE
nlt-api before a1b2c3d4e5f6 3 seconds ago ~28MB
記住這個 SIZE(以 alpine 為最終基底,通常落在 20–30MB 上下)。
步驟 3:優化① — 檢查 .dockerignore
.dockerignore 決定哪些檔案不要送進 build context(送越少,build 越快、也避免把機密或雜物打包進去)。打開 code/.dockerignore,確保至少涵蓋這些:
# code/.dockerignore
.git
.gitignore
.env # 機密!絕不能進 image
.env.example
.dockerignore
Dockerfile
docker-compose.yml
# 文件與本機產物
*.md
/server
/bin/
# 編輯器 / 作業系統雜物
.vscode/
.idea/
.DS_Store
逐行重點:
.env:含資料庫密碼,絕對不能被 COPY 進 image。靠這行排除。.git:版控歷史很肥又用不到。*.md、Dockerfile、docker-compose.yml:build 出來的執行檔用不到它們。/server、/bin/:你本機go build可能產生的執行檔,別打包進去。
註:
*_test.go不用特別排除——go build本來就不會把測試檔編進執行檔。
步驟 4:優化② — 換 distroless + 壓縮執行檔
這是體積與安全的最大躍進。把 code/Dockerfile 改成:
# code/Dockerfile
# syntax=docker/dockerfile:1
# ========== build 階段 ==========
FROM golang:1.22-alpine AS builder
WORKDIR /src
# 1) 先抓相依:這一層只在 go.mod/go.sum 變動時才重跑(層快取)
COPY go.mod go.sum ./
RUN go mod download
# 2) 複製原始碼,編譯成「精簡的靜態執行檔」
COPY . .
RUN CGO_ENABLED=0 GOOS=linux \
go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/api
# ========== 最終 image:distroless,極小且預設非 root ==========
FROM gcr.io/distroless/static-debian12:nonroot
# OCI 標籤:讓 GHCR 能把這顆 image 連回原始碼 repo(第 4 章會用到)
LABEL org.opencontainers.image.source="https://github.com/<你的GitHub帳號>/no-logic-trade"
LABEL org.opencontainers.image.description="no-logic-trade Go API"
WORKDIR /app
COPY --from=builder /out/server /app/server
# distroless:nonroot 內建一個非 root 使用者(uid 65532)
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/server"]
逐行說明(與前一版的差異重點):
# syntax=docker/dockerfile:1:啟用較新的 Dockerfile 功能,放第一行。- build 階段大致不變:仍是「先
COPY go.mod go.sum+go mod download,再COPY . .」的層快取技巧。 go build -trimpath -ldflags="-s -w"(新增的優化):-ldflags="-s -w":拿掉除錯符號表與 DWARF 資訊,執行檔變小(通常小 20–30%)。-trimpath:把編譯路徑資訊去掉,build 更可重現、也不外洩你本機的目錄結構。CGO_ENABLED=0:產生純靜態執行檔(不依賴系統 C 函式庫),才能丟進 distroless static。
FROM gcr.io/distroless/static-debian12:nonroot(最大改變):- distroless 是什麼? Google 出的「只有你的程式 + 最低限度執行環境」的基底 image——沒有套件管理器、沒有 shell、沒有多餘工具。
- 為什麼用它? 體積極小(基底約 2MB),且因為「裡面幾乎沒東西」,攻擊面非常小(駭客進去連
sh都沒得用)。 static-debian12:給「靜態執行檔」用的變體,已內含ca-certificates(打 HTTPS 需要)與時區資料。:nonroot:內建一個非 root 使用者,符合安全慣例。
LABEL org.opencontainers.image.source=...:標準的 OCI 中繼標籤。把它指到你的 GitHub repo 網址,第 4 章推到 GHCR 時,GHCR 會自動把這顆 image 連結到你的 repo(很方便)。記得把<你的GitHub帳號>換成你的帳號。USER nonroot:nonroot:以非 root 身分執行(distroless 已內建這個使用者,不必像 alpine 版自己adduser)。
這裡發生了什麼? 我們把「最終要跑的 image」從一個有 shell、有套件管理器的 alpine,換成「只有你的執行檔 + 憑證」的 distroless。執行檔本身也用
-s -w瘦身。兩者加起來,image 明顯變小、也更安全。
步驟 5:優化③ — 健康檢查的正確做法
你可能聽過 Docker 的 HEALTHCHECK 指令。但 distroless 沒有 shell、也沒有 curl/wget,所以傳統 HEALTHCHECK CMD curl ... 在這顆 image 裡跑不起來。
這其實沒問題,因為——在正式環境,健康檢查是交給「編排器」做的,不是 Docker 自己:
- Kubernetes:用 liveness / readiness probe(第 7 章會設定)。
- Cloud Run:平台會自動對你的服務做健康檢查(第 6 章)。
所以我們刻意不在 distroless image 裡加 HEALTHCHECK,把這件事留給編排器。這是現代容器部署的常見做法。
💡 想在本機
docker run也有 HEALTHCHECK? 那就改用有 shell 的基底(如 alpine)並HEALTHCHECK CMD wget -qO- http://localhost:8080/health || exit 1。但為了體積與安全,正式 image 用 distroless + 編排器 probe 是更好的選擇。
步驟 6:量測優化後
重新 build 並比大小:
docker build -t nlt-api:after .
docker images | grep nlt-api
預期會看到 after 明顯比 before 小:
nlt-api after ... ~15-18MB
nlt-api before ... ~28MB
| 基底 | 大約體積 | 有 shell? | 非 root? | |
|---|---|---|---|---|
| 優化前 | alpine | ~28MB | 有 | 是(自建 user) |
| 優化後 | distroless static | ~15–18MB | 無(更安全) | 是(內建 nonroot) |
(實際數字依你的相依而異,重點是看到「變小且更安全」。)
✅ 如何驗證這一章成功
驗證 1:image 變小
cd code
docker build -t nlt-api:after .
docker images nlt-api:after # 看 SIZE,應比第 2 步的 before 小
驗證 2:用新 image 端到端跑起來
因為 app 需要資料庫,用既有的 docker compose(它會用新版 Dockerfile build app,並一起跑 db):
docker compose up --build -d
curl http://localhost:8080/health
# 預期:{"status":"ok"}
成功代表:image 雖然瘦身又沒有 shell,但你的服務照樣跑得好。收工:
docker compose down
- [ ]
afterimage 體積比before小。 - [ ]
docker compose up --build後curl /health回{"status":"ok"}。
🧯 常見錯誤 / 踩雷點
| 症狀 | 原因 | 解法 |
|---|---|---|
| 本機 build 的 image 部署到雲端起不來(exec format error) | 你在 Apple Silicon(arm64)build,但 Cloud Run/GKE 預設要 amd64 | 部署用的 image 交給 CI 在 amd64 build(第 4 章);本機若要測 amd64:docker build --platform linux/amd64 . |
docker exec -it <容器> sh 進不去 |
distroless 沒有 shell(這是特性不是 bug) | debug 時暫時改回 alpine 基底,或用 :debug 變體;正式仍用 distroless |
改用 scratch 後打 HTTPS / 連 DB 失敗 |
scratch 連 ca-certificates 都沒有 |
用 distroless/static(已內含憑證),或在 scratch 手動 COPY 憑證 |
| image 還是很大 | 忘了 -ldflags="-s -w",或 COPY 進不該帶的東西 |
加上壓縮 flag;檢查 .dockerignore 是否漏掉大檔/資料夾 |
| 改了一行 code,build 卻每次都重抓所有套件 | COPY . . 放在 go mod download 之前,破壞了層快取 |
維持「先 COPY go.mod/go.sum → download → 再 COPY . .」的順序 |
HEALTHCHECK 沒作用 |
distroless 沒有 shell/wget | 正常;健康檢查交給 K8s probe(第 7 章)或 Cloud Run |
下一步 → 第 3 章:CI 第一步 — 用 GitHub Actions 在每次 push 自動跑測試與 build
我們會把 code/ 推上 GitHub,認識 GitHub Actions 與 YAML,替 app 補一個最小的單元測試,並寫出第一個 workflow:每次 push 自動跑 go vet、go test、docker build,讓「壞掉的 code 進不了主線」。