小紅書推出自研Rust高性能七層網關ROFF
01、背景
隨著小紅書自建機房的逐步完善和上量,亟需對標各大云廠商所提供的 TLS 軟硬件卸載、負載均衡,QUIC/HTTPS 等能力。小紅書接入層團隊自研高性能網關 ROFF,基于 Rust 語言實現了 Keyless TLS 硬件加速,支持更豐富的負載均衡類型、服務發現、動態變配,模塊插件拓展,保障小紅書自建場景接入能力的高效、穩定運行。
02、為什么選型 Rust 語言
ROFF 網關基于 Rust 語言開發完成,該語言具備不劣于 C/C++ 語言的性能以及極低的內存占用,并且以內存安全著稱,奠定了網關絕對穩定的基礎。Rust 語言已經運用于 Android 和 Fuchsia OS 等場景,并在 Android 的Rust 代碼中發現的內存安全漏洞為零。
目前,ROFF 網關已經在小紅書機房承接了主站的核心流量,上線至今無一次線上崩潰事故發生。其整體架構如下圖所示,具備下列特性:
· 內存安全與高性能:安全與性能的考量是內存管理亙古的話題,C/C++ 語言將內存交給程序員管理而引入不安全的代碼和資源的浪費,Java 等語言使用垃圾回收機制減輕程序員的負擔卻又帶來了性能上的損耗。不同以往,Rust 語言使用所有權和借用機制對資源進行管理,嚴格要求一塊內存在同一時刻只能被一個變量擁有所有權,在編譯階段即可以保證內存安全和并發安全。同時,其零成本抽象的能力讓程序員可以使用高級編程概念(如泛型、模版、集合等)時不會增加運行時開銷,而僅僅是增加編譯成本。
· 豐富的代理能力:ROFF 支持多種類型的負載均衡方式,并對后端節點進行主動健康檢查,及時摘除亞健康節點保持請求轉發的健康性。通過連接復用和主線程檢查從線程同步的方式,保證健康檢查任務不影響請求的處理,從而保障網關的性能。同時,考慮到云原生的應用場景,我們還內置了 EDS 服務發現的 Discovery 能力,不再需要旁路部署服務發現組件。
· TLS 硬件卸載加速:ROFF 結合自建機房的情況,深度定制 Rustls 庫實現 TLS 的 Keyless 硬件卸載方案提高卸載速度,大大提升了 HTTPS 的處理能力。
· 更穩定的熱重載和熱升級:ROFF 支持動態變配和熱重載兩種方式進行配置變更。動態變配支持無需重啟服務,就可以無損更新現有模塊配置。熱重載和熱升級支持程序運行時自動替換現有的工作進程。我們基于 Unix Domain Sockets (UDS) 實現的文件描述符轉移,使得 ROFF 在二進制文件變更期間可以保持現有連接不斷,進一步保障網關請求處理的穩定性。同時,ROFF 支持模塊狀態保留能力,在升級為新進程時可以獲得舊進程的插件狀態信息(如限流插件的統計信息等),以保證升級前后各模塊的狀態不丟失。
· 易于拓展的模塊開發:ROFF 以模塊開發作為設計理念,并充分發揮 Rust 宏的拓展能力,簡化用戶開發模塊的流程。同時我們借鑒 Gin/Koa 的洋蔥模型,將 HTTP 請求處理過程轉化為過濾器 HttpFilter 調用鏈的執行,并提供多達 30 余個過濾器供用戶自定義請求處理方式。
· 全面的可觀測性建設:ROFF 實現了請求全鏈路的日志和監控,同時,為了監控后端節點的健康狀態,健康檢查模塊支持返回網頁,可視化展示健康檢查結果。
· 不止網關:類似 Openresty 用 Lua 腳本語言擴展 Nginx。ROFF 將集成 Deno 庫,以實現基于V8 引擎的 JavaScript 運行時環境,為用戶提供強大的腳本拓展能力,復用整個 NPM/JSR 生態和完整的 JavaScript 能力,實現更加復雜和豐富的網關擴展。
03、我們做了什么
3.1 進程/線程模型
Nginx 采用主從 (Master-Worker) 多進程架構,Master 進程管理所有的 Worker 進程的生命周期,Worker 進程用以接收和處理請求。由于 Worker 進程擁有獨立的連接池和文件描述符表,因此,對于動態變配,健康檢查等需要進程間數據共享的場景時,只能依賴于進程間通信機制如 mmap 等。這不僅帶來了更大的通信開銷,更多的性能損失,也增加了開發和維護的復雜度。
ROFF 選擇了主從多線程的架構方案,如下圖所示,將啟動一個 Master 進程負責監聽程序的關閉、重啟、配置更新等信號,啟動一個 Worker 進程,其中 Worker 進程將運行多個 worker 線程以接收和處理請求。這種方式不僅減小了worker 線程間數據通信的成本,提高連接池連接復用率,也為熱重載和熱升級提供了便利。
- 主從雙進程模式:延續了 Nginx 對于平滑重啟和熱重載的設計精髓,使用 Master 進程管理 Worker 進程的生命周期。但與之不同的是,ROFF 中的 Master 進程成為了真正意義上永久運行的監控進程。Nginx 強依賴 fork 系統調用,使得主從進程的二進制文件必須保持一致,而升級服務需要重新創建一個新的 Master 進程并 fork 出多個 Worker 進程替換舊進程。在Roff的雙進程模式中,Master 進程采用 fork-then-exec 模型孵化 Worker 進程,其自身僅專注于 Worker 進程的管理和替換,不會對 Master進程產生影響,可以有效監聽舊進程的狀態。這套方案類似 Envoy 的熱重啟腳本 [1],ROFF 將這些功能編譯到一起,只需要配置項即可開箱使用,也作為緩存進程,在 reload 階段產生的多進程間共享數據。
- 多工作線程方案:不同于 Nginx 使用多個工作進程的方案,我們使用一個工作進程取而代之,并在這個工作進程中引入 main 線程與 worker 線程的概念。main 線程負責配置解析、動態變配、健康檢查等多個工作,并在需要時將變更的配置信息同步到其他 worker 線程,worker 線程則僅僅專注于請求的處理。main 線程在執行完初始化工作后,也會執行 worker 線程的工作,接收并處理請求,以此充分利用系統資源。
Master 進程作為“永久啟動”的監控進程,將以管理者和監控者身份對 Worker 進程進行生命周期管理、狀態監控和保存等操作,具備如下的能力:
- 熱重載/熱升級:當 Master 進程收到重載 SIGHUP 或者升級 SIGUSR2 的信號時,會根據新配置或新的二進制文件啟動新的 Worker 進程,并通知舊 Worker 進程退出。得益于 Master 進程的常駐,可以有效監聽新舊 Worker 進程的啟動和退出狀態,對于出錯的情況也能及時回退。
- Cache 共享:ROFF 將 Cache 存放到 Master 進程,Worker 進程啟動時從 Master 進程拉取緩存數據并周期性同步。
- 插件狀態保留:當 Worker 進程被替換后,該進程所記錄的一些插件狀態,例如限流數據等信息將會被清空。ROFF 提供 CacheHelper 功能,插件狀態將會被保留在 Master 進程中,并在新 Worker 進程啟動時下發給該進程以初始化。這種方式可以防止插件前后狀態不一致導致的嚴重錯誤。
- Unix 服務器:基于 Unix Socket 通信實現 HTTP 服務器,相比于使用信號控制或者進程間通信機制,Unix 服務器使進程間可以使用更加規范、可拓展且高效的通信和控制手段傳遞數據。
Worker 進程是真正處理請求的程序,在升級以及配置變更時,將會使用新的 Worker 進程替代舊進程,而 Master 進程不會有任何變化。為了進一步提升系統的性能,保護后端節點不會承載冗余的連接,我們又將工作線程拆分為 main 線程 和 worker 線程。我們將工作線程間共用的數據,例如監聽配置、后端節點信息、健康檢查信息等都交給 main 線程初始化和管理。當發生配置變更、服務發現新的后端節點、后端節點健康檢查信息更新等情況時,將由 main 線程把數據通過線程間通信機制同步到其余 worker 線程。具體而言,main 線程具備如下的能力:
- 初始化工作:在 main 線程啟動時,將解析配置文件,創建監聽配置并啟動多個 worker 線程。同時,將初始化各個模塊,支持模塊從控制面獲取必要的初始化信息。
- 動態變配:接收動態變配請求,并將重新初始化的配置信息同步到其余 worker 線程,如果發現初始化錯誤,也可以及時回滾,阻止錯誤的傳播。
- 健康檢查和服務發現:執行周期性健康檢查和服務發現任務,將獲取的最新的節點狀態和配置信息同步到其余 worker 線程。
- 監聽并處理請求:完成程序初始化工作后,main 線程也會執行 worker 線程的邏輯,即與其他 worker 線程一樣,接收并處理請求。
- Unix 服務器:main 線程在啟動時將運行一個 Unix 服務器用于與 Master 進程進行通信,并接收相應的控制信息。
3.2 熱重載/熱升級
對于網關來說,變更配置文件或者升級二進制文件是常態,對于線上的機器執行熱重載/熱升級等操作是不可避免的。為了避免影響線上服務的正常處理,在升級期間期望影響最小化,因此 Nginx 提供了熱重載操作,但是其也僅僅只能保證舊進程處理完現有連接后退出。這種方案依舊會導致連接的不穩定以及舊進程回收時間過長等問題。
因此,引入 Unix Domain Sockets (UDS) 方案進行文件描述符 File Descriptor (FD) 轉移,以增加系統的安全性和靈活性。每個使用 SO_REUSEPORT 的 socket 連接有自己單獨的監聽隊列。進程退出時,處于半打開 Half-Opened 的連接都會被關閉,從這些連接的客戶端視角看則是連接報錯,服務下線。而當監聽的連接 FD 被轉移到新進程后,新進程繼續持有 FD 的引用計數,即使舊進程的 FD 被釋放,連接還是會被新進程從監聽隊列里取出處理,一次保證連接不斷。
如下圖所示,舊進程可以有選擇的將處于監聽狀態的 FD 拷貝副本到新進程,新進程對比配置文件判斷哪些 FD 可以直接復用(效果等同于 pidfd_getfd 系統調用,但此方法有最低內核版本 5.6 要求)。進一步的,我們在 Master 進程和 Worker 進程均啟動了基于 Unix 的服務器,其在本地創建一個以“roff__unix_server_.socket” 文件作為通道的 HTTP 服務器。相比于使用系統信號控制進程,啟動 Unix 服務器可以在進程間傳遞更多的數據,支持更豐富的進程控制操作。
為了證明使用 FD 轉移進行熱重啟/熱升級的有效性,我們做了如下的對比實驗。使用同一配置文件啟動 Nginx 和 ROFF 服務,并使用相同的路由進行壓測,在壓測期間將進行多次的服務熱重啟。這里僅關注壓測報告中是否出現相關的 socket 錯誤:
- 左圖為 ROFF 的測試結果,在壓測期間并未產生任何的 socket 錯誤,所有請求都能在熱重啟期間正常的轉發和處理。
- 右圖為 Nginx 的測試結果,紅框標注了熱重啟期間產生的 read 和 timeout 錯誤。即使 Nginx 對舊工作進程有優雅退出的設計,允許其處理完現有連接主動斷連,但這也導致了頻繁重啟時連接不穩定以及舊進程回收時間長等問題。
結果表明 ROFF 在熱重載/熱升級階段使用 FD 轉移的能力,可以保證現有連接不斷,避免了現有連接丟失以及頻繁重新監聽的開銷等問題。
3.3 HTTP 請求處理
3.3.1 可拓展請求處理流程
下圖展示了 ROFF 中 Worker 進程請求處理以及轉發的流程,其中動態變配、服務發現以及健康檢查只在 main 線程中完成,通過線程間通信機制將數據同步到各個 worker 線程。得益于 Rust 的 trait 特性,我們將四層和七層處理分別抽象為了 InboundHandler 和 OutboundHandler 方法,以便于后續對多種協議的支持和擴展。在四層 TCP/UDP 的基礎上,提供多種協議(TCP、QUIC、Unix)的監聽方式。
圖中綠色加粗線條為 HTTP 請求的處理過程,請求到達時,將匹配 HttpInboudHandler 方法處理請求。在連接建立后,讀取請求頭中的 URL 和 Host 等信息以匹配合適的路由塊,以確定后續需要執行的模塊。OutboundHandler 方法定義了多種協議內容的處理方式,這里將匹配HttpOutboundHanler,以 HTTP 協議處理請求。
我們將 HTTP 協議的處理流程描述為 HttpFilter 調用鏈的形式,將請求處理過程根據不同的功能分為多個過濾器 Filter 方法。例如我們定義"pre_access_filter"為連接建立后讀取請求頭之前的第一個過濾器,"early_request_filter"為接收完請求頭后的第一個過濾器,等等。通過這種劃分方式,我們允許模塊在 HTTP 請求處理的不同過程中介入,以實現例如限流、trace 等能力。
3.3.2 靈活的HTTP過濾器調用鏈
在 HTTP 請求處理的過濾器調用鏈實現上,我們討論了如下的幾種現有方案,并結合各自的優缺點給出我們的方案:
- Nginx 的過濾器模型和 JavaScript-Koa、Golang-Gin洋蔥模型類似,通過 next 方法在編譯時確定為一個單向的調用鏈表。調用方可以在代碼的任意位置調用 next 方法,獲取返回結果并進行后續處理。洋蔥模型能提供更多細粒度控制能力。
- Envoy 采用 enum 返回狀態,對于每一個過濾器函數返回不同的狀態枚舉值來控制調用鏈的執行過程,是應該繼續還是停止。不能細粒度控制過濾器的執行順序。
- ROFF 本身的過濾器機制繼承自 Pingora,而后使用過程宏為每個過濾器生成新的函數簽名,添加 Next 函數參數,以此提供更加豐富的過濾器流程控制能力。
舉例來說,對于 request_filter 過濾器函數,在 Pingora 的函數原型如下所示,ROFF 為其添加了 Next 參數。
// Pingora
async fn request_filter(&self, session: &mut Session, ctx: &mut Self::CTX) -> Result<bool>;
// Roff
async fn request_filter(&self, session: &mut HttpServerSession, ctx: &mut RequestSession, next: RequestFilterNext<'_>) -> Result<bool>;
Next 參數給予我們操作流程的可能性,設計了如下的方法以支持自定義過濾器執行順序:
- "next#call().await":順序執行該過濾器的下一個鉤子。
- "next#ingore_call("B",...).await":忽略指定模塊 B 的該過濾器鉤子。
- "next#ingore_many_call(["B","C"],...).await":忽略多個模塊 B、C 的該過濾器鉤子。
- "next#call_to_end(...).await":忽略剩余的所有鉤子,直接執行該該過濾器的最后一個默認鉤子。
下圖展示了使用 Next 參數跳過指定模塊處理的案例,其中 keyless-tls 模塊需要讀取 builtin-tls 模塊的 TLS 證書/私鑰。由于 keyless-tls 模塊本身也具備 TLS 處理能力,和 builtin-tls 模塊的能力沖突,因此需要跳過 builtin-tls 模塊的該過濾器鉤子。圖中綠色線條為實際的處理流程受 next.call_ignore 控制,虛線線條為正常流程。
3.4 高效的模塊擴展
功能拓展是網關軟件不可避免的話題,不同的行業或業務需要針對特定的需求開發不同的額外功能。更低的開發成本,更安全的功能擴展,將決定了軟件生態能否健康長久的發展。Nginx 將自己的服務模塊化,當收到請求時,通過匹配配置文件中的相應路由確定需要執行的指令,然后依次執行指令所對應的模塊完成請求處理。這些模塊通常包括 Core 模塊、Events 模塊、HTTP 模塊、Stream 模塊和三方模塊如 Lua 模塊等。得益于這種模塊化架構,Nginx 搭建了豐富的生態系統,來自社區的拓展,例如緩存、壓縮、認證、流量統計等,拓寬了其應用場景。
和 NGINX 類似,ROFF 從兩個方面保證了用戶可以快速的進行功能的拓展:指令解析和模塊開發。
3.4.1 復雜指令解析
ROFF 具備強大的復雜指令解析能力,以及更加便捷的配置獲取,便于用戶在自定義模塊時為配置文件加入更多的指令。如下圖所示,我們將一條指令定義為包含參數 Arguments、屬性 Properties 以及孩子節點 Children 的 Directive 類型,其中的子指令可以再次嵌套指令以實現更加復雜的指令形式。利用 Rust 的泛型推斷能力,可以快速的將用戶配置轉化成 Directive 類型以供用戶解析和使用。而在 Nginx 中,對于復雜的多級嵌套塊需要頻繁調用ngx_conf_parse 方法解析配置。
在 ROFF 中,類似 Nginx 中的 http 塊、server 塊和 location 塊也可被視為多個 Directive 指令結構。以 http 塊為例,可以視為一個指令名稱為"http"的 Directive,其中嵌套的子指令即為 server 塊。在解析配置文件時,用戶只需要根據 ROFF 提供的指令名稱來判斷當前的指令是否為該模塊需要使用的配置,通過作用域信息來細粒度控制指令的作用范圍。
first_arg會自動推導類型并轉換,不需要 ngx_conf_set_str_slot。
#[any_conf]
struct Conf {
statsd_endpoint: Option<SocketAddr>,
prometheus_remote_write: Option<Url>,
prometheus_pushgateway: Option<Url>,
prometheus_addr: Option<SocketAddr>,
}
#[http_module]
#[async_trait(?Send)]
impl Module for StatsModule {
fn parse_directive(
&mut self,
ctx: &ParseContext,
cmd: &Directive,
_conf: &mut BoxAnyConf
) -> anyhow::Result<()> {
fn parse(cmd: &Directive, conf: &mut StatConf) -> anyhow::Result<()> {
match &*cmd.name {
"statsd_endpoint" => {
conf.statsd_endpoint = cmd.first_arg()?;
}
"prometheus_remote_write" => {
conf.prometheus_remote_write = cmd.first_arg()?;
}
"prometheus_pushgateway" => {
conf.prometheus_pushgateway = cmd.first_arg()?;
}
"prometheus_addr" => {
conf.prometheus_addr = cmd.first_arg()?;
}
_ => {}
}
Ok(())
}
match &**ctx {
// TOP_BLOCK/HTTP_MAIN/HTTP_SRV/HTTP_LOC/HTTP_UPSTREAM
ConfLevel::Top => {
parse(cmd, &mut self.conf)?;
}
_ => {}
}
Ok(())
}
}
3.4.2 模塊系統
Nginx 將請求的處理過程劃分為了 11 個階段,不同階段又定義了不同的模塊,其中有七個階段開放給用戶的自定義模塊介入。但是大部分自定義模塊都專注于 NGX_HTTP_CONTENT_PHASE 擴展。Nginx 中需要手動通過全局的 ngx_http_top/next_foo_filter 串聯自定義的過濾器。
// set next filter
ngx_http_next_body_filter = ngx_http_top_body_filter;
// replace top body filter to our filter
ngx_http_top_body_filter = ngx_http_helloworld_response_body_filter;
ROFF 吸收了這種模塊化和使用過濾器鏈式處理內容的思想,不再單獨區分請求處理的多個階段,而是將全部過濾器都集成到 HttpFilter 中,累計 31 個過濾器供用戶自定義模塊介入。從連接的建立,到請求處理、再至響應的返回,用戶只需要明確在哪個過濾器插槽中實現自定義功能即可。下圖展示了 Nginx 與 ROFF 在處理流程上劃分的差異,ROFF 只展示了主要的幾個流程。
用戶將自定義的 listen_accepted 操作插入處理流程中的示例:
// insert a listen_accepted
ctx.filter.hook_listen_accepted.insert(Self::name(), Rc::new(self.clone()));
下面給出一個創建"example"指令的案例,該指令將用戶的請求直接返回"Hello World"信息。左圖為 Nginx 中實現的邏輯,需要定義模塊的指令設置,配置解析流程,模塊上下文等等,其中充斥著函數指針和內存的操作,這無疑增大了安全隱患。右圖為 ROFF 中開發模塊的流程,我們提供了模塊的基本實現,用戶只需要關心核心邏輯即可。其中指令解析部分,暴露給用戶更加直觀的處理方式,不需要考慮配置偏移等與內存打交道的設置。在對 Filter 過濾器開發時,只對 request_filter 插槽做實現即可完成介入。借助 Rust 的 async/await,ROFF 中大量 hook 原生支持 async,可以將請求轉發到其他控制面實現復雜邏輯。
3.5 Keyless-TLS 硬件卸載
在互聯網中,為了提高信息傳遞的安全性,通常使用 SSL/TLS 對 HTTP 協議的數據內容進行加密,也就是所謂的 HTTPS 協議。在使用 HTTPS 協議通信時,除了需要建立 TCP 連接、發送報文外,還需要進行 SSL 通信過程。這個額外的通信過程需要對傳輸的數據進行加密和解密計算操作,這是一項計算密集型任務,會大量消耗服務器的計算資源。因此,通常在網絡架構中,使用專用設備進行 SSL/TLS 的加解密過程,并將解密后的流量轉發給后端服務器。這種 SSL 卸載的方式可以減輕內網服務器的負擔,加快網絡通信速度,同時統一的證書管理和加密設置,可以使得網站具有更強的安全特性和防護措施。
SSL 卸載主要分為硬件卸載和軟件卸載,硬件卸載專注于使用 QAT 等加速卡來進行加解密計算,軟件卸載則使用服務器自身的計算資源進行操作。典型的軟件卸載案例則是使用 Nginx 作為負載均衡服務器,并將所有的 HTTPS 流量在本機卸載為 HTTP 流量后轉發給后端服務器。如下圖所示,我們對 Nginx 使用軟卸載的方式進行了基準測試,當使用 HTTPS 訪問服務器時,所能承載的 QPS 相比使用 HTTP 訪問時下降了一半左右。
因此,我們也在探索使用硬件卸載方案,加快 TLS 握手速度,降低 CPU 占用,從整體角度降本增效。同時,我們也需要支持軟件卸載的能力,以此作為硬件卸載的兜底方案。OpenSSL 是一個功能全面歷史悠久的用于處理 SSL/TLS 加解密庫,但是其結構復雜龐大,跨平臺開發難度大,深度定制成本高。在 ROFF 中選擇使用 Rustls 庫作為 OpenSSL 庫的替代,其在性能和內存使用上都優于后者。
在硬件卸載方面,我們將 SSL 通信過程中最為耗時的非對稱加密操作卸載到硬件加速卡上,也即是業界采用的 Keyless 硬件卸載方案。
基于 Rust 天然內存安全的特性并且還能保持與 C/C++ 語言同樣的性能優勢,我們開發了基于 ROFF 的 TLS 硬件卸載方案,重寫 Rustls 部分函數實現 TLS-QAT 的硬件卸載加速,Keyless 卸載失敗能自動切換到 OpenSSL 兜底卸載部分 TLS 流量,以保障服務的可靠性和穩定性。
3.6 解決長尾延遲問題
長尾延遲( long-tail latency )是指重計算的情況下核心任務不均勻導致部分請求積壓,請求延遲上漲。解決長尾延遲普遍采用 tokio 的 multiple worker 運行時,一些共享變量需要額外的 Sync 約束,進而引入過多互斥鎖開銷。
采用 Thread Per Core 能避免開銷但不得不引入長尾延遲。ROFF 的核心邏輯重 IO 轉發不會積壓請求,可能存在重計算的邏輯都在模塊。
為解決上述兩個問題,ROFF 除了自身的 worker 線程,還會額外創建 tokio 的 work-stealing 運行時供模塊選擇性優化。
/// Thread per core model causes long-tail latency. gateway just a IO forwarder, there no compute intensive task in core function except third party modules.
///
/// We start another tokio work stealing scheduler here, module should submit compute intensive task to this multiple thread runtime
///
/// **Don't call tokio::spawn directly, it will spawn on current thread scheduler.**
///
/// See https://blog.cloudflare.com/keepalives-considered-harmful/
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{
if let Some(h) = HANDLE.get() {
h.spawn(future)
} else {
log::warn!("spawn task on current thread scheduler, which maybe not your intention");
tokio::spawn(future)
}
}
3.7 HTTP3/QUIC 支持
ROFF 使用 Pingora 構建 HTTP 處理能力,但目前 Pingora 不支持 HTTP/3 能力 [2]。小紅書大量流量依賴 HTTP/3,于是我們為 ROFF 開發了 HTTP/3 能力。當前 Rust 的 QUIC/HTTP3 生態:
- Quiche: QUIC+HTTP/3
- s2n-quic/quinn: QUIC
- h3: HTTP/3
- libnghttp3-dev: HTTP/3
我們為 Quiche 開發了 tokio 支持,但使用發現 Quiche 與 OpenSSL/BoringSSL 強綁定關系,ROFF 的 ssl_backend: "rustls" 指令需要支持 rustls,最后放棄了Quiche。h3 庫處于早期版本不建議生產使用。為了能承接小紅書的核心流量不出問題,ROFF 為 nghttp3 C 庫開發了 Rust 的 FFI 和 tokio 異步支持。借助 nghttp3 [3],我們獲得了能穩定用于線上的 Rust HTTP/3 庫,最終 HTTP/3 協議棧如下:
3.8 配置文件
最初支持 KDL/JSON/YAML,隨著功能越來越多指令越來越復雜,JSON/YAML無法對齊KDL的表達能力,反而帶來配置膨脹,難以閱讀的問題。
為了能減少 Nginx 開發者的理解成本,ROFF 只采用 KDL 文件格式并兼容大多數 Nginx 指令和變量。格式如下:
// config.kdl
master_process true
statsd_endpoint "127.0.0.1:9125"
http {
proxy_read_timeout "60s"
ssl_backend "rustls"
ssl_certificate "../certificates/www.example.org.full.cert.pem"
ssl_certificate_key "../certificates/www.example.org.key.pem"
include "./upstream.kdl"
include "./servers.kdl"
server {
listen "tcp://0.0.0.0:443" default-server=true
listen "tcp://[::]:443" default-server=true
listen "udp://0.0.0.0:443" default-server=true
listen "udp://[::]:443" default-server=true
http2 true
ssl true
http3 "on"
server_name "_"
add_header "Access-Control-Allow-Methods" "GET, PUT, OPTIONS, POST, DELETE"
location "/" {
proxy_pass "http://backend"
}
location "/header" {
add_header "Remote-Addr" "$remote_addr"
proxy_set_header "URI" "$request_uri"
return 200
}
}
}
04、總結與展望
ROFF 已經完成了開發以及系統性測試,經過與 Nginx 的對比實驗驗證了其在性能上的優勢。目前已經在小紅書如下場景灰度運行中:
- 已在自建機房灰度并承接了主站的核心流量。得益于 Rust 語言嚴格的靜態檢查,作為一個全新的項目,自上線以來無一例內存安全問題。
- ROFF 已經支持容器化部署并作為自建機房對象存儲的接入網關,承接了所有對象存儲的入口流量。
- 相比于云廠商七層 LB,自建網關 ROFF 成本能夠降低 80%左右。
4.1 性能對比
與此同時,我們建立了自動化壓測工具,對每次的升級迭代都與 Nginx 進行及時的性能對比。
我們在同一臺機器上分別運行 ROFF 以及 Nginx,并使用相同的配置文件進行壓測實驗,實驗結果如下圖所示。其中,左圖為 HTTP 測試結果,右圖為 HTTPS 測試結果,結果表明在相同環境下,ROFF 所能承接的流量與 Nginx 基本無異(注意Y軸數值)。
ROFF 大量使用范型、動態分發、async 語法等現代語法特性,且性能和 Nginx 相當,原因有幾點:
- C 語言和 Rust 語言性能同一梯隊,語言能提供給編譯器的信息越多,編譯器能做更多的優化。由于 Rust 語法要求更嚴格,給編譯器提供的信息更多,編譯器能做比 C/C++ 更激進的優化。最近一個很熱門的話題 [4]。
- Rust 自帶 Cargo 包管理,最大限度復用社區已有生態。比如 Nginx 使用 C 語言實現的 HTTP 解析器,而 Rust 解析庫 httparse [5] 已經用上了 SIMD。
- 開發過程中借鑒了很多 Nginx 的設計,不斷的壓測找到薄弱點。
總的來說,ROFF 的線程模型更像 Envoy,架構/模塊設計更像 Nginx。
4.2 未來規劃
后續會持續灰度公網 HTTP/3,實現更多 Nginx 的指令并開源。集成 Deno 庫,實現類似 OpenResty 的擴展能力。
基于Deno擴展網關邊界
即使 Nginx 有如此豐富的社區以及高度可自定義的模塊開發,但是由于其開發成本大,一部分受眾將目光轉向了 Nginx+Lua 的模式。因此,誕生了 OpenResty。用戶可以直接使用 Lua 腳本動態擴展功能而無需重新編譯服務器。目前主要應用在API網關等場景中。LuaJIT 所提供的高效的腳本執行能力,使得網關更容易處理動態內容以及更加復雜的邏輯,并且 Lua 開發更加靈活。但是,使用 Lua 作為 Nginx的擴展腳本可能也是無奈之舉,在腳本語言中 JavaScript 的強大擴展能力以及更豐富的社區生態 [6],更適用于 Web 服務的建設。由 Google 開源的 V8 引擎已經具備了極強的性能。
后續 ROFF 將考慮集成 Deno 庫:Rust 開發的高效安全的 JavaScript 運行時環境,使得 ROFF 可以使用 JavaScript 腳本極大的擴展網關的邊界能力。之所以選擇 Deno 庫,主要是其具備如下的優點:
- 由 Rust 編寫,ROFF 與 Deno 的 rust-runtime 零開銷調用。
- 基于 V8 引擎的 JavaScript 運行時相比 Lua 具備超強的性能。
- JavaScript 的單線程 EventLoop 非常契合我們的線程模型,每個 worker 線程都能運行 JavaScrip t的 EventLoop。
- 復用整個 JavaScript 的繁榮生態,不再編寫精簡版的 JavaScript,而是能使用任何 NPM 包。
- Deno 庫不依賴 node_modules,直接嵌入到二進制。
4.3 與 Pingora 的關系
非常感謝 Pingora 提供了與 Nginx 類似的框架能力。
ROFF 最初將 Pingora 作為 Cargo 依賴使用,好處是更新 Pingora 版本就可以享受到上游的新特性或是 bug-fix。但是在開發過程中,遇到了一些問題使得 ROFF 不得不自行維護部分 Pingora 代碼。
- Pingora模塊在過濾器之后。先有了不同的過濾器,再注冊模塊,但模塊只是多了幾個 hook 點,并不支持定義指令解析、HTTP 變量等能力、線程啟動退出Hook。當然這也是因為 Pingora 定位是 HTTP 框架,而不是完整的網關程序 [7]。
- ROFF 使用 Thread Per Core 的線程模型配合 Thread Local 存放數據,只有很少的地方有資源競爭。Pingora 默認 tokio work-stealing,很多位置需要 Send + Sync 約束。
- 開發過程中碰到了很多問題,只能 hack 繞過。導致 ROFF 維護了大量與 Pingora 的膠水代碼。久而久之造成 ROFF 自有代碼和 Pingora 的嚴重割裂(比如 tcp_connect,ROFF 和 Pingora 都維護一套)。所以在一次重構中,我們 fork 了其 HTTP 轉發邏輯,大量重寫確保 Pingora 和 ROFF 的架構契合度。
- Pingora 不支持半途終止 HttpFilter 迭代的特性,部分模塊依賴 [8]。
- 灰度流量時線上代碼死循環,等不及上游修復 [9]。
- 很多功能會對上游引入 breaking change。Pingora 沒有對外暴露 keepalive_timeout、proxy_read_timeout等指令。
- Pingora 的 HTTP/3 支持緩慢。對小紅書來說 HTTP/3 是剛需,不可能等到上游實現,只能對 Pingora 進行修改。
05、作者簡介
軒宇(姚劍鵬)
接入網關方向負責人。
蕭炎(吳偉超)
接入網關方向研發。
露卡(陸于洋)
接入網關方向研發。
長恭(陳凱)
接入網關方向研發。
06、參考文獻
- Envoy hot restart script:
https://github.com/envoyproxy/envoy/blob/main/restarter/hot-restarter.py - Pingora HTTP3/QUIC Support:
https://github.com/cloudflare/pingora/issues/95 - Are we have Rust bindings?
https://github.com/ngtcp2/nghttp3/issues/281 - 如何看待 Rust 寫的 PNG 解碼器比 C 實現更快?
https://www.zhihu.com/question/6568018545/answer/55004999007、https://www.zhihu.com/question/6568018545/answer/55165637634 - Rust http parse:
https://github.com/seanmonstar/httparse/blob/master/src/simd/mod.rs - 為什么選擇 javascript 而不是 lua:
https://www.zhihu.com/question/395593519/answer/2738722877 - Pingora is Not an Nginx Replacement:
https://navendu.me/posts/pingora/ - Is it possible to terminate iteration early in HttpModule:
https://github.com/cloudflare/pingora/issues/491#issuecomment-2556159561 - Pingora bug:
https://github.com/cloudflare/pingora/issues/475