Microsoft Graph 的 .NET 6 之旅
這是一篇釋出在dotnet 團隊部落格上由微軟Graph首席軟體工程師 Joao Paiva 寫的文章,原文地址: https://devblogs.microsoft.com/dotnet/microsoft-graph-dotnet-6-journey/。
Microsoft Graph 是一個 API 閘道器,它提供了對 Microsoft 365 生態系統中資料和智慧的統一訪問。 該服務需要實現兩大目標:以非常高的規模執行並有效利用 Azure 計算資源。 我們使用 .NET 構建雲原生的應用已經能夠實現這兩個目標。 我將向您詳細介紹我們是如何將 Microsoft Graph 構建到現在這樣海量服務中的過程。
.NET 6 之旅
四年前,該服務採用 .NET Framework 4.6.2 上的 ASP.NET 執行在 IIS 上。現在該服務採用 .NET 6 上ASP.NET Core 執行在 HTTP.sys 上。 從 .NET Core 3.1 到 .NET 5 ,隨著每次升級我們觀察到 CPU 利用率有所提高,尤其是在 .NET Core 3.1 和最近使用 .NET 6。
- 從 .NET Framework 升級到 .NET Core 3.1,在相同的流量下,我們觀察到 CPU 減少了 30%。
- 從 .NET Core 3.1 到 .NET 5,我們沒有觀察到有意義的差異。
- 從 .NET 5 到 .NET 6,對於相同的流量,我們觀察到 CPU 又減少了 10%。
CPU 利用率的大幅降低轉化為更低的延遲、更高吞吐量和計算容量時的有意義的成本節約,有效地幫助我們實現了目標。
該服務覆蓋全球,目前部署在全球 20 個地區。四年前,該服務每天處理 10 億個請求,運營成本極高。如今,它每天處理大約 700 億個請求,增長了 70 倍,每處理 10 億個請求,運營成本就降低了 91%。這反映了過去 4 年的增長和改進步伐,其中從.NET Framework遷移到 .NET Core 發揮了重要作用。
.NET Core 的影響
從 .NET Framework 4.6.2 (IIS + ASP.NET) 到 .NET Core 3.1 (Kestrel + ASP.NET Core;以及後來的 HTTP.sys) 的初始遷移過程中,我們的基準測試顯示吞吐量顯著提高。 下圖比較了堆疊,並繪製了使用 Standard_D3_v2 虛擬機器和合成流量的每秒請求數 (RPS) 和 CPU 利用率。
當我們比較兩個.NET 執行時堆疊,該圖表說明了 RPS 相對於相同 CPU 利用率的顯著增加。 在 60% CPU 時,老的.NET Framework 4.6.2(橙色)中的 RPS 約為 350,新的.NET Core 3.1(藍色)中的 RPS 約為 850。 .NET Core 在更高的 CPU 閾值下效能明顯更好。
重要的一點是要注意此基準測試使用的是合成流量,並且觀察到的改進不一定直接轉化為具有真實流量的更大規模生產環境。 在生產中,我們觀察到 CPU下降了 30%(對於相同的流量)。
構建系統的現代化
我們的構建系統的現代化是 遷移到 .NET Core 成為可能的一項重大任務。
我們使用的是內部構建系統的時候,構建系統工具鏈與 .NET Core 不相容。因此,在我們的案例中,第一步是使構建系統現代化。我們遷移到了一個更新的現代構建系統,主要使用具有MSBuild和dotnet支援的Visual Studio工具鏈。新的工具鏈支援.NET Framework和.NET Core,併為我們提供了所需的靈活性。
對構建系統進行現代化改造的投資雖然一開始很困難,但它通過更快的構建和專案,更容易建立和維護,大大提高了我們的生產力。
整體情況
每次 .NET 升級都有許多改進,即使 Graph 團隊沒有執行任何顯式工作來提高效能也是如此。每個新的 .NET 版本都改進了底層執行時 API、通用演算法和資料結構,從而導致 CPU 週期和 GC 工作的減少。對於像 Microsoft Graph 這樣受計算約束的服務,使用新的執行時和演算法來減少時間和空間複雜性至關重要,並且是使服務快速且可縮放的最有效方法之一。在 .NET 團隊的朋友的幫助下,我們能夠提高吞吐量、減少延遲開銷和計算運營成本。謝謝!
遷移的另一個原因是使程式碼庫現代化。現代的程式碼庫更能吸引了人才(招聘),並使我們的開發人員能夠使用更新的語言功能和API來編寫更好的程式碼。像.NET Core中引入的 spans 這樣的構造是無價的。我使用 span 的常見方法之一是字串操作。字串操作是老的 .NET 程式碼庫中的常見陷阱。由於無休止的連線給GC帶來了壓力,最終反映在更高的CPU成本上,舊模式通常會導致字串分配的爆炸式增長。開發人員甚至沒有意識到這種分配的實際成本和影響。.NET Core 所引入的Spans 和 string.Create 為我們提供了一個操作字串的工具,避免了堆上不必要的字串分配成本。
此外,我們依靠可觀察性工具來監視在 CPU、記憶體、檔案和網路 I/O 等維度上程式碼的成本。這些工具幫助我們識別迴歸和機會,以改善處理延遲、運營成本和可擴充套件性。
我們通過新的 API 和 C# 特性獲得了非常顯著的優勢:
- 通過array pooling 減少緩衝區分配。
- 減少與記憶體和span相關的型別的緩衝區和字串分配。
- 減少使用靜態匿名函式從封閉上下文中捕獲狀態的委託分配。
- 使用 ValueTask 減少任務分配。
- 使用 nullable 刪除整個程式碼庫中冗餘的 null 檢查。
- 使用null-coalescing assignment 或 using declarations編寫簡潔的程式碼,僅舉兩例。
此列表未涵蓋許多其他改進,包括演算法和資料結構以及重要的體系結構和基礎結構改進。最終,.NET Core和語言功能使我們能夠提高工作效率,並編寫演算法和資料結構,以減少時間和空間的複雜性,這對於實現我們的長期目標至關重要。
最後但並非最不重要的一點是,.NET Core使我們的服務準備好在Windows和Linux中執行,並使我們能夠通過HTTP/3和gRPC等傳輸協議快速創新。
遷移指南
本節介紹從 ASP.NET 遷移到 ASP.NET 核心環境所採用的策略,旨在作為高階指導。
步驟 1 — 構建現代化
第一個先決條件是允許您構建 .NET Framework 和 .NET Core 程式集的生成系統(如果情況並非如此)。
對於 Graph 團隊來說,對生成系統進行現代化改造不僅使遷移到 .NET Core 成為可能,而且還通過更快的生成和更易於建立和維護的專案,大大提高了我們的工作效率。
第 2 步 — 架構就緒
擁有良好的體系結構來執行遷移非常重要。讓我們使用圖表作為我們將要經歷的三個主要階段的插圖。
- 在第 1 階段,我們有 ASP.NET Web 伺服器程式集和麵向 .NET Framework(黃色)的所有庫。
- 在第 2 階段,我們有兩個 Web 伺服器程式集,每個程式集都面向各自的 .NET 執行時,而庫現在面向 .NET Standard(藍色)。這樣可以進行 A/B 測試。
- 在第 3 階段,我們有一個 Web 伺服器程式集和所有面向 .NET Core(綠色)的庫。
如果你的解決方案尚未在多個程式集中分解(階段 1),則現在是執行此操作的好機會。ASP.NET 程式集應該是 Web 伺服器的非常薄的存根,從主機中抽象出應用程式。此 ASP.NET 程式集應特定於主機,並引用實現各個元件(如控制器、模型、資料庫訪問等)的下游庫。重要的是要有一個具有關注點分離的體系結構模式,因為這有助於簡化依賴關係鏈和遷移工作。
在我們的服務中,這是通過單個 HTTP 應用程式處理程式來完成的,該處理程式是特定於主機的傳入請求。該處理程式將傳入的轉換為與主機無關的等效物件,該物件將傳遞到下游程式集,這些程式集使用該物件讀取傳入的請求並寫入響應。我們使用的介面分別抽象了每個主機環境所使用的傳入 System.Web.HttpContext 和 Microsoft.AspNetCore.Http.HttpContext 。此外,我們在下游程式集中實現路由規則,與主機無關,這也簡化了遷移。該服務沒有 UI 或檢視元件。如果您有一個具有 MVC 和模型繫結的檢視元件,則解決方案必然會更加複雜。
步驟 3 — .NET Framework 依賴項的清單
建立服務使用的所有依賴項的清單,這些依賴項僅屬於 .NET Framework,並標識所有者以在需要時與它們進行互動。
根據相關性和投資回報對每個依賴關係進行分類。使用和維護依賴關係會帶來一些包袱和稅收,它們是值得的。通常,良好的依賴關係遵循以下原則:
- 它不攜帶隱式依賴項,除了 .NET 執行時或擴充套件。
- 它解決了一個不容易解決的有意義的問題,或者邏輯非常敏感,不需要重複。
- 它具有良好的質量,可靠性和效能,特別是在熱路徑中存在時。
- 它得到了積極的維護。
如果不滿足這些前提中的任何一個,則可能是時候找到替代方案了,要麼通過找到另一個執行該工作的依賴項,要麼通過實現它。
大多數流行的庫已經是以.NET Standard為目標,許多甚至以.NET Core為目標。對於任何專門針對 .NET Framework 的庫,通常已經在所有者的雷達中在 .NET Standard 中構建它們。大多數人都非常樂於接受這樣的工作。 可以與庫的所有者聯絡,瞭解提供 .NET Core 相容版本的時間表。
步驟 4 — 從專案庫中擺脫 .NET Framework 依賴項
開始逐個遷移依賴項,移動到 .NET Standard 中的等效項。如果解決方案中有許多專案,請按照自下而上的方法開始處理位於依賴項鍊底部的專案,因為它們通常具有最少數量的依賴項並且更易於遷移。
面向 .NET Framework 的專案可以繼續這樣做,而遷移工作正在進行中。一旦專案不再引用任何 .NET Framework 依賴項,請將其設定為 .NET Standard。
第 5 步 — 避免被阻止
如果服務具有舊版或規模很大,則可能會發現隱藏了難以擺脫的依賴項。不要放棄。
請考慮以下選項:
- 自願幫助所有者將依賴項構建為 .NET Standard,以便自行取消阻止。
- 將程式碼分叉,並將其程式碼放到你的程式碼庫中生成為 .NET Standard,作為臨時的解決方案,直到相容的版本可用。
- 將依賴項作為單獨的控制檯應用程式或與 .NET Framework 一起執行的後臺服務執行。現在,你的服務可以在 ASP.NET Core 中執行,而控制檯應用程式或後臺服務可以在 .NET Framework 中執行。
- 作為最後的手段,請嘗試從 .NET Core 專案中引用依賴項,包括 .NET Framework ProjectReference 或 PackageReference .NET Core 執行時使用相容性填充程式,允許您載入和使用某些 .NET Framework 程式集。但是,不建議將此作為永久性措施。必須(在執行時)對此方法進行詳盡的測試,因為即使生成成功,也無法保證程式集相容(在所有程式碼路徑中)。
NoWarn="NU1702"
在 Microsoft Graph 遷移的案例中,我們在不同的時間和不同的依賴項中使用了所有這些選項。目前,我們仍然將一個控制檯應用程式作為 .NET Framework 執行,並使用相容性填充程式在服務中載入一個 .NET Framework 程式集。
步驟 6 — 為 ASP.NET Core 建立新的 Web 伺服器專案
使用等效設定,為 ASP.NET Core 建立一個新專案,與當前 ASP.NET 框架專案並行。新 ASP.NET Core 專案預設使用 Kestrel。它非常好,是大多數.NET團隊投資的地方。這是他們的跨平臺Web伺服器。但是,您可以考慮其他選擇,例如HTTP.sys,IIS甚至NGINX。
請確保在 .NET Core 中啟用較新的效能計數器。花點時間來啟用它們,特別是與CPU,GC,記憶體和執行緒池相關的。還要為所選的 Web 伺服器啟用效能計數器(例如,請求佇列)。當您開始實施時,這些對於檢測任何迴歸或異常非常重要。
此時,您應該已完成第 2 階段(在我上面圖片中),並準備好執行 A/B 測試並開始實施。
步驟 7 — A/B 測試和實施計劃
建立一個實施計劃,該計劃允許在通過所有預生產關口後,在某些生產容量中進行 A/B 測試(例如,將新執行時部署到一個規模集)。使用真實流量進行大規模測試是最終的大門和關鍵時刻。
您可以使用以下啟發式方法測量應用程式之前和之後的效率,測量 A/B 位之間的差異:
Efficiency = (Requests per second) / (CPU utilization)
在第一次實施期間,儘量減少在有效負載中引入的更改,以減少可能導致意外迴歸的變數數。如果我們在有效負載中引入太多變數,我們就會增加引入其他可能與新執行時無關的錯誤的可能性,但仍會浪費工程師的時間來確定和根本原因。
一旦初始部署在小規模內成功並經過審查,請按照現有的安全部署實踐逐步實施,計劃使用逐步推出來啟用新位。重要的是要遵循逐步實施,這樣您就可以及時檢測和緩解可能隨著數量和規模的增加而出現的問題。
步驟 8 — 在所有專案中以 .NET Core 為目標
一旦服務在 ASP.NET Core 中執行,大規模部署並經過審查,就可以刪除 .NET Framework 中仍然存在的最後一個片段了。刪除用於 ASP.NET 的 Web 伺服器專案,並將所有專案庫顯式移動到 .NET Core 而不是 .NET Standard,以便您可以開始使用較新的 API 和語言功能,使開發人員能夠編寫更好的程式碼。有了這個,你已經成功地完成了第3階段。
升級技巧
應用了一些主要的學習和升級技巧。
URI 編碼中的怪癖
該服務的一個核心功能是分析傳入的 URI。多年來,我們最終在整個程式碼庫中都有不同的點,對傳入請求的編碼方式進行了嚴格的假設。當我們從 ASP.NET 轉移到 ASP.NET Core時,許多這些假設都被違反了,導致許多問題和邊緣情況。經過長時間的修復和分析,我們整合了以下規則,用於將 ASP.NET Core Path和Query轉換為程式碼不同部分所需的老的 ASP.NET 格式。
使用 .NET 6 啟用動態 PGO
在.NET 6中,我們啟用了動態PGO,這是.NET 6.0最令人興奮的功能之一。PGO 可以通過最大限度地提高穩態效能而使 .NET 6.0 應用程式受益。
動態 PGO 是 .NET 6.0 中的一項選擇加入功能。需要設定 3 個環境變數才能啟用動態 PGO:
-
set DOTNET_TieredPGO=1
.此設定利用方法的初始 Tier0 編譯來觀察方法行為。在 Tier1 重新設定方法時,將從 Tier0 執行收集的資訊用於優化 Tier1 程式碼。 -
set DOTNET_TC_QuickJitForLoops=1
.此設定為包含迴圈的方法啟用分層。 -
set DOTNET_ReadyToRun=0
. 預設情況下,.NET 附帶的核心庫都啟用了 ReadyToRun。ReadyToRun允許更快的啟動,因為JIT編譯較少,但這也意味著ReadyToRun映像中的程式碼不會經過支援動態PGO的Tier0分析過程。通過禁用 ReadyToRun,.NET 庫還可以參與動態 PGO 過程。
這些設定使 Azure AD 閘道器的應用程式效率提高了 13%。
其他參考資料
有關更多瞭解,請參閱 Azure AD 閘道器姊妹團隊釋出的以下部落格:
總結
每個新版本的 .NET 都帶來了巨大的生產力和效能改進,這些改進繼續幫助我們實現構建可擴充套件服務的目標,這些服務具有高可用性、安全性、最小的延遲開銷和最佳路由,同時具有儘可能低的運營成本。
請放心,沒有銀彈。在大多數情況下,遷移需要團隊的認真承諾和辛勤工作。但從長遠來看,這項工作無疑會帶來許多紅利。