CloudFlare 在 2022 年中时曾发布过一篇博文 How we built Pingora, the proxy that connects Cloudflare to the Internet 介绍了其为了解决 Nginx 在实际应用场景下的诸多困境从头设计并使用 Rust 开发了一个名为 Pingora代理, 并在文章中介绍将会开源该系统. 但后续针对 Pingora 便再无其他消息, 开源计划看起来也是遥遥无期, 个人也一直担心是否会像 Servo 一样胎死腹中. 但昨天 CloudFlareGithub 上开源了该系统相关代码 cloudflare/pingora, 同时也发布了一篇博文 Open sourcing Pingora: our Rust framework for building programmable network services 介绍了 Pingora 开发相关的工作.

在开发 Pingora 之初, 与所有其他公司一样 CloudFlare 也考虑过通过付费以便 Nginx 背后的公司可以为其进行定制化开发和迁移到其他诸如 Envoy 等第三方代理系统, 但经长期考虑最终还是选择自行构建一个全新的代理系统, 该系统设计目标主要是 快速, 高效安全. 在不舍弃 高效快速 的前提下追求尽可能 安全 的目标, CloudFlare 自然地选择了 Rust 语言开发 Pingora. Pingora 被设计的不是一个完全可交付的最终产品, 而是一个可扩展的框架. 类比 Java 生态, Pingora 提供的更像是一个个可构建的 过滤器, 其允许开发人员在收到来自外部的请求后可个性化地修改或拒绝该请求. 同时又要达成对于那些从事 Nginx 开发的人员可以轻松地使用 Pingora 进行高效地开发.

在介绍完相关 Pingora 开发和设计的背景后, 我们使用 Pingora 来进行基本的开发. 首先是新建一个 Rust 项目并添加 Pingora 依赖:

cargo new np && cd np && cargo add async-trait && cargo add pingora -F lb,proxy

首先我们创建一个 Server 的实例并启动它:

use pingora::prelude::*;

fn main() {
    let mut server = Server::new(None).unwrap();
    server.bootstrap();
    server.run_forever();
}

现在这个 server 还没有做任何事情, 我们再来定义一个元组性结构体 - LB:

pub struct LB(Arc<LoadBalancer<RoundRobin>>);

LB 内只包含一个 LoadBalancer 结构体, 而 LoadBalancer 结构体主要提供 服务发现, 健康检测后端选择 能力. LoadBalancerpingora-load-balancing/src/lib.rs 中定义如下:

pub struct LoadBalancer<S> {
    backends: Backends,
    selector: ArcSwap<S>,
    /// How frequent the health check logic (if set) should run.
    ///
    /// If `None`, the health check logic will only run once at the beginning.
    pub health_check_frequency: Option<Duration>,
    /// How frequent the service discovery should run.
    ///
    /// If `None`, the service discovery will only run once at the beginning.
    pub update_frequency: Option<Duration>,
    /// Whether to run health check to all backends in parallel. Default is false.
    pub parallel_health_check: bool,
}

同时 RoundRobin 则是在 pingora-load-balancing/src/selection/algorithms.rs 定义并实现了 SelectionAlgorithm Trait:

/// Round Robin selection
pub struct RoundRobin(AtomicUsize);

impl SelectionAlgorithm for RoundRobin {
    fn new() -> Self {
        Self(AtomicUsize::new(0))
    }
    fn next(&self, _key: &[u8]) -> u64 {
        self.0.fetch_add(1, Ordering::Relaxed) as u64
    }
}

在了解以上 定义后, 为了完成我们初步的代码, 还需要为新定义的 LB 实现 ProxyHttp Trait. ProxyHttp 主要用于控制 HTTP 代理, 该 Trait 内的方法主要包含 filtercallback 两类. 其中任意一个 filter 返回 Result::Err 对象都将终止继续处理该 HTTP 请求, 同时错误将被记录. ProxyHttp 定义了很多方法, 但只有 new_ctxupstream_peer 是必须要实现的, 其中 new_ctx 主要用于生成上下文 Context, 可以在后续处理逻辑中存取对应状态; upstream_peer 主要用于获取当前代理的 HTTP 请求将发发往何处. 其他还有一些方法都是 filtercallback 相关的都有默认实现, 在当前可以不用考虑.

在暂时不为 LB 对象设计过多逻辑的情况下可以返回一个 (即 ()) 的 Context 对象:

type CTX = ();
fn new_ctx(&self) -> Self::CTX {
}

另一个必须要实现的方法是 upstream_peer, 在定义 LB 结构体时已经声明其包含的对象是 LoadBalancer<RoundRobin>, 因此在 upstream_peer 方法内通过引用 LoadBalancer 对象便可轮询地返回后端实例:

    async fn upstream_peer(&self, _session: &mut Session, _ctx: &mut ()) -> Result<Box<HttpPeer>> {
        let upstream = self
            .0
            .select(b"", 256)
            .unwrap();

        info!("upstream peer is: {:?}", upstream);

        let peer = Box::new(HttpPeer::new(upstream, true, "1.1.1.1".to_string()));
        Ok(peer)
    }

这里 LoadBalancer 结构体提供的 select 方法主要用于后端实例选择, 其会根据特定的选择算法返回第一个健康的后端实例, 而选择则会根据相应的 Hash 来进行, 但针对 随机轮询 两种选择算法而言则不关心 Hash 值, 对应到此处由于采用的是 RoundRobin 因此 Hash 则无关紧要, 故而仅固定给了一个 b"", select 的第二个参数是为寻找下一可用实例的 最大查询时间.

至此我们已经基本完成了一个 HTTP 代理的雏形, 但距离实际可用还有一定的距离, 如果此刻运行就会发现后端实例此刻会返回 403 Forbidden, 这是因为在 HTTP 的请求头里还包含了 Host 信息, 而任何遵守 RFC 7231 的客户端而言其在发起 HTTP 请求前都会根据其欲访问的地址设置对应的 Host 请求头, 此刻在经过 Pingora 代理后会服务端会发现其收到的 HTTP 请求并非是访问当前后端的, 故而拒绝响应. 如果想正常访问我们必须在经过 Pingora 代理时将请求头的 Host 改写为代理后的后端实例地址即可. 正如前文所述, 我们可以利用 ProxyHttp 里面对应的 filter 来实现:

    async fn upstream_request_filter(&self, _session: &mut Session, upstream_request: &mut RequestHeader, _ctx: &mut Self::CTX) -> Result<()> {
        upstream_request.insert_header("Host", "192.168.1.1").unwrap();
        Ok(())
    }

至此已经完成了一个 HTTP 代理的主体部分, 剩余的工作就是如何将定义的 LB 应用到之前创建的 Server 上. Server 提供了一个 add_service 方法可将对应的 Service 绑定到服务上:

    let upstreams = LoadBalancer::try_from_iter(["192.168.1.1:443", "192.168.1.2:443"]).unwrap();
    let mut lb = http_proxy_service(&server.configuration, LB(Arc::new(upstreams)));
    lb.add_tcp("0.0.0.0:1234");
    server.add_service(lb);

首先通过 LoadBalancer 定义了两个后端实例 192.168.1.1:443192.168.1.2:443, 接着再通过 http_proxy_service 将定义的实例绑定到之前声明的 LB 上, 最终将其添加到 Pingora 服务上.

在完成了以上代码后, 可以通过 cargo run 运行并通过 curl 模拟测试, 以下是后台输出日志:

Upstream peer is: Backend { addr: Inet(192.168.1.1:443), weight: 1 }
Upstream peer is: Backend { addr: Inet(192.168.1.2:443), weight: 1 }
Upstream peer is: Backend { addr: Inet(192.168.1.1:443), weight: 1 }
Upstream peer is: Backend { addr: Inet(192.168.1.2:443), weight: 1 }

从以上日志可以看出当前代理服务缺失以轮询的方式访问后端实例.

后记

当前 Pingora 仍然有待完善, 首先 API 稳定性并不能保证, 后续可能随着版本升级或是功能迭代可能变化, 另一个问题是对非 Unix 系统的支持并不在路线图中.

REF

  1. How we built Pingora, the proxy that connects Cloudflare to the Internet
  2. cloudflare/pingora
  3. Open sourcing Pingora: our Rust framework for building programmable network services