第一個 Jenkinsfile:pipeline as code 為什麼贏過 UI 點按鈕
· tech
📑 目錄
上一篇最後那份 Jenkinsfile 只有二十行,語法簡單到不需要解釋。但我還是要為它寫一整篇——因為這二十行真正的價值不在語法,而在它躺在哪裡。
同一個 build,你可以在 Jenkins 的 UI 上點出來,也可以寫成一份進 git 的檔案。功能上兩者跑起來一模一樣;差別要等到半年後、事故當下、或是有人「臨時改一下」的時候才會現形。
Freestyle job:它其實也是檔案,只是住在別人家
先破除一個誤解:在 UI 上點出來的 Freestyle job,不是沒有設定檔。Jenkins 會把它存成 $JENKINS_HOME/jobs/<job 名>/config.xml,長這樣:
<!-- $JENKINS_HOME/jobs/payment-api-build/config.xml —— 在 controller 的硬碟上,不在你的 repo 裡 -->
<project>
<builders>
<hudson.tasks.Shell>
<command>./gradlew clean assemble -DskipTests=true</command>
</hudson.tasks.Shell>
</builders>
<publishers>
<hudson.tasks.junit.JUnitResultArchiver>
<testResults>build/test-results/**/*.xml</testResults>
</hudson.tasks.junit.JUnitResultArchiver>
</publishers>
</project>
看到那個 -DskipTests=true 了嗎?它是什麼時候長出來的、誰加的、為了解決什麼問題,這個檔案一個字都不會告訴你。 它不在任何 PR 裡、沒有 commit message、沒有 reviewer。有 Jenkins 權限的任何人,在任何一個下午,都可以把它改成任何樣子,而全公司沒有一個人會收到通知。
這就是 Freestyle 的四個原罪:
| 原罪 | 具體長什麼樣 |
|---|---|
| 不能 review | 改設定沒有 diff、沒有 PR;「這行為什麼在這」永遠問不到答案 |
| 不能回滾 | UI 沒有版本歷史(要另外裝外掛才勉強有);改壞了只能靠記憶改回去 |
| 不能跟著分支走 | 一份設定服務所有分支——feature branch 想加一個步驟?那就改到大家頭上 |
| 不能複製 | 新專案要開 job,標準作法是「複製那個 job 再改」,於是十個 job 十種樣子 |
但真正致命的,是設定跟程式碼不同步
上面四點都很煩,不過它們都還算「日常的痛」。真正會在事故當下咬你一口的是這件事:程式碼有版本,UI 上的設定沒有。
這件事在事故當下特別致命。線上出包要退回上一個好版本,你 checkout 了那個 tag,重新 build——但這半年來有人在 job 設定裡換過 JDK 版本、加過參數、改過打包方式。你手上這顆產物,跟當初上線那顆不是同一個東西。 它可能跑得起來,也可能在最糟的時間點給你另一個驚喜。
所以「Jenkinsfile 要進 repo」表面上是為了可審查(有 PR、有 diff、有理由),實際上是一次買三個:設定跟著 commit 走,自然就可重現(舊版本配舊設定)、也可回滾(git revert 一下,建置流程跟著回去)。這跟 IaC 講的 everything as code 是同一個信念——差別只在那邊管的是基礎設施,這邊管的是「怎麼把程式碼變成產物」。
同一件事,寫成 Jenkinsfile
把上面那個 Freestyle job 翻成 declarative pipeline,放在 repo 根目錄:
// Jenkinsfile —— repo 根目錄,跟程式碼一起 commit、一起 review、一起 revert
pipeline {
agent { label 'linux' }
options {
timeout(time: 30, unit: 'MINUTES') // 卡住的 build 不該佔著 agent 過夜
disableConcurrentBuilds() // 同一個分支不要兩個 build 打架
}
stages {
stage('Build') {
steps { sh './gradlew clean assemble' }
}
stage('Test') {
steps { sh './gradlew test' }
}
stage('Archive') {
steps { archiveArtifacts artifacts: 'build/libs/*.jar', fingerprint: true }
}
}
post {
always { junit 'build/test-results/**/*.xml' }
failure { echo "build #${env.BUILD_NUMBER} 失敗,先修再說" }
}
}
跟前面那份 config.xml 比,它短、看得懂、而且會被 review。更重要的是:當有人想把測試關掉的時候,他得送出這樣一個 PR——
stage('Test') {
- steps { sh './gradlew test' }
+ steps { sh './gradlew test -DskipTests=true' } // 先擋一下,QA 那邊卡著
}
這個 diff 會出現在 code review 上,有作者、有時間、有理由,而且三週後有人查得到。 UI 上的同一個改動,只會安靜地生效,然後被所有人忘記。
Declarative pipeline 的四層骨架
Declarative 的語法看起來有點囉嗦,但那個結構是刻意的——它逼你把一條 pipeline 拆成四個問題來回答:
post 最容易被忽略,但它是唯一能保證「失敗時也會執行」的地方——收報告、清理、通知都該住在這Declarative vs Scripted:先學前者,別急著逃
Jenkins 有兩種 pipeline 語法。同一件事寫成 scripted 是這樣:
// scripted —— 整份是一段 Groovy 程式,node/stage 只是函式呼叫
node('linux') {
stage('Build') { sh './gradlew clean assemble' }
stage('Test') { sh './gradlew test' }
// 想要「失敗也收報告」?自己 try/finally 包起來
}
差別不在能力,在約束:
| Declarative | Scripted | |
|---|---|---|
| 本質 | 有結構的宣告 | 一段 Groovy 程式 |
| 檢查時機 | 開跑前就驗證結構 | 跑到那一行才知道錯 |
| 失敗收尾 | post 內建 | 自己 try / finally |
| 可讀性 | 不熟 Jenkins 的人也看得懂 | 要會 Groovy |
| 動態產生 stage、複雜控制流 | 做不到(要開 script { } 逃生門) | 可以 |
我的建議很直接:以 declarative 為主,真的需要程式邏輯時再用 script { } 開一個小逃生門,不要整份改寫成 scripted。
stage('Deploy') {
steps {
script { // 只有這一小塊是程式
def envs = params.TARGETS.split(',')
envs.each { e -> sh "./scripts/deploy.sh ${e}" }
}
}
}
一份看得懂的 pipeline,價值遠高於一份寫得很聰明的 pipeline——下一個要在半夜看它的人,可能不會 Groovy。
反思
能被偷偷改的東西,就一定會被偷偷改
我印象最深的一次,是接手後發現某個服務的建置指令帶著 -DskipTests=true。追查下去,那是好幾個月前某次趕上線,有人為了「先擋一下」在 UI 上加的——加完就忘了。中間所有版本都沒跑過測試,而測試報告的頁面還好端端地掛在那,只是永遠是空的。
最讓我在意的不是那次事故,是查不到任何紀錄:沒有 commit、沒有作者、沒有時間。最後只能靠問「誰有那台 Jenkins 的權限」來縮小範圍,像在辦案。從那之後我的判斷很簡單:一個能在下午被人默默改掉、而且沒有痕跡的設定,遲早會被默默改掉。 這跟人品無關,趕上線的壓力下每個人都會這樣做——差別只在系統有沒有留下痕跡。
進 repo 之後,真正改變的是「誰有權改建置流程」
這是我後來才想通的一層。Jenkinsfile 進 repo,權限模型跟著整個翻轉:改建置流程的資格,從「有 Jenkins 管理權的人」變成「能對這個 repo 發 PR 的人」。
聽起來像是放寬,其實是收緊,而且收得更合理——改的人變成最懂這個專案的人,而不是最懂 Jenkins 的那個人;同時每一次改動都要經過該專案的 reviewer。附帶的好處是新人第一天就能改 pipeline:不用申請帳號、不用等人開權限,發 PR 就好。我看過太多團隊卡在「只有兩個人會動 Jenkins」,那不是技術問題,是把設定放錯地方造成的組織瓶頸。
但 pipeline as code 不是免費的——我的解法是讓 Jenkinsfile 只做編排
該講的代價也要講:Groovy 對後端工程師來說是陌生語言、本機沒辦法完整跑一次 pipeline、改一行常常要 push 一次才知道對不對。我看過有人為了調一個參數 push 了十幾次,commit 訊息全是 fix ci。
我自己的作法是把 Jenkinsfile 當編排層,真正的指令收進 repo 裡的腳本:
stages {
stage('Build') { steps { sh './scripts/ci-build.sh' } }
stage('Test') { steps { sh './scripts/ci-test.sh' } }
}
ci-build.sh 裡才是那一長串 gradle 參數。這樣有三個好處:本機能跑同一套(debug 不用 push)、review 時看到的是 shell 不是 Groovy(全隊都看得懂)、以及哪天要換 CI 工具,要搬的只是三十行的殼,腳本原封不動——這件事在系列最後一篇談搬家時會再出現。
代價是多一層間接:想知道 build 到底做了什麼,得多開一個檔案。我認為划算,但這是取捨,不是真理——如果你的建置就是一行 make,那就別為它蓋一層抽象。
下一篇會往下挖一層:這些 sh 到底是在哪一台機器上跑的,以及為什麼「工作去哪台」這件事不該由誰在 UI 上挑,而該由 Jenkinsfile 裡的一個 label 決定。