問題:功能「先在後不在」

我們替 Strapi 後台新增了 MFA(Multi-Factor Authentication)雙因素認證功能。第一次部署到 STG 時一切正常——登入攔截、TOTP 驗證、QR Code 設定流程都運作良好。

但幾天後,因為其他功能的修改推了新的 git tag stg-1.24,部署流程順利完成,Jenkins 顯示綠燈——打開 STG 後台,MFA 的登入流程卻完全不見了。

沒有錯誤訊息、沒有 crash log,功能就這樣無聲無息地消失。

這比「功能從未出現」更令人困惑:明明之前部署是好的,程式碼也沒有人動過 MFA 相關的部分,為什麼會突然不見?

調查過程:逐層排除

確認 Git Tag 內容

第一個直覺是——程式碼真的有被包進 tag 嗎?

# 確認 tag 指向的 commit 包含 MFA 程式碼
git show stg-1.24:src/index.ts | grep -i mfa

結果確認 src/index.ts 中有 import { registerMfaRoutes } from './mfa/controller'src/mfa/ 目錄也完整存在。Git tag 本身沒問題。

檢查 Pod 內的實際檔案

既然 tag 正確,問題可能出在建置或部署階段。直接進到 Kubernetes Pod 裡看:

# 進入 STG pod 檢查
kubectl exec -it deployment/strapi-stg -- sh

# 檢查編譯後的檔案
ls dist/src/mfa/
# 結果:ls: dist/src/mfa/: No such file or directory

# 檢查原始碼
ls src/mfa/
# 結果:ls: src/mfa/: No such file or directory

MFA 相關檔案完全不存在於 Pod 中。 甚至連原始碼都沒有被複製進 Docker image。

比對 src/index.ts 版本

進一步檢查 Pod 內的 src/index.ts

kubectl exec deployment/strapi-stg -- cat src/index.ts

內容是舊版本,完全沒有 MFA 的 import 語句。這表示 Docker build 時用的原始碼根本不是 tag 指向的那個 commit

根因分析:Jenkins Workspace 的隱形陷阱

先解釋一下什麼是 Jenkins Workspace。每個 Jenkins job 在執行時,都會在 agent 機器上分配一個工作目錄(通常是 ~/.jenkins/workspace/<job-name>/),用來存放從版本控制系統 checkout 出來的原始碼、建置過程中產生的中間檔案和最終產物。這個目錄就是 workspace。

關鍵在於:workspace 在建置結束後不會被自動清除。下次同一個 job 再執行時,會直接沿用上次殘留的檔案。這在大多數情況下是好事(可以加速建置),但如果 pipeline 沒有明確 checkout,workspace 裡的程式碼就可能是過時的。

問題的根因在 Jenkinsfile。以下是原本的 pipeline 結構:

stages {
    stage('Parse Git Tag') {
        steps {
            script {
                def tagName = env.TAG_NAME
                // 解析 tag 格式:(dev|stg|prod)-version
                // ...
            }
        }
    }

    stage('Build') {
        steps {
            // docker build ...
        }
    }
}

注意到了嗎?沒有 checkout scm 階段。

為什麼會出問題?

Jenkins 的 workspace 是持久化的。當一個 Pipeline 被觸發時,如果沒有明確執行 checkout scm,Jenkins 不會自動切換到 tag 指向的 commit。workspace 裡保留的是上一次建置的檔案。

這解釋了「先有後無」的時序:

  1. 首次部署 MFA(例如 stg-1.22):當時 workspace 剛好是正確的版本,MFA 順利部署、功能正常
  2. 中間發生了其他建置:可能是不同分支的 tag、或其他 Jenkins job 共用了同一個 workspace,workspace 內容被覆蓋成不包含 MFA 的版本
  3. 推送 stg-1.24:Jenkins 觸發建置,但沒有 checkout,workspace 裡是步驟 2 殘留的舊程式碼
  4. Docker COPY . . 複製的是舊程式碼
  5. 建置、推送、部署全部成功——只是內容是錯的
sequenceDiagram
    participant Dev as Developer
    participant Git as Git Repo
    participant Jenkins as Jenkins
    participant WS as Workspace
    participant Docker as Docker Build
    participant K8s as Kubernetes

    Note over WS: 初始狀態:空的 workspace

    Dev->>Git: push tag stg-1.22(含 MFA)
    Git->>Jenkins: 觸發建置
    Jenkins->>WS: 無 checkout,但 workspace 為空<br/>自動 clone 程式碼(含 MFA)
    WS->>Docker: COPY . .(含 MFA ✅)
    Docker->>K8s: 部署成功,MFA 正常運作 ✅

    Note over WS: workspace 保留 stg-1.22 的檔案

    Dev->>Git: push tag stg-1.23(其他修改,無 MFA 變更)
    Git->>Jenkins: 觸發建置
    Jenkins->>WS: 無 checkout<br/>workspace 被覆蓋為 stg-1.23 的內容
    Note over WS: ⚠️ workspace 被汙染<br/>可能缺少 MFA 檔案

    Dev->>Git: push tag stg-1.24(含 MFA)
    Git->>Jenkins: 觸發建置
    Jenkins->>WS: 無 checkout<br/>沿用 stg-1.23 殘留的舊檔案
    WS->>Docker: COPY . .(無 MFA ❌)
    Docker->>K8s: 部署成功,但 MFA 消失 ❌

為什麼之前沒發現?

這個 bug 的狡猾之處在於:它不是每次都觸發。如果連續兩次建置之間 workspace 剛好沒被其他 job 覆蓋,程式碼就是對的,一切正常。只有在 workspace 被汙染後,接下來的建置才會出問題。而且如果兩個版本之間差異很小,你根本察覺不到 workspace 用的是舊程式碼。只有像 MFA 這種新增了整個目錄的功能,消失時才會這麼明顯。

修復:兩步驟解決

立即修復:推送新 Tag

git tag stg-1.25
git push origin stg-1.25

由於 Jenkins 的 workspace 在 stg-1.24 建置後已經包含該次(雖然錯誤的)程式碼,再推一次 tag 會讓 workspace 有機會正確同步。但這只是碰運氣,不是根本解決方案。

永久修復:加入 checkout scm

stages {
    stage('Checkout') {
        steps {
            checkout scm  // 確保 workspace 同步到觸發的 tag commit
        }
    }

    stage('Parse Git Tag') {
        // ...
    }
}

checkout scm 會根據觸發這次建置的 SCM 事件(在這裡是 git tag push),將 workspace 切換到對應的 commit。加上這一行,就能保證每次建置用的原始碼都是正確的。

Docker Cache 不是元兇

調查過程中,我一度懷疑是 Docker BuildKit 的 --cache-from 快取了舊的 COPY . . layer。但這是錯誤的方向。

Docker BuildKit 的快取機制會對 COPY 指令計算檔案的 checksum。如果原始檔案有變動,快取會自動失效。問題不在 Docker cache,而在餵給 Docker 的檔案本身就是舊的。

這個誤判提醒了一個重要原則:debug 時要區分「資料來源錯誤」和「處理邏輯錯誤」。Docker 的建置邏輯沒問題,是 Jenkins 餵給它的輸入(workspace 內容)出了問題。

經驗總結

預設行為的陷阱

很多工程師(包括我)會假設「Jenkins 觸發了 tag 建置,workspace 當然會是那個 tag 的程式碼」。但 Jenkins 的預設行為並非如此。任何「理所當然」的假設都值得驗證。

除錯方法論

這次除錯的關鍵在於:不要只看建置結果,要進到最終產物裡面看實際內容

如果只看 Jenkins console log(全部綠燈)和 Kubernetes rollout status(成功部署),你永遠找不到問題。直接 kubectl exec 進 Pod 看檔案,才能發現原始碼根本不對。

防範措施

  1. Jenkinsfile 必須有 checkout scm — 永遠不要假設 workspace 是乾淨的
  2. 部署後驗證 — 不只看 health check,要驗證關鍵功能是否存在
  3. 建置產物比對 — 可以在 CI 中加入步驟,比對 Git commit hash 和 Docker image 內的版本資訊
// 在 Build stage 加入版本標記
sh "echo ${env.GIT_COMMIT} > .git-commit"
// 部署後驗證
sh "kubectl exec deployment/\${DEPLOYMENT} -- cat .git-commit"

這次的經驗再次印證:CI/CD 管線中最危險的 bug,不是建置失敗,而是建置成功但內容錯誤。前者會被立即發現,後者可能潛伏到使用者回報時才被注意到。