返回文章列表

Rust 服务怎么优雅关闭:别让发布变成随机杀进程

561·4 分钟阅读
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");
}

这只能说明一件事:你的进程知道有人让它停。

但“知道要停”和“停得干净”中间差了很远。一个真正的服务停机,至少要回答四个问题:

  1. 还接不接新请求?
  2. 已经进来的请求等不等?
  3. 后台任务怎么通知?
  4. 最后一批日志、指标、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 里的 CancellationTokenTaskTracker 正好解决这个组合。


后台任务要听得懂“该收尾了”

先定义一个全局状态:

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 一按了事。生产服务不行。生产服务要给正在处理的请求一个交代,也要给下一次排障留下最后几秒的证据。