做 K8S 工作负载运维的这几年,最让我难受的场景之一就是一次普通的滚动更新:镜像从 v1 换成 v2,Pod 整只被删掉重建,节点上重新拉镜像、重新分配 CPU、重新走一遍就绪探针,几十秒内这块业务容量是塌的。要是赶上大促或者流量高峰,HPA 都来不及补。后来我把 Kruise(OpenKruise)里 CloneSet 的原地升级实现从头翻了一遍源码,才真正把“原地”这两个字搞明白——它既不是真的把进程在内存里换血,也不是简单改个 image 字段就完事,中间隔着 K8S 一堆不可变字段的约束、控制器和 kubelet 之间的状态交接、以及一层专门设计出来兜底的状态机。这篇就把我从源码里读到的东西完整摊开:Kruise 是怎么在 K8S 的原生限制下把 Pod 里的容器替换掉的、三个核心对象(Pod spec、annotation、status)各自承担什么角色、状态机在哪几个点会停住、以及我在实际集群里踩过的那几个坑。适合已经能熟练写 Deployment、想进一步搞清楚控制器底层逻辑的运维和平台开发同学,也适合正在评估要不要把工作负载迁到 CloneSet 的架构同学,看完至少能做到出问题时知道该看哪个字段。
1. 原地升级到底“原地”在哪,先把概念钉死
1.1 重建式升级的真实代价,不只是慢了那几十秒
很多人对滚动更新的痛感停留在“慢”,其实真正的代价在别处。Deployment 的更新逻辑是新建 ReplicaSet、扩新缩旧,落到节点上就是 Pod 对象被删除再创建,这意味着 Pod IP 变了、容器文件系统的可写层全丢、emptyDir 里的缓存全没、节点上的镜像得重新拉(没预热的话,一个 2GB 的镜像在跨机房拉取时能把就绪时间拖到分钟级)。对无状态服务来说这些还能忍,但凡业务里有长连接、有本地缓存预热、有 JVM 的类加载和 JIT 预热,每次重建都是一次性能断崖。我见过一个 Java 服务,冷启动到能扛住峰值 QPS 需要 90 秒,滚动更新期间靠多副本硬撑,结果副本数一多,节点上的 CPU 争抢反而把整个集群拖慢了。
更麻烦的是资源调度。Pod 重建意味着重新走一遍调度器,节点资源紧张时新 Pod 可能 Pending 很久,新旧副本比例失控,这时候如果 maxUnavailable 设得太激进,可用容量直接掉到警戒线以下。所以说原地升级解决的问题不是“快一点”,而是把一次升级从“资源重新分配”降级成“容器重启”,调度、网络、存储这三层全部保持不变,只让容器进程重来一遍。这个差别在源码里体现得非常直接:Kruise 的原地升级路径上,压根没有调用 apiserver 的 Delete 接口。
1.2 边界要划清楚:换的是容器,不是进程
这里必须泼一盆冷水,免得期望错位。Kruise 的原地升级,本质是更新 Pod spec 里的容器镜像字段,由 kubelet 感知后销毁旧容器、拉起新容器。Pod 对象没变、Pod IP 没变、挂载的 volume 没变、nodeName 没变,但容器进程是实打实被杀掉重启的。它不是热补丁,不是进程内替换二进制,业务代码里的内存状态一样会丢。
把这个边界想清楚,很多设计上的取舍就顺了:既然是容器级重启,那么 terminationGracePeriodSeconds、preStop hook、就绪探针这些原生机制依然全程有效,升级的优雅退出能力一点没丢;但反过来说,如果你的服务启动要加载 10GB 模型文件,原地升级也救不了你,该慢还是慢,只是省掉了调度和拉镜像(镜像还是得拉,除非节点上已有)。K8S 社区后来搞的 InPlacePodVerticalScaling(原地调整 resources)是另一个维度的事,跟 Kruise 这套不是一回事,别混着理解。
1.3 三种升级策略怎么选,一张表说清楚
CloneSet 的spec.updateStrategy.type提供三个值,选错了后面全是麻烦。我把它们的实际行为和适用场景整理成下面这张表,这是我翻完控制器代码加上几次线上验证之后总结的:
| 策略值 | 控制器实际动作 | 适用场景 | 主要风险 |
|---|---|---|---|
ReCreate | 删除 Pod 再按新模板创建,等价于 Deployment 的行为 | 变更涉及不可变字段(env、command、volumeMounts、资源 requests) | 容量抖动,长连接全断 |
InPlaceIfPossible | 优先原地升级;遇到不可变字段变更时自动退化为重建 | 绝大多数业务,尤其是只改镜像的日常发布 | 退化时不会明确报错,需要看 Pod 事件确认 |
InPlaceOnly | 只做原地升级,遇到不可变字段变更直接失败并报错 | 明确知道自己只改镜像、且绝对不能容忍重建的核心服务 | 改错字段会导致升级卡死,回滚前一直不推进 |
InPlaceOnly这个选项特别值得说一句。它的价值在于“把隐式退化变成显式失败”——InPlaceIfPossible在某次发布里悄悄退化成重建,你只会看到 Pod 重启了,但不知道为什么;InPlaceOnly会直接把错误打到 CloneSet 的 status 和事件里。我个人的做法是:核心链路用InPlaceOnly,非核心用InPlaceIfPossible,让问题在测试环境就炸出来,而不是在凌晨三点的生产环境里悄悄重建。
2. 源码入口:CloneSet 控制器怎么判断“该升级了”
2.1 从 CRD 到控制器:一条你得先摸清的启动链路
要看懂原地升级,得先知道代码从哪进。OpenKruise 的控制器注册走的是 controller-runtime 那套,CloneSet 的入口在pkg/controller/cloneset/cloneset_controller.go,Add方法里构造ReconcileCloneSet,注册对 CloneSet、Pod、ControllerRevision 这几类对象的 watch。关键点在于Pod 的 event handler 会做 ownerReference 反查,把属于某个 CloneSet 的 Pod 变更映射回对应的 CloneSet 名字塞进队列,所以一次 Pod 就绪、一次容器状态变化,都会重新触发一次 Reconcile。
Reconcile 本身很短,核心就是把活派给pkg/controller/cloneset/sync包里的几个函数,按顺序是Scale(先保证副本数对)→Update(再做版本收敛)。这个顺序不是随便排的:先扩容再升级,能保证升级过程中可用副本不会因为副本数不足而进一步恶化。Update函数再往下分叉,根据updateStrategy.type走updateInPlace、updateRecreate或updateInPlaceOnly三条路。这三条路的差异只在于“改 Pod 的方式”,前面的筛选逻辑是共用的。
2.2 calculateDiffs:把期望状态和实际状态算成一笔账
Update里第一步是calculateDiffs,这是整个升级逻辑的决策中枢。它做的事情可以概括成一句话:把所有 Pod 按 revision 分成“已更新”和“待更新”两堆,再根据 partition、maxUnavailable、当前可用副本数,算出这一轮最多能动手几个、具体动哪几个。这一步不做任何写操作,纯计算,因此可以安全地反复执行——这也是控制器模式的核心,Reconcile 必须幂等。
计算里有几个判断顺序值得记住,因为它直接决定你调参时的直觉:
- 先过滤掉
DeletionTimestamp非空的 Pod,正在删除的不参与升级。 - 再排除掉不健康的 Pod(NotReady、正在被驱逐、处于 Paused 状态的),这些不能动。
- 然后按 partition 截断:revision 为旧的 Pod 中,保留 partition 个不动,剩下的才进入候选池。
- 最后用 maxUnavailable 和当前的 availableReplicas 算出一个额度,候选池里超出额度的部分本轮不处理。
这个顺序解释了一个常见困惑:为什么我把 maxUnavailable 调大了,升级速度却没变快?因为瓶颈可能卡在第 3 步的 partition 上,或者卡在第 2 步——集群里一堆 Pod 本来就不 Ready,额度根本放不出来。
2.3 partition 和 maxUnavailable 到底谁说了算
这两个参数是 CloneSet 灰度能力的核心,但语义容易混。我按自己的理解重新表述一遍:
partition控制的是版本维度的保留量,表示“我要留多少个旧版本的 Pod”。设成 5、总副本 10,那么最多只有 5 个 Pod 会被升到新版本,剩下的原地不动。这是做金丝雀的天然工具,改一个数字就是一次灰度比例调整。maxUnavailable控制的是并发维度的速率,表示“同一时刻最多允许有多少个 Pod 处于不可用状态”。它约束的是升级的节奏,不是最终状态。
两者的关系是“与”:先被 partition 砍掉一批候选,再被 maxUnavailable 砍掉一批。所以灰度发布的标准姿势是:先把 maxUnavailable 设成 1(严格控制爆炸半径),partition 从 replicas-1 开始逐步往下调,每调一次观察一段时间。这里有个坑我在生产里踩过——maxUnavailable 的额度计算会把“本来就 NotReady 的 Pod”算进去,如果一个服务本身就有两个副本因为探针配置问题一直不 Ready,那么额度一开始就是负的,升级会完全停滞,而且日志里只有一句轻描淡写的 skip。所以升级前先kubectl get pods确认一下 Ready 状态,比什么都重要。
2.4 先算后动:为什么这个设计能救命
把“计算”和“执行”彻底分开,是这套控制器最值得学的设计。好处有三个:其一,计算过程不产生副作用,控制器被重复调度(比如 informer 抖动导致同一对象被处理十次)也不会误伤;其二,所有的“为什么这个 Pod 没升级”都能在计算阶段用日志和事件解释清楚,排查时不用去猜执行路径;其三,限流逻辑集中在一处,改一处就等于改了所有策略的并发控制。
反过来说,这也意味着控制器的判断完全依赖它看到的状态。如果 informer 缓存里的 Pod 状态是旧的(比如 kubelet 已经重建了容器,但 apiserver 到控制器的 watch 还没推到),控制器就会认为这个 Pod 还没升完,于是反复在下一轮 Reconcile 里跳过它。这类“看起来卡住了、其实是在等状态同步”的现象,是排查原地升级问题时最高频的误判来源,后面第 5 节会专门讲怎么区分。
3. 核心实现:一次原地升级在源码里走了哪几步
3.1 第一道坎:Pod 的大部分字段压根不让改
想理解 Kruise 为什么要写那么厚一层封装,得先知道 K8S 的校验有多严。apiserver 在ValidatePodUpdate里做的是一个“先抹平再比较”的把戏:把新 Pod 里那些允许变更的字段替换成旧值,然后跟旧 Pod 做DeepEqual,只要还有差异就返回Forbidden: pod updates may not change fields other than ...。被允许变更的字段是一个很短的清单,主要包括:
spec.containers[*].image和spec.initContainers[*].imagespec.activeDeadlineSecondsspec.terminationGracePeriodSecondsspec.tolerations(且只允许新增)- 新版本里陆续放开的 resources 原地调整相关字段
也就是说,container 的 env、command、args、volumeMounts、resources.requests 这些一概不可变。这解释了为什么InPlaceIfPossible遇到这些字段变更时会退化成重建:不是 Kruise 不想做,是 apiserver 那道门就过不去。所以你在设计发布流程时要有个清醒认知:只改镜像的发布是原地的,改了启动命令的发布一定不是。把这个记在心里,能省掉大量“为什么这次没原地”的困惑。
3.2 UpdateSpec 与 Refresh:先把“要改什么”写成一张清单
真正干活的是pkg/util/inplaceupdate这个包,它的对外接口大致是三步:Refresh→ 判断 →Update。Refresh的作用是只读地算出这次升级要动哪些东西,产出物是一个UpdateSpec结构,核心字段包括:
// 简化示意,真实结构以仓库为准 type UpdateSpec struct { InPlaceUpdate InPlaceUpdateState // 要写进 annotation 的状态 UpdateRevision string // 目标版本 CheckInPlaceUpdateCompleted func(*corev1.Pod) error IsInPlaceOnly bool }而InPlaceUpdateState是理解整个机制的钥匙,它会被序列化成 JSON 写进 Pod 的apps.kruise.io/inplace-update-state注解里。里面几个字段各自的职责是:
revision:这次要升到哪个版本,控制器靠它判断 Pod 是否已经“被标记为升级中”。updateTimestamp:这次升级动作的发起时间,配合宽限期和超时判断。nextContainerImages:目标镜像清单,key 是容器名。lastContainerStatuses:每个容器在升级发起前的 imageID 和 image 快照,这是判断“容器到底换没换”的基准,也是回滚和后路的关键。nextContainerRefMetadata:容器引用的 ConfigMap/Secret 的 ObjectMeta,靠 resourceVersion 感知配置变化,比如挂载的配置文件改了,可以靠这个触发一次“顺带”的容器重建。updateEnv、gracePeriodSeconds、updatePriority:分别是合并后的环境变量、宽限期、多容器串行升级的优先级。
注意:
lastContainerStatuses里存的是升级前的状态快照,不是当前状态。很多人看这个注解以为里面的镜像是新镜像,其实nextContainerImages才是目标,lastContainerStatuses是旧账本。搞反了会导致完全错误的排查方向。
3.3 三段式提交:打注解、改 spec、等 kubelet
真正的写操作在updateInPlace里,顺序非常讲究,可以概括成“三段式提交”:
第一段,把状态写进 annotation。控制器先算出完整的InPlaceUpdateState,序列化后打到 Pod 的注解上。这一步单独做,是为了让整个升级过程“可追溯”——即便后续改 spec 失败、或者控制器被杀掉重启,下一个 Reconcile 也能从注解里读到“这个 Pod 正在升级、目标是哪个镜像、升级前是什么样子”,从而继续推进而不是重来。
第二段,原地改 spec 里的容器镜像。这一步用的是标准的client.Update,带 resourceVersion 做乐观并发控制,冲突了重试。因为只改了 image 这个允许变更的字段,apiserver 会放行。注意这里不会碰 status,控制器只更新 spec 和 metadata,status 归 kubelet 管,这符合 K8S 的分工原则。
第三段,等 kubelet 把容器换掉,然后回收注解。控制器在后续的 Reconcile 里会去比对新旧:拿pod.status.containerStatuses[*].imageID跟lastContainerStatuses里的快照做对比,如果还是旧的,说明容器还没重建,继续等;如果变了且容器 Ready,就把这个容器标记为完成。所有容器都完成后,清掉注解里的 next 信息,这个 Pod 才算真正升级完成。
这三段之间是异步的,中间隔着一次 kubelet 的 syncLoop。这也是为什么原地升级不是瞬时的——你在kubectl get pod -o yaml里可能看到一个诡异的状态:spec 里已经是新镜像,status 里还是旧镜像,注解里同时挂着 next 和 last。这不是 bug,这是提交到一半的正常中间态。
3.4 宽限期与 lifecycle hook:让旧容器体面退场
kubelet 重建容器的流程本身是原生的:发现 image 变了,先执行 preStop hook,发 SIGTERM,等terminationGracePeriodSeconds,超时再 SIGKILL,然后拉起新容器。原生的优雅退出能力一点没丢,这也是原地升级相比直接删 Pod 的最大优势之一——你的 preStop 脚本、连接排空逻辑全部照常生效。
Kruise 在原生之上又加了两层控制。一是spec.updateStrategy.inPlaceUpdateStrategy.gracePeriodSeconds:控制器会在 Pod 上打一个apps.kruise.io/inplace-update-grace时间戳注解,在到达这个时间点之前,控制器不会推进这个 Pod 的升级动作,给业务留出一个“收到通知、处理完手头请求”的窗口。二是 lifecycle hook,通过给 Pod 打上lifecycle.apps.kruise.io/inplace-update标签,可以让业务侧在容器重建前插入一个自定义动作,比如等待长连接自然断开、或者做一个外部健康检查。
注意:这两个机制的具体行为和版本相关性较强,我在不同版本上验证时细节有出入。上线前一定要在自己的集群上跑一次灰度,用
kubectl get pod -o yaml观察注解里的时间戳变化,别直接照抄文档参数。
还有一个容易被忽略的细节:updatePriority。多容器 Pod(比如 sidecar 模式)如果直接同时重建所有容器,会出现主容器起来了、sidecar 还没好的空窗期。Kruise 支持给容器排优先级,按顺序串行重建,前一个 Ready 了再动下一个。这个能力在 service mesh 场景里价值很高,但需要对 Pod 打上对应的注解才能启用,默认是关闭的。
4. 状态机与可观测性:卡住了到底该看哪
4.1 把 inplace-update-state 拆开看,八成问题当场定位
我排查这类问题的第一动作永远是kubectl get pod <name> -o yaml | grep -A 30 inplace-update-state。这个注解就是原地升级的黑匣子,几个字段的判读方法我总结如下:
| 现象 | 注解特征 | 大概率原因 |
|---|---|---|
| 一直不发起升级 | 没有inplace-update-state注解 | 被 partition 拦住、或 Pod 本身不 Ready 导致额度不足 |
| 发起后长时间无进展 | nextContainerImages有值,lastContainerStatuses长时间不变 | kubelet 还没重建容器,通常是镜像拉取失败或节点压力大 |
| 注解一直在但 spec 没变 | 有注解,spec.containers[*].image还是旧值 | 第二段提交失败,通常是乐观并发冲突反复重试,或者被 webhook 拦截 |
| 升级完成但 Pod 一直 NotReady | 注解已清理,容器 Ready 为 false | 应用自身启动失败,跟 Kruise 无关了 |
这张表能覆盖我遇到过的绝大多数情况。要特别强调第三种:admission webhook 拦截是隐形杀手。如果集群里装了会修改 Pod spec 的 webhook(比如注入 sidecar、注入日志采集配置),它可能对 image 字段做额外处理,导致控制器的 Update 请求反复失败,而错误信息埋在控制器日志里,Pod 事件里根本看不到。
4.2 updatedReadyReplicas 是怎么算出来的,为什么它比 readyReplicas 更可信
CloneSet 的 status 里有updatedReplicas、updatedReadyReplicas、readyReplicas这么几个字段,名字像到让人头晕。区别在于“updated”这个前缀表示已经收敛到当前 revision 的副本数。控制器判断一个 Pod 是否算 updated,看的是它身上的 revision 标签(controller-revision-hash)是否等于 CloneSet 当前的 revision,而不是看镜像是否变了。
这就带来一个微妙之处:如果一次原地升级只改了镜像、没改 Pod 模板里的其他内容,ControllerRevision 会生成新的一版,标签随之更新,Pod 立刻被算作 updated——即使容器还没真正重建完。所以判断升级是否真的完成,不能只看updatedReplicas,要结合updatedReadyReplicas,后者要求对应的容器已经 Ready。做发布门禁的时候我一般用updatedReadyReplicas == replicas作为完成条件,而不是updatedReplicas,能避免“控制器说完成了、实际业务还没起来”的尴尬。
4.3 readinessGate:让流量在容器重建期间避开这个 Pod
这是我认为原地升级设计里最贴心的一环。设想一下:kubelet 正在重建容器,Pod 的Ready条件此刻可能还没被改成 False(有一定延迟),Service 的 Endpoints 里它还在,流量照打过来,请求直接失败。Kruise 的解法是给 Pod 注入一个自定义 readinessGate,在升级动作发起时把这个 gate 置为 False,Pod 立即变成 NotReady 并从 Endpoints 摘除,等容器重建完再置回 True。
这个机制的意义在于把“升级中”这个状态显式地告诉了下游。它比单纯依赖探针失败要快得多,因为探针的 failureThreshold 通常配了 3 次、每次间隔 10 秒,最快也要 30 秒才判定失败,而 readinessGate 是控制器直接写的,基本秒级生效。代价是会多出一次 Endpoints 变更,在超大集群里对 kube-proxy 有一点压力,不过实测下来影响可以忽略。
5. 踩坑实录:五种卡住场景和我的排查手段
5.1 五种典型卡点,按出现频率排序
第一种,镜像拉不下来。这是最高频的,注解里 next 有值、last 一直不变,kubectl describe pod能看到ErrImagePull。根源可能是私有仓库凭证没配好、镜像 tag 写错、或者节点到仓库的网络不通。原地升级在这里没有任何特殊处理,依然是 kubelet 拉镜像,所以排查思路跟普通 Pod 完全一样。
第二种,额度为负导致完全不推进。前面说过,maxUnavailable 的额度计算会把本来就 NotReady 的 Pod 算进去。表现是控制器日志里疯狂刷“skip update”,但没有任何错误。我的处理方式是先把 maxUnavailable 临时调大做验证,确认是额度问题之后再回头修探针配置。
第三种,webhook 拦截。表现是注解有、spec 不变,控制器日志里能看到admission webhook denied或者反复的冲突重试。这类问题最阴,因为 Pod 事件里干干净净,必须去看控制器自己的日志。
第四种,应用启动失败导致的假完成。容器换了,注解清了,但新容器的探针一直过不去,Pod 卡在 NotReady。这时候看updatedReadyReplicas就会暴露——它不会等于 replicas。处理方式跟 Kruise 无关了,去看应用日志。
第五种,多容器优先级配置不当。给容器排了优先级,但前一个容器本身一直起不来,后面的永远轮不到。表现是只有一个容器被重建,其他容器纹丝不动。这个坑在引入 service mesh 之后特别常见,sidecar 的健康检查配错了,主容器就永远升不了。
5.2 我的三段式排查法:从外到内,不跳步
排查这类问题我现在固定走三步,从来不跳。第一步看 Pod 注解,判断升级到底发起没有、发起到哪一段。第二步看 Pod 事件和容器状态,如果注解显示已发起但 spec 没变,问题在控制面(webhook、权限、并发冲突);如果 spec 变了但容器没换,问题在节点侧(kubelet、镜像、资源)。第三步才去看控制器日志,因为日志量大、噪音多,前面两步能定位的话尽量不看。
这里有个小技巧值得分享:控制器的日志里会打 Pod 名字,但如果你手上有个几百副本的 CloneSet,靠grep找起来很痛苦。我的做法是先kubectl get pods -o wide按 AGE 排序,找出那批“应该升但没升”的 Pod,拿到名字再反查日志,效率高很多。另外 OpenKruise 支持给 CloneSet 加apps.kruise.io/cloneset-sync-velocity之类的观测手段,这个我没深用过,就不展开了。
5.3 速查表:常见症状与对应动作
| 症状 | 优先检查项 | 常用命令 |
|---|---|---|
| 升级完全不启动 | partition 值、就绪 Pod 数量 | kubectl get cloneset -o yaml看 status |
| 部分 Pod 不升 | 这些 Pod 是否有 DeletionTimestamp、是否被 Paused | kubectl get pod -o yaml | grep -i deletion |
| 注解有但 spec 未变 | webhook 拦截、控制器权限 | kubectl auth can-i update pods,查控制器日志 |
| spec 已变但容器未换 | 镜像拉取、节点资源、节点 NotReady | kubectl describe pod,看 Events |
| 容器已换但一直 NotReady | 应用启动、探针配置 | kubectl logs --previous |
| 升级完成时间远长于预期 | terminationGracePeriodSeconds、preStop 阻塞 | 看容器退出耗时 |
这张表我贴在团队的 runbook 里,新同学上手基本照着走一遍就能定位到八九成的问题。
6. 我个人在实际操作中的几点体会
关于参数,我的经验是不要贪快。maxUnavailable 设大确实能让升级更快,但原地升级本身的耗时大头是容器启动和探针判定,不是控制器的调度速度,你把并发调高十倍,总时长可能只缩短 20%,却把故障影响面放大了十倍。我现在默认是 1,只在副本数超过 50 的服务上才放开到 5%。
关于灰度,我强烈建议把 partition 和发布流程绑定起来,做成“每调一次 partition,自动观察 5 分钟,看错误率和 updatedReadyReplicas”。有一次我在一个只有 3 副本的服务上直接把 partition 从 3 调到 0,三个 Pod 同时开始重建,恰好那批节点的镜像缓存都过期了,整体不可用接近两分钟。这个教训让我后来所有服务都强制走两步灰度,哪怕只有两个副本。
关于字段变更的认知,这条最重要:上线前用kubectl diff或者 dry-run 确认这次变更到底动了什么。如果 diff 里出现了 env、command、resources.requests 这些,那就别指望原地升级了,把更新策略临时切成 ReCreate,走正常的滚动流程,心里反而踏实。把“能不能原地”当成一个需要显式判断的前置条件,而不是默认期待,是我这几年最实用的一条经验。