容器化技術 ch08 Dockerfile 完整解析:每個指令與快取的秘密
下一章→
CH 08 打包主線

Dockerfile 完整解析:每個指令與快取的秘密

Build context 與 .dockerignore指令逐一拆解CMD vs ENTRYPOINTLayer Cache 為什麼會失效BuildKit

前面七章都在講「怎麼用別人做好的映像」。從這一章開始換邊站——你要自己做映像了。

Dockerfile 就是那份製作說明書。它的語法簡單到十分鐘就能看完,但寫得好和寫得爛,差別是 180 MB vs 900 MB、8 秒 vs 4 分鐘。這一章先把每個指令講清楚,然後深入真正決定建置速度的關鍵:layer cache 什麼時候會失效。

理解 cache 之後你會發現,Dockerfile 的指令順序不是隨便排的—— 它是一個關於「什麼東西多久變一次」的設計問題。

幾乎每個人第一天都打過這行指令,卻很少人知道最後那個點的意義:

docker build -t myapp:1.0 .
#                          ↑ 這個點
那個點是 build context(建置情境):你指定的這個目錄,會被整包打包送給 Docker 引擎。Dockerfile 裡的 COPY 只能從這包東西裡拿檔案——拿不到情境以外的任何檔案。

兩個立刻能解釋的現象

  • 「為什麼我 build 一下就卡好幾秒,還顯示 Sending build context to Docker daemon 1.2GB?」
    因為你的專案目錄裡有 target/、node_modules/、.git/——全部被打包送過去了,即使 Dockerfile 根本沒 COPY 它們。
  • 「為什麼 COPY ../common/lib.jar 會失敗?」
    因為 .. 在情境外面。這是刻意的安全設計——否則一份 Dockerfile 就能偷走你整台機器的檔案。

解法:.dockerignore

放在情境根目錄,語法跟 .gitignore 幾乎一樣。它同時解決速度與安全兩個問題:

# === 建置產物與相依(最佔空間)===
target/
build/
node_modules/
*.jar

# === 版控與 IDE ===
.git/
.gitignore
.idea/
.vscode/

# === 秘密!絕對不要進映像 ===
.env
*.pem
*.key
secrets/

# === 其他雜物 ===
README.md
docs/
Dockerfile
docker-compose*.yml
🚨 .git/ 這條特別重要:如果你用 COPY . . 而沒有排除 .git,整個版控歷史都會進到映像裡——包括你三個月前不小心 commit 又刪掉的那個 API key。ch03 說過,映像的層是永久紀錄,刪不掉。
⭐ 小技巧:docker build -f Dockerfile.prod -t myapp . 可以指定用哪份 Dockerfile,情境仍然是那個點。Dockerfile 的位置和 build context 是兩件獨立的事。

FROM —— 從哪個基底開始

FROM eclipse-temurin:17-jre-jammy
FROM maven:3.9-eclipse-temurin-17 AS builder   # 具名階段,多階段建置用(ch10)
  • 必須是第一個指令(ARG 除外)。基底選擇決定了映像 80% 的大小與漏洞數量(ch10 主題)。

WORKDIR —— 設定工作目錄

WORKDIR /app     # 不存在會自動建立;後續指令的相對路徑都以此為基準
  • 不要用 RUN cd /app——每個 RUN 是獨立的一層、獨立的 shell,cd 的效果不會延續到下一個指令。

COPY vs ADD —— 幾乎永遠用 COPY

COPY target/app.jar /app/app.jar
COPY --chown=appuser:appuser target/app.jar /app/app.jar   # 順便設擁有者
  • ADD 多了兩個「貼心」功能:自動解壓 tar、可以從 URL 下載。但這兩個行為都很隱晦:URL 下載不會驗證、不會用快取、還會讓映像多一層;自動解壓則常常出乎意料。
  • 官方建議:一律用 COPY,除非你就是要自動解壓本地 tar。要抓網路檔案請用 RUN curl(可以驗證雜湊、可以順手清理)。

RUN —— 在建置時執行指令,產生新的一層

RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*
  • 為什麼要串在同一個 RUN?ch03 說過層是不可變的:如果 apt-get install 在一層、rm 在另一層,那些套件檔案永遠留在前一層裡,映像照樣變大。「產生垃圾」和「清掉垃圾」必須在同一層完成。
🚨 經典事故:RUN apt-get update 單獨一層。這層被快取住之後可能好幾個月都不重跑,於是套件索引老舊,某天 install 突然噴 404 Not Found——因為索引裡的版本已經從鏡像站下架了。update 和 install 一定要在同一個 RUN。

ENV vs ARG —— 執行期 vs 建置期

ARG JAR_FILE=target/*.jar        # 只在 build 時有效,容器裡看不到
ENV SPRING_PROFILES_ACTIVE=prod  # 會留在映像裡,容器執行時有效

# 建置時覆寫 ARG
docker build --build-arg JAR_FILE=target/myapp.jar .
🚨 兩個都不能拿來放密碼:ENV 會永久留在映像設定裡,任何人 docker inspect 或 docker history 都看得到;ARG 雖然不留在最終環境變數,但會記錄在建置歷史裡。密碼請用執行時注入或 BuildKit secret(ch10)。

其餘指令快覽

  • EXPOSE 8080:文件宣告,不會開埠(ch07 講過)。
  • USER appuser:之後的指令與容器主程式都用這個身分執行。安全必備(ch10)。
  • VOLUME /data:宣告該路徑應被持久化;沒指定時 Docker 會自動建匿名 volume(ch06 說過,會累積垃圾)。自己的應用映像通常不需要寫這個,交給使用者用 -v 決定。
  • LABEL:加 metadata(維護者、版本、原始碼位置),方便管理與稽核。
  • HEALTHCHECK:健康檢查(ch05 講過,Java 記得設 --start-period)。

這兩個都在說「容器啟動時執行什麼」,區別讓無數人困惑。用一個類比就通了:

ENTRYPOINT = 這台機器是什麼(不可換的主體)
CMD = 預設參數(使用者可以換掉)

類比:一台果汁機

  • ENTRYPOINT = 果汁機本體:它就是拿來打果汁的,這件事不會變。
  • CMD = 預設放進去的水果:預設是蘋果,但你可以改放香蕉。
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
CMD ["--spring.profiles.active=prod"]

# 直接執行 → java -jar /app/app.jar --spring.profiles.active=prod
docker run myapp

# 覆寫 CMD(換水果)→ java -jar /app/app.jar --spring.profiles.active=dev
docker run myapp --spring.profiles.active=dev

四種組合的實際行為

  • 只有 CMD(最常見):CMD ["java", "-jar", "app.jar"]。使用者在 docker run 後面接指令會整個取代它——所以 docker run myapp bash 可以進去除錯,很方便。一般應用建議用這個。
  • 只有 ENTRYPOINT:使用者接的東西變成參數,取代不掉主程式。適合把映像做成一個「指令工具」。
  • 兩者都有:ENTRYPOINT 是主體,CMD 是預設參數。彈性最好也最清楚。
  • 都沒有:繼承基底映像的設定;如果基底也沒有,容器啟動就報錯。
🚨 ch05 的鐵律在這裡再強調一次:兩者都務必用 exec form(JSON 陣列)。寫成 shell form 會被包進 /bin/sh -c,你的 Java 就不是 PID 1,SIGTERM 收不到,優雅關機失效。

寫 JSON 陣列時注意:必須用雙引號,單引號不是合法 JSON,會被當成 shell form 處理。

entrypoint 腳本:需要開機前置作業時

#!/bin/sh
set -e
# 這裡可以做啟動前的準備(等待相依服務、產生設定檔…)
exec java -jar /app/app.jar "$@"      # ← exec 是關鍵!
  • exec 這個關鍵字非常重要:它讓 java 取代掉這個 shell 而不是當它的子行程——於是 java 繼承 PID 1,訊號才收得到。少了 exec,你就掉回 ch05 那個陷阱。
  • "$@" 把 CMD 傳來的參數接過去。

Docker build 的每一層都會被快取。理解快取何時失效,是把建置從四分鐘壓到八秒的唯一途徑。

快取命中的規則

Docker 逐層檢查「這一層我以前建過嗎」,判斷依據是:

  • 對 RUN、ENV、WORKDIR 等:比對指令字串本身。一字不差就命中——注意它不會去檢查指令的實際結果(所以 RUN apt-get update 才會拿到過期的索引)。
  • 對 COPY、ADD:比對被複製檔案的內容雜湊。檔案內容變了就失效(只改動時間戳不算)。
最關鍵的規則:快取一旦在某一層失效,後面的所有層全部失效,無一例外。
因為每層都建立在前一層之上,前提變了,後面全部得重來。

由此推導出 Dockerfile 的第一設計原則

⭐ 把「越少變動」的東西放越前面,「越常變動」的放最後面。

Java 專案的實際對照

# ❌ 爛寫法:改一行程式碼,Maven 依賴全部重新下載(4 分鐘)
FROM maven:3.9-eclipse-temurin-17
WORKDIR /app
COPY . .                    ← 原始碼一改,這層就失效
RUN mvn package             ← 於是這層也失效,重新下載 200 個 jar
# ✅ 好寫法:只有 pom.xml 改變時才重新下載依賴
FROM maven:3.9-eclipse-temurin-17
WORKDIR /app
COPY pom.xml .                          ← 很少變動
RUN mvn dependency:go-offline -B        ← 依賴被快取住了!
COPY src ./src                          ← 常常變動,但放在後面
RUN mvn package -o -DskipTests          ← 只有這層要重跑(約 20 秒)

差別:改一行 Java 程式碼,前者要 4 分鐘(重下 200 個 jar),後者只要 20 秒。這個技巧在每個語言都成立——Node 是先 COPY package.json 再 npm ci,Python 是先 COPY requirements.txt 再 pip install。共同原則:先複製「依賴清單」裝好依賴,再複製原始碼。

其他常見的快取殺手

  • COPY . . 沒有 .dockerignore:改個 README 或 .git 一動就整包失效。
  • 把 ARG BUILD_TIME 放太前面:每次建置值都不同 → 從那層開始全滅。這類每次都變的東西一定放最後。
  • CI 上每次都是全新機器:本機有快取、CI 沒有。解法是 docker buildx build --cache-from/--cache-to 把快取存到 registry。

查看與強制

docker build .              # 命中時輸出會顯示 CACHED
docker build --no-cache .   # 強制全部重建(懷疑快取有問題時用)
docker history myapp:1.0    # 看每層多大、由哪個指令產生

從 Docker 23 起,docker build 預設就是用 BuildKit 引擎(舊版可用 DOCKER_BUILDKIT=1 開啟)。它帶來四個實用能力:

① 平行建置與只建需要的部分

  • 多階段建置中互不相依的階段會同時進行。
  • 沒被最終映像用到的階段直接跳過不建。

② 快取掛載(cache mount)——Java 開發者的大禮

# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
    mvn package -DskipTests
  • /root/.m2(Maven 本地倉庫)被掛成一塊跨建置持久化的快取——即使前面的層失效了,已下載的 jar 依然在。
  • 而且這塊快取不會進到最終映像,映像不會因此變大。
  • Node 用 /root/.npm、Go 用 /root/.cache/go-build,同樣道理。

③ 秘密掛載(secret mount)——ch03 那個地雷的正解

RUN --mount=type=secret,id=maven_settings,target=/root/.m2/settings.xml \
    mvn package

# 建置時提供
docker build --secret id=maven_settings,src=$HOME/.m2/settings.xml .
  • 檔案只在那一個 RUN 執行期間存在,不會留在任何一層。這是把私有套件庫憑證帶進建置的正確方式。

④ 多架構建置(ch04 那個 M1 問題的正解)

docker buildx build --platform linux/amd64,linux/arm64 \
  -t myregistry.io/myapp:1.0 --push .
⭐ 第一行 # syntax=docker/dockerfile:1 是啟用這些新語法的宣告,建議所有新 Dockerfile 都加上——它會自動使用最新的 Dockerfile 語法解析器,不用升級 Docker 就能用新功能。
CMD vs ENTRYPOINT 四種組合
寫法docker run myapp 的結果docker run myapp bash 的結果適用
只有 CMD執行 CMDCMD 被整個取代 → 進 bash一般應用(方便除錯)
只有 ENTRYPOINT執行 ENTRYPOINTbash 變成 ENTRYPOINT 的參數把映像做成指令工具
兩者都有ENTRYPOINT + CMD 當參數bash 取代 CMD 成為參數主體固定、參數可換(彈性最好)
都沒有繼承基底映像的設定執行 bash基底映像已定義好時
容易混淆的指令對照
對照組差別該選哪個
COPY vs ADDADD 多了自動解壓 tar 與 URL 下載,但行為隱晦、不驗證、不快取一律用 COPY;抓網路檔案用 RUN curl
ENV vs ARGENV 留在映像、執行時有效;ARG 只在建置期有效執行期設定用 ENV,建置參數用 ARG;兩者都不能放密碼
exec form vs shell formshell form 被包進 /bin/sh -c,你的程式不是 PID 1主程式一律 JSON 陣列(雙引號!)
RUN cd vs WORKDIR每個 RUN 是獨立 shell,cd 的效果不會延續一律用 WORKDIR
EXPOSE vs -pEXPOSE 只是文件宣告,不會開埠要連得進去必須執行時加 -p
Layer Cache 失效規則與對策
指令類型快取比對依據什麼會讓它失效對策
RUN / ENV / WORKDIR指令字串本身字串改了一個字元注意它不檢查實際結果(apt-get update 陷阱)
COPY / ADD被複製檔案的內容雜湊檔案內容變動(改時間戳不算)先 COPY 依賴清單再 COPY 原始碼
所有後續層前一層是否命中前面任一層失效 → 後面全部失效變動頻率低的放前面(第一設計原則)

練習題 點選選項查看解析

0 / 9
01 / 9
docker build -t myapp . 最後那個點代表什麼?
A 當前目錄的 Dockerfile
B build context:這個目錄會被整包送給 Docker 引擎,COPY 只能從這包裡拿檔案
C 映像的儲存位置
D 建置完成後的輸出目錄
解析
這解釋了兩個現象:一是「Sending build context 1.2GB」很慢(target/、node_modules/、.git/ 都被送過去了,即使沒 COPY);二是 COPY ../common/lib.jar 會失敗(情境外的檔案拿不到,這是刻意的安全設計)。解法是寫 .dockerignore。
02 / 9
為什麼 .dockerignore 一定要排除 .git/?
A 會讓映像無法啟動
B COPY . . 時整個版控歷史都會進到映像裡——包括你三個月前 commit 又刪掉的 API key,而映像的層是永久紀錄刪不掉
C Git 檔案格式與容器不相容
D 會影響網路效能
解析
這是真實發生過的資安事故模式。.git 裡藏著所有曾經 commit 過的東西,就算後來刪除了檔案,歷史裡還在。加上 ch03 說的「層是永久紀錄」,等於把秘密永久封裝進映像。.dockerignore 應同時排除 .git、.env、*.pem、*.key。
03 / 9
為什麼 apt-get install 和 rm -rf /var/lib/apt/lists/* 必須寫在同一個 RUN?
A 為了讓指令執行更快
B 因為層是不可變的:如果安裝在一層、刪除在另一層,那些檔案永遠留在前一層裡,映像照樣變大——產生垃圾和清掉垃圾必須在同一層完成
C 因為 apt 不支援分開執行
D 為了避免權限錯誤
解析
這是 ch03「層是不可變的、刪除只是 whiteout 遮蔽」的直接應用。同理,RUN apt-get update 也必須和 install 串在同一個 RUN——否則 update 那層被快取好幾個月,套件索引過期,某天 install 會突然噴 404。
04 / 9
ENTRYPOINT ["java","-jar","/app/app.jar"] 搭配 CMD ["--spring.profiles.active=prod"],執行 docker run myapp --spring.profiles.active=dev 的結果是?
A 報錯,因為參數衝突
B java -jar /app/app.jar --spring.profiles.active=dev(使用者提供的參數取代了 CMD,ENTRYPOINT 不變)
C java -jar /app/app.jar --spring.profiles.active=prod --spring.profiles.active=dev
D 只執行 --spring.profiles.active=dev
解析
果汁機類比:ENTRYPOINT 是機器本體(不可換),CMD 是預設水果(可換)。docker run 後面接的東西會取代 CMD,成為 ENTRYPOINT 的參數。這個組合讓映像有固定的主體行為,又保留參數彈性。
05 / 9
Docker build 的 layer cache,什麼時候會失效?
A 只有 RUN 指令會失效
B RUN/ENV 比對指令字串、COPY/ADD 比對檔案內容雜湊;而且任一層失效後,後面所有層全部失效
C 每 24 小時自動失效
D 只有明確加 --no-cache 才會失效
解析
「一層失效、後面全滅」是最關鍵的規則,因為每層都建立在前一層之上。由此推出 Dockerfile 的第一設計原則:把越少變動的東西放越前面、越常變動的放最後面。另外注意 RUN 只比對「字串」不檢查「結果」,這是 apt-get update 陷阱的成因。
06 / 9
為什麼 Java 專案要先 COPY pom.xml 並執行依賴下載,之後才 COPY src?
A 因為 Maven 規定的順序
B 因為原始碼比 pom.xml 常變動;先裝依賴可以讓那層被快取住,改一行程式碼只需重跑最後的編譯(4 分鐘變 20 秒)
C 為了減少映像層數
D 為了避免權限問題
解析
這是 layer cache 原則最典型的應用,每個語言都成立:Node 先 COPY package.json 再 npm ci、Python 先 COPY requirements.txt 再 pip install。共同原則:先複製依賴清單裝好依賴,再複製原始碼。
07 / 9
關於在 Dockerfile 中放置密碼,正確的是?
A 用 ENV 放很安全,因為只有容器內看得到
B ENV 和 ARG 都不安全——ENV 永久留在映像設定(docker inspect 就看得到),ARG 會留在建置歷史;應改用執行時注入或 BuildKit 的 secret mount
C 用 ARG 就完全安全
D 只要不推到公開 registry 就安全
解析
BuildKit 的 RUN --mount=type=secret 是建置期需要憑證(例如私有 Maven 倉庫的 settings.xml)時的正解:檔案只在那個 RUN 執行期間存在,不會留在任何一層。執行期的設定則用環境變數在 docker run/compose 時注入。
08 / 9
BuildKit 的 RUN --mount=type=cache,target=/root/.m2 有什麼好處?
A 把 Maven 倉庫打包進映像,加快啟動
B 把 Maven 本地倉庫掛成跨建置持久化的快取——即使前面的層失效,已下載的 jar 依然存在,而且這塊快取不會進到最終映像
C 自動更新所有依賴到最新版
D 壓縮依賴檔案節省空間
解析
這是 layer cache 之外的第二層保險:layer cache 是「整層重用」,cache mount 是「即使重跑也不用重新下載」。Node 用 /root/.npm、Go 用 /root/.cache/go-build,同樣道理。記得 Dockerfile 第一行要加 # syntax=docker/dockerfile:1。
09 / 9
在 entrypoint 腳本裡,為什麼最後一行要寫 exec java -jar app.jar "$@" 而不是直接 java -jar app.jar?
A exec 可以加快啟動速度
B exec 讓 java 取代掉這個 shell 而非成為它的子行程,於是 java 繼承 PID 1,SIGTERM 才收得到、優雅關機才有效
C exec 可以傳遞環境變數
D 這只是慣例,沒有實質差別
解析
少了 exec,PID 1 會是那個 shell,你就掉回 ch05 的陷阱:SIGTERM 送給 shell,shell 忽略也不轉發,Java 完全不知道要收工,10 秒後整組被 SIGKILL。"$@" 則負責把 CMD 傳來的參數接過去。

點擊卡片翻面查看答案,共 11 張。

QUESTION
docker build . 的「點」是什麼?
點擊翻面
ANSWER
build context:這個目錄整包送給 Docker 引擎
COPY 只能從這包裡拿檔案(拿不到 ../ 外面的)
→ 用 .dockerignore 瘦身,同時擋掉 .git/.env/*.key
點擊翻回
QUESTION
COPY vs ADD 該用哪個?
點擊翻面
ANSWER
一律用 COPY
ADD 多了自動解壓 tar + URL 下載,但隱晦、不驗證、不快取
抓網路檔案請用 RUN curl(可驗雜湊、可順手清理)
點擊翻回
QUESTION
ENV vs ARG?
點擊翻面
ANSWER
ENV:留在映像,容器執行時有效
ARG:只在 build 期有效,--build-arg 可覆寫
⚠️ 兩者都不能放密碼(inspect / history 都看得到)
點擊翻回
QUESTION
為什麼 install 和 rm 要在同一個 RUN?
點擊翻面
ANSWER
層不可變:分開寫的話檔案永遠留在前一層,映像照樣變大
產生垃圾和清掉垃圾必須同層完成
(同理 apt-get update 要和 install 串一起,否則索引過期噴 404)
點擊翻回
QUESTION
CMD vs ENTRYPOINT 的類比?
點擊翻面
ANSWER
ENTRYPOINT = 果汁機本體(主體,不可換)
CMD = 預設放的水果(參數,使用者可換)
docker run 後面接的東西會取代 CMD
點擊翻回
QUESTION
Layer cache 的比對依據?
點擊翻面
ANSWER
RUN/ENV/WORKDIR:比對「指令字串」(不檢查實際結果!)
COPY/ADD:比對「檔案內容雜湊」(改時間戳不算)
⚠️ 任一層失效 → 後面全部失效
點擊翻回
QUESTION
Dockerfile 的第一設計原則?
點擊翻面
ANSWER
越少變動的放越前面,越常變動的放最後面
Java:COPY pom.xml → 下載依賴 → COPY src → 編譯
效果:改一行程式碼從 4 分鐘變 20 秒
點擊翻回
QUESTION
三個常見的快取殺手?
點擊翻面
ANSWER
① COPY . . 沒有 .dockerignore(改 README 就全滅)
② ARG BUILD_TIME 之類每次都變的放太前面
③ CI 每次都是新機器 → 用 buildx --cache-from/--cache-to
點擊翻回
QUESTION
BuildKit 的四個能力?
點擊翻面
ANSWER
① 階段平行建置、跳過用不到的階段
② cache mount(--mount=type=cache)跨建置快取依賴
③ secret mount(不留在任何一層)
④ buildx 多架構建置
點擊翻回
QUESTION
entrypoint 腳本的關鍵字?
點擊翻面
ANSWER
exec java -jar app.jar "$@"
exec:讓 java 取代 shell → 繼承 PID 1 → 訊號收得到
"$@":把 CMD 傳來的參數接過去
點擊翻回
QUESTION
查看建置與快取的指令?
點擊翻面
ANSWER
docker build .(命中會顯示 CACHED)
docker build --no-cache .(強制全重建)
docker history myapp:1.0(看每層多大、哪個指令產生)
點擊翻回