用Rust替代Java重寫DNS解析器

banq發表於2022-11-25

我們重寫BlueCat Edge核心的 DNS 解析器的經驗可以證明:Rust可以成為編寫網路應用程式和伺服器的不錯選擇。

BlueCat Edge是一個智慧DNS解析器和快取層,允許你建立自定義規則(策略),規定流量應如何處理,以及流量應轉發給誰(名稱空間)。同時,我們收集所有透過Edge的DNS流量的日誌,並透過UI或API使其可被發現。簡而言之,Edge讓您對您的DNS流量有前所未有的控制和可視性。
Edge的主要邏輯存在於名稱空間和策略中。名稱空間是一組 DNS 解析器(例如 [1.1.1.1, 8.8.8.8])和一個域的集合,以確定何時應使用該名稱空間進行匹配。或者,你可以指定一個預設的名稱空間,使每個查詢都能匹配。Edge的部署可以有多個名稱空間,每個名稱空間只有在傳入的DNS訊息與它配置的域列表中的一個域相匹配時才會被選擇。

這種邏輯被編入我們最初的Edge DNS解析器(Java閘道器)的Java實現中,儘管該服務是函式性的,但它有兩個主要問題需要繼續解決:可維護性和效能。
我們可以不顧這些挑戰,嘗試改進Java閘道器,或者我們可以重寫軟體以滿足我們的要求。
重寫軟體是一項重要的工作,決不能輕率行事。這個過程需要大量的思考、研究和計劃;即使如此,也不能保證重寫後的實現會超過現有程式碼的能力。

為了決定是改進還是重寫我們的實現,我們比較了我們用Java閘道器的情況和我們想用Edge的DNS解析器服務的情況。

Java gateway的程式碼很複雜。該版本的實際程式碼只有不到65000行。文件是有限的,在許多情況下,原作者早已不在了。
從效能上看,該服務在其預設配置下可以處理大約每秒4000次DNS查詢(QPS);我們的目標是每個物理核心10000次QPS。
此外,增加額外的CPU資源並不能使QPS有效擴充套件。
當然,Java能夠在CPU資源的作用下,實現令人尖叫的快速和良好的擴充套件,但這些最佳化需要非常仔細的編碼,許多開發人員在編寫和維護方面有困難。
考慮到Java閘道器程式碼庫的狀況和我們可用的開發人員資源,改進我們現有的實現不是一個可行的選擇。

我們的效能要求使我們考慮使用C或C++,但記憶體管理的開銷有可能引入我們在記憶體安全的Java中工作時從未遇到過的問題。考慮到我們對記憶體安全的渴望,促使我們更深入地研究使用Rust--一種最初來自Mozilla的程式語言,現在由其自己的基金會獨立領導,用於編寫具有C語言效能的安全系統程式碼--來進行重寫。

用Rust重寫
我們估計,編寫習慣性Rust將使我們能夠實現我們的效能和可擴充套件性目標,而不需要將大量的開發資源用於最佳化。此外,在沒有垃圾收集器的情況下管理記憶體可以減少尾部延遲,使效能更可預測。效能對於這個應用程式來說是至關重要的,因為處理過程是即時發生的。因此,我們在計算策略或名稱空間規則時,每花一毫秒都會給使用者的DNS查詢增加延遲。

使用Rust的另一個好處是它的工具。Rust編譯器提供了有用的、可讀的錯誤資訊,cargo也是一個福音:cargo測試 "只是工作"。Cargo很容易實現 "工作區 "式的設定,即在一個產生二進位制檔案的工作區中,專案被分為許多板塊。這種設定有助於減少編譯時間和組織程式碼。即使如此,新構建的編譯時間也不是很好,可能需要一兩分鐘。在Mac上,如果在docker中構建,情況可能會更糟。在開發過程中使用rust-analyzer進行快速反饋有助於緩解其中的一些問題。

雖然我們最初的開發團隊有不同的電腦科學背景和經驗,但不是每個人都熟悉Rust。最終,儘管如此,使用Rust的理由足以讓我們承諾在重寫Java gateway的過程中使用它。我們很快就發現,在團隊中有一個熟悉Rust的人,對於讓大家加快進度和保持勢頭至關重要。在一個新的語言上開始一個新的專案,如果沒有人指導,就會導致在學習常見的習語、生態系統和庫的狀態方面的緩慢上升。從開始到第一次釋出,我們能夠在一年內寫出一個功能完整的Rust實現。

我們在Rust方面的經驗
一個專案的重要基準是它是否容易加入新的開發人員。舊的Java閘道器程式碼庫是很脆弱的,做一些小的功能改動往往需要花費數週的時間。
相比之下,我們讓剛接觸Rust的員工在一兩個星期內就能上手併為corten貢獻大量的新功能。
儘管如此,新加入Rust的開發人員也不是沒有挑戰的。
與C語言相比,Rust是一種更復雜的語言,因為它對記憶體管理有獨特的方法。雖然習慣於Rust的借貸檢查器--驗證資料的壽命和所有權--最初可能會令人沮喪,但它極大地提高了程式碼的安全性,並帶來長期的回報。
因此,我們沒有觀察到corten的 "segfault "崩潰,這證明了Rust編譯器的程式碼驗證的可靠性。此外,用sum和product型別表達業務邏輯感覺很直觀,而且Rust的嚴格編譯器定期通知重構,使事情順利進行。

Corten還受益於Rust的 "無畏的併發性",我們在專案中廣泛使用了tokio。
tokio原被用來從UDP和TCP中讀取資訊,併為每個傳入的訊息生成一個任務,支援在單個TCP流中進行訊息的管道化。所有與給定訊息相關的資料都由該任務完全擁有,所以它可以自由地改變其內容。
此外,tokio支援輕量級的任務生成,這使得一個盒子可以執行成千上萬,甚至數十萬的任務。
tokio-util的編解碼模組可以幫助實現Stream,但是我們在corten成立之初無法使用它,因為來自tokio-util的UdpFramed需要一個完全擁有的UdpSocket,但是我們在實踐中最經常需要一個Arc<UdpSocket>(之前在tokio 0.2中是ReadHalf)。這導致我們為tokio-util貢獻了一個PR,改變了這種行為。
自發布以來,async-stream已經出現,以涵蓋更多的情況,使用async/await語法輕鬆建立流。


成品
總的來說,Corten在幾個方面比我們原來的Java閘道器實現有了改進。
Corten只有大約15000行,但透過整合幾個較小的微服務,設法比Java gateway的功能更豐富。
此外,Corten在效能和可擴充套件性方面大大超過了我們的目標。
說實話,Rust並不是造成所有差異的唯一原因,因為你對某件事情的第二次嘗試總是會吸收第一次的經驗教訓。
然而,應該注意的是,我們並沒有試圖對Rust版本進行大量的最佳化。
至於記憶體的使用,在沒有域名列表的預設配置下,corten只消耗了十幾MB。
一般來說,記憶體使用的最大決定因素是每個名稱空間/政策所附的域名列表有多大,而這些列表可能相當長。然而,在啟動後出現了一個值得注意的注意事項。

Rust預設使用系統分配器來管理記憶體:
在Linux上,這個分配器是為速度而調整的,如果它認為程式很快就會需要記憶體,它就不會輕易地把記憶體釋放給作業系統。這種行為導致了與corten的意外互動,corten將域以壓縮的trie形式儲存在記憶體中,可以在操作過程中更新或重新下載和反序列化,然後替換。這些列表可以有幾百MB的大小,包含了數百萬個域,我們注意到隨著時間的推移,記憶體會被消耗到某個上限。在幾次更新之後,經常可以看到corten消耗接近2GB的記憶體,而這些記憶體在新開始時只需要200-300MB。

我們透過用jemalloc代替分配器並啟用background_thread配置引數來解決這個問題,以犧牲幾個百分點的效能為代價,更容易將記憶體釋放回作業系統。
與Rust不同的是,JVM有一個垃圾收集器來處理壓縮和釋放記憶體,儘管對於高效能的Java應用來說,必須調整你的GC是很常見的。Java在分配域列表時可能使用了較少的記憶體,但如果你想走最佳化之路,Rust對型別的大小給出了更多的底層控制。這是Rust的一個共同主題:如果你需要更多的控制,它是以增加複雜性為代價的。

雖然 Rust 生態系統並不完美,而且升級可能具有挑戰性,但這個專案取得了成功,我們希望在未來用 Rust 做更多的事情。在此過程中,我們能夠為 tokio 和 trust-dns 庫做出貢獻。我們還最終一起破解了一些東西以滿足我們的整合測試需求,但這些細節可能值得他們自己的部落格文章。如果您有興趣使用 Rust 編寫網路應用程式,請隨時檢視我們的另一個專案dhcproto,一個 DHCP 解析器/編碼器,並考慮加入我們的 BlueCat!

 

相關文章