≈
2018 年 4 月 1 日,Cloudflare 宣佈推出 1.1.1.1 公用 DNS 解析程式。多年來,我們為該平台增加了偵錯頁面(用於疑難排解),全球快取清除、Cloudflare 區域的 0 TTL、上游 TLS 和 1.1.1.1 for Families。在這篇文章中,我們想要分享一些幕後的細節和變化。
專案啟動後,我們選擇了 Knot Resolver 作為 DNS 解析程式。我們開始在此基礎上構建整個系統,以便它能符合 Cloudflare 的使用案例要求。擁有一個經過實戰測試的 DNS 遞迴解析程式以及一個 DNSSEC 驗證程式簡直太棒了,因為我們可以騰出精力去做其他事情,而不是擔心 DNS 通訊協定的實作情況。
Knot Resolver 採用基於 Lua 的外掛程式系統,具有極大的靈活性。它讓我們能夠快速擴充核心功能來支援各種各樣的產品功能,如 DoH/DoT、記錄、基於 BPF 的攻擊緩解、快取共用以及反覆邏輯覆寫。而隨著流量的不斷增長,我們達到了一定的限制。
汲取的經驗
在深入探討之前,我們先來鳥瞰一下簡化的 Cloudflare 資料中心設定,這會有助於我們理解後面要討論的內容。Cloudflare 的每部伺服器都完全相同:在一部伺服器上執行的軟體堆疊與另一部伺服器上的相同,只是設定可能有所差異。這種設定大大降低了系列維護的複雜性。
圖 1 資料中心配置
解析程式作為精靈程序 kresd 執行,不能獨立工作。要求,具體而言是 DNS 要求,會透過 Unimog 進行負載平衡處理,進入資料中心內部的伺服器。DoH 要求會在 TLS 終端子處終止。而設定和其他小段資料只需短短數秒,即可透過 Quicksilver 傳遞到世界各地。有了所有這些幫助,解析程式便可專注於自己的目標——解析 DNS 查詢——而不必為傳輸通訊協定的細節而擔心。下面,我們來談談想要改進的 3 大領域——封鎖外掛程式中的 I/O、提升快取空間使用效率、外掛程式隔離。
封鎖事件迴圈的回呼
Knot Resolver 的外掛程式系統極其靈活,可用於擴充核心功能。我們將外掛程式稱為模組,它們基於回呼構建。在要求處理期間的某些特定時刻,會使用目前的查詢內容叫用這些回呼。這讓模組能夠檢查、修改甚至產生要求/回應。這些回呼在設計上非常簡單,以免封鎖基礎事件迴圈。這一點之所以特別重要,是因為服務是單一執行緒,而事件迴圈負責同時為多個要求提供服務。因此,即便只有一個要求在回呼中遭遇攔截,也意味著其他並行要求在回呼完成之前不會有任何進展。
該設定對我們而言一直都是相當不錯的,直到我們需要執行封鎖操作,例如,為了在回應用戶端之前從 Quicksilver 提取資料。
快取效率
由於針對某個網域的要求可進入資料中心內的任何節點,當另一個節點已經得到答案時,重複解析某個查詢就是一種浪費。憑直覺,如果快取可在伺服器間共用,則可改善延遲,因此,我們建立了一個快取模組來多點傳送新加入的快取項目。然後,同一資料中心內的節點就能訂閱事件並更新本機快取。
Knot Resolver 中的預設快取實作是 LMDB。對於中小型部署而言,它既快速又可靠。但在我們的案例中,快取逐出很快成為一個問題。快取本身不會對任何 TTL、熱門程度等進行追蹤。當它已滿時,就會清除所有項目,然後重新開始。而像區域列舉這樣的情況可能會用以後不太可能擷取的資料填滿快取。
此外,多點傳送快取模組還會將不太有用的資料放大到所有節點,讓情況變得更糟,並導致這些節點同時達到快取上限標準。然後,我們會看到延遲暴增,這是因為所有節點都捨棄了快取,並幾乎在同一時間重新開始。
模組隔離
隨著 Lua 模組清單不斷變長,偵錯問題變得越來越困難。這是因為單一 Lua 狀態在所有模組間共用,所以如果一個模組行為不當,就會影響另一個模組。例如,當 Lua 狀態內部出現問題時,比如,協同程式過多,或記憶體不足,若只是程式損毀,我們就很幸運了,但由此產生的堆疊追蹤會很難讀取。若要強行卸除或升級正在執行的模組也很困難,因為它不僅在 Lua 執行階段中具有狀態,在 FFI 中也有,所以記憶體安全無法得到保證。
你好,BigPineapple
我們發現,現有的任何軟體都無法滿足我們的小眾需求,因此,最終我們開始自己構建一些東西。第一次嘗試就是使用精簡服務包裝 Knot Resolver 的核心,該服務是用 Rust(經過修改的 edgedns)撰寫的。
事實證明,這樣的構建難度很大,因為必須不斷地在儲存體以及 C/FFI 類型之間進行轉換,而且還有其他一些奇怪之處(例如,用於從快取中查詢記錄的 ABI,需要傳回的記錄在下一次呼叫或讀取處置結束之前不可變)。但我們透過嘗試實作這種分割功能(主機(即服務)為來賓(即解析程式核心程式庫)提供一些資源)以及如何改進該介面,學到了很多東西。
在後面的反覆運作中,我們用基於非同步執行階段的新程式庫取代了整個遞迴程式庫;我們為其加入了一個重新設計的模組系統,隨著時間的推移,我們換掉了越來越多的元件,偷偷地將該服務重寫到 Rust 中。這個非同步執行階段是 tokio,它不僅提供了一個整潔的執行緒集區介面來執行非封鎖和封鎖工作,還提供了一個良好的生態系統來處理其他壓縮容器格式(Rust 程式庫)。
此後,隨著 futures 組合子變得單調乏味,我們開始將一切都轉換為 async/await。這時 Rust 1.39 尚未提供 async/await 功能,這導致我們在很長一段時間內使用的是 nightly(Rust beta 版),並發生了一些小問題。當 async/await 穩定後,我們才能以最高效能撰寫要求處理常式,類似於 Go。
所有工作可以同時執行,而某些 I/O 繁重的工作可分解成更小的部分,以便從更精細的排程中獲益。由於執行階段在執行緒集區,而不是單一執行緒上執行工作,因此,也會從工作竊取中獲益。這就完全避免了我們先前存在的問題,即單一要求花費大量時間去處理,因而封鎖了事件迴圈上的所有其他要求。
圖 2 元件概觀
最終,我們成功打造出一個令人滿意的平台,我們稱之為 BigPineapple。上圖顯示了主要元件概觀,以及這些元件之間的資料流程。在 BigPineapple 內部,伺服器模組從用戶端取得傳入要求,進行驗證,並將其轉換到統一架構串流,然後由工作程式模組進行處理。工作程式模組包含一組工作程式,它們的任務是找出要求中問題的答案。每個工作程式與快取模組互動,以檢查答案是否存在並且仍然有效,否則它會驅動遞迴程式模組以遞迴方式反覆執行該查詢。遞迴程式不會執行任何 I/O,當它需要某個東西時,會將子工作委派給執行程式模組。然後執行程式會使用傳出查詢,從上游名稱伺服器取得資訊。在整個程序中,一些模組可與沙箱模組互動,透過在內部執行外掛程式來擴充功能。
下面,我們將深入探討部分元件的細節,瞭解它們如何幫助我們克服以前存在的問題。
更新的 I/O 架構
我們可以將 DNS 解析程式視為用戶端和幾個權威名稱伺服器之間的代理程式:它從用戶端接收要求,以遞迴方式從上游名稱伺服器擷取資料,然後撰寫回應並將其傳送回用戶端。因此,它既有傳入流量,也有傳出流量,分別由伺服器和執行程式元件進行處理。
伺服器會接聽使用不同傳輸通訊協定的一系列介面。然後將其提取到「架構」串流中。從較高的層級看,每個架構表示為一個 DNS 訊息,以及一些額外的中繼資料。在下面,它可能是一個 UDP 封包、一段 TCP 資料流,或者 HTTP 要求的承載,但處理方式並無不同。然後架構會轉換為非同步工作,轉而由一組負責解析這些工作的工作程式進行挑選。完成的工作會重新轉換為回應,並傳送回用戶端。
這種透過通訊協定及其編碼提取「架構」的做法簡化了用來規範架構來源的邏輯,例如,強制實行公平性以防止耗盡資源,控制步調以保護伺服器不被淹沒。我們從先前的實作中汲取的一個經驗是,針對向公眾開放的服務,較之最佳 I/O 效能,公平控制用戶端步調的能力反而更加重要。這主要是因為每個遞迴要求的時間和計算成本相差甚遠(例如,快取命中與快取遺漏的差距),並且很難事先猜到。遞迴服務中的快取遺漏不僅會耗用 Cloudflare 的資源,還會耗用正在查詢的權威名稱伺服器的資源,因此,我們必須多加注意。
在伺服器的另一端是執行程式,它負責管理所有傳出連線。它會在連絡上游之前幫忙回答一些問題:就延遲而言,連線至哪台名稱伺服器最快?如果所有名稱伺服器都無法連線該怎麼辦?使用什麼通訊協定進行連線?是否有更好的選項?執行程式能夠透過追蹤上游伺服器的計量(例如,RTT、QoS 等)做出這些決定。憑藉這些資訊,它還可以猜測上游容量、UDP 封包遺失等情況,並採取必要的措施,例如,當它認為前一個 UDP 封包未連絡到上游時,進行重試。
圖 3 I/O 執行程式
圖 3 顯示了有關執行程式的簡化資料流程。它由上面提及的交換程式呼叫,使用上游要求作為輸入內容。先對要求執行重複資料刪除:這意味著在較短的時間內,如果有大量要求到達執行程式並詢問相同的問題,只有一個會通過,其他要求則進入等待佇列。這種情況通常在快取項目到期時發生,並會減少不必要的網路流量。然後根據要求和上游計量,連線指示程式會挑選開啟的連線(如果可用),或產生一組參數。利用這些參數,I/O 執行程式能夠直接連線至上游,甚至可以使用我們的 Argo Smart Routing 技術採取通過另一個 Cloudflare 資料中心的路線!
快取
在遞迴服務中,快取至關重要,因為伺服器傳回快取的回應只需不到一毫秒,而在發生快取遺漏時進行回應則需要數百毫秒。由於記憶體是一種有限的資源(在 Cloudflare 架構中也是一種共用資源),提升快取空間使用效率就成為了我們想要改進的一個主要方面。新的快取透過一個快取取代資料結構 (ARC) 而不是 KV 存放區來實作。這樣可以充分利用單一節點上的空間,因為不太熱門的項目會逐步逐出,並且該資料結構可以防止掃描。
此外,BigPineapple 不會像我們以前那樣透過多點傳送在整個資料中心內重複快取,而是瞭解它在相同資料中心中的對等節點,如果在自己的快取中找不到項目,便會將查詢從一個節點轉送至另一個節點。具體做法是,持續地將查詢透過雜湊處理到每個資料中心中運作良好的節點。因此,比如說,針對相同註冊網域的查詢通過一小部分相同的節點,這樣不僅可以提高快取命中率,還有助於改進基礎架構快取,該快取用於儲存有關名稱伺服器效能和功能的資訊。
圖 4 更新的資料中心配置
非同步遞迴程式庫
遞迴程式庫是 BigPineapple 的 DNS 大腦,因為它知道如何在查詢中找到問題的答案。從根目錄開始,它將用戶端查詢分解為子查詢,並使用它們從網際網路上的各種權威名稱伺服器以遞迴方式收集知識。而這個程序的結果就是問題的答案。藉助 async/await,可以將它提取為一個像下面這樣的函數:
該函數包含針對指定要求產生回應所需的全部邏輯,但不會獨自執行任何 I/O。而我們會傳遞一個 Exchanger 特性(Rust 介面),該特性瞭解如何以非同步的方式與上游權威名稱伺服器交換 DNS 訊息。交換程式呼叫通常在各種 await 點進行,例如,當遞迴開始後,它所做的第一件事就是查詢該網域距離最近的快取委派。如果它在快取中沒有最終委派,則需要詢問哪些名稱伺服器負責此網域並等待回應,然後才能繼續進行。
async fn resolve(Request, Exchanger) → Result<Response>;
由於這種設計會從遞迴 DNS 邏輯中分離「等待一些回應」部分,透過模擬交換程式實作,測試就變得更輕鬆了。此外,它還會讓遞迴反覆運作程式碼(特別是 DNSSEC 驗證邏輯)更具可讀性,因為它是依順序撰寫,而不是散佈在多個回呼中。
好玩的是:從頭開始撰寫 DNS 遞迴解析程式一點都不好玩!
不僅因為 DNSSEC 驗證非常複雜,還因為需要針對各種各樣的 RFC 不相容伺服器、轉寄站、防火牆等採取必要的「因應措施」。因此,我們將 deckard 移轉到 Rust 中來協助測試。此外,當我們開始遷移到這一新的非同步遞迴程式庫時,還會先以「影子」模式執行:處理來自生產服務的真實查詢範例,並比較不同之處。我們過去也在 Cloudflare 的權威 DNS 服務中這樣做過。但遞迴服務的難度略大一些,因為遞迴服務必須在網際網路上查詢所有資料,而權威名稱伺服器通常為同一個查詢提供不同的答案,因為當地語系化、負載平衡等因素導致出現很多誤判。
2019 年 12 月,我們終於在公用測試端點上啟用了新服務(請參閱公告),在將生產端點逐漸遷移到新服務之前解決了剩餘的問題。即便如此,我們還是會發現 DNS 遞迴(特別是 DNSSEC 驗證)的邊緣案例,但由於採用了新的程式庫架構,修正和重現這些問題變得容易多了。
沙箱化外掛程式
能夠即時擴充核心 DNS 功能對我們來說非常重要,因此,BigPineapple 重新設計了外掛程式系統。此前,Lua 外掛程式與服務本身在同一個記憶體空間中執行,通常可根據需要隨意執行操作。這很方便,因為我們能使用 C/FFI 在服務和模組之間隨意傳遞記憶體參照。例如,可以直接從快取中讀取回應而不必先複製到緩衝區。但它也很危險,因為模組可讀取未初始化的記憶體、使用錯誤的函數簽章呼叫主機 ABI、在本機通訊端上封鎖,或執行其他不當操作,而且服務無法限制這些行為。
因此,我們考慮用 JavaScript 或本機模組取代嵌入式 Lua 執行階段,但大約在同一時間,WebAssembly(簡稱 Wasm)的嵌入式執行階段開始出現了。WebAssembly 程式有兩個亮點:一個是讓我們能夠使用與服務其餘部分相同的語言撰寫程式,一個是這些程式在隔離的記憶體空間中執行。因此,我們開始根據 WebAssembly 模組的限制建立來賓/主機介面模型,來看看效果如何。
BigPineapple 的 Wasm 執行階段目前採用 Wasmer 技術。隨著時間的推移,我們嘗試了數種執行階段,比如一開始的 Wasmtime、WAVM,然後發現,在我們的案例中,Wasmer 更簡單易用。該執行階段可讓每個模組在自己的執行個體中執行,並使用隔離記憶體和訊號陷阱,自然地解決了前文所述的模組隔離問題。除此以外,我們還可以讓同一模組的多個執行個體同時執行。由於精心控制,應用程式可以從一個執行個體熱交換至另一個,而不會遺漏一個要求!這樣,應用程式不必重新啟動伺服器,就可以即時升級。鑑於 Wasm 程式透過 Quicksilver 發佈,只需短短數秒,BigPineapple 的功能就會在全世界實現安全變更!
為了更好地理解 WebAssembly 沙箱,我們需要先介紹幾個詞彙:
主機程式:執行 Wasm 執行階段的程式。與核心類似,它可透過執行階段完全控制來賓應用程式。
來賓應用程式:沙箱內的 Wasm 程式。在受限制的環境內,它只能存取由執行階段提供的自己的記憶體空間,並呼叫匯入的主機程式呼叫。我們將其簡稱為應用程式。
主機程式呼叫:在主機程式中定義的函數,可由來賓匯入。與 syscall 相比,它是來賓應用程式存取沙箱外部資源的唯一方式。
來賓執行階段:來賓應用程式的程式庫,以便與主機程式輕鬆互動。它會實作一些常見介面,因此,應用程式無需瞭解基礎詳細資料,便可使用非同步、通訊端、記錄和追蹤。
現在適合來深入探討沙箱,留下來看看吧。首先,我們從來賓端開始,看看一般的應用程式週期是什麼樣子的。在來賓執行階段的幫助下,撰寫來賓應用程式就像撰寫標準程式一樣。因此,與其他可執行檔一樣,應用程式以一個 start 函數開頭作為進入點,在載入時由主機程式進行呼叫。它還會提供來自命令列的引數 as。此時,執行個體通常會執行部分初始化,更重要的是,針對不同的查詢階段註冊回呼函數。這是因為在遞迴解析程式中,查詢必須通過數個階段才能收集足夠的資訊來產生回應,例如,快取查詢,或發出子要求以解析網域的委派鏈結,因此,必須能夠將這些階段連結起來,應用程式才能用於不同的使用案例。而 start 函數也能執行一些背景工作,來補充階段回呼,並儲存全域狀態。例如,報告計量,或從外部來源預先擷取共用資料等。再說一遍,就像撰寫標準程式一樣。
但程式引數從何而來呢?來賓應用程式如何傳送記錄和計量?答案是,外部函數。
圖 5 基於 Wasm 的沙箱
在圖 5 中,我們可以看到中間有一條分界線,這是沙箱界限,用於分隔來賓與主機程式。一端與另一端連絡的唯一方式是透過由對等節點事先匯出的一組函數。如圖中所示,「hostcalls」由主機程式匯出,由來賓匯入和呼叫;而「trampoline」是主機程式所知道的來賓函數。
我們將其稱為 trampoline,這是因為它可用於在來賓執行個體內部叫用未匯出的函數或閉包。階段回呼正是一個很好的範例,可以說明我們為何需要 trampoline 函數:每個回呼會傳回一個閉包,因此不能在具現化時匯出。那麼,如果來賓應用程式想要註冊回呼,則會使用回呼位址「hostcall_register_callback(pre_cache, #30987)
」呼叫主機程式呼叫,當需要叫用回呼時,主機程式卻無法呼叫該指標,因為它指向來賓的記憶體空間。而它能夠做的是利用前面提及的某個 trampoline,並為其提供回呼閉包的位址:「trampoline_call(#30987)
」。
隔離開支就像一個硬幣有兩面,伴隨著新沙箱的出現,確實產生了一定的額外開支。WebAssembly 所提供的可攜性和隔離功能帶來了額外的成本。下面,我們將列出兩個範例進行說明。
第一,來賓應用程式不允許讀取主機程式記憶體。來賓通常透過主機程式呼叫提供記憶體區域,然後主機程式再將資料寫入到來賓記憶體空間。這就會產生記憶體複本,如果我們在沙箱外部,就不需要。壞消息是,在我們的使用案例中,來賓應用程式應對查詢及/或回應執行一些操作,因此,它們幾乎總是需要從主機程式讀取有關每一個要求的資料。而好消息是,在要求生命週期內,資料不會變更。因此,我們會在來賓應用程式具現化之後,立即在來賓記憶體空間中預先配置大量的記憶體。配置的記憶體不會使用,而是用於在位址空間中佔據一個空位。在主機程式取得位址詳細資料後,會將包含來賓所需一般資料的共用記憶體區域對應至來賓的空間。當來賓程式碼開始執行後,它就可以存取共用記憶體重疊中的資料,而不需要任何複本。
另一個問題是我們想要在 BigPineapple 中為現代通訊協定 oDoH 增加支援時遇到的。它的主要工作是解密並解析用戶端查詢,然後對答案加密並將其傳送回去。這並不屬於設計的核心 DNS,而應當是使用 Wasm 應用程式擴充出來的。然而,WebAssembly 指令集並不提供部分密碼編譯原始物件,例如,AES 和 SHA-2,這使它無法從主機硬體獲得好處。目前,我們正在努力使用 WASI-crypto 將此功能引入 Wasm。到那時,我們對這個問題的解決方案就是透過主機程式呼叫將 HPKE 委派給主機程式,並且我們已經看到,相較於在 Wasm 內部執行,效能提升了 4 倍之多。
Wasm 中的非同步還記得我們之前談到的回呼可能會封鎖事件迴圈的問題嗎?從根本上說,這個問題就是如何以非同步的方式執行沙箱化程式碼。因為無論處理回呼的要求多麼複雜,如果它能夠暫止,我們就可以為其允許封鎖的時間長度設定一個上限。幸運的是,Rust 的非同步架構既簡練又輕便。因此,我們可以使用一組來賓呼叫來實作「Future」。
在 Rust 中,Future 是用於非同步計算的建置組塊。從使用者的角度來看,為了建立非同步程式,必須注意兩件事:一是實作可輪詢的函數來驅動狀態轉換,二是放置一個 waker 作為回呼,以在可輪詢的函數由於一些外部事件(例如,時間流逝、通訊端變為可讀取等)應再次呼叫時喚醒自己。前者是要能夠逐步推進程式,例如,從 I/O 讀取緩衝資料,然後傳回一個新狀態,指出工作的狀態:已完成或已暫止。後者用於工作暫止的情況,因為它會在滿足工作等待的條件後觸發 Future 進行輪詢,而不是忙於循環直到完成。
我們來看看在沙箱中是如何實作的。對於來賓需要執行一些 I/O 的情況,必須透過主機程式呼叫來執行,因為它位於受限制的環境內。假設主機程式提供了一組簡化的主機程式呼叫,來為基本通訊端操作(開啟、讀取、寫入和關閉)建立鏡像,則來賓可以將其虛擬輪詢程式定義如下:
在這裡,主機程式呼叫將資料從通訊端讀取至緩衝區,視傳回的值而定,函數可將自身移至上文提到的一種狀態:已完成 (Ready) 或已暫止 (Pending)。神奇之處在於主機程式呼叫內部。還記得在圖 5 中,它是存取資源的唯一方式嗎?來賓應用程式沒有自己的通訊端,但可透過「hostcall_socket_open
」取得一個「控制代碼」,它將轉而在主機程式端建立一個通訊端,並傳回一個控制代碼。控制代碼在理論上可以是任何東西,但在實踐中如果使用整數通訊端,控制代碼就能完美地對應到主機程式端上的檔案描述項,或者 vector 或 slab 中的索引。透過參照傳回的控制代碼,來賓應用程式能夠從遠端控制真實的通訊端。由於主機程式端完全非同步,它可以簡單地將通訊端狀態轉送到來賓。如果您注意到上面並未使用 waker 函數,您可真是太厲害了!那是因為當呼叫主機程式呼叫時,不僅會開始開啟通訊端,還會註冊要呼叫的目前 waker,然後就會開啟通訊端(或無法開啟)。因此,當通訊端就緒後,主機程式工作將被喚醒,它會從內容中找到對應的來賓工作,並使用圖 5 中所示的 trampoline 函數將其喚醒。還有一些案例,也是一個來賓工作需要等待另一個來賓工作,例如,async mutex。這裡的機制與之類似:使用主機程式呼叫註冊 waker。
fn poll(&mut self, wake: fn()) -> Poll {
match hostcall_socket_read(self.sock, self.buffer) {
HostOk => Poll::Ready,
HostEof => Poll::Pending,
}
}
所有這些複雜的東西統統封裝在我們的來賓非同步執行階段中,並提供易於使用的 API,因此,來賓應用程式不必擔心基礎詳細資料,即可使用標準非同步函數。
結尾(不是結束)
希望這篇部落格文章讓您對支援 1.1.1.1 的創新平台有了大概的瞭解。這個平台仍在不斷演進。截至今日,我們已經有數種產品(例如,1.1.1.1 for Families、AS112 和 Gateway DNS)是由在 BigPineapple 上執行的來賓應用程式提供支援的。我們期待著將更多的新技術引入其中。如果您有任何想法,歡迎您透過社群或電子郵件與我們交流。