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