“
Pingora /pɪŋˈgɔːrə/ ,是位于美国怀俄明州的一座山峰。
“
我们今天暂时不卷「 Rust 与 LLM」主题了,正好昨天 Cloudflare 开源了 Nginx 的平替 Pingora ,我们来讲讲 Pingora。 这在业界算是大事件,很多人可能不看好 Pingora ,那么就先阅读一下本文先了解下 Pingora 再做评判。
早在 2022 年 Cloudflare (下面简称 CF)就发过一篇文章: 将 Cloudflare 连接到互联网的代理——Pingora 的构建方式[1] 。
在该文章中披露,CF 已经在内部生产使用一个 Rust 实现的名为 Pingora 的 HTTP 代理,每天处理请求超过一万亿个,并且只占用以前代理基础架构的三分之一的 CPU 和内存资源。 这个代理服务用语 CF 的 CDN、Workers fetch、Tunnel、Stream、R2 以及许多其他功能和产品。
CF 之前的 HTTP 代理是基于 Nginx 构建的。
Nginx 之前一直运行良好。但是随着 CF 的业务规模逐渐扩大,Nginx 的瓶颈也凸显,无法再满足 CF 的性能和复杂环境下所需功能的需求。
CF 团队对 Nginx 也做了很多优化。然而,Nginx 的 Worker 进程架构是 CF 性能瓶颈的根源。
在 NGINX 中,每个请求只能由单个 worker 处理。这会导致所有 CPU 内核之间的负载不平衡[2],从而导致速度变慢[3]。
对 CF 的应用场景来说,最关键的问题是糟糕的连接重用。NGINX 连接池[4]与单个 worker 相对应。当请求到达某个 worker 时,它只能重用该 worker 内的连接。当 CF 添加更多 NGINX worker 以进行扩展时,连接重用率会变得更差,因为连接分散在所有进程的更多孤立的池中。这导致更慢的 TTFB 以及需要维护更多连接,进而消耗 CF 和客户的资源(和金钱)。
除了架构问题,使用 Nginx 还面临有些类型的功能难以添加的问题。
例如,当重试请求/请求失败[5]时,有时希望将请求发送到具有不同请求标头集的不同源服务器。但 NGINX 并不允许执行此操作。在这种情况下,CF 需要花费时间和精力来解决 NGINX 的限制。
另外,NGINX 完全由 C 语言编写的,这在设计上不是内存安全的。使用这样的第 3 方代码库非常容易出错。即使对于经验丰富的工程师来说,也很容易陷入内存安全问题[6], CF 希望尽可能避免这些问题。
CF 用来补充 C 语言的另一种语言是 lua
。它的风险较小,但性能也较差。此外,在处理复杂的 Lua 代码和业务逻辑时,CF 经常发现自己缺少静态类型[7]。
而且 NGINX 社区也不是很活跃,开发往往是“闭门造车”[8]。
经过团队综合评估,包括评估使用像 envoy 这种第三方代理库,最终决定自建代理。于是就有了 Pingora。
经过两年的内部使用,到 2024 年的今天,我们才看到开源的 Pingora 。毕竟要注重安全性,无论是内存安全还是信息安全,经过实际检验审查后开源对 CF 来说更稳妥。
在 2017 年 2 月 的某个周五,Cloudflare 团队接到了 Google Project Zero 团队成员Tavis Ormandy 的安全报告,他发现通过 Cloudflare 运行的一些 HTTP 请求返回了损坏的网页。
事实证明,在一些异常情况下,CF 的边缘服务器有内存泄露,并返回了包含隐私信息的内存,例如 HTTP cookies、身份验证令牌、HTTP POST主体和其他敏感数据。
CF 在发现问题后的 44 分钟内停止了这个漏洞,并在 7 小时内完全修复了这个问题。然而,更糟的是,一些保存的安全信息被像 Google、Bing 和 Yahoo 这样的搜索引擎缓存了下来。
这就是知名的 Cloudbleed[9] 安全漏洞。它是 CF 的一个重大安全漏洞,泄露了用户密码和其他可能敏感的信息给数千个网站,持续了六个月。
“
《The Register》将其描述为「坐在一家餐厅里,本来是在一个干净的桌子上,除了给你递上菜单,还给你递上了上一位用餐者的钱包或钱包里的东西」。
Cloudbleed
这个名字是Tavis Ormandy 命名的,一种玩笑式地纪念 2014 年的安全漏洞Heartbleed
。然而,Heartbleed 影响了50万个网站。
那么这个安全漏洞的根源在哪里呢?
因为 Cloudflare 的许多服务依赖于在其边缘服务器上解析和修改 HTML 页面,所以使用了一个 Ragel[10](一个状态机编译器) 编写的解析器,后来又自己实现了一个新的解析器 cf-html。这两个解析器共同被 CF 作为 Nginx 模块直接编译到了 Nginx 中。
旧的 Ragel 实现的解析器实际上包含了一个隐藏了多年的内存泄露 Bug,但是由于 CF 团队使用这个旧解析器的方式(正好避免了内存泄露)没有把这个 Bug 暴露出来。引入新解析器 cf-html 之后,改变了旧解析器的使用方式,从而导致内存泄露发生了。
其实内存泄露本身不算内存安全(Safety)问题。但是因为 CF 泄露的内存(未正常回收)中包含了敏感数据,那就造成了信息泄露,属于信息安全问题(Security)。
那么这个内存泄露的根源又在哪里呢?
Ragel 代码会生成 C 代码,然后进行编译。C 代码使用指针来解析 HTML 文档,并且 Ragel 本身允许用户对这些指针的移动有很多控制。问题根源正是由于指针错误引起的。
`/* generated code */ if ( ++p == pe ) goto _test_eof; `
错误的根本原因是使用等号运算符来检查缓冲区的末尾,并且指针能够越过缓冲区的末尾。这被称为缓冲区溢出。
如果使用>=
进行检查而不是 ==
,就能够捕捉到越过缓冲区末尾的情况。等号检查是由 Ragel 自动生成的,不是 CF 编写的代码的一部分。这表明 CF 没有正确使用Ragel 。
CF 编写的 Ragel 代码中存在一个错误,导致指针跳过了缓冲区的末尾,并超出了==
检查的能力,未发现缓冲区溢出。
这段包含缓冲区溢出的代码,在 CF 的生产环境运行了很多年,从未出过问题。但是当新解析器被增加的时候,代码架构和环境发生了变化,潜藏多年的缓冲区溢出 Bug 终于得到了“苏醒的机会”。
总的来说,这次内存泄露导致的信息安全问题,本质还是因为内存安全引发的。
这次严重的安全问题对于 Cloudflare 来说,几乎是致命的。
因为 Cloudflare 的使命是:“我们保护整个企业网络,帮助客户高效构建互联网规模的应用程序,加速任何网站或互联网应用程序,抵御分布式拒绝服务攻击,防止黑客入侵,并可以帮助您在零信任的道路上前进”。
一家伟大的技术服务公司,如果因为小小的缓冲区溢出而倒下,是多么地可惜呢?
目前 Pingora 刚开源,还没有形成开箱即用的生态。
不过不用担心。
由 ISRG 主导开发 Prossimo 项目(也主导开发了 sudo-rs[11] )宣布,将与Cloudflare、Shopify 和 Chainguard 合作,计划构建一个新的高性能和内存安全的反向代理 river[12],将基于 Cloudflare 的 Pingora 构建。
River 的预计包括以下重要特性:
采用异步多线程模型。连接重用的效果要优于 Nginx 。
基于 WebAssembly 支持脚本功能。意味着,可以支持任何能够编译为 WASM 的语言来写脚本。
简单的配置。吸取过去几十年配置其他软件的所有教训。
用 Rust 实现。避免内存安全问题。
该项目计划本年度第二个季度启动,感兴趣的可以去围观或参与。
CloudFlare Pingora[13] 现已开源,代码量大约是 3.8 万行 Rust 代码。
Pingora 是一个用于构建快速、可靠和可编程网络系统的 Rust 框架。它经过了“战斗”的考验,因为它已经连续几年每秒处理超过 4000万 个互联网请求。
特色亮点:
异步 Rust:快速可靠
HTTP 1/2 端到端代理
TLS 支持( OpenSSL 或 BoringSSL,这些库具备 FIPS 合规性和post-quantum[14]加密 )
gRPC 和 WebSocket 代理
零停机优雅重启,在升级自身时不会丢失任何一个传入的请求
可定制的负载均衡和故障转移策略
支持各种可观测性工具。Syslog、Prometheus、Sentry、OpenTelemetry和其他必备的可观测性工具也可以轻松地与 Pingora集成
过滤器和回调函数,以允许用户完全自定义服务应该如何处理、转换和转发请求 (这些 API 对于 OpenResty 和 NGINX 用户来说尤其熟悉)
Pingora 的一些重要组件:
Pingora
:用于构建网络系统和代理的“公共 API”。Pingora 代理框架提供的API 极具可编程性。方便用户构建定制化和高级网关或负载均衡器。
Pingora-core
: 这个创建定义协议、功能和基本 trait。
Pingora-proxy
:构建 HTTP 代理的逻辑 和 API。
Pingora-error
: 在 Pingora 创建的各个 crate 中常见的错误类型
Pingora-HTTP
: HTTP头定义和 API
Pingora-openssl
和 pingora-boringssl
:与 SSL 相关的扩展和 API
Pingora-ketama
: Ketama[15] 一致性算法
Pingora-limits
: 高效计数算法
Pingora-load-balancing
:Pingora 代理的负载均衡算法扩展
Pingora-memory-cache
:带有缓存锁的异步内存缓存,以防止缓存失效
Pingora-timeout
:一个更高效的异步定时器系统
TinyUfo
:pingora-memory-cache
背后的缓存算法
官方给出了一个示例:pingora-proxy/examples/load_balancer.rs[16] 。看上去可以非常快速地定制一个负载均衡器。代码不到 100 行。
``use async_trait::async_trait; use log::info; use pingora_core::services::background::background_service; use std::{sync::Arc, time::Duration}; use structopt::StructOpt; use pingora_core::server::configuration::Opt; use pingora_core::server::Server; use pingora_core::upstreams::peer::HttpPeer; use pingora_core::Result; use pingora_load_balancing::{health_check, selection::RoundRobin, LoadBalancer}; use pingora_proxy::{ProxyHttp, Session}; // 定义一个 load-balance 对象类型 pub struct LB(Arc<LoadBalancer<RoundRobin>>); // 任何实现 `ProxyHttp` trait 的对象都是一个 HTTP 代理 #[async_trait] impl ProxyHttp for LB { type CTX = (); fn new_ctx(&self) -> Self::CTX {} // 唯一需要的方法是 `upstream_peer()` ,它会在每个请求中被调用 // 应该返回一个 `HttpPeer` ,其中包含要连接的源IP以及如何连接到它 async fn upstream_peer(&self, _session: &mut Session, _ctx: &mut ()) -> Result<Box<HttpPeer>> { // 实现轮询选择 // pingora框架已经提供了常见的选择算法,如轮询和哈希 // 所以这里只需使用它 let upstream = self .0 .select(b"", 256) // hash doesn't matter .unwrap(); info!("upstream peer is: {:?}", upstream); // 连接到一个 HTTPS 服务器还需要设置 SNI // 如果需要,证书、超时和其他连接选项也可以在HttpPeer对象中设置 let peer = Box::new(HttpPeer::new(upstream, true, "one.one.one.one".to_string())); Ok(peer) } // 该过滤器在连接到源服务器之后、发送任何HTTP请求之前运行 // 可以在这个过滤器中添加、删除或更改HTTP请求头部。 async fn upstream_request_filter( &self, _session: &mut Session, upstream_request: &mut pingora_http::RequestHeader, _ctx: &mut Self::CTX, ) -> Result<()> { upstream_request .insert_header("Host", "one.one.one.one") .unwrap(); Ok(()) } } // RUST_LOG=INFO cargo run --example load_balancer fn main() { env_logger::init(); // read command line arguments let opt = Opt::from_args(); let mut my_server = Server::new(Some(opt)).unwrap(); my_server.bootstrap(); // 127.0.0.1:343" is just a bad server // 硬编码了源服务器的IP地址 // 实际工作负载中,当调用 `upstream_peer()` 时或后台中也可以动态地发现源服务器的IP地址 let mut upstreams = LoadBalancer::try_from_iter(["1.1.1.1:443", "1.0.0.1:443", "127.0.0.1:343"]).unwrap(); // We add health check in the background so that the bad server is never selected. let hc = health_check::TcpHealthCheck::new(); upstreams.set_health_check(hc); upstreams.health_check_frequency = Some(Duration::from_secs(1)); let background = background_service("health check", upstreams); let upstreams = background.task(); let mut lb = pingora_proxy::http_proxy_service(&my_server.configuration, LB(upstreams)); lb.add_tcp("0.0.0.0:6188"); let cert_path = format!("{}/tests/keys/server.crt", env!("CARGO_MANIFEST_DIR")); let key_path = format!("{}/tests/keys/key.pem", env!("CARGO_MANIFEST_DIR")); let mut tls_settings = pingora_core::listeners::TlsSettings::intermediate(&cert_path, &key_path).unwrap(); tls_settings.enable_h2(); lb.add_tls_with_settings("0.0.0.0:6189", None, tls_settings); my_server.add_service(lb); my_server.add_service(background); my_server.run_forever(); } ``
测试服务:
`curl 127.0.0.1:6188 -svo /dev/null < HTTP/1.1 200 OK `
下图展示了在这个示例中请求是如何通过回调和过滤器流动的。Pingora 代理框架目前在请求的不同阶段提供了更多的过滤器和回调,允许用户修改、拒绝、路由和/或记录请求(和响应)。
Pingora 代理框架在底层负责连接池、TLS握手、读取、写入、解析请求和其他常见的代理任务,以便用户可以专注于对他们重要的逻辑。
本文先来阅读一下 Pingora 负载均衡算法的 Rust 代码。从上面介绍中看得出来, Pingora 负载均衡算法应该在 Pingora-ketama
crate 中实现,它采用 Ketama[17] 一致性算法。
“
Pingora-ketama 实际上 Nginx 负载均衡算法的 Rust 移植。从这个狭隘的角度看,Pingora 也算是用 Rust 重写的 Nginx。
负载均衡简单来说就是从 n 个候选服务器中选择一个进行通信的过程。这个过程讲究的就是一个均衡,不能十个服务器,总是把请求落到其中某一个服务器,而其他服务器空闲。
负载均衡常用算法就是一致性哈希算法(Consistent Hashing Algorithm)。一致性哈希负载均衡需要保证的是“相同的请求尽可能落到同一个服务器上“。
“
在 Nginx、Memcached、Key-Value Store、Bittorrent DHT、LVS 、Netflix 视频分发 CDN、discord 服务器集群等都采用了一致性哈希算法。
一致性哈希是一种分布式系统技术,通过在虚拟环结构(哈希环)上为数据对象和节点分配位置来运作。一致性哈希在节点总数发生变化时最小化需要重新映射的键的数量。
Ketama 是一种一致性哈希算法的实现。具体来说,该算法工作机制如图展示:
如图,假如有服务器 IP 和端口 group 列表节点。对每个服务器字符串计算哈希得到几个(100-200个)无符号整数。这些整数会被存放在一个环形结构上。
当请求到来时,选择离用户最近的服务器,拿用户 ID 作为 hash key,服务器ip和端口的hash值作为 value,映射起来。
当用户请求量大,且物理服务器较少时,很可能大量的请求都会落在同一个物理节点。或者,当一个物理服务器故障时,它原本所负责的任务将全部交由顺时针方向的下一个物理服务器处理,导致这个物理服务器瞬间压力增大。所以引入了虚拟节点的概念。一个物理节点将会映射多个虚拟节点,这样 Hash 环上的空间分割就会变得均匀。
每台服务器对应的虚拟节点的数量(权重)取决于服务器的处理性能。例如,如果某台服务器的处理性能是其他服务器的两倍时,它可以分配其他服务器两倍的虚拟节点。
物理服务器出现故障时,会导致和故障服务器相关虚拟服务器节点消失,留存的物理服务器将被重新分配虚拟节点。新增物理服务器也是类似的过程。
虚拟节点(Virtual Nodes) 是一致性哈希算法中的一个关键概念,主要用来提高分布式系统中的负载均衡性和系统的弹性。
“
虚拟节点的抽象和操作系统虚拟内存空间抽象很相似。
虚拟节点的作用主要是平衡请求压力:
增强负载均衡:通过增加虚拟节点的数量,可以使得物理节点在哈希环上的分布更加均匀。即使物理节点的数量较少,通过合理设置虚拟节点,也能有效地分散请求或数据项,避免某个节点过载。
提高系统弹性:当物理节点加入或离开系统时,只有与这个物理节点相关的虚拟节点会受到影响,这意味着只有一小部分的请求需要被重新分配到其他节点。这减少了因节点变动导致的系统震荡,使得系统更加稳定。
简化节点管理:虚拟节点简化了节点的管理和扩展。例如,如果一个物理节点的处理能力比其他节点强,可以给它分配更多的虚拟节点,而无需改变整个系统的架构或重新平衡所有请求。
虚拟节点本身不处理请求。它们只是哈希环上的标记,用于将请求映射到实际的物理节点。每个虚拟节点都与一个物理节点关联,真正处理请求的是这些物理节点。虚拟节点的作用主要是作为负载均衡和系统弹性策略的一部分,而不是直接参与请求处理。
Pingora-ketama
的实现是对 Nginx 一致性哈希算法的 Rust 语言移植,保持了与Nginx 相同的行为。
Bucket
结构体 表示一致性哈希环上的一个节点(或称为"桶")。每个Bucket
包含一个节点的地址(SocketAddr
)和该节点的权重(weight
)。权重较高的节点在哈希环上会占有更多的点,因此会接收到更多的请求。
`/// A [Bucket] represents a server for consistent hashing /// /// A [Bucket] contains a [SocketAddr] to the server and a weight associated with it. #[derive(Clone, Debug, Eq, PartialEq, PartialOrd)] pub struct Bucket { // The node name. // TODO: UDS node: SocketAddr, // The weight associated with a node. A higher weight indicates that this node should // receive more requests. weight: u32, } `
Point
结构体表示哈希环上的一个点,包含一个指向节点地址数组的索引(node
)和该点的哈希值(hash
)。这个结构体在内部使用,用于在哈希环上定位节点。
`// A point on the continuum. #[derive(Clone, Debug, Eq, PartialEq)] struct Point { // the index to the actual address node: u32, hash: u32, } `
每个物理节点(Bucket
)根据其权重,会在哈希环上生成多个点(Point
)。权重越高的节点,在哈希环上生成的点就越多。
这个点实际上就是前面说过的虚拟节点,具体来说:
对于每个物理节点,根据其权重和一个固定的倍数(POINT_MULTIPLE
),确定需要在哈希环上生成的点的数量。在代码中,POINT_MULTIPLE
被设定为 160,这意味着每个权重单位会在哈希环上生成 160 个点。这个 160 是从 Nginx 那复制过来的。
对于每个点,通过对节点地址和一个递增的哈希值(模拟不同的虚拟节点)进行哈希计算,生成多个不同的哈希值。每个哈希值对应哈希环上的一个点,这些点分散在整个哈希环上。
当需要定位一个键应该由哪个节点处理时,首先计算该键的哈希值,然后在哈希环上找到最近的一个点,该点所代表的物理节点就是目标节点。因为每个物理节点都通过多个点(虚拟节点)在哈希环上有了广泛的表示,这就实现了负载的均衡分配,同时也提高了系统的弹性。
Continuum
结构体代表了一致性哈希环本身,其中包含了两个主要的字段:ring
(一个Point
数组,代表环上的所有点),和addrs
(一个SocketAddr
数组,存储了所有节点的地址)。这个结构体提供了主要的功能,比如添加节点、查找给定键的节点等。
`/// The consistent hashing ring /// /// A [Continuum] represents a ring of buckets where a node is associated with various points on /// the ring. pub struct Continuum { ring: Box<[Point]>, addrs: Box<[SocketAddr]>, } `
Continuum 哈希环实现了以下三个方法:
初始化 (Continuum::new
): 根据传入的Bucket
数组构建一致性哈希环。算法会根据每个节点的权重在环上生成相应数量的点,每个点都会通过 CRC32 哈希算法得到一个哈希值,这些点按哈希值排序后存储在ring
数组中。
节点查找 (Continuum::node
): 给定一个键,此方法会计算其哈希值,然后在哈希环上找到对应的节点。这是通过在ring
数组中进行二分查找实现的。找到的点对应的节点就是此键应该映射到的节点。
故障转移 (Continuum::node_iter
): 如果找到的节点不可用,可以使用这个方法来获取一个迭代器,它会按顺序遍历哈希环上的其他节点,从而找到一个可用的故障转移节点。
地址获取(Continuum::get_addr
):根据节点索引获取真实 Socket 地址。
贴一个兼容 nginx 负载均衡测试用例:
`#[test] fn matches_nginx_sample() { let upstream_hosts = ["127.0.0.1:7777", "127.0.0.1:7778"]; let upstream_hosts = upstream_hosts.iter().map(|i| get_sockaddr(i)); let mut buckets = Vec::new(); for upstream in upstream_hosts { buckets.push(Bucket::new(upstream, 1)); } let c = Continuum::new(&buckets); // 可以看到不同的请求,被分配到了不同节点 assert_eq!(c.node(b"/some/path"), Some(get_sockaddr("127.0.0.1:7778"))); assert_eq!( c.node(b"/some/longer/path"), Some(get_sockaddr("127.0.0.1:7777")) ); assert_eq!( c.node(b"/sad/zaidoon"), Some(get_sockaddr("127.0.0.1:7778")) ); assert_eq!(c.node(b"/g"), Some(get_sockaddr("127.0.0.1:7777"))); assert_eq!( c.node(b"/pingora/team/is/cool/and/this/is/a/long/uri"), Some(get_sockaddr("127.0.0.1:7778")) ); assert_eq!( c.node(b"/i/am/not/confident/in/this/code"), Some(get_sockaddr("127.0.0.1:7777")) ); } `
本文介绍了 Pingora 诞生的背景,以及介绍了 Pingora 框架的特性和基本用法,并且阅读了 Pingora 负载均衡算法的 Rust 实现。
后面有时间再继续深入 Pingora 的源码实现,并且会关注 River 的实现进展。
感谢阅读。
参考资料