Shared Library:把十份幾乎一樣的 Jenkinsfile,變成公司資產
· tech
📑 目錄
第 2 篇的結論是:把 pipeline 寫成程式碼、放進 repo。這件事在一個專案上是純粹的勝利,但當公司有十個、三十個專案之後,會長出一個新問題——十份幾乎一樣、但又不完全一樣的 Jenkinsfile。
這篇講怎麼把它們收斂成一份共用函式庫,以及收斂之後接踵而來的兩個新麻煩:版本與抽象的分寸。
複製貼上長出來的十份 Jenkinsfile,是十份技術債
新專案要開 CI,最自然的動作是「複製隔壁專案的 Jenkinsfile 再改」。這動作本身沒錯,錯的是它會持續發生兩年:
Shared Library 的三個目錄
一個 shared library 就是一個 git repo,結構固定:
pipeline-lib/
├── vars/ # 給 Jenkinsfile 直接呼叫的「步驟」
│ ├── deployApp.groovy # → 步驟名就是檔名:deployApp(...)
│ └── notifySlack.groovy
├── src/ # 正常的 Groovy class,放複雜邏輯
│ └── com/example/ci/Semver.groovy
└── resources/ # 非 Groovy 的靜態檔(shell、模板)
└── com/example/ci/deploy.sh
vars/ 底下一支步驟長這樣:
// vars/deployApp.groovy —— 呼叫端寫 deployApp(env: 'staging', image: '...')
def call(Map cfg) {
assert cfg.env : 'deployApp: 缺少 env'
assert cfg.image : 'deployApp: 缺少 image'
def script = libraryResource('com/example/ci/deploy.sh') // 實際指令住在 shell 裡
writeFile file: '.ci-deploy.sh', text: script
withCredentials([file(credentialsId: "kubeconfig-${cfg.env}", variable: 'KUBECONFIG')]) {
sh "bash .ci-deploy.sh ${cfg.env} ${cfg.image}"
}
}
呼叫端只要一行宣告,而且帶著版本:
@Library('pipeline-lib@1.4.0') _ // ← 釘住版本,不是抓 master
pipeline {
agent { label 'linux' }
stages {
stage('Build') { steps { sh './scripts/ci-build.sh' } }
stage('Deploy') { steps { deployApp(env: 'staging', image: "app:${env.GIT_COMMIT}") } }
}
post { failure { notifySlack(channel: '#api-alerts') } }
}
我的習慣是讓 vars/ 保持薄:它負責參數檢查、憑證綁定、組裝;真正的指令放在 resources/ 的 shell 腳本裡(理由跟第 2 篇那段一樣——shell 全隊都看得懂,而且本機跑得動),複雜的計算才放 src/ 的 class。
版本釘選:共用函式庫是一種 API,不是一個資料夾
@Library('pipeline-lib') 不加版本,預設抓的是 library 的預設分支。這寫法很方便,而且會在某個週一早上讓全公司的 build 同時壞掉。
更關鍵的是它會打破可重現性:第 2 篇那張圖說「checkout 舊 commit 就拿到當時的建置方式」,但如果 Jenkinsfile 抓的是 library 的 master,那半年前那個 commit 配到的是今天的 library——建置方式又變成一個沒有版本的全域變數,只是這次躲在別的 repo 裡。
所以規則很簡單:
- 一律釘 tag(或 commit SHA),用語意化版本;
@1.4.0而不是@main。 - library 自己要有 changelog 與遷移說明——它的使用者是其他工程師,破壞性變更要有遷移期:舊步驟保留、標記 deprecated、給時間。
- 唯一該強推的是安全性修補,而且要主動通知,不是偷偷改。
抽象的甜蜜點:共用「怎麼做」,不共用「做什麼」
收斂會上癮。抽到後來,很容易出現這種東西:
@Library('pipeline-lib@2.0.0') _
standardPipeline(type: 'springboot') // 就這樣。一行。
看起來很美,實際上是把可審查性又藏回去了——讀這個專案的 Jenkinsfile,你完全看不出它會做什麼。這跟第 2 篇批評的「設定躺在 UI 裡」是同一種病,只是這次躺在另一個 repo 裡:
我的判準是一句話:共用「怎麼做」,不要共用「做什麼」。 怎麼推 image、怎麼發通知、怎麼綁憑證、怎麼跑掃描——這些每個專案都一樣,收進 library;但「這個專案有哪幾個 stage、跑哪些測試、什麼條件才部署」,那是專案自己的事,應該留在它自己的 Jenkinsfile 裡看得見。
另外一定要留逃生門:library 的步驟要能接參數覆寫,或提供 hook 讓專案插入自己的步驟。沒有逃生門的抽象,最後一定會被繞過——工程師會 fork 一份自己改,而你甚至不會知道。這跟 IaC 講的「切太碎是另一種單體」 是同一組取捨:抽象的邊界要跟著「誰會一起變」來切,不是跟著「看起來很像」來切。
Pipeline code 也是 code:它要有自己的測試與 CI
這是最多團隊跳過、然後付代價的一步。共用函式庫一改就影響全公司,它比大部分業務程式碼更需要測試:
// test/vars/DeployAppSpec.groovy —— 用 JenkinsPipelineUnit 假裝跑一次
class DeployAppSpec extends BasePipelineTest {
@Test
void '缺少 env 參數時要直接失敗'() {
def deployApp = loadScript('vars/deployApp.groovy')
shouldFail(AssertionError) { deployApp(image: 'app:abc') }
}
@Test
void '會用對應環境的 kubeconfig'() {
helper.registerAllowedMethod('withCredentials', [List, Closure]) { c, body -> body() }
def deployApp = loadScript('vars/deployApp.groovy')
deployApp(env: 'staging', image: 'app:abc')
assertThat(helper.callStack.findAll { it.methodName == 'sh' }
.any { it.args[0].toString().contains('staging') }, is(true))
}
}
library 自己也該有一條 pipeline:lint → 單元測試 → 打 tag 發版。沒有 CI 的共用函式庫,是一個沒有 CI 的 CI 系統——這句話聽起來像繞口令,但它就是很多公司的現況。
反思
我做過最失敗的抽象,是一行就跑完的 standardPipeline()
那時候我很得意:新專案接 CI 只要三行,一天內可以開十個。但三個月後開始出現兩種味道——第一種是參數爆炸,standardPipeline(type: 'springboot', skipIT: true, extraStage: 'x', customImage: ...),那些旗標一路長到十幾個;第二種更糟,有兩個團隊直接把 library 複製一份改成自己的,因為「等你們排 review 太慢了」。
我後來想明白:那個抽象把每個專案的差異都當成例外,但差異才是常態。而且它踩到一個組織上的死穴——建置流程的修改權被收回中央了。工程師要動自己專案的 CI,得去改另一個 repo、等另一個團隊 review。第 2 篇說 Jenkinsfile 進 repo 讓「改建置流程的資格」回到專案手上,而我那個抽象親手把它收了回去。
現在我重做一次會這樣切:library 提供步驟(deployApp、notifySlack、scanImage),不提供流程;流程長什麼樣,永遠寫在專案自己的 Jenkinsfile 裡。多幾行沒關係,那幾行是給人看的。
那次「改 master」的週一早上
還有一次是版本問題。我們的 library 一直用 @Library('pipeline-lib') _,某個週五下午我改了一個共用步驟的預設值,測過我自己的專案,沒問題,合進 master。
週一早上,四個團隊的 build 同時紅了。最麻煩的不是修,是沒有東西可以 revert——壞掉的是他們的 build,但要 revert 的 commit 在另一個 repo,而且他們沒有任何一個人 review 過那次變更。那天我才真的理解:共用函式庫是一種 API,而我一直把它當成一個資料夾在用。
從那之後規矩就定死了:library 打 tag、走語意化版本、有 changelog;專案端一律釘版本,升級是專案自己發一個 PR(所以升級這件事本身也可 review、可 revert)。唯一例外是安全性修補,而且要在群組公告、給時間。代價是升級變慢了,但換到的是「壞掉的時候有人可以按 revert」——這筆交易我認為划算得不得了。
寫 library 的人,要有產品思維
最後一個心得比較軟。共用函式庫的使用者是其他工程師,而工程師這種使用者有個特性:你的 API 難用,他們不會來抱怨,他們會繞過去——fork 一份、複製貼上、或乾脆不用。等你發現的時候,收斂早就失敗了,而且你完全不知道是什麼時候失敗的。
所以我現在維護共用函式庫,會刻意做三件事:寫一份帶可貼上範例的 README(工程師不讀文件,但會抄範例)、每個破壞性變更附遷移指引、以及定期去看有幾個專案還釘在舊版本——那個數字比任何滿意度調查都誠實。如果有一半的專案卡在三個版本前,那不是他們懶,是我的升級成本設計得太高。
順帶一提,這份 library 之後還會再幫你一次:當有一天要評估搬去別的 CI 工具時,抽象層就是搬家的槓桿——要改的是一份 library,不是三十份 Jenkinsfile。這件事最後一篇會再回來談。
下一篇回到分支:multibranch 怎麼讓每個分支與 PR 都有自己的 pipeline,以及為什麼「trunk-based」不是叫工程師勤勞一點,而是要先把合併與發布這兩件事解開。