天天看點

建構系統之我見

譯者注

在持續內建的流程中,軟體建構是最重要的環節之一,負責生成測試和部署用的軟體包。在一個大型軟體項目中軟體建構是一個很複雜和很耗時的工程,提高建構的效率和準确性對軟體開發團隊的工程效率和軟體品質是至關重要的。在Google的Blaze出現之前,大部分C/C++項目都是使用GNU make作為建構工具。make應付小型的項目還可以,對于大型軟體項目來說make的建構速度,準确性和多語言擴充等都是一個巨大的挑戰,需要耗費大量的開發資源和伺服器資源。Blaze的出現像是開了一扇天窗,受其影響出現了Buck/Pants/Please等一系列新的建構工具,繼而Google開源了Blaze(命名為Bazel)。建構系統技術領域一下子活躍了起來。本文是一位建構系統的資深專家寫于2018年的文章,我嘗試翻譯一下,和讀者一起學習了解建構系統設計的一些核心要素。裡面有小部分内容譯者沒有完全領會,且感覺不影響本文的核心内容,就選擇性跳過了(手動狗頭)

原文:

https://ruudvanasseldonk.com/2018/09/03/build-system-insights

------ 以下為翻譯内容 ------

前言

最近一些新一代的建構系統吸引了很多關注,加入到了本已挺多的建構工具的行列裡。盡管這些新的建構系統的來源和設計目标不盡相同,但他們有一些共性的東西。筆者最近嘗試使用了多種不同的建構系統,引發了一些思考,慢慢地形成了關于建構系統關鍵原則的一些淺見。在這裡我列一下這些見解,探讨一下建構系統該如何工作。

緩存和增量建構

第一個同時也是最核心的見解是關于緩存。可以把建構步驟看成一個純函數式程式設計的結構,output = func(input),在輸入固定的情況下輸出是固定的,這是緩存工作的基礎。在建構系統中,建構步驟的輸入決定了其建構步驟的輸出,換句話說,建構輸出的内容應該存儲于可根據輸入内容尋址的存儲結構裡。如果輸出内容已經存在,其建構步驟可以跳過,因為即使重複執行其輸出應該是始終一樣的。理想情況下重複執行同樣的建構步驟其輸出内容應該是位元組相等的,但實際場景中可能因為各種原因隻能達到功能等價,這通常不是問題。如果輸出内容不存在,這時緩存就能派上用場,輸出内容可以從遠端緩存直接擷取,而不需要執行建構過程。

緩存的特點:

  1. 緩存可以存儲一個建構目标的不同制品。同一個建構目标,因為代碼版本變化(譬如切換了代碼分支)或配置變更(譬如打開或關閉了優化開關)會生成不同的制品。如果同樣條件(版本和配置)的制品已經在緩存中存在了,就沒必要重複執行建構過程。
  2. 緩存可以安全地在多個不相關的代碼倉庫間共享。一個共享庫如果被兩個項目使用,沒必要建構兩次。
  3. 緩存可以安全地在多個機器間共享。持續內建的工作流或另一個同僚建構生成的建構制品可以被從遠端的緩存直接擷取過來,和本地機器無關。

把建構步驟看做一個純函數使得緩存的實作相對容易,大多數現代的建構工具都使用某種方式的不可變的根據輸入尋址的緩存技術。Nix使用這種緩存技術來做系統包的管理,Bazel和SCons用來管理細粒度的建構目标,Stack用它來實作在不同倉庫間共享依賴,Goma則根據輸入檔案和編譯指令的哈希來緩存建構制品。

這裡有個比較大的問題:擷取一個建構步驟的所有輸入可能是很困難的。這裡的輸入包含所有可能影響建構過程的輸入檔案,建構指令及參數,已經使用的環境變量等。一些建構步驟所使用的工具鍊可能隐式地從環境中擷取一些狀态,譬如從CXX環境變量或預設的頭檔案路徑中擷取狀态。實際上,Nix和Bazel的實作在不遺餘力的防止意外擷取這些影響建構結果的輸入狀态,進而為工具鍊提供一個受控的和可重制的環境。

建構目标定義

建構目标定義内容應該盡可能和源代碼放在一起

相比于在一個中心的倉庫定義全局的建構目标(然後被各個子產品引用),把建構目标定義在各自的子產品源代碼中的方式在大型的軟體項目中可維護性更好。

我所了解的一些最大的代碼倉庫的軟體項目都在使用這一原則。譬如Chromium的建構系統GN,以及它的前輩GYP。也包括Blaze及其衍生的(Pants和Buck等)建構系統。

建構目标應盡可能細粒度

相比于少量大粒度的建構目标,大量細粒度的建構目标對緩存效率和并行建構更加友好。如果一個建構步驟的輸入的内容有變更而需要重新建構,更細粒度的建構目标定義可以有效縮小重新建構的範圍。互相沒有依賴關系的建構目标可以并行建構,是以細粒度的目标定義可以解鎖更大的并行度。再者,在建構過程中一個建構目标需要等到其所有依賴的目标都建構完成後才能開始建構,如果建構目标實際隻依賴其中一小部分目标,多餘的建構目标将沒必要的拉長建構過程的關鍵路徑,增加了非必要的建構時間。在CPU核足夠多的情況下,細粒度目标的建構總是能顯著的快于粗粒度目标的建構。

我在使用Bazel的時候體會到了細粒度目标的重要性,其實這是為什麼Bazel能快速的建構大型依賴圖的原因。跟Bazel類似的建構工具Buck曾提及它能内部自動把粗粒度目标圖轉化為細粒度目标圖,這也是Buck為什麼快的其中一個原因。

延遲計算建構目标定義

延遲計算隻計算真正需要建構的目标,可能大部分建構目标不需要解析,延遲計算能帶來更高的性能,對大型倉庫亦如是。

Bazel的延遲計算的實作方式是每個子產品定義一個BUILD檔案,以及使依賴路徑和檔案系統路徑一緻。使得沒有被依賴的建構目标的檔案甚至都不想要被加載。

工具鍊和依賴

建構工具需要管理運作時和編譯器工具鍊

當工具鍊或一個依賴需要從外部系統擷取的時候,一個簡單的建構步驟可能變為一個耗時很長的依賴下載下傳或涉及瑣碎的配置問題,給建構過程帶來很大的不确定性。建構系統的可重複性将深受其害。

一個真正的具有可重複性的建構需要受控的建構環境。

通過語言包管理器固定其所管理的建構環境的依賴包是可重複性的重要的一步,但不能完全解決問題,隻要在建構過程中對建構環境有隐性的依賴(譬如通過系統包管理器安裝的一些共享庫或工具),”隻在我的機器上能工作“的問題就始終存在。

有兩種方式可以用來建立受控的建構環境:

  1. 追蹤所有的隐含依賴,将它們變為顯式依賴。在一個沙箱環境中建構,使得所有未聲明的依賴無效,能有效的識别出隐式依賴。舉個例子,如果建構步驟沒有指定GCC的依賴,PATH中就沒有gcc可用。Nix就是用這個方式實作的。
  2. 直接固定整個建構環境,而不是具體的依賴,譬如在Docker容器或虛拟機中建構。不過需要注意環境在初始化後不能被修改,例如,在已有容器中運作apt update将會使得建構環境成為不确定的狀态。

工程效率

性能是建構系統的一個重要功能,啟動時間很重要

軟體開發過程中一個常見的操作是修改一小部分代碼然後重新建構系統,在這個場景下最關鍵的是快速定位哪些建構步驟需要重新執行,這個過程中解釋器或JIT編譯器的開銷可能是比較可觀的,建構語言的設計也能影響到建構啟動的快慢。

以我使用Bazel的經驗來看,雖然Bazel編譯大型項目很快,但啟動過程比較慢。因為Bazel是在JVM上運作的,有時在一個很小的代碼倉庫裡做一個空操作都需要好幾秒,而另一個跟它相似的但是用Go實作的建構工具Please就比它快捷得多。建構目标是否能被高效的計算也是性能的一個影響因素,譬如,雖然make和Ninja都是native的工具,但因為Ninja文法更簡潔,能更高效的計算建構目标,建構過程就比make要快。

小結

本文中我列出了一些關于建構系統的見解,一些可能相對深入,一些則比較粗淺。一個共同的主題是函數式程式設計的一些原則同樣适用于建構工具,特别地,把建構步驟比作純函數,把建構制品看做不可變的,使得高效和正确的緩存技術自然湧現出來。在操作實踐方面,把建構目标定義盡可能貼近源代碼使得代碼倉庫更易維護,細粒度的建構目标可以解鎖更高的并行度進而使建構更快速。像所有好的想法一樣,這些見解可能事後看起來很明顯,我仍然希望看到它們能被更多的應用于建構系統的設計當中。