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

Jenkinsfile 不是 Groovy:CPS、序列化與沙箱

· tech

#jenkins#ci-cd#groovy

📑 目錄

這個系列到目前為止給過三次同一個建議:Jenkinsfile 只做編排,邏輯放到 shell 腳本或 src/ 的 class 裡。 但我給的理由一直都很軟——好讀(半夜看它的人不一定會 Groovy)、好測(本機跑得動)、好搬家(換 CI 工具只搬三十行的殼)。

這篇補上硬的那個理由:Jenkinsfile 裡的 Groovy,不是你熟悉的那個 Groovy。 它被動過手腳,而那個手腳解釋了幾乎所有你會遇到的怪事。

為什麼 Jenkins 要動你的程式碼

一條 pipeline 可能跑好幾個小時、跨好幾台 agent、中間還會停下來等人核准。而 Jenkins 想保證一件事:controller 重新啟動之後,正在跑的 build 要能接著跑完,不是從頭來過。

要做到這件事,它必須能在任何一個步驟之間,把「這條 pipeline 現在執行到哪、變數裡有什麼」完整地存到磁碟:

checkout sh 'build' sh 'test' deploy 每個步驟之間,把「執行到哪 + 變數裡有什麼」整包寫到磁碟 ⚡ controller 在這裡重啟 重啟後:從最後一次存檔的狀態接著跑,不用從頭來過 為了能隨時存檔,Jenkins 把你的 Groovy 轉成「可以一步步暫停」的形式(CPS) 而能存檔的前提是:所有還活著的變數,都必須可以序列化 NotSerializableException、@NonCPS、某些寫法行為詭異——全部源自這一句話
這張圖是本篇唯一需要記住的東西。Jenkins 給你的是可續跑這個很強的保證,而你付的價錢是「你的程式碼要能被切成一段一段、隨時存進磁碟」。所有奇怪的限制,都是這筆交易的分期付款

第一個症狀:NotSerializableException

最常見的爆法,是把一個「不能序列化」的東西放進變數,然後讓它活過一個 pipeline step:

// ✗ 會炸:JsonSlurper 解析出來的物件不可序列化,而它活過了下一個 sh
def json = new groovy.json.JsonSlurper().parseText(readFile('build-info.json'))
sh "echo 版本是 ${json.version}"      // → java.io.NotSerializableException
// ✓ 用 step 解析(readJSON 來自 Pipeline Utility Steps 外掛),而且立刻取成基本型別
def version = readJSON(file: 'build-info.json').version.toString()
sh "echo 版本是 ${version}"

正規表示式是另一個經典陷阱,因為 =~ 產生的 Matcher 也不可序列化:

// ✗ Matcher 活過了 sh
def m = (readFile('app.log') =~ /version=(\d+)/)
if (m) { sh "echo ${m[0][1]}" }

// ✓ 當場取出值,用完立刻把 Matcher 丟掉
def ver = 'unknown'
def matcher = (readFile('app.log') =~ /version=(\d+)/)
if (matcher.find()) { ver = matcher.group(1) }
matcher = null                      // 別讓它活到下一個 step
sh "echo ${ver}"

規律很好記:任何不是字串、數字、List、Map 的東西,都不要讓它活過一個 step。 用完當場榨成基本型別。

@NonCPS:逃出轉換,但也逃出 pipeline

有些寫法在 CPS 底下就是會怪(eachsortcollect 搭 closure 尤其容易出事)。@NonCPS 讓一個方法不做 CPS 轉換,裡面就是原汁原味的 Groovy:

@NonCPS
def topFailures(String junitXml) {
  // 這裡可以放心用完整 Groovy:closure、sort、collect 都正常
  return new XmlSlurper().parseText(junitXml)
      .testcase.findAll { it.failure.size() > 0 }
      .collect { it.@name.toString() }
      .sort()
      .take(5)
}

但它的代價很硬,而且很多人不知道:

  • 不能在 @NonCPS 方法裡呼叫任何 pipeline step——shechowithCredentials 都不行。因為那些 step 的本質是「暫停,等結果回來再繼續」,而你剛剛才把暫停能力關掉。
  • 不能被中斷、不會存檔,所以裡面別做長時間的事。
  • 出錯時的堆疊訊息會比平常更難讀。

我的規則因此很簡單:@NonCPS 只做純計算,輸入輸出都是字串 / List / Map。 它是一個函式,不是一段流程。

Sandbox:你的 Jenkinsfile 是「不受信任的程式碼」

第三件怪事:有些完全正常的 Groovy,會被擋下來說要管理員核准。

Scripts not permitted to use method java.io.File getText

理由其實很正當,而且跟這個系列的主軸直接相關:第 2 篇我們大力主張「Jenkinsfile 進 repo、人人可 review、任何人都能發 PR 改它」。那麼反過來看——任何能對這個 repo 發 PR 的人,都能讓一段 Groovy 在你的 Jenkins 上執行。 如果不設限,那等於把 controller 的檔案系統、憑證、JVM 全部交出去。

所以 Jenkins 預設把 Jenkinsfile 跑在 Groovy sandbox 裡:只有白名單上的方法能用,其餘要管理員在 script approval 頁面核准。

這裡有個觀念要擺正:核准不是「幫某個人開一次例外」,而是對整台 Jenkins 開放那個方法——之後任何 job、任何 PR 帶進來的 Jenkinsfile 都能用它。它是一個安全決策,不是一個「同意」按鈕。第 6 篇講的信任邊界,在這裡是同一條線。

於是「在 Jenkinsfile 裡寫一行聰明的 Groovy」的真實成本變成:你可能要請一個管理員,替全公司承擔一個永久的風險擴張。 而同一件事寫成 shell 腳本,成本是零。

同一段邏輯的三個住處

把上面三件事併起來看,答案就很清楚了:

Jenkinsfile 主體 @NonCPS 方法 shell / src class 一般 Groovy 寫法 ✗ 受 CPS 限制 ✓ 完整 Groovy ✓ 完全正常 呼叫 sh / 憑證等 step ✓ 這就是它的工作 ✗ 一律不行 — 自己就是系統呼叫 本機能不能跑 ✗ 要 push 才知道 △ 要靠測試框架 ✓ 直接執行 需要沙箱核准嗎 可能要,且是全域開放 一樣受限 ✓ 不需要 該放什麼 流程編排 純計算 真正的邏輯 流程留 Jenkinsfile · 計算放可測試的 class · 跟系統互動的事情放 shell
這張表就是我前面幾篇「別在 Jenkinsfile 裡寫程式」的完整答案:不是 Groovy 不好,是那個位置不好。同一段邏輯往右移一格,你就換到了本機可跑、不需核准、全隊看得懂

那 shared library 的 src/ 呢?它一樣受 CPS 影響(除非標 @NonCPS),但它有一個 Jenkinsfile 沒有的優勢:它是真正的 class,可以寫單元測試(第 7 篇有範例)。所以我的分配是:

  • 要跟 pipeline step 互動的 → vars/ 的步驟
  • 純運算、需要被測試的 → src/ 的 class(必要時 @NonCPS)
  • 跟系統、工具鏈互動的 → shell 腳本,而且放在專案 repo 裡

反思

教會我這件事的,是一個「只在 Jenkins 重啟後才失敗」的 bug

我第一次認真理解 CPS,是因為一個詭異到不行的狀況:某條 pipeline 平常都好好的,但只要那天 Jenkins 有升級或重啟,正在跑的 build 就會在重啟後爆掉,而錯誤訊息是一個我從沒看過的序列化例外。

追下去才發現,那段程式碼把一個 XML 解析物件放在變數裡,跨了兩個 step。平常沒事,是因為根本沒發生存檔;一旦 controller 要把狀態寫進磁碟,那個物件就送不出去。

那次讓我改變的不只是寫法,是心智模型:我以前把 Jenkinsfile 想成「一個從上到下執行的腳本」,那之後我把它想成「一台狀態機,而它的狀態要能寫進磁碟」。用這個模型去看,前面那些限制沒有一條是任性的——它們全都是為了同一個承諾。

在 Jenkinsfile 裡寫程式,成本是階梯狀的

這是我後來常跟團隊講的比喻:前十行程式碼幾乎免費,你會覺得「這樣寫很方便」;但從某一行開始,你會突然同時撞上序列化例外、@NonCPS 的限制、以及一個要管理員核准的方法呼叫——而那時候你已經投入很深,通常會選擇繼續硬幹,而不是退回去改架構。

所以我現在在 review 看到 Jenkinsfile 裡出現 def 開始接複雜結構、或出現第二層 closure,就會提早喊停:這段程式碼想住的地方不是這裡。 不是它寫得不好,是它會在錯誤的地方繼續長大。

「可審查」與「不受信任」是同一枚硬幣

sandbox 這件事一開始讓我覺得很煩,後來我認為它反而把一個道理講得很誠實:我們之所以要 sandbox,正是因為我們成功了。 Jenkinsfile 進了 repo、任何人都能發 PR 改它——這是第 2 篇追求的目標,而它的另一面就是:這個檔案是外部輸入,不能無條件信任。

想通這點之後,我對「Jenkinsfile 該多薄」有了更硬的立場:一個會被當成不受信任輸入的檔案,天生就不該是放核心邏輯的地方。 邏輯應該住在經過 review、有測試、有版本的地方——shell 腳本、src/ class、或共用函式庫。Jenkinsfile 只留下「先做什麼、再做什麼」這件事。

薄的 Jenkinsfile 不是為了好看,是因為它站在信任邊界上。

下一篇進第三批,談品質關卡:測試報告、覆蓋率與靜態分析,怎麼從「有跑」變成「擋得住」,以及為什麼 flaky test 是這道門最大的敵人。