第 2 章:從既有容器化 app 出發 — 優化 Docker image

第 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 裡幾乎每一個指令(FROMCOPYRUN…)都會產生一層。這帶來兩個關鍵特性:

  • 層快取(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:版控歷史很肥又用不到。
  • *.mdDockerfiledocker-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
  • [ ] after image 體積比 before 小。
  • [ ] docker compose up --buildcurl /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 失敗 scratchca-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 vetgo testdocker build,讓「壞掉的 code 進不了主線」。