Appearance
第23章 生产环境调优:超时矩阵、背压、优雅关闭
23.0 本章导读
前面二十二章,我们一层一层把 hyper 与 tower 拆到了底——Service trait(第 2 章)、Layer/Builder(第 3 章)、poll_ready 背压协议(第 4 章)、http1/h2 wire(第 11、15 章)、流控与 PING(第 16、17 章)、upgrade(第 18 章)、pool(第 20 章)。每一章都有专属的调优旋钮,但它们从来不是孤立起作用的——把它们拼到一个真实的、跑在 K8s 上、每秒扛几万 QPS 的服务里,会出现一种奇怪的现象:每个旋钮配置都"看起来合理",但整体行为完全不合理。
这就是这一章要解决的问题——把前面章节的离散旋钮组装成一个一致的生产配置,并回答三个工程师在凌晨 3 点被 pager 叫起来后会问的问题:
- 我的超时是不是漏了一层?——TCP、HTTP、请求、Body 四层时钟如何叠加,一张表一眼看全。
- 我的背压是不是真的传导到了客户端?——用户业务慢了,信号有没有传到 h2 window、TCP buffer、最终让客户端知道。
- 我的滚动升级为什么总报 500?——SIGTERM 到 pod 死亡的 30 秒里,hyper 在做什么,K8s 的 terminationGracePeriodSeconds 是怎么配才正确。
我们会用 hyper 1.9.0、hyper-util 0.1.x、tower 0.5.x 的真实源码作锚,讲到 /tmp/book-refs/hyper/src/server/conn/http1.rs:344(header_read_timeout)、.../http2.rs:226-237(keep_alive_interval/timeout)、/tmp/book-refs/tower/tower/src/timeout/mod.rs:64-69(请求级 sleep)、.../load_shed/mod.rs:43-64(LoadShed 的 poll_ready 骗术)、.../limit/concurrency/service.rs:27-32(Semaphore)、/tmp/book-refs/hyper-util/src/server/conn/auto/mod.rs:467(auto 的 graceful_shutdown)这几处关键实现。
读完这一章,你手里会多一份可以直接复制到生产代码里的 Builder 配置,和一份出事时知道怎么问诊的排障心智图。下一章我们会收尾到本书最后一个话题——设计哲学:为什么 hyper 选择做成一堆可组合的小库,而不是一个"开箱即用"的大框架。
23.1 完整的超时矩阵
生产环境最常见的事故形态,是"某一层没有超时"——一个慢客户端把一条连接留在半握手状态,一个慢业务把一个请求挂在 handler 里,一个慢网络把一个 body 读到一半阻塞。每一种都需要特定的一层去盯它。hyper 的设计是"每层只管自己的事",这带来了极大的灵活性,也带来了一个陷阱——没有任何一个"总开关"能关掉所有慢行为。
这一节把四层时钟画成一张表。
23.1.1 四层时钟的物理解释
四层的独立性是关键——任何一层的 kill switch 触发都会让请求结束,但它们互相不感知。举个例子:tower::timeout 的 60 秒计时到了,中间件直接返回 Err(Elapsed),但 HTTP/2 的 keep-alive PING 仍然每 10 秒照发一次;TCP 层的 keepalive 仍然记录"连接健康"。一个请求被砍了,不等于连接被关了。这个区分是第 17 章反复强调过的。
23.1.2 四层详表
| 层级 | 旋钮 | 配置点 | 默认值 | 触发时的行为 |
|---|---|---|---|---|
| TCP | SO_KEEPALIVE | socket2::SockRef::set_keepalive_* | off(Linux 默认 2 小时) | 发 TCP keepalive 探针,9 次无响应 RST |
| TCP | TCP_USER_TIMEOUT | socket2::SockRef::set_user_timeout | off(内核 tcp_retries2 ≈ 15 分钟) | 写入数据长时间未 ACK 时 RST |
| HTTP/1 | header_read_timeout | http1::Builder::header_read_timeout | 30 秒(http1.rs:242) | 关连接 + 日志 |
| HTTP/2 | keep_alive_interval | http2::Builder::keep_alive_interval | None(默认关) | 周期发 PING |
| HTTP/2 | keep_alive_timeout | http2::Builder::keep_alive_timeout | 20 秒(http2.rs:237) | PING 未 ACK 时关连接 |
| HTTP/2 | max_concurrent_streams | http2::Builder::max_concurrent_streams | 200 | REFUSED_STREAM |
| HTTP/2 | max_pending_accept_reset_streams | 同名 | 20(http2.rs:129) | GOAWAY + ENHANCE_YOUR_CALM |
| 请求 | tower::timeout::Timeout | ServiceBuilder::timeout(Duration) | 无默认 | call future 超时 → Elapsed |
| 请求 | ConcurrencyLimit | ServiceBuilder::concurrency_limit(N) | 无默认 | poll_ready 等 Semaphore permit |
| 请求 | RateLimit | ServiceBuilder::rate_limit(n, per) | 无默认 | poll_ready 等令牌 |
| 请求 | LoadShed | ServiceBuilder::load_shed() | 无默认 | poll_ready 假装 ready,call 里 Overloaded |
| Body | http_body_util::Limited | Limited::new(body, max) | 无默认 | 超字节 → LengthLimitError |
| Body | 业务 tokio::time::timeout | 手写 | 无默认 | 自定义错误 |
这张表里有两处特别容易漏看的默认值:
第一处是 HTTP/2 的 keep_alive_interval 默认是 None——不发 PING。源码在 http2.rs:226:
rust
// /tmp/book-refs/hyper/src/server/conn/http2.rs:226-229
pub fn keep_alive_interval(&mut self, interval: impl Into<Option<Duration>>) -> &mut Self {
self.h2_builder.keep_alive_interval = interval.into();
self
}对比 HTTP/1——HTTP/1 有 30 秒的 header_read_timeout 默认值保护。HTTP/2 的这个"默认不发 PING"意味着:假如一个 HTTP/2 客户端连上,发了 SETTINGS 之后什么都不做,hyper 服务端会静静地留着这条连接直到天荒地老。第 14 章讲过的那个 NAT 撕连接事故,在 HTTP/2 上会比 HTTP/1 更严重——HTTP/1 上至少每次新请求都有 30 秒窗口去感知"客户端是不是还在",HTTP/2 根本没这个机会。生产 HTTP/2 服务强烈建议显式配 keep_alive_interval(10s) + keep_alive_timeout(3s)。
第二处是 max_concurrent_streams 默认 200。源码 http2.rs:215:
rust
// /tmp/book-refs/hyper/src/server/conn/http2.rs:206-217
/// Sets the [`SETTINGS_MAX_CONCURRENT_STREAMS`][spec] option for HTTP2
/// connections.
///
/// Default is 200, but not part of the stability of hyper. It could change
/// in a future release. You are encouraged to set your own limit.
pub fn max_concurrent_streams(&mut self, max: impl Into<Option<u32>>) -> &mut Self {
self.h2_builder.max_concurrent_streams = max.into();
self
}注释里用了 "you are encouraged to set your own limit" ——意思是"不要依赖 200 这个默认,自己显式配一个"。200 是一个相当激进的值——意味着一条 HTTP/2 连接可以同时挂 200 个并发请求。如果你的服务平均单请求耗 100ms、串行处理,200 个流并发 = 每连接 2000 QPS 的突发上限;每条 stream 有自己的 flow-control window(第 16 章),200 个 stream 的内存占用相当可观。对于常规 API 服务,显式降到 64 或 32 更合理。
23.1.3 TCP 层两个要配的 option
hyper 本身不碰 TCP socket option——它只要一个实现了 Read + Write 的 IO。这意味着 SO_KEEPALIVE、TCP_NODELAY、TCP_USER_TIMEOUT 得自己在 accept 之后配。生产里最常见的组合:
rust
use socket2::{SockRef, TcpKeepalive};
use std::time::Duration;
let (stream, peer) = listener.accept().await?;
let sock_ref = SockRef::from(&stream);
sock_ref.set_nodelay(true)?;
sock_ref.set_tcp_keepalive(
&TcpKeepalive::new()
.with_time(Duration::from_secs(30))
.with_interval(Duration::from_secs(10))
.with_retries(3),
)?;
// Linux only: 关心"写出去的数据未 ACK 超过多久就 RST"
#[cfg(target_os = "linux")]
sock_ref.set_tcp_user_timeout(Some(Duration::from_secs(30)))?;TCP_USER_TIMEOUT 是 Linux 特有的,它覆盖 tcp_retries2 内核参数——默认内核要重传 15 次大约 15 分钟才 RST,对于前台 HTTP 服务这个太长了。30 秒的 user timeout 让"网络真的断了但 TCP 没感知到"场景下能快速释放资源。
这两个 socket option 和 hyper 的 header_read_timeout/keep_alive_interval 完全正交——前者盯"TCP 层存活",后者盯"HTTP 层进度"。一个典型生产故障是只配了 HTTP 层超时没配 TCP 层——结果"客户端设备电源拔了"这种硬断场景下,hyper 看到的是"连接健康,只是没数据来",既不会触发 header_read_timeout(header 已收完了),也不会触发 keep_alive_timeout(HTTP/1 没 keep-alive PING)——连接在 ESTABLISHED 状态静静躺到下次 HTTP/1 请求触发。
23.1.4 和卷四 Tokio Time Driver 的衔接
所有 hyper 的超时——header_read_timeout、keep_alive_timeout、tower::timeout::Timeout——最终都落到 tokio::time::sleep,也就是卷四第 11 章 Time Driver 与分层定时器轮 讲过的那个 6 层 × 64 slot 分层时间轮。这带来两个实践上需要注意的点:
一是 hyper 要求你显式提供 Timer。看 http1.rs:402 与 http2.rs:271:
rust
// http1.rs:402-409
pub fn timer<M>(&mut self, timer: M) -> &mut Self
where
M: Timer + Send + Sync + 'static,
{
self.timer = Time::Timer(Arc::new(timer));
self
}文档里写着 "Panics if header_read_timeout is configured without a [Timer]"——忘配 Timer 会在运行时 panic。hyper_util::rt::TokioTimer::new() 就是那个 Timer。hyper_util::server::conn::auto::Builder 里这个 Timer 自动就有了——这是直接用 auto 而非 http1/http2 的一个理由。
二是"1 万个连接每个定时 30 秒"对 Time Driver 不是压力。分层时间轮的插入是 O(1),取下一个到期也是 O(1)——即使几万个定时器同时存在,调度成本和几十个没差别。所以可以大胆配超时,不用担心 timer 开销。真正的开销是 sleep future 本身占的那几十字节内存 × 连接数。
三是 Timer 实现要和 Tokio runtime 匹配。TokioTimer 内部调用的是 tokio::time::sleep_until——它必须在有 Tokio runtime 的 context 里调用。如果你把 hyper server 放到一个自定义 runtime(比如 smol、async-std)里,就不能用 TokioTimer——得实现一份自己的 Timer。这种场景实际上几乎遇不到,但是API 的解耦让这种可能性存在——这也是 hyper 1.x 相比 0.14 最大的设计进步之一。过去 hyper 0.14 硬编码依赖 Tokio runtime,1.x 把 runtime 抽象成 Read+Write、Executor、Timer 三个 trait,让 hyper 在语义上对 runtime 无感。
23.1.5 超时值之间的"包含关系"
真正难的不是每一层的具体数值——而是各层超时的包含关系。一个典型错误:
tower::timeout = 60s
header_read_timeout = 10s
h2 keep_alive_interval = 30s, timeout = 10s (合计 40s)看上去每个都合理。但结合起来——一个健康的 HTTP/2 上传请求用了 45 秒(合理),业务这一侧还在处理……但 keep_alive 总计 40 秒没收到 PING ACK 的话就关了连接——连接关了,tower::timeout 的 60 秒还没到,请求被从底层砍断,客户端看到的是 RST_STREAM。这不是漏洞,是"每层独立计时"的自然结果,但如果你不意识到,debug 时会被"请求耗时明明没到 60 秒"迷惑。
正确的包含关系是:
TCP 层时钟 ≥ HTTP 层时钟 ≥ 请求层时钟 ≥ Body 层时钟
(几分钟) (几十秒-几分钟) (几十秒) (上下文决定)底层的时钟要比上层更宽——不然上层还没说完话,底层就把地板抽了。反过来也有一个例外——header_read_timeout 必须远小于请求层 timeout——因为 Slowloris 这种攻击是"我故意不发完 header",如果 header_read_timeout 配到 tower::timeout 一样大(60s),等于没防。一般 header_read_timeout 在 5-15 秒是合理的。
23.2 端到端背压的全链路
"背压"这个词在 Rust 异步生态里被滥用——写的人假装谁都懂,读的人往往只抓到其中一两层。第 4 章讲过单点的 poll_ready——那是 tower 的局部视角。这一节把视角拉到一个请求从客户端到服务端再回来的整条链路,看背压信号是怎么层层传导的。
23.2.1 背压的六层链路
上面这张图核心意思是:只要每一层的 buffer 是有界的,慢消费者就能从最底端反推到最上游。反过来说——只要任意一层的 buffer 是无界的,背压就断链。tower 的 Buffer(第 6 章)就是常见的断链点——它内部有一个 MPSC channel,channel bound 够大时,生产者永远 poll_ready = Ready,下游真正慢下来的信号反馈不到上游。
23.2.2 各层 buffer 大小的配置
| 层 | 旋钮 | 默认 | 生产常用 |
|---|---|---|---|
| TCP send/recv buffer | 内核 net.core.{r,w}mem_* | 几百 KB | 保持默认,极端场景按 BDP 设 |
| HTTP/2 stream window | initial_stream_window_size | 65535(spec 默认) | 1 MB(文件上传)到 4 MB |
| HTTP/2 conn window | initial_connection_window_size | 65535 | 4 × stream window |
| HTTP/2 max_send_buf_size | 同名 | ~400 KB(http2.rs:244) | 保持默认或 1 MB |
| HTTP/1 max_buf_size | http1::Builder::max_buf_size | ~400 KB(http1.rs:368) | 256 KB 节约 |
| tower Buffer bound | ServiceBuilder::buffer(N) | 无默认 | 不要随意套!(下面讲) |
initial_stream_window_size 的源码在 http2.rs:157:
rust
// /tmp/book-refs/hyper/src/server/conn/http2.rs:157-163
pub fn initial_stream_window_size(&mut self, sz: impl Into<Option<u32>>) -> &mut Self {
if let Some(sz) = sz.into() {
self.h2_builder.adaptive_window = false;
self.h2_builder.initial_stream_window_size = sz;
}
self
}注意这里只要设过 initial_stream_window_size 或 initial_connection_window_size,adaptive_window 会被关掉——不能"既设固定值又要自适应"。自适应 window 是第 16 章讲过的 BDP 估算,实时根据 RTT × 带宽调窗口——对于一般的 API 服务,BDP 估算的开销(每次 PING 更新)比窗口开小带来的损失更小;对大文件上传/下载服务,adaptive 的好处会显现。
23.2.3 tower::Buffer 为什么是一个陷阱
/tmp/book-refs/tower/tower/src/buffer/service.rs:19-66 的核心片段:
rust
// tower/src/buffer/service.rs 简化
pub struct Buffer<Req, F> { /* 内部是 mpsc 到 Worker 的 channel */ }
impl Buffer<Req, F> {
pub fn new<S>(service: S, bound: usize) -> Self { /* ... */ }
pub fn pair<S>(service: S, bound: usize) -> (Self, Worker<S, Req>) { /* ... */ }
}Buffer 的目的是把不 Clone 的 Service 变成可以被多 task 共享的——通过把请求丢到 channel、在独立 worker task 里处理。但它有两个副作用:
- 背压会被 buffered——channel 没满之前
poll_ready永远 Ready。下游 100ms 才处理一个请求,上游可以每 10ms 塞一个,bound=100 的 channel 能掩盖 1 秒的真实慢; - 取消传播断链——请求丢进 channel 后,调用方 drop future 并不会让 worker 知道"这个请求不要了"。
生产里看到 .buffer(1024) 这种配置要警惕——作者大概率是因为遇到 !Clone 编译错才套上去,不是有意识地做流控。第 6 章讲过,解决 !Clone 还有 tower::util::BoxService + ConcurrencyLimit 的组合,更可控。
23.2.4 LoadShed 的反常识语义
相比 Buffer 的"软缓冲",LoadShed 走另一个极端——不排队,满了就拒。源码 /tmp/book-refs/tower/tower/src/load_shed/mod.rs:43-64:
rust
// tower/src/load_shed/mod.rs:43-64
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
// We check for readiness here, so that we can know in `call` if
// the inner service is overloaded or not.
self.is_ready = match self.inner.poll_ready(cx) {
Poll::Ready(Err(e)) => return Poll::Ready(Err(e.into())),
r => r.is_ready(),
};
// But we always report Ready, so that layers above don't wait until
// the inner service is ready (the entire point of this layer!)
Poll::Ready(Ok(()))
}
fn call(&mut self, req: Req) -> Self::Future {
if self.is_ready {
self.is_ready = false;
ResponseFuture::called(self.inner.call(req))
} else {
ResponseFuture::overloaded()
}
}LoadShed 是对 tower 背压协议的"有意违背"——它永远 poll_ready = Ready,把"真实的 ready"存到 is_ready 字段,在 call 的时候才检查;没 ready 就直接返回 Overloaded 错误 future。
这是生产服务一个非常重要的模式——当上游已经顶到 concurrency_limit,上游的 poll_ready 会阻塞住,HTTP 层的 accept 循环会把连接留在队列里,客户端看到的是"发了请求没回"这种最难调的状态。套一层 LoadShed,让被 shed 的请求立刻得到 503——客户端立刻知道要重试别的节点,整体 P99 反而下降。
注意 poll_ready 里的 self.is_ready = ... 有副作用——LoadShed 只能配合那种"poll_ready 是幂等探测"的内层 Service,对于 ConcurrencyLimit 这种"poll_ready 真的占用 permit"的 Service 要特别小心——第 6 章讲过的 "poll_ready 副作用陷阱"。生产栈的推荐顺序是 LoadShed -> ConcurrencyLimit -> 业务 Service,而不是反过来。
23.2.5 端到端示例:慢客户端拖慢上游
设想一个典型场景:
Client (慢下载) -> hyper server -> upstream grpc (快)客户端下载慢 → hyper server 的 h2::SendStream::send_data 一直 Pending → 业务 Handler 的 Response Body poll_frame 被 pending → 业务 handler 自己保持活跃(占 1 个 task)→ 但它同时占着一个 upstream grpc 的 response stream(从 reqwest/tonic client 角度,这个流还没被 drain)→ upstream 连接的 stream/conn window 不能向上游发 UPDATE → 上游的 write 开始阻塞 → 上游业务也变慢。
这就是"一个慢客户端拖垮整条链路"的经典故事——背压端到端传导,所有共享上游的请求都遭殃。防护方案有两种:
- 给每个请求套
tower::timeout——30 秒写不出就砍了; - 给下游 pool 套
ConcurrencyLimit——不让单个慢请求独占 upstream pool 的太多配额。
这两种是互补不互斥的——timeout 限制"单个请求占用时长的上限",concurrency_limit 限制"同时占用 upstream 的总数"。
23.2.6 背压传导和"无锁并发"的微妙互动
卷四第 19 章 性能调优 讲过 Tokio 的 coop budget——每个 task 执行 128 次让步后会被强制 yield。这个机制和背压的互动很微妙:
假设一条 HTTP/2 连接同时跑 100 个 stream。每个 stream 的 body poll_frame 都返回 Pending——因为 TCP 对端慢。Connection poll 的主循环会依次 poll 每个 stream 的 future,每次 poll 消耗 1 个 coop 点。128 点耗完——整个 connection task 被强制 yield。
这里的反常识结论:背压传导的"效率"反而受 Tokio 调度的限制。一条连接挂 100 个慢 stream 不会让 CPU 耗尽——调度器会自动 yield 出去,让其他 task 有机会跑。这是 Tokio 的自我保护。
但这个机制也有代价——假设你的服务大部分 stream 是"快进快出"(response 很小 ,几微秒内完成),少数是"挂住慢"(等上游响应)——如果这两种混在一条连接上,coop budget 每次用完在慢 stream 上,快 stream 都没机会被及时 poll。这对 P99 的影响就会放大。
这种场景下的优化是——分 connection 分流——让慢业务走独立的连接/端口,不要和快业务挤在一条 HTTP/2 多路复用的连接上。或者客户端侧用 reqwest 的 pool.max-per-host 控制并发。
23.3 优雅关闭的完整流程
"SIGTERM 到进程退出的这 30 秒,hyper 在做什么?"——这是生产服务作者必须能清晰回答的问题。这一节把整条"关闭链路"拆开讲。
23.3.1 官方 API:graceful_shutdown
三个 Builder 都有 graceful_shutdown——HTTP/1、HTTP/2、以及 hyper-util 的 auto。HTTP/1 在 http1.rs:137 和 http1.rs:518(UpgradeableConnection):
rust
// /tmp/book-refs/hyper/src/server/conn/http1.rs:127-139
/// Start a graceful shutdown process for this connection.
///
/// This `Connection` should continue to be polled until shutdown
/// can finish.
///
/// # Note
///
/// This should only be called while the `Connection` future is still
/// pending. If called after `Connection::poll` has resolved, this does
/// nothing.
pub fn graceful_shutdown(mut self: Pin<&mut Self>) {
self.conn.disable_keep_alive();
}HTTP/1 的优雅关闭就一句话——disable_keep_alive()——意思是"当前请求处理完了之后,不再接新请求,直接关连接"。如果连接上还没有 in-flight 请求,下一次请求一来就带 Connection: close 响应头然后关。
HTTP/2 在 http2.rs:78:
rust
// /tmp/book-refs/hyper/src/server/conn/http2.rs:68-81
/// Start a graceful shutdown process for this connection.
///
/// This `Connection` should continue to be polled until shutdown
/// can finish.
pub fn graceful_shutdown(mut self: Pin<&mut Self>) {
self.conn.graceful_shutdown();
}HTTP/2 的内层是第 17 章详细讲过的 two-stage GOAWAY——先发 GOAWAY(last_stream_id=2^31-1),等一个 PING-ACK,再发真正的 GOAWAY(last_stream_id=真实处理过的最大)。这是为了给客户端正在发送的 stream 一个"你这个 stream 会不会被处理"的明确答案——避免 race。
hyper_util::server::conn::auto::Builder 则是包装二者的抽象。/tmp/book-refs/hyper-util/src/server/conn/auto/mod.rs:467-476:
rust
// hyper-util/src/server/conn/auto/mod.rs:467-476 简化
pub fn graceful_shutdown(self: Pin<&mut Self>) {
match self.project().state.project() {
// ...
ConnStateProj::H1 { conn } => conn.graceful_shutdown(),
// ...
ConnStateProj::H2 { conn } => conn.graceful_shutdown(),
}
}——简单 dispatch 到底层协议。生产代码应该拿 auto 的 Connection 句柄去调 graceful_shutdown,不用关心协议。
23.3.2 一个最小但正确的优雅关闭 server 骨架
rust
use std::{pin::Pin, sync::Arc};
use hyper::{body::Incoming, Request, Response};
use hyper_util::{rt::{TokioExecutor, TokioIo, TokioTimer}, server::conn::auto};
use tokio::{net::TcpListener, signal};
use tokio_util::task::TaskTracker;
async fn run(listener: TcpListener, svc: MyService) -> anyhow::Result<()> {
let builder = Arc::new({
let mut b = auto::Builder::new(TokioExecutor::new());
b.http1().timer(TokioTimer::new()).header_read_timeout(Duration::from_secs(10));
b.http2().timer(TokioTimer::new())
.keep_alive_interval(Duration::from_secs(10))
.keep_alive_timeout(Duration::from_secs(3))
.max_concurrent_streams(64);
b
});
let tracker = TaskTracker::new();
let shutdown_tx = Arc::new(tokio::sync::Notify::new());
// 信号监听
let sig_tx = shutdown_tx.clone();
tokio::spawn(async move {
signal::ctrl_c().await.ok();
tracing::info!("SIGTERM received, starting graceful shutdown");
sig_tx.notify_waiters();
});
loop {
tokio::select! {
// 停止 accept 的触发
_ = shutdown_tx.notified() => break,
Ok((stream, peer)) = listener.accept() => {
let io = TokioIo::new(stream);
let svc = svc.clone();
let builder = builder.clone();
let shutdown_tx = shutdown_tx.clone();
tracker.spawn(async move {
let conn = builder.serve_connection_with_upgrades(io, svc);
tokio::pin!(conn);
tokio::select! {
res = conn.as_mut() => { /* 正常结束 */ res }
_ = shutdown_tx.notified() => {
// 告诉这条连接:不再接新请求,但 in-flight 请求继续处理
conn.as_mut().graceful_shutdown();
conn.await // 等 in-flight 完成
}
}
});
}
}
}
// 给所有 live connection 指定的时间完成
let deadline = tokio::time::sleep(Duration::from_secs(25));
tokio::select! {
_ = tracker.wait() => tracing::info!("all connections drained cleanly"),
_ = deadline => tracing::warn!("timeout waiting, forcing shutdown"),
}
tracker.close();
Ok(())
}这段代码里有四个关键细节:
shutdown_tx.notify_waiters()同时触发两件事——accept 循环 break 停止接新连接;每条 live connection 的 select 分支触发graceful_shutdown()。- accept 循环的 select 里,只要收到 shutdown 信号就立刻 break——不再产新连接。这一步对应 K8s 的 preStop 之后要达到的效果。
- 每条连接的 graceful_shutdown 之后继续
conn.await——这是必须的。不 await,in-flight 请求拿不到 poll 机会,就永远完不成。graceful_shutdown 只是"翻个开关",真正的完成要靠 poll 推进。 - 最后
tracker.wait()有 deadline——若干连接因为慢客户端迟迟不退,25 秒后强制走——这和 K8s 的terminationGracePeriodSeconds要匹配(下一节讲)。
23.3.3 为什么不直接 drop Builder 就完事
新手会觉得——"我把 accept 停了,不 serve 新 connection 不就完了?已经 serve 的 connection 会自己跑完。"
这个理解部分正确——已 serve 的 HTTP/1 连接确实会把当前请求处理完,然后因为 keep_alive 默认为 true,它会等下一个请求到来直到 TCP 关闭。HTTP/2 更糟——它本来就希望长时间复用,客户端不会主动关。没有 graceful_shutdown 的 signal,连接会挂在那里直到超时或 TCP 断,你的进程等不到退出。
graceful_shutdown 的本质,是主动告诉对端"我要关了,把手头的请求发完,别发新的"——对 HTTP/1 是下一次响应带 Connection: close,对 HTTP/2 是两阶段 GOAWAY。这个主动性是关键。
23.3.4 Connection: close 语义的细节
HTTP/1 的 graceful_shutdown = disable_keep_alive——下一次响应头里加 Connection: close,客户端看到后就不在这条连接上发新请求,而是开新连接(或者用已有的其他连接)。如果你有流量的 nginx 前置代理,nginx 看到 Connection: close 会立刻把这条连接从上游池里拿掉,不会再往这条连接扔新请求——这是优雅滚动升级的基石。
HTTP/2 的 GOAWAY 更直接——第一阶段 GOAWAY(0x7FFFFFFF) 告诉对端"我打算关但还没关死,你现在发的 stream 可能还会被处理";PING-ACK 往返完成(证明对端已经看到这个 GOAWAY)之后,第二阶段 GOAWAY(最大已处理 stream_id) 给出权威答案——"这个 id 之前的都会处理完,之后的算你重试到别处"。客户端看到 GOAWAY 就开新连接,第二阶段 GOAWAY 之后不再发新 stream。
两种协议都有这个"对端主动重路由"的语义,这是 graceful 成立的前提——客户端必须能识别信号并去别处。如果你的客户端是自己写的、不识别 Connection: close 或 GOAWAY,优雅关闭就退化为暴力关——客户端会看到连接断。
23.3.5 drain 的长尾问题
一个真实难题——有 50 条连接处于 in-flight 状态,49 条 10 秒内完成,第 50 条连着一个"下载 1GB 文件但客户端网速只有 100KB/s"的慢客户端。drain 给了 25 秒——这第 50 条绝对不可能完成。
有三种处理策略:
- 硬 deadline 到了强制断——客户端看到下载中断、报错;K8s 按预期时序退出进程。好处是可预测,代价是明确损失一次用户体验。
- 延长 deadline——把
terminationGracePeriodSeconds配到几分钟。好处是客户端能完整地下完文件,代价是滚动升级慢(需要等所有长连接走完)。 - 单独处理长连接场景——把长连接服务和短连接服务拆成两个 deployment,各自配合适的 drain 时间。这是最干净的做法,但要额外的架构成本。
大多数 API 服务的选择是第一种——25 秒硬 deadline,长尾请求损失。这个选择的前提是客户端有重试机制——客户端看到 504/connection reset 会自动去别的节点重试;整体上用户还是收到正确结果,只是多花了几秒。这其实是在交易"可预测的运维行为 vs 个别请求的无损"——长远看,可预测性更重要。
这一点对 WebSocket 和 SSE(Server-Sent Events)这种本质上长连接的业务是不够用的——第 18 章讲过的 upgrade 链路出来的连接,不走 HTTP 常规 request/response 模型,graceful_shutdown 在这里的语义是"让它自己识别服务端要关了"——但 WebSocket 协议本身没有"服务端礼貌告知"的信号,需要在应用层通过自定义消息实现。
23.4 K8s 部署的关键点
大多数生产 hyper 服务跑在 K8s 上。K8s 的滚动升级机制和 hyper 的 graceful_shutdown 要精确咬合——任何一环错位都会导致滚动升级报 5xx。
23.4.1 K8s 的关闭时序
关键的时序风险在第 2 步和第 3 步之间——"Endpoints 摘除"是通过 K8s controller → kube-proxy iptables/ipvs → 每个 Node 生效,这个链路并不是瞬时的。常见的时间是几百毫秒到 2-3 秒。在这个窗口里,kubelet 已经给 pod 发了 SIGTERM,但 kube-proxy 还没更新,Service IP 可能仍然把新流量路由过来。
如果 hyper 收到 SIGTERM 的反应是"立刻关 listener",那这 2-3 秒内新到的 TCP 连接会被TCP RST——客户端看到 connection refused 或 5xx。
23.4.2 preStop hook 是干什么的
preStop 的真正作用是给 "Endpoints 生效" 争取时间。标准配置:
yaml
# k8s deployment spec
spec:
template:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
lifecycle:
preStop:
exec:
command:
- "sh"
- "-c"
- "sleep 10"这个"sleep 10"不做任何事,只是 block 10 秒不让 kubelet 发 SIGTERM。这 10 秒里:
- kubelet 已经把 pod 从 Endpoints 摘除;
- kube-proxy 已经把规则同步到所有 Node;
- Service IP 不再路由新流量到这个 Pod;
- 进程仍然正常处理请求,健康的很。
10 秒过完 preStop,kubelet 才发 SIGTERM。这时候 Pod 已经彻底"看不见"新流量——graceful_shutdown 只需要处理 in-flight 请求。
23.4.3 terminationGracePeriodSeconds 怎么配
hyper 侧的 drain deadline(23.3.2 的 25 秒)必须 < K8s 的 terminationGracePeriodSeconds,不然 K8s 会先 SIGKILL——hyper 来不及通知客户端就被杀。
推荐公式:
terminationGracePeriodSeconds = preStop 时长 + hyper drain deadline + buffer
= 10s + 25s + 10s = 45sbuffer 是为了给 graceful_shutdown 最后那些"即将完成的响应"一点时间完成 TCP FIN/ACK 挥手。
典型错误:terminationGracePeriodSeconds: 30(默认值)+ 20 秒长请求 + 10 秒 preStop。这组合下,K8s 留给 hyper 的时间只有 30 - 10 = 20 秒,但长请求本身就要 20 秒——任何在 10-30 秒窗口启动的长请求一定被砍,表现为客户端收到 502。
23.4.4 readinessProbe 和 livenessProbe
readinessProbe 的作用是控制 Endpoints 成员资格——探测失败就从 Endpoints 摘除,和新流量的路由关联;livenessProbe 的作用是重启 pod——探测失败 kubelet 直接 SIGKILL + 启动新实例。
关闭阶段的陷阱在 livenessProbe。想象:hyper 正在 graceful_shutdown 中,有 50 个 in-flight 请求在处理——这时候 livenessProbe HTTP GET /healthz 怎么回?回 200 正常,回 503 会让 kubelet 以为服务挂了直接 SIGKILL——暴力关。
生产推荐:
- readinessProbe:关闭期间立刻回 503(在 preStop 里给个文件 flag,让 /readyz handler 读到 flag 就 503)——这是加速 Endpoints 摘除。
- livenessProbe:关闭期间继续回 200——告诉 kubelet "我还活着,在清理,别 kill 我"。直到进程主动 exit。
这两个 probe 的行为在关闭时必须不一样——很多 Rust HTTP 框架模板里把它们写成同一个 /health 接口,上线就出事。
23.4.5 K8s HPA 和 concurrency_limit 的拉扯
HPA(Horizontal Pod Autoscaler)会根据 CPU 或 QPS 扩 pod。假设你配了 ConcurrencyLimit(100)——每个 pod 同时最多 100 个请求。流量突然 2× 上涨:
- 前 10 秒:每个 pod 的 concurrency 打满 100,后续请求在 poll_ready 阻塞——或者被 LoadShed 拒掉;
- HPA 感知 metrics 滞后(15-30 秒);
- HPA 扩出新 pod;
- 新 pod 启动 + readinessProbe 就绪还要 10-20 秒。
这意味着突刺流量下,ConcurrencyLimit 会让客户端"感知"到延迟升高(若不配 LoadShed)或大量 503(若配 LoadShed),持续约 1 分钟。缓解手段:
- readiness gate 不走 /readyz 而是
ConcurrencyLimit::available_permits() > 20才回 ready——让接近满载的 pod 从 Endpoints 主动摘掉(需要自己实现); - HPA 监控 concurrency utilization 而非 CPU——更快感知;
- pre-warmed pool——始终比实际负载多预留 30% 的 pod。
第三条和成本/可用性权衡相关,通常是最后的手段。
23.5 可观测性与 Metrics
"配好了,但看不见"和没配好一样。这一节是关于"在哪里埋点、埋什么、看什么"。
23.5.1 必须暴露的 metrics
| Metric 名 | 类型 | 含义 | 告警线 |
|---|---|---|---|
http_request_duration_seconds | Histogram | 单请求端到端耗时(P50/P95/P99) | P99 > SLA |
http_requests_total{status,method,path} | Counter | QPS 总量 | 异常变化 |
http_active_connections | Gauge | 当前 live 连接数 | > 阈值(FD 限制 / 2) |
http_concurrency_in_flight | Gauge | 当前并发请求数 | 接近 ConcurrencyLimit |
http_header_read_timeout_total | Counter | header 超时计数 | > 0 持续 |
http_h2_keep_alive_ping_rtt_seconds | Histogram | PING RTT | 突升 |
http_h2_reset_stream_total{reason} | Counter | 各种 RST_STREAM 原因 | REFUSED_STREAM 上涨 |
http_load_shed_total | Counter | 被 LoadShed 拒掉的请求 | > 0 |
http_client_pool_utilization | Gauge | 客户端 pool 使用率 | > 0.8 |
前四个是"业务可用性"信号——P99 和 QPS 看客户端感知,active connections 和 concurrency 看资源边界。中间四个是 hyper 协议层的"协议健康"信号——第 17 章讲过的 PING RTT 是最好的"网络感受器"。最后一个 pool_utilization 是客户端侧的——第 20 章讲过的 pool 耗尽事故由它直接预警。
23.5.2 tracing 埋点位置
hyper 和 tower 内部已经打了大量 tracing::trace!——生产环境开 RUST_LOG=info 只看高层日志,排障时临时调到 RUST_LOG=hyper=debug,h2=debug,tower=debug 能看到大部分问题。
你自己的代码至少要打三个 span:
rust
use tracing::{info_span, Instrument};
let span = info_span!(
"http_request",
method = %req.method(),
path = %req.uri().path(),
request_id = %req_id,
);
async move {
let resp = inner_service.call(req).await?;
tracing::info!(status = %resp.status(), "request completed");
Ok(resp)
}.instrument(span).awaittower_http::trace::TraceLayer 已经把这套模板做好了——直接套:
rust
use tower_http::trace::TraceLayer;
let svc = ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.timeout(Duration::from_secs(30))
.service(router);23.5.3 Prometheus 导出
用 metrics + metrics-exporter-prometheus 是 Rust 生态的主流方案:
rust
use metrics_exporter_prometheus::PrometheusBuilder;
let handle = PrometheusBuilder::new()
.install_recorder()?;
// 暴露 /metrics
let app = Router::new()
.route("/metrics", get(|| async move { handle.render() }))
.merge(business_router);业务代码里打点:
rust
metrics::counter!("http_requests_total", "status" => status.as_u16().to_string()).increment(1);
metrics::histogram!("http_request_duration_seconds").record(duration.as_secs_f64());关键约束:label 基数别太大。path 作为 label 很诱人,但如果是 /users/{id} 这种动态 path,百万级 id 会让 Prometheus 存储爆炸——应该用 route pattern(/users/:id)而非具体 path。
23.5.4 log level 建议
- info:请求级事件(请求开始/结束、状态码、耗时);服务生命周期(启动、关闭、reload)。
- warn:预期外但不致命(super 慢请求、LoadShed 拒绝、PING RTT 飙升)。
- error:请求处理失败;协议错误;连接被异常关闭。
- debug:中间件打点(只在排障时开);pool 取/还;stream window 变化。
- trace:hyper/h2 内部(只在特定排障场景开);waker 唤醒链。
不要在 hot path 打 info 级以上的日志——每条 log 至少是一次 string formatting + IO。P99 是这么被吃掉的。
23.5.5 "看得到不等于看得懂"
埋点和 metric 是基础,但真正能用来诊断问题的是在线上真出事那一刻,能快速从症状定位到原因。实现这一点需要把 metric 和 trace 关联起来——比如看到 P99 飙升,能立刻下钻看"这段时间哪些 span 最慢";看到某个 span 慢,能看它在 hyper 的哪一层卡住了。
具体技术是 OpenTelemetry 的 trace_id 贯穿——从 HTTP request header 的 traceparent 进来,落到 tower 的 TraceLayer,向下一路传到上游的 reqwest client,再由上游服务用同一个 trace_id 继续。运维拿到一个慢请求的 trace_id,可以在所有相关服务的日志里 grep 到完整链路。这种"全链路可观测"能力是现代微服务运维的基础。
Rust 生态里 tracing-opentelemetry 把这套做得相当完整——一次配置,hyper server 入口、reqwest client 出口、数据库 driver 的 span、业务 handler 的 span,全部贯通一个 trace_id。配合 Jaeger 或 Tempo 查询,排障效率可以提升一个数量级。
23.6 压力测试方法
"上线前压过了"的承诺,很多时候是假的——因为压测工具选得不对、场景搭得不像。这一节梳理几个靠谱的工具和对应场景。
23.6.1 wrk:HTTP/1 高并发小请求
wrk 是最经典的工具——C 写的、epoll-based、单机能打满千兆网卡。
sh
# 4 个线程、400 个连接、压 30 秒
wrk -t4 -c400 -d30s http://localhost:8080/api/hello
# 带 Lua 脚本自定义 body
wrk -t4 -c100 -d30s -s post.lua http://localhost:8080/api/userswrk 的强项是极限 QPS,弱项是不能精确控速——它会尽可能地发请求直到对端顶不住,很难模拟"稳定 1k QPS"这种现实场景。
23.6.2 wrk2:定速 QPS(正确的 latency 测量)
wrk2 fork 自 wrk,加了恒定速率(constant throughput)模式——这是测 P99 唯一正确的模式。原因是 Coordinated Omission Fallacy——wrk 是"请求 A 慢了之后 B 才发",响应快的系统被低估的延迟就被掩盖了。
sh
wrk2 -t4 -c100 -d60s -R10000 http://localhost:8080/api/hello
# -R 10000 表示恒定 10000 QPS看 wrk2 的 latency 直方图——如果你的服务实际能扛 8000 QPS,你打 10000,直方图 P99 会飙到几秒。这是"容量测试"最直观的方法。
23.6.3 vegeta:脚本化和报表
vegeta Go 写的、JSON/CSV 输出、会画直方图——适合 CI 集成。
sh
echo "GET http://localhost:8080/api/hello" | \
vegeta attack -duration=60s -rate=5000 | \
vegeta report -type=json > report.json23.6.4 ghz:gRPC 专用
hyper 背后的 tonic 做 gRPC 服务——用 ghz:
sh
ghz --insecure -c 50 -n 100000 \
--proto ./api.proto --call package.Service/Method \
-d '{"name":"world"}' \
localhost:5005123.6.5 必测的"异常场景"
除了正常 QPS,生产准备阶段必须压三种异常场景:
- 慢客户端(Slowloris):用
slowhttptest -c 1000 -r 100 -H -u http://yoursvc——1000 个 slow header 连接,100 个/秒的速率。服务必须能在 header_read_timeout(如 10 秒)内把所有半握手连接清掉; - 连接数上限:
wrk -c 100000打 10 万并发连接——服务必须在 FD 耗尽前 LoadShed 或acceptbackpressure; - 大 body:1GB 大 body 上传——测
Limited::new的 cutoff 和 h2 window 流控效果。
这三种都是生产真实事故来源——第 23.7 节会讲一个 Slowloris 真实事故的复盘。
23.6.6 压测环境必须匹配生产
压测一个最常见的错误——压测环境跑 localhost,生产跑跨区网络。localhost 上 RTT < 1ms,所有第 16 章讲过的 HTTP/2 流控窗口几乎不构成瓶颈;真实跨区网络 RTT 几十毫秒,1 MB 的 stream window 很可能不够用,导致 throughput 比压测时低一个数量级。
标准的做法是压测环境至少要和生产在相近的网络拓扑上——同 AZ 内部压力测试 + 跨 AZ 压力测试各跑一份,对比观察。很多团队会发现压测没问题的配置上线后性能差一大截——几乎都是这个 BDP(带宽 × 延迟乘积)不匹配的原因。
另外压测目标服务要和生产环境相同的机器规格。Tokio 的 worker threads 默认等于 CPU 核数——在 2 核机器上跑得好不代表在 32 核跑得好(LIFO slot 的局部性、跨核 cache line 弹跳等问题在核数多时才显现)。这是卷四第 19 章 性能调优 讲过的"高核数场景的特殊陷阱"。
23.7 三个典型事故复盘
理论讲完,挑三个真实事故——也是前面章节引用过的那三个——把它们放到同一个聚光灯下复盘。
23.7.1 事故一:pool 耗尽(来自第 20 章)
现象:凌晨 3 点告警——P99 从 50ms 飙到 8s,log 里全是 hyper::Error(IncompleteMessage) 或 pool is exhausted。
根因:服务 A 调用下游服务 B,用的是 reqwest::Client(内部是 hyper client pool)。某个请求路径在某个分支里忘了 drop body(response 没 collect 就丢了)。hyper client pool 里的 connection 不是"请求结束"归还,而是"body 读完 EOF"归还——body 没读,连接一直被"占用",Pool 里 idle 可用的连接数缓慢归零。持续 6 小时后 pool 完全耗尽。
修复:
rust
// 错的写法
let resp = client.get(url).send().await?;
if resp.status().is_success() {
return Ok(()); // resp drop 时 body 还没读完 —— 连接归还被推迟
}
// ...
// 对的写法
let resp = client.get(url).send().await?;
if resp.status().is_success() {
let _ = resp.bytes().await?; // 显式读完 body,连接归还
return Ok(());
}教训:
- hyper client pool 对"body 未读完的 response 早 drop"这个模式没有任何保护机制。
pool_utilizationmetric 必须监控——不然这类问题只会在耗尽那一刻才爆。- 事后推动了团队内部 lint 规则——禁止 reqwest response 不走 bytes()/json() 直接 drop。
23.7.2 事故二:Slowloris 对 hyper 的影响
现象:某个新上线的 gRPC 服务,白天没问题,晚上 10 点定时报 active_connections 飙到 10 万。FD 耗尽后整个服务拒新连接。
根因:新服务忘了配 header_read_timeout——用了 http1::Builder::new() 默认,虽然默认是 30 秒,但不配 Timer 的话 header_read_timeout 是死的(http1.rs:344 注释里写了 Panics if 没 Timer)——他们用的是底层 http1::Builder 没套 TokioTimer。实际效果是"header_read_timeout 完全不生效"。
每天晚上 10 点定期跑一个扫描机器人,每秒新建 1000 条 TCP 连接,每条只发半条 HTTP header(GET / HT),不发完。每条连接占 1 个 FD + 一点内存 + 一个 Tokio task。几分钟就把 FD 耗尽。
修复:
rust
let mut builder = http1::Builder::new();
builder
.timer(TokioTimer::new()) // <- 这里,原先漏了
.header_read_timeout(Duration::from_secs(10));或者更稳的方案——直接用 hyper_util::server::conn::auto::Builder,它默认帮你把 Timer 配好。
教训:
- hyper 的 API 设计是"你不主动配就没保护"——这带来了灵活性也带来了责任。
- Timer 的 panic 警告在文档里,但很容易漏看——生产配置必须显式套
TokioTimer::new()。 - 加
http_active_connectionsmetric 告警线在 FD 限制的一半。
23.7.3 事故三:K8s rolling update 导致 500
现象:每次发版都有一波 500 错误,持续 20 秒左右,和发版时间完全对齐。
根因:复合的:
- K8s deployment 没配 preStop——SIGTERM 一发下来立刻触发 hyper 停 accept。
- 那一瞬间 kube-proxy 规则还没更新——Service IP 仍往 pod 路由。
- 新的 TCP SYN 到了 pod——TCP 栈接收了(内核 listen backlog 没关),但 hyper 的 accept task 已经 break——backlog 里的连接永远不会被 accept。
- 客户端等 read timeout 后报 502。
修复:
yaml
spec:
template:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 15"]- hyper 代码里 graceful_shutdown 流程按 23.3.2 节模板改造。
教训:
- K8s 默认行为不是"优雅"——优雅需要你主动组装 preStop + graceful_shutdown + tracker。
terminationGracePeriodSeconds: 30默认值对大多数服务是不够的。- 发版期间的 5xx 必须被监控——不然这个问题"感觉发版不稳定"但找不到根因,能拖几个月。
三个事故合起来揭示一件事——hyper 的默认值保护不了所有场景。生产准备清单必须包括"主动检查每一层超时""主动写优雅关闭""主动配 K8s lifecycle"——没有哪一条是"开箱即用"的。这也和卷四第 19 章 性能调优与典型陷阱 的基调一致——Tokio/hyper 给你最大自由,代价是你必须主动负责。
23.7.4 三个事故的共同模式
这三个事故表面是三个不同的根因,但如果抽象一层看——它们都是"静默的默认值"带来的。hyper 的 header_read_timeout 默认有值但需要 Timer 注入才生效;K8s 的 terminationGracePeriodSeconds 有默认但没配 preStop 时行为不对;reqwest pool 的 "body 未读完延迟归还"是一个没写在第一页文档的隐含契约。
这些"默认能跑但生产会翻车"的地方,是工程文化里最难治的——代码能跑、单元测试过、staging 也正常,只有在生产流量/规模下才暴露。防御它的方法只有三条:
- 明确列出每一层的默认值和触发条件——像 23.1.2 那张表一样,把默认值写出来,让人能主动对比。
- 压异常场景,不只压 happy path——Slowloris、连接数爆炸、慢客户端是必须的三项,它们覆盖了上面所有事故。
- 暴露"边界指标"——pool_utilization、active_connections、concurrency_in_flight——这些不是 SLI(业务人不看),而是给运维团队看的"接近极限"信号。没有这些 metric,出事前完全不知道在接近悬崖。
更深一层的教训是:"库的灵活性"和"生产的可靠性"是一对永远需要调和的矛盾。hyper 选择把灵活性极大化——把超时、背压、优雅关闭都交给用户,带来了第 24 章会讨论的"生态繁荣"——但同时也要求用户具备相当的工程素养。如果你的团队没有这个素养,用一个更"规范"的框架(如 Go 的 net/http 或 Java 的 Netty-based 框架)可能更合适。这不是 hyper 做得不好,而是设计取舍。
23.8 一份可复制的生产 Builder 模板
最后给一份可以直接抄到生产项目里的完整配置模板——取 hyper 1.9.0 + hyper-util 0.1.x + tower 0.5.x 的稳定 API。
rust
use std::{sync::Arc, time::Duration};
use anyhow::Result;
use hyper::body::Incoming;
use hyper::{Request, Response};
use hyper_util::{
rt::{TokioExecutor, TokioIo, TokioTimer},
server::conn::auto,
};
use socket2::{SockRef, TcpKeepalive};
use tokio::{net::TcpListener, signal, sync::Notify};
use tokio_util::task::TaskTracker;
use tower::{Service, ServiceBuilder};
use tower_http::trace::TraceLayer;
pub struct ProdServer<S> {
listener: TcpListener,
service: S,
cfg: ServerConfig,
}
#[derive(Clone)]
pub struct ServerConfig {
// HTTP/1
pub h1_header_read_timeout: Duration,
pub h1_max_buf_size: usize,
// HTTP/2
pub h2_keep_alive_interval: Duration,
pub h2_keep_alive_timeout: Duration,
pub h2_max_concurrent_streams: u32,
pub h2_initial_stream_window: u32,
pub h2_initial_conn_window: u32,
pub h2_max_frame_size: u32,
pub h2_max_pending_accept_reset_streams: usize,
// TCP
pub tcp_nodelay: bool,
pub tcp_keepalive_time: Duration,
pub tcp_keepalive_interval: Duration,
pub tcp_keepalive_retries: u32,
#[cfg(target_os = "linux")]
pub tcp_user_timeout: Duration,
// Server
pub graceful_drain_timeout: Duration,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
h1_header_read_timeout: Duration::from_secs(10),
h1_max_buf_size: 256 * 1024,
h2_keep_alive_interval: Duration::from_secs(10),
h2_keep_alive_timeout: Duration::from_secs(3),
h2_max_concurrent_streams: 64,
h2_initial_stream_window: 1 * 1024 * 1024, // 1 MB
h2_initial_conn_window: 4 * 1024 * 1024, // 4 MB
h2_max_frame_size: 16 * 1024, // 16 KB (SPEC 默认)
h2_max_pending_accept_reset_streams: 20,
tcp_nodelay: true,
tcp_keepalive_time: Duration::from_secs(30),
tcp_keepalive_interval: Duration::from_secs(10),
tcp_keepalive_retries: 3,
#[cfg(target_os = "linux")]
tcp_user_timeout: Duration::from_secs(30),
graceful_drain_timeout: Duration::from_secs(25),
}
}
}
impl<S, B> ProdServer<S>
where
S: Service<Request<Incoming>, Response = Response<B>> + Send + Clone + 'static,
S::Future: Send + 'static,
S::Error: Into<Box<dyn std::error::Error + Send + Sync>> + 'static,
B: http_body::Body + Send + 'static,
B::Data: Send,
B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
{
pub async fn serve(self) -> Result<()> {
let cfg = self.cfg;
let mut builder = auto::Builder::new(TokioExecutor::new());
// HTTP/1 配置
builder
.http1()
.timer(TokioTimer::new())
.header_read_timeout(cfg.h1_header_read_timeout)
.max_buf_size(cfg.h1_max_buf_size);
// HTTP/2 配置
builder
.http2()
.timer(TokioTimer::new())
.keep_alive_interval(cfg.h2_keep_alive_interval)
.keep_alive_timeout(cfg.h2_keep_alive_timeout)
.max_concurrent_streams(cfg.h2_max_concurrent_streams)
.initial_stream_window_size(cfg.h2_initial_stream_window)
.initial_connection_window_size(cfg.h2_initial_conn_window)
.max_frame_size(cfg.h2_max_frame_size)
.max_pending_accept_reset_streams(cfg.h2_max_pending_accept_reset_streams);
let builder = Arc::new(builder);
let tracker = TaskTracker::new();
let shutdown = Arc::new(Notify::new());
// SIGTERM 监听
let sig = shutdown.clone();
tokio::spawn(async move {
let _ = signal::ctrl_c().await;
tracing::info!("shutdown signal received");
sig.notify_waiters();
});
loop {
let accept = self.listener.accept();
tokio::pin!(accept);
tokio::select! {
biased;
_ = shutdown.notified() => {
tracing::info!("accept loop stopping");
break;
}
Ok((stream, peer)) = &mut accept => {
// TCP 层配置
let sock_ref = SockRef::from(&stream);
let _ = sock_ref.set_nodelay(cfg.tcp_nodelay);
let _ = sock_ref.set_tcp_keepalive(
&TcpKeepalive::new()
.with_time(cfg.tcp_keepalive_time)
.with_interval(cfg.tcp_keepalive_interval)
.with_retries(cfg.tcp_keepalive_retries),
);
#[cfg(target_os = "linux")]
{
let _ = sock_ref.set_tcp_user_timeout(Some(cfg.tcp_user_timeout));
}
let io = TokioIo::new(stream);
let svc = self.service.clone();
let builder = builder.clone();
let shutdown = shutdown.clone();
tracker.spawn(async move {
let conn = builder.serve_connection_with_upgrades(io, svc);
tokio::pin!(conn);
let res = tokio::select! {
res = conn.as_mut() => res,
_ = shutdown.notified() => {
conn.as_mut().graceful_shutdown();
conn.await
}
};
if let Err(e) = res {
tracing::warn!(peer = %peer, error = %e, "connection ended");
}
});
}
}
}
// Drain
tracker.close();
let deadline = tokio::time::sleep(cfg.graceful_drain_timeout);
tokio::select! {
_ = tracker.wait() => tracing::info!("all connections drained cleanly"),
_ = deadline => tracing::warn!("drain timeout"),
}
Ok(())
}
}
// 业务层的 tower 栈
pub fn make_router() -> impl Service<Request<Incoming>, Response = Response<impl http_body::Body>, Error = Box<dyn std::error::Error + Send + Sync>> + Clone + Send + 'static {
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.load_shed()
.concurrency_limit(1024)
.timeout(Duration::from_secs(30))
.service(your_business_router())
}这份配置里每一个数字都有理由——不是拍脑袋出来的:
h1_header_read_timeout = 10s——防 Slowloris,10 秒足够任何正常客户端发完 header;h2_keep_alive_interval = 10s+timeout = 3s——10 秒对端没回 PING 就 3 秒内关,13 秒内能清掉死连接;h2_max_concurrent_streams = 64——默认 200 太激进,64 对 API 服务够用;h2_initial_stream_window = 1 MB——默认 64 KB 太小,高 BDP 场景会打满;concurrency_limit = 1024——结合 CPU 核数和单请求耗时估出的上限,配合load_shed满了直接 503;timeout = 30s——业务请求级硬上限,比多数实际业务慢很多但够防"handler 里无限循环";graceful_drain_timeout = 25s——配合 K8sterminationGracePeriodSeconds = 45s(preStop 10s + drain 25s + buffer 10s)。
套上 tower 中间件的顺序也不是随意的——从外到内是 Trace → LoadShed → ConcurrencyLimit → Timeout → 业务。
- Trace 在最外面——任何情况都能记录;
- LoadShed 在 ConcurrencyLimit 外面——满了立刻 503,不等;
- Timeout 在业务外面——30 秒 kill 业务;
- 业务拿到的是已经过 LoadShed + ConcurrencyLimit 筛过、有 30 秒时间的请求。
这份模板不是"最终答案"——每个服务都有自己的负载特征,具体数字要基于压测调。但它是一个合格的起点——比 hyper::server::conn::http1::Builder::new() 那种完全裸跑好出一个数量级。
23.8.5 修一条容易误导的默认值:h2 stream window 不是 64 KB
§23.8 模板里写"h2_initial_stream_window = 1 MB——默认 64 KB 太小,高 BDP 场景会打满"——这条不准确,会导致读者照着配反而等同于不配。实测 hyper 1.9.0:
| 角色 | 实际默认 | 源码位置 |
|---|---|---|
| server stream window | 1 MB = 1024 × 1024 | hyper/src/proto/h2/server.rs:37 DEFAULT_STREAM_WINDOW: u32 = 1024 * 1024 |
| server conn window | 1 MB | hyper/src/proto/h2/server.rs:36 DEFAULT_CONN_WINDOW: u32 = 1024 * 1024 |
| client stream window | 2 MB | hyper/src/proto/h2/client.rs:49 DEFAULT_STREAM_WINDOW: u32 = 1024 * 1024 * 2 |
| client conn window | 5 MB | hyper/src/proto/h2/client.rs:48 DEFAULT_CONN_WINDOW: u32 = 1024 * 1024 * 5 |
| HTTP/2 spec 基线 | 64 KB = 65535 | hyper/src/proto/h2/mod.rs:30 SPEC_WINDOW_SIZE: u32 = 65_535 |
"64 KB"那个数字来自 RFC 9113 §6.9.2 的协议基线(SPEC_WINDOW_SIZE),但 hyper 从来不用它当默认——hyper 在 server/client 两侧都早早把 DEFAULT_STREAM_WINDOW 抬到了 1 MB / 2 MB。所以"= 1 MB"这一行在 server 侧配了等于没配;高 BDP 场景如果想要更大的 window,应该配到 4 MB 或 8 MB(同时考虑 adaptive_window 默认 false,见 proto/h2/server.rs:63)。
下一版修订需要把 §23.8 的这条注释改成:
rust
// 默认是 1 MB(DEFAULT_STREAM_WINDOW),HTTP/2 spec 基线 64 KB 只是 RFC 兜底;
// 如果是 BDP × 单 stream 大文件场景,配到 4 MB 或 8 MB;
// 否则可以删掉这一行——默认就够。这一条事实校对体现的不是"hyper 文档不清楚",而是协议规范与实现默认之间的常见 gap——读者看 RFC 看到 65535,看 hyper 公开 API 文档看到"hyper will use a default",两个数字不会对齐——只有 wc -l + grep DEFAULT 直接读源码才能拿到真实数字。这也是本系列反复强调"所有数字都要回到源码验证"的一次活生生的例子。
23.8.6 调优旋钮的源码总账本:14400 行可调代码
把本章 §23.1.2 那张大表背后的所有"旋钮文件"按真实行数列出来——这是你"调一个生产 hyper 服务"时需要心中有数的代码总量:
| 子系统 | 文件 | 行数 | 本章对应小节 |
|---|---|---|---|
| hyper server H1 | hyper/src/server/conn/http1.rs | 559 | §23.1 header_read_timeout(line 344) |
| hyper server H2 | hyper/src/server/conn/http2.rs | 312 | §23.1 keep_alive_*(line 226-237)、max_concurrent_streams(line 209 默认 200) |
| hyper proto H2 server | hyper/src/proto/h2/server.rs | (含全部 frame 编解码) | §23.1.2 表 + §23.8.5 默认值真相 |
| hyper-util auto Builder | hyper-util/src/server/conn/auto/mod.rs | 1376 | §23.7 graceful_shutdown(line 467 附近的 into_owned) |
| hyper-util graceful | hyper-util/src/server/graceful.rs | 488 | §23.7 优雅停机(本章正文未直接引,但 axum 真实落地走它) |
| tower timeout | tower/src/timeout/mod.rs | 70 | §23.4 tower::timeout |
| tower load_shed | tower/src/load_shed/mod.rs | 76 | §23.5 LoadShed(line 43-64 的 poll_ready 永真 + call 里 Overloaded) |
| tower concurrency | tower/src/limit/concurrency/service.rs | 117 | §23.5 ConcurrencyLimit + Semaphore |
| tower 全 crate | tower-0.5.3/src/** | 11904 | §23.4-23.6 中间件矩阵(其中本章直接用到的 263 行是 timeout+load_shed+limit/concurrency) |
合计可见:真正能拨动行为的"旋钮代码" 不到 hyper-util client 子树(9422 行)的两倍——大约 14400 行 Rust 散布在 hyper / hyper-util / tower 三个 crate 里。这意味着你对生产 hyper 服务的每一次调优,本质上都是在这 14400 行里的某十几个 pub fn 上拨开关;理解这个分布以后,"调优"就从"拍脑袋改 Builder::new()"变成"找到对应那行源码、读它的 Default::default()、决定是否覆盖"——这是把 §23.0 那个凌晨 3 点的 pager 答案变成可计算问题的关键认知。
更关键的是:本章 §23.5 用到的三个 tower middleware 只有 263 行 Rust(timeout 70 + load_shed 76 + concurrency 117),却撑起了"生产 HTTP 服务"的负载控制底盘。这又是一次"抽象层薄、生态承担重"的实例——回到卷三 ch02 §2.X.1 给出的 tower-service 390 行 / 85% 文档的对照来看,Rust 生态里"几十行核心 + 几百行配套 + 上万行扩展" 的层次结构在这里再次重复。
23.9 落到你键盘上
- 检查当前服务的四层超时矩阵—— 打开你线上服务的配置,对着 23.1.2 的表过一遍:TCP keepalive 配了吗?
header_read_timeout配了 Timer 吗?HTTP/2 keep_alive_interval 配了吗?tower::timeout 在 service stack 里吗?Limited 套了 body 吗?每一项都回答 "yes" 或 "no,因为...",不能含糊。 - 自己做一次 Slowloris 测试—— 随便找一台 VM 装 slowhttptest,对自己的 dev 环境打:
slowhttptest -c 500 -r 50 -H -u http://your-dev:8080。看http_active_connectionsmetric 是否稳定在 header_read_timeout 内的 connection 数(50 conn/s × 10s 超时 ≈ 500 稳态)。如果飚到几千,说明 header_read_timeout 没真的生效——检查 Timer 是否注入了。 - 手写一次 graceful shutdown 完整流程—— 从 signal 监听、accept 循环的 select、每连接 graceful_shutdown、到 TaskTracker::wait with deadline——不用抄我的模板,自己写一遍,理解每一步"为什么不能少"。写完用
tcpdump抓 SIGTERM 时刻的包,看 HTTP/2 的 GOAWAY 两阶段、HTTP/1 的Connection: close是不是按预期发出了。 - 给你的 K8s deployment 加 preStop——
preStop: ["sleep", "15"]+terminationGracePeriodSeconds: 60+ 发版前后 5 分钟监控 5xx 曲线。对比加之前的差异——大多数服务这里会有一个戏剧性的改善。 - 读
tower_http::trace::TraceLayer—— 第 21-22 章讲过 tower-http,但这一层具体怎么和 tracing 对齐、span 怎么延续、关键字段怎么埋——看源码tower-http/src/trace/mod.rs。它是生产级 tracing 的范本。 - 跑一次 wrk2 capacity test—— 阶梯式增加
-R参数从 1000 一直到服务扛不住,记录每一档的 P99。画出 "QPS-P99" 曲线,找到膝盖点——那就是你的 ConcurrencyLimit 应该配的值。用实测数据而不是拍脑袋定旋钮值。