症狀:部署成功,程式碼卻是舊的
一個新功能已經 commit 並推上 Git,Jenkins pipeline 顯示建置成功、部署完成,Kubernetes Pod 也順利啟動。進入 Pod 檢查——程式碼還是舊的。
這不是網路延遲、不是 image pull policy、不是 tag 衝突。問題藏在 Dockerfile 裡一行看起來「很聰明」的快取優化。
背景知識:Docker 建置的關鍵概念
在進入除錯過程之前,先釐清幾個 Docker 建置中的核心概念:
Docker Image 與 Layer(映像檔與分層)
Docker 映像檔由多個唯讀的「層」(layer)堆疊而成。Dockerfile 裡的每一條指令(如 COPY、RUN)都會產生一層。Docker 會對每一層計算 hash,下次建置時如果指令和輸入都沒變,就直接重用該層——這就是 layer cache。
BuildKit
BuildKit 是 Docker 18.09 之後引入的新一代建置引擎(透過 DOCKER_BUILDKIT=1 啟用)。相比傳統引擎,BuildKit 支援平行建置、更聰明的快取策略,以及本文主角——--mount=type=cache(cache mount)語法。
Cache Mount(--mount=type=cache)
BuildKit 專有的功能。它在 RUN 指令執行期間,將一個持久化的目錄掛載到容器內的指定路徑。這個目錄由 BuildKit 管理,不會被寫入最終的 image layer,但內容會跨越不同次的 docker build 保留下來。常見用途是快取套件管理器的下載目錄(如 yarn cache),避免每次建置都重新下載。
Inline Cache 與 --cache-from
另一種 BuildKit 快取策略。透過 --build-arg BUILDKIT_INLINE_CACHE=1 把快取 metadata 嵌入產出的 image,再用 --cache-from 從遠端 registry 拉取。這讓不同機器(例如 CI runner)也能共享建置快取。
--no-cache 旗標
Docker build 的選項,告訴 Docker 不要使用 layer cache,強制每一層都重新執行。但注意——它只作用於 layer cache,對 cache mount 的持久化儲存完全無效。
除錯過程:四次部署才找到真正元兇
以下是從 stg-1.27 到 stg-1.30 的除錯路徑,每一次都以為找到了問題,結果只是剝開了下一層:
第一次失敗:Layer Cache 全部命中
Jenkins build log 顯示所有 Docker layer 都是 CACHED,包括 COPY . . 這個應該偵測到檔案變更的指令。原本使用的 --cache-from 搭配 inline cache 機制,理論上會比對每一層的內容。但實際上,inline cache 的 metadata 有時不會正確追蹤 COPY 指令涉及的檔案變更,導致 Docker 誤判「這層沒變」。
修正:移除 --cache-from,改用 --no-cache。
第二次失敗:–no-cache 跑了,但還是舊的
加了 --no-cache 之後,build log 確認 COPY . . 和 yarn build 都確實重新執行了(不再顯示 CACHED)。yarn build 花了 19 秒,看起來一切正常。
但進入新部署的 Pod,admin panel 的 bundle 仍然是舊版本。
這完全違反直覺——如果 COPY 和 yarn build 都重新跑了,怎麼可能產出舊的 build artifacts?
關鍵發現:第三層快取
問題出在 Dockerfile 的這一行:
# 修改前:使用 BuildKit cache mount 加速 build
RUN --mount=type=cache,target=/opt/app/.strapi,id=strapi-cache,uid=1000,gid=1000 \
yarn build
.strapi/ 目錄是 Strapi 存放 admin panel build artifacts 的位置。--mount=type=cache 會在 build 過程中掛載一個持久化的快取卷到指定路徑。
根本原因:Docker 快取的三個層次
這次除錯揭露了 Docker 建置過程中三種完全不同的快取機制,各自獨立運作:
1. Layer Cache(指令層快取)
Docker 預設的快取機制。每個 RUN、COPY 指令都是一層,Docker 會比對指令內容和輸入檔案的 hash,決定是否重用快取。
--no-cache 停用的就是這層。
2. Inline Cache(遠端快取 metadata)
透過 --build-arg BUILDKIT_INLINE_CACHE=1 將快取 metadata 寫入 image,搭配 --cache-from 從遠端 registry 拉取快取。這讓不同機器也能共享快取。
問題:inline cache 對 COPY . . 的檔案變更偵測可能不精確,導致誤判。
3. Cache Mount(持久化卷快取)
--mount=type=cache 建立的是 BuildKit 管理的持久化儲存空間,跨 build 存活,完全獨立於 image layer 之外。
--no-cache 對它完全無效。
這就是問題的核心。即使 yarn build 確實重新執行了,由於 .strapi/ 被掛載了舊的 cache mount,Strapi 的 build 工具偵測到裡面已有編譯結果,認為不需要重新編譯 admin panel,直接使用了舊的 artifacts。
用表格整理這三層的差異:
| 快取類型 | 作用對象 | --no-cache 能清除? | 跨 build 存活? |
|---|---|---|---|
| Layer Cache | Docker 指令層 | ✅ 能 | 是 |
| Inline Cache | 遠端 registry metadata | ✅ 能(不用 –cache-from 即可) | 是 |
| Cache Mount | BuildKit 持久化卷 | ❌ 不能 | 是 |
用圖表來看 --no-cache 的作用範圍,就更清楚為什麼它救不了我們:
修復:移除 Cache Mount
修復方式出乎意料地簡單——移除 .strapi 目錄的 cache mount:
# 修改後:直接 build,不使用 cache mount
RUN yarn build
不過,這代表每次建置都要完整跑一次 Strapi admin 的 webpack 打包(約 60-90 秒)。在我們的場景中,部署頻率大約每天 1-2 次,這點建置時間完全可以接受。正確性永遠比速度重要。
什麼時候該用 Cache Mount?
Cache mount 並非一無是處。它在以下場景很有用:
- 套件管理快取:
/root/.npm、/root/.yarn-cache等路徑,存放的是不可變的套件壓縮檔,不會有「舊版本殘留」的問題 - 編譯器快取:如 Go 的
GOMODCACHE,快取的是 module 而非最終產物 - 不影響最終產物的中間快取:關鍵在於——快取內容不會直接成為 image 的一部分
不該用 cache mount 的場景:
- Build artifacts 目錄(如
.strapi/、dist/、.next/) - 任何會影響最終建置產出的目錄
- 當建置工具會根據快取目錄的內容決定是否跳過編譯時
教訓
--no-cache不是萬能的。它只停用 Docker layer cache,對--mount=type=cache的持久化卷毫無作用。把 build artifacts 目錄掛載為 cache mount,等於把定時炸彈藏在 CI pipeline 裡。快取的代價是正確性風險。快取永遠是在「速度」和「正確性」之間做取捨。在 CI/CD 場景中,一次部署錯誤的成本(除錯時間 + 影響範圍)遠大於省下的幾十秒建置時間。
當 build 結果不符預期,檢查所有快取層。不要假設「我已經清了快取」——先確認你清的是哪一層。
本文記錄了從 stg-1.27 到 stg-1.30 的四次部署嘗試,最終在移除一行 Dockerfile 指令後解決問題。
