《架构师》2023年3月
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

没有NGINX和OpenResty的未来:Cloudflare工程师正花费大量时间用Rust重构现有功能

作者 Tina 核子可乐

在Cloudflare公司,工程师们正在花费大量时间重构或重写现有功能。

当每年处理的流量增长一倍时,原本最优雅的问题解决方案往往会随着工程约束条件的变化而迅速过时。不仅如此,面对每秒高达4000万的请求总量,即使流经Cloudflare网络的全部请求中有0.001%发生问题,代表的也是冲击数百万用户的大事件。或者从另一个角度讲,发生概率仅为万亿分之一的罕见事件在这样的运行规模下每天都会出现。这就是Cloudflare所面临的最大的问题。

长期以来,Cloudflare一直依赖Nginx作为其HTTP代理堆栈的一部分。随着Cloudflare规模的扩大,NGINX的处理能力已经不能满足业务需求了。

去年9月,他们宣布已将Nginx替换为其内部由Rust编写的Pingora软件,想以此建立一个更快、更高效、更通用的内部代理,作为他们当前和未来产品的平台。

但这还不是全部,上周Cloudflare又发布了一篇博客称,他们用Rust编写了Cloudflare基础设施中最古老和最不为人所知的部分cf-html的替代品。Cf-html是一套用于在网站源到网站访问者之间解析并重写HTML的框架。从创立之初起,Cloudflare就提供相关功能,可以为用户即时重写Web请求的响应正文。它位于Cloudflare核心反向Web代理FL(Front Line)之内。FL运行着Cloudflare应用程序的大部分逻辑,因此无疑这次替换更具有挑战性。

同时FL作为OpenResty的一部分运行在NGINX之上,“通过这样做,我们为完全摆脱NGINX铺平了道路。”“很明显,这套曾经代表着开发者易用性与速度性最佳组合的平台,已经开始显露出明确的时代局限性。”

“我们正在逐步替换掉用于运行NGINX/OpenResty代理的组件”,从而构建一个“没有NGINX的未来”。

“内存安全”也是一大原因

FL主要由Lua脚本语言编写的代码组成,作为OpenResty的一部分运行在NGINX之上。为了直接与NGINX进行交互,其中某些部分(如cf-html)是用C和C++等低级语言编写的。

过去,Cloudflare掌握着大量这样的OpenResty服务,但现在留下的就只有FL等为数不多的几种了,其他组件已经被转移到了Workers或者基于Rust的代理处

所有cf-html逻辑都是用C语言编写的,因此跟其他大型C代码库一样,它也容易受到内存损坏问题的困扰。2017年,团队在尝试替换部分cf-html时就曾引发安全漏洞。FL从内存中读取任意数据并将其附加至响应主体,而这可能包含同一时间通过FL的其他请求中的数据。这次安全事件,也就是后来广为人知的Cloudbleed

自那次事件以来,Cloudflare实施了式项政策和保障措施,以确保不再发生类似的问题。虽然多年来一直在cf-html上开展工作,但该框架几乎没有实现什么新功能。现在的业务情况导致大家对FL中的任何崩溃都非常敏感(当然,对网络上任何进程的崩溃都很敏感),特别是那些可能影响到响应性能的问题。

时间快进到2022、2023年,FL Platform团队收到的请求越来越多,大家希望改用新的系统,从而轻松查看和重写响应主体数据。与此同时,Cloudflare的另一支团队一直在为Workers开发新的响应主体解析和重写框架,名为lol-html,即低输出延迟HTML。Lol html不仅比Laxy HTML更快、更高效,而且目前已经在Worker接口中得到了全面的生产应用。另外,它是用Rust编写的,所以在内存处理方面比C语言安全得多。

“总而言之,Lol-html正是我们用来替换FL中古老陈旧HTML解析器的理想选项。”Cloudflare的工程师Sam Howson在博客中表示。

于是乎,该团队开始尝试用Rust编写新的框架,让该框架把lol-html合并进来。这样既能帮助其他团队编写响应主体解析功能,又不会造成大量安全问题。新系统被定名为ROFL,即FL响应监工(Response Overseer for FL),这是一个完全用Rust编写的全新NGINX模块。

截至目前,ROFL已经在生产环境中每秒处理数百万个响应,其性能可以与cf-html相媲美。“在构建ROFL的过程中,我们得以弃用Cloudflare整个代码库中最糟糕的部分,同时给Cloudflare各团队提供了一套强大系统,供他们以响应主体数据解析和重写为基础编写出更多功能。”

个中挑战

给飞机更换引擎总会有一些挑战。

NGINX模块系统也给各模块的工作方式提供了极大的灵活性,使其能高度匹配特定用例。当然,这种灵活性设计也有相应的问题。他们遇到的一大挑战,跟Rust和FL之间的响应数据处理方式有关。在NGINX当中,响应主体会被拆分成块,之后将这些块串连起来形成一个列表。另外,如果响应规模很大,则每条响应可能对应多个链接列表。

要想高效处理这些块,就需要加快对各个块的处理和传递速度。在编写用于操作响应的Rust模块时,大家往往会想到在链表中采用基于Rust的视图。但如果这样做,就必须确保在变更基于Rust的视图时,也一并更新底层NGINX数据结构,否则Rust与NGINX间的不同步会导致严重bug。Cloudflare工程师以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.metric
s.borrow_mut()) 
            .expect("Could not get a free chain link."); 
        let mut link_buf = unsafe { TemporaryBuffer::from_ngx_buf(&mut *(*fre
e_link).buf) }; 
        data = link_buf.write_data(data).unwrap_or(b""); 
        out_chain.append(free_link); 
    } 
} 

这段代码的设计目标是获取lol-html的HTMLRewriter输出,并将其写入缓冲区的输出链。重要的是,输出可能大于单一缓冲区,所以需要在循环内从链中取出新的缓冲区,直到将所有输出均写入缓冲区。在这样的逻辑中,NGINX应该负责从空闲链中取出缓冲区,再将新块附加到输出链上。它确实也是这么做的,但如果只考虑NGINX处理链表视图的方式,往往会忽视Rust不会更改其free_chain.head所指向的缓冲区。

这就导致逻辑永远循环,且NGINX工作进程完全锁定。这类问题可能需要很长时间才能发现,特别是在意识到其根源与响应主体的大小有关之前,他们甚至没法稳定地加以重现。

另外,使用gdb获取coredump来执行分析也很困难,因为当大家注意到内存占用过量而开始写入硬盘时,进程内存已经增长到了可能令服务器崩溃的程度,这时候做什么都太晚了。幸运的是,这段代码从没被投入生产环境。跟以往一样,虽然Rust编译器能帮助团队发现很多常见错误,但如果数据是通过FFI共享自另一个环境,那么即使不直接使用unsafe也会存在很多隐患。所以必须格外小心,特别是在NGINX的这种灵活性“优势”可能导致整台设备停止服务的情况下。

该团队面临的另一个重大挑战,跟传入响应正文块的背压有关。本质上,如果ROFL因必须向传输流中注入大量代码(例如用大量JavaScript替换电子邮件地址)而增加了响应的大小,则NGINX能以比单独推出更快的速度,将ROFL的输出提供给其他下游模块。这时如果下一模块的EAGAIN错误未得到处理,则可能导致数据被丢弃、HTTP响应主体被截断。这也是个很难通过测试发现的问题,因为大多数时候响应的刷新速度是够的,背压并不会造成影响。为了将其解决,他们创建了一条特殊的链来存储这些被称为saved in的块,再用一种特殊的方法完成附加。

#[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>, 
    [...] 
} 

实际上,Cloudflare工程师们决定在短时间内对数据进行“排队”,这样就不会因为提供速度超出处理速度而冲垮其他模块。NGINX开发者指南中提供了很多重要的信息,但这里的案例显得比较孤立、不成体系,所以并没有包含他们遇到的问题。这类情况其实是NGINX复杂工作环境所催生出的结果,往往只有少数用户能够发现。

写在最后

Cloudflare工程师对Rust表现出了极度的热爱,并在整个基础设施中使用它来获得内存安全优势、更现代的功能和其他优势。

该博客还指出,他们正在招聘更多的Rust工程师,并谈到了Rust对他们的好处:

“这里我们要特别感谢Rust的存在,只有这款语言能让我们在获得速度优势的同时实现高安全性,并获得Bindgen和Serde等高质量库的协助。

大多数人认为编程语言的安全性优势只体现在预防bug上,但事实不仅如此——作为一家规模化企业,我们发现语言的安全优势还能让某些极度困难、甚至原本认为与安全相悖的目标成为现实。

无论是用类似Wireshark的过滤语言来编写防火墙规则、允许数百万用户编写任意JavaScript代码并直接在我们平台上运行,还是即时重写HTML响应,Rust都为我们的服务划定了严格的执行边界,让这种种不可能成为了可能。我们也欣慰地看到,Rust的普及正逐步将那些曾经困扰开发行业的内存安全问题丢进历史的垃圾堆。”