Prhub

#39006 [router] Speak cleartext h2c on both edges: serve it inbound, forward it outbound

原始 PR 作者 Kangyan-Zhou 合并时间 2026-09-16 13:30 文件变更 17 提交数 1 评论 0 代码增减 +1009 / -41

执行摘要

Router 双端支持 h2c:入站同端口协商、出站按 worker 选协议转发

PR 描述给出的动机很直接:用 --enable-http2 启动的引擎会让 Granian 跑在 HTTPModes.auto 下,在明文端口上同时提供 h2c 和 HTTP/1.1;而 router 此前两个方向都只说 HTTP/1.1,导致“前置的 mesh sidecar”和“后置的 h2c 引擎”都退化成一条连接一个请求,无法多路复用。作者强调“Nothing here needs configuring: inbound is negotiated per connection, outbound is selected per worker from a value already on the worker”,即动机是把已有的逐 worker 协议解析结果落到线上,而不是引入新的开关。同时它是 1.7k 行原始改动 #38743 拆分栈的第三层(前两层分别是已关闭的 #38989 与已合入的 #39004),目的是让每一层可以单独 review。

值得精读,尤其适合关注路由层网络栈的工程师。推荐关注三个设计决策:其一,客户端对象在构造期分裂并在热路径做纯选择,这是把“per-worker 属性”落到共享代理对象上的通用解法,可直接迁移到其他按 worker 差异化的配置项;其二,admin_client() 把“扇出型流量不属于任何 worker”这一语义显式编码进 API,而不是让调用点随便借一个 worker 的协议;其三,测试用“只讲 HTTP/2 的 mock”与“只讲 HTTP/1.1 的 mock”制造互斥,让错误绑定在线上失败而不是在断言里失败,这套手法比断言“传进去的值是 X”强得多。阅读时建议同时看 #39004(解析规则与 dials_cleartext 的 URL 归一化陷阱)以及 Cargo.toml 中 http2 feature 的存在理由,否则容易误以为 WireProtocol::Http1 在 TLS 上也只讲 HTTP/1.1。

讨论亮点

本次 PR 没有任何 review 评论(review_comments_count = 0、comments_count = 0),只有 sherlockwu 一条空白 body 的 APPROVED。因此“讨论”实际发生在 PR 描述里,作者以自问自答的方式预先回应了几处争议:

“The call sites pass worker.protocol() alongside worker.url, which is what binds the resolved value to the leg it was resolved for.”

“HTTP/1.1 stays the fallback wherever an upgrade is not positively safe, and that fallback is the negotiating client, not a downgrade.”

“Admin fan-out (/flush_cache) always uses the default client, since it addresses every worker at once rather than a selected one — hence Proxy::admin_client rather than reaching for any one worker's protocol.”

关于入站 feature 声明的“看似冗余”也被主动解释:“It looks redundant and is not … The listener therefore already accepts h2c, untested, and would revert to HTTP/1.1-only the day that gRPC dependency left the graph.”并且作者诚实地标注了覆盖边界:“these do not fail if axum's http2 feature is dropped (other dependencies also enable hyper-util/http2) — verified by removing it”,即测试保的是算子可依赖的性质,不是 feature 图的现状。

实现拆解

  1. 出站:把单一客户端拆成两个,逐请求选择(experimental/sgl-router/src/proxy/mod.rs)。Proxy 原先只有一个 pub client: Client,现改为私有的 default_client(协商客户端:明文 HTTP/1.1,TLS 下 ALPN 通告 h2, http/1.1)与 h2c_client(HTTP/2 prior knowledge,仅用于 /server_info 报 --enable-http2 的明文 worker);新增自由函数 build_client(protocol) 统一连接池(pool_max_idle_per_host(64))与 5 s 建连超时,只在 Http1/H2c 分支上分叉。forward_json_to、forward_streaming_to 各新增 protocol: WireProtocol 形参,把原来的 self.client.post(...) 换成 self.client_for(protocol).post(...)。作者在 Proxy::new 的文档注释里写明了“为什么两个客户端都在构造期建好”:协议是 per-worker 属性,热路径只应做一次选择,既避免逐请求构造客户端,也避免“共享客户端的第一个写入者替整个机群决定协议”。client_for 与新增的 admin_client() 是仅有的两个出口,后者固定返回 default_client。

  2. 调用点:把协议绑定到它被解析出来的那条腿(server/routes/chat.rs、server/routes/cache.rs)。chat_completions 的 PD 分支有 5 处转发调用,全部补上协议实参:prefill 腿因为 worker 的所有权会被移进 tokio::spawn,必须先在 spawn 之前捕获 let prefill_protocol = worker.protocol();;decode 腿与普通非 PD 分支则现场读 decode_worker.protocol() / worker.protocol()(流式与非流式各两处)。cache.rs 的 flush_cache 是跨 worker 扇出,语义上不属于任何单个 worker,因此从 ctx.proxy.client 改为 ctx.proxy.admin_client()。

  3. 入站:声明 feature 把已经从传递依赖里得到的 h2 能力钉住(experimental/sgl-router/Cargo.toml、experimental/sgl-router/src/main.rs)。axum::serve 跑在 hyper-util 的 auto builder 上,其 HTTP/2 分支由 hyper-util/http2 编译,而这条路径此前只通过 sgl-kv-indexer -> tonic -> hyper-util/server-auto 传递获得——也就是说监听端“已经能接受 h2c,只是没人测,且随时可能因为某个 gRPC 依赖离开依赖图而悄悄退回 HTTP/1.1-only”。因此显式声明 axum 的 http2 feature(同时启用 HTTP/2 extended CONNECT,为 h2 websocket 铺路),并让 reqwest 打开 http2(http2_prior_knowledge() 本身是 #[cfg(feature = "http2")])。main.rs 侧 +4 行配合入站服务路径(本次未提供该文件 patch 细节,具体行文不确定)。

  4. 文档与语义澄清(src/workers/worker.rs、src/workers/manager.rs、README.md)。WireProtocol::Http1 的文档从“明文与 TLS 一律 HTTP/1.1、不参与 ALPN 协商”改写为“明文 HTTP/1.1、TLS 下参与 ALPN 协商”,并明确警告“丢掉 http2 feature 会把这个变体静默降级为到处 HTTP/1.1”;manager.rs 的 resolve_protocol 文档与 log_protocol_resolution 的 (Some(true), Some(false))、(None, _) 两条日志文案同步改写,避免出现与实现不符的运维日志。

  5. 测试与 CI 配套(4 个新增/修改的测试文件 + 1 个测试镜像)。tests/proxy/h2c_forward.rs 用 hyper 的 http2::Builder 起一个只讲 HTTP/2 的服务端,分别验证 h2c 客户端可达、HTTP/1.1 客户端必然失败、以及 forward_streaming_to 在 multi-frame SSE 下不截断且 on_first_byte 触发;tests/proxy/inbound_h2c.rs 走真实 socket + axum::serve(协商发生在 tower service 之下,oneshot 够不到),断言同一明文端口既接受 h2c prior-knowledge 又保留 HTTP/1.1;tests/proxy/pd_protocol_binding.rs 让 prefill mock 只讲 HTTP/2、decode mock 只讲 HTTP/1.1,用线上行为反证每条腿用的是自己 worker 的协议;k8s e2e 的 test_h2c_forwarding.py 往三期 uvicorn 机群里塞一个 Granian HTTPModes.auto worker,通过 fake worker 回显的 x_http_version 断言“2”和“1.1”同时出现;fake_worker.py 因此新增 FAKE_WORKER_HTTP2 开关(对 "False" 这类 Python 风格写法做了归一)、enable_http2 上报与 x_http_version 字段,Dockerfile.fake_worker 相应调整。tests/proxy/main.rs 注册新测试模块,chat_routing.rs 少量适配。

文件 模块 状态 重要度
experimental/sgl-router/src/proxy/mod.rs 转发代理 modified 8.77
experimental/sgl-router/tests/proxy/pd_protocol_binding.rs 协议绑定 added 8.26
experimental/sgl-router/src/server/routes/chat.rs 聊天路由 modified 5.77
experimental/sgl-router/tests/proxy/h2c_forward.rs 转发测试 added 8.03
experimental/sgl-router/tests/proxy/inbound_h2c.rs 入站测试 added 7.71
experimental/sgl-router/tests/e2e/k8s_integration/test_h2c_forwarding.py 端到端测试 added 7.75
experimental/sgl-router/tests/e2e/k8s_integration/fake_worker.py 测试模拟端 modified 5.63
experimental/sgl-router/src/workers/manager.rs 协议解析 modified 5.82
experimental/sgl-router/src/workers/worker.rs 协议定义 modified 5.78
experimental/sgl-router/Cargo.toml 依赖配置 modified 5.0
experimental/sgl-router/src/server/routes/cache.rs 缓存管理 modified 5.28
experimental/sgl-router/README.md 使用文档 modified 3.45

关键符号

build_client Proxy::new client_for admin_client forward_json_to forward_streaming_to client_for_maps_each_protocol_to_its_own_client resolve_protocol log_protocol_resolution chat_completions flush_cache pd_legs_each_use_their_own_workers_protocol spawn_h2_only_worker spawn_http1_only_worker h2c_client_reaches_http2_only_worker http1_client_cannot_reach_http2_only_worker h2c_client_streams_sse_from_http2_only_worker inbound_accepts_h2c_prior_knowledge inbound_still_accepts_http1 test_router_forwards_over_h2c_to_an_http2_worker _observed_protocols

关键源码片段

experimental/sgl-router/src/server/routes/chat.rs core-logic

协议真正落到线路上的地方:`chat_completions` 的 5 处转发调用全部补上协议实参,其中 PD 分支的 `prefill_protocol` 必须在 `tokio::spawn` 之前捕获,是“解析值 → 使用点”绑定最易出错的接缝。

// experimental/sgl-router/src/server/routes/chat.rs —— 把协议绑到它被解析出来的那条腿
​
// PD 分支:worker 的所有权会被移进 spawn 出去的任务,所以协议必须在 spawn
// 之前就取下来(`prefill_protocol`),spawn 之后就再也拿不到 worker 了。
let prefill_url = worker.url.clone();
let prefill_protocol = worker.protocol();
let prefill_breaker = Arc::clone(&worker.breaker);
let prefill_headers = headers.clone();
let prefill_body = outgoing_body.clone();
let prefill_proxy = Arc::clone(&ctx.proxy);
​
tokio::spawn(async move {
    match prefill_proxy
        .forward_json_to(
            &prefill_url,
            prefill_protocol, // 绑定 prefill worker 自己解析出的协议
            &prefill_breaker,
            "/v1/chat/completions",
            &prefill_headers,
            prefill_body,
        )
        .await
    {
        // ... prefill 结果处理保持不变 ...
    }
});
​
// decode 腿:worker 仍在作用域内,直接现取 `decode_worker.protocol()`;
// 流式与非流式两条路径各一处,保证与 decode worker 注册时解析的协议一致。
let fetch = ctx.proxy.forward_streaming_to(
    &decode_worker.url,
    decode_worker.protocol(),
    &decode_worker.breaker,
    "/v1/chat/completions",
    &headers,
    /* stream_guards, on_first_byte, on_stream_end ... */
);
​
let fetch = ctx.proxy.forward_json_to(
    &decode_worker.url,
    decode_worker.protocol(),
    &decode_worker.breaker,
    "/v1/chat/completions",
    &headers,
    body,
);
​
// 非 PD 的普通分支同理:`worker.protocol()` 随 `&worker.url` 一起传入,
// 流式与非流式各一处,避免任何“借用别的 worker 协议”的可能。

评论区精华

为什么把客户端做两个并在构造期建好,而不是逐请求构造或共用一个 设计

PR 描述中作者说明:“protocol is a per-worker property resolved from each engine's `/server_info`, so the request path must be able to pick either one per request. Building them here reduces that to a selection — no per-request client construction, and no single shared client whose first writer decides the protocol for the whole fleet.”

结论:采纳双客户端 + `client_for` 纯选择的方案;`client_for_maps_each_protocol_to_its_own_client` 只锁映射关系,真正的线路差异交给 `h2c_forward.rs` 验证。 · 已定案(无 review 评论,结论来自 PR 描述与代码)

`/flush_cache` 这类扇出流量该用哪个客户端 设计

作者自述:“Admin fan-out (`/flush_cache`) always uses the default client, since it addresses every worker at once rather than a selected one — hence `Proxy::admin_client` rather than reaching for any one worker's protocol.”

结论:新增 `admin_client()` 显式表达语义,`cache.rs` 的 `flush_cache` 改为调用它,避免误用被选中 worker 的协议。 · 已定案

axum 的 `http2` feature 是否冗余、以及入站测试能保证什么 测试

作者承认该 feature 通过 `sgl-kv-indexer -> tonic -> hyper-util/server-auto` 已经间接生效,并坦言“these do *not* fail if axum's `http2` feature is dropped (other dependencies also enable `hyper-util/http2`) — verified by removing it.”

结论:仍然显式声明该 feature,把能力钉在 axum 上(并顺带启用 HTTP/2 extended CONNECT);测试的定位是保“算子可依赖的性质”,而非锁定 feature 图现状。 · 作者已自行披露覆盖边界,未见反对意见

TLS + `--enable-http2` worker 现在会经 ALPN 协商到 HTTP/2 正确性

`manager.rs` 与 `worker.rs` 的原始注释都说该场景“stays on HTTP/1.1”,本 PR 改写为“using the negotiating client, which reaches HTTP/2 over TLS via ALPN”,并在 `WireProtocol::Http1` 文档中加入“Dropping that feature silently reduces this variant to HTTP/1.1 everywhere.”

结论:行为变化是有意为之,属于恢复 ALPN 协商语义;但这是线路级行为变更,只在文档与日志层面被说明。 · 已随代码定案,缺少独立验证记录

缺少 `/server_info` 的 worker 终生停在 HTTP/1.1 设计

该取舍在 #39004 中已被明确接受:“It still resolves a model name and becomes routable, and `reconcile_unresolved_workers` keys on empty `model_ids`, so it is never revisited — that worker forwards over HTTP/1.1 for its lifetime. A throughput cost, never a correctness one.”

结论:本 PR 未改变该失败模式,也未引入补偿;作者认为把 worker 扣留到两个 introspect 都返回是独立设计,应另开 PR。 · 已知并接受,未解决

风险与影响

1) 协议选择错误 = 整条腿硬失败:h2c_client 用 prior knowledge,不做协商,对不提供 h2c 的端点会直接失败。缓解来自上游契约——只在 enable_http2 == Some(true) && dials_cleartext == Some(true) 时才选 H2c,其余一律走协商客户端。
2) Cargo feature 静默降级:作者在 worker.rs 文档中明确写“Dropping that feature silently reduces this variant to HTTP/1.1 everywhere”。若后续有人因依赖瘦身移除 reqwest 的 http2 或 axum 的 http2,cargo build 可能仍然通过(http2_prior_knowledge 会编译失败,但 axum 侧不会),只有 inbound_h2c.rs / h2c_forward.rs 会报警——而这些测试本身也承认在 feature 图被其他依赖补上的情况下未必失败。
3) PD 路径的协议绑定很脆弱但测试只有一处:chat_completions PD 分支是 prefill_protocol(spawn 前捕获)+ 两处 live decode_worker.protocol(),传错任何一个都能编译通过;pd_protocol_binding.rs 是唯一把“解析值”和“实际使用”绑在一起验证的测试,且 prefill 腿依赖 3 s 轮询的 spawn-and-forget 断言,属于弱同步。
4) TLS 上的行为变化:打开 http2 feature 后,WireProtocol::Http1 在 https:// worker 上会通过 ALPN 协商到 HTTP/2(此前恒为 HTTP/1.1)。这是作者有意恢复的语义(“restores the ALPN behaviour”),但对既有 TLS worker 而言是线路级行为变更,需要真实环境验证。
5) 无 review 记录:单 commit、无评论、作者即 merged_by,resolve_protocol 的失败模式(/server_info 不可达的 worker 终生 HTTP/1.1,reconcile_unresolved_workers 只按空 model_ids 重试、永不回头)在 #39004 中已被承认,但本 PR 没有引入任何补偿。
6) e2e 测试的环境耦合:test_h2c_forwarding.py 依赖 kind 集群、--cb-threshold 1 的 router manifest 与 fake-worker 镜像重建,收敛窗口 90 s;它也是唯一覆盖“发现→introspect→解析→h2c 转发”整链的测试,环境抖动会降低其信号价值。

用户侧:对已用 --enable-http2 启动引擎的部署,router 到引擎的转发从“一连接一请求”升级为 HTTP/2 多路复用,连接数与握手开销下降;同时 router 自身的明文端口现在能接受 mesh sidecar 的 h2c prior-knowledge,mesh 侧的多路复用也能生效。纯 HTTP/1.1 部署无功能变化,inbound_still_accepts_http1 与 http1_client_cannot_reach_http2_only_worker 两侧都做了兜底断言。系统侧:Proxy 的公共字段 client 变为私有并分裂为两个客户端,forward_json_to / forward_streaming_to 签名新增必填参数,属于 router crate 内部的 API breaking(对 crate 外的调用者无影响,仓库内调用点已在同一 PR 内全部更新)。团队侧:新增三层测试(进程内转发、入站监听、k8s e2e)与一套“用只讲单一协议的 mock 让错误配置在线上失败”的测试范式,后续任何协议相关改动都可复用;README 与 WireProtocol 文档同步更新,降低了运维误读风险。

协议错配即整腿硬失败 Cargo feature 缺失会静默降级为 HTTP/1.1 TLS 上 ALPN 行为变更 PD 协议绑定仅一处测试覆盖 prefill 腿断言依赖 3 s 轮询 无 Review 评论记录 e2e 测试依赖 k8s 集群与镜像重建

关联 Issue

#39004 [router] Resolve a wire protocol per worker at registration

完整报告

参与讨论