🌐 This page hasn't been translated yet — showing the original Chinese. Translated posts

進階 pipeline:平行、條件、失敗處理與那個很貴的人工關卡

· tech

#jenkins#ci-cd#pipeline

📑 目錄

前面四篇處理的是「在哪跑」與「東西放哪」。這一篇回到 Jenkinsfile 本身:同樣一條會動的 pipeline,怎麼讓它跑得快、失敗得清楚、而且不會在半夜卡住一台機器

這批東西的共同點是:它們都很好學,但用錯的代價要很久以後才會顯現——尤其是 retryinput 這兩個。

平行:最直接的加速,但不是免費的

最容易拿到的加速,是把互不相依的 stage 攤平一起跑:

stage('Verify') {
  parallel {
    stage('Unit')        { steps { sh './scripts/unit.sh' } }
    stage('Integration') { agent { label 'linux && docker' }
                           steps { sh './scripts/it.sh' } }
    stage('Lint')        { steps { sh './scripts/lint.sh' } }
  }
}

效果是這樣:

序列:一個接一個 Unit 4m Integration 6m 1m Image 3m 14m 010 分 平行:同時開跑 Unit 4m Integration 6m ← 最慢的那條 1m Image 3m 6m —— 總時間 = 最慢的那一條 所以再加平行不會更快,要動的是那條 6 分鐘的 平行的代價:每條各佔一個 executor 格子 · 同機互搶 CPU 與硬碟 · 日誌交錯變難讀 格子不夠時,「平行」會退化成「排隊」——只是排在 Jenkins 內部,你在 UI 上看不出來
平行化的收益有天花板,而且天花板由最慢的那一條決定。這也是為什麼「先量再改」很重要:很多團隊把四條快的攤平,省了兩分鐘,卻沒發現真正該處理的是那條六分鐘的整合測試

failFast: true 可以在任一條失敗時砍掉其他條,省資源;但代價是你只會看到第一個錯,重跑才知道還有沒有別的。我的習慣是:PR build 開 failFast(要快),主幹的定時 build 不開(要看全貌)。

需要跑版本矩陣時用 matrix,它會自動展開組合:

stage('Compat') {
  matrix {
    axes {
      axis { name 'JDK';  values '17', '21' }
      axis { name 'OS';   values 'linux', 'windows' }
    }
    excludes { exclude { axis { name 'OS'; values 'windows' }
                         axis { name 'JDK'; values '17' } } }   // 這組不測
    stages {
      stage('Test') { steps { sh "./scripts/test.sh --jdk=${JDK}" } }
    }
  }
}

矩陣很好用,但它會乘法級地吃掉 executor:2 × 2 就是四格,而且每一格都是一個完整的 workspace。開之前先看一眼你有幾個格子(第 3 篇那張派工圖)。

when:讓分支只跑該跑的,但記得加 beforeAgent

不是每個分支都需要跑完整條 pipeline:

stage('Deploy to Staging') {
  when {
    beforeAgent true               // ← 關鍵:先判斷再決定要不要佔 agent
    branch 'main'
  }
  agent { label 'linux' }
  steps { sh './scripts/deploy.sh staging' }
}

beforeAgent true 是這裡最容易漏的一行。 沒有它,Jenkins 會先配一個 agent、拉一份程式碼,才發現條件不成立然後跳過——你省了執行時間,卻沒省到排隊與 checkout 的成本。在一個 stage 很多的 pipeline 上,這個差別很有感。

其他常用條件:changeset '**/*.sql'(只有動到 SQL 才跑遷移檢查)、changeRequest()(只在 PR 跑)、expression { params.FULL_RUN }

但有條線我會守得很緊:when 是拿來省時間的,不是拿來偷偷跳過品質關卡的。 「這個分支比較急,先跳過整合測試」這種條件一旦寫進去,它會活得比那次急件久很多——關卡該怎麼設計是第 10 篇的主題。

post:唯一保證會執行的地方

post 是 declarative 相對 scripted 最實用的一個好處:不用自己寫 try/finally

post {
  always    { junit 'build/test-results/**/*.xml' }        // 成敗都要收報告
  success   { sh './scripts/notify.sh ok' }
  failure   { sh './scripts/notify.sh fail' }
  unstable  { echo '測試有失敗,但 build 本身沒炸' }
  changed   { sh './scripts/notify.sh changed' }           // 只有狀態翻轉時才吵人
  cleanup   { cleanWs() }                                  // 最後一定跑,清 workspace
}

兩個常被忽略的:

  • changed 只在「由綠轉紅」或「由紅轉綠」時執行。把通知掛在這裡,而不是 failure,可以讓一條連紅五天的 pipeline 不要吵五天——通知的價值在於狀態改變,不在於現在是什麼狀態
  • cleanup 保證最後執行,適合放清理;always 則是在成敗判定後、cleanup 之前跑。

timeout、retry、catchError:三把刀,別拿錯

options { timeout(time: 30, unit: 'MINUTES') }     // ① 整條 pipeline 的上限

stage('Fetch deps') {
  steps {
    retry(3) { sh './scripts/fetch-deps.sh' }      // ② 只包「會因為網路而失敗」的動作
  }
}

stage('Upload report') {
  steps {
    catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') {
      sh './scripts/upload-report.sh'              // ③ 非關鍵:失敗就記一筆,別擋整條
    }
  }
}

三者的分工:

用來對付用錯的樣子
timeout卡住不動的 build(等鎖、等回應、無窮迴圈)沒設——一條卡住的 build 佔著 executor 一整夜
retry暫時性、外部、冪等的失敗:拉相依、推 image、call API拿來包測試,把 flaky 蓋掉
catchError非關鍵步驟失敗時不想擋住整條包住關鍵步驟,讓紅的變綠的

retry 的前提是那個動作冪等——做兩次跟做一次結果一樣。拉相依、下載檔案沒問題;但「呼叫一個會扣款的 API」重試三次可能扣三次。這件事跟 排程任務要能安全重跑 是同一個道理,只是換了個場景。

至於 timeout,我的立場是每條 pipeline 都該有一個。理由在第 3 篇講過:卡住的 build 不會自己放手,它會抱著 executor 格子跟 workspace 睡到天亮,然後隔天早上所有人的 build 都在排隊。

input:看起來最無害,實際上最貴的 step

input 讓 pipeline 停下來等人按「同意」。語法簡單到危險:

stage('Approve') {
  steps {
    input message: '要部署到 Production 嗎?', ok: '部署', submitter: 'release-managers'
  }
}

問題是:這個 stage 停在哪裡,它就抱著哪些資源。

✗ input 卡在有 agent 的 stage 裡 Build(佔 agent) input:等人按「同意」可能等一整夜(下班了、在開會、忘了) Deploy 這段期間:executor 格子被佔 · workspace 被鎖 · 別人的 build 在排隊 ✓ input 放在 agent none 的獨立 stage Build(佔 agent) agent none + input + timeout不佔 agent;沒人按就自動結束,不會卡到天亮 Deploy按了才重配 agent 更根本的解法:把「要不要上線」拆成另一條 pipeline 建置與部署綁在同一次執行裡,等於讓一個人的猶豫,變成整個團隊的排隊 拆開之後:建置永遠跑完就結束,部署是拿著某顆已經存在的產物去執行
「等人按一下」在流程圖上只是一個小方框,在系統裡卻是一段持有資源的等待。最低限度要做的是把它移出 agent 並加上 timeout;真正乾淨的作法,是讓建置與部署成為兩件事

還有一個配套是 milestone:當新版本已經跑到後面,擋住還卡在核准階段的舊版本,避免有人半夜按了同意、結果把三天前的舊產物推上去。

參數化:方便,但別變成 UI 點按鈕的復辟

parameters {
  choice(name: 'TARGET', choices: ['staging', 'production'], description: '部署目標')
  booleanParam(name: 'FULL_RUN', defaultValue: false, description: '跑完整測試矩陣')
}

參數很有用,但我 review 時會盯一種用法:拿參數當「跳過檢查」的開關SKIP_TESTSFORCE_DEPLOY 這類參數一旦存在,它就會在最忙、最急、最不該省的那天被打勾——而且跟 UI 點按鈕一樣,不會留下「為什麼」。

需要緊急放行的機制不是不能有,但它應該留下痕跡(誰、何時、為什麼),而不是一個藏在下拉選單裡的核取方塊。

反思

我最後悔的一次 retry,是把 flaky test 包起來

有段時間某個整合測試大概每十次失敗一次。查了半天沒結論,我就先用 retry(2) 包住,想著「等有空再處理」。紅燈立刻消失,大家都很開心。

三個月後,Production 出現一個偶發的資料錯亂,查到最後是一個 race condition——而那個 flaky test 從頭到尾都在告訴我們這件事。它不是不穩定,它是間歇性地說對了。我親手把唯一的警報器包了層棉花。

所以現在我對 retry 的規則很硬:只包網路與外部相依,不包測試。 測試不穩定就當成 bug 開單處理——查不出來也要先隔離、標記、留紀錄,而不是重試到它閉嘴。這跟 測試的意義 是同一件事:測試存在的價值是告訴你真相,你把真相重試掉了,剩下的只是一個很花時間的儀式。

加平行之前先量,不然你會在錯的地方省時間

第一次接手一條 14 分鐘的 pipeline,我的直覺是「攤平就好」。攤完剩 9 分鐘,不錯——但再往下就卡住了,因為整合測試那條就要 8 分鐘。

實際去量之後才發現,那 8 分鐘裡有將近 5 分鐘是測試裡的固定 sleep:等服務起來、等資料寫入、等訊息消費。改成輪詢等待條件成立之後,那條變成 3 分鐘,整條 pipeline 掉到 4 分鐘——而這件事跟平行化一點關係都沒有。

從那之後我的順序固定是:先量每個 stage 的時間 → 找最慢那條 → 問它慢在哪 → 最後才考慮平行。 平行是把時間攤開,不是把時間變少;真正的加速通常來自刪掉不必要的等待。這個題目第 15 篇會完整展開。

input 是一種流程設計的味道

最後一個是觀念上的。我現在看到 pipeline 中間有 input,第一個念頭不是「這裡要加 timeout」,而是「為什麼建置和部署被綁在同一條 pipeline 裡?」

把它們拆開之後,幾乎所有問題自己就消失了:建置永遠跑完就結束(不會有人在等),部署是一次獨立的執行、拿著一顆已經存在且不可變的產物去做事(想部署哪個版本就給哪個版本,不用重跑建置)。連審核紀錄都變好了——「誰在什麼時候把哪個版本推到 Production」變成一次獨立事件,而不是藏在某次 build 的第七個 stage 裡。

input 本身沒有錯,錯的是用它把兩件不同節奏的事黏在一起。這條線怎麼拆,是第 11 篇的主題。

下一篇處理一個踩雷成本最高的題目:憑證。機密怎麼進 pipeline、Jenkins 的遮蔽到底遮得住什麼,以及為什麼「一條可審查的 pipeline」的代價,就是機密必須從一開始就不在裡面。