怎么读这一卷
主线位置:三条主线的总览。本页把请求、服务、缓存三条主线各拆成几步,标出每一步落在哪个函数、哪一页讲它;再说明实验环境,为什么所有实验都把 tokio 钉在 1.53.2;最后讲门面 crate
pingora的 feature 各打开什么、哪些组合编不过或者编得过却跑不通。一次请求和一个 Server 的一生在本机上把前两条线逐步打点走一遍。
flowchart TB
start[从这里开始] --> q{读这一卷为了什么}
q -->|会用| use1["01 全景 再到 05"]
q -->|读懂实现| all[00 到 12 按顺序]
q -->|只关心一次请求| req["一次请求 再到 03 04 05"]
q -->|只关心进程管理| srv["一个 Server 的一生 再到 02"]
锁定版本
pingora 的仓库是一个 Cargo 工作区,22 个 crate 用同一个版本号,一起发版、一起打 tag,tag 名就是版本号本身。本卷锁 0.9.0:
| 项 | 本卷 | 说明 |
|---|---|---|
| tag | 0.9.0 | 指向 702f690(2026-09-09,「Update changelog for 0.9.0」) |
| crates.io 包 | 与 tag 相同 | pingora、pingora-core、pingora-proxy、TinyUFO 等包里的 .cargo_vcs_info.json 记的都是 702f69015e53… |
| 上一个版本 | 0.8.0(faac65b,2026-03-02) | 本站网关用的是它,见 11 在本站中 |
| tag 之后 | main 到 84f21ee(2026-10-22)有 36 个提交 | 没有发版,本卷不跟;涉及时用 git log 0.9.0..origin/main -- <文件> 核实,提一句 |
源码链接一律指向 tag:https://github.com/cloudflare/pingora/blob/0.9.0/<crate>/src/...#L<行>,行号是条目开始的那一行。
仓库里有 README.md、CHANGELOG.md 和 docs/(quick start 一篇,user guide 19 篇)。本卷把它们当资料读,引用之前在源码或实验里核实;对不上的地方会写出来。一个例子就在 pingora-core/src/lib.rs 的 crate 文档里:它说 openssl 是「default if no TLS feature is specified」,而 pingora-core 的 [features] 是 default = [],一个 TLS feature 都不开时用的是一个空壳后端 noop_tls,见下面的 feature 表。
三条主线
请求主线:一次 curl 经过代理
flowchart TB acc["accept 建 Stream"] --> hs["handshake set_buffer"] hs --> app["process_new 选 h1"] app --> rd["read_request"] rd --> pr["process_request"] pr --> peer["upstream_peer"] peer --> pool["取池里连接"] pool --> duplex["h1 双向转发"] duplex --> fin["logging 与回收"] fin -->|keep-alive| app
| 步骤 | 源码 | 讲它的页 |
|---|---|---|
1. 每个监听地址一个 accept 循环 run_endpoint;TransportStack::accept 拿到 TcpStream,impl From<TcpStream> for Stream 先用容量 0 的 BufStream 包起来 | services/listening.rs、listeners/mod.rs、protocols/l4/stream.rs | 服务与监听器、L4 流 |
2. 每个连接 spawn 一个任务,UninitializedStream::handshake 调 set_buffer 换成真正的缓冲区(默认读 64 KiB、写 1460 字节),有 TLS 就做握手 | listeners/mod.rs | L4 流、TLS |
3. ServerApp::process_new 按 h2c 前言和 ALPN 选 HTTP/2 还是 HTTP/1;HTTP/1 时在一个 while let 里反复调 process_new_http,这就是下游 keep-alive | apps/mod.rs | 一次请求 |
4. HttpProxy::process_new_http → handle_new_request,HTTP/1 的 read_request 用 httparse 解析请求头 | pingora-proxy/src/lib.rs、protocols/http/v1/server.rs | HTTP/1 服务端 |
5. process_request:early_request_filter → 下游模块 → request_filter → proxy_cache(不开缓存时只调 request_cache_filter 和 is_purge)→ proxy_upstream_filter → 重试循环 | pingora-proxy/src/lib.rs | process_request、ProxyHttp |
6. proxy_to_upstream 调 upstream_peer 拿到 HttpPeer,Connector::get_http_session 先查池再新建 | connectors/http/mod.rs、connectors/mod.rs | HttpPeer、HTTP 连接器、连接池 |
7. proxy_to_h1_upstream 调 connected_to_upstream;proxy_1to1 复制请求头、调 upstream_request_filter、写给上游,然后 try_join! 两个半边:下游读请求体、上游读响应 | proxy_h1.rs | 双向转发、HTTP/1 的 body |
8. 响应经过 upstream_response_filter → response_filter → upstream_response_body_filter → response_body_filter(h1_response_filter) | proxy_h1.rs、lib.rs | 双向转发 |
9. finish 调 logging、persist_connection_context,把下游连接交回第 3 步的循环;上游连接由 release_http_session 放回池 | lib.rs、connectors/http/mod.rs | 一次请求、连接池 |
服务主线:Server 的一生
flowchart TB new["Server new 读配置"] --> boot["bootstrap"] boot --> add["add_service"] add --> run["run_forever"] run --> rt["每服务一个运行时"] rt --> wait["main_loop 等信号"] wait -->|SIGTERM| grace["广播 停 accept 等宽限期"] wait -->|SIGQUIT| up["传监听 fd 再停"] wait -->|SIGINT| quick["立刻关运行时"]
| 步骤 | 源码 | 讲它的页 |
|---|---|---|
1. Server::new:Opt 里有 -c 就读 YAML,否则用 ServerConf 的默认值(threads: 1、work_stealing: true、宽限期 None) | server/mod.rs、server/configuration/mod.rs | 配置与命令行 |
2. Bootstrap::bootstrap:-t 时到此退出;-u 时从旧进程的 upgrade socket 收监听 fd | server/bootstrap_services.rs | 平滑升级与停机 |
3. add_service 把服务放进依赖图(0.8.0 起服务之间可以声明依赖) | server/mod.rs | 服务与监听器 |
4. run 按拓扑序给每个服务调 run_service:建一个以服务名命名的运行时,在里面 start_service | server/mod.rs、pingora-runtime/src/lib.rs | 运行时 |
5. 主线程 block_on main_loop,UnixShutdownSignalWatch::recv 等 SIGQUIT / SIGTERM / SIGINT | server/mod.rs | 一个 Server 的一生 |
6. 停机:广播 ShutdownWatch → accept 循环退出 → 宽限期(默认 300 秒)→ 各运行时 shutdown_timeout(默认 5 秒) | server/mod.rs、services/listening.rs | 平滑升级与停机 |
缓存主线:同一个 URL 请求两次
| 步骤 | 源码 | 讲它的页 |
|---|---|---|
1. request_cache_filter 里调 HttpCache::enable 打开缓存;默认实现什么都不做,等于不缓存 | pingora-proxy/src/proxy_cache.rs、pingora-cache/src/lib.rs | 缓存状态机 |
2. cache_key_callback 生成 CacheKey,set_cache_key;默认实现 unimplemented!,开了缓存就必须自己写 | proxy_trait.rs、key.rs | 键、元数据与可缓存性 |
3. cache_lookup miss → CacheLock 只放一个请求去上游 | lib.rs、lock.rs | 缓存锁 |
4. 上游响应头到达,cache_http_task 调 response_cache_filter 判断能否缓存,能就 set_miss_handler,边转发边写存储 | proxy_cache.rs、storage.rs、put.rs | 存储与淘汰 |
5. 第二次请求 hit:cache_found → cache_hit_filter → proxy_cache_hit 从存储读出响应,不连上游 | proxy_cache.rs | 缓存状态机 |
三条线都从 process_request 分出去:请求主线走 proxy_to_upstream,缓存主线在它前面的 proxy_cache 里短路或接管写入;服务主线不经过请求,管的是这些请求跑在哪个运行时、什么时候不再接受新连接。
实验环境
| 项 | 本卷 |
|---|---|
| 平台 | M2 Max、macOS 27、rustc 1.99.0;写明 debug 或 release;计时用 release,取多次里的最小值并注明 uptime 负载 |
| pingora | 0.9.0,openssl 后端;模板开 openssl、proxy、lb、cache、time、connection_filter、upstream_modules,子 crate 全部 =0.9.0;另有一份 rustls 后端的模板 |
| tokio | 1.53.2,见下一节 |
| 上游 | python3 -m http.server 或自己写的 hyper 服务;需要看连接复用时,上游打印对端端口 |
| 客户端 | curl 8.7.1(带 nghttp2);本机没有 wrk、h2load、oha,要压力就用 hyper / reqwest 自己写 |
没有 Linux 机器可用。pingora 的 README 写的是「Linux is our tier 1 environment and main focus」,「macOS (though some features might be missing)」。缺的部分在源码里看得到:平滑升级传监听 fd 的 send_fds_to 在 #[cfg(not(target_os = "linux"))] 下是一个直接返回 Ok(0) 的空函数,读内核 TCP_INFO 的 get_tcp_info 在非 Linux 的 Unix 上返回一个全零的结构体。两者都不报错,只是什么都没做。这些地方本卷只读源码,并写明。
tokio 为什么钉在 1.53.2
pingora 的工作区依赖写的是 tokio = "1"。在今天(2026-10-29)新建一个项目,cargo 会解析到 tokio 1.54.1,pingora 0.9.0 接受第一个连接就 panic。实验 intro-tokio154:最小的代理(127.0.0.1:18140 → 18141),只把 Cargo.lock 里的 tokio 从 1.53.2 换成 1.54.1,连发两次 curl(debug):
$ curl -s http://127.0.0.1:18140/ ; echo "curl exit=$?"
curl exit=56
$ curl -s http://127.0.0.1:18140/ ; echo "curl exit=$?"
curl exit=7
代理的输出:
thread 'Pingora HTTP Proxy Service' (5541334) panicked at .../pingora-core-0.9.0/src/protocols/l4/stream.rs:549:26:
capacity must be greater than zero
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
[2026-10-29T05:00:33Z INFO pingora_core::server] service 'Pingora HTTP Proxy Service' exited.
release 构建的输出相同。原因分三段:
- pingora 这边。
impl From<TcpStream> for Stream用BufStream::with_capacity(0, 0, ..)包住刚 accept 的连接,等handshake里的set_buffer再换成真正大小的缓冲区。注释写了为什么这样做:「It is only set later because of the malloc overhead in critical accept() path」。pingora 0.8.0 是同样的写法。 - tokio 这边。 tokio 1.54.0(2026-10-19)收进了 #8494(
dd826ef1,「io: reject zero capacity in BufReader」),BufReader::with_capacity开头多了一行assert!(capacity > 0, "capacity must be greater than zero")。提交说明给的理由是,容量为 0 的BufReader「poll_fill_buf can never make progress and every AsyncBufRead operation reports end-of-file」。BufStream::with_capacity内部调它,两者都标了#[track_caller],所以 panic 位置报在 pingora 的stream.rs:549。写缓冲区容量为 0 不受影响,只检查读的一侧。 - 为什么整个服务都死了。 这次转换发生在 accept 循环自己里面:
Listener::accept一拿到TcpStream就.into(),调用它的是run_endpoint里的stack.accept(),不在每个连接各自 spawn 的任务里。panic 结束了run_endpoint这个任务,start_service等的join_all随之返回,日志打出「service ... exited」,监听 socket 关闭。所以第一次 curl 是连接被重置(exit 56),第二次是连接被拒绝(exit 7),而进程还活着,只是不再听这个端口。
修复在 main 上:5b30d71(2026-10-19,「Keep zero-capacity L4 read buffers working on tokio 1.54」)把 L4 缓冲换成一个小枚举,读容量为 0 时只留 BufWriter。它在 0.9.0 之后,还没有发版。
问: 修复已经在 main 上了,为什么不直接用 main,或者给 tokio 打个补丁绕过?
答: 本卷讲的是 0.9.0 这个发布出来的版本:链接指向 tag,实验跑的是 crates.io 上的包,用 [patch] 换成 main 的 pingora-core 就等于把 0.9.0 之后的 36 个提交一起带进来。把 tokio 降到 1.53.2 只改了一个依赖的版本,pingora 的代码一行没动。代价是这一卷的实验都不在 tokio 的最新版上跑;tokio 卷锁的是 1.54.1,两卷讲到同一段 tokio 代码时以 tokio 卷为准。
所有实验 crate 的 Cargo.lock 都用 cargo update --offline -p tokio --precise 1.53.2 钉住,展示运行结果的页面会写一句「tokio 1.53.2」。本站网关(pingora 0.8.0)的 Cargo.lock 是 tokio 1.52.3,线上不受影响;但网关的 Cargo.toml 同样只写了 pingora 的版本,执行一次 cargo update 就会换上 1.54。
feature 各打开什么
门面 crate pingora 本身只有 101 行(pingora/src/lib.rs):pub use pingora_core::*,再按 feature 加几个模块别名。它的 [features](pingora/Cargo.toml)里,default = []:
| feature | 打开什么 | 规则与实测 |
|---|---|---|
openssl / boringssl | pingora-core 的同名 feature,以及 pingora-proxy?、pingora-cache?、pingora-load-balancing? 的同名 feature;再开 openssl_derived | ? 表示只在那个可选依赖已经被别的 feature 拉进来时才转发;本机没有 boringssl 的依赖缓存,只编过 openssl |
rustls / s2n | 同上,再开 any_tls | rustls 在 Cargo.toml 的注释里标着「Highly Experimental」,「Try it, but don't rely on it (yet)」;s2n 本机没有依赖缓存 |
proxy | 依赖 pingora-proxy,出现 pingora::proxy 模块,prelude 加上 pingora_proxy::prelude::* | pingora-proxy 无条件依赖 pingora-cache,所以只开 proxy 也会编译整个缓存 crate,见源码地图 |
lb | pingora-load-balancing,并且开 proxy | |
cache | 依赖 pingora-cache,出现 pingora::cache 模块 | 开了 proxy 时它只多一个模块路径;prelude 那一行 pub use pingora_cache::prelude::* 什么也带不进来,pingora_cache::prelude 是个空模块 |
time | time = [],只多一个 pingora::time 模块 | pingora-timeout 是无条件依赖,prelude 不管开不开都 pub use pingora_timeout::* |
upstream_modules | pingora-proxy?/upstream_modules:ProxyHttp 上多出 init_upstream_modules、adjust_upstream_modules 两个钩子 | 0.9.0 新增;不开 proxy 时什么都不做 |
connection_filter | pingora-core 和 pingora-proxy? 的同名 feature:TLS 握手前按对端地址决定收不收连接 | 不开时 set_connection_filter 照样存在,是个空函数 |
sentry、dial9*、trace | 错误上报、tokio 运行时遥测、缓存的链路追踪 | 本卷不讲;trace 没有写进文档 |
any_tls / openssl_derived | 内部 feature,注释写着「intentionally not documented」 | 见下文 |
patched_http1 | pingora-core 和 pingora-proxy? 的同名 feature | 同样不写进文档;见下文 |
两个内部 feature:any_tls 与 openssl_derived
TLS 后端是用 cfg 拼出来的。pingora-core 在 lib.rs 里按 feature 把某个后端 crate 改名成 tls:
// If both openssl and boringssl are enabled, prefer boringssl.
// This is to make sure that boringssl can override the default openssl feature
// when this crate is used indirectly by other crates.
#[cfg(feature = "boringssl")]
pub use pingora_boringssl as tls;
#[cfg(feature = "openssl")]
pub use pingora_openssl as tls;
#[cfg(feature = "rustls")]
pub use pingora_rustls as tls;
#[cfg(feature = "s2n")]
pub use pingora_s2n as tls;
#[cfg(not(feature = "any_tls"))]
pub use protocols::tls::noop_tls as tls;
any_tls 的意思是「至少选了一个 TLS 后端」,四个后端都开它;openssl_derived 的意思是「后端是 OpenSSL 的 API」,openssl 和 boringssl 开它。pingora-core 里按这两个名字写 cfg 的地方有 40 处:protocols/tls/mod.rs 在 openssl_derived 时编译 boringssl_openssl 模块,upstreams/peer.rs 里只有 OpenSSL 系才有的证书字段用 openssl_derived 包着,not(any_tls) 时换成 noop_tls。这样写的好处是业务代码只认 pingora::tls 一个路径,代价是这些路径在不同 feature 下是不同的类型,09 设计取舍会再讲。
上面那段注释说「同时开 openssl 和 boringssl 时优先 boringssl」,代码里却没有任何互斥的 cfg:四行 pub use ... as tls 是并列的。开两个后端会怎样,实验 intro-twotls 开 openssl 加 rustls(debug):
error[E0252]: the name `tls` is defined multiple times
--> .../pingora-core-0.9.0/src/lib.rs:117:9
|
114 | pub use pingora_openssl as tls;
| ---------------------- previous import of the module `tls` here
...
117 | pub use pingora_rustls as tls;
| ^^^^^^^^^^^^^^^^^^^^^ `tls` reimported here
...
error: could not compile `pingora-core` (lib) due to 75 previous errors
后面还有 74 个错误,大多是 E0659(TlsRef、Acceptor、TlsSettings 等 is ambiguous):protocols/tls/mod.rs 把两个后端的模块都 pub use ...::* 了进来。boringssl 本机编不了,但 lib.rs 第 111 行和第 114 行是同样的两行 pub use ... as tls,开 openssl 加 boringssl 应该得到同一个 E0252;那段注释描述的行为在 0.9.0 的代码里不存在。所以规则是:TLS 后端最多开一个,而且要在整个依赖图里只开一个,因为 feature 会统一。
一个 TLS 后端都不开:编得过,跑不通
不开 TLS 时,noop_tls 提供同名的类型和函数,让上层代码照样编译。它的 TlsSettings::intermediate 直接返回 Ok(Self),根本不读证书文件;Acceptor::tls_handshake 是 unimplemented!("No tls feature was specified");连上游用的 connect 返回一个假的 SslStream,它的 poll_read 立刻返回 0 字节,poll_write 吞掉所有数据并报告写完。
实验 intro-notls:pingora 只开 proxy,add_tls("127.0.0.1:18132", "/nonexistent/cert.pem", "/nonexistent/key.pem") 用两个不存在的路径,upstream_peer 返回 HttpPeer::new("127.0.0.1:18131", true, "localhost")(tls = true)。编译没有任何警告,启动也成功。先发一个明文请求到 18130,再发一个 HTTPS 请求到 18132(debug):
$ curl -s -w 'http_code=%{http_code}\n' http://127.0.0.1:18130/x
http_code=502
$ curl -sk -w 'http_code=%{http_code}\n' https://127.0.0.1:18132/y ; echo "exit=$?"
http_code=000
exit=35
代理的输出:
[2026-10-29T04:59:08Z ERROR pingora_proxy] Fail to proxy: Upstream ConnectionClosed context: Peer: addr: 127.0.0.1:18131, scheme: HTTPS, sni: localhost cause: context: while reading response headers, bytes already read: 0, status: 502, tries: 1, retry: false, GET /x, Host: 127.0.0.1:18130
thread 'Pingora HTTP Proxy Service' (5491759) panicked at .../pingora-core-0.9.0/src/protocols/tls/noop_tls/mod.rs:102:13:
not implemented: No tls feature was specified
上游(一个打印连接的 hyper 服务)看到的是一次 TCP 连接,没有收到任何字节就被关掉:请求头写进了假的 SslStream。下游 TLS 握手的 panic 发生在每个连接自己的任务里,监听循环不受影响,之后明文端口照样返回 502;这和上一节 tokio 1.54 的 panic 不同。
patched_http1
patched_http1 打开后,parse_req_buffer 改调 httparse::Request::parse_unchecked,其余用到它的地方都是 #[cfg(test)] 里的测试;pingora-http 上的同名 feature 在 0.9.0 什么都不控制。crates.io 上的 httparse(本机缓存是 1.10.1)没有这个方法,实验 intro-patched 开 openssl、proxy、patched_http1(debug):
error[E0599]: no method named `parse_unchecked` found for mutable reference `&mut httparse::Request<'_, 'buf>` in the current scope
--> .../pingora-core-0.9.0/src/protocols/http/v1/server.rs:1805:13
|
1805 | req.parse_unchecked(buf)
| ^^^^^^^^^^^^^^^ method not found in `&mut httparse::Request<'_, 'buf>`
也就是说,这个 feature 要配一个打过补丁的 httparse 用([patch.crates-io]),仓库里没有这份补丁。它为什么不写进文档,从这里就能看出来。
阅读路线
- 只想会用:01 全景 的三页;05 的 ProxyHttp 和错误与重试;02 的配置与命令行和平滑升级与停机;04 的 HttpPeer。
- 想读懂实现:按顺序读。03 讲清楚一个
Stream怎么变成一个 HTTP/1 会话,04 讲清楚上游连接从哪来,05 的process_request才好读;06 缓存是插在 05 里的一个大分支。 - 只关心一次请求:一次请求 → L4 流 → HTTP/1 服务端 → process_request → HTTP 连接器 → 连接池 → 双向转发 → 12 动手。
- 只关心进程管理:一个 Server 的一生 → 配置与命令行 → 服务与监听器 → 运行时 → 平滑升级与停机。
前置知识
- async Rust、
async_trait;tokio 的多线程运行时和 current-thread 运行时有什么不同(tokio 卷的多线程调度器和 current-thread 调度器)。 - HTTP/1.1:keep-alive、
Content-Length与 chunked、逐跳头(hop-by-hop header);HTTP/2 的流和 ALPN。 - 反向代理的用语:下游(downstream)是客户端那一侧,上游(upstream)是后端服务那一侧。pingora 的类型名都按这个分:
ServerSession/HttpSession是下游,HttpPeer和ClientSession是上游。 - Cargo feature 怎么在依赖图里统一(上面 TLS 后端开两个时的报错就是它的后果)。
- 读过 hyper 卷 会快很多:同样在 tokio 上的 HTTP/1,hyper 的
Dispatcher和 pingora 的proxy_1to1可以对照着读。
小结
- 22 个 crate 同版本发布;本卷锁 tag
0.9.0(702f690),crates.io 包就是这个 commit;之后 main 上的 36 个提交不跟。 - 请求主线:accept →
set_buffer→process_new的 keep-alive 循环 →read_request→process_request的钩子 → 池里取连接 →proxy_1to1双向转发 →logging→ 两侧连接各自回收。 - 服务主线:
Server::new→bootstrap→add_service→ 每服务一个运行时 →main_loop等信号 → 广播停机 → 宽限期 →shutdown_timeout。 - 缓存主线:
request_cache_filter打开 →cache_key_callback→ miss 加锁 →response_cache_filter→ 边转发边写 → 第二次 hit。 - 实验只在 macOS 上做;tokio 必须是 1.53.2,否则
capacity must be greater than zero在 accept 循环里 panic,整个服务不再接受连接(tokio #8494,pingora 的修复5b30d71未发版)。 - TLS 后端最多开一个(开两个是 E0252 加 74 个连带错误);一个不开时
add_tls能编能启动,握手时 panic,TLS 上游返回 502;patched_http1配 crates.io 的httparse编不过;time只多一个模块路径;proxy会带上整个pingora-cache。