每次重启能救下几十万个请求:Cloudflare 如何用 Rust 实现零停机升级
蛇蜕皮,是为了生长。旧皮脱落的那一刻,蛇并没有停止存在——它只是换上了新的外壳,继续前行。 Cloudflare 给自己的零停机升级库起名叫 ecdysis,就是取自这个意象。这个库在 Cloudflare 内部生产环境中运行了五年,覆盖全球 330 多个数据中心,今年正式开源。 这篇文章就来聊聊它解决了什么问题,以及它是怎么解决的。 原文链接:https://blog.cloudflare.com/ecdysis-rust-graceful-restarts/ 假设你有一个网络服务,每秒处理数千个请求,现在需要发布一个安全补丁。最直接的做法是:停掉旧进程,启动新进程。 这个方案有两个致命缺陷。 第一,存在空窗期。 旧进程停止时,它绑定的监听 socket 随之关闭,操作系统立刻开始拒绝新连接,返回 第二,已有连接被强制断开。 旧进程退出时,它维持的所有 TCP 连接也随之终止。正在上传大文件的用户突然掉线,WebSocket 长连接被直接切断,gRPC 流式调用中途失败。从客户端的角度来看,服务凭空消失了。 有人会想到 在设计 ecdysis 时,团队确立了四个硬性目标: 这四个目标,共同指向一个核心约束:在整个升级过程中,必须始终有进程在处理请求,新旧进程之间的交接必须无缝。 ecdysis 采用的方案,最早由 NGINX 在早期版本中引入,思路非常直接: 这个流程的关键在于:监听 socket 在整个过渡期间从未关闭。父进程和子进程共享同一个底层的内核 socket 数据结构。在子进程初始化期间,父进程正常继续接受新连接和处理已有请求。子进程就绪后,父进程关闭自己那份 socket 副本,但所有已建立的连接不受影响,会继续由父进程处理完毕。 有一个短暂的窗口期,父子进程会同时接受新连接,这是刻意设计的。父进程接受的这些连接,会作为"排水"过程的一部分被处理完毕。 这个模型还天然提供了崩溃安全性。如果新进程在初始化阶段失败——比如配置文件有误——它直接退出,父进程感知不到任何异常,因为父进程从来没有停止监听。升级失败,修复问题,重试即可,线上服务全程不受影响。 下面是官方给出的简化示例,一个支持优雅重启的 TCP echo 服务: 几个关键点值得注意: 当你向这个进程发送 优雅重启引入了一个短暂的两进程共存窗口,这在安全上需要认真对待。ecdysis 在设计上做了几个明确的选择: fork-then-exec 保证内存隔离。 fork 之后立即 exec,子进程会加载全新的地址空间,旧进程的内存内容不会泄漏给新进程。两者之间唯一共享的是明确传递的文件描述符。 CLOEXEC 防止文件描述符泄漏。 除了监听 socket 和通信管道之外,所有其他文件描述符都被标记为 seccomp 的权衡。 如果你的服务使用了 seccomp 过滤器来限制系统调用,那么 对于绝大多数网络服务来说,这些权衡是完全可以接受的。fork-exec 模型在 NGINX、Apache 等软件中已经被验证了几十年,安全边界清晰,行为可预期。 ecdysis 自 2021 年开始在 Cloudflare 生产环境运行,覆盖流量路由、TLS 生命周期管理、防火墙规则执行等核心基础设施服务,部署在全球 120 多个国家的 330 多个数据中心。 每次使用 ecdysis 进行重启,相较于粗暴的 stop/start 方式,都能保住数十万个本来会被丢弃的请求。在全球规模下,这意味着每次部署能挽救数百万个连接。 对于这类服务,即使是 0.01% 的请求失败率,在 Cloudflare 的体量下也是用户可以直接感知到的故障。ecdysis 把这个数字降到了接近零。 tableflip:Cloudflare 的 Go 版本优雅重启库,ecdysis 在设计上参考了它。实现的是同一套 fork-and-inherit 模型。如果你的服务是 Go 写的,直接用 tableflip。 shellflip:Cloudflare 的另一个 Rust 优雅重启库,专门为 Oxy(Cloudflare 的 Rust 代理框架)设计,强依赖 systemd 和 Tokio,支持在父子进程之间传递任意应用状态。适合需要迁移复杂内部状态、或者安全沙箱极度严格以至于无法自行打开 socket 的场景,但对简单场景来说过于重量级。 ecdysis 定位在两者之间:有完整的 Tokio 集成和 systemd 支持,但不强制要求;对简单服务几乎零配置,对复杂服务也足够灵活。 优雅重启这个问题,在系统工程里已经有几十年的讨论历史,但在 Rust 生态里一直缺乏一个经过大规模生产验证的开箱即用的库。 ecdysis 填补了这个空白。它的设计没有任何新奇之处——fork、exec、socket 继承,这些都是 Unix 里几十年前的东西——但把它们组合成一个 Rust 原生的、有完善 async 支持的、在极端规模下验证过的库,本身就是一件有价值的工程工作。 核心的工程思想也值得记住:升级不是让服务停下来换新衣服,而是让它在不停止运行的情况下,把旧皮悄悄蜕掉。
开源地址:https://github.com/cloudflare/ecdysis
文档:https://docs.rs/ecdysis最简单的重启方式,为什么不够用
ECONNREFUSED。即使新进程几乎同时启动,这中间也必然存在一个间隔——哪怕只有 100ms,对于每秒处理几千个请求的服务来说,也意味着几百个连接被直接丢弃。在 Cloudflare 的规模下,乘以全球几百个数据中心,一次"短暂"的重启可能导致数百万个请求失败。SO_REUSEPORT——这个 socket 选项允许多个进程同时绑定同一个地址和端口。但它同样解决不了问题。SO_REUSEPORT 下,内核会为每个进程维护独立的监听 socket,并在它们之间做负载均衡。当旧进程退出时,那些已经完成三次握手、排在旧进程 accept() 队列里等待处理的连接,会被内核直接丢弃。这个问题在 GitHub 工程团队构建 GLB Director 负载均衡器时被详细记录过,是 SO_REUSEPORT 的固有缺陷,绕不开。ecdysis 要解决什么
核心机制:一个 NGINX 二十年前就发明的思路
1. 父进程 fork() 出一个子进程
2. 子进程用 execve() 把自己替换成新版本的二进制文件
3. 监听 socket 的文件描述符,通过命名管道从父进程传递给子进程
4. 子进程完成初始化后,通知父进程
5. 父进程收到通知,关闭自己的监听 socket,继续处理已有连接,直到全部处理完毕后退出代码示例:一个支持优雅重启的 TCP 服务
use ecdysis::tokio_ecdysis::{SignalKind, StopOnShutdown, TokioEcdysisBuilder};
#[tokio::main]
async fn main() {
// 创建 ecdysis builder,监听 SIGHUP 信号触发升级
let mut ecdysis_builder = TokioEcdysisBuilder::new(
SignalKind::hangup()
).unwrap();
// 监听 SIGUSR1 触发停止
ecdysis_builder
.stop_on_signal(SignalKind::user_defined1())
.unwrap();
// 创建 TCP 监听器,这个 socket 会被子进程继承
let addr: SocketAddr = "0.0.0.0:8080".parse().unwrap();
let stream = ecdysis_builder
.build_listen_tcp(StopOnShutdown::Yes, addr, |builder, addr| {
builder.set_reuse_address(true)?;
builder.bind(&addr.into())?;
builder.listen(128)?;
Ok(builder.into())
})
.unwrap();
// 启动连接处理任务
let server_handle = tokio::spawn(async move {
// 处理连接...
});
// 通知父进程:初始化完成,可以开始交接
let (_ecdysis, shutdown_fut) = ecdysis_builder.ready().unwrap();
// 阻塞,直到收到升级或停止信号
let shutdown_reason = shutdown_fut.await;
// 等待已有连接处理完毕,然后退出
server_handle.await.unwrap();
}build_listen_tcp 创建的监听器,会自动被子进程继承。开发者不需要关心文件描述符传递的细节,ecdysis 在内部处理好了。ready() 是父子进程之间的信号点。调用它意味着"我已经初始化完毕,父进程可以安全退出了"。在子进程调用 ready() 之前,父进程会一直保持监听。shutdown_fut.await 会阻塞,直到进程需要退出——无论是因为触发了新的升级,还是收到了停止信号。收到信号后,进程停止接受新连接,等待已有连接全部处理完毕,然后干净退出。SIGHUP 时,ecdysis 在父进程侧会 fork 并 exec 一个新进程,把 socket 传给它,然后等待子进程调用 ready();在子进程侧,代码走同样的初始化流程,但 socket 是继承而来的,不需要重新绑定,初始化完成后调用 ready() 通知父进程。安全考量:fork 模型的边界在哪里
CLOEXEC,在 exec 时自动关闭,不会意外被子进程继承。fork() 和 execve() 必须在白名单里,否则优雅重启无法工作。这是一个需要显式权衡的取舍点,没有办法绕过。五年生产数据:每次重启节省几十万请求
和同类库的对比
小结