Rust in Everything
基于 pingora-core 0.9.0

配置与命令行

主线位置:服务主线的第一步和第二步。Server::new 读命令行和配置文件,得到一份 ServerConf;bootstrap 处理 -t 和 -u。之后的 add_service 和 run_forever 只读这份配置,不再改它。守护进程化不在 bootstrap 里,而在 run 的第一步,见平滑升级与停机。

flowchart TB
  opt["Opt 命令行"] --> q1{"传了 Opt?"}
  q1 -->|"None"| d1["ServerConf new"]
  q1 -->|"有"| q2{"有 -c?"}
  q2 -->|"无"| d2["new 再合并 -d"]
  q2 -->|"有"| y["读 YAML 再合并 -d"]
  d1 --> conf["Arc ServerConf"]
  d2 --> conf
  y --> conf
  conf --> boot["bootstrap"]
  boot -->|"-t"| exit0["exit 0"]
  boot -->|"-u"| fds["从旧进程收 fd"]

代码在 pingora-core/src/server/configuration/mod.rs(748 行,一半是测试)和 server/mod.rs(896 行)里 Server::new、new_with_opt_and_conf 两个构造函数,bootstrap 本体在 server/bootstrap_services.rs(343 行)。实验 crate 是 exp-pingora/server-lab(M2 Max、macOS 27、rustc 1.99.0,release,tokio 1.53.2)。

三个入口,三种默认值

Server::new 按「有没有 Opt」「Opt 里有没有 -c」分三路:

        let conf = if let Some(opt) = opt.as_ref() {
            opt.conf.as_ref().map_or_else(
                || {
                    // options, no conf, generated
                    ServerConf::new_with_opt_override(opt).ok_or_else(|| {
                        Error::explain(ErrorType::ReadError, "Conf generation failed")
                    })
                },
                |_| {
                    // options and conf loaded
                    ServerConf::load_yaml_with_opt_override(opt)
                },
            )
        } else {
            ServerConf::new()
                .ok_or_else(|| Error::explain(ErrorType::ReadError, "Conf generation failed"))
        }?;
  • ServerConf::new 不是直接调 Default,而是解析一段只有 version: 1 的 YAML。结构体上有 #[serde(default)],没写的字段取 Default::default(),所以它和 Default 只差 version:一个是 1,一个是 0。
  • merge_with_opt 只合并一个开关:命令行有 -d 就把 daemon 置成 true。-u 和 -t 不进 ServerConf,留在 Server::options 里给 bootstrap 用。
  • 第三个入口 new_with_opt_and_conf 给自己解析配置的程序用:调用方直接给一份 ServerConf,Opt 里的 -c 只打一条 warn,被忽略。

实验 conf(src/bin/conf.rs)把这几条路都走了一遍:

Default::default().version = 0
ServerConf::new().version  = 1
default == new except version: true
...
unknown key ignored, threads = 4
bad type:  ReadError context: Unable to parse yaml conf ---
version: 1
threads: four
 cause: threads: invalid type: string "four", expected usize at line 3 column 10
validate:  ReadError context: max_blocking_threads must be greater than zero
missing conf:  ReadError context: Unable to read conf file from /nonexistent/pingora.yaml cause: No such file or directory (os error 2)
with -d -u: conf.daemon=true opt.upgrade=true
new_with_opt_and_conf: threads=3 version=0

version 在整个仓库里没有任何代码读它,写 0、1 还是 2 都一样。未知的键(my_app_key)被 serde 忽略,这是 ServerConf 文档注释里说的扩展方式:同一个 YAML 里放自己的键,再用自己的结构体解析一遍。类型错了、文件不存在,Server::new 返回 ReadError,错误信息把整段 YAML 原样带出来。

📝NOTE
没有蠢问题

问: 我的 main 写的是 Server::new(None),启动时加了 -c /etc/app.yaml,为什么配置没生效?

答: None 走的是第三路 ServerConf::new(),根本不看命令行。命令行要靠 Opt::parse_args 解析出来再传进去:Server::new(Some(Opt::parse_args()))。Opt 是一个 clap 的 Parser,-c 写错文件名会在 Server::new 返回错误;但如果根本没调 parse_args,-c 和任何别的参数都不会报错,因为没人读 std::env::args。

Opt:四个开关

Opt 有 -u/--upgrade、-d/--daemon、-t/--test、-c/--conf,外加一个隐藏的 --nocapture(注释说是为了让 cargo test 传进来的参数不报错)。svc --help 的真实输出:

Usage: svc [OPTIONS]

Options:
  -u, --upgrade      This is the base set of command line arguments for a pingora-based service
  -d, --daemon       Whether this server should run in the background
  -t, --test         This flag is useful for upgrading service where the user wants to make sure the new service can start before shutting down the old server process.
  -c, --conf <CONF>  The path to the configuration file.
  -h, --help         Print help

-u 的帮助文字是「This is the base set of command line arguments...」:这句本该是整个命令的描述,被 #[clap(help = ...)] 写到了 upgrade 字段上。想用 pingora 默认命令行的程序,--help 里 -u 的说明就是这一句。docs/user_guide/start_stop.md 的参数表写 -c 的默认值是「empty string」,代码里是 Option<String>,没给就是 None。

字段表:默认值和谁读它

ServerConf 的 Default 给出所有默认值;不少字段是 Option,None 的含义要到读它的地方找。下表按「谁读」整理,「读它的代码」一列都在 tag 0.9.0 上 git grep 过:

字段默认None 时实际用读它的代码
version0(new() 是 1)—无
daemonfalse—Server::run 开头 daemonize
pid_file/tmp/pingora.pid—daemonize
error_logNonestderrdaemonize(只在守护进程时用)
user / group / working_directoryNone不切换daemonize
upgrade_sock/tmp/pingora_upgrade.sock—Bootstrap::new、main_loop 的 SIGQUIT 分支
threads1—run:服务自己的 threads() 优先;连接池容量
listener_tasks_per_fd1—run_service → start_service
work_stealingtrue—create_runtime
runtime_enable_alt_timerfalse—runtime_opts,只对 Steal 生效,要 tokio_unstable
grace_period_secondsNone300(EXIT_TIMEOUT)run 的停机段
graceful_shutdown_timeout_secondsNone5run 的停机段
max_blocking_threads / blocking_threads_ttl_secondsNonetokio 的 512 / 10 秒BlockingPoolOpts
fast_timeout_to_tokio_threshold_secondsSome(900)写 null 关掉回退fast_timeout::set_fast_timeout_to_tokio_threshold
runtime_metrics_poll_time_histogram*false / None—runtime_opts,要 tokio_unstable
upstream_keepalive_pool_size128—ConnectorOptions::from_server_conf,乘以 threads
client_bind_to_ipv4 / _ipv6空—ConnectorOptions
ca_fileNoneTLS 库默认信任库各 TLS 后端的连接器
upstream_connect_offload_*None不开ConnectorOptions,两个都大于 0 才开
downstream_tls_offload_*None不开TlsSettings::set_offload_threadpool_from_server_conf,要调用方自己调
upstream_debug_ssl_keylogfalse—ConnectorOptions
max_retries16—pingora-proxy 的 HttpProxy::new
upgrade_sock_connect_accept_max_retriesNone5(MAX_RETRY)无
daemon_wait_for_readyfalse—daemonize
daemon_ready_timeout_secondsNone600 秒daemonize
daemon_notify_timeout_secondsNone60 秒Bootstrap::new

几条要单独说的:

  • work_stealing 默认是 true。 不写配置时,每个服务跑在 tokio 的多线程(工作窃取)运行时上;NoSteal 要显式写 work_stealing: false。这个默认值从 0.1.0 起没变过。两种运行时的区别见运行时。
  • threads 是「每个服务」的线程数,服务之间不共享。GenBackgroundService 自己把 threads 设成 Some(1),所以后台服务不管配置写多少都是 1 个线程。代理服务里,ConnectorOptions::from_server_conf 把上游连接池容量算成 upstream_keepalive_pool_size × threads。
  • 配置在 http_proxy_service(&server.configuration, ...) 那一刻被读走。 HttpProxy::new 从这份 Arc<ServerConf> 里取 max_retries、threads,建连接器。Server 的 configuration 字段是 pub 的 Arc,之后再换掉它,已经拿走这份 Arc 的服务看不到;要改配置,应该在建服务之前用 new_with_opt_and_conf 传进去。
  • validate 只查四个数:max_blocking_threads 和两个直方图参数不能是 0,桶数不超过 1024。threads: 0 能通过校验,见本页的动脑筋。
  • docs/user_guide/conf.md 的表格漏了 grace_period_seconds、graceful_shutdown_timeout_seconds、listener_tasks_per_fd、max_retries、max_blocking_threads、blocking_threads_ttl_seconds、upstream_debug_ssl_keylog 和 upgrade_sock_connect_accept_max_retries;这些字段只能从源码里查。
📝NOTE
冷知识:upgrade_sock_connect_accept_max_retries 从加进来那天起就没人读

de4cc62(2026-01-08,「Add configurable retries for upgrade sock connect/accept」,首次进入 0.7.0,没有 PR 编号)做了两件事:给 ServerConf 加了这个字段,注释说它控制 send_fds_to 重试连接、get_fds_from 重试 accept 的次数;给这两个函数加了一个 max_retry: Option<usize> 参数,None 时用原来的常量 MAX_RETRY = 5。但同一个提交里,唯一的调用方 Fds::send_to_sock 和 get_from_sock 传的都是 None,配置值没有一条路径传进去。到 0.9.0 和 0.9.0 之后的 main(84f21ee)都还是这样。CHANGELOG 0.7.0 一节写着「Add configurable retries for upgrade sock connect/accept」,按字面理解会以为配了就生效;实际上平滑升级时两边仍然各重试 5 次、每次间隔 1 秒。

bootstrap 做了什么

Server::bootstrap 只是锁住 Bootstrap 调 Bootstrap::bootstrap:

    pub fn bootstrap(&mut self) {
        // already bootstrapped
        if self.completed {
            return;
        }
        // ...
        if self.test {
            info!("Server Test passed, exiting");
            std::process::exit(0);
        }
        // ...
        #[cfg(unix)]
        if let Some(pid) = self.notify_parent_pid {
            notify_parent_ready_for_fds(pid, self.daemon_notify_timeout);
        }

        #[cfg(unix)]
        match self.load_fds(self.upgrade) {
            Ok(_) => {
                info!("Bootstrap done");
            }
            Err(e) => {
                // ...
                error!("Bootstrap failed on error: {:?}, exiting.", e);
                std::process::exit(1);
            }
        }
        // ...
    }

按顺序是三件事:

  1. -t 到此为止。 进程以 0 退出。所以 -t 能检查的只是 Server::new 能不能读通配置、bootstrap 之前的代码能不能跑通;监听端口能不能绑上、服务能不能启动,它不检查。svc -c conf/grace.yaml -t 的输出最后一行是 Server Test passed, exiting。
  2. 通知守护进程的父进程(只在 daemon_wait_for_ready 时有 pid),见平滑升级与停机。
  3. -u 时从旧进程收监听 fd。 load_fds 调 Fds::get_from_sock(upgrade_sock),收到的 fd 写进一张所有服务共享的表(ListenFds)。没有 -u 时什么都不做,表是空的,各服务自己 bind。收失败直接 exit(1),macOS 上一定走这条路,见平滑升级与停机。

还有一个变体 bootstrap_as_a_service:把同样的逻辑包成一个后台服务,放进依赖图,让它等别的服务就绪后再收 fd。0.9.0 之前这条路有一个 bug:服务在 bootstrap 运行之前就拿到了 fd 表的快照,永远是空的,b633683(「Fix listen fds not inherited during bootstrap_as_a_service graceful upgrade」)把表改成在 Bootstrap::new 里预先分配、收到 fd 后原地填充,首次进入 0.9.0。

守护进程化不在 bootstrap 里,0.1.0 起 daemonize 就一直在 run_forever/run 的开头。docs/user_guide/daemon.md 写的是「This process happens in the run_forever() call」,和代码一致。

❗IMPORTANT
动脑筋:threads: 0 会在哪一步出错?

配置文件写 threads: 0,work_stealing 分别取 true 和 false。ServerConf::from_yaml 会拒绝它吗?如果不拒绝,进程在哪一步、以什么信息失败?

动脑筋答案

from_yaml 不拒绝:validate 不检查 threads。两种运行时都在 run 里建运行时时 panic,位置不同(release):

== threads 0 work_stealing true

thread 'main' (5579065) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/pingora-runtime-0.9.0/src/lib.rs:471:14:
Worker threads cannot be set to 0
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
== threads 0 work_stealing false

thread 'main' (5579073) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/pingora-runtime-0.9.0/src/lib.rs:611:9:
assertion failed: threads != 0
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
  • Steal:pingora-runtime 第 471 行是 .worker_threads(self.threads),panic 文字来自 tokio 的 Builder::worker_threads。
  • NoSteal:第 611 行是 NoStealRuntime::new 里的 assert!(threads != 0)。
  • 两次都在 main 线程,发生在 bootstrap 之后,所以 -t 发现不了这个错误。

小结

  • Server::new(None) 不看命令行;要用 -c/-d/-u/-t 必须传 Some(Opt::parse_args())。
  • 命令行只有 -d 合并进 ServerConf;-u、-t 留在 Opt 里给 bootstrap。
  • 默认 threads: 1、work_stealing: true、宽限期 300 秒、运行时停机超时 5 秒。
  • version 和 upgrade_sock_connect_accept_max_retries 没有代码读;validate 不查 threads: 0。
  • bootstrap 只做两件事:-t 退出,-u 从旧进程收监听 fd;守护进程化在 run 里。

💬 关联讨论

发起讨论
加载中...