平滑升级与停机
主线位置:服务主线的最后一段,「
main_loop等到信号 → 停机流程」,以及升级时新进程在bootstrap里接手监听 fd 的那一步。守护进程化发生在run的开头,也放在本页。
sequenceDiagram participant N as 新进程 participant O as 旧进程 participant K as 内核 N->>N: bootstrap 带 -u N->>K: bind upgrade_sock 并 accept O->>O: 收到 SIGQUIT O->>K: connect upgrade_sock O->>N: sendmsg SCM_RIGHTS 带监听 fd N->>N: 服务 from_raw_fd 接着 accept O->>O: 等 5 秒 CLOSE_TIMEOUT O->>O: 广播停机 accept 循环退出 O->>O: 宽限期后关运行时
代码在 pingora-core/src/server/mod.rs(main_loop、run 的后半段)、server/transfer_fd/mod.rs(737 行,三分之二是测试)、server/daemon.rs(446 行)。图里是 Linux 上的流程;macOS 上 transfer_fd 是空实现,见下文。实验 exp-pingora/server-lab(M2 Max、macOS 27、rustc 1.99.0,release,tokio 1.53.2),服务端是 src/bin/svc.rs,threads: 2,grace_period_seconds: 2,graceful_shutdown_timeout_seconds: 3。
三个信号
UnixShutdownSignalWatch::recv 用 tokio::signal 同时等三个信号,谁先到返回谁:
| 信号 | ShutdownSignal | main_loop 做什么 | 之后 |
|---|---|---|---|
SIGTERM | GracefulTerminate | 广播 ShutdownWatch = true | 宽限期,再 shutdown_timeout |
SIGINT | FastShutdown | 什么也不广播 | shutdown_timeout(0) |
SIGQUIT | GracefulUpgrade | 传 fd,睡 5 秒,再广播 | 同 SIGTERM |
main_loop 只处理第一个信号;源码里有一条 TODO 说 SIGQUIT 处理期间「still need to select! on signals in case a fast shutdown is needed」。实际影响比 TODO 说的更大:tokio 一旦接管了某个信号,就不再执行系统的默认动作,而 main_loop 返回之后再也没人读这些信号。实验:默认配置的 svc,先发 SIGTERM,0.5 秒后发 SIGINT,再 0.5 秒后发 SIGQUIT,进程仍然活着(alive after TERM+INT+QUIT),日志停在 grace period 300s starts,最后是 runq 的 SIGKILL 结束了它。宽限期里想提前结束进程,只剩 SIGKILL。0.5.0 起 run 接受一个 RunArgs,里面的 ShutdownSignalWatch 可以换成自己的实现,比如由管理接口触发停机;run_forever 用的是默认的 Unix 信号版本。
停机的阶段可以通过 watch_execution_phase 订阅,一个 broadcast 通道,每次切换发一个 ExecutionPhase。下面的日志里 svc] phase ... 就是实验程序打印的阶段。
SIGTERM:停 accept,等宽限期,关运行时
main_loop 收到 SIGTERM,把 shutdown_watch 置成 true,返回 ShutdownType::Graceful。之后 run 的停机段:
if matches!(shutdown_type, ShutdownType::Graceful) {
// ...
let exit_timeout = self
.configuration
.as_ref()
.grace_period_seconds
.unwrap_or(EXIT_TIMEOUT);
info!("Graceful shutdown: grace period {}s starts", exit_timeout);
thread::sleep(Duration::from_secs(exit_timeout));
info!("Graceful shutdown: grace period ends");
}
// Give tokio runtimes time to exit
let shutdown_timeout = match shutdown_type {
ShutdownType::Quick => Duration::from_secs(0),
ShutdownType::Graceful => Duration::from_secs(
self.configuration
.as_ref()
.graceful_shutdown_timeout_seconds
.unwrap_or(5),
),
};
// ...
let shutdowns: Vec<_> = runtimes
.into_iter()
.map(|(rt, name)| {
info!("Waiting for runtimes to exit!");
let join = thread::spawn(move || {
let start = Instant::now();
rt.shutdown_timeout(shutdown_timeout);
start.elapsed()
});
(join, name)
})
.collect();
宽限期是主线程上的一个无条件的 thread::sleep:不管连接是不是早就处理完了,都要睡满。没配置时是 EXIT_TIMEOUT = 300 秒。不写配置的 svc 收到 SIGTERM 后打的是 Graceful shutdown: grace period 300s starts,runq 6 秒后把它 SIGKILL 掉了。
广播之后各处的反应:accept 循环 break,监听 socket 关闭;HTTP/1 会话在下一个请求的响应里带上 Connection: close;后台服务自己决定,watcher 收到通知就返回了。实验 term.sh:先发两个请求(/sleep?ms=1500、/sleep?ms=10000),0.5 秒后 SIGTERM,再过 0.3 秒发一个新连接。时间戳由 shell 脚本里的 python 打印,每个有几十毫秒的开销:
0.604s sent SIGTERM
1.035s [new conn at 0.8s] 000 curl_exit=7
1.635s [sleep 1.5s, sent at 0s] 200 curl_exit=0
2.609s [sleep 10s, sent at 0s] 000 curl_exit=52
2.623s server process gone
- 新连接被拒(curl 7,connection refused):监听 socket 已经关了。
- 1.5 秒的请求在宽限期内完成,正常返回。
- 10 秒的请求在 SIGTERM 之后 2.0 秒被切断(curl 52,empty reply),进程随即退出。
graceful_shutdown_timeout_seconds: 3没有让它多活 3 秒。
同一个服务上,一个 keep-alive 连接每 0.3 秒发一个请求,第 4 个请求前发 SIGTERM(ka.py):
0.618s req2: 200 [b'Connection: keep-alive']
0.922s SIGTERM
0.922s req3: 200 [b'Connection: close']
1.226s req4: connection closed by server
问: graceful_shutdown_timeout_seconds: 3,为什么那个 10 秒的请求没有多等 3 秒?
答: 这 3 秒是传给 tokio Runtime::shutdown_timeout 的。tokio 的文档写得很清楚:运行时关闭时,异步任务「keep running until they yield. Then they are dropped」,超时只约束 spawn_blocking 里还没返回的阻塞任务。/sleep 的任务在 sleep().await 上让出,运行时一关它就被 drop 了。换成 /block?ms=10000(在 spawn_blocking 里 thread::sleep)再做一次:
2.616s [block 10s, sent at 0s] 000 curl_exit=52
5.686s server process gone
...
[ 3.651s INFO pingora_core::server] Waiting for service runtime lab to exit
[ 6.658s WARN pingora_core::server] Service runtime lab did not exit within 3s
[ 6.658s INFO pingora_core::server] All runtimes exited, exiting now
等待 spawn_blocking 的那个异步任务照样在宽限期结束时被 drop,客户端在同一时刻收到 curl 52;进程却多等了 3 秒,等那个阻塞线程,等不到就把它泄漏掉退出。所以真正给请求留时间的是 grace_period_seconds,graceful_shutdown_timeout_seconds 只管阻塞线程池。
SIGINT:不广播,直接关
SIGINT 对应 ShutdownType::Quick:不发 ShutdownWatch,跳过宽限期,shutdown_timeout(0)。同一个脚本:
0.626s [sleep 10s, sent at 0s] 000 curl_exit=52
0.626s sent SIGINT
0.626s [sleep 1.5s, sent at 0s] 000 curl_exit=52
1.058s [new conn at 0.8s] 000 curl_exit=7
1.142s server process gone
两个进行中的请求都在信号到达的瞬间被切断。日志里没有 BG watcher saw shutdown:后台服务收不到停机通知,它们的任务和其他任务一样被直接 drop,没有机会做清理。run_forever 最后 std::process::exit(0),所以 SIGINT 退出的进程退出码也是 0。
graceful_shutdown_timeout_seconds上面那段代码里,每个运行时的停机线程只调 rt.shutdown_timeout(shutdown_timeout)。6f15714(「Fix shutdown sleeping twice for graceful timeout」,来自 PR #733,修 issue #727,首次进入 0.9.0)之前,这个闭包里还有一行 thread::sleep(shutdown_timeout)。提交说明:「The shutdown_timeout call already waits up to the timeout duration. The additional sleep causes shutdown to always wait for the full timeout period even when runtimes exit early.」也就是 0.8.x 及以前,即使所有运行时立刻退出,进程也要在宽限期之后再白等 5 秒(默认值)。0.9.0 的 run 还给每个运行时记了耗时,超时时打 did not exit within,就是上面实验里的那条 warn。CHANGELOG 0.9.0 里对应的一条是「Remove the duplicate graceful-shutdown sleep」。
SIGQUIT 与 transfer_fd
平滑升级要解决的问题是:旧进程退出、新进程绑定之间,端口上没有人监听,新连接会被拒绝。pingora 的办法是不重新 bind,而是把旧进程已经在监听的 socket 交给新进程,内核里那个 socket 和它的 accept 队列一直都在。docs/user_guide/graceful.md 承诺的正是这一点:「No request will see connection refused when trying to connect to the server endpoints」。
传 fd 用的是 Unix domain socket 的 SCM_RIGHTS 控制消息。Linux 版的两端:
- 新进程(
-u,在bootstrap里):get_fds_from先unlink掉upgrade_sock路径上的旧文件,bind、chmod 0666(注释说 socket 是在切换用户之前建的)、listen,然后非阻塞地accept:EAGAIN就睡 1 秒重试,retried > MAX_RETRY(MAX_RETRY = 5)才放弃,算下来最多睡 6 次、约 6 秒。收到连接后recvmsg,控制消息里最多 32 个 fd(MAX_FDS),正文是空格分隔的地址列表,两者按顺序配对成Fds表。 - 旧进程(
main_loop的 SIGQUIT 分支):send_fds_to去connect这个路径;ENOENT(新进程还没建 socket)、ECONNREFUSED、EACCES(权限还没改)都当作「新进程还没准备好」,每秒重试,一共尝试 6 次、睡 5 次后放弃;连上后一次sendmsg把所有监听 fd 发过去。
两边都在等对方,最多大约 5 秒,所以先起新进程再发 SIGQUIT(graceful.md 的顺序),和先发 SIGQUIT 再起新进程(systemd.md 里 ExecReload 的顺序)在 Linux 上都能对上。重试次数本该由 upgrade_sock_connect_accept_max_retries 配置,但它没有接进来,见配置与命令行。地址列表序列化进一个 2048 字节的栈上缓冲,serialize_vec_string 里写着 // TODO: check the buf is large enough;监听地址很多时,这一点和 32 个 fd 的上限都要留意(这两条只读了源码,没有在 Linux 上验证)。
旧进程发完 fd 后,main_loop 睡 CLOSE_TIMEOUT = 5 秒:这段时间新旧进程同时 accept 同一个 socket,然后旧进程广播停机,后面和 SIGTERM 完全一样。新进程这边,各服务在 Listeners::build 里查到表里的 fd,from_raw_fd 接手(服务与监听器)。
0.9.0 之前,get_fds_from 里 accept 回来的连接只是一个裸的 RawFd,函数结束时只关了监听用的那个 socket,这个连接从来没关过,每升级一次泄漏一个 Unix socket;recvmsg 的 flags 是 MsgFlags::empty(),收到的监听 fd 没有 FD_CLOEXEC,进程之后 exec 的任何子进程都会继承它们,子进程不退出,端口就一直被占着。b2b35fd(「Close the transfer socket and set CLOEXEC on received listener fds」,来自 PR #960,首次进入 0.9.0)用 OwnedFd 接管这个连接,recvmsg 改传 MSG_CMSG_CLOEXEC。提交说明还解释了第二个问题和同一版本 f82478a 的关系:通过 exec 继承来的 fd 不在 Fds 表里,「Close unclaimed inherited listening sockets」那段清理看不到它。
macOS 上:两端都是空实现
transfer_fd/mod.rs 的 Linux 版本都包在 #[cfg(target_os = "linux")] 里,其他平台是两个桩函数:
#[cfg(not(target_os = "linux"))]
pub fn get_fds_from<P>(
_path: &P,
_payload: &mut [u8],
_max_retry: Option<usize>,
) -> Result<(Vec<RawFd>, usize), Error>
where
P: ?Sized + NixPath + std::fmt::Display,
{
log::error!("Upgrade is not currently supported outside of Linux platforms");
Err(Errno::ECONNREFUSED)
}
// ...
#[cfg(not(target_os = "linux"))]
pub fn send_fds_to<P>(
// ...
) -> Result<usize, Error>
// ...
{
Ok(0)
}
接收端返回错误,发送端什么都不做却返回成功。实验 up.sh:旧进程先起,探针每 20 ms 新建一个连接请求 /(probe.py,记录是哪个 pid 应答);0.5 秒后用 -u 起新进程,再过 0.5 秒给旧进程发 SIGQUIT:
old pid=49999
probe 0.075s.. 6.009s pid=49999 x215
probe 6.042s.. 14.055s refused x297
--- old
[ 2.172s INFO pingora_core::server] SIGQUIT received, sending socks and gracefully exiting
[ 2.172s INFO pingora_core::server] Trying to send socks
[ 2.172s INFO svc] phase GracefulUpgradeTransferringFds
[ 2.172s INFO pingora_core::server] listener sockets sent
[ 2.172s INFO svc] phase GracefulUpgradeCloseTimeout
[ 7.174s INFO pingora_core::server] Broadcasting graceful shutdown
...
--- new
[ 0.001s INFO pingora_core::server::bootstrap_services] Bootstrap starting
[ 0.001s ERROR pingora_core::server::transfer_fd] Upgrade is not currently supported outside of Linux platforms
[ 0.001s INFO svc] phase Bootstrap
[ 0.001s ERROR pingora_core::server::bootstrap_services] Bootstrap failed on error: ECONNREFUSED, exiting.
卡在哪一步很明确:新进程在 bootstrap 的第一次 get_fds_from 就以 exit(1) 退出,根本没有建 upgrade socket。旧进程不知道这些,日志里写着 listener sockets sent(send_fds_to 的 Ok(0)),照常等 5 秒、停 accept、走完宽限期。从 SIGQUIT 之后 5 秒起,端口上没有任何进程监听,探针此后全部 refused。在 macOS 上用 -u,结果比不带 -u 更糟。
不带 -u 时,新进程按普通启动流程去 bind,撞上 AddrInUse 后每秒重试(服务与监听器讲过的 30 次),旧进程一关监听 socket,它下一次重试就绑上了。这是 macOS 上唯一能用的升级方式。
-u 的空窗有多长?同样的 up.sh,新进程不带 -u。旧进程在 SIGQUIT 之后 5 秒关闭监听 socket,新进程每秒尝试一次 bind。探针每 20 ms 一个新连接,会看到多长一段 refused?
守护进程
-d 或配置里 daemon: true 时,run 的第一件事是 daemonize。它在 bootstrap 之后、建任何运行时之前:
- 先
fast_timeout::pause_for_fork():pingora-timeout的时钟线程持有读写锁,fork时锁的状态会被复制,暂停它等锁都释放了再 fork。 move_old_pid把已有的 pid 文件改名成.old;源码注释承认这本该由退出的旧进程来做。进程退出时 pid 文件不会被删除(server/mod.rs里有一行// TODO: delete the pid when exit)。build_daemonize配置daemonix(0.9.0 用它换掉了不再维护的daemonizecrate,ae96f7e):umask0o007,pid 文件,working_directory(0.9.0 新增;不配时daemonix默认切到/),user/group。配了error_log时 stderr 指向这个文件,stdout 是daemonix默认的/dev/null;没配时 stdout、stderr 都保留原样。
实验(conf/daemon.yaml,error_log 和 pid_file 都在实验目录下):
parent exit=0 after 0.106s
pid file: 51853
PID PPID STAT COMMAND
51853 1 S ~/exp-pingora/target-server/release/svc -c conf/daemon.yaml -d
threads: 5
ok pid=51853 ThreadId(5)
--- parent stdout/stderr:
...
[ 0.000s INFO svc] run_forever pid=51851
[ 0.000s INFO pingora_core::server] Server starting
[ 0.000s INFO pingora_core::server] Daemonizing the server
--- error_log:
[ 0.023s INFO pingora_core::server] Starting services in dependency order: ["BG watcher", "lab"]
...
命令行启动的进程是 51851,守护进程是 51853(两次 fork,父进程是 1)。守护进程有 5 个线程:主线程、Server、BG watcher、两个 lab。前台运行时会多一个实验程序在 run 之前开的线程(打印 ExecutionPhase 用),守护进程里它没了,error_log 里也一条 phase 都没有:fork 只复制调用线程。run_forever 的文档说「any additional threads created before this function will be lost」,指的就是这种情况。
daemon_wait_for_ready:0.9.0 的就绪信号
传统的守护进程化里,父进程 fork 完立刻退出。systemd 的 Type=forking 把父进程退出当作「新实例已启动」,紧接着给旧进程发 SIGQUIT;而这时新进程可能还在初始化。ee387f4(「Add a mechanism for signalling between old and new processes when doing graceful upgrades」,首次进入 0.9.0,CHANGELOG 里是「Add graceful-upgrade signalling between old and new processes」)加了 daemon_wait_for_ready:父进程不立刻退出,而是在 wait_for_ready_or_exit 里等守护进程发来的 SIGUSR1,等到了以 0 退出,超时(默认 600 秒)或者守护进程先死了就以 1 退出。
发 SIGUSR1 的是 Bootstrap::bootstrap 里的 notify_parent_ready_for_fds,而父进程的 pid 是 run 在 daemonize 之后才交给 Bootstrap 的。如果程序像大多数例子那样在 run_forever 之前调了 server.bootstrap(),那次 bootstrap 时 pid 还没设,completed 已经是 true,信号永远不会发。conf/daemon-wait.yaml 把超时设成 3 秒,分别用 bootstrap() 和 bootstrap_as_a_service():
[plain] parent exit=1 after 3.163s
~/exp-pingora/server-lab/out/dw-plain.log:[ 0.031s INFO pingora_core::server::daemon] Waiting up to 3s for daemon to signal readiness via SIGUSR1
~/exp-pingora/server-lab/out/dw-plain.log:[ 3.033s ERROR pingora_core::server::daemon] Daemon did not signal readiness within 3s, aborting
ok pid=52109 ThreadId(6)
[service] parent exit=0 after 0.105s
~/exp-pingora/server-lab/out/d-error.log:[ 0.024s INFO pingora_core::server::daemon] Sending SIGUSR1 to parent process (pid 52122) to signal daemon readiness
...
~/exp-pingora/server-lab/out/dw-service.log:[ 0.024s INFO pingora_core::server::daemon] Daemon signaled readiness, parent exiting
第一种情况下守护进程活得好好的(ok pid=52109),父进程却在超时后以 1 退出;放在 systemd 下,这就是一次「失败」的 reload,而且默认要等 10 分钟才失败。只有 bootstrap_as_a_service() 能和 daemon_wait_for_ready 配合:bootstrap 推迟到 run 里作为一个服务执行,那时 pid 已经设好。仓库里的 pingora/examples/graceful_upgrade.rs 用的正是 bootstrap_as_a_service,docs/user_guide/conf.md 里对这个字段的说明没有提这个前提。
动脑筋答案
半秒左右,两次实验分别是 18 次和 20 次探针被拒:
probe 0.075s.. 6.028s pid=50249 x218
probe 6.050s.. 6.540s refused x18
probe 6.569s.. 14.068s pid=50257 x275
...
probe 0.077s.. 5.998s pid=50473 x217
probe 6.026s.. 6.528s refused x20
probe 6.557s.. 14.074s pid=50520 x273
- 旧进程在 SIGQUIT 后 5 秒(
CLOSE_TIMEOUT)停 accept、关监听 socket,探针在约 6.03 秒看到第一次refused。 - 新进程在起来时第一次
bind失败,之后每隔约 1 秒重试一次;它的重试时刻和旧进程关闭时刻之间的相位差决定空窗,这两次都是约 0.5 秒,最坏接近 1 秒。 - 空窗之后应答的 pid 换成了新进程。这正是
transfer_fd要消灭的那一段connection refused。
小结
- SIGTERM:广播停机,accept 循环立刻关监听 socket,keep-alive 响应改成
Connection: close,主线程无条件睡满宽限期(默认 300 秒),然后关运行时。 graceful_shutdown_timeout_seconds只约束阻塞线程池;异步请求在宽限期结束时就被 drop。- SIGINT 不广播、不等,进行中的请求立刻被切断,后台服务收不到通知;退出码仍是 0。
- SIGQUIT 在 Linux 上经
SCM_RIGHTS把监听 fd 交给带-u的新进程,5 秒后旧进程按 SIGTERM 的流程退出;macOS 上接收端直接报错退出、发送端假装成功,端口从此没人监听。 - 守护进程化在
run开头,fork之前创建的线程会丢;daemon_wait_for_ready只能和bootstrap_as_a_service一起用。