症狀:部署成功,程式碼卻是舊的

一個新功能已經 commit 並推上 Git,Jenkins pipeline 顯示建置成功、部署完成,Kubernetes Pod 也順利啟動。進入 Pod 檢查——程式碼還是舊的。

這不是網路延遲、不是 image pull policy、不是 tag 衝突。問題藏在 Dockerfile 裡一行看起來「很聰明」的快取優化。

背景知識:Docker 建置的關鍵概念

在進入除錯過程之前,先釐清幾個 Docker 建置中的核心概念:

Docker Image 與 Layer(映像檔與分層)

Docker 映像檔由多個唯讀的「層」(layer)堆疊而成。Dockerfile 裡的每一條指令(如 COPYRUN)都會產生一層。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 的除錯路徑,每一次都以為找到了問題,結果只是剝開了下一層:

Mermaid Diagram

第一次失敗: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 仍然是舊版本。

這完全違反直覺——如果 COPYyarn 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 預設的快取機制。每個 RUNCOPY 指令都是一層,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 CacheDocker 指令層✅ 能
Inline Cache遠端 registry metadata✅ 能(不用 –cache-from 即可)
Cache MountBuildKit 持久化卷❌ 不能

用圖表來看 --no-cache 的作用範圍,就更清楚為什麼它救不了我們:

Mermaid Diagram

修復:移除 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/
  • 任何會影響最終建置產出的目錄
  • 當建置工具會根據快取目錄的內容決定是否跳過編譯時

教訓

  1. --no-cache 不是萬能的。它只停用 Docker layer cache,對 --mount=type=cache 的持久化卷毫無作用。把 build artifacts 目錄掛載為 cache mount,等於把定時炸彈藏在 CI pipeline 裡。

  2. 快取的代價是正確性風險。快取永遠是在「速度」和「正確性」之間做取捨。在 CI/CD 場景中,一次部署錯誤的成本(除錯時間 + 影響範圍)遠大於省下的幾十秒建置時間。

  3. 當 build 結果不符預期,檢查所有快取層。不要假設「我已經清了快取」——先確認你清的是哪一層。


本文記錄了從 stg-1.27 到 stg-1.30 的四次部署嘗試,最終在移除一行 Dockerfile 指令後解決問題。