Controller 與 Agent:工作到底在哪台機器上跑
· tech
📑 目錄
上一篇那份 Jenkinsfile 裡有一行 sh './scripts/ci-build.sh'。它到底在哪台機器上執行?
這個問題聽起來很基本,但它決定了三件事:你的 build 要等多久、失敗時該去哪台機器查、以及最重要的——換一台機器跑,還會不會過。
Controller 不 build,它只做四件事
Jenkins 的 controller(以前叫 master)其實不碰你的程式碼。它負責的是:
- 讀 Jenkinsfile——從 repo 拉下來、解析結構;
- 排隊——把待跑的 build 放進佇列;
- 派工——依照 label 找到合適的 agent,把工作丟過去;
- 保存——紀錄、日誌、產物、以及那個大家都在看的 UI。
它不該執行 build。 這不是潔癖,是很實際的理由:controller 是單點,它一倒,全公司不能上線;而 build 是最會吃 CPU、吃記憶體、寫滿硬碟、偶爾把整台機器搞掛的工作。把兩者放同一台,等於拿最脆弱的東西去承受最粗暴的負載。
實務上第一件該做的事,就是把內建節點的 executor 數設成 0——用 JCasC 寫是這樣(這份檔案本身也該進 git,第 13 篇會專門講):
# jenkins.yaml —— Configuration as Code
jenkins:
numExecutors: 0 # 內建節點不接工作:controller 只調度,不建置
labelString: "built-in"
一個 build 從觸發到真的開始跑
按下 build 到日誌開始滾動,中間這段路是很多人卡住卻不知道自己卡在哪的地方:
Executor 是這裡最常被誤解的概念。 它不是一台機器,而是一台 agent 上的並行格子:一台 agent 設 4 個 executor,就能同時跑 4 個 build。所以「加機器」跟「加 executor」是兩種不同的解法——而且後者很容易變成災難,我在反思那段會講。
Label:Jenkinsfile 開需求,平台負責供給
工作要去哪一台,不是誰在 UI 上挑的,是 Jenkinsfile 裡宣告的:
pipeline {
agent { label 'linux && docker' } // 需要:Linux,而且裝了 docker
// ...
}
label 支援布林運算(&&、||、!),所以你可以精確描述「這份工作需要什麼能力」。也可以整條 pipeline 不綁 agent,讓每個 stage 自己挑:
pipeline {
agent none // 整條不佔 agent
stages {
stage('Build') {
agent { label 'linux && docker' }
steps { sh './scripts/ci-build.sh' }
}
stage('Sign iOS') {
agent { label 'mac && xcode-16' } // 只有這一段需要 mac
steps { sh './scripts/sign.sh' }
}
}
}
這裡有個一定要知道的陷阱:換 agent 就是換機器,也就換了 workspace。 上一個 stage 產出的檔案不會自己跟過去,要靠 stash / unstash 搬運——這是下一篇的主題。
還有一個我看到就會在 review 提出來的反模式:
agent { label 'build-server-03' } // ✗ 這不是能力,是機器名
label 應該描述「需要什麼能力」,不是「要哪一台機器」。 寫成機器名,等於把實作細節寫進契約——那台機器退役、改名、或臨時要維護,所有指名它的 pipeline 一起壞掉。這個抽象跟 Kubernetes 的 nodeSelector 是同一個道理:你宣告需求,排程器負責媒合,而不是自己挑 node。
Agent 怎麼接上來
三種接法,差別在誰主動連誰,以及誰負責這台機器的生命週期:
| 方式 | 連線方向 | 適合 | 代價 |
|---|---|---|---|
| SSH | controller 主動連 agent | 固定的 VM、地端機房 | controller 要能連到 agent(網路、金鑰要管) |
| Inbound(JNLP) | agent 主動連回 controller | NAT / 防火牆後面、雲端、跨網段 | agent 要拿得到連線密鑰 |
| 動態容器 / K8s Pod | 每次 build 現開,跑完就丟 | 尖峰彈性、要乾淨環境 | 沒有暖好的快取,首次拉相依較慢(第 12 篇談) |
順帶一個安全提醒:agent 拿得到 controller 派給它的一切,包含那次 build 用到的憑證。所以「誰能在共用 agent 上跑 build」跟「誰能拿到正式環境的金鑰」是同一個問題——第 6 篇會專門談。
手養的 agent,是環境飄移的溫床
最後這件事,是整篇最重要的:
從寵物到牛,中間有一條可以逐步走的路,不必一步到位:
- 手養——SSH 上去裝東西,裝完就忘;
- 可重建——用 Ansible 之類的工具把 agent 的組態寫成程式碼,隨時能照著重建一台(這已經解決八成問題);
- 可拋棄——每次 build 用容器或 K8s pod 現開現丟,環境飄移歸零(第 12 篇)。
多數團隊卡在第一階,而跨到第二階的投報率最高:你不需要動整套 K8s,只要「這台機器怎麼來的」有一份寫在 git 裡的答案,環境飄移就止血了。
反思
「只有 03 號機 build 得過」是我看過最貴的技術債
這句話我聽過不只一次,而且每次背後都是同一個故事:某台 agent 上有人為了救急手動裝了某個版本的工具,沒寫在任何地方;後來 build 開始只在那台過,於是大家很自然地在 Jenkinsfile 裡指名那台機器。從此那台機器變成聖物——不敢升級、不敢重灌、OS 有漏洞也不敢修。
代價在硬碟壞掉那天一次付清。重建花了兩天,不是因為裝機器慢,是因為沒有人知道那台機器上到底有什麼——只能靠考古:翻舊 build 的日誌、比對版本、一個一個試。
所以現在我對 agent 只有一個要求:它必須是能被砍掉重建的。 我甚至覺得,團隊該定期主動重建一台 agent,不是為了維護,是為了驗證自己還做得到。這跟備份一樣——沒還原演練過的備份,不算備份;沒重建過的 agent,不算可重建。
Executor 開太多,是一種免費的錯覺
有次 CI 塞車,我第一直覺是把幾台 agent 的 executor 從 4 調到 8——反正機器閒著也是閒著。結果隊列確實變短了,但每個 build 的執行時間拉長了快一倍,而且開始出現詭異的偶發失敗:測試逾時、port 被佔用、磁碟寫入卡住。
原因很簡單:executor 是並行格子,不是資源。八個 build 擠在一台四核機器上,它們搶的是同一份 CPU、同一顆硬碟、同一段網路頻寬。從開發者的角度看,「排隊 5 分鐘 + 跑 10 分鐘」變成「排隊 1 分鐘 + 跑 19 分鐘」,體感更差,還多了一批查不出原因的 flaky。
後來我把它調回 4,甚至有幾台調到 2(那種 build 本身就會吃滿多核的專案)。要看的指標從來不是「隊列長度」,是「從提交到綠燈」的總時間——這件事第 14 篇會展開講。
Label 是一份介面契約
最後一個觀念上的收穫:我現在把 label 看成 pipeline 與平台之間的介面。Jenkinsfile 那一側說「我需要一台 Linux、要有 docker」,平台那一側負責供給——中間怎麼實作(地端 VM、雲端機器、K8s pod)是平台的自由。
這個切法帶來的好處很實際:平台把地端 agent 換成 K8s 動態 pod 那次,只要新的 pod 帶著同一組 label,沒有任何一個專案需要改 Jenkinsfile。而那些當初圖方便寫了機器名的 pipeline,就是那次遷移裡唯一要一個一個去修的。
介面寫得抽象一點,當下多花五分鐘;寫成機器名,兩年後有人要花兩週。
下一篇會停在同一台 agent 上,看更細的東西:build 跑起來之後,檔案到底放在哪裡(workspace)、產物怎麼留下來(artifact 與 fingerprint),以及「快取」這件事到底是在拿什麼換什麼。