Rust in Everything
基于 pingora 0.9.0

pingora 解决什么问题

主线位置:三条主线的外面。本页回答写一个反向代理要决定哪些事、pingora 替你决定了哪些、留给你的是什么;README 里的说法逐条对到代码上;最后用一次实测说明「自己的 HTTP/1 实现」在线上看得见的后果。
flowchart TB
  user["你的代码 impl ProxyHttp"] --> proxy["pingora-proxy 固定阶段"]
  proxy --> h1["自己的 HTTP/1"]
  proxy --> h2["h2 crate"]
  proxy --> conn["连接器与连接池"]
  h1 --> l4["L4 流与 TLS 后端"]
  conn --> l4
  srv["Server 进程管理"] --> proxy
  srv --> rt["每服务一个 tokio 运行时"]

写一个反向代理要决定什么

一个反向代理收到下游的请求,决定发往哪个上游,转发过去,再把响应转回来。这句话里藏着一串要决定的事:怎么解析下游的请求、能不能改写它、上游的连接怎么建立和复用、上游失败了重试还是换一个、两边谁先关、进程收到信号怎么退、升级二进制时监听端口怎么不中断。

配置驱动的代理(nginx 是典型)把这些事做成配置项,再用模块或嵌入脚本补上配置表达不了的逻辑。pingora 的做法是:这些事全部写成 Rust 代码,框架把一次请求切成固定的阶段,在每个阶段调你实现的 trait 方法。最小的代理就是实验模板里的这几行:

struct Hello;

#[async_trait]
impl ProxyHttp for Hello {
  type CTX = ();
  fn new_ctx(&self) -> Self::CTX {}

  async fn upstream_peer(&self, _session: &mut Session, _ctx: &mut ()) -> Result<Box<HttpPeer>> {
    Ok(Box::new(HttpPeer::new("127.0.0.1:18081", false, String::new())))
  }
}

fn main() {
  let mut server = Server::new(None).unwrap();
  server.bootstrap();
  let mut proxy = http_proxy_service(&server.configuration, Hello);
  proxy.add_tcp("127.0.0.1:18080");
  server.add_service(proxy);
  server.run_forever();
}

ProxyHttp 有 42 个方法,只有 new_ctx 和 upstream_peer 没有默认实现。其余 40 个按阶段分:读完请求头(early_request_filter、request_filter)、缓存(request_cache_filter、cache_key_callback 等十来个)、选上游和连上之后(upstream_peer、connected_to_upstream、upstream_request_filter)、响应(upstream_response_filter、response_filter 和各自的 body / trailer 版本)、失败(fail_to_connect、error_while_proxy、fail_to_proxy)、收尾(logging)。哪个阶段先调、调几次,一次请求逐个打日志看;每个钩子的语义见 ProxyHttp。main 里的 Server 管的是进程那一层:读配置、起运行时、处理信号,见一个 Server 的一生。

README 的说法,落在哪段代码上

README 的「Feature highlights」有七条。逐条核实:

README代码核实
Async Rust: fast and reliable每个服务一个 tokio 运行时(run_service),可选工作窃取或 N 个单线程运行时「fast」没有离线可复现的数字,不讲
HTTP 1/2 end to end proxy下游:process_new 按 ALPN 或 h2c 前言分流;上游:proxy_to_upstream 里 ClientSession::H1 / H2 两个分支,h2 失败时可以降级到 h1(prefer_h1)下游 h2 + 上游 h1 也行,proxy_1to1 里专门从 :authority 补 Host
TLS over OpenSSL, BoringSSL, s2n-tls, or rustls (experimental)门面 crate 的四个 feature,pingora-core 里按 feature 把后端改名成 tls只能开一个,见怎么读这一卷
gRPC and websocket proxyinggRPC-Web 转换是一个下游模块 GrpcWebBridge;WebSocket 走 HTTP/1 的 Upgrade,0.9.0 默认的上游请求策略是 WebSocketOnly:只放行 WebSocket 升级,其他 Upgrade 被 sanitize_h1_upstream_request 删掉原生 gRPC 就是 h2 上游,不需要额外代码
Graceful reloadSIGQUIT 时旧进程把监听 fd 经 Unix socket 交给带 -u 启动的新进程(server/transfer_fd/)传 fd 只在 Linux 上实现;macOS 上旧进程照样打印「listener sockets sent」,见一个 Server 的一生
Customizable load balancing and failover strategiespingora-load-balancing;fail_to_connect / error_while_proxy 决定重试,重试时重新调 upstream_peer见 07 负载均衡 和错误与重试
Support for a variety of observability toolspingora-prometheus、sentry、dial9、pingora-foundations本卷都不展开

README 另有一句「serving more than 40 million Internet requests per second」,链到 Cloudflare 的博客。这类规模数字离线无法核实,本卷不引用。它在「System requirements」一节写得很直白:「Linux is our tier 1 environment and main focus」,macOS 只保证大部分代码能编译,「some features might be missing」。本卷的实验平台正好是后者,缺了什么在用到的页面上写明。

和用 hyper 搭的代理比

用 hyper 也能写反向代理:hyper 的服务端连接收请求,hyper-util 的 legacy Client 发给上游。和这种写法比,下面四处差别都能在代码里核实。

自己的 HTTP/1。 cargo tree -p pingora@0.9.0 里没有 hyper,HTTP/1 在 pingora-core/src/protocols/http/v1/(13212 行)里,解析用 httparse,编码自己写。最直接的后果在头名的大小写上。hyper 的 HeaderMap 里头名一律小写,默认也按小写写出去,要原样转发得打开 preserve_header_case,见 hyper 卷的解析与编码。pingora 的 RequestHeader 在 http crate 的 Parts 旁边多存了一份 header_name_map,记下每个头在线上的原始写法,默认就按它写出去。本页最后的实验能看到这一点。

固定阶段的钩子。 tower 的中间件是 Service 套 Service,每一层都能改请求、改响应、决定要不要往里传(tower 卷:一个请求穿过一摞中间件)。pingora 只有一个 ProxyHttp,阶段的顺序写死在 process_request 里,你只能在这些位置插代码,换来的是不用自己拼请求体、响应体的双向转发和重试循环。

服务级共享的上游连接池。 HttpProxy 里有一个 client_upstream(Connector),整个服务的所有下游连接、所有工作线程共用它的连接池(默认容量 upstream_keepalive_pool_size: 128)。上游连接用完就放回池里,下一个请求不论来自哪条下游连接,都先查池。hyper-util 的 legacy Client 也有连接池(hyper 卷的连接池),差别在于 pingora 不需要你选:每个代理默认如此,复用的判定(同 IP:port、scheme、SNI、证书设置等才算同一个 Peer)也是框架定的。实测见一次请求。

自带进程管理。 hyper 只管一条连接上的 HTTP,accept 循环、信号、停机都由使用者组织(hyper 卷的优雅停机用的是 hyper-util 的 GracefulShutdown)。pingora 的 Server 把这些都包了:命令行(-c、-d、-t、-u)、YAML 配置、守护进程、SIGTERM 宽限期、SIGQUIT 平滑升级。代价是你得按它的方式组织程序:run_forever 不返回,服务的运行时由它建。

这四处各有代价,完整的对照放在 09 设计取舍。

📝NOTE
没有蠢问题

问: pingora 也用 http crate 的 HeaderMap,HeaderName 存进去就是小写的,原始大小写存在哪?转发时改了头会不会丢?

答: 存在 RequestHeader / ResponseHeader 的 header_name_map: Option<CaseMap> 里,它是一个和 HeaderMap 平行的映射,键是同一个小写的 HeaderName,值是线上原样的字节。所以 pingora 不让你直接拿 &mut HeaderMap 去改:通过 insert_header / append_header 改,两份才能同步。这也是 0.9.0 去掉 RequestHeader 和 ResponseHeader 的 DerefMut 的原因之一,CHANGELOG 的说法是「unrestricted mutation could violate internal invariants」,见请求头与错误。

实验:一个请求头穿过 pingora

intro-run 的 hooks 代理(127.0.0.1:18100 → 18101,所有钩子只打日志、不改任何东西)前面放一个 Python 写的客户端,后面放一个原样打印收到字节的上游(rawup.py,回一个带 x-UpStReAm: yes 的 200)。客户端发:

GET /case HTTP/1.1\r\n
Host: 127.0.0.1:18100\r\n
X-MiXeD-CaSe: 1\r\n
connection: close\r\n
\r\n
❗IMPORTANT
动脑筋:上游收到的请求头有几行?X-MiXeD-CaSe 和 connection 两行各变成什么样?

客户端拿到的响应里,上游写的 x-UpStReAm 又是什么写法?平台是 M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0(tokio 1.53.2),debug。

动脑筋答案

上游收到的(\r\n 照原样标出):

GET /case HTTP/1.1\r\n
Host: 127.0.0.1:18100\r\n
X-MiXeD-CaSe: 1\r\n
\r\n

客户端收到的:

HTTP/1.1 200 OK\r\n
x-UpStReAm: yes\r\n
Content-Length: 3\r\n
Date: Thu, 29 Oct 2026 05:06:50 GMT\r\n
Connection: close\r\n
\r\n
ok
  • 三行头,connection 那一行没了。 Connection 是逐跳头(hop-by-hop header),只对一跳有意义。0.9.0 起上游请求默认经过 sanitize_h1_upstream_request,删掉标准的逐跳头和 Connection 里点名的头,CHANGELOG 把它列在破坏性变更里:「Upstream requests strip hop-by-hop and Connection-nominated headers by default」。下游要求关连接,不等于上游连接也要关;上游连接能不能复用,由 pingora 自己决定。
  • X-MiXeD-CaSe 原样保留。 这就是上一节说的 header_name_map。经过 hyper 默认配置的代理,上游会看到 x-mixed-case。
  • 响应方向同样保留。 x-UpStReAm 一个字母都没变。
  • pingora 加了两行。 Date 和 Connection: close 是下游 HTTP/1 会话在 prepare_response_header 里写的:Date 用 insert_header,上游给了也会被换成 pingora 缓存的当前时间;Connection 按这条下游连接还能不能 keep-alive 来写,这里客户端要求了 close。

小结

  • pingora 把代理的行为写成 ProxyHttp 上的代码:42 个方法,必须写的只有 new_ctx 和 upstream_peer,阶段顺序固定在 process_request 里。
  • README 的七条特性都能在代码里找到;平滑升级的传 fd 只在 Linux 上实现;规模数字不在本卷核实范围内。
  • 和 hyper 搭的代理比:没有 hyper 依赖,自己的 HTTP/1 默认保留头名大小写;钩子固定不可嵌套;上游连接池按服务共享、默认开启;Server 管进程、信号和升级。
  • 实测:请求头和响应头的大小写原样通过;connection: close 不会转给上游(0.9.0 默认删逐跳头);下游响应的 Date、Connection 由 pingora 自己写。

💬 关联讨论

发起讨论
加载中...