本站的网关
主线位置:请求主线的两头和服务主线的最后一步。一个公网请求先到网关,经过
upstream_peer、upstream_request_filter、response_filter三个钩子转给 app;80 端口的请求在request_filter就结束。连接处理完一个请求之后怎么等下一个,以及容器停止时SIGTERM之后发生什么,是本页一半的篇幅。
flowchart TB
web["浏览器"] --> l443[":443 TLS"]
web --> l80[":80 明文"]
l443 --> alpn{"ALPN"}
alpn -->|h2| h2s["h2 会话"]
alpn -->|http/1.1| h1s["h1 会话"]
h1s --> gw["AppGateway"]
h2s --> gw
gw --> peer["upstream_peer"]
peer --> urf["upstream_request_filter"]
urf --> app["app :8080"]
app --> rf["response_filter"]
l80 --> rq["HttpToHttps"]
rq --> r301["request_filter:301"]
h1s -.->|"keep-alive"| h1s
网关的全部代码在 crates/gateway/src/main.rs,221 行,本站仓库提交 8729cfb。它依赖 pingora = { version = "0.8", features = ["lb", "openssl"] },锁文件里是 pingora-core 0.8.0、pingora-openssl 0.8.1、tokio 1.52.3。本卷锁定 0.9.0,下面先讲每一处在 0.9.0 里对应哪段源码、哪一页,0.8.0 写法不同的地方再链 0.8.0 的源码。
实验都在网关的拷贝上做(M2 Max、macOS 27、rustc 1.99.0),不碰生产服务器和容器:site-gw08 是原样拷贝,只把 443 / 80 / 8080 三个地址换成 127.0.0.1:18910 / 18911 / 18920,证书用 openssl req -x509 现造的自签名证书;锁文件就是网关自己的(tokio 1.52.3)。site-gw08fix 在它上面加了本页给出的三处改法(端口 18913 / 18914)。site-gw09 和 site-gw09fix 是升级到 pingora 0.9.0 的拷贝(18950 / 18951,18953 / 18954),tokio 钉在 1.53.2,原因见怎么读这一卷。上游是一个 Python 写的 HTTP/1.1 服务,把收到的请求行和请求头原样写进响应体,/csp 额外回一个和 app 同款的 Content-Security-Policy 和 X-Frame-Options: SAMEORIGIN。除内存那一节用 release 构建外,都是 debug 构建。
main:两个服务
fn main() {
env_logger::init();
let mut server = Server::new(None).unwrap();
server.bootstrap();
// 443: TLS 终止 + 反代到 app
let mut proxy = http_proxy_service(&server.configuration, AppGateway);
let cert = std::env::var("TLS_CERT_PATH").expect("TLS_CERT_PATH 未配置:指向 fullchain.pem 路径");
let key = std::env::var("TLS_KEY_PATH").expect("TLS_KEY_PATH 未配置:指向 privkey.pem 路径");
let mut tls = pingora_core::listeners::tls::TlsSettings::intermediate(&cert, &key)
.expect("加载 TLS 证书 / 私钥失败:检查路径与权限");
tls.enable_h2();
proxy.add_tls_with_settings(HTTPS_BIND, None, tls);
server.add_service(proxy);
// 80: HTTP→HTTPS 301
let mut redirect = http_proxy_service(&server.configuration, HttpToHttps);
redirect.add_tcp(HTTP_BIND);
server.add_service(redirect);
// ...
server.run_forever();
}
逐行对过去(main):
| 网关代码 | pingora 0.9.0 | 讲它的页 |
|---|---|---|
Server::new(None) | Server::new:不解析命令行,用 ServerConf::default | Server 与配置 |
server.bootstrap() | bootstrap:没有 -t、-u,只打两行日志 | Server 与配置 |
http_proxy_service(&server.configuration, ..) 两次 | http_proxy_service:两个服务,名字都是「Pingora HTTP Proxy Service」,各建一个运行时 | 服务与监听器 |
TlsSettings::intermediate | intermediate:Mozilla intermediate 配置 | TLS |
tls.enable_h2() | enable_h2:ALPN 选 h2 时走 h2 会话 | HTTP/2 |
add_tls_with_settings / add_tcp | add_tls_with_settings、add_tcp | 服务与监听器 |
server.run_forever() | run_forever:等信号 | 平滑升级与停机 |
Server::new(None) 意味着网关的全部配置都是 ServerConf 的默认值。其中三项和本页有关:threads: 1(每个服务的运行时只有一个工作线程,见运行时),grace_period_seconds: None(停机时等 300 秒,见下面「停机」一节),max_retries: 16。超时没有一项是网关自己设的,起作用的是 pingora 0.8.0 的默认值,hyper 卷的本站的服务端路径列过一张表;那张表里「读请求头每次最多等 60 秒」只对一条连接上的第一个请求成立,下面「连接空闲」一节讲之后的请求。
实验:TLS 与 h2
curl 走 h2,带上几个伪造的转发头:
curl -sk --noproxy '*' --http2 -D - https://localhost:18910/csp \
-H 'X-Forwarded-For: 6.6.6.6' -H 'Forwarded: for=6.6.6.6' \
-H 'X-Real-IP: 7.7.7.7' -H 'X-Forwarded-Proto: http'
HTTP/2 200
server: rie-gateway
date: Thu, 29 Oct 2026 06:04:29 GMT
content-type: text/plain
content-length: 148
content-security-policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'
x-frame-options: SAMEORIGIN
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
GET /csp HTTP/1.1
user-agent: curl/8.7.1
accept: */*
x-forwarded-for: 127.0.0.1
Host: localhost:18910
x-real-ip: 127.0.0.1
x-forwarded-proto: https
空行之前是浏览器看到的响应头,之后是上游收到的请求。下游是 h2,上游是 HTTP/1.1:h2 的伪头 :authority 变成了 Host,其余头名保持 h2 的小写。openssl s_client 分别用 TLS 1.2 和 1.3 连,两次 ALPN 都选中 h2:
tls1_2: New, TLSv1.2, Cipher is ECDHE-RSA-AES256-GCM-SHA384 Protocol: TLSv1.2 ALPN protocol: h2
tls1_3: New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384 Protocol: TLSv1.3 ALPN protocol: h2
TLS 用的 OpenSSL 不是系统的。pingora-openssl 的 Cargo.toml 写死了 openssl = { version = "0.10.72", features = ["vendored"] },网关的锁文件里也有 openssl-src 300.6.1+3.6.3:OpenSSL 3.6.3 的源码在构建时编进二进制。本机 release 构建的 otool -L 只列出 libiconv 和 libSystem,strings 里有 OpenSSL 3.6.3 9 Jun 2026。Linux 上的构建没有跑过,但 vendored 不依赖平台。所以 Dockerfile 运行镜像里装的 libssl3t64 网关用不到,Debian 的 OpenSSL 安全更新也到不了网关,要靠更新锁文件里的 openssl-src 重新构建。
h2 的默认上限:0.8.0 没有
本卷 HTTP/2 讲过,0.9.0 的 handshake 在没有给选项时用 default_h2_options,通告 100 个并发流、64 KiB 的头列表上限。0.8.0 的 handshake 是 options.unwrap_or_default(),用 h2 自己的默认值。同一个脚本(发 h2 前言和一个空 SETTINGS,解码服务端的第一个 SETTINGS 帧)对两个拷贝各跑一次:
0.8.0: server frame type=4 len=0 settings=[]
0.9.0: server frame type=4 len=12 settings=[('MAX_CONCURRENT_STREAMS', 100), ('MAX_HEADER_LIST_SIZE', 65536)]
线上网关发的是第一行:不通告并发流上限。这件事不用升级到 0.9.0 也能修。pingora 有一个 0.8.1 版(tag 0.8.1,2026-06-04),CHANGELOG 只有两条安全项,第一条就是「Bound default HTTP/2 server limits to mitigate memory exhaustion」。网关的锁文件建于 0.8.1 之前(a228d8d,2026-05-31);10 月 23 日的 f3011f7 打开 openssl feature 时,cargo 只为新加的 pingora-openssl 解析了最新的 0.8.1,已经锁住的 pingora-core 没动。所以锁文件里才会是 core 0.8.0、openssl 0.8.1 这样的组合。
本卷的 HTTP/2 页说这个改动是 90890bb,首次进入 0.9.0。它在 main 上的日期是 2026-06-03;第二天,同名的 d193c8d(「Bound default HTTP/2 server limits to mitigate memory exhaustion」)进了 0.8.x 分支,随 719ef6c「Release 0.8.1」发布(git tag --contains d193c8d 只有 0.8.1)。两个提交对 pingora-core/src/protocols/http/v2/server.rs 的改动一样:加 DEFAULT_MAX_HEADER_LIST_SIZE、DEFAULT_MAX_CONCURRENT_STREAMS 两个常量和 default_h2_options,把 handshake 里的 unwrap_or_default() 换成 unwrap_or_else(default_h2_options),再加一组读服务端 SETTINGS 的测试。0.8.1 的 CHANGELOG 把它放在「🔒 Security」下面;0.9.0 的 CHANGELOG 把同一件事列进「Potential Breaking Changes」。提交里没有 PR 编号。
本机的 cargo 缓存里没有 pingora-core 0.8.1 的包,离线构建不了,所以「升到 0.8.1 之后 SETTINGS 帧变成第二行」没有实测,只读了 tag 0.8.1 的源码。
AppGateway:三个钩子
AppGateway 的 CTX 是 (),实现了 ProxyHttp 必须的 new_ctx、upstream_peer,再加两个钩子。一个请求经过的完整钩子顺序见一次请求。
upstream_peer
async fn upstream_peer(&self, _session: &mut Session, _ctx: &mut ()) -> Result<Box<HttpPeer>> {
Ok(Box::new(HttpPeer::new(UPSTREAM_ADDR, false, UPSTREAM_SNI.into())))
}
upstream_peer 对每个请求都新建一个 HttpPeer::new。HttpPeer::new 用 std 的 ToSocketAddrs 同步解析地址(见 L4 连接);"127.0.0.1:8080" 是 IP 字面量,ToSocketAddrs 直接解析成 SocketAddr,不查 DNS。tls: false、SNI 为空,PeerOptions 全是默认值(Peer)。每个请求新建的 peer 字段都一样,reuse_hash 相同,所以它们共用连接池里同一组空闲连接(连接池)。
upstream_request_filter:覆盖,而不是追加
req.insert_header("X-Forwarded-Proto", "https").ok();
// 清掉可能的伪造 Forwarded(RFC 7239 变体)
req.remove_header("Forwarded");
if let Some(ip) = session.client_addr().and_then(forwarded_ip) {
req.insert_header("X-Real-IP", ip.clone()).ok();
// ...
req.insert_header("X-Forwarded-For", ip).ok();
} else {
// 拿不到客户端地址也要清掉伪造 XFF
req.remove_header("X-Forwarded-For");
}
upstream_request_filter 在请求头发给上游之前调用(trait 里的声明在 ProxyHttp::upstream_request_filter)。RequestHeader::insert_header 替换同名头的所有值,remove_header 删掉所有值,头名都不区分大小写。上面 h2 的实验里,伪造的 X-Forwarded-For、X-Real-IP、X-Forwarded-Proto 都被覆盖,Forwarded 没有到上游。HTTP/1.1 下发两个 X-Forwarded-For 和三个 Forwarded(其中一个小写 forwarded),上游收到:
GET /dup HTTP/1.1
Host: localhost:18910
User-Agent: curl/8.7.1
Accept: */*
X-Forwarded-For: 127.0.0.1
X-Forwarded-Proto: https
X-Real-IP: 127.0.0.1
每个头只剩一个值,Forwarded 一个不剩。forwarded_ip 只取 IP 不带端口,app 的限流按它分桶。网关没碰的头原样过去,比如客户端发的 X-Forwarded-Host: evil 在 h2 实验的上游请求里照样出现;在 8729cfb 的 crates/ 下搜 x-forwarded-host,网关之外没有代码读它,目前无害。
response_filter:补头,不碰 CSP
response_filter 只调 apply_security_headers:HSTS 和 Server 总是覆盖,三个通用头缺了才补,CSP 不碰。上面 h2 实验的响应头就是结果:/csp 带来的 content-security-policy 和 x-frame-options: SAMEORIGIN 原样保留,server 变成 rie-gateway,补了 HSTS、nosniff 和 Referrer-Policy。上游不带这些头时(/h),X-Frame-Options 补成 DENY。
ProxyHttp::response_filter 只处理上游来的响应。pingora 自己生成的错误响应不经过它。把上游关掉再请求:
HTTP/1.1 502 Bad Gateway
Server: Pingora
Date: Thu, 29 Oct 2026 06:14:33 GMT
Content-Length: 0
Cache-Control: private, no-store
Connection: close
没有 HSTS,Server 也是 Pingora,源码注释想隐藏的版本指纹在这里露出来了。app 重启的那几秒里,访客拿到的就是这样的 502;下面 80 端口实验里的 400 也一样。要统一,需要覆盖 fail_to_proxy 自己写错误响应(规则见错误、重试与故障转移);handle_new_request 里读请求头失败回的 400 发生在任何钩子之前(见 process_request),网关改不了。
HttpToHttps:request_filter 写 301
async fn request_filter(&self, session: &mut Session, _ctx: &mut ()) -> Result<bool> {
let req = session.req_header();
let host = req.headers.get("host").and_then(|v| v.to_str().ok()).unwrap_or("");
let path = req.uri.path_and_query().map(|p| p.as_str()).unwrap_or("/");
let location = format!("https://{}{}", host, path);
let mut resp = ResponseHeader::build(301, None).unwrap();
resp.append_header("Location", location).ok();
resp.append_header("Content-Length", "0").ok();
// end_of_stream=true:301 无 body,回写头即结束
session.write_response_header_ref(&resp, true).await.ok();
Ok(true) // short-circuit;不再走 upstream_peer
}
request_filter 返回 Ok(true) 表示「已经回应了」(ProxyHttp)。write_response_header_ref 是 pingora-proxy 的 Session::write_response_header_ref,第二个参数 end_of_stream;core 里同名的方法只收一个参数。process_request 看到 true,调 logging、finish,把连接交回 keep-alive 循环;upstream_peer 永远不会被调用。这个响应也不经过 response_filter,所以没有 Server: rie-gateway。
80 端口的几种请求,0.8.0 和 0.9.0 两个拷贝各发一遍(Connection: close,只看状态行和 Location):
| 请求 | 0.8.0 | 0.9.0 |
|---|---|---|
Host: rustineverything.app,GET /a/b?x=1 | 301 https://rustineverything.app/a/b?x=1 | 同左 |
没有 Host(HTTP/1.1) | 301 https:///p?q=1 | 同左 |
两个 Host:a.example、b.example | 301 https://a.example/p | 400 |
GET http://rustineverything.app/z?k=1,Host 相同 | 400,日志 invalid uri http://rustineverything.app/z?k=1 | 301 https://rustineverything.app/z?k=1 |
Host: rustineverything.app:80 | 301 https://rustineverything.app:80/ | 同左 |
第一行是正常情况。没有 Host 的 HTTP/1.1 请求按 RFC 9112 应该回 400,pingora 两个版本都放行,网关拼出一个没有主机名的 Location。两个 Host 和绝对形式(absolute-form)的差别来自 0.9.0 的请求目标校验,见 HTTP/1 服务端;0.8.0 拒绝绝对形式,是因为 RequestHeader::set_raw_path 把整个请求目标当成 path 去建 Uri,失败报「invalid uri」。最后一行:Host 带 :80 时网关原样拼进去,跳到 https://…:80/ 就连不上了;浏览器对默认端口不写端口号,只有手工构造的请求会这样。
连接空闲:第一个请求之后不再超时
HTTP/1 服务端 讲过这件事的机制:新连接的第一个会话由 process_new 设成 set_keepalive(Some(60));读完请求头,respect_keepalive 对 HTTP/1.1 设 Some(0),也就是 Infinite;这个值经 HttpPersistentSettings 带给下一个会话,read_request 在 Infinite 下不设超时。0.8.0 的 respect_keepalive 和 process_new 是同样的写法。这个行为从 0.6.0 开始,之前怎样、哪两个提交改的,见动手那一页。
在网关拷贝上,原样的 site-gw08 和加了改法的 site-gw08fix 同时跑,每种连接开一条,最多等 130 秒(uptime 负载 3.2)。silent 是连上(443 的完成 TLS 握手)后什么都不发;one 发一个 GET 收完响应后不再发;h2-silent 握手时 ALPN 选 h2,之后一个字节都不发;h2-preface 发完 h2 前言和一个空 SETTINGS 后不再发:
orig-443-h1-silent: alpn=http/1.1 FIN after 60.0s, got 0 bytes
orig-443-h1-one: alpn=http/1.1 still open after 130.0s, got 426 bytes
orig-80-silent: alpn=None FIN after 60.0s, got 0 bytes
orig-80-one: alpn=None still open after 130.0s, got 148 bytes
orig-443-h2-silent: alpn=h2 still open after 130.0s, got 9 bytes
orig-443-h2-preface: alpn=h2 still open after 130.0s, got 18 bytes
fix-443-h1-silent: alpn=http/1.1 FIN after 60.0s, got 0 bytes
fix-443-h1-one: alpn=http/1.1 FIN after 60.0s, got 426 bytes
fix-80-one: alpn=None FIN after 60.0s, got 148 bytes
fix-443-h2-preface: alpn=h2 still open after 130.0s, got 18 bytes
原样的网关上,HTTP/1 连接只要发过一个请求就不再超时,443 和 80 都是;h2 连接不管发没发过东西都不超时(收到的 9 字节是服务端的 SETTINGS 帧,18 字节多一个 SETTINGS ACK)。0.8.0 的 h2 接流循环在 process_new 里,那里的注释就是 // TODO: add a timeout?。
一条空闲连接多少内存
开 N 条 TLS 连接,每条发一个 GET、收完响应后挂着,前后读网关进程的 RSS(release 构建,用网关自己的 lto = "thin"、codegen-units = 1):
1000 idle keep-alive conns: gateway RSS 11488 KiB -> 79472 KiB, 68.0 KiB per conn
2000 idle keep-alive conns: gateway RSS 79504 KiB -> 146224 KiB, 33.4 KiB per conn
第二行是同一个网关进程上的第二次:第一次的 1000 条连接在脚本退出时关掉,释放的内存被分配器留着、又被这次复用,所以按 2000 条算,峰值 146 MiB。每条空闲连接约 68 KiB。docker-compose.prod.yml 给网关的 mem_limit 是 128m:按这个数,一个客户端开两千条连接、每条发一个请求后挂着,就能让网关超过内存上限。这是 macOS 上的数字,Linux 的分配器和 OpenSSL 缓冲不一定一样,没有在 Linux 上测。
改法:读完请求头之后把 Infinite 换掉
最小的缝在 Session 上:pingora-core 的 Session::set_keepalive 和 get_keepalive 都是 pub 的,pingora-proxy 的 Session 通过 Deref 暴露它们;get_keepalive 对 Infinite 返回 Some(0)。只要在 respect_keepalive 之后、reuse 之前把它改成有限值,HttpPersistentSettings 带给下一个会话的就是 Timeout(60s)。early_request_filter 正好在这个窗口里:handle_new_request 读完请求头之后,process_request 第一件事就是调它。80 端口的 HttpToHttps 已经有 request_filter,加一行即可:
@@ -35,6 +35,17 @@
const HSTS_VALUE: &str = "max-age=31536000; includeSubDomains";
+/// 下游 HTTP/1 keep-alive 连接两个请求之间最多空闲多久(秒)。
+const DOWNSTREAM_IDLE_SECS: u64 = 60;
+
+/// pingora 读完请求头后把 HTTP/1.1 keep-alive 设成无限(`Some(0)`),
+/// 下一个请求之前的那次读就没有超时;换回有限值。`None`(客户端要求 close)不动。
+fn cap_keepalive(session: &mut Session) {
+ if session.get_keepalive() == Some(0) {
+ session.set_keepalive(Some(DOWNSTREAM_IDLE_SECS));
+ }
+}
+
struct AppGateway;
#[async_trait]
@@ -42,6 +53,11 @@
type CTX = ();
fn new_ctx(&self) -> Self::CTX {}
+ async fn early_request_filter(&self, session: &mut Session, _ctx: &mut ()) -> Result<()> {
+ cap_keepalive(session);
+ Ok(())
+ }
+
/// 选定上游:恒为 app 容器(单 upstream,不做 LB)。
async fn upstream_peer(&self, _session: &mut Session, _ctx: &mut ()) -> Result<Box<HttpPeer>> {
Ok(Box::new(HttpPeer::new(UPSTREAM_ADDR, false, UPSTREAM_SNI.into())))
@@ -122,6 +138,7 @@
fn new_ctx(&self) -> Self::CTX {}
async fn request_filter(&self, session: &mut Session, _ctx: &mut ()) -> Result<bool> {
+ cap_keepalive(session);
let req = session.req_header();
let host = req.headers.get("host").and_then(|v| v.to_str().ok()).unwrap_or("");
let path = req.uri.path_and_query().map(|p| p.as_str()).unwrap_or("/");
这是对 8729cfb 的 crates/gateway/src/main.rs 的建议,没有改本站仓库。上面实验里的 fix-443-h1-one 和 fix-80-one 就是加了它之后的结果:发过一个请求的连接也在 60.0 秒时被关掉,和第一个请求之前的超时一致。网关自带的 3 个单元测试照样通过。
问: 为什么要先判断 get_keepalive() == Some(0),直接 set_keepalive(Some(60)) 不是更短?
答: respect_keepalive 遇到 Connection: close 或者不带 Connection: keep-alive 的 HTTP/1.0 请求时设的是 None(Off)。直接设 Some(60) 会把它改回 keep-alive:will_keepalive 变成真,prepare_response_header 写出 Connection: keep-alive,客户端要关的连接被留下来。判断之后这两种请求不受影响,在 site-gw08fix 上发一个 Connection: close 的请求和一个 HTTP/1.0 请求,都是 FIN after 0.0s。停机时 process_new_http 也会设 None,同样保留。
h2 那两行改法管不到:pingora-core 的 Session::set_keepalive 对 H2 什么都不做,0.8.0 也没有别的 h2 空闲超时可配。0.8.0 上能在网关代码里做的只有去掉 enable_h2(),让 443 只走 HTTP/1.1;这会让浏览器失去多路复用,不建议为此这么做。0.9.0 有 HttpServerOptions::h2_idle_timeout(3e657e2,首次进入 0.9.0),下面升级那一节测它。
复用连接上的 POST:0.8.0 会重试
错误、重试与故障转移在 0.8.0 的最小代理上复现过:复用的上游连接读不到响应头时,0.8.0 默认的 error_while_proxy 只看连接是不是复用的,不看方法,POST 也会换一条连接重发。网关没有覆盖这个钩子。用那一页的 flaky_upstream.py(每条连接只答第一个请求,读到第二个就关连接)当上游,三次独立的 curl 经 443 依次发 GET /g1、GET /g2、POST /p1:
GET /g1 -> 200
GET /g2 -> 200
POST /p1 -> 200
上游日志:
conn#1 req#1 GET /g1 HTTP/1.1
conn#1 req#2 GET /g2 HTTP/1.1
conn#1 closing without response
conn#2 req#1 GET /g2 HTTP/1.1
conn#2 req#2 POST /p1 HTTP/1.1
conn#2 closing without response
conn#3 req#1 POST /p1 HTTP/1.1
网关日志里两条 WARN,结尾分别是 tries: 1, retry: true, GET /g2 和 tries: 1, retry: true, POST /p1。POST 被上游收到了两次。这个实验里上游第一次没处理就关了连接;如果 app 处理完、写响应之前连接断了(比如容器在那一刻被重启),同一个 POST 就会执行两次。复用连接被对端关掉、又恰好和新请求撞上,概率不高(连接池讲过 idle_poll 能发现提前关掉的空闲连接),但发表评论、下单这类请求重复一次的代价不小。
改法是把 0.9.0 的默认策略(error_while_proxy)抄进网关:
@@ -75,6 +75,24 @@
Ok(())
}
+ /// pingora 0.8.0 的默认实现在复用连接上出错时连 POST 也重试;照 0.9.0 的默认策略只重试幂等方法。
+ fn error_while_proxy(
+ &self,
+ peer: &HttpPeer,
+ session: &mut Session,
+ e: Box<pingora_core::Error>,
+ _ctx: &mut (),
+ client_reused: bool,
+ ) -> Box<pingora_core::Error> {
+ let mut e = e.more_context(format!("Peer: {peer}"));
+ if !session.req_header().method.is_idempotent() || session.as_ref().retry_buffer_truncated() {
+ e.set_retry(false);
+ } else {
+ e.retry.decide_reuse(client_reused);
+ }
+ e
+ }
+
/// 响应改写:补安全头,见 [`apply_security_headers`]。
async fn response_filter(
&self,
is_idempotent 是 http::Method 的方法。site-gw08fix 上同样三次请求是 200、200、502,POST 那条日志是 ERROR ... status: 502, tries: 1, retry: false, POST /p1,上游日志少了最后一行 conn#3 req#1 POST /p1。换来的是这种情况下客户端看到 502,需要自己重试。升级到 0.9.0 后这段可以删掉。
tokio 1.54:锁文件挡住了
怎么读这一卷讲过:tokio 1.54.0 的 #8494 让 BufReader::with_capacity(0, ..) 直接 panic,而 pingora 接受连接时就是这样建缓冲区的。0.8.0 的位置是 impl From<TcpStream> for Stream,0.9.0 是 同一个 impl,修复 5b30d71 在 main 上,还没发版(见 L4 流)。
网关没有中招,靠的是两样东西:Cargo.lock 里的 tokio 是 1.52.3,Dockerfile 用 cargo build --release --locked,锁文件和 Cargo.toml 对不上就构建失败、不会偷偷重新解析。pingora-core 0.8.0 对 tokio 的要求只是 version = "1",没有上限。在拷贝里 cargo update --offline --dry-run:
Locking 98 packages to highest compatible versions
...
Updating tokio v1.52.3 -> v1.54.1
...
warning: not updating lockfile due to dry run
(离线时 cargo 只在本机缓存里挑版本,联网的 cargo update 会挑当时最新的。)照做一次,site-gw154 就是这样一个拷贝。启动后两个端口各连两次:
https 000 exit=35
http 000 exit=56
https 000 exit=7
http 000 exit=7
thread 'Pingora HTTP Proxy Service' (5686373) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/pingora-core-0.8.0/src/protocols/l4/stream.rs:473:26:
capacity must be greater than zero
每个服务的第一个连接让 accept 循环所在的任务 panic,之后连接被拒(curl 7)。进程没有退出:docker-compose.prod.yml 的 restart: unless-stopped 只在进程退出时起作用,gateway 服务也没有 healthcheck,容器会一直显示在运行。本站仓库没有 Dependabot / Renovate 配置,.github/workflows/ci.yml 里也没有构建或测试 crates/gateway 的步骤,release.yml 只构建镜像;所以这个风险只会在有人手动 cargo update 时出现,出现了也没有任何检查会拦住。
停机:300 秒宽限期与 docker 的 10 秒
SIGTERM 之后的流程见平滑升级与停机:停止 accept、关监听 socket,然后主线程无条件睡 grace_period_seconds,没配时是 300 秒(0.8.0 的 EXIT_TIMEOUT 也是 60 * 5)。0.8.0 的 run 在那之后还要再睡满 graceful_shutdown_timeout_seconds(默认 5 秒),这一行 0.9.0 删了,来历见一个 Server 的一生。
网关怎么被停:Dockerfile 的入口是 tini,它把容器收到的信号转给网关进程(hyper 卷的本站的服务端路径讲过 tini 的作用)。两个 compose 文件里 gateway 都没有 stop_signal、stop_grace_period,用的是 docker 的默认值:先发 SIGTERM,10 秒后 SIGKILL。证书续期脚本 cert_refresh.sh 换完证书后执行 docker restart rie-gateway,部署时 docker compose up -d 换镜像,走的都是这条路。本页没有运行 docker,10 秒是 docker 文档写的默认值。
用一个脚本模仿 docker stop:起网关,发两个在途请求(上游分别睡 2 秒和 12 秒),0.5 秒时发 SIGTERM,1.0 秒时再开一条新连接,等到 10 秒还没退出就 SIGKILL:
== site-gw08
0.50s SIGTERM
1.02s new conn: ConnectionRefusedError: [Errno 61] Connection refused
2.02s in-flight 2s: 200 b'GET /slee'
10.02s still alive at 10 s, SIGKILL, rc=-9
10.02s in-flight 12s: RemoteDisconnected: Remote end closed connection without response
== site-gw08fix
0.51s SIGTERM
1.02s new conn: ConnectionRefusedError: [Errno 61] Connection refused
2.02s in-flight 2s: 200 b'GET /slee'
5.52s in-flight 12s: RemoteDisconnected: Remote end closed connection without response
6.54s gateway exited by itself, rc=0
原样的网关打出 Graceful shutdown: grace period 300s starts,然后一直睡到被 SIGKILL。监听 socket 在 SIGTERM 那一刻就关了,network_mode: host 下新容器要等旧进程退出才能绑上 80 / 443,所以每次重启,新连接被拒的时间至少是 docker 的整整 10 秒。宽限期本来是给在途请求留的,但在这里 300 秒永远等不满。
site-gw08fix 用 new_with_opt_and_conf 传一份显式的配置:
@@ -20,6 +20,7 @@
//! 读配额 60/min 刷新一次就会 429,`/api/auth/me` 还被算进 10/min 的写配额
use async_trait::async_trait;
+use pingora_core::server::configuration::{Opt, ServerConf};
use pingora_core::server::Server;
use pingora_core::upstreams::peer::HttpPeer;
use pingora_core::Result;
@@ -144,7 +145,13 @@
fn main() {
env_logger::init();
- let mut server = Server::new(None).unwrap();
+ // 默认宽限期 300 秒,docker 10 秒后就 SIGKILL;在那之前自己退出
+ let conf = ServerConf {
+ grace_period_seconds: Some(5),
+ graceful_shutdown_timeout_seconds: Some(1),
+ ..Default::default()
+ };
+ let mut server = Server::new_with_opt_and_conf(None::<Opt>, conf);
server.bootstrap();
// 443: TLS 终止 + 反代到 app
Server::new(None) 用的 ServerConf::new() 是解析 version: 1 这一行 YAML、其余字段取 #[serde(default)],和 ServerConf::default() 只差 version 一个字段(1 对 0);这里只改了两项宽限期。宽限期 5 秒后在途的 12 秒请求被切断,进程在 6.54 秒自己退出,返回 0;比 5 秒多出的约 1 秒就是 0.8.0 那次多余的 thread::sleep(shutdown_timeout),日志里两条 Waiting for service runtime 相隔 1 秒。这段改法在 0.9.0 的拷贝上同样能编译。
这是一个取舍,不是白赚:宽限期越短,重启时拒绝新连接的时间越短,超过宽限期的在途请求也越早被切断。网关前面只有一个进程,又绑在宿主的端口上,不停服重启只能靠传监听 fd 的平滑升级,而那条路只在 Linux 上实现,并且要求新旧进程在同一个环境里交接,和「停一个容器、起一个容器」的部署方式对不上。另一种做法是在 compose 里给 gateway 加 stop_grace_period,把 docker 的上限调到比 grace_period_seconds 长;本页没有测。
kill -HUP 不会重载证书
main.rs 开头的文档注释写着「Reload TLS certs without dropping connections: kill -HUP <pid>」。pingora 的 main_loop 只处理 SIGINT、SIGTERM、SIGQUIT,0.8.0(main_loop)也一样,tag 0.9.0 和 main 上都搜不到 SIGHUP。没有处理函数,SIGHUP 的默认动作是结束进程。对 site-gw08 发一次:
rc=129 (128+1=SIGHUP)
日志最后一行停在启动时的 Starting service,信号没有留下任何记录,之后的 curl 是 exit=7。证书只在 TlsSettings::intermediate 里读一次,换证书只能重启进程,cert_refresh.sh 做的正是这件事。注释里提到的「re-exec'd via the signal」指的是 SIGQUIT 加新进程 -u 的平滑升级:Server::new(None) 不解析命令行,网关根本拿不到 -u,这条路在网关上本来就不通。
升级到 0.9.0
site-gw09 只改 Cargo.toml 里四个 "0.8" 为 "0.9",cargo update --offline 之后把 tokio 钉回 1.53.2:
Updating pingora v0.8.0 -> v0.9.0
Updating pingora-core v0.8.0 -> v0.9.0
...
Updating pingora-openssl v0.8.1 -> v0.9.0
...
Updating tokio v1.52.3 -> v1.54.1
...
Downgrading tokio v1.54.1 -> v1.53.2
不钉的话,这一步就把 tokio 带到了 1.54.1,0.9.0 同样会 panic。cargo build 没有任何错误或警告,cargo test 3 个测试全过。0.9.0 CHANGELOG 的「Potential Breaking Changes」里,和网关可能有关的是 RequestHeader / ResponseHeader 不再 DerefMut;网关只用 insert_header、remove_header、append_header 改头,读头时用的 req.headers.get、resp.headers.contains_key 走的是 Deref,所以碰不到。
行为差别,用同一组请求实测(443 上的请求都走 TLS 上的 HTTP/1.1,80 上的见上面的表):
| 项 | 0.8.0 | 0.9.0 |
|---|---|---|
客户端发 Connection: close, X-Secret、X-Secret、Keep-Alive、TE、Proxy-Authorization | 全部原样转给 app | 只剩 Host、X-Kept 和网关加的三个头 |
Connection: close, X-Forwarded-For | 200 | 400,日志 protected header cannot be nominated by the Connection header |
| 复用连接上失败的 POST | 重试,上游收到两次 | 502 |
| h2 SETTINGS | 空 | MAX_CONCURRENT_STREAMS 100、MAX_HEADER_LIST_SIZE 65536 |
| HTTP/1 发过一个请求后的空闲 | 不超时 | 不超时 |
| tokio 1.54 | panic | panic |
前两行来自 0.9.0 默认的逐跳头清理(HTTP/1 服务端),它在 upstream_request_filter 之前执行,网关加的头不受影响。站点不用 WebSocket(app 的 crates/app/src/server/security.rs 注释写着「站点本身不用 WebSocket」,ws: 只给开发时的热重载),0.9.0 只放行 WebSocket 升级的默认策略没有影响。另外,0.8.0 读上游响应头不是 cancel safe 的,请求体还在转发时响应头分两次到达会变成 502(双向转发),0.9.0 修了;app 是 hyper,响应头通常一次写完,本页没有在网关上复现。
升级后,空闲问题的 HTTP/1 一半照样要靠 cap_keepalive,h2 一半可以用新的 h2_idle_timeout。site-gw09fix 在 site-gw09 上加了 cap_keepalive,再给 443 的服务设 60 秒:
let mut opts = pingora_core::apps::HttpServerOptions::default();
opts.h2_idle_timeout = Some(std::time::Duration::from_secs(DOWNSTREAM_IDLE_SECS));
proxy.app_logic_mut().expect("proxy service has app logic").server_options = Some(opts);
HttpServerOptions 是 #[non_exhaustive],不能写结构体字面量,只能从 default() 开始改字段。
site-gw09fix 上,一条 TLS 连接 ALPN 选了 h2,之后一个字节都不发(连 h2 前言都不发)。设了 h2_idle_timeout 60 秒,它会在 60 秒时被关掉吗?动脑筋答案
不会。site-gw09 和 site-gw09fix 同时跑,最多等 130 秒(uptime 负载 11.9):
v09-443-h1-one: alpn=http/1.1 still open after 130.0s, got 426 bytes
v09-80-one: alpn=None still open after 130.0s, got 148 bytes
v09-443-h2-silent: alpn=h2 still open after 130.0s, got 21 bytes
v09-443-h2-preface: alpn=h2 still open after 130.0s, got 30 bytes
v09fix-443-h1-silent: alpn=http/1.1 FIN after 60.0s, got 0 bytes
v09fix-443-h1-one: alpn=http/1.1 FIN after 60.0s, got 426 bytes
v09fix-80-one: alpn=None FIN after 60.0s, got 148 bytes
v09fix-443-h2-preface: alpn=h2 FIN after 60.0s, got 30 bytes
v09fix-443-h2-silent: alpn=h2 still open after 130.0s, got 21 bytes
v09-*四行:0.9.0 不加改法,和 0.8.0 一样,发过请求的 HTTP/1 连接和 h2 连接都不超时。21 字节是带两个参数的 SETTINGS 帧(9 + 12),30 字节多一个 ACK。v09fix-443-h1-*和v09fix-80-one:cap_keepalive在 0.9.0 上一样有效。v09fix-443-h2-preface:发过前言、握手完成的 h2 连接,没有在途的流,60 秒后被关掉,这是h2_idle_timeout管的。v09fix-443-h2-silent:不发前言的连接 130 秒后还开着。h2_idle_timeout只在accept_downstream_sessions的接流循环里生效,而这条连接卡在它前面的handshake:h2 的服务端握手要读到客户端前言才返回,pingora 外面没有套超时。TLS 握手本身有 60 秒的超时(listening.rs里的timeout(Duration::from_secs(60), io.handshake())),但这个客户端已经握完了。tag 之后 main 上到84f21ee,handshake没有改。
所以即使升级到 0.9.0 并设了 h2_idle_timeout,只握手不说话的 h2 连接仍然能一直占着网关;要堵住这个口子,得改 pingora 本身。
小结
- 网关是两个
http_proxy_service:443 用TlsSettings::intermediate加enable_h2,80 在request_filter里写 301 返回Ok(true)。Server::new(None)让所有配置取默认值:一个工作线程、300 秒宽限期、16 次重试。 upstream_request_filter的insert_header覆盖同名头的所有值,伪造的X-Forwarded-For、X-Real-IP、X-Forwarded-Proto都被换掉,Forwarded全删;response_filter补安全头,CSP 原样转发。pingora 自己生成的 400 / 502 不经过response_filter,带的是Server: Pingora。- 80 端口:没有
Host时跳到https:///…;两个Host0.8.0 取第一个、0.9.0 回 400;绝对形式 0.8.0 回 400、0.9.0 正常跳转。 - HTTP/1 连接发过一个请求后空闲不再超时,每条约 68 KiB,两千条超过容器的 128 MiB;在
early_request_filter和request_filter里把Some(0)换成Some(60)就修好了,实测 60.0 秒关闭。h2 连接 0.8.0 上没有办法,0.9.0 的h2_idle_timeout只管握手之后。 - 0.8.0 会在复用连接失败时重试 POST,实测上游收到两次;在网关里覆盖
error_while_proxy、照抄 0.9.0 的策略后是 502。 - 锁文件的 tokio 1.52.3 加
--locked挡住了 1.54 的 panic;一次cargo update就会失守,panic 后进程不退出,restart不会救它。锁文件里的 pingora-core 还停在 0.8.0,错过了 0.8.1 的 h2 上限修复。 SIGTERM后网关要睡 300 秒,docker 10 秒就 SIGKILL,每次重启都拒绝新连接整整 10 秒;显式配grace_period_seconds能让它自己退出,代价是更早切断长请求。kill -HUP会直接结束进程,不会重载证书。- 升级到 0.9.0 不需要改代码:编译和测试都通过,换来逐跳头清理、POST 不重试、有界的 h2 默认值;空闲超时和 tokio 钉版本两件事仍然要自己处理。