Appearance
第14章 keep-alive、半关闭与超时矩阵
14.1 连接的"生存线"
前面两章我们把 HTTP/1 的 wire format 和 Dispatcher 状态机读完了。在理想情况下一切都很干净——客户端发请求、服务端回响应、Dispatcher 循环推动一切。
但真实世界没那么干净。真实世界里:
- 客户端发了一半的 header 就挂了——TCP 不会主动告诉你,数据永远不来。
- 客户端发了
Connection: close但随即又发了一个新请求——你应该按协议关连接,但请求里的数据要不要处理? - NAT 中间件 30 分钟没看到流量就静默丢连接——你的 keep-alive 连接看起来正常但下次 write 就
ECONNRESET。 - 客户端慢到每秒只发 1 个字节(slow loris 攻击)——TCP 看来是"正常连接",但永远读不完 header。
- 服务器刚发完 response 想复用连接,结果客户端的 socket 已经在对方那边 close 了。
这些边界在 HTTP 协议规范里有零碎的描述,但把它们转成高效、正确、不让生产环境崩的代码,靠的是工业级 HTTP 库的累积经验。Hyper 通过 http1::Builder 暴露的一组配置项,就是这些经验的结晶:
rust
// hyper/src/server/conn/http1.rs:70-86
pub struct Builder {
h1_parser_config: httparse::ParserConfig,
timer: Time,
h1_half_close: bool,
h1_keep_alive: bool,
h1_title_case_headers: bool,
h1_preserve_header_case: bool,
h1_max_headers: Option<usize>,
h1_header_read_timeout: Dur,
h1_writev: Option<bool>,
max_buf_size: Option<usize>,
pipeline_flush: bool,
date_header: bool,
}这一章我们把每个配置背后的"防什么、怎么防、代价是什么"讲清楚。这些不是"锦上添花"——这是你上线 hyper-based 服务必须理解的生产基线。
14.2 Keep-Alive:协议 + 工程的重叠
14.2.1 协议层:什么时候允许 keep-alive
HTTP/1.1 规定:默认 keep-alive(连接默认复用)。HTTP/1.0 规定:默认每请求一连接(除非 Connection: keep-alive 明确开启)。
判断一条连接是否应该 keep-alive,hyper 在 role.rs 里做了大量工作:
rust
// 逻辑简化版
let mut keep_alive = version == Version::HTTP_11; // HTTP/1.1 默认 true
if let Some(conn_hdr) = headers.get(CONNECTION) {
if conn_hdr.contains("close") {
keep_alive = false;
} else if conn_hdr.contains("keep-alive") {
keep_alive = true; // HTTP/1.0 显式开启
}
}这些决策在 Server / Client 的 parse 函数里做,结果写进 Conn::state::keep_alive: KA——我们在第 12 章讨论过 KA::Busy / Idle / Disabled 三态,以及 &= false 如何永久 disable。
14.2.2 工程层:h1_keep_alive 开关
Builder 上有一个粗暴的开关:
rust
// hyper/src/server/conn/http1.rs:265-268
pub fn keep_alive(&mut self, val: bool) -> &mut Self {
self.h1_keep_alive = val;
self
}默认 true——符合 HTTP/1.1 协议。设为 false 时,Connection::new 会立刻 conn.disable_keep_alive()——整个连接一开始就设为 Disabled,一次请求完就关。
什么时候设为 false?三种场景:
- 短命代理:反向代理把请求转发给 upstream,一次就完。Hyper 接客户端如果是 short-lived 场景,也许关掉 keep-alive 更简单。
- 调试:排查 connection-reuse 相关的 bug 时,关掉 keep-alive 让每次请求走新连接,排查条件更干净。
- 资源受限:每条连接有 struct 开销、有 buffer、还被 tokio 计数——如果你的业务每连接只做一次请求,关 keep-alive 避免无谓的 idle 连接占资源。
一般 99% 的生产服务保持默认 true。
14.2.3 graceful_shutdown:让当前请求跑完再关
rust
// hyper/src/server/conn/http1.rs:138-140
pub fn graceful_shutdown(mut self: Pin<&mut Self>) {
self.conn.disable_keep_alive();
}graceful_shutdown 的实现只有一行——把连接 KA 标成 Disabled。这意味着:
- 当前正在处理的请求继续处理。
- response 写完后,try_keep_alive 发现 KA 是 Disabled——不再 reset 状态机,走
Closed路径。 poll_shutdown被调用,TCP 半关闭(发 FIN)。- Dispatcher 结束。
这是部署场景的基石——滚动发布 / rolling upgrade 里你需要"让 pod 不再接新请求、正在处理的跑完、然后退出"。通过:
- 收到 SIGTERM。
- 把 server 的 accept 关掉(不再接新 TCP 连接)。
- 对所有 live connection 调
graceful_shutdown。 - 等所有 connection future 结束。
- 进程退出。
graceful_shutdown 的实现简单到让人惊讶——一行代码——但它给了上层极强的生命周期控制能力。
14.3 Half-Close:容易忽视的边界
rust
// hyper/src/server/conn/http1.rs:257-261
pub fn half_close(&mut self, val: bool) -> &mut Self {
self.h1_half_close = val;
self
}默认 false。设为 true 时:
Clients can chose to shutdown their write-side while waiting for the server to respond. Setting this to
truewill prevent closing the connection immediately ifreaddetects an EOF in the middle of a request.
14.3.1 什么是 half-close
TCP 是全双工连接——读写两个方向独立。调用 shutdown(SHUT_WR) 可以只关闭写方向,保留读方向。客户端可以:
- 发请求(写 header + body)。
shutdown(SHUT_WR)——写方向 FIN,告诉服务端"我发完了,但我还想读响应"。- 读响应。
- 收到完整响应后关闭 socket。
这是 HTTP/1 的一个合法(但少见)用法。HTTP spec 并没有禁止——但许多 HTTP server 会把 "读到 EOF" 解释成"连接关了,我也关",即使读到的是半关信号。
14.3.2 h1_half_close 的作用
默认情况下 hyper 读到 EOF 就认为连接结束。但如果 h1_half_close = true,hyper 会知道"这只是对端写完了,我还得把响应写完"——继续处理请求并回响应。
这个选项在大多数 Web 场景下没用——浏览器、curl、reqwest 都不做 half-close。但在某些网关/负载均衡软件(HAProxy 的旧版本、某些自研 proxy)会用 half-close 来优化资源回收——那时候后端必须开 half_close 配合。
默认关的合理性:大多数情况下,读到 EOF 就是对端完全断开,继续处理是浪费。选择性开启应付特定基础设施。
14.4 header_read_timeout:抵御 Slowloris
14.4.1 Slowloris 攻击
2009 年披露的经典 DoS 技术——客户端建立 TCP 连接,非常慢地发送请求头(每秒 1 字节、每 10 秒 1 字节)。server 认为这是一个"正在发请求的 client",维持连接和 buffer 直到 request 完整。
攻击者开 1000 个这样的慢连接,很快占光 server 的连接池。Apache 2.x 在这方面曾经惨败(单台攻击 100 个慢连接就能打垮默认配置的 Apache)。Nginx 和 hyper 都有防御。
14.4.2 hyper 的 header_read_timeout
rust
// hyper/src/server/conn/http1.rs:338-347
pub fn header_read_timeout(&mut self, read_timeout: impl Into<Option<Duration>>) -> &mut Self {
self.h1_header_read_timeout = Dur::Configured(read_timeout.into());
self
}默认 30 秒(Duration::from_secs(30))。语义:客户端必须在 30 秒内发完整个请求头,否则连接被强制关闭。
注意这个超时只针对 header——一旦 header 读完,这个 timer 被重置。body 的读取由业务逻辑自己设超时(通过 Timeout 中间件)。
这个区分有道理——header 长度有上限(一般几 KB),正常客户端不可能花 30 秒发不完;body 可能是大文件上传,用户侧真的可能几分钟。把 header 和 body 的 timeout 分开让防御更精确。
14.4.3 需要 Timer
rust
/// Requires a [`Timer`] set by [`Builder::timer`] to take effect. Panics if `header_read_timeout`
/// is configured without a [`Timer`].hyper 1.x 的设计原则之一是"runtime 无关"(第 1 章讨论过)——hyper 本身不绑定 tokio,不直接调 tokio::time::sleep。它通过 hyper::rt::Timer trait 抽象时钟能力,让用户注入具体实现:
rust
use hyper_util::rt::TokioTimer;
http1::Builder::new()
.timer(TokioTimer::new())
.header_read_timeout(Duration::from_secs(30))
.serve_connection(io, svc)
.await如果设了 header_read_timeout 但没设 timer——运行时 panic。这个"配置需要先决条件"的模式是 hyper 对"runtime 无关"的代价——用户得自己负责把 timer 注入。
在 hyper-util crate 里,hyper_util::server::conn::auto::Builder(第 19 章主角)会默认帮你注入 TokioTimer,省去这一步。但如果你直接用 http1::Builder,必须自己注入。
14.5 max_headers:内存 + 安全
rust
// hyper/src/server/conn/http1.rs:335-338
pub fn max_headers(&mut self, val: usize) -> &mut Self {
self.h1_max_headers = Some(val);
self
}默认 100。超过返回 431 Request Header Fields Too Large。
14.5.1 栈分配 vs 堆分配
文档里说得清楚:
Note that headers is allocated on the stack by default, which has higher performance. After setting this value, headers will be allocated in heap memory, that is, heap memory allocation will occur for each request, and there will be a performance drop of about 5%.
默认的 100 个 header slot 是栈分配——[httparse::EMPTY_HEADER; 100]。用户 override 后就变成动态大小——必须堆分配(Vec::with_capacity)。
所以 max_headers(50) 不一定比默认 100 快——反而可能慢 5%,因为切换到堆分配路径。真的想省内存,是降低 max_buf_size(控制整条连接的 buffer),不是降 max_headers。
14.5.2 攻击场景
攻击者发一个有 10000 个 X-Evil-N: value\r\n 头的请求。没上限的话服务端会分配 10000 * 32 bytes = 320KB 的 header 数组(加上 Bytes refs 更多)。几千个这样的连接——内存就吃爆了。
max_headers = 100 在这里当了硬限——第 101 个 header 直接拒绝返回 431。配合后面讲的 max_buf_size 形成两层防线。
14.6 max_buf_size:连接级 buffer 上限
rust
// hyper/src/server/conn/http1.rs:366-380
pub fn max_buf_size(&mut self, max: usize) -> &mut Self {
assert!(
max >= proto::h1::MINIMUM_MAX_BUFFER_SIZE,
"the max_buf_size cannot be smaller than the minimum that h1 specifies."
);
self.max_buf_size = Some(max);
self
}默认约 400 KB。最小不能小于 MINIMUM_MAX_BUFFER_SIZE(8192 bytes)——小了 HTTP 基本功能会崩(单个大 header 塞不下)。
这个值限制整条连接的读写 buffer 上限。当 buffer 超过此值:
- 读:hyper 停止从 socket 读——造成 TCP flow control 向客户端反压。
- 写:hyper 停止从 body 里拉 frame——造成 Body::poll_frame 挂起。
这是连接级的背压阀门。没有这个阀门,恶意 client 可以:
- 发一个带 10MB chunked header 的请求(或者每行 header 99KB、总共 200 行)——buffer 被迫膨胀到几十 MB。
- 开几千条这种连接——内存爆掉。
400KB 是一个 sensible default——正常 header 撑死几十 KB,给应用的写 buffer 也够。生产服务通常不需要调,除非你需要支持极大单个 header(罕见)。
14.7 writev:vectored I/O 的取舍
rust
// hyper/src/server/conn/http1.rs:358-362
pub fn writev(&mut self, val: bool) -> &mut Self {
self.h1_writev = Some(val);
self
}三态:Some(true)(强制 vectored)、Some(false)(禁用)、None(auto,默认)。
14.7.1 vectored 是什么
writev / write_vectored 是一个系统调用——一次提交多个不连续的 buffer,内核把它们 scatter-gather 写到 socket,无需用户层先 memcpy 成一个连续 buffer。
hyper 大量用这个——response header、chunked size、body 数据、\r\n 分隔符常常是四个独立 buffer,用 writev 一次提交。
14.7.2 为什么要选 auto?TLS 的副作用
TLS(rustls、native-tls)在上层包一层加密——它收到 vectored write 后要把所有 buffer 拷贝到一个连续 buffer 里加密,然后 write 加密数据。等于 hyper 避免的 copy 被 TLS 重新做了一次——还多了一层 encrypted copy。
对 plain TCP,vectored 是纯净收益。对 TLS,vectored 往往反而更慢(因为 TLS 层不得不先合并)。
hyper 的 auto 模式:运行时探测 IO 对象是否支持 "real vectored write"——不支持就切成 flatten 写。这是通过 AsyncWrite::is_write_vectored() 的 tokio API 实现。rustls 的 AsyncWrite 正确返回 false——让 hyper 不走 vectored 路径。
一般不用手动设。只有当你用自研 TLS 库、或者底层是非标准 IO,并且 benchmarks 证明 vectored 有问题时才考虑。
14.8 超时矩阵:生产服务必备
一个生产级 HTTP 服务的超时不是一个数字——是一张矩阵。以下是完整版:
14.8.1 TCP 层
TCP_USER_TIMEOUT:socket 发出数据后多久没 ACK 就放弃(Linux 专用)。默认跟系统 TCP retries,可能 15-20 分钟。生产建议设 10-30 秒。SO_KEEPALIVE+TCP_KEEPIDLE/INTVL/CNT:NAT 连接保活。TCP_KEEPIDLE=60+KEEPINTVL=30+KEEPCNT=3大约 2 分半检测到死连接。SO_RCVTIMEO/SNDTIMEO:read/write 系统调用超时。Tokio runtime 一般不需要——自己用 timeout future 包。
14.8.2 HTTP 连接层(hyper 配置)
header_read_timeout:30 秒(默认)。防 Slowloris。keep_alive_idle_timeout:hyper 没有直接的配置!idle 连接不会自动关。客户端实现(第 20 章的 connection pool)有自己的 idle timeout,服务端要靠 外部 watchdog(你的业务代码或者 reverse proxy)关 idle 连接。
为什么 hyper server 不自带 idle timeout?因为"idle timeout 是部署策略问题"——proxy 层(Nginx / HAProxy / Envoy)已经有自己的 idle 策略,hyper 做会重复。自建 hyper server 直接暴露到公网的用户可以用 tower 中间件或自己轮询 live connections 来实现。
14.8.3 请求层(tower 中间件)
tower::timeout::Timeout:每请求的 wall-clock 超时。第 5 章读过源码——tokio::time::sleep+ business future 赛跑。tower::limit::rate::RateLimit:单位时间请求数。tower::limit::concurrency::ConcurrencyLimit:并发 in-flight 数。
14.8.4 Body 层(应用代码)
- 读 body 超时:应用代码里
tokio::time::timeout(dur, body.collect()).await?。 - 写 body 超时:基本同上,但写超时往往由 TCP 层的 write timeout 触发。
- 体积限制:
http_body_util::Limited(第 10 章)。
14.8.5 典型配置实录
一个生产配置的示例:
rust
use std::time::Duration;
use hyper_util::rt::TokioTimer;
let http = hyper::server::conn::http1::Builder::new();
http.timer(TokioTimer::new())
.header_read_timeout(Duration::from_secs(15)) // Slowloris 保护
.max_headers(50) // 降低而不改默认的理由?略
.max_buf_size(256 * 1024) // 256KB,节约
.keep_alive(true); // 默认加上 tower 栈:
rust
let svc = tower::ServiceBuilder::new()
.timeout(Duration::from_secs(60)) // 每请求 60s
.layer(http_body_util::Limited::layer(10 * 1024 * 1024)) // body 最大 10MB
.service(router);再加上 tokio 的 TCP 层:
rust
let socket = tokio::net::TcpSocket::new_v4()?;
socket.set_keepalive_params(..)?; // TCP keepalive这三层合起来,构成 "连接级 → 请求级 → body 级" 三重防护。一个合格的生产 Rust HTTP server 的超时矩阵就长这样。
14.9 一次真实事故:idle 连接堆积
一个生产事故的匿名复盘:
某团队的 Rust gRPC 网关部署在 K8s 上,每周三凌晨 2 点监控会报 FD 数量上涨。不是流量涨——是 ESTABLISHED 状态的 TCP 连接数从 200 上涨到 10000。客户端是 Android 设备,每个设备建一个长连接发心跳。
根本原因是 NAT timeout:客户端位于移动运营商的 NAT 后面,运营商的 NAT 表 30 分钟无流量就丢 entry。客户端从 NAT 角度"消失"了,但 hyper server 不知道——它还持有这个 TCP 连接,以为是空闲 keep-alive。
解决方案有三个层次:
- Linux TCP keepalive:socket 级 keepalive 会在 2 小时后触发——太慢,NAT 表早丢了。
- HTTP 层心跳:业务层的 gRPC PING(HTTP/2 专属,第 17 章讲)——这是最可靠的方法,但实施要修改协议。
- 短 idle timeout:在 server 侧每 5 分钟扫 live connections,关掉过期的——补救方案,但是 hyper server 没内建,团队最终自己写了个 watchdog。
这个事故的教训:hyper server 不自带 idle connection 清理——你得自己想这件事。而客户端(如 reqwest)有 idle pool cleanup——第 20 章会读。
14.10 与 Nginx / Go 的对照
Nginx:
keepalive_timeout 65;——连接 idle 65 秒后关。client_header_timeout 10s;——请求头超时。client_body_timeout 10s;——body 读超时。send_timeout 60s;——write 超时。- 全部在
nginx.conf里,一键配置。
Go net/http:
server.IdleTimeout = 120*time.Second——idle 关。server.ReadHeaderTimeout = 10*time.Second。server.ReadTimeout = 30*time.Second。server.WriteTimeout = 30*time.Second。
hyper:
- 除了
header_read_timeout,其他全部靠外部 Tower 中间件或业务代码。 - 更灵活(任意组合)、学习曲线更陡(不知道的人会漏掉)。
这三者的取舍体现不同语言生态的气质——Nginx 是配置至上、Go 是内建至上、Rust 是组合至上。没有谁更好——用 Rust 做 HTTP 服务的人必须显式知道超时矩阵的存在。这也是为什么本书值得读——很多 Rust 生产事故源于"默认很宽松,没人主动去改"。
14.11 落到你键盘上
- 现在就检查你的 hyper-based 服务的配置:
header_read_timeout有没有设?tower::timeout有没有套?Limited::layer有没有保护 body? - 压一次 Slowloris 测试:用
slowhttptest工具对你的服务发慢请求——看header_read_timeout是否真的生效。 - 读
hyper_util::server::conn::auto::Builder——它把 http1 + http2 Builder 包在一起,帮你自动注入 TokioTimer / TokioExecutor。实际生产代码应该用这个 Builder 而不是直接用 http1::Builder。
下一章我们开始讲 HTTP/2——从 h2 crate 和 HPACK 开始,理解二进制 frame 和流控窗口如何改变整个协议模型。