Rust in Everything
基于 pingora-core 0.9.0

平滑升级与停机

主线位置:服务主线的最后一段,「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 同时等三个信号,谁先到返回谁:

信号ShutdownSignalmain_loop 做什么之后
SIGTERMGracefulTerminate广播 ShutdownWatch = true宽限期,再 shutdown_timeout
SIGINTFastShutdown什么也不广播shutdown_timeout(0)
SIGQUITGracefulUpgrade传 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
📝NOTE
没有蠢问题

问: 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。

📝NOTE
冷知识:停机曾经总要睡满 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 接手(服务与监听器)。

📝NOTE
冷知识:每次升级都会漏一个 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 上唯一能用的升级方式。

❗IMPORTANT
动脑筋:不带 -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 用它换掉了不再维护的 daemonize crate,ae96f7e):umask 0o007,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 一起用。

💬 关联讨论

发起讨论
加载中...