Tips on Dockerfile

Dockerfile 看似簡單,幾行 FROM、RUN、COPY 就能把應用打包成 image,但寫得好不好,直接牽動 build 速度、image 大小、安全性與日後的可維護性。本文整理幾個實務中最常用、卻也最常被忽略的 Dockerfile 撰寫訣竅。

(一)善用 layer cache , 指令由 變動較少 排到 容易變化 (由上到下)

Docker build 是逐層(layer)快取的,只要某一層的指令或內容改變,該層之後的所有 layer 都會失效重建。因此在Dockerfile內容中,指令順序(由上到下)應該從「變動頻率低」排到「變動頻率高」。最典型的例子,是先複製相依清單、安裝套件,最後才複製原始碼:

# 先處理相依,讓 cache 命中率最大化
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

如此一來,只要原始碼變動、相依沒變,npm ci 這一層就能命中 cache,不必重跑。

(二)用 multi-stage build 縮小 image

把「編譯環境」與「執行環境」分開,只把最終產物帶進 runtime image,可大幅縮小體積,也降低受攻擊面(attack surface):

FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN go build -o server .

FROM alpine:3.20
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

runtime image 不再包含 compiler 與原始碼,從數百 MB 降到數十 MB 是常態。

(三)合併 RUN 並在同一層清理 cache

每一個 RUN 都是一層。把相關指令用 && 串接成一層,並在同一層清掉套件快取,避免多餘體積殘留在 image 裡:

RUN apk add --no-cache curl \
 && rm -rf /var/cache/apk/*

Debian/Ubuntu 則常見 apt-get install 之後接 rm -rf /var/lib/apt/lists/*。重點是「清理必須和安裝在同一層」,否則被刪掉的檔案仍會留在前一層,image 體積依舊臃腫肥大。

(四)指明特定版本,別依賴 latest

FROM alpine:latest 看似方便,卻讓 build 結果無法重現:今天和下週 pull 到的可能是不同版本。base image 與關鍵套件都應該釘住明確版本(pin version),讓 image 具備可重現性(reproducibility):

FROM node:20.11-alpine3.19

(五)別用 root 跑 container

預設情況下 container 內的 process 以 root 執行,一旦被突破,風險極高。建立專用使用者並切換過去,是最基本的 security hardening:

RUN adduser -D appuser
USER appuser

此外,搭配 .dockerignore.gitnode_modules、本機祕密檔案排除在 build context 之外,既加快 build,也避免敏感資料被打包進 image。

(六)辨別 ENTRYPOINT 與 CMD

兩者常被混用,但角色不同: ENTRYPOINT 定義「這個 container 就是要跑的執行檔」,不易被覆寫; CMD 提供「預設參數」,執行 docker run 時可從命令行覆蓋。實務上常見的組合,是用 ENTRYPOINT 固定程式、CMD 給預設參數:

ENTRYPOINT ["nginx"]
CMD ["-g", "daemon off;"]

這樣既保證跑的是 nginx 又保留調整參數的彈性。(兩者的 shell form 與 exec form 差異,留待專文再談。)

(七)加上 HEALTHCHECK 讓容器狀態可觀測

沒有 HEALTHCHECK 時,只要 process 還在,Docker 就認為 container 是健康的,即便服務其實早已卡死。加上健康檢查,orchestrator 才能據此重啟或替換異常的 container:

HEALTHCHECK --interval=30s --timeout=3s \
  CMD curl -f http://localhost:8080/health || exit 1

這對 SRE 而言,是把「容器活著」升級成「服務真的可用」的關鍵一步。

小結

以上提到的共通精髓之處,是把 Dockerfile 當成一個「可重現、可維護、最小化」的產物來對待,而不只是「能 build 起來就好」。

最後,補充一個實務上經常遭遇的情境:在企業內網或 air-gapped 環境下 build image 時,經常卡在 proxy 設定,導致 apkaptnpm 無法對外下載套件而 build 失敗。這是編寫 Dockerfile 時最常見的問題之一,其解決方式我在一篇熱門文章已有提到,可以參考:〈Docker 環境下的 Proxy 配置〉。

常見問題(FAQ)

為什麼調整 Dockerfile 的指令順序能加快 build?
因為 Docker build 是逐層(layer)快取的,只要某一層改變,其後所有 layer 都會失效重建。把變動頻率低的指令(例如複製相依清單、安裝套件)排在前面,把最常變動的原始碼複製排在後面,就能讓相依安裝這一層在原始碼變動時仍命中 cache,不必重跑,大幅縮短 build 時間。
multi-stage build 為什麼能縮小 image?
multi-stage build 把編譯環境與執行環境分開,只用 COPY --from 把最終產物帶進 runtime image。這樣 runtime image 不再包含 compiler、建置工具與原始碼,體積常從數百 MB 降到數十 MB,同時也降低攻擊面。
ENTRYPOINT 和 CMD 有什麼差別?
ENTRYPOINT 定義 container 一定要執行的程式,不易被覆寫;CMD 提供預設參數,執行 docker run 時可從命令行覆蓋。實務上常用組合是 ENTRYPOINT 固定執行檔、CMD 給預設參數,例如 ENTRYPOINT ["nginx"] 搭配 CMD ["-g", "daemon off;"],既保證跑的是 nginx,又保留調整參數的彈性。

留言

熱門文章

Docker 環境下的 Proxy 配置