Skip to content

从 StatefulSet 到推理负载:滚动升级需要保留什么

Published on
从 StatefulSet 到推理负载:滚动升级需要保留什么

一次推理服务升级,只改了 Decode 的镜像。Prefill 的配置没有变,但如果两者属于同一个重建单元,Prefill 也要重新启动。再把场景放大:一个 Role 实例包含多个跨节点协作的 Pod,重新创建它,可能还要重新等待资源、建立通信、加载权重。

滚动升级中的“一次替换一个”,到了这里就需要重新解释。一个指的是 Pod、一个多机实例,还是一组共同服务请求的角色?替换期间保留了多少副本,又是否保留了一条能够工作的请求路径?

Kthena 的 ServingGroup、Role,以及围绕版本历史、协同更新和原地更新的设计,把这些问题逐渐带进了工作负载 API。

从一个 Pod 的顺序开始

StatefulSet 给每个 Pod 稳定的序号与网络身份。在默认的逐个滚动更新方式下,控制器从高序号开始替换,等待新 Pod Running、Ready,再推进前一个。partition 可以把一部分序号留在旧版本:低于边界的 Pod 即使被删除,也应按旧模板重建。1

这里已经有一个很强的约定:暂停升级的副本,在故障恢复时仍然遵守暂停的版本边界。 否则,一次意外删除就能绕过灰度策略。

沿用这个思路,可以把三个副本理解为:

期望模板:v2,partition:2

pod-0    v1     保留
pod-1    v1     保留;故障后仍恢复为 v1
pod-2    v2     更新

StatefulSet 提供的是 Pod 层面的身份、顺序和版本管理。应用可以在上面建立更复杂的协作关系,但这些关系并不会因为 Pod 有了序号,就自动成为控制器的更新语义。

假设两个 Pod 共同组成一个模型并行实例。如果新进程必须等同版本的其他成员就绪,逐个更新并等待 Ready 就可能卡住。若 Prefill 与 Decode 分属两个 StatefulSet,它们各自的更新顺序也无法直接表达“新 Decode 准备好之后,才能开放新 Prefill”。这些约束需要由应用自身或更上层的编排补充。

把更新单位放到合适的位置

ModelServing 在 Pod 上方引入了两层组织:ServingGroup 表达一组工作负载,组内通过 Role 区分不同职责;一个 Role 又可以有多个实例,每个实例包含 Entry 和若干 Worker Pod。2

这使更新可以在不同边界上发生。ServingGroupRollingUpdate 替换整个组,适合需要一起切换的角色组合;RoleRollingUpdate 则替换发生变化的 Role 实例,让其他 Role 继续运行。后者仍然可能一次重建多个 Pod,因为一个 Role 实例本身可以是多机实例。3

选择较小的更新单位,可以减少无关进程的重启,也把更多责任交给版本兼容性。只升级 Decode 的前提,是保留下来的 Prefill 能继续和它协作。如果通信协议、KV cache 格式或启动约定发生不兼容变化,就需要更大的切换边界,或者明确的新旧版本隔离。

恢复策略也必须与这个选择一致。若配置了 ServingGroupRecreate,Role 更新造成的删除仍可能触发整组恢复;希望只重建 Role,就需要相应的 RoleRecreate 恢复边界。更新策略与故障策略最终操作的是同一批进程。3

边界还决定预算的单位。ServingGroup 级别的 maxUnavailable: 1 允许一个组不可用;Role 级别的同名配置,约束的是每个 ServingGroup 内对应 Role 的实例。多个组分别执行自己的预算,不能理解成整个 ModelServing 总共只会失去一个 Role 实例。3

maxSurge 则把一部分升级成本转化为临时资源需求。先增加新容量,再删除旧容量,可以减少主动中断;但多机实例需要的额外资源也要能够真正调度出来。若选择零不可用预算,而额外实例迟迟无法就绪,等待就是合理结果。控制器无法凭一个字段同时兑现“没有空闲 GPU”“不中断旧实例”和“立即完成替换”。

保留下来的旧版本,必须能够重建

partition 让新旧版本共存,也让故障恢复变得更有意思。

假设正在从 v1 更新到 v2,某个旧 Role 被 partition 保护。此时它的 Pod 全部丢失,控制器又重启了。最新 spec 只描述 v2,内存中关于 v1 的信息已经消失,原先 Pod 上的版本标签也不在了。控制器要恢复 v1,必须还能找到它的定义,以及这次恢复应该使用它的依据。

因此,版本历史会直接参与日常协调。一个哈希可以回答配置是否相同,却不能单独回答旧副本该怎样重建。

Kthena 的 revision 方案用 ControllerRevision 保存经过规范化的工作负载声明,并让哈希与存储内容来自同一份字段投影。Role 模板、workerReplicas、schedulerName 和插件声明进入版本;ModelServing 与 Role 的副本数、partition、更新预算等保留为运行参数。4

这里的区别很具体:把 Role 从四个实例扩到六个,改变的是容量;把每个实例从一个 Worker 改为三个 Worker,改变的是实例结构。前者不应因为回退镜像而丢失,后者则需要跟随版本恢复。

受 partition 保护的旧 Role 发生故障后,由保留的版本引用找到 ControllerRevision v1,结合当前运行参数恢复旧版工作负载;最新 spec v2 继续用于允许更新的实例。

版本生命周期还需要持久化仍被工作负载使用的版本引用,覆盖 Pod 删除与控制器重启之间的空窗。清理历史时,不能只凭“当前没有 Pod 带着这个标签”就删除旧版本,因为缺失的 Pod 可能正等待按它恢复。5

版本身份也要独立于控制器内部类型的表示方式。若一个 Kubernetes 类型增加了序列化时会省略的空字段,工作负载声明并未改变;仅凭结构哈希变化触发滚动,可能把控制器升级变成一次不必要的模型重启。可以先用哈希快速比较,再以规范化后的版本数据和语义比较确认差异;若历史版本无法解析,暂停依赖它的创建或删除,比猜测一个新模板更安全。5

按这套设计,回滚可以复用正常的滚动更新:把历史中的版本化字段写回 spec,再按当前的 partition 与预算推进。高峰期间已经扩到六个实例,回退应用版本不应顺手把容量退回四个;正在使用的更新节奏也应该保留。4

不过,这份历史有明确的边界。它保存用户声明,不承诺复制出逐字段相同的历史 Pod,更不会恢复进程内存。外部配置、准入行为和控制器实现仍可能变化。如果需要精确重现模型与镜像,制品本身也要有稳定的版本标识。

各自可用,还需要一起工作

把更新缩小到 Role 之后,会出现另一个问题:几个 Role 各自都满足 maxUnavailable,它们的新版本容量却可能推进得很不均衡。

例如 A 有 100 个实例,B 有 50 个,C 有 10 个。三个 Role 同时替换一个,进度分别是 1%、2%、10%。如果它们组成一条具有版本依赖的调用链,独立滚动可能过早放大某一段的新版本流量。

协同比例更新可以用两个约束处理这个问题:用 maxSkew 限制参与 Role 的归一化更新进度差;用依赖图描述新版本调用关系。协调器沿用 Role 更新路径,把进度与依赖关系转成更新边界和目标版本启动许可。6

以三者初始进度均为零、maxSkew: 10% 为例,比例规则给出的累计启动替换额度是 A 的 10 个、B 的 5 个、C 的 1 个。实际执行还要受各自可用性预算与依赖约束限制。进度依据是旧实例替换后达到 Ready 的比例;单纯多建了几个临时实例,不能算作旧版本已经退出。副本数离散,取整也会带来一个实例步长以内的偏差。6

更关键的是依赖方向。对于 A → B → C,可以先让内部的 B、C 建立新版本容量,在整条新依赖路径 Ready 后,再放行入口 A。收尾则沿另一方向:只要旧 A 还存在,就保留旧 B;只要旧 B 还存在,就保留旧 C。6

新版本 B、C 先建立可用容量,再放行新 A;旧 A 退出后才释放最后的旧 B,随后释放最后的旧 C。箭头表示调用依赖。

这也解释了为什么一个依赖 Role 只有一个实例时,临时容量可能不可避免:旧调用者还需要它,新调用者又要求新版本先就绪。同一个实例无法同时承载两套不兼容的进程,必须增加容量、允许中断,或由应用提供跨版本兼容。

但依赖图不会自动完成流量隔离。上述顺序成立的前提,是路由和应用能把新请求送进相应的新版本链路。保留一个旧依赖实例也只保住了路径存在,不保证它足以承受剩余全部流量。比例进度提供一种编排约束,实际处理能力仍取决于各 Role 的性能与请求分布。

对 Prefill、Decode 也是如此。它们存在请求协作,并不意味着任何部署都需要严格的同版本配对。是否增加协同约束,应由具体的运行时兼容性决定;多加一条依赖,也就多了一个可能让升级等待的条件。

原地更新保留的是哪一层状态

更新单位确定以后,还可以继续缩小变更动作:如果只改镜像,是否需要删除 Pod?

Kthena 的原地更新 proposal 尝试保留现有 Pod,修改普通容器镜像,让 kubelet 重启受影响的容器。这样可以保留 Pod 的 UID、IP、节点位置和 Pod 生命周期内的卷,避免重复执行与本次变更无关的调度和数据准备。其初始范围限定为满足条件的镜像变更,更新预算仍按 ServingGroup 计算。7

这些保留项对推理负载很有价值:已经找到的一组设备、已经形成的放置关系,以及 emptyDir 中仍可复用的数据,都可能减少恢复前的准备工作。但模型进程仍然重启,显存中的权重与进程内的 KV cache 不会因为 Pod UID 没变而自动保留。原地更新省去了部分重建成本,模型初始化依然需要面对。

动作变小,控制器需要识别的中间状态反而更多。替换更新可以观察旧 Pod 消失、新 Pod 出现;原地更新中,同一个 Pod 的镜像声明已经变化,容器却可能尚未完成重启。只看到 spec 或版本标签更新,不足以证明新进程已经接管。

因此,这类方案可以先通过 readiness gate 让选中的整组退出基于 Ready 的流量,再执行镜像更新,核对容器运行状态,最后恢复就绪;同时记录足够的信息,以便控制器重启后继续判断。原先没有 gate 的 Pod,还需要先经过一次替换准备。7

Ready 的变化也需要传播到路由,已经开始的长请求则有自己的生命周期。退出新请求选择、等待在途请求结束、重启进程,是三个不同动作。就绪传播等待本身没有提供应用级排空确认,因此不能据此推导出流式请求不会中断。7

若要等在途请求结束,可以把排空纳入删除协议:控制器标记 Pod 正在退出,Router 将它从新请求候选中移除,同时保留对运行中请求的跟踪;引擎运行与排队请求归零后,Router 才确认可删除。等待上限可以避免排空无限阻塞,但超时后仍可能中断剩余请求。要在排空期间维持服务容量,还需先准备新副本并留出调度所需的设备资源。8

原地更新还有一个有用的设计选择:不符合条件时拒绝执行,更新中的坏镜像也不会因为超时就自动退化成删除重建。若用户选择这个策略是为了保留放置与本地数据,隐式切换到重建反而可能违背选择它的理由。更昂贵的恢复动作,需要有明确的触发方式。

更新策略在承诺什么

从 StatefulSet 到推理负载,稳定身份与版本历史仍然有用,只是需要维护的关系变多了:一个多机实例的成员关系,几个 Role 之间的兼容关系,以及新旧请求路径在过渡期的共存关系。

这些关系不必全部压进一个更大的更新单元。强耦合的模型并行成员可以共同重建,兼容的 Role 可以独立滚动;只有镜像变化时,还可以考虑保留 Pod 身份。边界选得越细,省下的重启越多,对版本判断、状态反馈与应用兼容性的要求也越具体。

一个值得保留的分工是:工作负载控制器声明这次变更允许失去哪些容量、必须留下哪些版本与身份;运行时说明怎样才算真正恢复服务,以及哪些请求已经可以安全结束。双方的约定足够具体之后,“升级完成”才有机会同时对应资源收敛与服务恢复。

Footnotes

  1. Kubernetes StatefulSet:滚动更新与 partition。 返回正文

  2. Kthena ModelServing 架构。 返回正文

  3. Kthena 滚动更新语义与配置,以及 Role 更新设计。 返回正文 返回正文 返回正文

  4. ModelServing revision 历史与回滚设计;确定性版本投影。 返回正文 返回正文

  5. 版本比较与历史恢复实现;持久化版本引用。 返回正文 返回正文

  6. 协同比例 Role 更新设计;Role 更新协调器。 返回正文 返回正文 返回正文

  7. 原地滚动更新设计。 返回正文 返回正文 返回正文

  8. 请求排空与工作负载删除协议设计。 返回正文