Rust in Everything
基于 pingora-core 0.9.0

HTTP/2

主线位置:请求主线的第二步换成 h2 时的样子。process_new 在 TLS 握手选出 h2、或明文端口上读到 h2 连接前言时,不建 v1 的 HttpSession,而是做 h2 握手,再为每个流建一个 v2 的 HttpSession,各自走一遍和 HTTP/1 相同的 process_new_http。上游方向的 Http2Session 是连接器的事,本页只讲到它包了什么。

flowchart TB
  pn["process_new"] --> tls{"TLS?"}
  tls -->|是| alpn{"ALPN 是 h2?"}
  tls -->|否| h2c{"开了 h2c?"}
  h2c -->|是| peek["try_peek 24 字节"]
  peek -->|"是前言"| hs["server::handshake"]
  peek -->|"不是"| h1["v1 HttpSession"]
  h2c -->|否| h1
  alpn -->|是| hs
  alpn -->|否| h1
  hs --> loop["accept 循环"]
  loop --> chk["from_h2_conn"]
  chk -->|"坏流: RST 或 400"| loop
  chk -->|"好流"| task["每流一个任务"]

代码在 pingora-core/src/protocols/http/v2/:server.rs 2093 行(前 958 行是实现),client.rs 909 行,mod.rs 770 行(共用的 write_body 和测试)。HTTP/2 的帧、HPACK、流控全在 h2 crate 里,实验锁文件里是 h2 0.4.20;pingora 这一层负责的是:什么时候进 h2、握手参数、把 h2::server::Connection 拆成一个个会话、校验请求、写响应时去掉 HTTP/1 专用的头。

实验在 exp-pingora/proto-main(M2 Max、macOS 27、rustc 1.99.0,debug,tokio 1.53.2)。两个 h2c 端口都转到 18301 的原始 TCP 上游:18303 用默认设置,18304 在 HttpProxy 上设了 h2_options = Some(H2Options::new())。

什么时候走 h2

入口在 process_new:

        let mut h2c = self.server_options().as_ref().map_or(false, |o| o.h2c);
        // ...
        // h2c is for cleartext connections; on TLS, ALPN handles protocol negotiation.
        // Otherwise, h2c stays true on TLS streams, forcing HTTP/1.1 clients into HTTP/2
        if stream.get_ssl_digest().is_some() {
            h2c = false;
        }
        // try to read h2 preface
        else if h2c && !custom {
            let mut buf = [0u8; H2_PREFACE.len()];
            let peeked = stream
                .try_peek(&mut buf)
                .await
                .map_err(|e| {
                    // this error is normal when h1 reuse and close the connection
                    debug!("Read error while peeking h2c preface {e}");
                    e
                })
                .ok()?;
            // not all streams support peeking
            if peeked {
                // turn off h2c (use h1) if h2 preface doesn't exist
                h2c = buf == H2_PREFACE;
            }
        }
        if h2c || matches!(stream.selected_alpn_proto(), Some(ALPN::H2)) {

两条路:

  • TLS + ALPN。 监听器的 TlsSettings 调了 enable_h2(ALPN 设成 H2H1),客户端提议 h2,握手后 selected_alpn_proto() 是 H2。不调 enable_h2 就不会有 h2,见 TLS。
  • 明文 h2c。 HttpServerOptions 的 h2c 设成 true。连接一进来先 try_peek 24 字节(H2_PREFACE,PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n),完全相同才走 h2,否则把读到的字节退回去走 HTTP/1。这只支持「先验知识」(prior knowledge),不支持 HTTP/1.1 的 Upgrade: h2c。

同一个 h2c 端口上:

$ curl --http2-prior-knowledge -i http://127.0.0.1:18303/h2c
HTTP/2 200
...
content-length: 92
...

GET /h2c HTTP/1.1\r\nuser-agent: curl/8.7.1\r\naccept: */*\r\nHost: 127.0.0.1:18303\r\n\r\n

$ curl -i http://127.0.0.1:18303/h1
HTTP/1.1 200 OK
...

下游是 h2,到上游变成了 HTTP/1.1,:authority 被 proxy_h1 写成了 Host(放在最后,因为是后插进去的)。

try_peek 是 read_exact,要凑满 24 字节才返回,中间没有超时。比 24 字节短的明文请求,比如 GET / HTTP/1.0\r\n\r\n(18 字节),发到 h2c 端口上:

-- 0 bytes; idle timeout (connection still open)

客户端等了 3 秒,一个字节也没收到;关掉连接时代理打出 Read error while peeking h2c preface early eof。同一个请求发到非 h2c 端口会得到 200。hyper-util 的 auto::Builder 也读最多 24 字节、也没有超时,但它逐字节比对,第一个字节对不上就转去 HTTP/1,这个 18 字节的请求在那里不会卡住,见 auto。

📝NOTE
冷知识:0.9.0 之前,开了 h2c 的服务会把 TLS 上的 HTTP/1.1 客户端推进 h2

process_new 里那段「h2c is for cleartext connections」的判断是 0f7c505(「fix: skip h2c preface detection on TLS streams」,PR #826,首次进入 0.9.0)加的。之前的代码只看 h2c && !custom 就去 peek;TLS 流不支持 peek(Peek 的默认实现返回 false),peeked 为假,h2c 就保持 true。提交说明:「When h2c is enabled and both TCP and TLS listeners share a service, preface detection runs on TLS streams too. On TLS, try_peek returns peeked=false, leaving h2c=true unconditionally. This forces all TLS connections into the HTTP/2 branch, breaking HTTP/1.1 clients.」修复是先看 get_ssl_digest(),TLS 上把 h2c 关掉,交给后面的 ALPN 判断。

握手参数:0.9.0 的有界默认值

H2Options 就是 h2::server::Builder。handshake 在调用方没给选项时用 default_h2_options:

// 64 KiB decoded header-list limit.
const DEFAULT_MAX_HEADER_LIST_SIZE: u32 = 64 * 1024;
const DEFAULT_MAX_CONCURRENT_STREAMS: u32 = 100;
// ...
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);
    // ...
}

这是 90890bb(「Bound default HTTP/2 server limits to mitigate memory exhaustion」,首次进入 0.9.0),CHANGELOG 把「Default HTTP/2 server limits are bounded rather than unbounded」列为可能破坏兼容的变更。同样的改动以 d193c8d 先发进了 0.8.1(2026-06-04),0.8.1 的 CHANGELOG 把它列在 Security 下;还停在 0.8.0 的项目没有这两个上限(本站网关就是,见在本站中)。之前是 options.unwrap_or_default(),即 h2 的默认值:不通告 MAX_CONCURRENT_STREAMS(按协议就是不限),本地对解码后头列表的上限是 h2 0.4.20 framed_read.rs 里的 16 << 20(16 MiB)。

选项从 HttpServerApp::h2_options 来,代理服务是 HttpProxy 上的 pub h2_options 字段。陷阱在 Option 上:给了 Some(...),unwrap_or_else 就不会调 default_h2_options。从 H2Options::new() 开始改一个窗口大小,100 和 64 KiB 两个上限就一起没了;default_h2_options 的文档注释写的正是「Use this as the starting point when customizing options」。一个 Python 脚本发 h2 前言和一个空 SETTINGS,打印服务端的第一个 SETTINGS 帧,两个端口各跑一次,结果在动脑筋答案里。

❗IMPORTANT
动脑筋:18303(h2_options 为 None)和 18304(h2_options = Some(H2Options::new()))发出的第一个 SETTINGS 帧里各有哪些参数?

两边都不改窗口:h2 的默认流窗口和连接窗口都是协议默认的 65535,pingora 不通告也不发 WINDOW_UPDATE 放大它。hyper 的服务端默认是 200 个并发流、1 MiB 的窗口、16 KiB 的头列表上限,见 HTTP/2 服务端。

一个流,一个 HttpSession

握手之后,accept_downstream_sessions 循环调 from_h2_conn,conn.accept() 驱动整个 h2 连接的读写。select! 里还有两个分支:停机信号到了就 conn.graceful_shutdown()(先发一个 last_stream_id 为最大值的 GOAWAY),之后不再响应信号、继续接流直到连接排空;设了 h2_idle_timeout 且没有在途的流时,到时直接丢掉连接。每接到一个流,process_new 传进来的闭包 spawn 一个任务,ServerSession::new_http2(h2_stream) 进 process_new_http。同一条连接上的流共享一个 Arc<Digest>,它在握手前从 L4 / TLS 流上收集好。

from_h2_conn_with_malformed_budget 在把流交出去之前做三项检查,每项失败都只影响这一个流:

  1. :path 里有 CR、LF 这类分隔字节:回 RST_STREAM(PROTOCOL_ERROR)。h2 允许这些字节进 :path,转给 HTTP/1 上游就能拆出第二个请求行,所以这里兜底。
  2. Content-Length 冲突或不可解析:RST_STREAM(PROTOCOL_ERROR),规则和 HTTP/1 相同,见消息体。
  3. invalid_request_authority:重复 Host、userinfo、Host 与 :authority 不一致、两者都没有、:path 是绝对形式:回一个 400 并结束流。注释说这里故意不用 RST,因为「An immediate PROTOCOL_ERROR reset would replace this observable 400 in h2」。

三项共用一个每连接的计数器,累计到 MAX_MALFORMED_STREAMS_PER_CONN(32)就报连接级错误,整条连接关掉;合法的流不会清零它。这三项本页没有构造请求验证(curl 发 h2 时会把 Host 头改写成 :authority,造不出不一致),server.rs 的测试覆盖了它们。

v2 的 HttpSession 比 v1 小得多:请求头、RecvStream(请求体)、SendResponse、写响应体用的 SendStream、ended 标记、字节计数、重试缓冲、共享的 digest 和两个超时。没有 keep-alive、没有 body 状态机,这些都是 h2 的流在管。读请求体的 read_body_bytes 每拿到一块数据就 release_capacity(data.len()),把流控窗口还给对端:代理读得慢,客户端就会被窗口挡住,这就是 h2 下的背压。

写响应

write_response_header:

        if header.status.is_informational() {
            // ignore informational response 1xx header because send_response() can only be called once
            // https://github.com/hyperium/h2/issues/167
            debug!("ignoring informational headers");
            return Ok(());
        }
        // ...
        /* update headers */
        header.insert_header(header::DATE, get_cached_date())?;

        // remove other h1 hop headers that cannot be present in H2
        // https://httpwg.org/specs/rfc7540.html#n-connection-specific-header-fields
        header.remove_header(&header::TRANSFER_ENCODING);
        header.remove_header(&header::CONNECTION);
        header.remove_header(&header::UPGRADE);
        header.remove_header(&HeaderName::from_static("keep-alive"));
        header.remove_header(&HeaderName::from_static("proxy-connection"));

h2 的 SendResponse::send_response 每个流只能调一次,所以 1xx 全部被丢掉,包括 100 Continue:h2 下游发 Expect: 100-continue,上游回的 100 到不了客户端。HTTP/1 上游响应里的 Transfer-Encoding、Connection 等在这里删掉,HTTP/2 禁止这些头。

写 body 用 mod.rs 里共用的 write_body,服务端写响应、客户端写请求体都用它。reserve_and_send 一次把剩余字节全部 reserve_capacity,poll_capacity 等到多少就切多少发出去,循环到发完;空数据不能等容量,直接 send_data。写超时套在每一次 reserve_and_send 外面,不是整段 body 的总时长。没有长度的响应体也不需要 chunked,finish 发一个空的 DATA 帧带上 END_STREAM。pingora 一次预留整块数据的长度;hyper 先拿到数据块、只预留 1 字节,它的 #4149 说按整块预留在压测里让每个请求多花约 10% 的 CPU,见 HTTP/2 服务端。

上游方向:Http2Session

连接上游的 h2 客户端会话是 client.rs 里的 Http2Session:一个 h2::client::SendRequest,加一个 ConnectionRef(同一条上游 h2 连接的共享句柄,Drop 时 release_stream 归还一个流的名额)。连接本身由 drive_connection 在单独的任务里 poll;设了 ping 间隔时再起一个任务定时发 PING,PING_TIMEOUT(5 秒)内没有 PONG 就关连接。哪些请求共用一条上游 h2 连接、流用完怎么还,见 HTTP 连接器。

动脑筋答案

===== settings 18303 (pingora defaults)
SETTINGS MAX_CONCURRENT_STREAMS=100, MAX_HEADER_LIST_SIZE=65536
SETTINGS ACK

===== settings 18304 (H2Options::new())
SETTINGS (empty)
SETTINGS ACK
  • 18303:h2_options 是 None,handshake 用 default_h2_options,SETTINGS 里正好是那两个上限。
  • 18304:Some(H2Options::new()) 绕过了 default_h2_options,h2 的 Builder 默认什么都不通告,SETTINGS 帧是空的。客户端于是认为并发流不限;服务端本地对头列表的上限回到 h2 的 16 MiB。
  • 两边的第二帧都是对客户端那个空 SETTINGS 的 ACK,之后没有 WINDOW_UPDATE:两边的窗口都停在 65535。

小结

  • 进 h2 有两条路:TLS 上 ALPN 选中 h2(要 enable_h2),明文端口上 h2c 打开且读到完整的 24 字节前言。0.9.0 起 TLS 流不做前言检测(PR #826)。
  • 前言检测是 read_exact,不足 24 字节的明文请求会一直等,没有超时。
  • 0.9.0 默认通告 100 个并发流、64 KiB 头列表;给 h2_options 传 Some(H2Options::new()) 会把这两个上限一起丢掉,要从 default_h2_options() 改起。
  • 每个流一个 v2 HttpSession,各自 spawn 一个任务走 process_new_http;:path 分隔字节和冲突的 Content-Length 回 RST,authority 问题回 400,每连接累计 32 个就关连接。
  • 写响应时 1xx 全部丢弃,HTTP/1 的连接级头被删;body 按流控容量分段发,结束用空 DATA 帧。

💬 关联讨论

发起讨论
加载中...