Rust in Everything
基于 pingora-core 0.9.0

第一个请求之后连接永不空闲超时

主线位置:请求主线的最后一步「下游 keep-alive」,也就是一个请求处理完、reuse 之后等下一个请求的那次读。respect_keepalive 怎么把超时改成无限、这个值怎么经 HttpPersistentSettings 带给下一个会话,见 HTTP/1 服务端;这个行为从哪个版本开始,见动手。本页给出复现、0.8.0 上的对照和改法。

stateDiagram-v2
  [*] --> FirstWait: 新连接 60 s
  FirstWait --> Closed: 60 s 没有请求
  FirstWait --> Serving: 读到请求头
  Serving --> Infinite: respect_keepalive Some 0
  Infinite --> Serving: 下一个请求
  Infinite --> Infinite: 空闲 永不超时
  Serving --> Capped: early_request_filter 改成 60
  Capped --> Closed: 空闲 60 s

实验 crate exp-pingora/pita-lab,bin px:明文 19250,TLS(enable_h2)19251;同一个 bin 加 PX_FIX_KA=1 PX_H2IDLE=60 起在 19256 / 19257。本站网关的拷贝 exp-pingora/pita-gw(pingora 0.8.0、tokio 1.52.3,443 / 80 改成 19232 / 19233,PITA_FIX_KA=1 打开本页的改法)。客户端 tools/idle2.py:silent 连上(TLS 的完成握手)后什么都不发;one 发一个 GET 收完响应后不再发;close 发一个带 Connection: close 的 GET;h2 发 h2 前言和一个空 SETTINGS 后不再发。所有连接同时开,最多等 130 秒。M2 Max、macOS 27、rustc 1.99.0,debug,0.9.0 的一侧 tokio 1.53.2,uptime 负载约 4.2。

现象

v09-h1-silent: alpn=None FIN after 60.0s, got 0 bytes
v09-h1-one: alpn=None still open after 130.0s, got 198 bytes
v09-tls-h1-one: alpn=http/1.1 still open after 130.0s, got 198 bytes
v09-tls-h2-silent: alpn=h2 still open after 130.0s, got 21 bytes
v09-tls-h2-preface: alpn=h2 still open after 130.0s, got 30 bytes
v08gw-443-h1-silent: alpn=http/1.1 FIN after 60.0s, got 0 bytes
v08gw-443-h1-one: alpn=http/1.1 still open after 130.0s, got 426 bytes
v08gw-80-one: alpn=None still open after 130.0s, got 148 bytes
v08gw-443-h2-preface: alpn=h2 still open after 130.0s, got 18 bytes
  • 什么都不发的 HTTP/1 连接在 60.0 秒被关掉,这是新连接等第一个请求的超时。
  • 发过一个请求的 HTTP/1 连接 130 秒后还开着,明文和 TLS 都一样,0.9.0 和 0.8.0 都一样。
  • h2 连接不管发没发前言都不超时。got 21 bytes 是 0.9.0 服务端那个带两个参数的 SETTINGS 帧,30 多一个 SETTINGS ACK;0.8.0 的 SETTINGS 是空的,所以是 18。

一条这样的连接什么也不做,就一直占着一个 fd 和它的缓冲区。在本站网关的拷贝上量过,每条空闲的 TLS 连接约 68 KiB,两千条就超过网关容器 128 MiB 的内存上限,见在本站中。

出处

新连接的第一个会话由 process_new 设 set_keepalive(Some(60))。读完请求头,respect_keepalive 改掉它:

            if keepalive {
                let (timeout, _max_use) = self.get_keepalive_values();
                // TODO: respect max_use
                match timeout {
                    Some(d) => self.set_keepalive(Some(d)),
                    None => self.set_keepalive(Some(0)), // infinite
                }
            } else {
                self.set_keepalive(None);
            }
        } else if self.req_header().version == Version::HTTP_11 {
            self.set_keepalive(Some(0)); // on by default for http 1.1

get_keepalive_values 没有实现,总是 (None, None),所以 HTTP/1.1 的 keep-alive 请求一律得到 Some(0),也就是无限。HttpPersistentSettings 把这个值带给下一个会话,read_request 在无限时不设超时。0.8.0 是同样的写法,main 上到 84f21ee 没改。

h2 是另一条路:TLS 握完、ALPN 选了 h2 之后,handshake 要读到客户端前言才返回,外面没有超时;握手之后的接流循环只有在设了 HttpServerOptions::h2_idle_timeout(0.9.0 新加,默认 None)时才会因空闲关连接。

修法

HTTP/1:在 respect_keepalive 之后、reuse 之前把 Some(0) 换成有限值。early_request_filter 是读完请求头后第一个调用的钩子,正好在这个窗口里:

  async fn early_request_filter(&self, session: &mut Session, _ctx: &mut ()) -> Result<()> {
    if session.get_keepalive() == Some(0) {
      session.set_keepalive(Some(60));
    }
    Ok(())
  }

只改 Some(0):客户端要关(Connection: close、不带 keep-alive 的 HTTP/1.0)时这个值是 None,不能把它改回 keep-alive。h2:0.9.0 上给服务设 h2_idle_timeout:

  let mut o = HttpServerOptions::default();
  o.h2_idle_timeout = Some(Duration::from_secs(60));
  app.server_options = Some(o);

HttpServerOptions 是 #[non_exhaustive],只能从 default() 改字段。同一轮里加了改法的那一组:

v09fix-h1-close: alpn=None FIN after 0.0s, got 193 bytes
v09fix-h1-one: alpn=None FIN after 60.0s, got 198 bytes
v09fix-tls-h2-preface: alpn=h2 FIN after 60.0s, got 30 bytes
v09fix-tls-h2-silent: alpn=h2 still open after 130.0s, got 21 bytes

网关的拷贝(0.8.0)打开 PITA_FIX_KA=1,early_request_filter 和 80 端口的 request_filter 里各调一次同样的判断:

v08gwfix-443-h1-close: alpn=http/1.1 FIN after 0.0s, got 439 bytes
v08gwfix-80-one: alpn=None FIN after 60.0s, got 148 bytes
v08gwfix-443-h1-one: alpn=http/1.1 FIN after 60.0s, got 426 bytes
  • 发过一个请求的 HTTP/1 连接在 60.0 秒被关掉,和第一个请求之前的超时一致;要求关闭的连接照样立刻关。0.8.0 和 0.9.0 都有效。
  • 发过前言的 h2 连接在 60.0 秒被 h2_idle_timeout 关掉。0.8.0 没有这个选项。
  • 只握手、不发 h2 前言的连接仍然开着:它卡在 handshake 里,h2_idle_timeout 管不到,在 pingora 外面没有办法设这个超时。这一条是上游问题,0.8.0、0.9.0 和 main 都一样。

怎么确认没中招

  • 用一个脚本开一条连接、发一个请求、收完响应后什么都不做,看服务端多久关它;超过你预期的空闲超时还开着,就是中招了。上面的 idle2.py ... one 130 就是这样做的。
  • 看服务端的连接数(lsof -nP -p <pid> -iTCP -sTCP:ESTABLISHED | wc -l)有没有只涨不跌。
  • 前面有负载均衡或 CDN 的部署,看它们自己的空闲超时;它们会替你关掉空闲连接,直接对公网的服务就只能靠自己。

小结

  • HTTP/1.1 连接只给第一个请求 60 秒;处理完一个请求后 respect_keepalive 设 Some(0),之后的空闲等待没有超时。0.8.0、0.9.0、main 一样。
  • 在 early_request_filter 里把 Some(0) 换成 Some(60),实测 60.0 秒关闭;None 不要动。
  • h2:0.9.0 的 h2_idle_timeout 能关握手之后空闲的连接;只握 TLS、不发 h2 前言的连接仍然永远挂着。

💬 关联讨论

发起讨论
加载中...