Prhub

#39015 [Router] Add the shutdown-drain configuration surface

原始 PR 作者 Kangyan-Zhou 合并时间 2026-09-16 15:26 文件变更 23 提交数 1 评论 0 代码增减 +347 / -12

执行摘要

为 Router 新增优雅关闭排空配置面与启动校验、告警

PR body 讲得很直白:「Adds the two flags a SIGTERM readiness drain needs, plus the checks that keep them honest.」要解决的问题是:router 收到 SIGTERM 后如果立刻关闭 listener,k8s 的 endpoint 摘除还没到达 kube-proxy,新请求仍会被打到正在关闭的 pod 上;而探针驱动的负载均衡器要等 failureThreshold * periodSeconds 次 /readyz 失败才会摘除,同样需要一段「继续服务但已 503」的窗口。第二个 flag 解决的是信息不对称:router 读不到自己的 pod spec,缺少真实 terminationGracePeriodSeconds 时只能对照 k8s 默认值比较,会把一个故意设长的正确 drain 误报为不安全,因此需要 --termination-grace-secs 让运维声明真实预算。作者同时强调本 PR 不改变运行行为:「Nothing reads shutdown_drain_secs yet — the shutdown path lands in the next PR in the stack — so this changes no runtime behaviour beyond one startup validation and one startup warning.」

值得精读,但只推荐给关心 router 部署形态与滚动升级语义的人(k8s / 平台 / Router owner);对模型、内核方向的工程师,读执行摘要即可,因为本 PR 没有任何运行期行为变化。精读时建议关注四个设计决策:① shutdown_drain_advisory 的 >= 边界与 grace_declared 字段(前者决定「drain 正好等于 grace」是否告警,后者决定同一告警是否能被日志聚合去重);② 把秒 → Duration 的转换从 main.rs 收进 ServerConfig::shutdown_drain() 的理由;③ 软 advisory 与硬 MAX_SHUTDOWN_DRAIN_SECS 的职责切分,以及它留下的「drain > grace 仍可启动」空隙是否可接受;④ Default impl(服务测试)与穷举 into_config(约束生产)并存这一对取舍。同时把 #39016 一起拉出来读,才能真正判断这套配置面是否够用。

讨论亮点

本 PR 的 review 信号极弱:comments_count = 0、review_comments_count = 0,唯一评审记录是 sherlockwu 一次无正文的 APPROVED。因此不存在真正的技术交锋,下面这些「看点」全部来自作者在 PR body 与代码注释中的自述,属于单方面论证而非经过对抗性审查的结论:

「the default equals the k8s default terminationGracePeriodSeconds, so a pod that has not raised its grace period has nothing left for the in-flight drain and shutdown_drain_advisory warns at every startup. That is the intended reading rather than a misconfigured default」

「Whether a given drain fits a given grace period is the advisory's job, because the operator can raise the budget; the ceiling is the separate hard gate for a value that is not a drain at all」

「a message carrying the configured seconds cannot be grouped or deduped by a log aggregator」

「where a from_secs/from_millis slip would silently shorten every drain by a factor of 1000 with nothing to fail」

关键缺口是:「默认 drain 等于默认 grace,因此每个未声明 grace 的部署每次启动都会告警」这一会影响所有部署的取舍,没有任何 reviewer 给出独立意见;同样,1800 s 上限与 >= 边界的取值也没有第三方复核。

实现拆解

  1. 配置类型(src/config/types.rs,+59):ServerConfig 新增 shutdown_drain_secs: u64 与 termination_grace_secs: Option<u64>;新增 ServerConfig::shutdown_drain() 把秒 → Duration 的转换收进库内(注释指出放在 main.rs 调用点会有 from_secs / from_millis 手误,静默缩短 1000 倍且没有任何东西会失败);抽出 default_host() / default_port() / default_shutdown_drain_secs() 并实现 Default for ServerConfig,因为 tests/ 是独立 crate,#[cfg(test)] 构造器够不到集成测试 fixture。
  2. CLI 入口(src/config/cli.rs,+94/-8):新增 --shutdown-drain-secs(default_value_t = default_shutdown_drain_secs(),即 30)与 --termination-grace-secs(Option<u64>,无默认,None 表示「假设 k8s 默认」);host / port 的默认值也改走 default_*() 函数,避免默认值出现第二处定义。Cli::into_config 填充两个新字段并保持穷举,使未来加字段仍在生产路径上强制做出决定。help 文本写清 failureThreshold * periodSeconds 的探针场景与「0 表示关闭暂停」语义。
  3. 启动校验与告警(src/config/mod.rs,+174/-4):新增常量 K8S_DEFAULT_GRACE_SECS = 30、MAX_SHUTDOWN_DRAIN_SECS = 1800,结构体 ShutdownDrainAdvisory { shutdown_drain_secs, termination_grace_secs, grace_declared } 与纯函数 shutdown_drain_advisory()(边界取 >=,None 视为假设默认值)。Config::validate 增加唯一一条硬闸门:shutdown_drain_secs > 1800 直接拒启,错误文本点名 flag 并给出补救动作(声明 --termination-grace-secs)。advisory 返回字段而非拼好的字符串,便于日志聚合分组与去重。
  4. 测试配套:config/mod.rs 新增 5 个单测(低于默认 grace 静默、默认 drain 必然告警且调高 grace 可静默、>= 边界与上限值都告警、声明 grace 的双向行为与 grace_declared、上限可启动 / 超一被拒);config/cli.rs 新增 3 个单测(多值透传且 shutdown_drain() 与秒一致、超上限在 CLI 路径被拒、--termination-grace-secs 映射且缺省为 None)。
  5. fixture plumbing(23 个文件中的 15+ 个):src/policies/factory.rs、src/server/app_context.rs::stub、src/tokenizer/mod.rs、src/workers/manager.rs 以及 tests/component/**、tests/proxy/** 中的 ServerConfig 字面量统一补 ..Default::default(),与保持 into_config 穷举构成一对取舍:生产路径强制决定,测试路径允许省略。
  6. 明确未做的部分:没有任何代码读取 shutdown_drain_secs,pause、/readyz 503 与在途 drain 的执行、k8s e2e 证明都在 #39016(原 #39017 已折入)。
文件 模块 状态 重要度
experimental/sgl-router/src/config/mod.rs 配置校验 modified 8.94
experimental/sgl-router/src/config/types.rs 服务配置 modified 8.36
experimental/sgl-router/src/config/cli.rs 命令行参数 modified 8.04
experimental/sgl-router/tests/proxy/graceful_shutdown.rs 优雅关闭 modified 3.72
experimental/sgl-router/src/server/app_context.rs 服务上下文 modified 3.99
experimental/sgl-router/tests/proxy/pd_protocol_binding.rs 测试夹具 modified 3.32

关键符号

shutdown_drain_advisory ServerConfig::shutdown_drain Config::validate Cli::into_config ServerConfig::default default_shutdown_drain_secs default_host default_port

关键源码片段

experimental/sgl-router/src/config/mod.rs core-logic

语义核心:新增 `K8S_DEFAULT_GRACE_SECS`、`MAX_SHUTDOWN_DRAIN_SECS`、`ShutdownDrainAdvisory`、`shutdown_drain_advisory()`,并在 `Config::validate` 中加入唯一一条硬校验(超过 1800 s 拒启);同时承载 5 个把边界语义钉死的单测。

/// k8s 默认的 `terminationGracePeriodSeconds`。运维没有声明真实值时按它假设:
/// `shutdown_drain_secs` 大于等于 grace period 就没给随后的在途 drain 留时间,
/// pod 会在 drain 跑完前被 SIGKILL —— 这正是 drain 想避免的结果。
pub const K8S_DEFAULT_GRACE_SECS: u64 = 30;
​
/// `shutdown_drain_secs` 的上限,由 [`Config::validate`] 强制。它按工作负载定尺寸,
/// 而不是按 k8s 默认的 grace period:一条流式 completion 可以让 router 忙上数分钟,
/// 因此不想被 termination 打断的部署会把 `terminationGracePeriodSeconds` 设到
/// 数十分钟,并配上等长的 drain。判断某个 drain 是否适配某个 grace period 是
/// [`shutdown_drain_advisory`] 的职责 —— 那只是建议,因为运维可以调高预算;
/// 这个常量是另一道更硬的闸门,用来拒绝「根本不是一个 drain」的值(多打一位数字,
/// 或者把秒和毫秒搞混),这些值任何 grace period 都救不回来。
pub const MAX_SHUTDOWN_DRAIN_SECS: u64 = 1800;
​
/// 一次 `shutdown_drain_secs` 在 grace period 下没给在途 drain 留空间时的告警载荷。
/// 被比较的两个值作为字段携带,调用方因此可以打静态消息 + 结构化字段,而不是把数字
/// 插值进消息文本里(那样日志聚合无法分组、去重)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShutdownDrainAdvisory {
    pub shutdown_drain_secs: u64,
    /// drain 被拿来比较的预算。
    pub termination_grace_secs: u64,
    /// 该预算来自运维声明,还是来自 [`K8S_DEFAULT_GRACE_SECS`]:
    /// 假设出来的预算让告警只是猜测,声明过的预算才让它成为事实。
    pub grace_declared: bool,
}
​
/// 对「drain 没给在途 drain 留空间」给出建议。注意这不是硬错误:drain 可能本来就是对的,
/// 该调高的是 grace period。边界取 `>=` 而不是 `>`,因为 drain 正好等于 grace period 时,
/// grace period 已经被它全部吃掉。
pub fn shutdown_drain_advisory(
    shutdown_drain_secs: u64,
    termination_grace_secs: Option<u64>,
) -> Option<ShutdownDrainAdvisory> {
    // router 读不到自己的 pod spec,所以 `None` 只能退回 k8s 默认值;
    // 这也解释了为什么「声明真实 grace period」才是让告警安静的正确方式,
    // 而不是把一个本来正确的 drain 调小。
    let grace = termination_grace_secs.unwrap_or(K8S_DEFAULT_GRACE_SECS);
    (shutdown_drain_secs >= grace).then_some(ShutdownDrainAdvisory {
        shutdown_drain_secs,
        termination_grace_secs: grace,
        grace_declared: termination_grace_secs.is_some(),
    })
}
​
impl Config {
    /// 校验类型系统和 `clap` 兜不住的不变量;由 [`cli::Cli::into_config`]
    /// 在组装完 `Config` 之后调用。
    pub(crate) fn validate(&self) -> Result<()> {
        // …… model id、bucket config、sampling overrides 的既有校验略 ……
        // 本 PR 新增的唯一硬闸门:超过上限直接拒绝启动,因为那样的值会让之后
        // 每一次 termination 都变成 SIGKILL;错误文本同时点名 flag 与补救动作。
        if self.server.shutdown_drain_secs > MAX_SHUTDOWN_DRAIN_SECS {
            return Err(anyhow!(
                "shutdown_drain_secs must be at most {MAX_SHUTDOWN_DRAIN_SECS} (got {}); \
                 past the ceiling a value is a typo rather than a drain, and the pod \
                 would be SIGKILLed long before the pause elapsed. A long but deliberate \
                 drain is fine — declare --termination-grace-secs so startup can check \
                 it against the pod's real budget",
                self.server.shutdown_drain_secs,
            ));
        }
        // …… discovery、proxy、active_load 的既有校验略 ……
        Ok(())
    }
}
experimental/sgl-router/src/config/types.rs data-contract

定义配置契约:`ServerConfig` 新增 `shutdown_drain_secs` 与 `termination_grace_secs`,新增 `shutdown_drain()` 把秒 → `Duration` 的转换固定进库内,并抽出 `default_host()` / `default_port()` / `default_shutdown_drain_secs()` 与 `Default` impl,供独立 crate 的测试 fixture 使用。

#[derive(Debug, Clone)]
pub struct ServerConfig {
    pub host: String,
    pub port: u16,
    /// SIGTERM 之后继续服务(同时 `/readyz` 翻成 503)多少秒,然后 HTTP server
    /// 停止 accept。默认值同时覆盖两条摘除路径:pod 打上 `deletionTimestamp` 之后
    /// 端点摘除到达 kube-proxy,以及由探针驱动的负载均衡器(它必须等到
    /// `failureThreshold * periodSeconds` 次 `/readyz` 失败才会动作)。
    ///
    /// 注意它等于 k8s 默认的 `terminationGracePeriodSeconds`,所以没有调高 grace
    /// period 的 pod 在暂停之后什么都没留给在途 drain,启动时会一直告警。这是有意
    /// 为之而不是默认值配错:completion 会流式跑数分钟的 router 本来就无法在 30 s
    /// 内干净退出,该调高的是 grace period。0 表示关闭暂停。
    pub shutdown_drain_secs: u64,
    /// pod 真实的 `terminationGracePeriodSeconds`,由运维声明。router 读不到自己的
    /// pod spec,所以没有这个值时启动检查只能对着 k8s 默认值比较 —— 对一个故意调高
    /// grace period 的部署会误报。`None` 表示「按默认值假设」。
    pub termination_grace_secs: Option<u64>,
}
​
impl ServerConfig {
    /// 把 [`Self::shutdown_drain_secs`] 转成 `Duration`。转换放在库里(有测试钉住),
    /// 而不是放在 `main.rs` 的调用点:那里一个 `from_secs` / `from_millis` 的手误
    /// 会把每次 drain 静默缩短 1000 倍,而且没有任何东西会失败。
    pub fn shutdown_drain(&self) -> std::time::Duration {
        std::time::Duration::from_secs(self.shutdown_drain_secs)
    }
}
​
pub fn default_host() -> String {
    "127.0.0.1".into()
}
​
pub fn default_port() -> u16 {
    30000
}
​
pub fn default_shutdown_drain_secs() -> u64 {
    30
}
​
/// 存在的理由:让测试 fixture 只写出自己关心的字段(`tests/` 是独立 crate,
/// `#[cfg(test)]` 构造器够不到集成测试 fixture)。`Cli::into_config` 保持穷举,
/// 这样将来新增字段时,生产路径上仍然被迫做出决定。
impl Default for ServerConfig {
    fn default() -> Self {
        Self {
            host: default_host(),
            port: default_port(),
            shutdown_drain_secs: default_shutdown_drain_secs(),
            termination_grace_secs: None,
        }
    }
}
experimental/sgl-router/src/config/cli.rs entrypoint

用户可见的配置入口:新增两个 flag 并接入 `Cli::into_config`,`host` / `port` 的默认值同时改走 `default_*()` 函数以消除默认值重复定义;help 文本承载了 `failureThreshold * periodSeconds`、`0 关闭暂停 `、需要声明真实 grace period 等运维语义。

pub struct Cli {
    // ---- server ----
    /// HTTP server 绑定的地址。
    #[arg(long, default_value_t = default_host())]
    pub host: String,
    /// HTTP server 绑定的端口。
    #[arg(long, default_value_t = default_port())]
    pub port: u16,
    /// SIGTERM 之后、server 停止 accept 之前继续服务多少秒(其间 `/readyz` 返回
    /// 503),好让端点摘除先到达 kube-proxy。要给随后的在途 drain 在
    /// terminationGracePeriodSeconds 之下留出空间。如果你依赖 readiness 探针
    /// (而不是 pod 删除)来摘除,就要把它设得大于 failureThreshold * periodSeconds。
    /// 默认值等于 k8s 默认的 terminationGracePeriodSeconds,因此在没有调高 grace
    /// period 的 pod 上启动会一直告警 —— 用 --termination-grace-secs 声明真实预算,
    /// 让检查用上它。0 表示关闭暂停。
    #[arg(long, default_value_t = default_shutdown_drain_secs())]
    pub shutdown_drain_secs: u64,
    /// pod 的 terminationGracePeriodSeconds(如果你把它从 k8s 默认的 30 调高了)。
    /// 只用于启动时检查 --shutdown-drain-secs 是否为在途 drain 留出空间:router
    /// 读不到自己的 pod spec,没有它就只能对着默认值告警,而一个故意设长的 drain
    /// 没有办法说明自己是安全的。
    #[arg(long)]
    pub termination_grace_secs: Option<u64>,
​
    // ---- model(恰好一个)----
    // …… 其余 flag 略 ……
}
​
impl Cli {
    /// 把 flag 解析成一份经过校验的 [`Config`]。
    pub fn into_config(&self) -> Result<Config> {
        // …… discovery、policy、bucket、sampling 的既有解析略 ……
        let config = Config {
            server: ServerConfig {
                host: self.host.clone(),
                port: self.port,
                // 两个新字段在这里落地;保持字段穷举,未来加字段仍会编译报错,
                // 从而强制在生产路径上做出决定。
                shutdown_drain_secs: self.shutdown_drain_secs,
                termination_grace_secs: self.termination_grace_secs,
            },
            // …… observability / model / discovery / proxy / active_load 略 ……
        };
        // 上限硬闸门在这里触发:超过 MAX_SHUTDOWN_DRAIN_SECS 直接拒绝启动。
        config.validate()?;
        Ok(config)
    }
}
​
#[cfg(test)]
mod tests {
    /// 多个取值而不是只有默认值:任何 clamp 或缩放都能骗过单值断言。
    #[test]
    fn shutdown_drain_secs_maps_into_config() {
        for secs in ["0", "17", "1800"] {
            // …… 解析 --shutdown-drain-secs 并断言原值透传 ……
            // 同时断言 Duration accessor 与配置的秒数一致。
        }
    }
}

评论区精华

advisory(软)与 ceiling(硬)的职责划分 设计

作者在 PR body 与 `config/mod.rs` 注释中明确:drain 是否适配 grace period 由 `shutdown_drain_advisory` 给建议(因为运维可以调高 grace period),`MAX_SHUTDOWN_DRAIN_SECS = 1800` 只负责拒绝「根本不是 drain」的值(多打一位数字或把秒与毫秒搞混);比较边界取 `>=` 而非 `>`,因为 drain 正好等于 grace period 时已把它全部消耗。

结论:设计被接受:advisory 返回 `Option<ShutdownDrainAdvisory>` 且不阻断启动,只有超过 1800 s 才在 `Config::validate` 拒启;配套单测钉住静默区间、`>=` 边界、声明 grace 的双向行为以及上限的可启动 / 超一被拒。 · accepted(作者自述,reviewer 未提出异议)

默认 drain 等于默认 grace period,会让每个未声明 grace 的部署每次启动都告警 设计

`--shutdown-drain-secs` 默认 30,恰好等于假设的 `K8S_DEFAULT_GRACE_SECS = 30`,因此开箱即用地在 `>=` 边界上触发告警。作者在 body 里主动承认并辩护:「That is the intended reading rather than a misconfigured default」,理由是流式 completion 可以跑数分钟,30 s 内本来无法干净退出,该调高的是 grace period,并用 `--termination-grace-secs` 声明。单测 `the_default_drain_warns_until_the_grace_period_is_raised` 把这个默认行为显式断言下来,防止未来有人顺手改安静。

结论:接受这一取舍,代价是常态化的启动告警;缓解手段是告警使用静态消息 + 结构化字段(`grace_declared`)以便日志聚合分组去重。 · accepted-with-acknowledged-noise

commit message 的默认值与最终实现不一致(5 s vs 30 s) documentation

唯一的 commit `f313a5bf` 的 message 写「`--shutdown-drain-secs` (default 5)」,而最终代码 `default_shutdown_drain_secs()` 返回 30,PR body 与全部测试也都按 30 描述,说明默认值在开发过程中被上调但历史记录没有同步。

结论:代码、测试与 PR body 一致(30),只是 commit message 失真;因运维实际依据是 PR body 与 CLI help,影响限于追溯性,但会让后来者误判默认值演化。 · unresolved-minor

配置面早已合并,但行为验证与 CI 证据不完整 测试

PR body 声称 `cargo fmt --check`、`cargo clippy --all-targets -- -D warnings`、`cargo test` 全绿,checklist 中 PR Test (Base) 通过;但 `Latest PR Test (Extra)`(Run #35066732674)显示失败,且没有任何评论解释这次失败。同时,本 PR 由于无人读取新字段,其单测只覆盖纯函数与配置映射,无法覆盖任何运行期行为。

结论:未解决:材料无法判断该 CI 失败是否与本 PR 相关(本 PR 无运行期改动,可能是 flaky 或环境问题),也没有 reviewer 要求补充说明;运行期证据要等 #39016 的 k8s e2e。 · unresolved

风险与影响

  1. flag 当前是 no-op:shutdown_drain_secs / termination_grace_secs 在 src/config/cli.rs 被解析、在 Config::validate 被校验,但仓库内没有任何读取点。若 #39016 延期而 router 镜像先发布,运维按 --help 认为 drain 已生效,实际仍是「立即关闭」的旧行为,滚动升级期间仍会丢流式 completion。
  2. 默认值带来持续性启动噪声:默认 30 s 恰好等于假设的 grace period 30 s,shutdown_drain_advisory 因此对每个未声明 --termination-grace-secs 的部署在每次启动都返回告警。作者接受这一点,但常态告警会稀释其它真实启动告警的可见度;grace_declared 字段的设计正是为了让它可分类,却也让「猜出来的告警」和「基于事实的告警」共享同一输出通道。
  3. 上限只挡 typo,不挡 drain > grace:MAX_SHUTDOWN_DRAIN_SECS 是硬闸门,而「drain 是否适配 grace period」只是建议。配置 --shutdown-drain-secs 600 而 terminationGracePeriodSeconds 仍是 30 的部署可以正常启动,随后每次 termination 都在 drain 中被 SIGKILL。
  4. 校验入口唯一:Config::validate 是唯一执行上限检查的地方,而它只由 Cli::into_config 调用。一旦未来引入配置文件或程序化构造 Config 的路径,这条不变量就可能被绕过(当前仓库内确实没有其它构造点,风险暂时较低)。
  5. 公开结构体加字段属于源码级 breaking:ServerConfig 是 pub,下游若以字面量构造会编译失败。仓库内的缓解手段是新增公开 Default impl + fixture 补 ..Default::default(),但这只解决了本仓库的问题,对 crate 使用者的语义化版本仍需注意。
  6. CI 状态存在未解释的失败:PR body 声称 cargo fmt --check、cargo clippy --all-targets -- -D warnings、cargo test 全绿,checklist 里 PR Test (Base) 通过,但 Latest PR Test (Extra)(Run #35066732674)标记为失败,且没有评论解释。鉴于本 PR 无运行期改动,大概是 flaky 或环境问题,但材料里无法确认。
  7. commit 记录与实现不一致:唯一 commit f313a5bf 的 message 写「--shutdown-drain-secs (default 5)」,而代码 default_shutdown_drain_secs() 与 PR body 都是 30,说明默认值在开发中被上调过却没有留下说明,历史记录失真。

对运维 / 部署:本 PR 本身不改变请求路径行为,影响集中在启动期——多一条 shutdown_drain_secs 上限错误(拒启)与一条常态告警;但它正式确立了 router 优雅关闭的配置契约,k8s 部署(terminationGracePeriodSeconds、readiness probe 的 failureThreshold * periodSeconds)从此有明确的调参对象。
对系统:ServerConfig 新字段是纯增量,不触碰 worker 选择、KV 路由、PD 转发等热路径,也不影响吞吐或延迟;Config::validate 多一次常数比较,无性能意义。
对团队 / 代码库:ServerConfig 是散布在测试里的公共构造点,本次一次性修改了 15+ 个 fixture 才让编译通过,说明该类字段新增在未来仍会带来同样的 plumbing 成本;本次引入公开 Default impl 后,后续新增字段的测试成本会显著下降。另外本 PR 示范了一种可复用的模式——「软告警(advisory)+ 硬闸门(ceiling)+ 保持生产路径穷举」——适合被后续生命周期类配置沿用。

flag 暂为 no-op 启动告警可能疲劳 公开结构体源码级 breaking 上限不挡 drain > grace CI Extra 失败未解释 commit 信息与实际默认值不符

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论