【CSDN 編者按】你最常用的開發語言是哪種呢?近日,一位專注於 Linux 性能和開源自動化基準測試的軟體工程師 Michael Larabel 在一篇文章中表示,在 Cloudflare,他們正在用 Rust 編寫的替代方案來取代 Nginx,但 Cloudflare 的基礎設施非常龐大,並且有許多不同的服務在發揮作用。最後他們是怎樣來編寫的呢?一起來看文章內容~
編譯 | 禾木木 責編 | 王子彧
在 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
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.rs
include!(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::null()
];
#[no_mangle]
pub static mut ngx_module_type: [*const c_char; 2] = [
"HTTP_FILTER\0".as_ptr() as *const c_char,
ptr::null()
];
#[no_mangle]
pub static mut ngx_module_names: [*const c_char; 2] = [
"rust_nginx_module\0".as_ptr() as *const c_char,
ptr::null()
];
在編寫 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::null()
];
本質上說,工程師們希望模組恰好在模組之前運行,這應該允許它在預期的位置運行。
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_null() => 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
(&mut self, value: *mut T) -> Result<(), ()> { unsafe {
let cln = ngx_pool_cleanup_add(self.0, 0);
if cln.is_null() {
return Err(());
}
(*cln).handler = Some(cleanup_handler::
); (*cln).data = value as *mut c_void;
Ok(())
}
}
/// Allocate memory for a given value.
pub fn alloc
(&mut self, value: T) -> Option<&'a mut T> { unsafe {
let p = ngx_palloc(self.0, mem::size_of::
()) 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
(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
, pub free: RefCell
, pub busy: RefCell
, pub out: RefCell
, [...]
}
實際上,在短時間內對資料進行「排隊」,這樣就不會以超出其他模組處理能力的速度向其提供資料,從而壓倒其他模組。《 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