团队里关于自建代理的呼声不断涌现,大家越来越觉得这种方式虽然投资较大,但最终回报是值得的。
作者|Yuchen Wu、Andrew Hauck
编译|核子可乐
编辑|燕珊
长期以来,NGINX 可以说是网站安全和托管服务提供商 Cloudflare 的核心,是其所使用的基础软件的一部分。
“Cloudflare 将 NGINX 用于其提供的所有 Web 服务,并在世界各地的数千台机器上使用它作为反向代理服务器。”“我们选择 NGINX 主要是因为它的性能。”Cloudflare CTO John Graham-Cumming 曾如此阐述 NGINX 对 Cloudflare 的重要性。
不过,如今 Cloudflare 决定放弃 NGINX ,转而使用内部开发的 Pingora。理由也和性能有关。
按照官方的说法,随着 Cloudflare 的发展壮大,NGINX 已经无法满足他们的现实业务需求。“虽然 NGINX 多年来一直表现良好,但时间推移之下。其局限性也在我们的持续迭代、规模扩张之下暴露无遗。我们既无法获得理想的性能,NGINX 也没法为高度复杂的环境提供必要的功能支持。”Cloudflare 于 9 月 14 日发布的博文中写道。
Pingora 是 Cloudflare 工程师用 Rust 编写的全新 HTTP 代理系统,专为 Cloudflare 用例及业务规模设计。据介绍,它每天处理超过 1 万亿条请求,提高系统性能之余,也为 Cloudflrae 客户带来不少新功能。更重要的是,它运行所占用的 CPU 和内存资源只相当于原有代理基础设施的三分之一。
目前 Pingora 尚未开源,官方称将找个合适的时机再对外分享。
以下内容源自 Cloudflare,其详细讲述了换掉旧代理的原因,以及他们是如何开发出 Pingora 的。
1为什么要构建新代理
多年以来,NGINX 的种种局限性已经严重影响到我们的业务运营。虽然先后优化或缓解了部分限制,但仍有一部分问题始终得不到完美解决。
架构限制开始拖累性能
NGINX worker(进程)架构在我们的用例中存在缺陷,而且已经损害了 Cloudflare 的性能和效率。
首先,在 NGINX 当中,每条请求只能由单个 worker 处理。这会导致各 CPU 核心的负载不均衡,进而拖慢处理速度。
由于这种请求进程锁定效应,一旦出现高强度 CPU 操作或阻塞 IO 任务的请求,那么其他请求的处理速度也会受到影响。我们已经投入了不少精力尝试解决,但收效不佳。
对我们的用例来说,NGINX 中最大的麻烦还在于糟糕的连接重用机制。我们的设备与原始服务器间通过 TCP 连接来代理 HTTP 请求。连接重用会重复使用连接池中包含的原有连接,由此跳过建立新连接所需要的 TCP 和 TLS 握手,从而缩短 TTFB(第一字节时间)。
然而,NGINX 连接池是按 worker 划分的。当请求到达特定 worker 时,其只能重用该 worker 之内的连接。因此当我们添加更多 NGINX worker 进行扩容时,连接重用率就会变得越来越差,导致大量连接分散在所有进程的多个隔离池内。于是 TTFB 延长了、需要维护的连接数量增加了,我们自己乃至客户的资源(成本)也被白白浪费掉了。
可以看到,NGINX 中的 worker/ 进程模型才是罪魁祸首,所以开发新代理就成了从根源上解决问题的最佳途径。
难以添加某些功能类型
NGINX 其实是款非常出色的 Web 服务器、负载均衡器和简单网关。问题是 Cloudflare 的需求远不止于此。以往,我们常常围绕 NGINX 构建自己需要的各项功能,但同时还要避免同 NGINX 的上游代码库发生严重分歧,这相当于是戴着脚镣跳舞、非常痛苦。
例如,当请求重试 / 失败时,我们往往希望能将请求发送到具有不同请求标头集的其他服务器处。但 NGINX 并不允许这样的操作,所以我们就得投入时间和精力想办法突破 NGINX 的限制。
除了设计上的限制之外,NGINX 的编程语言要求也让我们颇为头痛。NGINX 是纯用 C 语言编写的,因此在设计上不具备内存安全性。在使用第三方代码库时经常会出错,即使对经验丰富的工程师来说,也很容易闹出内存安全问题。我们当然希望能尽量回避掉这些问题。
我们用过的另一种补充性语言是 Lua,它的风险更低,但性能也比较差。另外,在处理复杂的 Lua 代码和业务逻辑时,经常会出现静态类型缺失的问题。
最后,NGINX 社区不太活跃,开发团队往往像在“闭门造车”。
2决定自己开辟一条道路
过去几年以来,随着我们不断扩大客户群体和功能集,Cloudflare 也一直在认真评估以下三种选项:
- 继续投资 NGINX,并尝试通过 fork 让它能 100% 满足我们的需求。我们已经拥有必要的专业知识,但考虑到设计之初的架构限制,恐怕要投入大量资源才能根据自身需求完成全面重建。
- 迁移至其他第三方代理选项。市面上并不缺乏好的项目,比如 envoy 等。但我们担心再过几年,同样的问题也许会在那些项目身上重演。
- 从零开始构建内部平台与框架。这个选项的效果肯定是最好的,问题就是占用的工程资源和产生的前期投入也最多。
过去几年来,我们每个季度都会对这些选项开展评估,但始终没找到最有说服力的答案。于是我们继续走阻力最小的道路,不断增强 NGINX。但团队中关于自建代理的呼声开始涌现,大家越来越觉得这种方式虽然投资较大,但最终回报是值得的。于是我们开始从零入手构建代理,希望能设计出完美匹配自身需求的代理方案。
Pingora 项目
设计决策
为了让代理快速、高效且安全地处理每秒数百万条请求,我们先得做出一系列重要的设计决策。
首先,我们选择用 Rust 编写这个项目。因为它能够在不影响性能的前提下,以内存安全的方式带来可与 C 语言比肩的极佳性能。
虽然市面上已经有不少很棒的第三方 HTTP 库,例如 hyper,但我们还是决定自行构建库。理由很简单,我们想要最大限度提升 HTTP 流量处理的灵活性,并确保按照自己的节奏推动创新。
在 Cloudflare,我们需要面对几乎是整个互联网的流量,必须支持种种稀奇古怪、不符合 RFC 的 HTTP 流量。这也是 HTTP 社区和 Web 领域的常见难题,即如何在严格遵循 HTTP 规范的同时,适应潜在遗留袖或服务器同广泛生态系统间的细微差别与紧张关系。
在 RFC 9110 中,HTTP 状态码被定义成一个三位整数,通常区间在 100 到 599 之间。Hyper 就是这样一种实现。但也有不少服务器支持使用 599 到 999 之间的状态码。这种冲突曾引发过激烈的争论,虽然 hyper 团队最终接受了这一变更,但如果不接受其实也没有办法——毕竟双方都有自己的道理可讲。而这,还只是我们需要支持的种种不合规行为中的冰山一角。
为了满足 Cloudflare 在 HTTP 生态系统中主导性地位的要求,我们必须建立起一个健壮、宽松、可定制的 HTTP 库,适应互联网上的狂野法则、支持种种不合规用例。好在由于是内部原创,所以至少决定权把握在我们自己手中。
下一项设计决策则跟工作负载调度系统有关。我们选择了多线程、而非多进程,目的是为了轻松实现资源共享,特别是连接池共享。我们还决定用工作窃取机制来避免前文提到的某些性能问题。事实证明,Tokio 异步运行时特别符合我们的需求。
最后,我们希望这个项目能直观些、对开发者们友好些。我们要开发的并不是最终产品,而是可以进一步扩展的平台,允许在其上构建更多功能。于是,我们决定提供一个类似于 NGINX/OpenResty 的基于“请求生命周期”事件的可编程接口。举例来说,可以后续编写“请求过滤器”帮助开发人员在收到请求标头时,运行相应代码来修改或拒绝请求。通过这样的设计,我们就能清晰地把业务逻辑和通用代理逻辑分离开来,同时保证之前接触 NGINX 的开发人员也能轻松转向 Pingora、迅速提高工作效率。
3Pingora 在生产中速度更快
让我们快进到现在,Pingora 已经在处理几乎一切需要与源服务器交互的 HTTP 请求(例如缓存未命中的情况),我们在此期间也收集到了大量性能数据。
首先,我们来看看 Pingora 如何推动客户流量提速。Pingora 上的总体流量显示,TTFB 中位数降低了 5 毫秒,第 95 百分位 TTFB 降低了 80 毫秒。这当然不是因为我们的代码运行更快了,毕竟原封不动的旧服务现在也可以将请求响应控制在亚毫秒范围内。
这样的节约源自新架构,特别是它跨所有线程实现连接共享的能力。凭借着更好的连接重用率,我们在 TCP 和 TLS 握手上耗费的时间大为缩短。
与旧服务相比,Pingora 将全体客户的每秒新连接使用量降低至三分之一。对其中一家主要客户,其连接征用率从之前的 87.1% 提升到了 99.92%,意味着新连接数量降低至原本的一百六十分之一。为了更直观地感受这种变化,大家不妨看看这个数字:自从使用 Pingora 以来,我们每天能够为客户和用户节约下长达 434 年的握手时间。
更多功能
有了工程师们熟悉的开发者友好接口,又消除了以往令人头痛的限制,我们自然可以快速开发出更多新功能。凭借新的协议等核心功能,我们现在能够为客户提供更多产品构建块。
例如,我们能够以相对简单的方式为 Pingora 添加 HTTP/2 上游支持,由此加快了向客户提供 gRPC 的速度。很明显,要想将这项功能添加进 NGINX,不只是涉及的工程量更大、甚至有可能压根无法实现。
最近,我们又公布了 Cache Reserve,Pingora 在其中使用 R2 存储作为缓存层。随着我们向 Pingora 添加更多功能,相信未来将提供更多开创性的新产品。
更高效率
在生产环境中, 面对同等流量负载的情况下,Pingora 所消耗的 CPU 和内存资源量与旧有服务相比,分别降低了约 70% 和 67%。这样可观的资源节约源自以下几大要素。
与旧的 Lua 代码相比,我们的 Rust 新代码运行效率更高。更重要的是,二者在架构上也存在显著的效率差异。以 NGINX/OpenResty 为例,当 Lua 代码想要访问 HTTP 标头时,必从 NGINX C 结构中进行读取、分配一个 Lua 字符串,然后将该标头复制到 Lua 字符串内。最后,Lua 还得对这个新字符串进行垃圾回收。而 Pingora 不同,它能直接执行字符串访问,就这么简单。
多线程模型也让跨请求数据共享变得更加高效。NGINX 虽然也提供共享内存,但由于实现限制,每次共享内存访问都需要使用互斥锁,而且只能将字符串和数字放入共享内存。在 Pingora 中,大多数共享条目都能通过原子引用计数器后的共享引用进行直接访问。
至于 CPU 的节约,主要体现在前文提到的新连接创建量显著降低之上。不同于会造成高昂 TLS 握手成本的旧方案,Pingora 可以更多通过已建立的连接实现数据发送和接收。
更安全
快速安全地发布功能绝非易事,在 Cloudflare 这样庞大的运营规模下更是困难重重。我们几乎无法预测每秒要处理几百万条请求的分布式环境中可能发生哪些极端状况,毕竟模糊测试和静态分析根本就覆盖不到这样的场景。这时候,Rust 的内存安全语义保护挺身而出,在保护我们免受未定义行为困扰的同时,也让我们坚定相信自己的服务能够正确运行。
有了这些保障,我们就能更多关注自己的服务变更如何与其他服务或客户源进行交互。我们能以更快的节奏开发出新功能,不再受到内存安全和种种未知崩溃的拖累。
而一旦真的发生了崩溃,工程师们当然要花时间来诊断崩溃如何发生、背后又有怎样的原因。自 Pingora 运行以来,我们已经先后处理过数百万亿条请求,还没遇到过任何一次源自服务代码的崩溃问题。
事实上,Pingora 的崩溃可以说非常罕见,每次出现的问题都跟 Pingora 自身没什么关系。最近,我们在一次服务崩溃中发现了一个内核 bug,还在某些设备上发现了硬件问题。这种感觉简直神奇,长久以来最不稳定的软件元素现在却成了最可靠的部分,甚至足以支持我们发现硬件层面的内存 bug……之前无数次重大调试都找不到这些问题,原因自然是当时不等硬件崩溃,软件早已经坚持不住了。
4总结
总而言之,我们建立起一套更快、更高效、更通用的内部代理,并把它当成现有及未来产品的运行平台。
借此机会,我们重新将视线集中到 Cloudflare 面临的问题、值得探索的优化空间,以及 Pingora 开发过程中积累下的重要经验教训与技术细节身上。我们也将回归开源精神,找个适合的机会与大家分享更多后续成果。
总之,Pingora 是我们重构系统的一次最新尝试,但绝不会是最后一次。期待 Pingora 能成为我们全面系统重构的重要基石。
原文链接:
https://blog.cloudflare.com/how-we-built-pingora-the-proxy-that-connects-cloudflare-to-the-internet/