“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 还面临有些类型的功能难以添加的问题。
例如,当重试请求/请求失败[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 的预计包括以下重要特性:
该项目计划本年度第二个季度启动,感兴趣的可以去围观或参与。
CloudFlare Pingora[13] 现已开源,代码量大约是 3.8 万行 Rust 代码。
Pingora 是一个用于构建快速、可靠和可编程网络系统的 Rust 框架。它经过了“战斗”的考验,因为它已经连续几年每秒处理超过 4000万 个互联网请求。
特色亮点:
Pingora 的一些重要组件:
Pingora
:用于构建网络系统和代理的“公共 API”。Pingora 代理框架提供的API 极具可编程性。方便用户构建定制化和高级网关或负载均衡器。Pingora-core
: 这个创建定义协议、功能和基本 trait。Pingora-proxy
:构建 HTTP 代理的逻辑 和 API。Pingora-error
: 在 Pingora 创建的各个 crate 中常见的错误类型Pingora-HTTP
: HTTP头定义和 APIPingora-openssl
和 pingora-boringssl
:与 SSL 相关的扩展和 APIPingora-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 是一种一致性哈希算法的实现。具体来说,该算法工作机制如图展示:
虚拟节点(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 的实现进展。
感谢阅读。