Pingora:Cloudflare用Rust编写的取代Nginx代理服务器


CloudFlare 如何构建 Pingora:一个用rustlang编写的新 HTTP 代理,它取代了 NGINX,每天服务超过 1 万亿个请求,只需要三分之一我们之前代理基础设施的 CPU 和内存资源。
随着 Cloudflare 的扩展,我们已经超越了 NGINX。多年来NGINX一直很棒,但随着时间的推移,它在我们规模上的局限性意味着构建新的东西是有意义的。我们无法再获得我们需要的性能,NGINX 也没有我们非常复杂的环境所需的功能。

为什么要建立另一个代理
多年来,我们对 NGINX 的使用遇到了限制。对于一些限制,我们优化或解决了它们。但其他人更难克服。

1、架构限制会损害性能
NGINX工作者(进程)架构在我们的用例中存在操作缺陷,这会损害我们的性能和效率。
首先,在 NGINX 中,每个请求只能由单个worker处理。这会导致所有 CPU 内核的负载不平衡,从而导致运行缓慢

由于这种请求进程锁定效应,执行CPU 繁重阻塞 IO 任务的请求可能会减慢其他请求的速度。正如这些博客文章所证明的那样,我们已经花费了大量时间来解决这些问题。

对于我们的用例来说,最关键的问题是糟糕的连接重用。我们的机器与原始服务器建立 TCP 连接以代理 HTTP 请求。连接重用通过重用连接池中先前建立的连接、跳过新连接所需的 TCP 和 TLS 握手来加快请求的 TTFB(第一个字节时间)。

但是,NGINX 连接池是每个worker的。当请求到达某个worker时,它只能重用该worker内的连接。当我们添加更多 NGINX 工作人员以扩大规模时,我们的连接重用率会变得更糟,因为连接分散在所有进程的更多隔离池中。这导致更慢的 TTFB 和需要维护的更多连接,这会消耗我们和我们的客户的资源(和金钱)。

如果我们能够解决根本问题:worker/进程模型,我们将自然而然地解决所有这些问题。

2、某些类型的功能难以添加
NGINX 是一个非常好的 Web 服务器、负载均衡器或简单的网关。但 Cloudflare 的作用远不止于此。我们过去常常围绕 NGINX 构建我们需要的所有功能,但要尽量避免与 NGINX 上游代码库有太多分歧,这并不容易。
例如,当重试/失败请求时,有时我们希望将请求发送到具有不同请求标头集的不同源服务器。但这不是 NGINX 允许我们做的事情。在这种情况下,我们会花费时间和精力来解决 NGINX 的限制。

同时,我们不得不使用的编程语言并没有帮助缓解这些困难。NGINX 纯粹是用 C 语言编写的,这在设计上不是内存安全的。使用这样的第 3 方代码库非常容易出错。即使对于经验丰富的工程师来说,也很容易陷入内存安全问题,我们希望尽可能避免这些问题。

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

而 NGINX 社区也不是很活跃,开发往往是“闭门造车”

3、选择建立我们自己的
在过去几年中,随着我们不断扩大客户群和功能集,我们不断评估三种选择:

  1. 继续投资 NGINX,并可能将其分叉以 100% 满足我们的需求。我们拥有所需的专业知识,但鉴于上述架构限制,需要付出大量努力才能以完全支持我们需求的方式重建它。
  2. 迁移到另一个 3rd 方代理代码库。肯定有好的项目,比如[url=https://dropbox.tech/infrastructure/how-we-migrated-dropbox-from-nginx-to-envoy]envoy[/url]等。但这条道路意味着同样的循环可能会在几年内重复。
  3. 从零开始,构建内部平台和框架。这种选择需要在工程工作方面进行最多的前期投资。

在过去的几年中,我们每季度对这些选项进行评估。没有明显的公式来判断哪种选择是最好的。几年来,我们继续走阻力最小的道路,继续增强 NGINX。然而,在某些时候,建立我们自己的代理的投资回报似乎是值得的。我们呼吁从头开始构建代理,并开始设计我们梦想中的代理应用程序。

Pingora设计决策
为了使代理能够快速、高效和安全地处理每秒数百万个请求,我们必须首先做出一些重要的设计决策。
我们选择Rust作为项目的语言,因为它可以在不影响性能的情况下以内存安全的方式完成 C 可以做的事情。
尽管有一些很棒的现成的第 3 方 HTTP 库,例如hyper,但我们选择构建自己的库是因为我们希望最大限度地提高处理 HTTP 流量的灵活性,并确保我们可以按照自己的节奏进行创新。
在 Cloudflare,我们处理整个 Internet 的流量。我们必须支持许多奇怪且不符合 RFC 的 HTTP 流量案例。

为了满足 Cloudflare 在 HTTP 生态系统中的地位要求,我们需要一个健壮、宽松、可定制的 HTTP 库,该库可以在 Internet 的狂野中生存并支持各种不合规的用例。保证这一点的最好方法是实现我们自己的。

下一个设计决策是围绕我们的工作负载调度系统。我们选择多线程而不是多处理是为了轻松共享资源,尤其是连接池。我们还决定需要工作窃取来避免上面提到的某些类别的性能问题。Tokio 异步运行时结果非常适合我们的需求。

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

Pingora 的生产速度更快
Pingora 处理几乎所有需要与源服务器交互的 HTTP 请求(例如缓存未命中),我们在此过程中收集了大量性能数据。
首先,让我们看看 Pingora 如何加快我们客户的流量。Pingora 上的总体流量显示,TTFB 中位数减少了 5 毫秒,第 95 个百分位数减少了 80 毫秒。这不是因为我们运行代码更快。甚至我们的旧服务也可以处理亚毫秒范围内的请求。
提高原因来自我们的新架构,它可以跨所有线程共享连接。这意味着更好的连接重用率,在 TCP 和 TLS 握手上花费的时间更少。

在所有客户中,与旧服务相比,Pingora 每秒的新连接数只有三分之一。对于一个主要客户,它将连接重用率从 87.1% 提高到 99.92%,这将新连接减少了 160 倍。为了更直观地呈现数字,通过切换到 Pingora,我们每天为客户和用户节省了 434 年的握手时间。

在生产环境中,Pingora 在相同流量负载的情况下,与我们的旧服务相比,消耗的 CPU 和内存减少了约 70% 和 67%。节省来自几个因素。
与旧的Lua 代码相比,我们的 Rust 代码运行效率更高。最重要的是,它们的架构也存在效率差异。例如,在 NGINX/OpenResty 中,当 Lua 代码想要访问 HTTP 头时,它必须从 NGINX C 结构中读取它,分配一个 Lua 字符串,然后将其复制到 Lua 字符串中。之后,Lua 也必须垃圾收集它的新字符串。在 Pingora 中,它只是一个直接的字符串访问。

多线程模型还使得跨请求共享数据更加高效。NGINX 也有共享内存,但由于实现限制,每次共享内存访问都必须使用互斥锁,并且只能将字符串和数字放入共享内存。在 Pingora 中,大多数共享项目可以通过原子引用计数器后面的共享引用直接访问。
如上所述,CPU 节省的另一个重要部分是减少了新的连接。与仅通过已建立的连接发送和接收数据相比,TLS 握手成本很高。

Rust 的内存安全语义保护我们免受未定义行为的影响,并让我们相信我们的服务将正确运行。
有了这些保证,我们可以更多地关注我们的服务更改将如何与其他服务或客户来源进行交互。我们可以以更高的节奏开发功能,而不会受到内存安全和难以诊断崩溃的负担。

事实上,Pingora 崩溃是如此罕见,当我们遇到一个问题时,我们通常会发现不相关的问题。最近,我们的服务开始崩溃后不久,我们发现了一个内核错误。我们还在一些机器上发现了硬件问题,过去排除了由我们的软件引起的罕见内存错误,即使在几乎不可能进行重大调试之后也是如此。

结论
总而言之,我们已经建立了一个更快、更高效、更通用的内部代理,作为我们当前和未来产品的平台。
我们将返回有关我们面临的问题、我们应用的优化以及我们从构建 Pingora 并将其推出以支持互联网的重要部分的经验教训的更多技术细节。我们也将返回我们的开源计划。
Pingora 是我们重写系统的最新尝试,但它不会是我们的最后一次。它也只是我们系统重新架构的基石之一。