[译] 我们为何创造 Pingora -- Cloudflare 与互联网之连接的代理

原文地址: https://blog.cloudflare.com/how-we-built-pingora-the-proxy-that-connects-cloudflare-to-the-internet/

引言

今天我很荣幸的介绍 Pingora, 这我们使用 Rust 进行自研的全新 HTTP 代理, 每天能够承载超过 1 万亿次请求, 提高了我们的性能, 并为 Cloudflare 的客户带来了许多新特性, 而所有这些仅需之前代理基础设施 CPU/内存 资源的三分之一.

随着 Cloudflare 的规模扩大, 我们的需求超过了 NGINX 所能处理的能力. 多年来它运行良好, 但随着时间的推移却限制了我们的规模, 意味着我们必要构建些新东西. NGINX 无法满足我们的性能要求, 也缺少我们复杂环境下所需的功能.

许多客户使用 Cloudflare 全球网络作为 HTTP 客户都 (如: 浏览器/apps/物联网设备,等) 与服务器之间的代理. 过去, 我们讨论了许多关于浏览器与其他用户代理怎么连接我们网络, 并且我们也开发了许多技术和实现了新的协议(如 QUICHTTP/2 的优化) 使连接更加高效.

如今, 我们将要关注等式的另一部分: 在我们网络和互联网上服务器之间代理流量的服务. 这个代理服务为我们的 CDN、Workers fetch、Tunnel、Stream、R2以及许多其他功能和产品提供了支持.

让我们研究为什么我们选择取代我们的旧版服务以及 Pingora 的开发过程, 这是我们专门为 Cloudflare 的客户用例和规模而设计的新系统.

为什么要构建另一个代理

多年来, 我们对 NGINX 的使用常面临困境. 其中一些限制能够通过优化或者绕过进行处理. 但有些难以解决.

架构限制了性能

NGINX 的 worker (进程) 架构 对于我们的用例而言存在缺点以至于性能和效率遭到降低.

首先, 在 NGINX 中, 每个请求只能由单个 Worker 进行处理. 这回导致 CPU 间的负载不均, 进而减慢速度.

由于 请求-进程 锁定的影响, 需要执行大量 CPU 密集型 或者 I/O 阻塞的任务, 将会减慢其他请求的速度. 正如其他文章所证明的那样, 我们花费了大量的时间解决这些问题.

对于我们使用的场景来说, 较为关键的问题是糟糕的连接复用. 我们的机器与源服务器建立 TCP 连接用于代理 HTTP 请求. 连接复用可以通过连接池已经建立的连接, 跳过 TCP / TLS 的握手, 加快请求的 TTFB (time-to-first-byte 首字节时间).

然而, NGINX 连接池 与 Worker 关联在一起(不同的 Worker 不能共享). 当请求到达某个 Worker 时, 只能使用该 Worker 中已有的连接. 当我们添加更多的 NGINX Worker 进行扩展时, 我们的连接复用率将会变的更糟, 因为连接分散在所有 Worker 中孤立的池中. 这导致 TTFB 变慢以及需要维护更多的连接, 进而消耗我们和客户的资源(和金钱).

就如以前文章所提到的, 我们为其中的一些问题提供了解决方案. 但如果我们能够根本的解决问题: Worker/Process 模型, 这些问题自然就消失了.

难以新增某些类型的功能

NGINX 是一个非常棒的web服务器、负载均衡器或简单的网关. 但是 Cloudflare 需要的远不止如此. 我们常围绕 NGINX 构建我们所需的功能, 但是又要避免与 NGINX 上游代码库有太多的分歧, 这十分不容易.

例如, 当重试请求/请求失败时, 有时我们想要将这个请求转发到不一样的源服务器并使用不同的请求头. 但是这是 NGINX 所不允许的. 这种情况下, 我们需要花费时间精力解决 NGINX 的限制.

与此同时, 我们被迫使用的编程语言也没有缓解我们解决这些困难. 由于 NGINX 是使用纯粹的 C 语言编写的, 由于 C 语言在内存设计上不是内存安全的. 与第三方代码库一起使用非常容易出问题, 即使是经验丰富的工程师来说, 也很容易陷入内存安全问题, 我们希望尽可能的避免这些问题.

我们用来辅助 C 的另一种语言是 Lua, 虽然它的风险较小但性能也较差. 此外, 在处理复杂的 Lua 代码和业务逻辑时, 我们经常发现自己缺少静态类型检查.

此外, NGINX 的社区也不够活跃, 开发往往是“闭门造车”.

选择自己打造

在过去几年中,随着我们不断扩大客户群和功能集,我们持续评估三种选择:

  1. 继续投入 NGINX 并且建立分支, 使其能 100% 符合我们的需求. 我们拥有所需的专业知识, 但是考虑到上述的架构局限性, 重构它以符合我们的要求需要付出巨大的努力.
  2. 迁移到另一个第三方的代理. 肯定有不错的项目, 类似于envoy或者其他. 但是这个选择在几年后可能会重蹈覆辙.
  3. 从零开始,构建内部平台和框架. 这种选择在工程前期投入巨大.

过去的几年中我们每个季度都会对这些选项进行评估. 没有具体的算法表明哪种方式是最好的. 这几年中, 我们继续走阻力最小的道路, 继续增强 NGINX. 然而, 某些时候, 构建自由的代理投资回报率似乎更加值得. 我们呼吁从零开始构建并设计我们理想中的代理程序.

Pingora 项目

设计决策

为了打造一个每秒提供数百万次请求且快速、高效和安全的代理, 我们必须首先做出一些重要的设计决定.

我们选择 Rust 作为项目的语言, 因为它可以在不影响性能的情况下以内存安全的方式完成 C 可以做的事情.

尽管有一些很棒的第三方库, 例如 Hyper, 我们选择构建自己的库是因为想要能以最大限度的灵活性处理 HTTP 流量, 并确保我们能够按照自己的节奏进行创新.

在 Cloudflare, 我们处理整个互联网的流量. 我们必须支持多种奇怪且不符合 RFC 规则的 HTTP 流量. 这是整个 HTTP 社区和 Web 的普遍困境. 在严格遵循 HTTP 规范和适应潜在遗留客户端或服务器的广泛生态系统的细微差别之间存在矛盾和冲突, 需要在其中作出艰难抉择.

HTTP 状态码在 RFC 9110 中定义为一个三位整数, 通常预期在 100 到 599 的范围内。Hyper 就是按照这样进行实现的. 但是, 许多服务器支持使用 599 到 999 之间的状态代码. 为此特性还创建了一个 issue进行了多方面的辩论. 虽然 hyper 团队最终确实接受了这一要求, 但他们有充分的理由拒绝这样的要求, 而这只是我们需要支持的众多不合规行为中的一个.

为了满足 Cloudflare 在 HTTP 生态系统中占据一席之地, 我们需要一个稳健、宽松、可定制的 HTTP 库, 并支持各种不合规的情况, 便于在狂野的互联网上存活. 保证这一点的最佳方法就是我们自己实现.

下一个设计决策是围绕我们的工作负载调度系统. 我们选择多线程而不是多进程,是为了方便分享资源, 特别是连接池. 我们还决定需要工作窃取 (work stealing)来避免上述某些类别的性能问题. 事实证明,Tokio 异步运行时非常适合我们的需求.

最后, 我们希望我们的项目是直观的, 对开发者友好的. 我们构建的并不是产品的最终形态, 它应该是可扩展的平台, 更多的特性可以基于此进行构建. 我们决定实现一个类似于 NGINX/OpenResty 的基于 “请求的生命周期” 事件的可编程接口. 例如, “请求过滤器” 阶段允许收到请求头时修改或拒绝请求. 通过这种设计, 我们可以清晰地分离我们的业务逻辑和通用代理逻辑. 之前从事 NGINX 工作的开发人员可以轻松切换到 Pingora 并迅速提高工作效率.

Pingora 在生产中更快

让我们快进到现在. Pingora 处理了几乎所有需要与源服务器交互的 HTTP 请求 (例如, 缓存未命中), 我们在此过程中收集了很多性能数据.

首先, 让我们看看 Pingora 如何加速客户流量. Pingora 上的总体流量显示, TTFB 中位数减少了 5 毫秒, 第 95 个百分位数减少了 80 毫秒. 并不是我们的代码更快. 即使是我们的旧服务也可以处理亚毫秒范围内的请求.

因为我们的新架构节约了时间, 因为能跨所有线程共享连接. 这意味着更高的连接复用率, 花费在 TCP 和 TLS 握手上的时间更少.

与旧服务比较, Pingora 将所有客户每秒创建的新连接数量降低到原来的三分之一. 对于一个大客户来说, Pingora 将连接复用率从87.1%提高到99.92%, 创建新连接减少了 160 倍. 更加直观的说, 通过切换到Pingora, 我们每天为我们的客户和用户节省了434年的握手时间.

更多功能

拥有工程师熟悉的开发人员友好界面, 同时消除以前约束条件, 使我们能够更快地开发更多功能. 就比如支持新协议这样的核心功能充当我们为客户提供更多产品的基石.

例如, 我们能为 Pingora 添加 HTTP/2 upstream 的支持而不受阻碍. 这让我们不久之后就能为客户提供 gRPC. 将相同的功能添加到 NGINX 中需要更多的努力, 并且可能无法完成.

最近, 我们推出了 Cache Reserve, 其中 Pingora 使用 R2 存储作为缓存层. 随着我们向 Pingora 添加更多功能, 我们能够提供以前无法提供的新产品.

更高效

在生产环境中, 与我们的旧服务相比, Pingora 在相同流量负载的情况下, 消耗的 CPU 和内存减少了约 70% 和 67%. 节省来自几个因素.

与旧的 Lua 代码相比, Rust 代码执行效率更高. 最重要的是, 二者在架构也存在效率差异. 例如, 在 NGINX/OpenResty 中, 当 Lua 代码想要读取 HTTP 头时, 它必须从 NGINX C 结构中读取它并分配复制到一个 Lua 字符串. 之后, Lua 还对其创建的字符串进行垃圾回收. 在 Pingora 中, 它只是一个能直接访问的字符串.

多线程模型还使得跨请求共享数据更加高效. NGINX 中的共享内存因为实现时的限制, 每次共享内存访问都必须使用互斥锁,并且只能共享字符串和数字. 在 Pingora 中, 大多数共享项目可以通过原子引用计数器背后的共享引用直接访问.

另一个显着的 CPU 节省部分来自上面提及的新连接创建的减少. 与使用已有连接发送接受数据相比, TLS 握手的代价昂贵.

更安全

快速、安全地发布功能绝非易事, 尤其是在我们这种规模下. 很难在每秒数百万请求的分布式环境中预测极端情况. 模糊测试(Fuzzing)和静态分析(static analysis)只能缓解部分. Rust 的内存安全语义保护我们免受未定义行为的影响, 并让我们相信我们的服务将正确运行.

有了这些保证,我们可以更多地关注如何改变我们的服务与客户的源服务器进行交互. 我们能够以更块的节奏开发功能, 而不会因内存安全和难以诊断的崩溃而被拖累.

当崩溃实际发生时, 工程师需要花时间来诊断它是如何发生的以及是什么原因造成的. 自 Pingora 创立以来, 我们已经处理了数百万亿个请求, 至今尚未因为我们的服务代码而崩溃.

实际上, Pingora的崩溃是如此罕见, 以至于我们通常在遇到这种情况时发现与之无关的问题. 最近在我们的服务崩溃后, 我们发现了一个内核漏洞. 过去我们也曾发现过一些机器上的硬件问题, 即使经过了大量的调试, 也很难排除由我们的软件引起的罕见的内存错误.

总结

总之, 我们建立了一个内部代理, 作为我们当前和未来产品的平台, 它更快、更高效、更通用.

我们之后将介绍有关我们面临的问题和应用优化的更多技术细节, 我们在构建Pingora和将其推出为互联网重要部分所面临的问题. 我们之后也将介绍我们的开源计划(We will also be back with our plan to open source it).

Pingora 是我们重构系统的最新尝试,但不会是我们的最后一次尝试。它也只是我们系统重新架构的基石之一。

Author: Sean
Link: https://blog.whileaway.io/posts/ee9b56e5/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.