的增量構(gòu)建引擎架構(gòu)與落地實踐)
2023年我把Buck2的源碼從GitHub拉下來的時候第一反應(yīng)是Meta真敢把一個每天支撐幾萬開發(fā)者提交的內(nèi)部構(gòu)建系統(tǒng)直接開源。第二反應(yīng)才是這代碼結(jié)構(gòu)比想象中干凈太多。作為一個長期跟Bazel、GN、CMake、sbt這些構(gòu)建系統(tǒng)打交道的工程師我一直在等一個真正“從底層設(shè)計就面向monorepo和遠端執(zhí)行”的開源方案。Buck2的出現(xiàn)基本把構(gòu)建系統(tǒng)這潭水攪活了。這篇東西我不會去復(fù)述官方文檔也不會教你寫B(tài)UCK文件而是做一次“源碼視角”的盡調(diào)Buck2的架構(gòu)到底怎么分層、核心執(zhí)行模型長什么樣、增量計算和緩存是怎么用Rust做到的、企業(yè)級的坑在哪里。如果你正在做構(gòu)建系統(tǒng)選型或者想在內(nèi)部推一套高性能跨語言構(gòu)建方案這篇文章應(yīng)該能幫你在幾個小時之內(nèi)把Buck2的骨架摸清楚。1. 為什么一個構(gòu)建引擎值得逐行看源碼構(gòu)建系統(tǒng)在多數(shù)技術(shù)團隊眼里就是“裝好后沒人再碰的底層設(shè)施”??梢坏┕こ桃?guī)模上來問題就會變得非?,F(xiàn)實全量構(gòu)建要半小時以上、增量構(gòu)建失效頻繁、語言生態(tài)各搞一套、CI里跑一次流水線貴得離譜。這時候你會發(fā)現(xiàn)構(gòu)建引擎的選擇直接決定研發(fā)效率天花板。而Buck2不是又一個封裝腳本它是一個正經(jīng)的操作系統(tǒng)級“調(diào)度器”既要管進程、管緩存、管網(wǎng)絡(luò)通信還要設(shè)計一套自己的增量計算模型。1.1 先搞清楚Buck2在解決什么Meta內(nèi)部之前的構(gòu)建系統(tǒng)叫Buck1是Java寫的設(shè)計思路借鑒了Bazel的前身Blaze。Buck1在Facebook大規(guī)模使用很多年后暴露出幾個挺棘手的問題并發(fā)模型老化、緩存失效邏輯不夠精確、對跨語言項目支持不夠平滑。與其繼續(xù)修修補補Meta選擇用Rust從零寫一個Buck2。這里面的關(guān)鍵判斷是構(gòu)建系統(tǒng)本身就是一個“高頻并發(fā)、強緩存、狀態(tài)復(fù)雜”的系統(tǒng)軟件非常適合Rust這種能手動控制內(nèi)存又不犧牲性能的語言。源碼里可以清楚看到這個目標Buck2不是簡單把“編譯命令”串起來而是把所有構(gòu)建任務(wù)抽象成可計算、可緩存、可分布執(zhí)行的“動作”。在一個大型repo里你可以同時跑C、Python、Rust、TypeScript的構(gòu)建任務(wù)它們共享一套底層的緩存和調(diào)度機制。這才是“跨語言構(gòu)建引擎”的真正含義而不是提供一個能調(diào)用各種編譯器的腳本入口。1.2 與Bazel的本質(zhì)差異你可能會問Bazel不也是干這個的嗎從用戶角度看Buck2和Bazel確實長得像都是聲明式構(gòu)建規(guī)則、都有遠端執(zhí)行協(xié)議、都用Starlark做擴展語言。但源碼層面有一個非常核心的區(qū)別Buck2把“動態(tài)依賴”作為一等公民。傳統(tǒng)構(gòu)建系統(tǒng)的靜態(tài)圖是需要提前把每個目標的所有依賴聲明清楚Buck2則允許在執(zhí)行過程中根據(jù)中間結(jié)果繼續(xù)發(fā)現(xiàn)新的依賴。這個能力在處理“生成代碼再編譯”這類場景時極有價值。Bazel也有dynamic dependencies但支持度沒有Buck2這樣深入底層。DICE就是為這種動態(tài)圖計算而生的后面我會專門拆DICE的實現(xiàn)思路。2. 頂層架構(gòu)從客戶端到后臺守護進程很多人在用Buck2時會覺得它好快其實一部分原因是它不是一條命令跑完就結(jié)束而是有一個常駐后臺進程類似開發(fā)服務(wù)器。你敲下buck2 build的時候?qū)嶋H是向這個后臺進程發(fā)起一次RPC請求。這樣做的好處是解析過的BUCK文件、緩存的計算結(jié)果、做過指紋分析的文件狀態(tài)都能留在內(nèi)存里下一次構(gòu)建省掉大量冷啟動開銷。2.1 客戶端、服務(wù)端和交互協(xié)議在Buck2源碼里倉庫被劃分成多個crate其中最外層的是buck2_client和buck2_server。buck2_client負責(zé)命令行參數(shù)解析、啟動后臺進程、建立連接和整理用戶輸出buck2_server則是真正的構(gòu)建引擎后端它管理所有構(gòu)建狀態(tài)、執(zhí)行計算圖、調(diào)度遠程執(zhí)行。二者之間的通信協(xié)議不是純文本JSON而是用protobuf定義的一套結(jié)構(gòu)化RPC。這樣做的好處是不同語言寫的客戶端也可以方便對接比如編輯器插件可以只調(diào)這些接口不需要直接和構(gòu)建內(nèi)核打交道。我第一次看到這個架構(gòu)時想到的是這不就是一個“構(gòu)建操作系統(tǒng)”嗎它的后臺服務(wù)管理內(nèi)存狀態(tài)和并發(fā)任務(wù)客戶端只是薄薄的一層翻譯殼。如果你是做CI集成的最要緊的是理解這個進程模型——每次構(gòu)建都別簡單用命令行加參數(shù)而是考慮復(fù)用同一個服務(wù)進程或者至少要知道后臺進程的資源占用和失效策略。2.2 DICE增量計算引擎是絕對核心如果說Buck2是一臺汽車DICEDynamic Incremental Computation Engine就是它的發(fā)動機。在源碼里dice這個crate不是一個輔助模塊它幾乎貫穿所有構(gòu)建階段。DICE的核心是“帶依賴追蹤的可緩存計算單元”你定義一些計算任務(wù)每個任務(wù)可以聲明它依賴哪些輸入DICE會記錄任務(wù)的結(jié)果和依賴關(guān)系當輸入變化時DICE只重跑受影響的任務(wù)其他結(jié)果直接從緩存里拿。這個思路寫起來不復(fù)雜復(fù)雜的是在并發(fā)環(huán)境下做精確失效。Buck2的做法是給每個計算單元一個抽象的Key所有輸入都通過Key來訪問底層再配合文件系統(tǒng)的change monitor和內(nèi)容hash做失效判斷。DICE還有事務(wù)機制可以把一組計算包裝成一個事務(wù)事務(wù)內(nèi)要么全部成功提交要么整體回滾這樣不會出現(xiàn)緩存里殘存半截狀態(tài)的臟數(shù)據(jù)。源碼里對事務(wù)邊界的處理非常細致這也是它比很多自研構(gòu)建緩存更穩(wěn)的核心原因。2.3 構(gòu)建階段解析、配置、分析、執(zhí)行Buck2的構(gòu)建流程可以分為幾個大的階段你可以在buck2_build_api這個crate里找到對應(yīng)的核心數(shù)據(jù)結(jié)構(gòu)。解析階段讀取BUCK文件用Starlark解釋器生成“目標圖”的原始信息。配置階段把目標適配到特定平臺和構(gòu)建模式比如Debug還是ReleaseAndroid還是iOS。分析階段展開規(guī)則生成具體的動作列表和輸出聲明這個階段也是DICE介入最深的地方。執(zhí)行階段把動作調(diào)度到本地或遠端執(zhí)行校驗輸出指紋最后產(chǎn)生最終產(chǎn)物。這四個階段不是嚴格串行的很多目標可以在不同階段之間交叉推進。源碼里大量使用了Future和async配合Rust的tokio運行時可以讓不同目標的解析、分析和執(zhí)行重疊進行。這種設(shè)計讓Buck2在大型依賴圖上能壓榨出額外并發(fā)度也是它性能較好的原因之一。3. 核心源碼拆解緩存、指紋和遠端執(zhí)行盡調(diào)一份源碼最重要的不是看它寫了多少注釋而是看它的核心模塊怎么處理邊界條件。Buck2里最值得花時間讀的東西第一是DICE的緩存與失效邏輯第二是指紋計算第三是遠端執(zhí)行器的抽象。3.1 DICE的緩存如何做到精確失效很多構(gòu)建系統(tǒng)用“文件修改時間”判斷文件是否變化這在大型倉庫里極不可靠。Buck2默認會記錄文件的digest哈希按下map的key value變化來做失效。DICE在底層維護了類似“內(nèi)存狀態(tài)樹”的結(jié)構(gòu)每個計算Key對應(yīng)一個狀態(tài)狀態(tài)里存的是依賴版本和結(jié)果。當某個依賴文件變更時變更事件會向上傳播所有依賴它的計算節(jié)點都可能失效。源碼里有個很關(guān)鍵的做法DICE不會立刻把所有失效任務(wù)全部重算而是等到某個下游任務(wù)真正需要這個結(jié)果時才懶加載式地計算。這個懶加載機制避免了構(gòu)建時“炸鍋式”重算只算真正被需要的部分。你在讀dice crate源碼時會看到大量關(guān)于“dirty/clean”狀態(tài)流轉(zhuǎn)的邏輯這就是Buck2增量構(gòu)建的核心價值。3.2 指紋Fingerprint與內(nèi)容尋址的設(shè)計動作執(zhí)行完以后Buck2怎么知道這次執(zhí)行結(jié)果是不是和上次一樣答案是指紋機制。在源碼中動作會聲明自己的輸入、環(huán)境變量、命令行參數(shù)等信息Buck2把它們組合成一個確定性指紋。執(zhí)行前它會先查緩存里有相同指紋的歷史結(jié)果如果有就直接復(fù)用輸出文件如果沒有才真正啟動進程去跑。這里有一個隱含但極重要的點對于“確定性”的要求非常嚴格。如果某個工具輸出了帶隨機時間戳的文件或者通過絕對路徑訪問了未聲明的輸入指紋機制就會失效緩存命中率大幅下降。我在實戰(zhàn)中遇到過很多團隊把Buck2接入復(fù)雜項目后發(fā)現(xiàn)構(gòu)建總是全量重新執(zhí)行最后定位到是代碼生成腳本讀了環(huán)境變量但沒有在action里聲明這個變量。解決方式就是所有環(huán)境變量依賴都要顯式列出來并且所有非確定性的輸出都要在動作后做清洗。3.3 遠端執(zhí)行不是簡單的“把命令扔到別的機器”Buck2支持REAPIRemote Execution API這意味著你可以接上Bazel那套遠端執(zhí)行服務(wù)比如Buildbarn、BuildGrid也可以通過Meta內(nèi)部的一些基礎(chǔ)設(shè)施。源碼里把遠端執(zhí)行抽象成一個“ActionRunner”接口既能跑本地進程也能把action發(fā)給遠端集群。真正有意思的是它如何保證遠端執(zhí)行的結(jié)果和本地一致。Buck2會要求動作聲明“輸出目錄”和“輸出文件”遠端執(zhí)行完成后后臺會把這些輸出文件流式傳回并重新校驗內(nèi)容哈希。如果校驗不一致這次執(zhí)行會被標記失敗而不是默默吞掉。源碼層面還有“mingle”和“write”任務(wù)來處理大輸出文件的流式傳輸這個設(shè)計能避免把幾十GB的產(chǎn)物一次性塞進內(nèi)存。3.4 跨語言支持靠的是規(guī)則和動作抽象跨語言構(gòu)建的難點不是“能調(diào)用編譯器”而是“讓不同語言共享一套依賴調(diào)度和產(chǎn)物緩存”。Buck2的做法是把每種語言封裝成一個個規(guī)則rule規(guī)則內(nèi)部生成標準化的“動作”。action就像一個大統(tǒng)一接口描述“要運行什么命令、讀出哪些文件、寫出哪些文件”。底層緩存和執(zhí)行系統(tǒng)只關(guān)心action不關(guān)心它是來自C還是Rust規(guī)則。在源碼里buck2_build_api的action模塊是一個非常干凈的抽象層。只要某個語言前端最終能將編譯過程拆解成一組action它就能自動獲得緩存、遠端執(zhí)行、并發(fā)調(diào)度、失敗重試這套能力。所以你在用Buck2時說“支持Python”并不是因為引擎內(nèi)部硬編碼了Python邏輯而是提供了一套Python rule實現(xiàn)。這個可插拔架構(gòu)是Buck2跨語言能力的關(guān)鍵也是源碼里最有學(xué)習(xí)價值的部分。4. 源碼實證一個構(gòu)建請求從按下回車到產(chǎn)物輸出紙上談兵沒有意思我直接把一條buck2 build命令的流轉(zhuǎn)過程在源碼層面上串一下。這一節(jié)你就當剛才打開了一個正在跑Buck2的倉庫我們逐步看它內(nèi)部做了什么。4.1 命令行入口和后臺進程拉起你在終端輸入buck2 build //foo:bar。buck2_client會先解析參數(shù)檢查.buckconfig和buck2.toml等配置文件。如果檢測到當前項目根目錄下已經(jīng)有buck2 daemon在運行它會直接通過Unix socket或TCP發(fā)送請求否則會啟動一個新的daemon進程。這個daemon就是buck2_server啟動后它會加載一個“project context”包含項目根、配置片段、文件系統(tǒng)監(jiān)聽器。源碼里daemon的生命周期管理寫得很細包括空閑多少秒自動退出、日志怎么輪轉(zhuǎn)、異常崩潰時怎么恢復(fù)。企業(yè)在做CI時最好顯式控制daemon的啟動和回收不要讓每個job各起一個daemon否則內(nèi)存會被拖垮。4.2 目標解析和BUCK文件處理然后服務(wù)端根據(jù)目標模式//foo:bar去定位到foo/BUCK文件使用buck2_interpreter crate內(nèi)置的Starlark解釋器執(zhí)行這個文件。這個解釋器不是簡單調(diào)用某個Python運行時而是用Rust重實現(xiàn)的一個Starlark子集。因為BUCK文件是聲明式定義目標所以執(zhí)行BUCK文件不會產(chǎn)生任意副作用只會向一個包里注冊規(guī)則。源碼里目標被表示為一個TargetNode它會記錄自己的規(guī)則類型、屬性、依賴列表。依賴列表里的每一項都是另一個目標或顯式的filegroup。Buck2對循環(huán)依賴的檢測也在這一層它會在構(gòu)建圖構(gòu)建期間就報錯而不是等執(zhí)行到一半才崩。我實際遇到過一些項目會通過動態(tài)讀文件內(nèi)容來改變依賴這在Buck2里也能做但會加大指紋失效的復(fù)雜度能不用盡量不用。4.3 DICE事務(wù)和計算觸發(fā)接下來Buck2進入了分析和執(zhí)行階段。這時會通過DICE發(fā)起一個事務(wù)請求目標//foo:bar的“分析結(jié)果”。這個事務(wù)會從這個目標開始向下展開依賴圖。對每一個目標DICE會先看緩存里有沒有現(xiàn)成結(jié)果如果沒有它才去調(diào)用對應(yīng)的規(guī)則實現(xiàn)生成動作。這里我特別要說一下Buck2支持動態(tài)依賴所以在分析階段它可能發(fā)現(xiàn)某個動作的輸出會成為另一個動作的輸入。這個“在運行時發(fā)現(xiàn)依賴”的能力是DICE事務(wù)內(nèi)不斷擴展圖的關(guān)鍵。源碼里有一個API叫DynamicLambda之類的機制允許規(guī)則在分析時根據(jù)中間結(jié)果返回新的目標列表。這種設(shè)計對代碼生成場景非常友好但也意味著如果動態(tài)依賴邏輯寫得太復(fù)雜調(diào)試起來會比較燒腦。4.4 動作執(zhí)行和產(chǎn)物校驗?zāi)繕说姆治鼋Y(jié)果最終會產(chǎn)生一個Action列表。Buck2會把這些action提交給執(zhí)行調(diào)度器。如果遠端執(zhí)行開啟調(diào)度器會優(yōu)先發(fā)往遠端如果沒開啟就在本地起子進程。一個action的執(zhí)行會先做一次本地緩存查詢指紋匹配就直接log hit否則創(chuàng)建隔離的臨時執(zhí)行目錄跑命令收集輸出文件計算輸出指紋然后把結(jié)果寫回cas緩存。這個階段源碼里最需要注意的是“輸出文件聲明”的嚴格性。Buck2在做產(chǎn)物校驗時會檢查動作聲明了哪些輸出文件如果動作偷偷寫到聲明之外的文件這些文件通常會被忽略或?qū)е戮彺娌灰恢?。我在調(diào)研過程中看到很多項目就因為某個腳本在臨時目錄里生成了額外文件導(dǎo)致緩存命中率一直上不去。解決辦法是打開Buck2的日志看action的“output”列表把所有實際生成的文件都補充到out參數(shù)里。5. 企業(yè)級落地配置調(diào)優(yōu)、監(jiān)控與踩坑實錄源碼讀得再好最終還是要看能不能在企業(yè)里跑起來。這一部分是我在調(diào)研和試用過程中踩過的坑以及總結(jié)出來的可操作建議希望在選型時能幫你少走彎路。5.1 典型問題與排查技巧速查現(xiàn)象可能原因排查/解決辦法構(gòu)建頻繁全量執(zhí)行action未完整聲明輸入文件/環(huán)境變量查看日志中action的fingerprint字段對比兩次構(gòu)建的指紋差異遠端執(zhí)行結(jié)果和本地不一致動作里有非確定性輸出比如時間戳在action前對時間戳白名單化或使用機器可復(fù)現(xiàn)的編譯參數(shù)后臺daemon內(nèi)存居高不下長期運行后緩存積累、日志膨脹定期主動重啟daemon或調(diào)低daemon空閑回收時間異步動態(tài)依賴導(dǎo)致卡死規(guī)則在動態(tài)lambda里循環(huán)依賴檢查日志中的依賴展開棧對動態(tài)依賴增加深度上限某些目標總是cache miss輸出文件聲明遺漏或依賴未聲明用buck2 log打開執(zhí)行日志查看miss的fingerprint對比config平臺太多導(dǎo)致解析慢平臺配置導(dǎo)致配置階段矩陣爆炸減少統(tǒng)一配置變體盡量復(fù)用已有配置避免每目標單獨定義新平臺這張表基本覆蓋了我見過的90%的Buck2使用問題。核心思路永遠是先看動作指紋再看依賴聲明再看輸出聲明。指紋是最底層的事實所有緩存問題最終都能在指紋層面找到答案。5.2 部署建議和架構(gòu)選型思考如果你要在公司內(nèi)部推廣Buck2我建議分三步走。第一步是選一條核心編譯鏈路做PoC比如一個C服務(wù)或Rust工程先讓它能通過Buck2構(gòu)建出和現(xiàn)有構(gòu)建系統(tǒng)一致的產(chǎn)物。第二步是把CI接入Buck2重點觀察緩存命中率、全量構(gòu)建時間、增量構(gòu)建時間以及遠端執(zhí)行接入后的收益。第三步才是大規(guī)模推廣這時需要在意daemon的管理、remote cache容量、構(gòu)建日志聚合等企業(yè)級能力。Buck2的配置并不復(fù)雜核心是.buckconfig或buck2.toml里面可以配置緩存目錄、daemon超時、遠端執(zhí)行地址等。我習(xí)慣把變更少、體積大的工具鏈配置放到頂層的配置里把項目特有的規(guī)則放到各個包下。一個容易忽略的地方是--unstable-*參數(shù)很多也有一些配置項在持續(xù)演進所以你使用的版本一定要鎖定否則升級可能需要同步調(diào)整配置。5.3 源碼層面我最喜歡的設(shè)計細節(jié)最后說兩個源碼里很有啟發(fā)的小細節(jié)它們可能不會寫進官方博客但直接影響使用體驗。第一件是Buck2對診斷信息的處理。構(gòu)建失敗時它會嘗試輸出“為什么失敗”的因果鏈包括失敗action的具體命令、輸出日志、指紋文件列表。這個診斷機制在源碼里做得非常深不是為了好看而是為了讓你沙里淘金時能快速定位問題。第二件是Buck2對并發(fā)沖突的寬容度。Rust的ownership模型讓很多共享狀態(tài)不需要加鎖代碼里大量使用Arc和原子類型讓并發(fā)控制變得很清爽。如果你讀過其他JVM構(gòu)建工具的并發(fā)代碼再來看Buck2會在“爽”的同時意識到語言選擇對企業(yè)級軟件質(zhì)量的影響有多大。寫在最后的個人體會源碼盡調(diào)這件事最怕的就是只看了幾個模塊的名字就下結(jié)論。Buck2給我的整體感受是“架構(gòu)清晰、取舍果斷”它放棄了和Bazel完全兼容換來的是在動態(tài)依賴和增量計算上可以走得更遠它用Rust從零實現(xiàn)換來的是低層級的性能和內(nèi)存可控性。這套設(shè)計并不是萬能藥它在中小型項目上的優(yōu)勢沒有大型monorepo那么明顯如果你單獨編譯一個小庫用Buck2反而顯得重但一旦項目規(guī)模和團隊復(fù)雜度漲上來Buck2的價值就會快速放大。如果你后續(xù)想深入研究我會建議按這個順序讀源碼先從buck2_server的入口看起理解daemon啟動流程然后讀dice crate里的狀態(tài)機和事務(wù)實現(xiàn)再讀buck2_build_api里的action和artifact定義最后再看buck2_interpreter里的Starlark集成。四層讀完你基本就能在別人面前用“源碼邏輯”來聊Buck2了。而我自己的體會是這種能靠源碼本身講清楚設(shè)計意圖的構(gòu)建引擎真的太稀缺了。