沒有 Nginx 的未來,Cloudflare 工程師正在用 Rust 重構代碼!

csdn 發佈 2024-04-06T23:35:07.810851+00:00

【CSDN 編者按】你最常用的開發語言是哪種呢?

【CSDN 編者按】

你最常用的開發語言是哪種呢?近日,一位專注於 Linux 性能和開源自動化基準測試的軟體工程師 Michael Larabel 在一篇文章中表示,在 Cloudflare,他們正在用 Rust 編寫的替代方案來取代 Nginx,但 Cloudflare 的基礎設施非常龐大,並且有許多不同的服務在發揮作用。最後他們是怎樣來編寫的呢?一起來看文章內容~

編譯 | 禾木木 責編 | 王子彧

出品 | CSDN(ID:CSDNnews)

在 Cloudflare,工程師們會花大量的時間重構或重新改寫現有功能。最近在開發一個可以替代內部 cf-html 的組件,它在核心反向網絡代理中,被稱為 FL(Front Line)。cf-html 是負責解析和重寫 HTML 的框架,它從網站源頭流向網站訪問者。從 Cloudflare 早期開始,就提供了一些功能,這些功能將在飛行中為你重寫網絡請求的響應體。以這種方式編寫的第一個功能是用 Javascript 替換電子郵件地址,然後在網絡瀏覽器中查看時加載該電子郵件地址。由於機器人通常無法評估 JavaScript,這有助於防止從網站上搜刮電子郵件地址。

FL 是 Cloudflare 大部分應用基礎設施邏輯運行的地方,主要由 Lua 腳本語言編寫的代碼組成,它作為 OpenResty 的一部分在 Nginx 之上運行。為了直接與 Nginx 對接,部分(如cf-html)是用 C 和 C++ 等語言編寫。過去,在 Cloudflare 有許多這樣的 OpenResty 服務,但 FL 是為數不多的剩下的服務之一,因為工程師們把其他組件轉移到了 Workers 或基於 Rust 的代理上。

當 HTTP 請求通過網絡,尤其是 FL 做了什麼動作時,幾乎所有的注意力都集中在請求到達客戶的源頭之前發生的事情。這是大部分業務邏輯發生的地方:防火牆規則、工人和路由決定都發生在請求中。但從工程師的角度來看,許多有趣的工作發生在響應上,因此工程師們將 HTML 響應從原點流回給網站訪問者。

處理這種情況的邏輯,在一個靜態的 Nginx 模塊中,並在 Nginx 的響應體過濾器階段運行。cf-html 使用一個流式 HTML 分析器來匹配特定的 HTML 標籤和內容,稱為 Lazy HTML 或 lhtml,它和 cf-html 功能的大部分邏輯都是用 Ragel 狀態機引擎編寫的。

所以,他們正在用內部的 Rust 編寫的替代方案來取代 Nginx,但 Cloudflare 的基礎設施非常龐大,並且有許多不同的服務在發揮作用。

內存安全性

所有的 cf-html 邏輯都是用 C 語言編寫,因此容易受到困擾許多大型 C 代碼庫的內存損壞問題的影響。2017 年,當團隊試圖替換部分 cf-html 時,這導致了一個安全漏洞。FL 從內存中讀取任意數據並將其附加到響應體。這可能包括同時通過 FL 的其他請求的數據,此安全事件被廣泛稱為 Cloudbleach。

自這一事件發生以來,Cloudflare 實施了一系列政策和保障措施,以確保此類事件不再發生。儘管多年來在 cf-html 上進行了工作,但框架上幾乎沒有實現新功能,而且工程師們現在對 FL(以及網絡上運行的任何其他進程)中發生的崩潰非常敏感,尤其是在可以通過響應反映數據的部分。

目前,FL 平台團隊已經收到越來越多的系統請求,他們可以方便地使用該系統來查看和重寫響應體數據。同時,另一個團隊正在為 Workers 開發一個新的響應體解析和重寫框架,稱為 lol-HTML 或低輸出延遲 HTML。lol html 不僅比 Lazy HTML 更快、更高效,而且目前作為 Worker 界面的一部分,它已經在正式生產中使用,並且是用 Rust 編寫的。在處理內存方面,它比 C 語言安全得多。因此,它是一個理想的替代品。

因此,工程師們開始研究一個用 Rust 編寫的新框架,該框架將包含 lol-HTML,並允許其他團隊編寫響應體解析功能,而不會造成大量安全問題的威脅。新系統被稱為 ROFL 或 Response Overseer for FL,它是一個完全用 Rust 編寫的全新 Nginx 模塊。截至目前,ROFL 每秒處理數百萬個響應,性能與 cf-html 相當。在構建 ROFL 時,工程師們已經能夠棄用 Cloudflare 整個代碼庫中最可怕的代碼之一,同時為 Cloudflare 的團隊提供一個強大的系統,他們可以用來編寫需要解析和重寫響應體數據的功能。

用 Rust 編寫 Nginx 模塊

在編寫新模塊時,工程師們了解了很多 Nginx 的工作原理,以及如何讓它與 Rust 對話。Nginx 沒有提供太多用 C 語言以外的語言編寫模塊的文檔,因此工程師需要做一些工作來確定如何用選擇的語言編寫 Nginx 模塊。開始時,工程師們大量使用了 nginx-rs 項目中的部分代碼,尤其是緩衝區和內存池的處理。雖然在 Rus t中編寫完整的 Nginx 模塊是一個漫長的過程,但有幾個關鍵點使整個過程成為可能,並值得討論。

其中第一個是生成 Rust 綁定,以便 Nginx 可以與之通信。為此,工程師們根據 Nginx 頭文件中的符號定義,使用 Rust 的庫 Bindgen 構建 FFI 綁定。要將其添加到現有的 Rust 項目中,首先要刪除一個 Nginx 的副本並對其進行配置。理想情況下,這將在一個簡單的腳本或 Makefile 中完成,但手動完成時,它看起來像這樣:

$ git clone --depth=1 https://github.com/nginx/nginx.git$ cd nginx$ ./auto/configure --without-http_rewrite_module --without-http_gzip_module

在 Nginx 處於正確狀態的情況下,需要在 Rust 項目中創建一個文件,以便在模塊構建時自動生成綁定。現在,將在構建中添加必要的參數,並使用 Bindgen 生成文件。對於參數,只需要包含頭文件的目錄,以便 clang 執行其任務。其次,可以將它們與一些 allowlist 參數一起輸入 Bindgen,這樣它就知道應該生成綁定的內容,以及可以忽略的內容。在頂部添加一些樣板代碼,整個文件如下所示:

use std::env;use std::path::PathBuf;fn main { println!("cargo:rerun-if-changed=build.rs"); let clang_args = [ "-Inginx/objs/", "-Inginx/src/core/", "-Inginx/src/event/", "-Inginx/src/event/modules/", "-Inginx/src/os/unix/", "-Inginx/src/http/", "-Inginx/src/http/modules/" ]; let bindings = bindgen::Builder::default .header("wrapper.h") .layout_tests(false) .allowlist_type("ngx_.*") .allowlist_function("ngx_.*") .allowlist_var("NGX_.*|ngx_.*|nginx_.*") .parse_callbacks(Box::new(bindgen::CargoCallbacks)) .clang_args(clang_args) .generate .expect("Unable to generate bindings");
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap); bindings.write_to_file(out_path.join("bindings.rs")) .expect("Unable to write bindings.");}

希望這一切都是不言自明的。Bindgen 遍歷 Nginx 原始碼,並在 Rust 中生成一個等效構造,並將其導入到項目中。此外,Bindgen 在 Nginx 中的幾個符號存在問題,工程師們需要為其修復。應包含以下內容:

#include <ngx_http.h>const char* NGX_RS_MODULE_SIGNATURE = NGX_MODULE_SIGNATURE;const size_t NGX_RS_HTTP_LOC_CONF_OFFSET = NGX_HTTP_LOC_CONF_OFFSET;

在 Cargo.toml 文件的一節中設置了此項並設置了 Bindgen,就可以開始構建了。

$ cargo build Compiling rust-nginx-module v0.1.0 (/Users/sam/cf-repos/rust-nginx-module) Finished dev [unoptimized + debuginfo] target(s) in 4.70s

幸運的是,我們應該在 target/debug/build 目錄中看到一個名為 bindings.rs 的文件,其中包含所有 Nginx 符號的 Rust 定義。

$ find target -name 'bindings.rs' target/debug/build/rust-nginx-module-c5504dc14560ecc1/out/bindings.rs$ head target/debug/build/rust-nginx-module-c5504dc14560ecc1/out/bindings.rs/* automatically generated by rust-bindgen 0.61.0 */[...]

為了能夠在項目中使用它們,可以將它們包含在將調用的目錄下的新文件中:

$ cat > src/bindings.rsinclude!(concat!(env!("OUT_DIR"), "/bindings.rs"));

有了該集合,只需將通常的導入添加到文件的頂部,就可以從 Rust 訪問 Nginx 構造。與手動編碼相比,這不僅使 Nginx 和 Rust 模塊之間的接口出現錯誤的可能性要小得多,而且在 Rust 中構建模塊時,可以使用它來檢查 Nginx 中的東西的結構,並需要大量的腿部工作來設置一切。這確實證明了許多 Rust 庫(如 Bindgen)的質量,這樣的工作可以用很少的時間就可以完成。

一旦構建了 Rust 庫,下一步就是將其連接到 Nginx 中。大多數 Nginx 模塊都是靜態編譯的。也就是說,該模塊作為整個 Nginx 編譯的一部分進行編譯。然而,自 Nginx 1.9.11 以來開始支持動態模塊,這些模塊是單獨編譯的,然後使用文件中的指令加載。這就是工程師們需要用來構建 ROFL 的地方,這樣就可以在 Nginx 啟動時單獨編譯並加載庫。找到正確的格式以便從文檔中找到必要的符號是很困難的,儘管可以使用單獨的配置文件來設置一些元數據,但最好將其作為模塊的一部分加載,以保持整潔。幸運的是,通過 Nginx 代碼庫不需要花太多時間就可以找到調用的位置。

因此,之後只需確保相關符號存在的情況。

use std::os::raw::c_char;use std::ptr;#[no_mangle]pub static mut ngx_modules: [*const ngx_module_t; 2] = [ unsafe { rust_nginx_module as *const ngx_module_t }, ptr::()];#[no_mangle]pub static mut ngx_module_type: [*const c_char; 2] = [ "HTTP_FILTER\0".as_ptr() as *const c_char, ptr::()];#[no_mangle]pub static mut ngx_module_names: [*const c_char; 2] = [ "rust_nginx_module\0".as_ptr() as *const c_char, ptr::()];

在編寫 Nginx 模塊時,確保其相對於其他模塊的順序正確是至關重要的。當 Nginx 啟動時,動態模塊被加載,這意味著它們(可能與直覺相反)是第一個運行響應的模塊。通過指定模塊相對於 gunzip 模塊的順序來確保模塊在 gzip 解壓縮後運行是必不可少的,否則您可能會花費大量時間盯著無法列印的字符流,且想知道為什麼沒有看到預期的響應。幸運的是,這也可以通過查看 Nginx 原始碼並確保模塊中存在相關實體來解決。下面是可以設置的示例:

pub static mut ngx_module_order: [*const c_char; 3] = [ "rust_nginx_module\0".as_ptr() as *const c_char, "ngx_http_headers_more_filter_module\0".as_ptr() as *const c_char, ptr::()];

本質上說,工程師們希望模塊恰好在模塊之前運行,這應該允許它在預期的位置運行。

Nginx 和 OpenResty 的一個怪癖是,在處理 HTTP 響應時,它對調用外部服務不那麼友好。它不是作為 OpenRestyLua 框架的一部分提供,儘管它會使處理請求的響應階段變得更加容易。我們無論如何都可以做到這一點,但這意味著必須分叉 Nginx 和 OpenResty,這將帶來一些挑戰。因此,從 Nginx 處理 HTTP 請求到通過響應流傳輸狀態,這些年來花了很多時間來思考如何傳遞狀態,工程師們的很多邏輯都是圍繞這種工作方式構建的。

對於 ROFL,這意味著為了確定是否需要為響應應用某個特性,需要在請求中找出這一點,然後將該信息傳遞給響應,以便知道要激活哪些特性。為此,需要使用 Nginx 為您提供的一個實用程序。藉助前面生成的文件,可以查看結構的定義,其中包含與給定請求相關的所有狀態:

#[repr(C)]#[derive(Debug, Copy, Clone)]pub struct ngx_http_request_s { pub signature: u32, pub connection: *mut ngx_connection_t, pub ctx: *mut *mut ::std::os::raw::c_void, pub main_conf: *mut *mut ::std::os::raw::c_void, pub srv_conf: *mut *mut ::std::os::raw::c_void, pub loc_conf: *mut *mut ::std::os::raw::c_void, pub read_event_handler: ngx_http_event_handler_pt, pub write_event_handler: ngx_http_event_handler_pt, pub cache: *mut ngx_http_cache_t, pub upstream: *mut ngx_http_upstream_t, pub upstream_states: *mut ngx_array_t, pub pool: *mut ngx_pool_t, pub header_in: *mut ngx_buf_t, pub headers_in: ngx_http_headers_in_t, pub headers_out: ngx_http_headers_out_t, pub request_body: *mut ngx_http_request_body_t,[...]}

正如 Nginx 開發指南所提到的,它是一個可以存儲與請求相關聯的任何值的地方,該值應該與請求一樣長。在 OpenResty 中,這主要用於在 Lua 上下文中存儲請求的整個生命周期中的狀態。工程師們可以為模塊做同樣的事情,這樣當 HTML 解析和重寫在響應階段運行時,在請求階段初始化的設置就在那裡。下面是一個可用於獲取請求的示例函數:

pub fn get_ctx(request: &ngx_http_request_t) -> Option<&mut Ctx> { unsafe { match *request.ctx.add(ngx_http_rofl_module.ctx_index) { p if p.is_ => None, p => Some(&mut *(p as *mut Ctx)), } }}

這是生成 Nginx 模塊所需的模塊定義的一部分的類型結構。一旦有了這個,就可以將它指向包含想要的任何設置的結構。例如,下面是使用 LuaJIT 的 FFI 工具從 Lua 通過 FFI 到 Rust 模塊啟用電子郵件混淆功能的實際函數:

#[no_mangle]pub extern "C" fn rofl_module_email_obfuscation_new( request: &mut ngx_http_request_t, dry_run: bool, decode_script_url: *const u8, decode_script_url_len: usize,) { let ctx = context::get_or_init_ctx(request); let decode_script_url = unsafe { std::str::from_utf8(std::slice::from_raw_parts(decode_script_url, decode_script_url_len)) .expect("invalid utf-8 string for decode script") }; ctx.register_module(EmailObfuscation::new(decode_script_url.to_owned), dry_run);}

如果結構不存在,它也會初始化結構。一旦在請求過程中設置了所需的數據,就可以檢查響應中需要運行哪些功能,而無需調用外部資料庫,這可能會降低速度。

以這種方式存儲狀態以及與 Nginx 一起工作的好處之一是,它嚴重依賴內存池來存儲請求內容。這在很大程度上消除了程式設計師在使用後必須考慮釋放內存的任何需求,內存池在請求開始時將自動分配,並在請求完成時自動釋放。所需要的就是使用 Nginx 的內置函數來分配內存,將內存分配給內存池,然後註冊一個回調,該回調將被調用以釋放所有內容。在 Rust 中,它看起來類似於以下內容:

pub struct Pool<'a>(&'a mut ngx_pool_t);impl<'a> Pool<'a> {  /// Register a cleanup handler that will get called at the end of the request. fn add_cleanup<T>(&mut self, value: *mut T) -> Result<, > { unsafe { let cln = ngx_pool_cleanup_add(self.0, 0); if cln.is_ { return Err(); } (*cln).handler = Some(cleanup_handler::<T>); (*cln).data = value as *mut c_void; Ok() } } /// Allocate memory for a given value. pub fn alloc<T>(&mut self, value: T) -> Option<&'a mut T> { unsafe { let p = ngx_palloc(self.0, mem::size_of::<T>) as *mut _ as *mut T; ptr::write(p, value); if let Err(_) = self.add_cleanup(p) { ptr::drop_in_place(p); return None; }; Some(&mut *p) } }}unsafe extern "C" fn cleanup_handler<T>(data: *mut c_void) { ptr::drop_in_place(data as *mut T);} 

這應該允許工程師們為自己想要的任何東西分配內存,因為 Nginx 會為工程師們處理。

遺憾的是,在 Rust 中處理 Nginx 的接口時,必須編寫大量的塊。儘管已經做了大量的工作,儘可能地將其最小化,但不幸的是,編寫 Rust 代碼時經常會遇到這種情況,因為它必須通過 FFI 操作 C 結構。計劃在未來做更多的工作,並刪除儘可能多的行。

遇到的挑戰

Nginx 模塊系統在模塊本身的工作方式方面允許大量的靈活性,這使得它非常適合特定的用例,但這種靈活性也會導致問題。遇到的一個問題是 Rust 和 FL 之間處理響應數據的方式。在 Nginx 中,響應體被分塊,然後這些塊被連結到一個列表中。此外,如果響應很大,每個響應可能有不止一個連結列表。

有效地處理這些塊意味著處理它們並儘快傳遞它們。在編寫用於處理響應的 Rust 模塊時,很容易在這些連結列表中實現基於 Rust 的視圖。但是,如果這樣做,則必須確保在改變它們的同時更新基於 Rust 的視圖和底層 Nginx 數據結構,否則這可能會導致嚴重的錯誤,導致 Rust 與 Nginx 不同步。這是 ROFL 早期版本的一個小功能,它引起了大家的頭痛:

fn handle_chunk(&mut self, chunk: &[u8]) { let mut free_chain = self.chains.free.borrow_mut; let mut out_chain = self.chains.out.borrow_mut; let mut data = chunk; self.metrics.borrow_mut.bytes_out += data.len as u64; while !data.is_empty { let free_link = self .pool .get_free_chain_link(free_chain.head, self.tag, &mut self.metrics.borrow_mut) .expect("Could not get a free chain link."); let mut link_buf = unsafe { TemporaryBuffer::from_ngx_buf(&mut *(*free_link).buf) }; data = link_buf.write_data(data).unwrap_or(b""); out_chain.append(free_link); }}

這段代碼想要做的是獲取 lol-html 的 HTMLRewriter 的輸出,並將其寫入緩衝區的輸出鏈。重要的是,輸出可能比單個緩衝區大,因此需要在循環中將新的緩衝區從鏈中移除,直到將所有輸出寫入緩衝區。在這個邏輯中,Nginx 應該負責將緩衝區從自由鏈中彈出,並將新的塊附加到輸出鏈中。然而,如果只考慮 Nginx 處理其連結列表視圖的方式,可能不會注意到 Rust 從未更改其指向的緩衝區,導致邏輯永遠循環且 Nginx 工作進程完全鎖定。此類問題需要很長時間才能找到,尤其是在了解它與響應體大小有關之前,我們無法在個人計算機上複製它。

使用 gdb 獲取 coredump 執行一些分析也很困難,因為一旦注意到這一點,就已經太晚了,進程內存已經增長到伺服器有崩潰的危險,而且消耗的內存太大,無法寫入磁碟。幸運的是,這段代碼從未投入生產。與以往一樣,雖然 Rust 的編譯器可以幫助發現許多常見錯誤,但如果數據是通過 FFI 從另一個環境共享的,即使沒有太多直接使用,也無濟於事,因此在這些情況下必須格外小心,尤其是當 Nginx 允許某種靈活性可能導致整個機器停止運行時。

工程師們面臨的另一個主要挑戰是來自傳入響應體塊的背壓。本質上,如果 ROFL 必須向流中注入大量代碼(例如用 JavaScript 替換電子郵件地址)而增加了響應的大小,Nginx 可以將 ROFL 的輸出提供給其他下游模塊更快地推動它的速度,如果未處理來自下一模塊的錯誤,則可能導致數據丟失和 HTTP 響應主體被截斷。這是另一個問題很難測試的情況,因為大多數時候,響應會被快速沖洗,背壓不會成為問題。為了處理這個問題,我們必須創建一個特殊的鏈來存儲這些塊,這需要一個附加到它的特殊方法。

#[derive(Debug)]pub struct Chains { /// This saves buffers from the `in` chain that were not processed for any reason (most likely /// backpressure for the next nginx module). saved_in: RefCell<Chain>, pub free: RefCell<Chain>, pub busy: RefCell<Chain>, pub out: RefCell<Chain>, [...]}

實際上,在短時間內對數據進行「排隊」,這樣就不會以超出其他模塊處理能力的速度向其提供數據,從而壓倒其他模塊。《 Nginx 開發人員指南》中有很多很棒的信息,但其中的許多示例都微不足道,以至於不會出現類似的問題。像這樣的事情是基於 Nginx 的複雜環境中工作的結果,需要獨立發現。

沒有 Nginx 的未來

很多人可能會問一個顯而易見的問題:為什麼我們仍然在使用 Nginx?如前所述,Cloudflare 正在很好地替換用於運行 Nginx/OpenResty 代理的組件,或者無需對本土平台進行大量投資的情況下就可以完成的組件。也就是說,一些組件比其他組件更容易替換,而 FL 是 Cloudflare 應用程式服務的大部分邏輯運行的地方,無疑是更具挑戰性的一端。

做這項工作的另一個動機是,無論最終遷移到哪個平台,都需要運行組成 cf-html 的功能,為了做到這一點,希望擁有一個集成度較低且依賴 Nginx 的系統。ROFL 是專門在多個地方運行它而設計的,因此很容易將它移動到另一個基於 Rust 的 Web 代理(或者實際上是我們的 Workers 平台),而不會有太多麻煩。也就是說,很難想像如果沒有像 Rust 這樣的語言,會在同一個地方,它在提供高安全性的同時提供速度,更不用說像 Bindgen 和 Serde 這樣的高質量庫。更廣泛地說,FL 團隊正在努力將平台的其他方面遷移到 Rust,儘管 cf-html 及其組成部分是我們基礎設施中需要工作的關鍵部分,但還有許多其他方面。

程式語言的安全性通常被視為有利於防止錯誤,但作為一家公司,工程師們發現它還允許您做一些被認為非常困難或不可能安全完成的事情。無論是提供類似 Wireshark 的過濾語言來編寫防火牆規則,還是允許數百萬用戶編寫任意 JavaScript 代碼並直接在我們的平台上運行,或是動態重寫 HTML 響應,都有嚴格的界限允許我們提供我們無法提供的服務。儘管安全,但過去困擾行業的內存安全問題正日益成為過去。

Cloudflare 概述了他們如何在 Rust 中重寫 Nginx 模塊,且工程師們也表示非常喜歡 Rust,並在他們的基礎設施中使用它,以獲得內存安全方面的好處、更多的現代功能和其他優勢。

參考連結:

https://blog.cloudflare.com/rust-nginx-module/

https://www.phoronix.com/news/Cloudflare-Rewrite-Nginx-C-Rust

關鍵字: