小紅書推出自研Rust高性能七層網(wǎng)關(guān)ROFF
01、背景
隨著小紅書自建機(jī)房的逐步完善和上量,亟需對(duì)標(biāo)各大云廠商所提供的 TLS 軟硬件卸載、負(fù)載均衡,QUIC/HTTPS 等能力。小紅書接入層團(tuán)隊(duì)自研高性能網(wǎng)關(guān) ROFF,基于 Rust 語(yǔ)言實(shí)現(xiàn)了 Keyless TLS 硬件加速,支持更豐富的負(fù)載均衡類型、服務(wù)發(fā)現(xiàn)、動(dòng)態(tài)變配,模塊插件拓展,保障小紅書自建場(chǎng)景接入能力的高效、穩(wěn)定運(yùn)行。
02、為什么選型 Rust 語(yǔ)言
ROFF 網(wǎng)關(guān)基于 Rust 語(yǔ)言開發(fā)完成,該語(yǔ)言具備不劣于 C/C++ 語(yǔ)言的性能以及極低的內(nèi)存占用,并且以內(nèi)存安全著稱,奠定了網(wǎng)關(guān)絕對(duì)穩(wěn)定的基礎(chǔ)。Rust 語(yǔ)言已經(jīng)運(yùn)用于 Android 和 Fuchsia OS 等場(chǎng)景,并在 Android 的Rust 代碼中發(fā)現(xiàn)的內(nèi)存安全漏洞為零。
目前,ROFF 網(wǎng)關(guān)已經(jīng)在小紅書機(jī)房承接了主站的核心流量,上線至今無(wú)一次線上崩潰事故發(fā)生。其整體架構(gòu)如下圖所示,具備下列特性:
· 內(nèi)存安全與高性能:安全與性能的考量是內(nèi)存管理亙古的話題,C/C++ 語(yǔ)言將內(nèi)存交給程序員管理而引入不安全的代碼和資源的浪費(fèi),Java 等語(yǔ)言使用垃圾回收機(jī)制減輕程序員的負(fù)擔(dān)卻又帶來(lái)了性能上的損耗。不同以往,Rust 語(yǔ)言使用所有權(quán)和借用機(jī)制對(duì)資源進(jìn)行管理,嚴(yán)格要求一塊內(nèi)存在同一時(shí)刻只能被一個(gè)變量擁有所有權(quán),在編譯階段即可以保證內(nèi)存安全和并發(fā)安全。同時(shí),其零成本抽象的能力讓程序員可以使用高級(jí)編程概念(如泛型、模版、集合等)時(shí)不會(huì)增加運(yùn)行時(shí)開銷,而僅僅是增加編譯成本。
· 豐富的代理能力:ROFF 支持多種類型的負(fù)載均衡方式,并對(duì)后端節(jié)點(diǎn)進(jìn)行主動(dòng)健康檢查,及時(shí)摘除亞健康節(jié)點(diǎn)保持請(qǐng)求轉(zhuǎn)發(fā)的健康性。通過(guò)連接復(fù)用和主線程檢查從線程同步的方式,保證健康檢查任務(wù)不影響請(qǐng)求的處理,從而保障網(wǎng)關(guān)的性能。同時(shí),考慮到云原生的應(yīng)用場(chǎng)景,我們還內(nèi)置了 EDS 服務(wù)發(fā)現(xiàn)的 Discovery 能力,不再需要旁路部署服務(wù)發(fā)現(xiàn)組件。
· TLS 硬件卸載加速:ROFF 結(jié)合自建機(jī)房的情況,深度定制 Rustls 庫(kù)實(shí)現(xiàn) TLS 的 Keyless 硬件卸載方案提高卸載速度,大大提升了 HTTPS 的處理能力。
· 更穩(wěn)定的熱重載和熱升級(jí):ROFF 支持動(dòng)態(tài)變配和熱重載兩種方式進(jìn)行配置變更。動(dòng)態(tài)變配支持無(wú)需重啟服務(wù),就可以無(wú)損更新現(xiàn)有模塊配置。熱重載和熱升級(jí)支持程序運(yùn)行時(shí)自動(dòng)替換現(xiàn)有的工作進(jìn)程。我們基于 Unix Domain Sockets (UDS) 實(shí)現(xiàn)的文件描述符轉(zhuǎn)移,使得 ROFF 在二進(jìn)制文件變更期間可以保持現(xiàn)有連接不斷,進(jìn)一步保障網(wǎng)關(guān)請(qǐng)求處理的穩(wěn)定性。同時(shí),ROFF 支持模塊狀態(tài)保留能力,在升級(jí)為新進(jìn)程時(shí)可以獲得舊進(jìn)程的插件狀態(tài)信息(如限流插件的統(tǒng)計(jì)信息等),以保證升級(jí)前后各模塊的狀態(tài)不丟失。
· 易于拓展的模塊開發(fā):ROFF 以模塊開發(fā)作為設(shè)計(jì)理念,并充分發(fā)揮 Rust 宏的拓展能力,簡(jiǎn)化用戶開發(fā)模塊的流程。同時(shí)我們借鑒 Gin/Koa 的洋蔥模型,將 HTTP 請(qǐng)求處理過(guò)程轉(zhuǎn)化為過(guò)濾器 HttpFilter 調(diào)用鏈的執(zhí)行,并提供多達(dá) 30 余個(gè)過(guò)濾器供用戶自定義請(qǐng)求處理方式。
· 全面的可觀測(cè)性建設(shè):ROFF 實(shí)現(xiàn)了請(qǐng)求全鏈路的日志和監(jiān)控,同時(shí),為了監(jiān)控后端節(jié)點(diǎn)的健康狀態(tài),健康檢查模塊支持返回網(wǎng)頁(yè),可視化展示健康檢查結(jié)果。
· 不止網(wǎng)關(guān):類似 Openresty 用 Lua 腳本語(yǔ)言擴(kuò)展 Nginx。ROFF 將集成 Deno 庫(kù),以實(shí)現(xiàn)基于V8 引擎的 JavaScript 運(yùn)行時(shí)環(huán)境,為用戶提供強(qiáng)大的腳本拓展能力,復(fù)用整個(gè) NPM/JSR 生態(tài)和完整的 JavaScript 能力,實(shí)現(xiàn)更加復(fù)雜和豐富的網(wǎng)關(guān)擴(kuò)展。
03、我們做了什么
3.1 進(jìn)程/線程模型
Nginx 采用主從 (Master-Worker) 多進(jìn)程架構(gòu),Master 進(jìn)程管理所有的 Worker 進(jìn)程的生命周期,Worker 進(jìn)程用以接收和處理請(qǐng)求。由于 Worker 進(jìn)程擁有獨(dú)立的連接池和文件描述符表,因此,對(duì)于動(dòng)態(tài)變配,健康檢查等需要進(jìn)程間數(shù)據(jù)共享的場(chǎng)景時(shí),只能依賴于進(jìn)程間通信機(jī)制如 mmap 等。這不僅帶來(lái)了更大的通信開銷,更多的性能損失,也增加了開發(fā)和維護(hù)的復(fù)雜度。
ROFF 選擇了主從多線程的架構(gòu)方案,如下圖所示,將啟動(dòng)一個(gè) Master 進(jìn)程負(fù)責(zé)監(jiān)聽程序的關(guān)閉、重啟、配置更新等信號(hào),啟動(dòng)一個(gè) Worker 進(jìn)程,其中 Worker 進(jìn)程將運(yùn)行多個(gè) worker 線程以接收和處理請(qǐng)求。這種方式不僅減小了worker 線程間數(shù)據(jù)通信的成本,提高連接池連接復(fù)用率,也為熱重載和熱升級(jí)提供了便利。
- 主從雙進(jìn)程模式:延續(xù)了 Nginx 對(duì)于平滑重啟和熱重載的設(shè)計(jì)精髓,使用 Master 進(jìn)程管理 Worker 進(jìn)程的生命周期。但與之不同的是,ROFF 中的 Master 進(jìn)程成為了真正意義上永久運(yùn)行的監(jiān)控進(jìn)程。Nginx 強(qiáng)依賴 fork 系統(tǒng)調(diào)用,使得主從進(jìn)程的二進(jìn)制文件必須保持一致,而升級(jí)服務(wù)需要重新創(chuàng)建一個(gè)新的 Master 進(jìn)程并 fork 出多個(gè) Worker 進(jìn)程替換舊進(jìn)程。在Roff的雙進(jìn)程模式中,Master 進(jìn)程采用 fork-then-exec 模型孵化 Worker 進(jìn)程,其自身僅專注于 Worker 進(jìn)程的管理和替換,不會(huì)對(duì) Master進(jìn)程產(chǎn)生影響,可以有效監(jiān)聽舊進(jìn)程的狀態(tài)。這套方案類似 Envoy 的熱重啟腳本 [1],ROFF 將這些功能編譯到一起,只需要配置項(xiàng)即可開箱使用,也作為緩存進(jìn)程,在 reload 階段產(chǎn)生的多進(jìn)程間共享數(shù)據(jù)。
- 多工作線程方案:不同于 Nginx 使用多個(gè)工作進(jìn)程的方案,我們使用一個(gè)工作進(jìn)程取而代之,并在這個(gè)工作進(jìn)程中引入 main 線程與 worker 線程的概念。main 線程負(fù)責(zé)配置解析、動(dòng)態(tài)變配、健康檢查等多個(gè)工作,并在需要時(shí)將變更的配置信息同步到其他 worker 線程,worker 線程則僅僅專注于請(qǐng)求的處理。main 線程在執(zhí)行完初始化工作后,也會(huì)執(zhí)行 worker 線程的工作,接收并處理請(qǐng)求,以此充分利用系統(tǒng)資源。
Master 進(jìn)程作為“永久啟動(dòng)”的監(jiān)控進(jìn)程,將以管理者和監(jiān)控者身份對(duì) Worker 進(jìn)程進(jìn)行生命周期管理、狀態(tài)監(jiān)控和保存等操作,具備如下的能力:
- 熱重載/熱升級(jí):當(dāng) Master 進(jìn)程收到重載 SIGHUP 或者升級(jí) SIGUSR2 的信號(hào)時(shí),會(huì)根據(jù)新配置或新的二進(jìn)制文件啟動(dòng)新的 Worker 進(jìn)程,并通知舊 Worker 進(jìn)程退出。得益于 Master 進(jìn)程的常駐,可以有效監(jiān)聽新舊 Worker 進(jìn)程的啟動(dòng)和退出狀態(tài),對(duì)于出錯(cuò)的情況也能及時(shí)回退。
- Cache 共享:ROFF 將 Cache 存放到 Master 進(jìn)程,Worker 進(jìn)程啟動(dòng)時(shí)從 Master 進(jìn)程拉取緩存數(shù)據(jù)并周期性同步。
- 插件狀態(tài)保留:當(dāng) Worker 進(jìn)程被替換后,該進(jìn)程所記錄的一些插件狀態(tài),例如限流數(shù)據(jù)等信息將會(huì)被清空。ROFF 提供 CacheHelper 功能,插件狀態(tài)將會(huì)被保留在 Master 進(jìn)程中,并在新 Worker 進(jìn)程啟動(dòng)時(shí)下發(fā)給該進(jìn)程以初始化。這種方式可以防止插件前后狀態(tài)不一致導(dǎo)致的嚴(yán)重錯(cuò)誤。
- Unix 服務(wù)器:基于 Unix Socket 通信實(shí)現(xiàn) HTTP 服務(wù)器,相比于使用信號(hào)控制或者進(jìn)程間通信機(jī)制,Unix 服務(wù)器使進(jìn)程間可以使用更加規(guī)范、可拓展且高效的通信和控制手段傳遞數(shù)據(jù)。
Worker 進(jìn)程是真正處理請(qǐng)求的程序,在升級(jí)以及配置變更時(shí),將會(huì)使用新的 Worker 進(jìn)程替代舊進(jìn)程,而 Master 進(jìn)程不會(huì)有任何變化。為了進(jìn)一步提升系統(tǒng)的性能,保護(hù)后端節(jié)點(diǎn)不會(huì)承載冗余的連接,我們又將工作線程拆分為 main 線程 和 worker 線程。我們將工作線程間共用的數(shù)據(jù),例如監(jiān)聽配置、后端節(jié)點(diǎn)信息、健康檢查信息等都交給 main 線程初始化和管理。當(dāng)發(fā)生配置變更、服務(wù)發(fā)現(xiàn)新的后端節(jié)點(diǎn)、后端節(jié)點(diǎn)健康檢查信息更新等情況時(shí),將由 main 線程把數(shù)據(jù)通過(guò)線程間通信機(jī)制同步到其余 worker 線程。具體而言,main 線程具備如下的能力:
- 初始化工作:在 main 線程啟動(dòng)時(shí),將解析配置文件,創(chuàng)建監(jiān)聽配置并啟動(dòng)多個(gè) worker 線程。同時(shí),將初始化各個(gè)模塊,支持模塊從控制面獲取必要的初始化信息。
- 動(dòng)態(tài)變配:接收動(dòng)態(tài)變配請(qǐng)求,并將重新初始化的配置信息同步到其余 worker 線程,如果發(fā)現(xiàn)初始化錯(cuò)誤,也可以及時(shí)回滾,阻止錯(cuò)誤的傳播。
- 健康檢查和服務(wù)發(fā)現(xiàn):執(zhí)行周期性健康檢查和服務(wù)發(fā)現(xiàn)任務(wù),將獲取的最新的節(jié)點(diǎn)狀態(tài)和配置信息同步到其余 worker 線程。
- 監(jiān)聽并處理請(qǐng)求:完成程序初始化工作后,main 線程也會(huì)執(zhí)行 worker 線程的邏輯,即與其他 worker 線程一樣,接收并處理請(qǐng)求。
- Unix 服務(wù)器:main 線程在啟動(dòng)時(shí)將運(yùn)行一個(gè) Unix 服務(wù)器用于與 Master 進(jìn)程進(jìn)行通信,并接收相應(yīng)的控制信息。
3.2 熱重載/熱升級(jí)
對(duì)于網(wǎng)關(guān)來(lái)說(shuō),變更配置文件或者升級(jí)二進(jìn)制文件是常態(tài),對(duì)于線上的機(jī)器執(zhí)行熱重載/熱升級(jí)等操作是不可避免的。為了避免影響線上服務(wù)的正常處理,在升級(jí)期間期望影響最小化,因此 Nginx 提供了熱重載操作,但是其也僅僅只能保證舊進(jìn)程處理完現(xiàn)有連接后退出。這種方案依舊會(huì)導(dǎo)致連接的不穩(wěn)定以及舊進(jìn)程回收時(shí)間過(guò)長(zhǎng)等問(wèn)題。
因此,引入 Unix Domain Sockets (UDS) 方案進(jìn)行文件描述符 File Descriptor (FD) 轉(zhuǎn)移,以增加系統(tǒng)的安全性和靈活性。每個(gè)使用 SO_REUSEPORT 的 socket 連接有自己?jiǎn)为?dú)的監(jiān)聽隊(duì)列。進(jìn)程退出時(shí),處于半打開 Half-Opened 的連接都會(huì)被關(guān)閉,從這些連接的客戶端視角看則是連接報(bào)錯(cuò),服務(wù)下線。而當(dāng)監(jiān)聽的連接 FD 被轉(zhuǎn)移到新進(jìn)程后,新進(jìn)程繼續(xù)持有 FD 的引用計(jì)數(shù),即使舊進(jìn)程的 FD 被釋放,連接還是會(huì)被新進(jìn)程從監(jiān)聽隊(duì)列里取出處理,一次保證連接不斷。
如下圖所示,舊進(jìn)程可以有選擇的將處于監(jiān)聽狀態(tài)的 FD 拷貝副本到新進(jìn)程,新進(jìn)程對(duì)比配置文件判斷哪些 FD 可以直接復(fù)用(效果等同于 pidfd_getfd 系統(tǒng)調(diào)用,但此方法有最低內(nèi)核版本 5.6 要求)。進(jìn)一步的,我們?cè)?Master 進(jìn)程和 Worker 進(jìn)程均啟動(dòng)了基于 Unix 的服務(wù)器,其在本地創(chuàng)建一個(gè)以“roff__unix_server_.socket” 文件作為通道的 HTTP 服務(wù)器。相比于使用系統(tǒng)信號(hào)控制進(jìn)程,啟動(dòng) Unix 服務(wù)器可以在進(jìn)程間傳遞更多的數(shù)據(jù),支持更豐富的進(jìn)程控制操作。
為了證明使用 FD 轉(zhuǎn)移進(jìn)行熱重啟/熱升級(jí)的有效性,我們做了如下的對(duì)比實(shí)驗(yàn)。使用同一配置文件啟動(dòng) Nginx 和 ROFF 服務(wù),并使用相同的路由進(jìn)行壓測(cè),在壓測(cè)期間將進(jìn)行多次的服務(wù)熱重啟。這里僅關(guān)注壓測(cè)報(bào)告中是否出現(xiàn)相關(guān)的 socket 錯(cuò)誤:
- 左圖為 ROFF 的測(cè)試結(jié)果,在壓測(cè)期間并未產(chǎn)生任何的 socket 錯(cuò)誤,所有請(qǐng)求都能在熱重啟期間正常的轉(zhuǎn)發(fā)和處理。
- 右圖為 Nginx 的測(cè)試結(jié)果,紅框標(biāo)注了熱重啟期間產(chǎn)生的 read 和 timeout 錯(cuò)誤。即使 Nginx 對(duì)舊工作進(jìn)程有優(yōu)雅退出的設(shè)計(jì),允許其處理完現(xiàn)有連接主動(dòng)斷連,但這也導(dǎo)致了頻繁重啟時(shí)連接不穩(wěn)定以及舊進(jìn)程回收時(shí)間長(zhǎng)等問(wèn)題。
結(jié)果表明 ROFF 在熱重載/熱升級(jí)階段使用 FD 轉(zhuǎn)移的能力,可以保證現(xiàn)有連接不斷,避免了現(xiàn)有連接丟失以及頻繁重新監(jiān)聽的開銷等問(wèn)題。
3.3 HTTP 請(qǐng)求處理
3.3.1 可拓展請(qǐng)求處理流程
下圖展示了 ROFF 中 Worker 進(jìn)程請(qǐng)求處理以及轉(zhuǎn)發(fā)的流程,其中動(dòng)態(tài)變配、服務(wù)發(fā)現(xiàn)以及健康檢查只在 main 線程中完成,通過(guò)線程間通信機(jī)制將數(shù)據(jù)同步到各個(gè) worker 線程。得益于 Rust 的 trait 特性,我們將四層和七層處理分別抽象為了 InboundHandler 和 OutboundHandler 方法,以便于后續(xù)對(duì)多種協(xié)議的支持和擴(kuò)展。在四層 TCP/UDP 的基礎(chǔ)上,提供多種協(xié)議(TCP、QUIC、Unix)的監(jiān)聽方式。
圖中綠色加粗線條為 HTTP 請(qǐng)求的處理過(guò)程,請(qǐng)求到達(dá)時(shí),將匹配 HttpInboudHandler 方法處理請(qǐng)求。在連接建立后,讀取請(qǐng)求頭中的 URL 和 Host 等信息以匹配合適的路由塊,以確定后續(xù)需要執(zhí)行的模塊。OutboundHandler 方法定義了多種協(xié)議內(nèi)容的處理方式,這里將匹配HttpOutboundHanler,以 HTTP 協(xié)議處理請(qǐng)求。
我們將 HTTP 協(xié)議的處理流程描述為 HttpFilter 調(diào)用鏈的形式,將請(qǐng)求處理過(guò)程根據(jù)不同的功能分為多個(gè)過(guò)濾器 Filter 方法。例如我們定義"pre_access_filter"為連接建立后讀取請(qǐng)求頭之前的第一個(gè)過(guò)濾器,"early_request_filter"為接收完請(qǐng)求頭后的第一個(gè)過(guò)濾器,等等。通過(guò)這種劃分方式,我們?cè)试S模塊在 HTTP 請(qǐng)求處理的不同過(guò)程中介入,以實(shí)現(xiàn)例如限流、trace 等能力。
3.3.2 靈活的HTTP過(guò)濾器調(diào)用鏈
在 HTTP 請(qǐng)求處理的過(guò)濾器調(diào)用鏈實(shí)現(xiàn)上,我們討論了如下的幾種現(xiàn)有方案,并結(jié)合各自的優(yōu)缺點(diǎn)給出我們的方案:
- Nginx 的過(guò)濾器模型和 JavaScript-Koa、Golang-Gin洋蔥模型類似,通過(guò) next 方法在編譯時(shí)確定為一個(gè)單向的調(diào)用鏈表。調(diào)用方可以在代碼的任意位置調(diào)用 next 方法,獲取返回結(jié)果并進(jìn)行后續(xù)處理。洋蔥模型能提供更多細(xì)粒度控制能力。
- Envoy 采用 enum 返回狀態(tài),對(duì)于每一個(gè)過(guò)濾器函數(shù)返回不同的狀態(tài)枚舉值來(lái)控制調(diào)用鏈的執(zhí)行過(guò)程,是應(yīng)該繼續(xù)還是停止。不能細(xì)粒度控制過(guò)濾器的執(zhí)行順序。
- ROFF 本身的過(guò)濾器機(jī)制繼承自 Pingora,而后使用過(guò)程宏為每個(gè)過(guò)濾器生成新的函數(shù)簽名,添加 Next 函數(shù)參數(shù),以此提供更加豐富的過(guò)濾器流程控制能力。
舉例來(lái)說(shuō),對(duì)于 request_filter 過(guò)濾器函數(shù),在 Pingora 的函數(shù)原型如下所示,ROFF 為其添加了 Next 參數(shù)。
// 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 參數(shù)給予我們操作流程的可能性,設(shè)計(jì)了如下的方法以支持自定義過(guò)濾器執(zhí)行順序:
- "next#call().await":順序執(zhí)行該過(guò)濾器的下一個(gè)鉤子。
- "next#ingore_call("B",...).await":忽略指定模塊 B 的該過(guò)濾器鉤子。
- "next#ingore_many_call(["B","C"],...).await":忽略多個(gè)模塊 B、C 的該過(guò)濾器鉤子。
- "next#call_to_end(...).await":忽略剩余的所有鉤子,直接執(zhí)行該該過(guò)濾器的最后一個(gè)默認(rèn)鉤子。
下圖展示了使用 Next 參數(shù)跳過(guò)指定模塊處理的案例,其中 keyless-tls 模塊需要讀取 builtin-tls 模塊的 TLS 證書/私鑰。由于 keyless-tls 模塊本身也具備 TLS 處理能力,和 builtin-tls 模塊的能力沖突,因此需要跳過(guò) builtin-tls 模塊的該過(guò)濾器鉤子。圖中綠色線條為實(shí)際的處理流程受 next.call_ignore 控制,虛線線條為正常流程。
3.4 高效的模塊擴(kuò)展
功能拓展是網(wǎng)關(guān)軟件不可避免的話題,不同的行業(yè)或業(yè)務(wù)需要針對(duì)特定的需求開發(fā)不同的額外功能。更低的開發(fā)成本,更安全的功能擴(kuò)展,將決定了軟件生態(tài)能否健康長(zhǎng)久的發(fā)展。Nginx 將自己的服務(wù)模塊化,當(dāng)收到請(qǐng)求時(shí),通過(guò)匹配配置文件中的相應(yīng)路由確定需要執(zhí)行的指令,然后依次執(zhí)行指令所對(duì)應(yīng)的模塊完成請(qǐng)求處理。這些模塊通常包括 Core 模塊、Events 模塊、HTTP 模塊、Stream 模塊和三方模塊如 Lua 模塊等。得益于這種模塊化架構(gòu),Nginx 搭建了豐富的生態(tài)系統(tǒng),來(lái)自社區(qū)的拓展,例如緩存、壓縮、認(rèn)證、流量統(tǒng)計(jì)等,拓寬了其應(yīng)用場(chǎng)景。
和 NGINX 類似,ROFF 從兩個(gè)方面保證了用戶可以快速的進(jìn)行功能的拓展:指令解析和模塊開發(fā)。
3.4.1 復(fù)雜指令解析
ROFF 具備強(qiáng)大的復(fù)雜指令解析能力,以及更加便捷的配置獲取,便于用戶在自定義模塊時(shí)為配置文件加入更多的指令。如下圖所示,我們將一條指令定義為包含參數(shù) Arguments、屬性 Properties 以及孩子節(jié)點(diǎn) Children 的 Directive 類型,其中的子指令可以再次嵌套指令以實(shí)現(xiàn)更加復(fù)雜的指令形式。利用 Rust 的泛型推斷能力,可以快速的將用戶配置轉(zhuǎn)化成 Directive 類型以供用戶解析和使用。而在 Nginx 中,對(duì)于復(fù)雜的多級(jí)嵌套塊需要頻繁調(diào)用ngx_conf_parse 方法解析配置。
在 ROFF 中,類似 Nginx 中的 http 塊、server 塊和 location 塊也可被視為多個(gè) Directive 指令結(jié)構(gòu)。以 http 塊為例,可以視為一個(gè)指令名稱為"http"的 Directive,其中嵌套的子指令即為 server 塊。在解析配置文件時(shí),用戶只需要根據(jù) ROFF 提供的指令名稱來(lái)判斷當(dāng)前的指令是否為該模塊需要使用的配置,通過(guò)作用域信息來(lái)細(xì)粒度控制指令的作用范圍。
first_arg會(huì)自動(dòng)推導(dǎo)類型并轉(zhuǎn)換,不需要 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 模塊系統(tǒng)
Nginx 將請(qǐng)求的處理過(guò)程劃分為了 11 個(gè)階段,不同階段又定義了不同的模塊,其中有七個(gè)階段開放給用戶的自定義模塊介入。但是大部分自定義模塊都專注于 NGX_HTTP_CONTENT_PHASE 擴(kuò)展。Nginx 中需要手動(dòng)通過(guò)全局的 ngx_http_top/next_foo_filter 串聯(lián)自定義的過(guò)濾器。
// 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 吸收了這種模塊化和使用過(guò)濾器鏈?zhǔn)教幚韮?nèi)容的思想,不再單獨(dú)區(qū)分請(qǐng)求處理的多個(gè)階段,而是將全部過(guò)濾器都集成到 HttpFilter 中,累計(jì) 31 個(gè)過(guò)濾器供用戶自定義模塊介入。從連接的建立,到請(qǐng)求處理、再至響應(yīng)的返回,用戶只需要明確在哪個(gè)過(guò)濾器插槽中實(shí)現(xiàn)自定義功能即可。下圖展示了 Nginx 與 ROFF 在處理流程上劃分的差異,ROFF 只展示了主要的幾個(gè)流程。
用戶將自定義的 listen_accepted 操作插入處理流程中的示例:
// insert a listen_accepted
ctx.filter.hook_listen_accepted.insert(Self::name(), Rc::new(self.clone()));
下面給出一個(gè)創(chuàng)建"example"指令的案例,該指令將用戶的請(qǐng)求直接返回"Hello World"信息。左圖為 Nginx 中實(shí)現(xiàn)的邏輯,需要定義模塊的指令設(shè)置,配置解析流程,模塊上下文等等,其中充斥著函數(shù)指針和內(nèi)存的操作,這無(wú)疑增大了安全隱患。右圖為 ROFF 中開發(fā)模塊的流程,我們提供了模塊的基本實(shí)現(xiàn),用戶只需要關(guān)心核心邏輯即可。其中指令解析部分,暴露給用戶更加直觀的處理方式,不需要考慮配置偏移等與內(nèi)存打交道的設(shè)置。在對(duì) Filter 過(guò)濾器開發(fā)時(shí),只對(duì) request_filter 插槽做實(shí)現(xiàn)即可完成介入。借助 Rust 的 async/await,ROFF 中大量 hook 原生支持 async,可以將請(qǐng)求轉(zhuǎn)發(fā)到其他控制面實(shí)現(xiàn)復(fù)雜邏輯。
3.5 Keyless-TLS 硬件卸載
在互聯(lián)網(wǎng)中,為了提高信息傳遞的安全性,通常使用 SSL/TLS 對(duì) HTTP 協(xié)議的數(shù)據(jù)內(nèi)容進(jìn)行加密,也就是所謂的 HTTPS 協(xié)議。在使用 HTTPS 協(xié)議通信時(shí),除了需要建立 TCP 連接、發(fā)送報(bào)文外,還需要進(jìn)行 SSL 通信過(guò)程。這個(gè)額外的通信過(guò)程需要對(duì)傳輸?shù)臄?shù)據(jù)進(jìn)行加密和解密計(jì)算操作,這是一項(xiàng)計(jì)算密集型任務(wù),會(huì)大量消耗服務(wù)器的計(jì)算資源。因此,通常在網(wǎng)絡(luò)架構(gòu)中,使用專用設(shè)備進(jìn)行 SSL/TLS 的加解密過(guò)程,并將解密后的流量轉(zhuǎn)發(fā)給后端服務(wù)器。這種 SSL 卸載的方式可以減輕內(nèi)網(wǎng)服務(wù)器的負(fù)擔(dān),加快網(wǎng)絡(luò)通信速度,同時(shí)統(tǒng)一的證書管理和加密設(shè)置,可以使得網(wǎng)站具有更強(qiáng)的安全特性和防護(hù)措施。
SSL 卸載主要分為硬件卸載和軟件卸載,硬件卸載專注于使用 QAT 等加速卡來(lái)進(jìn)行加解密計(jì)算,軟件卸載則使用服務(wù)器自身的計(jì)算資源進(jìn)行操作。典型的軟件卸載案例則是使用 Nginx 作為負(fù)載均衡服務(wù)器,并將所有的 HTTPS 流量在本機(jī)卸載為 HTTP 流量后轉(zhuǎn)發(fā)給后端服務(wù)器。如下圖所示,我們對(duì) Nginx 使用軟卸載的方式進(jìn)行了基準(zhǔn)測(cè)試,當(dāng)使用 HTTPS 訪問(wèn)服務(wù)器時(shí),所能承載的 QPS 相比使用 HTTP 訪問(wèn)時(shí)下降了一半左右。
因此,我們也在探索使用硬件卸載方案,加快 TLS 握手速度,降低 CPU 占用,從整體角度降本增效。同時(shí),我們也需要支持軟件卸載的能力,以此作為硬件卸載的兜底方案。OpenSSL 是一個(gè)功能全面歷史悠久的用于處理 SSL/TLS 加解密庫(kù),但是其結(jié)構(gòu)復(fù)雜龐大,跨平臺(tái)開發(fā)難度大,深度定制成本高。在 ROFF 中選擇使用 Rustls 庫(kù)作為 OpenSSL 庫(kù)的替代,其在性能和內(nèi)存使用上都優(yōu)于后者。
在硬件卸載方面,我們將 SSL 通信過(guò)程中最為耗時(shí)的非對(duì)稱加密操作卸載到硬件加速卡上,也即是業(yè)界采用的 Keyless 硬件卸載方案。
基于 Rust 天然內(nèi)存安全的特性并且還能保持與 C/C++ 語(yǔ)言同樣的性能優(yōu)勢(shì),我們開發(fā)了基于 ROFF 的 TLS 硬件卸載方案,重寫 Rustls 部分函數(shù)實(shí)現(xiàn) TLS-QAT 的硬件卸載加速,Keyless 卸載失敗能自動(dòng)切換到 OpenSSL 兜底卸載部分 TLS 流量,以保障服務(wù)的可靠性和穩(wěn)定性。
3.6 解決長(zhǎng)尾延遲問(wèn)題
長(zhǎng)尾延遲( long-tail latency )是指重計(jì)算的情況下核心任務(wù)不均勻?qū)е虏糠终?qǐng)求積壓,請(qǐng)求延遲上漲。解決長(zhǎng)尾延遲普遍采用 tokio 的 multiple worker 運(yùn)行時(shí),一些共享變量需要額外的 Sync 約束,進(jìn)而引入過(guò)多互斥鎖開銷。
采用 Thread Per Core 能避免開銷但不得不引入長(zhǎng)尾延遲。ROFF 的核心邏輯重 IO 轉(zhuǎn)發(fā)不會(huì)積壓請(qǐng)求,可能存在重計(jì)算的邏輯都在模塊。
為解決上述兩個(gè)問(wèn)題,ROFF 除了自身的 worker 線程,還會(huì)額外創(chuàng)建 tokio 的 work-stealing 運(yùn)行時(shí)供模塊選擇性優(yōu)化。
/// 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 構(gòu)建 HTTP 處理能力,但目前 Pingora 不支持 HTTP/3 能力 [2]。小紅書大量流量依賴 HTTP/3,于是我們?yōu)?ROFF 開發(fā)了 HTTP/3 能力。當(dāng)前 Rust 的 QUIC/HTTP3 生態(tài):
- Quiche: QUIC+HTTP/3
- s2n-quic/quinn: QUIC
- h3: HTTP/3
- libnghttp3-dev: HTTP/3
我們?yōu)?Quiche 開發(fā)了 tokio 支持,但使用發(fā)現(xiàn) Quiche 與 OpenSSL/BoringSSL 強(qiáng)綁定關(guān)系,ROFF 的 ssl_backend: "rustls" 指令需要支持 rustls,最后放棄了Quiche。h3 庫(kù)處于早期版本不建議生產(chǎn)使用。為了能承接小紅書的核心流量不出問(wèn)題,ROFF 為 nghttp3 C 庫(kù)開發(fā)了 Rust 的 FFI 和 tokio 異步支持。借助 nghttp3 [3],我們獲得了能穩(wěn)定用于線上的 Rust HTTP/3 庫(kù),最終 HTTP/3 協(xié)議棧如下:
3.8 配置文件
最初支持 KDL/JSON/YAML,隨著功能越來(lái)越多指令越來(lái)越復(fù)雜,JSON/YAML無(wú)法對(duì)齊KDL的表達(dá)能力,反而帶來(lái)配置膨脹,難以閱讀的問(wèn)題。
為了能減少 Nginx 開發(fā)者的理解成本,ROFF 只采用 KDL 文件格式并兼容大多數(shù) 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、總結(jié)與展望
ROFF 已經(jīng)完成了開發(fā)以及系統(tǒng)性測(cè)試,經(jīng)過(guò)與 Nginx 的對(duì)比實(shí)驗(yàn)驗(yàn)證了其在性能上的優(yōu)勢(shì)。目前已經(jīng)在小紅書如下場(chǎng)景灰度運(yùn)行中:
- 已在自建機(jī)房灰度并承接了主站的核心流量。得益于 Rust 語(yǔ)言嚴(yán)格的靜態(tài)檢查,作為一個(gè)全新的項(xiàng)目,自上線以來(lái)無(wú)一例內(nèi)存安全問(wèn)題。
- ROFF 已經(jīng)支持容器化部署并作為自建機(jī)房對(duì)象存儲(chǔ)的接入網(wǎng)關(guān),承接了所有對(duì)象存儲(chǔ)的入口流量。
- 相比于云廠商七層 LB,自建網(wǎng)關(guān) ROFF 成本能夠降低 80%左右。
4.1 性能對(duì)比
與此同時(shí),我們建立了自動(dòng)化壓測(cè)工具,對(duì)每次的升級(jí)迭代都與 Nginx 進(jìn)行及時(shí)的性能對(duì)比。
我們?cè)谕慌_(tái)機(jī)器上分別運(yùn)行 ROFF 以及 Nginx,并使用相同的配置文件進(jìn)行壓測(cè)實(shí)驗(yàn),實(shí)驗(yàn)結(jié)果如下圖所示。其中,左圖為 HTTP 測(cè)試結(jié)果,右圖為 HTTPS 測(cè)試結(jié)果,結(jié)果表明在相同環(huán)境下,ROFF 所能承接的流量與 Nginx 基本無(wú)異(注意Y軸數(shù)值)。
ROFF 大量使用范型、動(dòng)態(tài)分發(fā)、async 語(yǔ)法等現(xiàn)代語(yǔ)法特性,且性能和 Nginx 相當(dāng),原因有幾點(diǎn):
- C 語(yǔ)言和 Rust 語(yǔ)言性能同一梯隊(duì),語(yǔ)言能提供給編譯器的信息越多,編譯器能做更多的優(yōu)化。由于 Rust 語(yǔ)法要求更嚴(yán)格,給編譯器提供的信息更多,編譯器能做比 C/C++ 更激進(jìn)的優(yōu)化。最近一個(gè)很熱門的話題 [4]。
- Rust 自帶 Cargo 包管理,最大限度復(fù)用社區(qū)已有生態(tài)。比如 Nginx 使用 C 語(yǔ)言實(shí)現(xiàn)的 HTTP 解析器,而 Rust 解析庫(kù) httparse [5] 已經(jīng)用上了 SIMD。
- 開發(fā)過(guò)程中借鑒了很多 Nginx 的設(shè)計(jì),不斷的壓測(cè)找到薄弱點(diǎn)。
總的來(lái)說(shuō),ROFF 的線程模型更像 Envoy,架構(gòu)/模塊設(shè)計(jì)更像 Nginx。
4.2 未來(lái)規(guī)劃
后續(xù)會(huì)持續(xù)灰度公網(wǎng) HTTP/3,實(shí)現(xiàn)更多 Nginx 的指令并開源。集成 Deno 庫(kù),實(shí)現(xiàn)類似 OpenResty 的擴(kuò)展能力。
基于Deno擴(kuò)展網(wǎng)關(guān)邊界
即使 Nginx 有如此豐富的社區(qū)以及高度可自定義的模塊開發(fā),但是由于其開發(fā)成本大,一部分受眾將目光轉(zhuǎn)向了 Nginx+Lua 的模式。因此,誕生了 OpenResty。用戶可以直接使用 Lua 腳本動(dòng)態(tài)擴(kuò)展功能而無(wú)需重新編譯服務(wù)器。目前主要應(yīng)用在API網(wǎng)關(guān)等場(chǎng)景中。LuaJIT 所提供的高效的腳本執(zhí)行能力,使得網(wǎng)關(guān)更容易處理動(dòng)態(tài)內(nèi)容以及更加復(fù)雜的邏輯,并且 Lua 開發(fā)更加靈活。但是,使用 Lua 作為 Nginx的擴(kuò)展腳本可能也是無(wú)奈之舉,在腳本語(yǔ)言中 JavaScript 的強(qiáng)大擴(kuò)展能力以及更豐富的社區(qū)生態(tài) [6],更適用于 Web 服務(wù)的建設(shè)。由 Google 開源的 V8 引擎已經(jīng)具備了極強(qiáng)的性能。
后續(xù) ROFF 將考慮集成 Deno 庫(kù):Rust 開發(fā)的高效安全的 JavaScript 運(yùn)行時(shí)環(huán)境,使得 ROFF 可以使用 JavaScript 腳本極大的擴(kuò)展網(wǎng)關(guān)的邊界能力。之所以選擇 Deno 庫(kù),主要是其具備如下的優(yōu)點(diǎn):
- 由 Rust 編寫,ROFF 與 Deno 的 rust-runtime 零開銷調(diào)用。
- 基于 V8 引擎的 JavaScript 運(yùn)行時(shí)相比 Lua 具備超強(qiáng)的性能。
- JavaScript 的單線程 EventLoop 非常契合我們的線程模型,每個(gè) worker 線程都能運(yùn)行 JavaScrip t的 EventLoop。
- 復(fù)用整個(gè) JavaScript 的繁榮生態(tài),不再編寫精簡(jiǎn)版的 JavaScript,而是能使用任何 NPM 包。
- Deno 庫(kù)不依賴 node_modules,直接嵌入到二進(jìn)制。
4.3 與 Pingora 的關(guān)系
非常感謝 Pingora 提供了與 Nginx 類似的框架能力。
ROFF 最初將 Pingora 作為 Cargo 依賴使用,好處是更新 Pingora 版本就可以享受到上游的新特性或是 bug-fix。但是在開發(fā)過(guò)程中,遇到了一些問(wèn)題使得 ROFF 不得不自行維護(hù)部分 Pingora 代碼。
- Pingora模塊在過(guò)濾器之后。先有了不同的過(guò)濾器,再注冊(cè)模塊,但模塊只是多了幾個(gè) hook 點(diǎn),并不支持定義指令解析、HTTP 變量等能力、線程啟動(dòng)退出Hook。當(dāng)然這也是因?yàn)?Pingora 定位是 HTTP 框架,而不是完整的網(wǎng)關(guān)程序 [7]。
- ROFF 使用 Thread Per Core 的線程模型配合 Thread Local 存放數(shù)據(jù),只有很少的地方有資源競(jìng)爭(zhēng)。Pingora 默認(rèn) tokio work-stealing,很多位置需要 Send + Sync 約束。
- 開發(fā)過(guò)程中碰到了很多問(wèn)題,只能 hack 繞過(guò)。導(dǎo)致 ROFF 維護(hù)了大量與 Pingora 的膠水代碼。久而久之造成 ROFF 自有代碼和 Pingora 的嚴(yán)重割裂(比如 tcp_connect,ROFF 和 Pingora 都維護(hù)一套)。所以在一次重構(gòu)中,我們 fork 了其 HTTP 轉(zhuǎn)發(fā)邏輯,大量重寫確保 Pingora 和 ROFF 的架構(gòu)契合度。
- Pingora 不支持半途終止 HttpFilter 迭代的特性,部分模塊依賴 [8]。
- 灰度流量時(shí)線上代碼死循環(huán),等不及上游修復(fù) [9]。
- 很多功能會(huì)對(duì)上游引入 breaking change。Pingora 沒(méi)有對(duì)外暴露 keepalive_timeout、proxy_read_timeout等指令。
- Pingora 的 HTTP/3 支持緩慢。對(duì)小紅書來(lái)說(shuō) HTTP/3 是剛需,不可能等到上游實(shí)現(xiàn),只能對(duì) Pingora 進(jìn)行修改。
05、作者簡(jiǎn)介
軒宇(姚劍鵬)
接入網(wǎng)關(guān)方向負(fù)責(zé)人。
蕭炎(吳偉超)
接入網(wǎng)關(guān)方向研發(fā)。
露卡(陸于洋)
接入網(wǎng)關(guān)方向研發(fā)。
長(zhǎng)恭(陳凱)
接入網(wǎng)關(guān)方向研發(fā)。
06、參考文獻(xiàn)
- 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 實(shí)現(xiàn)更快?
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