Workspace 與 Artifact:build 出來的東西去哪了
· tech
📑 目錄
上一篇解決了「在哪台機器上跑」。這一篇往下挖一層:跑起來之後,檔案到底放在哪、留多久、誰看得到。
這聽起來很枝微末節,但它其實是整個系列主軸裡「可重現」最常破功的地方——而且破得很安靜:你的 CI 一片綠,問題要等到換一台機器、或某個同事拉下同一個 commit 的時候才會現形。
Workspace:一個會被重複使用的目錄
Jenkins 在 agent 上給每個 job 一個工作目錄,大概長這樣:
/home/jenkins/workspace/payment-api-build/ ← 一般 job
/home/jenkins/workspace/payment-api_feature-abc/ ← multibranch 會帶分支名
關鍵是這句:下一次同一個 job 跑,預設還是用同一個目錄。 這是刻意的設計,為了快——git 只要抓增量、相依不用重載、編譯可以增量。代價是:上一次 build 留下的東西,還在那裡。
而 checkout 只會把「git 追蹤的檔案」更新到目標版本;那些 build 過程產生、卻沒被 git 追蹤的檔案,它一個都不會清:
處理方式有三層,由輕到重:
pipeline {
agent { label 'linux' }
stages {
stage('Build') {
steps {
sh './gradlew clean assemble' // ① 最輕:讓建置工具自己清它的產物
}
}
}
post {
cleanup { cleanWs() } // ② 每次跑完清掉整個 workspace
}
}
第三層是根本解:每次 build 開一個全新的容器或 pod(第 12 篇),那就沒有「上一次」可言了。
取捨很直接:乾淨 = 慢。所以我的實務作法是分開處理——PR build 允許重用 workspace 換速度,但發布用的 build 一定乾淨跑;另外排一條每天一次的乾淨建置,專門用來抓「我們是不是又開始依賴殘留了」。
檔案的四種住處,壽命完全不同
Jenkins 裡「把檔案留下來」有四種方式,新手最常搞混。它們真正的差別是活多久與誰看得到:
寫成程式碼是這樣:
pipeline {
agent none
stages {
stage('Build') {
agent { label 'linux' }
steps {
sh './gradlew clean assemble'
stash name: 'jar', includes: 'build/libs/*.jar' // 搬給下一個 stage(可能在別台機器)
}
}
stage('Integration Test') {
agent { label 'linux && docker' } // 換 agent = 換 workspace,檔案不會自己跟過來
steps {
unstash 'jar'
sh './scripts/it.sh'
}
}
stage('Publish') {
agent { label 'linux' }
steps {
unstash 'jar'
archiveArtifacts artifacts: 'build/libs/*.jar', fingerprint: true // 留紀錄
sh './scripts/publish-to-nexus.sh' // 真正的發佈
}
}
}
}
stash 有個容易踩的坑:它會把檔案送回 controller 暫存。搬幾 MB 的產物很合理,搬幾百 MB 的 node_modules 就是在拿 controller 的硬碟和網路開玩笑——那種東西該重裝或走遠端快取,不該 stash。
Fingerprint:這顆 jar 是哪一次 build 出來的
fingerprint: true 只多做一件事:記下檔案的雜湊值。但它換來的能力很關鍵——跨 job 追蹤同一個檔案:哪次 build 產出了它、哪些下游 job 用了它。
事故當下你要回答的問題往往是:「線上這顆 jar,是哪個 commit build 出來的?」沒有 fingerprint,你只能靠檔名和時間戳去猜;有了它,Jenkins 直接告訴你來源那次 build,而那次 build 又綁著 commit 與當時的 Jenkinsfile(第 2 篇那張圖的價值就在這裡兌現)。
不過老實說,fingerprint 是補救措施。更根本的作法是產物本身就帶不可變的版本(語意化版本 + commit SHA),而且 一顆產物走完所有環境,不要每個環境各 build 一次——那是第 11 篇的主題。
快取:拿可重現去換速度
快取幾乎是所有 CI 加速討論的第一招,但很少人講清楚它的本質:快取就是刻意保留上一次的狀態。 也就是說,它跟前面講的「髒 workspace」是同一件事的兩面——差別只在於一個是你有意識地留,一個是你不知道它還在。
三種快取的乾淨程度差很多:
| 作法 | 速度 | 可重現性 | 我的看法 |
|---|---|---|---|
| 直接靠 workspace 殘留 | 最快 | 最差 | 這不是快取,是運氣 |
agent 上的共用目錄(~/.m2、~/.gradle) | 快 | 中等 | 常見且堪用,但要接受「不同 agent 結果可能不同」 |
| 遠端快取 / 內部 mirror(內容定址、可鎖版本) | 中等 | 好 | 值得投資,尤其相依很多的專案 |
我的原則是:PR build 可以吃快取,發布用的 build 要乾淨;另外固定跑一條不吃快取的建置(每天或每次 release 前),當作「可重現性的體檢」。快取壞掉的成本,通常不是慢,是它讓你 build 出一顆跟你以為的不一樣的東西。
Hermetic build 在 Jenkins 上能做到幾分
Google SRE 講發布工程時強調 hermetic build:同樣的原始碼,今天 build、半年後 build、在誰的機器上 build,都吐出一樣的結果。完全做到很難,但可以拿四個問題來體檢:
- 工具鏈固定了嗎? JDK / Node / 編譯器版本寫在哪?(寫在容器映像檔裡 > 寫在 agent 上)
- 相依鎖住了嗎? 有 lockfile 嗎?會不會抓到
latest?外部來源掛掉時 build 會不會用到不同版本? - 環境殘留清掉了嗎? 上面整篇講的事。
- 有沒有藏著時間與隨機性? 產物裡有沒有 build 時間戳、隨機 ID,讓兩次 build 的位元永遠對不起來?
Jenkins 本身只給你地基:agent 可拋棄(第 3 篇)、Jenkinsfile 進 git(第 2 篇)。剩下四題全都要靠專案自己回答——這也是為什麼我認為「CI 工具選哪家」遠沒有大家想像的重要,真正決定可重現性的是這四題。
反思
「重跑一次就好了」是狀態問題,不是 flaky
團隊裡最常見的一句話是「這個 build 怪怪的,重跑一次就過了」。以前我也跟著鬆一口氣,現在我把它當成警訊:同一個 commit,兩次跑出不同結果,代表結果不只取決於程式碼。 差異一定來自某個狀態——workspace 殘留、共用快取、agent 之間的差異、或是測試之間互相污染。
我的規則是:同一個 commit 重跑會過,就值得花二十分鐘查一次。查了通常會發現是一個很小的東西(某個測試會寫檔案到專案目錄、某個 port 被前一個 build 佔著、某個相依沒鎖版本)。而不查的代價是,這些小東西會累積成一個「大家都知道 CI 有時候會怪怪的」的文化——那時候你不只失去可重現性,還失去了紅燈的意義。
快取省下的時間,我曾經在一次事故裡全部還回去
有次為了加速,把相依快取設成全 agent 共用而且不設過期。跑了幾個月都很順,直到某次升級一個內部函式庫——版本號沒變、內容變了(對,這件事本身就有問題)。快取裡那份舊的一直被拿來用,於是 CI 上測的是舊版、Production 跑的是新版,兩邊行為不一樣,查了整整兩天。
那次之後我的立場很硬:快取只能是效能手段,不能是正確性的一部分。 具體來說就是:發布路徑上的 build 不吃可疑的快取;相依一律鎖版本;內部函式庫改內容一定換版本號。快取讓你快十分鐘,但它出錯時吃掉的是以「天」計的排查時間,而且通常發生在最不該發生的時候。
把 Jenkins 的 archive 當發佈通道,是我看過最常見的架構錯誤
很多團隊的下游流程,是直接從 Jenkins 的 build 頁面抓 jar 或 zip——URL 寫死在部署腳本或別的 job 裡。這件事在小團隊很方便,但它有兩個致命問題:build 紀錄會被保留策略清掉(那條 URL 有一天會 404),以及沒有不可變的版本概念(誰也說不準那個「最新成功的 build」現在指的是哪一顆)。
我的分工很明確:archive 是給人看的,repository 是給機器取的。 Jenkins 上留一份方便查看與稽核、帶著 fingerprint;正式產物推進 Nexus / Artifactory / 容器 registry,帶上版本與 commit SHA,不可變、可稽核、有生命週期政策。部署流程只認 repository 裡的版本號——這樣「這版是哪來的」永遠有答案,而不是取決於某個 build 紀錄有沒有被清掉。
下一篇進入第二批,回到 Jenkinsfile 本身:平行、條件、失敗處理與那個很多人愛用、但代價比想像中高的人工核准關卡。