Rust in Everything
基于 pingora-core 0.9.0

一个 Server 的一生

主线位置:服务主线全程。从 Server::new 读配置,到 run_forever 给每个服务建运行时、等信号,再到 SIGTERM 之后按什么顺序停下来;每一步只讲到看清全貌的深度,细节链到 02 服务器。

stateDiagram-v2
  [*] --> Bootstrap
  Bootstrap --> BootstrapComplete
  BootstrapComplete --> Running
  Running --> GracefulTerminate: SIGTERM
  Running --> GracefulUpgradeTransferringFds: SIGQUIT
  GracefulUpgradeTransferringFds --> GracefulUpgradeCloseTimeout
  GracefulUpgradeCloseTimeout --> ShutdownStarted
  GracefulTerminate --> ShutdownStarted
  Running --> ShutdownStarted: SIGINT
  ShutdownStarted --> ShutdownGracePeriod
  ShutdownGracePeriod --> ShutdownRuntimes
  ShutdownStarted --> ShutdownRuntimes: SIGINT
  ShutdownRuntimes --> Terminated
  Terminated --> [*]

图里的状态名就是 ExecutionPhase 的变体(还有一个初始的 Setup,从来不会被广播)。Server 每进入一个阶段就往一个 broadcast 通道里发一次,watch_execution_phase() 可以订阅。下面的实验就靠它看顺序。

程序

实验 intro-run 的 lifecycle:一个代理服务(127.0.0.1:18110 → 上游 18111)加一个后台服务,另开一个线程订阅执行阶段。每行日志都带「距离实验开始的毫秒数」和线程名,客户端脚本用同一个起点,两边的时间能对齐。

fn main() {
  // ...
  let conf = std::env::args().nth(1).expect("conf path");
  let opt = Opt { conf: Some(conf), ..Default::default() };
  let mut server = Server::new(opt).unwrap();
  let mut phases = server.watch_execution_phase();
  std::thread::Builder::new()
    .name("phase-watch".into())
    .spawn(move || {
      while let Ok(p) = phases.blocking_recv() {
        say(format!("PHASE {p:?}"));
      }
    })
    .unwrap();
  server.bootstrap();
  let mut proxy = http_proxy_service(&server.configuration, Proxy);
  proxy.add_tcp("127.0.0.1:18110");
  server.add_service(proxy);
  server.add_service(background_service("ticker", Ticker));
  server.run_forever();
}

Ticker 每秒打一行,同时 select! 等 ShutdownWatch 变化;Proxy 在 upstream_peer 和 logging 里打日志。配置文件:

---
version: 1
threads: 2
work_stealing: true
grace_period_seconds: 3
graceful_shutdown_timeout_seconds: 2
pid_file: ~/exp-pingora/intro-run/out/pingora.pid
upgrade_sock: ~/exp-pingora/intro-run/out/upgrade.sock

上游每个请求睡 2500 ms。客户端脚本按这个顺序动作:

  1. 第 1 秒:sample 抓一次线程;一个 Python 客户端连上代理,发一个请求,拿到响应后不关连接,一直读,读到 EOF 就打印「proxy closed the idle connection」。
  2. 约第 5.5 秒:后台发一个 curl /slow(要 2.5 秒)。
  3. 0.5 秒后:kill -TERM。
  4. 再 1 秒后:发一个新的 curl /new。

平台 M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0,tokio 1.53.2,debug,RUST_LOG=info(pingora 自己的日志也打出来)。

启动:配置、bootstrap、每服务一个运行时

    10ms [main] conf threads=2 work_stealing=true grace=Some(3) shutdown_timeout=Some(2)
    10ms [main] INFO pingora_core::server::bootstrap_services: Bootstrap starting
    10ms [main] INFO pingora_core::server::bootstrap_services: Bootstrap done
    10ms [phase-watch] PHASE Bootstrap
    10ms [phase-watch] PHASE BootstrapComplete
    24ms [main] pid 54540
    24ms [main] INFO pingora_core::server: Server starting
    24ms [main] INFO pingora_core::server: Starting services in dependency order: ["BG ticker", "Pingora HTTP Proxy Service"]
    24ms [main] INFO pingora_core::server: Starting service: BG ticker
    24ms [main] INFO pingora_core::server: Starting service: Pingora HTTP Proxy Service
    24ms [BG ticker] background start
    24ms [phase-watch] PHASE Running

Server::new 看到 Opt.conf 有值,就读这个 YAML;Server::new(None) 或 Opt 里没有 conf 时用 ServerConf 的默认值,其中和本页有关的几项是 threads: 1、work_stealing: true、grace_period_seconds: None、graceful_shutdown_timeout_seconds: None,pid_file 和 upgrade_sock 在 /tmp 下。配置项的全表见配置与命令行。

bootstrap 在这里几乎什么都没做:没有 -t,不用退出;没有 -u,load_fds 不去接收旧进程的监听 fd。平滑升级时这一步才有内容,见平滑升级与停机。

run 先对服务的依赖图做拓扑排序(两个服务之间没有声明依赖,日志里的顺序和 add_service 的顺序无关),然后对每个服务调 run_service:

        let service_runtime = Server::create_runtime(
            service.name(),
            threads,
            work_stealing,
            blocking_opts,
            runtime_opts,
        );
        let service_name = service.name().to_string();
        service_runtime.get_handle().spawn(async move {
            // ...
            service
                .start_service(
                    #[cfg(unix)]
                    Some(fds),
                    shutdown,
                    listeners_per_fd,
                    ready_notifier,
                )
                .await;
            info!("service '{}' exited.", service_name);
        });

运行时用服务名命名,线程数是服务自己的 threads(),没有就用配置里的 threads。代理服务没有覆盖,用配置的 2;background_service 建的后台服务名字前加 BG ,GenBackgroundService::new 把 threads 写死成 Some(1)。全部服务起来以后,run 再建一个叫 Server 的单线程工作窃取运行时,在主线程上 block_on main_loop,所以等信号、广播停机的日志都标着 [main]。

线程

第 1 秒时 sample <pid> 1 列出的线程:

    760 Thread_5404420   DispatchQueue_1: com.apple.main-thread  (serial)
    760 Thread_5404421: phase-watch
    760 Thread_5404422: BG ticker
    760 Thread_5404423: Pingora HTTP Proxy Service
    760 Thread_5404424: Pingora HTTP Proxy Service
    760 Thread_5404425: Server

phase-watch 是实验自己开的。其余五个:主线程;后台服务一个;代理服务两个(threads: 2);Server 运行时的一个工作线程。把配置换成 work_stealing: false 再跑一次,线程的名字和个数完全一样;用默认配置的 hooks(一次请求用的那个)只有三个:

Thread_5561008   DispatchQueue_1: com.apple.main-thread  (serial)
Thread_5561010: Pingora HTTP Proxy Service
Thread_5561011: Server

线程名来自 pingora-runtime:工作窃取时是 build_work_stealing_tokio_builder 给 tokio 多线程运行时设的 thread_name(&self.name);work_stealing: false 时是 NoStealRuntime::init_pools 自己 std::thread::Builder::new().name(self.name.clone()),每个线程里跑一个 tokio current-thread 运行时。从外面看两种一样,区别在任务能不能在线程之间挪:前者是 tokio 的工作窃取(tokio 卷:多线程调度器),后者 spawn 时随机挑一个线程,之后就钉在那里。默认是工作窃取。两者的取舍见运行时。

SIGTERM:同一毫秒里发生的事

把服务端日志(去掉每秒一行的 tick)和三个客户端的日志按时间排在一起:

  5052ms idle-client got HTTP/1.1 200 OK
  5558ms slow curl start
  5571ms [Pingora HTTP Proxy Service] upstream_peer /slow
  6063ms send SIGTERM
  6066ms [main] INFO pingora_core::server: SIGTERM received, gracefully exiting
  6066ms [main] INFO pingora_core::server: Broadcasting graceful shutdown
  6066ms [main] INFO pingora_core::server: Graceful shutdown started!
  6066ms [main] INFO pingora_core::server: Broadcast graceful shutdown complete
  6066ms [Pingora HTTP Proxy Service] INFO pingora_core::services::listening: Shutting down 127.0.0.1:18110
  6066ms [BG ticker] background sees shutdown=true
  6066ms [BG ticker] background exit
  6066ms [phase-watch] PHASE GracefulTerminate
  6066ms [Pingora HTTP Proxy Service] INFO pingora_core::server: service 'Pingora HTTP Proxy Service' exited.
  6066ms [main] INFO pingora_core::server: Graceful shutdown: grace period 3s starts
  6066ms [BG ticker] INFO pingora_core::server: service 'BG ticker' exited.
  6067ms [phase-watch] PHASE ShutdownStarted
  6067ms [phase-watch] PHASE ShutdownGracePeriod
  6067ms idle-client: proxy closed the idle connection
  7154ms new curl start
000 total=0.000846
curl exit=7
  8077ms [Pingora HTTP Proxy Service] logging /slow status=200 error=None
200 total=2.507253
  8197ms slow curl done
  9077ms [main] INFO pingora_core::server: Graceful shutdown: grace period ends
  9077ms [main] INFO pingora_core::server: Waiting for runtimes to exit!
  9077ms [phase-watch] PHASE ShutdownRuntimes
  9077ms [main] INFO pingora_core::server: Waiting for runtimes to exit!
  9077ms [main] INFO pingora_core::server: Waiting for service runtime BG ticker to exit
  9078ms [main] INFO pingora_core::server: Service runtime BG ticker exited after 533.625µs
  9078ms [main] INFO pingora_core::server: Waiting for service runtime Pingora HTTP Proxy Service to exit
  9078ms [main] INFO pingora_core::server: Service runtime Pingora HTTP Proxy Service exited after 475.958µs
  9078ms [main] INFO pingora_core::server: All runtimes exited, exiting now
  9078ms [phase-watch] PHASE Terminated

按发生的先后:

  1. 广播。 main_loop 从 UnixShutdownSignalWatch::recv 拿到 GracefulTerminate,往 shutdown_watch(一个 watch::Sender<bool>)里写 true。所有服务的 ShutdownWatch 都是它的接收端。
  2. 停 accept。 run_endpoint 的 select! 一边是 stack.accept(),一边是 shutdown.changed();后者先到,打印「Shutting down 127.0.0.1:18110」,跳出循环,之后 start_service 清理监听器。新连接从这时起直接被拒:第 7154 ms 的 curl 在 0.8 ms 内以 exit 7(连接被拒绝)结束。
  3. 「service exited」不等于连接都结束了。 start_service 等的只是每个监听地址的 accept 循环,它们一退出,start_service 就清理监听器、调 app_logic.cleanup(),然后返回,run_service 打印「service ... exited」。每个连接是 accept 循环 spawn 出去的独立任务,还在运行时里跑:/slow 这时正在等上游。
  4. 关空闲的 keep-alive 连接。 cleanup() 对 HttpProxy 来说就是 http_cleanup:置 shutdown_flag,再唤醒所有在 await_shutdown 上等的任务。空闲的 keep-alive 连接正停在 handle_new_request 里,select! 着「读下一个请求头」和 await_shutdown,后者醒来,返回 None,连接被关掉。Python 客户端在 1 ms 后读到 EOF。
  5. 后台服务自己退出。 Ticker 的 shutdown.changed() 醒来,跳出循环。后台服务退不退是它自己的事:不看 ShutdownWatch 的后台服务会一直跑到运行时被关掉。
  6. 宽限期。 回到主线程,run 在 main_loop 返回之后 thread::sleep(grace_period_seconds),这里是 3 秒。期间 /slow 在第 8077 ms 完成,logging 照常调用,curl 拿到 200。
  7. 关运行时。 宽限期结束,run 给每个运行时开一个线程调 shutdown_timeout(graceful_shutdown_timeout_seconds),主线程逐个 join。这时已经没有在途的请求,两个运行时都在 0.5 ms 左右退出,进程 exit(0)。

work_stealing: false 时同样的脚本,顺序和间隔一样(SIGTERM 在 5856 ms,宽限期结束 8866 ms,运行时 0.6 ms 内退出)。

📝NOTE
冷知识:停机曾经总要多睡一整个 shutdown_timeout第 7 步现在的写法是:
                let join = thread::spawn(move || {
                    let start = Instant::now();
                    rt.shutdown_timeout(shutdown_timeout);
                    start.elapsed()
                });

6f15714「Fix shutdown sleeping twice for graceful timeout」之前,这个线程在 rt.shutdown_timeout(shutdown_timeout) 之后还有一行 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.」也就是说,运行时就算立刻退了,进程也要再等满 graceful_shutdown_timeout_seconds(默认 5 秒)。修复从 #733 同步过来(提交里写着「Fixes #727」),首次发布于 0.9.0;CHANGELOG 记作「Remove the duplicate graceful-shutdown sleep」。上面日志里的「exited after 533.625µs」是之后的 0a40b7c「Add tests for graceful shutdown」加上的计时,同样在 0.9.0 里。

默认宽限期是 300 秒

上面的实验把宽限期配成了 3 秒。不配时用 EXIT_TIMEOUT,源码里写的是 60 * 5。用默认配置的 hooks 发一次 SIGTERM:

[2026-10-29T04:58:01Z INFO  pingora_core::server] SIGTERM received, gracefully exiting
[2026-10-29T04:58:01Z INFO  pingora_core::server] Broadcasting graceful shutdown
[2026-10-29T04:58:01Z INFO  pingora_core::server] Graceful shutdown started!
[2026-10-29T04:58:01Z INFO  pingora_core::server] Broadcast graceful shutdown complete
[2026-10-29T04:58:01Z INFO  pingora_core::server] Graceful shutdown: grace period 300s starts
[2026-10-29T04:58:01Z INFO  pingora_core::server] service 'Pingora HTTP Proxy Service' exited.

这 300 秒是无条件睡满的:上面第 6 步的 thread::sleep 不看还有没有在途请求。一个已经没有任何连接的代理,收到 SIGTERM 后照样要 5 分钟才退出,其间监听端口已经关了,不再处理任何新请求。想让它快点退,就要在配置里显式写 grace_period_seconds。上游文档 start_stop.md 只说「wait for some preconfigured time and then exit」,没有写默认值。

SIGINT 和 SIGQUIT

SIGINT(同一个 lifecycle,第 1.2 秒发):

  1235ms [main] INFO pingora_core::server: SIGINT received, exiting
  1235ms [main] INFO pingora_core::server: Waiting for runtimes to exit!
  1235ms [phase-watch] PHASE ShutdownStarted
  1236ms [main] INFO pingora_core::server: Waiting for runtimes to exit!
  1236ms [phase-watch] PHASE ShutdownRuntimes
  1236ms [main] INFO pingora_core::server: Waiting for service runtime BG ticker to exit
  1236ms [main] INFO pingora_core::server: Service runtime BG ticker exited after 14µs
  1236ms [main] INFO pingora_core::server: Waiting for service runtime Pingora HTTP Proxy Service to exit
  1236ms [main] INFO pingora_core::server: Service runtime Pingora HTTP Proxy Service exited after 14.375µs
  1236ms [main] INFO pingora_core::server: All runtimes exited, exiting now
  1236ms [phase-watch] PHASE Terminated

没有「Broadcasting」,没有「background sees shutdown」:SIGINT 分支直接返回 ShutdownType::Quick,不写 shutdown_watch,服务收不到任何通知;shutdown_timeout 是 0,运行时里的任务在下一个 .await 处被丢弃。

SIGQUIT 在 macOS 上的输出:

  1077ms [main] INFO pingora_core::server: SIGQUIT received, sending socks and gracefully exiting
  1077ms [main] INFO pingora_core::server: Trying to send socks
  1077ms [phase-watch] PHASE GracefulUpgradeTransferringFds
  1078ms [main] INFO pingora_core::server: listener sockets sent
  1078ms [phase-watch] PHASE GracefulUpgradeCloseTimeout
  6081ms [main] INFO pingora_core::server: Broadcasting graceful shutdown
...
  9090ms [phase-watch] PHASE Terminated

「listener sockets sent」是假的:非 Linux 平台上的 send_fds_to 什么都不发,直接返回 Ok(0),main_loop 只看是不是 Ok。之后照常等 CLOSE_TIMEOUT(5 秒,给新进程接手的时间),再走和 SIGTERM 一样的广播和宽限期,只是不发 GracefulTerminate 这个阶段。新进程那一边,-u 启动时 get_fds_from 在非 Linux 上记一条「Upgrade is not currently supported outside of Linux platforms」并返回错误,bootstrap 随即 exit(1)。Linux 上真正的交接过程见平滑升级与停机。

❗IMPORTANT
动脑筋:在途请求比宽限期长会怎样?

同一个 lifecycle、同一份配置(宽限期 3 秒、graceful_shutdown_timeout_seconds: 2),上游改成每个请求睡 6 秒。SIGTERM 前约 0.5 秒发出的 /slow,curl 在 SIGTERM 之后多久结束、拿到什么?logging 会不会为它调用一次?

动脑筋答案

客户端:

   5262ms slow curl start
000 total=3.516278
curl exit=52
   8864ms slow curl done
   5778ms send SIGTERM

服务端(去掉 tick,从 SIGTERM 起):

  5781ms [main] INFO pingora_core::server: SIGTERM received, gracefully exiting
...
  5781ms [main] INFO pingora_core::server: Graceful shutdown: grace period 3s starts
...
  8261ms [Pingora HTTP Proxy Service] logging /idle status=200 error=None
  8788ms [main] INFO pingora_core::server: Graceful shutdown: grace period ends
  8788ms [main] INFO pingora_core::server: Waiting for runtimes to exit!
  8788ms [phase-watch] PHASE ShutdownRuntimes
...
  8789ms [main] INFO pingora_core::server: Service runtime Pingora HTTP Proxy Service exited after 982.417µs
  8789ms [main] INFO pingora_core::server: All runtimes exited, exiting now

上游:

UPSTREAM conn error: connection closed before message completed
  • 宽限期一到就断,不再等 2 秒。 curl 在 SIGTERM 之后约 3.0 秒(8788 ms 前后)结束,exit 52(服务器没有回任何字节就关了连接),不是 3 + 2 = 5 秒。graceful_shutdown_timeout_seconds 传给的是 tokio 的 Runtime::shutdown_timeout,它不给任务额外的时间跑完。tokio 的文档写的是:spawn 出来的任务「keep running until they yield. Then they are dropped」,这个时长只是发起关闭的线程最多等多久(等的主要是阻塞线程池里还没返回的工作)。/slow 正停在等上游响应的 .await 上,立刻被丢弃,代理那个运行时在 0.98 ms 内就退出了。
  • logging 没有为 /slow 调用。 任务是在 .await 处被丢弃的,process_request 后半段(包括 finish 里的 logging)根本没有机会执行。日志里只有 /idle 的 logging:这次 /idle 也要 6 秒,它在宽限期内(8261 ms)完成了,响应写完后连接被立刻关闭,因为 shutdown_flag 已经置上,下一轮 handle_new_request 直接返回。
  • 上游看到的是半截请求。 代理到上游的那条连接也随任务一起被 drop,hyper 写的上游报「connection closed before message completed」。

所以 grace_period_seconds 才是在途请求真正的时限;靠 logging 记账、计费的服务,要么把它配得比最长的请求还长,要么接受停机时会丢掉最后几条记录。

小结

  • Server::new 读 -c 指定的 YAML,否则用默认值(threads: 1、work_stealing: true、宽限期 None 即 300 秒);bootstrap 只在 -t / -u 时有实际动作。
  • run 按依赖拓扑序给每个服务建一个以服务名命名的运行时;后台服务固定 1 个线程;另有一个叫 Server 的运行时,main_loop 在主线程上 block_on。默认配置的代理进程只有三个线程:main、Pingora HTTP Proxy Service、Server。
  • SIGTERM:同一毫秒里广播、accept 循环退出(新连接被拒)、http_cleanup 关掉空闲 keep-alive 连接、看 ShutdownWatch 的后台服务退出;在途请求在宽限期内照常完成;宽限期结束后 shutdown_timeout 丢弃剩下的任务。
  • 宽限期是无条件睡满的,默认 300 秒;超过宽限期的请求被切断,下游 exit 52,logging 不调,上游看到半截请求。
  • SIGINT 不通知服务,立刻关运行时;SIGQUIT 在 macOS 上「listener sockets sent」是假的,传 fd 只在 Linux 上实现。
  • 0.9.0 修掉了停机时多睡一个 shutdown_timeout 的问题(6f15714,#733)。

💬 关联讨论

发起讨论
加载中...