Rust 服务怎么优雅关闭:别让发布变成随机杀进程
最糟糕的发布事故,不一定是新版本有 bug,而是旧版本死得太突然。
滚动发布期间最容易出现一种很烦的毛刺:错误率只抖几十秒,很快恢复,监控看起来不严重,但日志里会留下几条很刺眼的东西:
connection reset by peer
background job cancelled halfway
trace export failed: channel closed代码没变,数据库没抖,网关也没挂。问题往往出在旧实例收到 SIGTERM 后退出太快。
它还没处理完手上的请求,没停掉后台消费者,没 flush 最后一批 tracing span,连接池也被 runtime 直接带走。Kubernetes 以为自己给了 30 秒宽限期,但我们的进程 300 毫秒就走了。
所以我对 Rust 服务的关闭顺序有个很硬的判断:
优雅关闭不是“收到信号后退出”,而是“按顺序把系统从运行态降到静止态”。
这篇接着前一篇 超时和取消 写。前一篇讲单个请求怎么停下来,这篇讲整个进程怎么停下来。
本文代码环境:
# Cargo.toml
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
tracing = "0.1"最大的错觉:监听 Ctrl+C 就叫优雅关闭
很多示例代码长这样:
#[tokio::main]
async fn main() {
tokio::signal::ctrl_c().await.unwrap();
println!("bye");
}这只能说明一件事:你的进程知道有人让它停。
但“知道要停”和“停得干净”中间差了很远。一个真正的服务停机,至少要回答四个问题:
- 还接不接新请求?
- 已经进来的请求等不等?
- 后台任务怎么通知?
- 最后一批日志、指标、trace 发不发?
如果这些问题没有答案,Ctrl+C 只是一个漂亮一点的 kill。
我现在更喜欢把关闭过程拆成四步:
收到 SIGTERM / Ctrl+C
↓
停止接收新请求
↓
通知后台任务收尾
↓
等待任务退出,超时后强制结束
↓
flush 日志、指标、trace顺序很重要。反过来就会出事。
比如你先从注册中心注销,再排空请求,调用方缓存还没更新时就会打到一个“不知道自己已经快死”的实例。这个坑在 服务发现 里提过。进程内部也是一样:先进入 draining 状态,再真正退出。
HTTP 入口先停下来
Axum 已经把 HTTP 层的优雅关闭做得很顺手了:
use axum::{routing::get, Router};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let app = Router::new().route("/", get(|| async { "ok" }));
let listener = TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
}这段代码的意思不是“收到信号后立刻退出”。
with_graceful_shutdown 做的是:信号 future 完成后,server 停止接收新连接,然后尽量让已经接进来的连接处理完。
信号函数可以这么写:
use tokio::signal;
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c().await.expect("failed to listen for Ctrl+C");
};
tokio::select! {
_ = ctrl_c => {},
_ = terminate_signal() => {},
}
}Unix 上再补一个 SIGTERM 分支:
#[cfg(unix)]
async fn terminate_signal() {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to listen for SIGTERM")
.recv()
.await;
}
#[cfg(not(unix))]
async fn terminate_signal() {
std::future::pending::<()>().await;
}这里有个很实用的细节:本地开发通常是 Ctrl+C,线上容器一般是 SIGTERM。两个都要听。
只监听 Ctrl+C,本地测试很开心,上了 Kubernetes 就会发现:Pod 被删时你根本没走自己的关闭逻辑。
HTTP 停了,不代表后台任务停了
这是最容易踩的坑。
with_graceful_shutdown 管的是 HTTP server,不是你自己 spawn 出去的所有任务。
比如你有一个队列消费者:
tokio::spawn(async move {
loop {
let job = queue.pop().await;
process(job).await;
}
});HTTP server 已经优雅关闭了,但这个消费者还在跑。进程退出时,runtime 一关,它会被直接掐掉。
如果它刚好处理到一半,就会出现最难受的状态:消息可能已经被 ack,副作用只做了一半,重试也救不回来。
所以后台任务要有两个东西:
- 一个取消信号:告诉它该停了
- 一个任务追踪器:主进程能等它真的停了
Tokio-util 里的 CancellationToken 和 TaskTracker 正好解决这个组合。
后台任务要听得懂“该收尾了”
先定义一个全局状态:
use tokio_util::{sync::CancellationToken, task::TaskTracker};
#[derive(Clone)]
struct AppState {
shutdown: CancellationToken,
tasks: TaskTracker,
}
impl AppState {
fn new() -> Self {
Self {
shutdown: CancellationToken::new(),
tasks: TaskTracker::new(),
}
}
}然后后台任务不要裸 tokio::spawn,交给 TaskTracker:
use std::time::Duration;
use tokio::time;
fn spawn_metrics_pusher(state: AppState) {
let shutdown = state.shutdown.clone();
state.tasks.spawn(async move {
loop {
tokio::select! {
_ = shutdown.cancelled() => break,
_ = time::sleep(Duration::from_secs(5)) => {
flush_metrics().await;
}
}
}
flush_metrics().await;
});
}这段代码有两个重点。
第一,任务不是被外面硬 abort,而是自己收到取消信号后走到退出路径。
第二,退出前又 flush_metrics().await 一次。很多后台任务最后都需要这一步:把 buffer 里的东西吐出去,把正在处理的状态写回去,把还没 ack 的消息放回去。
补一个能编译的模拟函数:
async fn flush_metrics() {
// 真实项目里这里可能是 Prometheus push、OTLP export、队列 ack 等
}说实话,这比 JoinHandle::abort() 啰嗦。但服务端代码很多时候就该啰嗦一点。关闭路径不是 happy path,越含糊越容易出事故。
先 close,再 cancel,最后 wait
有了 TaskTracker,主流程就能等后台任务退出:
use std::time::Duration;
use tokio::time;
async fn shutdown_background(state: AppState) {
state.tasks.close();
state.shutdown.cancel();
match time::timeout(Duration::from_secs(20), state.tasks.wait()).await {
Ok(()) => tracing::info!("background tasks stopped"),
Err(_) => tracing::warn!("background tasks did not stop in time"),
}
}这里最容易漏的是 close()。
TaskTracker::wait() 等的是“tracker 已经关闭,并且里面所有任务都结束”。如果你不 close,它会认为未来还可能继续 spawn 新任务,就不会把“空了”当成最终状态。
我个人会把 close() 放在 cancel() 前面:
先 close:不再接受新任务
再 cancel:通知已有任务退出
最后 wait:等已有任务收尾这个顺序和 HTTP server 的 draining 是同一个思想:先关入口,再排空存量。
把 HTTP 和后台任务合在一起
现在把 HTTP server 和后台任务合起来。
先准备 app:
use axum::{routing::get, Router};
use tokio::net::TcpListener;
fn app(state: AppState) -> Router {
Router::new()
.route("/health", get(|| async { "ok" }))
.route("/work", get(handle_work))
.with_state(state)
}handler 里也能拿到 shutdown token:
use axum::extract::State;
async fn handle_work(State(state): State<AppState>) -> &'static str {
if state.shutdown.is_cancelled() {
return "server is shutting down";
}
"accepted"
}最后是主流程:
#[tokio::main]
async fn main() -> std::io::Result<()> {
let state = AppState::new();
spawn_metrics_pusher(state.clone());
let listener = TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app(state.clone()))
.with_graceful_shutdown(shutdown_signal())
.await?;
shutdown_background(state).await;
Ok(())
}这已经比“监听 Ctrl+C 然后退出”靠谱很多。
但它还有一个问题:HTTP server 收到信号后才返回,后台任务是在 HTTP 完全停完之后才收到取消。对于某些服务,这没问题;对于队列消费者,这可能太晚。
我更偏向最终版:收到信号时,同时通知 HTTP 和后台任务。
最终版:一个信号,两个方向
优雅关闭的关键是“同一个停机事件”同时触发两件事:
- HTTP server 停止接新请求
- 后台任务开始收尾
可以用一个额外的 token 把信号扇出去:
async fn wait_for_shutdown(token: CancellationToken) {
shutdown_signal().await;
token.cancel();
}主流程变成这样:
#[tokio::main]
async fn main() -> std::io::Result<()> {
let state = AppState::new();
spawn_metrics_pusher(state.clone());
let listener = TcpListener::bind("127.0.0.1:3000").await?;
let shutdown = state.shutdown.clone();
axum::serve(listener, app(state.clone()))
.with_graceful_shutdown(wait_for_shutdown(shutdown))
.await?;
shutdown_background(state).await;
Ok(())
}这里有个细节:wait_for_shutdown 里会 cancel token,所以 HTTP server 开始停的时候,后台任务也同步进入收尾。
HTTP 处理存量请求,后台任务 flush buffer,两个动作并行发生。等 server 退出后,再 TaskTracker::wait() 确认后台任务真的结束。
这才像一个完整的关闭过程。
Kubernetes 里最容易错的是时间预算
Kubernetes 默认会给 Pod 一段 terminationGracePeriodSeconds。很多人以为有这个就够了。
不够。
Kubernetes 给的是总预算,你的服务内部还要自己分配:
terminationGracePeriodSeconds = 30s
0s 收到 SIGTERM,readiness 变 false
1s 负载均衡停止转发新流量
1-20s 处理存量请求、后台任务收尾
20-25s flush tracing / metrics
30s 还没退出就会被 SIGKILL这里最怕的是你内部也写 30 秒。
比如后台任务等 30 秒,遥测 flush 又等 5 秒,那一定会被 SIGKILL 打断。正确做法是给每个阶段留预算。
我个人的默认值:
| 阶段 | 建议预算 | 原因 |
|---|---|---|
| 停止接新请求 | 立即 | 越快越好 |
| 存量 HTTP 请求 | 10-20 秒 | 看接口最大耗时 |
| 后台任务收尾 | 5-15 秒 | 消费者不要无限等 |
| 遥测 flush | 2-5 秒 | 最后一批 trace 很有用 |
| 兜底强退 | 留 2-3 秒 | 避免被 SIGKILL 卡死 |
表格里的数字不是标准答案,但思路很重要:
优雅关闭也要有 deadline。没有 deadline 的优雅关闭,最后会变成永远关不掉。
readiness 要比进程退出更早变 false
如果你跑在 Kubernetes 里,还有一个顺序问题:什么时候让 readiness 失败?
我的偏好是:收到关闭信号后,立刻让 readiness 返回失败。
async fn readiness(State(state): State<AppState>) -> &'static str {
if state.shutdown.is_cancelled() {
"shutting down"
} else {
"ready"
}
}真实项目里应该返回 503,而不是字符串。这里为了让代码短一点,只表达意思。
为什么 readiness 要先变?
因为负载均衡、Service、Ingress 都需要时间收敛。你不能等到进程快退出了才告诉别人“别打我了”。那时候已经晚了。
这一点和服务发现里的主动下线是同一个逻辑:先告诉世界我不接客了,再处理手上的活。
结论
优雅关闭不是为了“退出得慢一点”,而是为了“退出得有边界”。
没有优雅关闭,发布就是随机杀进程。
只有一半优雅关闭,也很危险。HTTP server 停了,但后台任务没停;请求不接了,但 readiness 还说 ready;进程退出了,但 trace 没 flush。每一处看起来都只是小细节,合在一起就是发布时那几十秒的毛刺。
Rust 服务的关闭路径应该当成正式业务路径来写:
- 收到 SIGTERM,不要慌
- 先停止接新请求
- 再通知任务收尾
- 等一段有限时间
- 最后 flush 可观测性数据
一个服务能不能优雅关闭,决定了它是不是一个真正能上线的服务。
本地 demo 可以 Ctrl+C 一按了事。生产服务不行。生产服务要给正在处理的请求一个交代,也要给下一次排障留下最后几秒的证据。