The Evolving Infrastructure of .NET Core | .NET Blog?

devblogs.microsoft.com
圖標

隨著.NET Core 3 Pre 6走出家門(下稱DNC),我們覺得應該回顧下我們的構建基礎設施進化史,以及最近一年做的重大提升。

如果你對構建基礎設施有興趣,或者想看看我們是如何構建DNC這樣的龐然大物的話,這個帖子會很有意思。這篇文章不會提到新特性,也沒有什麼示例代碼,如果你還喜歡請告訴我們,我們還有幾個這種類型的帖子,但是想知道你們覺不覺得這種文章有幫助。

一個小故事

3年多以前,DNC就已經嚴重背離了微軟的傳統項目:

  • 在GitHub上公開開發
  • 分為單獨的git庫,而不是統統放進一個龐大單一的庫中
  • 面向諸多平臺
  • 它的組件可能「一心多用」(例如,Roslyn是VS的一個組件,也是它的SDK)

我們早期的基礎設施只是必要的權宜決策,用Jenkins來進行Github PR和CI驗證,因為它支持跨平臺對象存儲開發,我們的官方構建放在Azure DevOps(那時候還叫VSTS)和TeamCity(ASP.DNC用的),上面還標記(signing)並搭載著其他關鍵基礎設施。我們用手動更新包依賴和有那麼一點自動化的Github PR結合的方式,將代碼庫集中到一起,就這樣,一個需要自己打包,佈局,本地化還有處理大項目涉及到的亂七八糟工作的團隊獨立成立了。雖然不是很理想,但是在早期的日子裡,這樣也夠用了。隨著DNC從1成長到1.1又到2.0以及更高版本,我們想要弄一個更加統一的技術棧,更快的搭載速度(faster shipping cadences)和更簡單的服務。我們朝思暮想要推出一個帶有最新運行時的SDK,並且希望這一切都不會減慢這個獨立代碼庫地開發速度。

.NET Core面向獨立,分散式特性對基礎設施提出了許多挑戰,雖然這些年來變化很大,整個產品還是可以由任何地方的20-30個獨立git庫組合起來(直到最近,ASP.DNC還有很多?)。一方面,擁有許多的獨立開發筒倉(silos)往往會使這些筒倉上的開發更為高效;開發者可以在這些筒倉上快速迭代而不需要太擔心技術棧的其他部分,另一方面,它使得整個工程的創新和集成更沒有效率,蠣子如下:

  • 如果我們想實施新的簽名(應為程序集簽名)和包特性,用不同的工具遍歷這麼多的獨立代碼庫很費事。
  • 在技術棧之間遷移修改又慢又費事,底層技術棧(例如corefx)的修改和新特性可能幾天內無法在上層技術棧(例如SDK)中看到。如果我們在corefx裏做了個修復,這個改動必須構建,然後新版本會被送到引用了它的上層技術棧(如core setup和ASP.DNC)接著測試,提交和構建,上層棧又會將新的輸出流到更上層,直到到達最頂層。

在這些因素影響下,失敗是多層次的,進一步減慢進程,隨著DNC3計劃開始認真執行,我們發現不能在基礎設施沒有顯著變化的情況下,搞出指定範圍的正式版本。

三管齊下

我們提出了一條三管齊下的解決方案:

  • 共享工具 (也就是Arcade) – 關注於我們的代碼庫之間的共享工具。
  • 系統增強 (Azure DevOps) – 將我們的Github CI從Jenkins遷移到Azure DevOps,把官方構建從傳統的VSTS時期進程,轉為現代的config-as-code(代碼化配置?)。
  • 自動依賴流和探測(Maestro) – 顯式跟蹤代碼庫間依賴,快速自動更新它們。

Arcade

在DNC3之前,不同的庫有三到五個依賴工具實現,(為什麼是三到五個)取決於你怎麼去數它們。

  • 核心運行時庫(dotnet/coreclr, dotnet/corefx and dotnet/core-setup) 有一個dotnet/buildtools.
  • ASP.DNC的庫有一個aspnet/KoreBuild
  • 像dotnet/symreader等亂七八糟的庫用Repo Toolset
  • 還有些孤立的庫有自己的依賴管理

當每個團隊自定義工具,只構建他們管的那一片時,就會有幾個很大的壞處:

  • 開發者在庫之間流動更沒效率比如:一個程序猿從dotnet/corefx遷移到dotnet/core-sdk,庫之間的「語言」是不同的,她打算輸入什麼去構建和測試?日誌放哪?如果她想給這個庫加個新項目,怎麼做呢?(全是她,女拳警告?)
  • 每個特性都需要構建N次舉例:DNC提供了堆積如山的nuget包,雖然有些不同(比如dotnet/core-setup提供的共享運行時包Microsoft.NETCore.App,和Microsoft.AspNet.WebApi.Client這種「正常」包構建是不一樣的),產出步驟卻很相似。倒黴的是,由於包的佈局,項目結構等等都不同,會在打包任務的實現上有所不同,比如代碼庫如何定義應生成哪些包,這些包中的內容,元數據等等。沒有共享工具,一個團隊更容易再造一個打包任務,而不是復用已有的輪子,浪費資源。

有了Arcade,我們努力把我們的代碼庫,代碼庫「語言」,和任務集合納入到一個通用佈局下,不過這還沒有脫離危險,任何共享工具都需要找準平衡,如果共享構建工具太過規範,任何足夠大的項目,其自定義都將變得困難,更新工具也會變難,更新很容易破壞一個代碼庫,BuildTools就深受其害。使用它的代碼庫和它耦合的太緊,不僅別的庫不能用它,對它做的任何改動都可能意外破壞用戶體驗。但如果共享工具不夠規範,代碼庫就更容易分開使用不同的工具,滾動更新需要大量的工作,這麼麻煩的話,為什麼共享構建工具要放在第一位?

Arcade同時兼顧了這兩個問題。它定義了個通用庫「語言」作為腳本集(參看這個鏈接),一個通用庫佈局,和一個通用的構建目標集合,作為MSBuild的SDK發布。完全採用了Arcade的代碼庫有了可預測的行為,在代碼庫之間做出修改更加簡單。不想這麼做的代碼庫也可以從一系列MSBuild任務包中選一個,它們也提供了基礎的功能,嘗試讓自己和其他所有庫看上去一致。我們修改構建任務時,盡了最大努力減少破壞性的變化。

來看看Arcade提供的主要特性,和它們是怎樣集成到我們的基礎設施中的:

  • 通用構建任務包 – 這是個MSBuild構建任務的基礎層,既可以獨立應用,也可以作為arcade sdk的一部分……它們提供了一組通用功能,應用於大多數DNC的庫:
    • 標記 Microsoft.DotNet.SignTool
    • 輸出發布 (針對跨庫聚合,原文feed): Microsoft.DotNet.Build.Tasks.Feed
    • 打包 Microsoft.DotNet.Build.Tasks.Packaging
  • 通用的目標庫和行為 – 「Arcade SDK」作為MSBuild SDK的一部分發布. 代碼庫可選擇使用Arcade的默認構建行為,項目和實例佈局等等。
  • 通用的代碼庫 『語言』 –使用依賴流,所有的Arcade庫都可以使用這組通用的腳本文件 進行同步構建(稍後詳細介紹)。這些腳本文件為應用了Arcade的代碼庫提供了一個通用的「語言」,開發者在這些代碼庫之間遷移更容易,而且,因為腳本在庫之間是同步的,應用了Arcade的代碼庫中的新改進在發布時,也可以快速將新特性和新行為引入到使用了共享工具的其他庫。
  • 共享的Azure DevOps工作和步驟模板 – 雖然定義了庫「語言」的腳本主要是面向人的,不過arcade還有一組面向Azure DevopsCI的工作和步驟模板,作為一個通用構建任務包,步驟模板生成了一個可以被幾乎所有庫使用的基礎層(比如發送構建遙測),工作模板生成了更多的完成單元,可以讓代碼庫更少關注CI進程的細節。

遷移到Azure DevOps

如上所述,從2.2開始,大起來的.NET團隊用了好幾個CI系統:

  • ASP.DNC的GitHub PR用的是Appveyor和Travis
  • ASP.NET的官方構建用的TeamCity
  • DNC的其他PR和發布驗證用的是Jenkins
  • 傳統(非YAML)的 Azure DevOps 為所有非ASP.DNC的官方構建服務

很多差異僅僅是被迫,Azure DevOps不支持GitHub的公共PR/CI驗證,所以ASP.DNC轉向了AppVeyor和Travis來填補Jenkins的空白。傳統的Azure DevOps沒有很多構建支持,所以當DNC團隊在Azure上建了個叫PipeBuild的工具時,ASP.DNC又轉向了TeamCity,這些分歧帶來了高昂的成本,尤其在一些不是那麼顯而易見的地方:

  • 雖然Jenkins很靈活,但是維護一個巨大(大約6000~8000個工作)且穩定的部署,也夠喝一壺了。
  • 在傳統的Azure DevOps上構建我們自己的業務流程需要很多妥協。簽入的管道標籤(checked in pipeline job description)實際上並不是人類可讀的(它們只是導出了手動創建的構建定義的json標籤),機密管理很難看,當我們試圖處理構建要求的廣泛差異時,它們很快變得過度參數化。
  • 當官方構建和日常驗證,還有PR驗證流程在不同的系統中執行時,共享邏輯就會變得困難,開發人員在進行流程更改時必須格外小心,因為崩潰是常有的事情,Jenkins的PR工作在一個特殊腳本裡面,TeamCity則有很多手動配置,AppVeyor和Travis使用它們自己的yaml文件格式, Azure DevOps有個難以理解的系統,某個PR很容易就改變構建邏輯,然後破壞官方CI構建。為了緩解這一問題,我們在官方CI和PR的構建將邏輯儘可能塞進腳本離去,但隨著時間的推移總會有差異,一些處在構建環境中的差異,基本不可能被完全移除。
  • 對工作流的實際改動差別很大,經常難以理解,一個開發者針對Jenkins的netci.groovy文件的知識,並不會轉化成官方CI的PipeBuild json文件,最後結果就是構建系統知識只對幾個團隊成員有用,對.NET這個大組織沒什麼意義。

當Azure DevOps開始發布基於yaml的構建管線,支持公共GitHub工程時,DNC3也開始推進了,我們意識到這是個好機會,有了這個支持,我們可以將所有的工作流程從分立的系統中拿出來,轉移到現在的Azure DevOps,也將官方CI和PR的工作流程處理進行些改動,就從以下幾點講起:

  • 處處使用yaml管線,在github上用代碼保持我們的構建邏輯
  • 擁有公有/私有的項目
    • 公有項目會通過GitHub代碼庫運行所有的公共CI和PR
    • 私有項目會在映射到公共GitHub庫的代碼庫中運行官方CI,來容納我們自己所做出的改動
    • 只有私有項目會接觸到受控的資源
  • 把同一個yaml在官方CI和PR構建之間共享,使用表達式模板來區分公有和私有項目之間那些不同的行為,或者控制資源只能用於私有項目,雖然這讓全局yaml有點凌亂。這些意味著:
    • 構建可能崩潰,會讓流程更慢。
    • 開發者只需要改變幾處地方就可以控制CI和PR構建流程。
    • 用通用任務包構建 Azure DevOps 模板,把重複的樣板yaml化簡到最小,允許用依賴流便捷的發布更新(例如遙測)。

現在,DNC3的主要代碼倉都在Azure DevOps來進行CI和PR維護了,arcade提供了官方的構建/PR流水線可作為示例。

Maestro and Dependency Flow(依賴流管理)

DNC3基礎構建設施的最後一部分就是依賴流了,這不是一個DNC專屬的內容。除非完全是自包含的應用,大多數項目都會對其他軟體暴露出一些版本參照,在DNC裡面,代表性示例就是nuget包,當我們想要實現新特性或者修改已經實現的特性時,會把項目的版本號也一併更新,當然,它的依賴包也會有版本,而依賴的依賴還有版本,如此下去就會成為一個圖表,當代碼庫拉取新版本時,依賴變動就會在圖表中「流動」。

一個複雜的依賴圖表

大多數的軟體項目的主要開發週期,只涉及到很少的互相有關的代碼庫,引入依賴一般是穩定的,更新也很稀少。當依賴需要改變的時候,通常都是手動操作。開發者自己評估引用的包版本,選擇一個適合的,然後進行更新,但是DNC明顯不是這樣的,讓組件(儘可能)彼此獨立,以不同的節奏演進,還有很好的內部循環開發體驗之類需求,導致了大量的代碼庫,和大量的互相依賴,然後形成了這樣一個複雜的依賴表:

我們也希望依賴表的變動能夠快速流動,這樣最終產品就可以隨時進行驗證,例如我們希望ASP.DNC或者DNC運行時的最新代碼可以儘快的把自己在SDK中表達出來,這很顯然意味著每個庫的依賴更新都必須快節奏,在DNC這種大小的依賴表下,快速更新依賴代表著手動更新並不可行,這種規模的項目可能以如下幾點進行解決:

  • 自動浮動引入代碼版本 — 在這個模型中,dotnet/core-sdk可能通過允許nuget浮動到最新預覽版,來引用到dotnet/core-setup產出的Microsoft.NETCore.App,這樣儘管有效,但是會受到幾個主要缺點影響:構建變得不可逆轉,檢出一個舊的git SHA值並用其進行構建,並不一定使用(和當時一樣的)的輸入,產出一樣的輸出,復現bug變得困難,而且一個dotnet/core-setup的壞commit能夠破壞所有依賴其輸出的代碼庫,除了PR和CI檢查。構建節奏將大大減慢,因為一次構建中幾臺分立的機器可能在不同的時間點重新存儲包,生成不同的結果,所有這樣的問題「都能解決」,但是需要極大的投入和不必要的編譯。
  • 「組件化」構建 —在這個模型中,通過使用每個引用庫最新的git SHA值,按照依賴順序,獨立地一次將整張依賴表都更新完成,每一級的輸出結果都會提供給下一級使用,一個庫可以根據其「依賴等級」快速重寫其依賴版本,當構建最終成功時,所有的輸出都被發布,所有的庫都會匹配到剛剛更新的依賴,比起自動浮動獨立庫版本號,這一實現有點好處,不會因為其他庫的壞檢查而自動破壞構建,但還是有大問題。破壞性的改動(例如最高位由4變成5—譯者注)幾乎不可能在庫和庫之間高效地流動,復現崩潰還是個問題,因為庫裏的源碼經常匹配不上其構建結果(因為輸入版本已經在源碼控制範圍之外重寫了)。
  • 自動依賴流 — 在這個模型裏,外部基礎設施用來以一種不可逆,驗證過的形式在庫和庫之間自動更新依賴,庫會在源碼中顯式聲明其依賴和相關版本,並且「監聽」其他庫的更新,當產出新構建時,會給改動開PR,這個辦法提高了復現性,破壞性變動流動性,還允許庫的所有者控制更新如何進行,但是壞處是,比前兩個方法都慢,從底至頂的一個改動,其流速只能和PR和CI的總次數掛鉤。

DNC三種方法都試過,在1.x的開發週期早期浮動版本,在2.0用了一定級別的自動依賴流,2.1和2.2則轉向了組件化構建,在3中我們決定,大量投入自動依賴流,拋棄其他辦法,打算從這幾個重要途徑入手,來完全優化我們以前的2.0基礎設施。

  • 對產品已有特性的簡易追蹤性 — 任何給定庫都有可能決定引入組件的版本,但是幾乎沒法找出這些組件在哪構建的,git SHA來自哪裡,這些組件的依賴組件又在哪裡,等等。
  • 減少人工集成 — 大多數的依賴更新都是單調枯燥的,通過驗證時自動合併PR可以加速依賴流。
  • 保持依賴流信息和庫狀態分離 — 庫只應該包含依賴圖中,其作為節點的當前狀態信息,而不應該包含涉及到依賴變換的信息,比如更新何時採納,源碼從何處拉取,等等。
  • 依賴流建立在「意圖」而不是分支上 — 因為DNC是由很多半自治,有不同分支哲♂學和組件發布節奏的團隊組成,因此不應該用分支作為意圖的代替。團隊應該基於「為什麼引入」而不是「從哪裡引入」,來決定引入的依賴,進一步說,這些引入項的目的,應該由產出這些依賴的團隊聲明。
  • 「意圖」應從構建時間推遲 — 為了提高靈活性,應避免在構建完成後為構建指派意圖,而應允許聲明多個意圖,在構建時輸出就只是一籃子二進位和一些git SHA,正如在Azure DevOps的輸出項上運行一個發布流水線實質上是為輸出指派一個目標,在依賴流中為構建指派一個意圖,開始了基於意圖來流動依賴的過程。

根據這些目標,我們搞了個叫Maestro++的服務和一個叫『darc』的工具來處理依賴流,Maestro++處理數據,自動化依賴流動,同時darc為Maestro++提供了一個人類可讀的介面,也是個全局產品依賴狀態的窗口,依賴流基於四個主要概念:依賴信息,構建,通道(channel)和訂閱(subscription)。

構建,通道和訂閱

  • 依賴信息 – 在這個文件中,每個庫都有一個引入依賴的聲明,還有這些依賴庫的源信息,通過讀取此文件,然後傳遞性地按照每個引入依賴項的存儲庫+ sha組合,生成產品依賴關係圖。
  • 構建 – 一個構建僅僅是Azure DevOps構建上的Maestro++視圖,標識了庫和其sha值,全局版本號和全部的資源集,還有這個構建中生成的本地資源(例如nuget包,zip文件和安裝文件等等)。
  • 通道 – 通道代表了意圖。可以把通道理解成是一個跨庫分支,構建可以被聲明成一個或多個通道以表明輸出意圖。通道可以和一個或多個發布流水線關聯起來。對通道的構建聲明,會觸發發布流水線,引起發布動作,構建的資源路徑基於發布活動而更新。
  • 訂閱 – 訂閱代表了變換,它將基於給定通道的構建輸出映射到另一個庫的分支,還帶有這些變換何時起效的額外信息。

這些概念的目的是代碼庫維護者不需要關心技術棧的全局知識,或者其他參與到依賴流的團隊的進度,他們基本只需要知道三件事:

  • 進行構建的意圖,可能會觸發通道的分配。
  • 引入的依賴,和這個依賴是哪個庫產出來的。
  • 希望從哪些通道更新那些依賴。

舉個例子,比方說我擁有dotnet/core-setup這個庫,我知道這個庫的主分支在DNC3一天天的開發中不停產出代碼,現在想要對預聲明的「.NET Core 3.0 Dev」的通道分配新的構建,我也知道我有幾個dotnet/coreclr和dotnet/corefx的包引用,但我不需要知道這幾個包怎麼產生的,或者是從哪個分支產生的,我只需要知道,我想要每天在每次需要時都給「.NET Core 3.0 Dev」通道引入最新的coreclr和corefx依賴。

首先,我添加了個文件進入工作,然後使用darc工具,確定我代碼庫在主分支上的每個新構建都默認分配到「.NET Core 3.0 Dev」通道上,然後,我設置了訂閱,來從.NET Core 3.0 Dev引入依賴給dotnet/corefx, dotnet/coreclr, dotnet/standard等的構建,這些訂閱有一個節奏和自動合併原則(比如每週或每個構建)。

每個訂閱被激活都會成為一個觸發器,Maestro++會基於和新生成的輸出交集的聲明依賴關係,更新一些core-setup庫中諸如eng/Version.Details.xml, eng/Versions.props之類的文件,這會打開一個PR,如果配置檢查通過,就會自動合併PR。

轉而會在core-setup的主分支上生成一個新構建,完成之後,「.NET Core 3.0 Dev」通道的構建自動分配就會開始,「.NET Core 3.0 Dev」通道已經有了一個相關的發布流水線,會將構建的輸出實例(比如包和符號文件),輸出到一組目標目錄,因為這個通道是面向日常的公共開發構建的,因此包和符號文件會被推到不同的公共路徑,發布流水線完成後,通道分配完成,任何這一事件激活的訂閱都會結束,隨著越來越多的組件添加,我們建立了一個完整的依賴流圖,代表所有的庫間自動依賴流。

點劃線:已經被禁用的,或僅僅是按需分配的依賴;虛線箭頭:每天更新的依賴;實線箭頭:每次構建更新的依賴

相干與退相干

DNC的依賴圖狀態可見性提高凸顯了一個問題:圖中的不同節點,所參照的同一個組件有多個版本時會發生什麼?DNC依賴圖中的每個節點都可能把依賴流向不止一個別的節點,例如otnet/core-setup產出的Microsoft.NETCore.App依賴,會流動到dotnet/toolset, dotnet/core-sdk, aspnet/extensions和幾個其他的庫,取決於PR驗證時間,還需要對破壞性變動做出反應,和需要的訂閱更新頻率,因為這些依賴可能流動到各處,最終在dotnet/core-sdk上集中到以啟,可能會造成幾個Microsoft.NETCore.App的不同版本在整個依賴圖中傳遞並被引用,這被稱為「退相干」,當在整個依賴圖中每個產品依賴都只有一個版本,依賴圖就是「相干」的,我們儘可能地發布「相干」的產品。

退相干發生時會有什麼問題? 退相干代表錯誤狀態可能發生,舉個例子,看看Microsoft.NETCore.App。這包代表了一個特定的API截面,雖然庫的依賴圖中,可能引用到多個Microsoft.NETCore.App版本,SDK還是隻會發布一個版本,運行時必須滿足所有可能在該運行時上執行的傳遞引用組件需求(例如WinForms和WPF),如果運行時不能滿足(比如破壞性的API變動),就會發生錯誤,在退相干依賴圖中,因為所有的庫都沒有獲取Microsoft.NETCore.App的同一個版本,可能會漏掉一個破壞性變化。

退相干永遠代表錯誤嗎? 不,比如依賴圖中的Microsoft.NETCore.App退相干只代表coreclr的一個變化,不會破壞JIT的bug修復。技術上說,不需要在依賴圖的每個節點上都引入一個新的Microsoft.NETCore.App,主要針對新運行時傳遞一樣的組件就行了。

既然退相干偶爾才會發生錯誤,那為什麼我們還要儘力去發布相干應用? 因為確定什麼時候退相干不是很困難,發布相干的最終產品,比嘗試理解退相干組件帶來的語義差異更簡單,後者也可以左到,但是在構建時,任務很密集,容易出錯,強制相干更加容易。

依賴流的好處

隨著庫之間依賴圖的增大,所有的自動化和追蹤機制的優點頁越來越大,開啟瞭解決日復一日的構建工作的實際問題的無限可能。儘管我們剛開始探索這個領域,但是這個系統已經可以回答我們的問題,還可以處理這樣的場景:

  • dotnet/core-sdk的兩個git SHA值之間,「究竟」發生了什麼改變?— 通過編寫Version.Details.xml文件以構建一個完整依賴圖,我可以明確依賴圖中非依賴變化的其他變化。
  • 一個修復出現在產品中需要多長時間?— 結合庫依賴流和每個庫的遙測,可以估計一個修復從庫A在依賴圖中移動到庫B需要多長時間,這在發布時尤其有價值,可以幫助我們在考慮是否進行具體更改時,進行更準確的成本/收益估算。
  • 一個core-sdk和它的引入依賴的構建,其所有資源的路徑在哪?
  • 在服務發布時,我們想進行特定的修復,但是掛起其他的,通道可以置為特定修復允許在依賴圖中流動,但是其他工作會被鎖定/需要批准的模式。

下一步是?

隨著DNC3的塵埃落定,我們還在尋求可以改進的地方,這個計劃還在(很)早期階段,我們打算在一些關鍵領域投入努力:

  • 減少將修復轉變為可發布且相干的產品的時間——我們的依賴圖中的躍點數很重要,躍點允許庫在構建過程中有很多自主權,但因為每個躍點都需要commit和官方構建,端到端的「構建」時間也變長了,我們需要顯著地減少端到端時間。
  • 改進我們的基礎設施遙測—如果可以更好地跟蹤構建失敗,資源使用情況,依賴狀態這些東西,就可以更好地確定精力應該放在哪裡,以推出更好的產品,在DNC3中,我們採取了一些手段,但仍有路可走。

這些年,我們的基礎設施很大程度上進化了,從Jenkins到Azure DevOps,從手動依賴流演變到Meastro++(就那個你在dnc庫和本文頭圖裡常見的機器人),從很多構建工具演變到只有一個,這些為了DNC3發布而做的改動是一個巨大的跨越,我們會努力發布一個比過去更有意思,更加可信賴的產品。


推薦閱讀:
相關文章