Rust in Everything
基于 pingora-core 0.9.0

Some(H2Options::new()):有界的 h2 默认值没了

主线位置:请求主线里下游走 h2 的那条岔路,process_new 选中 h2 后调 handshake,服务端的 SETTINGS 在这里发出。h2 选项从哪里来、0.9.0 为什么加了上限,见 HTTP/2。本页讲自定义选项时最容易写出的那一行。

flowchart TB
  opt{"h2_options"} -->|None| def["default_h2_options"]
  opt -->|"Some new"| raw["h2 Builder 默认"]
  opt -->|"Some 从默认改"| mod["上限加改动"]
  def --> b["100 流 64 KiB"]
  mod --> b
  raw --> u["不通告 不限"]
  b --> s431["80 KB 头 431"]
  u --> s200["80 KB 头 200"]

实验 crate exp-pingora/pita-lab:bin px 开 h2c,PX_H2OPTS=none(h2_options 保持 None)、new(Some(H2Options::new()))、default(Some(default_h2_options()) 再改窗口)三个进程分别在 19265、19266、19267;tools/h2settings.py 发 h2 前言和空 SETTINGS,打印服务端的 SETTINGS;bin h2probe 用 h2 crate 做客户端,先发一个带 20 个 4000 字节头(共 80 KB)的请求,再在一条连接上同时发 120 个 /sleep?s=1。上游是 tools/echo_upstream.py。M2 Max、macOS 27、rustc 1.99.0,debug,tokio 1.53.2。

现象

想把 h2 的初始窗口调大一点,顺手写成:

  let mut o = H2Options::new();
  o.initial_window_size(1 << 20);
  proxy.app_logic_mut().unwrap().h2_options = Some(o);

H2Options::new() 这一步就把默认上限丢了。只看最小的形式 Some(H2Options::new()),和不设、从默认值改三者对比:

== PX_H2OPTS=none
SETTINGS MAX_CONCURRENT_STREAMS=100, MAX_HEADER_LIST_SIZE=65536
SETTINGS ACK
80 KB header list: status 431 Request Header Fields Too Large
120 concurrent /sleep?s=1 on one connection: 120 x 200 in 2.03s
== PX_H2OPTS=new
SETTINGS (empty)
SETTINGS ACK
80 KB header list: status 200 OK
120 concurrent /sleep?s=1 on one connection: 120 x 200 in 1.02s
== PX_H2OPTS=default
SETTINGS MAX_CONCURRENT_STREAMS=100, INITIAL_WINDOW_SIZE=1048576, MAX_HEADER_LIST_SIZE=65536
SETTINGS ACK
80 KB header list: status 431 Request Header Fields Too Large
120 concurrent /sleep?s=1 on one connection: 120 x 200 in 2.03s
  • 不设时,服务端通告 100 个并发流、64 KiB 头列表;80 KB 的头被 431 拒绝,120 个请求按 100 个一批分两批跑完,用了 2 秒。
  • Some(H2Options::new()) 的 SETTINGS 是空的:并发流按协议就是不限,头列表的本地上限回到 h2 自己的 16 MiB。80 KB 的头被放行转给上游,120 个请求在一条连接上同时进行。
  • 改窗口这类正常的需求,结果看起来一切正常:请求都成功了,甚至更快了。丢掉的是对恶意客户端的保护,平时测不出来。

出处

H2Options 就是 h2::server::Builder 的别名,H2Options::new() 是 h2 的 Builder::new(),什么都不设。上限只在 default_h2_options 里设,而 handshake 只在调用方没给选项时才用它:

pub fn default_h2_options() -> H2Options {
    let mut options = H2Options::default();
    options.max_header_list_size(DEFAULT_MAX_HEADER_LIST_SIZE);
    options.max_concurrent_streams(DEFAULT_MAX_CONCURRENT_STREAMS);
    options
}

pub async fn handshake(io: Stream, options: Option<H2Options>) -> Result<H2Connection<Stream>> {
    let options = options.unwrap_or_else(default_h2_options);
    // ...

代理服务的选项是 HttpProxy 上的 pub h2_options,一旦是 Some,unwrap_or_else 就不会调 default_h2_options。default_h2_options 的文档注释写的是「Use this as the starting point when customizing options」,而 H2Options 这个类型自己的构造函数 new() / default() 来自 h2,不知道 pingora 的上限。上限是 90890bb 在 0.9.0 加的,同样的改动以 d193c8d 先发在 0.8.1 的 Security 一节里;本站网关锁在 0.8.0,连这两个上限都没有,见在本站中。main 上到 84f21ee 这几行没变。

修法

从 default_h2_options() 开始改:

  let mut o = default_h2_options();
  o.initial_window_size(1 << 20);
  app.h2_options = Some(o);

上面 PX_H2OPTS=default 那一组就是它:SETTINGS 里在 100 和 65536 之外多了 INITIAL_WINDOW_SIZE=1048576,80 KB 的头照样 431,120 个请求照样两批。

要比默认值更宽或更紧,就在 default_h2_options() 的基础上显式写 max_concurrent_streams、max_header_list_size,让数字出现在自己的代码里,而不是靠「没写」。

怎么确认没中招

  • 搜 H2Options::new()、H2Options::default()、h2::server::Builder::new():每一处都应当换成 default_h2_options(),或者在后面显式设两个上限。
  • 发一次 h2 前言,看服务端的第一个 SETTINGS 帧(h2settings.py PORT):里面应当有 MAX_CONCURRENT_STREAMS 和 MAX_HEADER_LIST_SIZE;SETTINGS (empty) 说明上限没了。TLS 端口要先握手、ALPN 选 h2 再发。
  • 升级 pingora 时注意:0.8.0 的 handshake 是 options.unwrap_or_default(),从 0.8.0 带过来的代码就算写的是 None 也没有上限,要升到 0.8.1 或 0.9.0。

小结

  • 0.9.0 的 h2 服务端在 h2_options 为 None 时用 default_h2_options():100 个并发流、64 KiB 头列表。
  • 传 Some(H2Options::new()) 会绕过它,SETTINGS 是空的:80 KB 的头被放行、120 个流同时进行,平时看起来还更快。
  • 自定义 h2 选项从 default_h2_options() 改起,用 SETTINGS 帧确认两个上限还在。

💬 关联讨论

发起讨论
加载中...