一、引言:停机这事没那么简单

很多朋友在本地跑后端服务的时候,最习惯的操作就是按一下 Ctrl+C。进程没了,世界安静了,好像什么都不用管。但如果你负责的是一个线上网关,手里管着成百上千个正在进行的请求,你还能这么潇洒地一按了之吗?想象一下,有个用户正在提交一笔转账,页面一直在转圈,你这边直接把服务关了,会发生什么?请求啪地断开,用户一脸茫然,然后他可能又点了一次提交,结果就重复扣款了。再比如,你的网关后面挂着一堆微服务,你为了发一个新版本,把网关重启了一下,所有在途请求全部失败,调用方疯狂往你群里发告警。这些麻烦事的背后,都是因为我们少做了一步——优雅停机。

优雅停机听起来是个挺专业的名词,但说白了就是:让正在处理的事情有个交代,再关门休息。这里有两层意思。一层是“给现有请求一个结果”,另一层是“别让新请求进来”。就像电影院散场,你不能直接把灯一黑然后锁门,得让观众慢慢往外走,同时门口不再放人进去。这个过程,放在网络世界里,就叫排空(draining)。

那到底怎么做到呢?这就需要我们把连接排空这件事从头到尾搞明白。这篇博客,我就带你用 Rust 的 Actix Web 框架,从零搭一个自带优雅停机的网关。你不需要有很深的基础,只要会写最简单的异步程序就行。我会把每一步都拆开讲清楚。

二、先搞懂连接 draining 是干嘛的

Draining 这个英文词,直译过来是“排水、排空”。你可以把服务想象成一个游泳池,池子里游着很多小鱼,每一条鱼都是一个正在处理的请求。你要把池子里的水放掉,但不想把鱼也冲走。那该怎么办?你得先让鱼游到隔壁池子里去,或者让它们自己游到出口,总之不能直接用大水管一下子把水抽干。这个“温柔放水但不伤鱼”的过程,就是 draining。

具体到 HTTP 服务上,连接排空需要处理这么几件事:第一,不再接收新的连接;第二,对于已经建立连接但请求还没发完的客户端,让它们把请求发完;第三,对于正在处理中的请求,让它们有足够的时间处理完并返回响应;第四,如果等待的时间太长,不能无限期等下去,得有一个上限,超过上限就强制关闭,把位置腾出来。

在 Actix Web 里面,默认的停机方式相当野蛮。你可以自己试一下:启动一个服务,然后发起一个耗时很长的请求,接着按 Ctrl+C,请求瞬间中断。为什么?因为默认的信号处理行为是直接终止整个运行时,根本不给任务完成的机会。所以,我们必须自己动手,把“温柔”一步步加进去。

三、从零搭建一个最简单的 Actix Web 服务

在正式写优雅停机代码之前,我们先搭一个最基础的 Actix Web 服务。下面的例子就是一个简单的 Hello World,但我故意加了一个耗时 5 秒的接口,方便我们观察停机时的行为。你把这个代码保存到 main.rs 里,然后运行 cargo run 就可以了。

// 技术栈:Rust + Actix Web 4 + Tokio 1.x

use actix_web::{web, App, HttpServer};
use std::time::Duration;

// 定义一个很慢的接口,模拟真实业务里的耗时操作
async fn slow_hello() -> &'static str {
    // 模拟处理业务需要 5 秒钟
    // 实际项目里这里可能是查数据库、调下游服务、做复杂计算等
    tokio::time::sleep(Duration::from_secs(5)).await;
    "终于处理完了"
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // 创建 HTTP 服务,注册 /slow 路由
    HttpServer::new(|| {
        App::new().route("/slow", web::get().to(slow_hello))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

编译运行之后,打开浏览器访问 http://127.0.0.1:8080/slow,你会发现页面要转圈 5 秒。就在这 5 秒之内,你回到终端按 Ctrl+C,浏览器里大概率直接报“连接被重置”。这就是没有做优雅停机的后果。你要是把这个服务部署到线上,等于每次发布都让用户经历一次断线。

四、让停机变优雅的几种常用手段

针对上面那个“野蛮”的问题,Actix Web 给了我们一些钩子。我们可以组合出下面几种常见手段,把停机过程变得温柔起来。

4.1 监听系统信号

在 Linux 系统里,我们通常用 Ctrl+C 给进程发送 SIGINT,用 kill 命令发送 SIGTERM。Actix Web 的 run() 方法会返回一个 Server 对象,这个对象有一个 stop() 方法,可以触发优雅停机。但问题来了:你得在合适的时机调用它。什么时候算合适?当然是收到信号的时候。

Tokio 提供了一个 signal 模块,可以让我们异步等待 SIGINT 和 SIGTERM。我们可以在启动服务器之后,再单独开一个异步任务,专门监听信号。一旦收到信号,就调用 server.stop()。这样,Ctrl+C 就不再是粗暴的夺命刀,而是变成了一个温柔的通知。

4.2 关闭新的连接

只调 stop() 够吗?注意,stop() 做的事情是:停止接受新的连接,但已经建立好的连接,会让它们把当前请求处理完。这正是我们想要的。也就是说,在你触发 stop() 的那一刻,网关的外部表现为“拒绝新进连接”,有点像电影院门口挂了块“停止入场”的牌子。

4.3 等待已有请求处理完

stop() 默认会等待所有 worker 上的请求完成。这里的 worker 就是 Actix 处理请求的线程。只要请求不超时,它就会一直等待。但如果你的某个接口因为 bug 卡住了,可能永远不返回,那么 stop() 就会一直阻塞下去,进程退出不了。这也不行,因为发布流程是有时间限制的。

4.4 设置超时强制结束

为了解决“永远等下去”的问题,Actix Web 提供了一个更实用的方法:stop_with_timeout(Duration)。这个方法会等待指定的时间。如果在时间内所有请求都处理完了,它就干净地退出;如果时间到了还有请求没处理完,它就会强制关闭所有连接。有了超时兜底,你的服务就既温柔又果断。

五、完整示例:一个带优雅停机的高可用网关

下面这个完整示例,实现了一个小型网关。它具备以下功能:监听 SIGINT 和 SIGTERM;收到信号后,先停止接受新连接;给已有请求最多 10 秒的排空时间;如果 10 秒后还有没处理完的请求,强制关闭。代码里我加了详细的注释,你跟着读一遍就懂。

// 技术栈:Rust + Actix Web 4 + Tokio 1.x

use actix_web::{web, App, HttpServer};
use std::time::Duration;

// 模拟一个需要一段时间才能处理完的接口
async fn heavy_work() -> &'static str {
    // 真实业务可能要去查一个慢数据库,或者调用一个外部 API
    // 这里用 sleep 模拟 2 秒的处理时间
    tokio::time::sleep(Duration::from_secs(2)).await;
    "业务处理完成"
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // 创建 HttpServer,注册 /heavy 路由
    let server = HttpServer::new(|| {
        App::new()
            .route("/heavy", web::get().to(heavy_work))
    })
    .bind("127.0.0.1:8080")?;

    // 调用 run() 拿到 Server 实例
    let srv = server.run();

    // 启动一个异步任务,专门等待操作系统的信号
    let srv_clone = srv.clone();
    tokio::spawn(async move {
        // 监听 SIGTERM(kill 默认发送)和 SIGINT(Ctrl+C)
        let mut sigterm = tokio::signal::unix::signal(
            tokio::signal::unix::SignalKind::terminate()
        ).unwrap();
        let mut sigint = tokio::signal::unix::signal(
            tokio::signal::unix::SignalKind::interrupt()
        ).unwrap();

        // 谁先来就响应谁
        tokio::select! {
            _ = sigterm.recv() => {
                println!("收到 SIGTERM,开始优雅停机...");
            }
            _ = sigint.recv() => {
                println!("收到 SIGINT(Ctrl+C),开始优雅停机...");
            }
        }

        // 停止接收新连接,并给已有请求最多 10 秒排空时间
        // 如果 10 秒内所有请求都处理完了,正常退出
        // 如果还有请求没处理完,也会强制退出,保证进程不卡死
        let result = srv_clone
            .stop_with_timeout(Duration::from_secs(10))
            .await;

        match result {
            Ok(()) => println!("所有请求已处理完毕,干净退出。"),
            Err(_) => println!("排空超时,强制关闭剩余连接。"),
        }
    });

    // 启动服务器,这里会阻塞直到服务器真正停止
    srv.await?;
    println!("服务器已经退出。");
    Ok(())
}

你可能会有疑问:为什么需要在 tokio::spawn 里面等待信号?因为 srv.await 会阻塞住主函数。如果我们把信号监听放在 srv.await 之后,那代码根本执行不到。所以必须把信号监听放到另一个异步任务里。这是很多初学者容易踩的坑。

六、应用场景分析

6.1 微服务网关

一个非常典型的场景就是 API 网关。你用 Actix Web 做统一入口,后面挂着一堆微服务。当你要升级网关版本的时候,不能直接 kill 进程,否则所有穿透网关的请求都会断开。用了优雅停机,正在处理的请求能继续走完,同时负载均衡器会检测到网关端口不再接受新连接,于是把新的流量转发到其他副本上。你的客户端完全感知不到这次发布。

6.2 滚动发布

在 Kubernetes 这种容器编排平台里,Pod 的生命周期管理非常严格。当你执行滚动发布时,K8s 会给旧 Pod 发送 SIGTERM 信号,并且把你这个 Pod 标记为“正在终止”。这时,Pod 里的进程必须优雅响应:不再接收新流量,把已有请求处理掉。Kubernetes 默认给你 30 秒的优雅终止期限。如果你的 Actix 网关能在 30 秒内完成排空,就不会出现请求失败。我们示例里的 stop_with_timeout 正好可以用上,只需要把时间设置得比 30 秒小一点,比如 25 秒。

6.3 本地开发调试

即便你只是在本地开发,养成优雅停机的习惯也很有好处。比如你用了代码热重载工具,每次保存文件就自动重启服务。如果没有优雅停机,正在发送的请求就会被打断,调试体验很差。而有了优雅停机,服务重启就变得很顺滑。

6.4 批处理任务与后台任务

如果你的网关还承载着一些异步任务,比如定时清理、消息推送等,优雅停机时要格外小心。因为 stop_with_timeout 等待的只是正在进行中的 HTTP 请求,后台的 tokio 任务不一定在它的管辖范围内。所以,你需要在代码里对后台任务也进行优雅的取消或等待。比如用一个 CancellationToken,收到信号后通知后台任务结束。这里不展开讲,但你要有这个意识。

七、技术优缺点对比

7.1 单纯使用 stop()

  • 优点:代码非常简单,只需要调用一个方法。
  • 缺点:没有超时控制。如果某个请求因为外部依赖卡住了一直不返回,你的服务就永远退不出去。而且你还需要单独实现信号监听,否则没人调用它。

7.2 使用 stop_with_timeout(Duration)

  • 优点:有超时兜底,不会无限期等待。这是生产环境最推荐的方式。
  • 缺点:超时时间不好拿捏。设置太短,一些长请求会被强制切断;设置太长,发布流程会被拖慢。你需要对自己的接口耗时非常清楚。

7.3 自己实现连接跟踪

你可以在中间件里用一个原子计数器,记录当前正在处理的请求数量。每次请求进来加一,完成时减一。停机时,轮询这个计数器,直到变成零再退出。这样做的好处是精确,可以根据请求数量决定是否继续等待;坏处是需要大量自定义代码,而且中间件里的并发问题也需要仔细处理。

下面给个简单的计数器中间件骨架思路,展示一下这种方案的风格,但并不打算写成完整代码,因为篇幅有限且容易偏离主题。

// 技术栈:Rust + Actix Web 4

// 伪代码,仅示意思路
// 你可以用 actix_web::middleware 或自定义 dev::Transform 来实现
static ACTIVE_REQUESTS: AtomicUsize = AtomicUsize::new(0);

fn request_started() {
    ACTIVE_REQUESTS.fetch_add(1, Ordering::SeqCst);
}

fn request_finished() {
    ACTIVE_REQUESTS.fetch_sub(1, Ordering::SeqCst);
}

// 在停机等待时,你可以循环检查 ACTIVE_REQUESTS
// 如果为 0,说明没有请求在处理,可以安全退出

这种方式的优点是可以做更精细的控制,比如可以知道当前到底还有多少个请求没处理完,方便打日志或者做更复杂的决定。缺点就是实现成本高,而且如果你忘了在某个分支调用 request_finished,计数器就会失真。

八、注意事项

8.1 超时时间的设置

超时时间不能拍脑袋定。你要先分析自己接口的最长耗时。比如你的网关会转发一个文件上传请求,这个请求可能需要 30 秒,那你的排空时间理论上要大于 30 秒。同时,你还要考虑上层负载均衡器或编排系统的期限。举个例子,如果你的服务跑在 Kubernetes 上,Pod 的 terminationGracePeriodSeconds 默认是 30 秒,那么你的 stop_with_timeout 最好设置成 25 秒,给程序后面的收尾动作留出 5 秒。

8.2 信号处理要放在服务器启动之前生效

我们的示例是在 srv.await 之前,把信号监听放到了 tokio::spawn 里。这样服务器一跑起来,信号监听就准备好了。如果你不小心把信号监听代码写到了 srv.await 之后,那它永远不会执行,服务器会直接中断进程。

8.3 keep-alive 连接的坑

HTTP/1.1 默认启用了 keep-alive,也就是说客户端会复用同一个 TCP 连接来发送多个请求。当你执行 stop() 时,Actix Web 会停止接受新连接,但已经建立且处于空闲状态的 keep-alive 连接还挂在服务器上。如果直接关闭这些连接,客户端下次想要复用这个连接时就会遇到连接重置的报错。幸运的是,Actix Web 的 stop_with_timeout 会在超时或结束时强制关闭所有连接,包括这些空闲连接。客户端通常都会自己重试,所以这个问题不算致命,但你在设计时也得了解这个风险。

8.4 负载均衡器配合

如果你的网关前面还有负载均衡器,比如 Nginx,你需要让负载均衡器感知到网关正在排空。一种办法是网关自己向注册中心注销,另一种是负载均衡器通过健康检查来判断。比如 Nginx 配置 upstream 时,如果一个上游节点宕机,Nginx 就会自动把它摘掉。但如果你用的是 Kubernetes 的话,默认的探针机制是“就绪探针”和“存活探针”。在排空期间,你最好让探针接口还能正常返回 200,哪怕大家正在排队退出,也要让 K8s 知道这个 Pod 还活着。否则 Pod 可能被提前杀掉,排空流程就中断了。

8.5 多 worker 与状态清理

Actix Web 默认会根据 CPU 核心数创建多个 worker 线程。stop_with_timeout 会等待所有 worker 上的请求完成。如果你的服务里还持有数据库连接池、Redis 连接池等资源,在信号处理里最好也加上关闭这些资源的逻辑。不过要注意顺序:先关 HTTP 连接,再关依赖资源,否则还没处理完的请求就会因为没有数据库连接而失败。

8.6 日志与监控

优雅停机期间,最好输出详细的日志,比如“收到停机信号”“已经排空 3 个请求”“超时,还剩 2 个请求”等等。这样你在排查问题的时候能知道停机过程中到底发生了什么。同时,监控系统也应该对停机时间有记录,如果排空时间总是接近超时上限,说明你的请求处理时间太长了,需要优化。

九、如何验证你的优雅停机是否生效

代码写完了,怎么确认它真的有用?这里给你一个简单的测试方法。

启动上面的网关服务,然后在另一个终端里,用 curl 发起一个请求。你可以指定让 curl 等待 5 秒:

# 技术栈:Shell 命令

# 发起一个请求,设置最大等待时间为 15 秒
curl --max-time 15 http://127.0.0.1:8080/heavy

在 curl 运行的同时,快速回到启动服务的终端,按 Ctrl+C。如果优雅停机生效了,你会看到服务端打印出“收到 SIGINT(Ctrl+C),开始优雅停机...”,然后 curl 那边依然会在 2 秒后正常输出“业务处理完成”。如果优雅停机没做好,curl 会立刻报错,或者服务端直接崩溃。

你也可以同时发起多个请求,比如开三个终端同时跑 curl。然后按 Ctrl+C,观察是不是每个 curl 都能正常完成。这个测试很简单,却能暴露很多问题。

十、文章总结

从零搭建一个 Actix Web 高可用网关,优雅停机绝对不是一个可有可无的加分项,而是一个必须具备的能力。没有它,你的上线发布就像一场赌博,赌的是没有用户在你发布的那几秒钟内发起请求。有了它,你可以在版本更新、流量切换的时候做到近乎无感知。

我们自己动手实现了一个带信号监听和超时排空的网关。核心步骤就三步:收到信号、停止接收新连接、等待已有请求完成(同时设好超时上限)。其中 stop_with_timeout 是 Actix Web 提供的最实用的方法,强烈推荐。

当然,真实世界永远比单段代码要复杂。你还要面对 HTTP/2 的连接、WebSocket 长连接、日志刷新、数据库连接池关闭等一堆细枝末节。但底层的思路都是相通的:先拒绝新人,再善待旧人,最后给自己的耐心设个上限。希望这篇文章能帮你少踩几个坑。下次你写网关或者任何需要停机的服务时,不妨先问问自己:如果我此刻按下 Ctrl+C,那些还在路上的请求会怎么办?想明白这个问题,你就离高可用又近了一步。