Rust in Everything
基于 pingora-proxy 0.9.0

本站的网关

主线位置:请求主线的两头和服务主线的最后一步。一个公网请求先到网关,经过 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::defaultServer 与配置
server.bootstrap()bootstrap:没有 -t、-u,只打两行日志Server 与配置
http_proxy_service(&server.configuration, ..) 两次http_proxy_service:两个服务,名字都是「Pingora HTTP Proxy Service」,各建一个运行时服务与监听器
TlsSettings::intermediateintermediate:Mozilla intermediate 配置TLS
tls.enable_h2()enable_h2:ALPN 选 h2 时走 h2 会话HTTP/2
add_tls_with_settings / add_tcpadd_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 这样的组合。

📝NOTE
冷知识:0.9.0 的有界默认值,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.00.9.0
Host: rustineverything.app,GET /a/b?x=1301 https://rustineverything.app/a/b?x=1同左
没有 Host(HTTP/1.1)301 https:///p?q=1同左
两个 Host:a.example、b.example301 https://a.example/p400
GET http://rustineverything.app/z?k=1,Host 相同400,日志 invalid uri http://rustineverything.app/z?k=1301 https://rustineverything.app/z?k=1
Host: rustineverything.app:80301 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 个单元测试照样通过。

📝NOTE
没有蠢问题

问: 为什么要先判断 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.00.9.0
客户端发 Connection: close, X-Secret、X-Secret、Keep-Alive、TE、Proxy-Authorization全部原样转给 app只剩 Host、X-Kept 和网关加的三个头
Connection: close, X-Forwarded-For200400,日志 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.54panicpanic

前两行来自 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() 开始改字段。

❗IMPORTANT
动脑筋: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:///…;两个 Host 0.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 钉版本两件事仍然要自己处理。

💬 关联讨论

发起讨论
加载中...