您当前的位置:首页 >  快讯  > 正文
没有 Nginx 的未来,Cloudflare 工程师正在用 Rust 重构代码!-每日速递
来源:CSDN     时间:2023-03-08 03:56:58

【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 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 buildCompiling 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(&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::);(*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

标签:

相关新闻

X 关闭

X 关闭

精彩推荐