首页 / 资讯中心 / 文章详情

Kruise原地升级全链路:Pod重建成本、源码与灰度调参

Kruise原地升级全链路:Pod重建成本、源码与灰度调参 ★ FEATURED ARTICLE
在线上做过一次稍大规模的发版之后很多人会开始重新思考 Kubernetes 默认的更新方式。一个 Pod 被删掉重建容器是新的、镜像是对的、副本数也补齐了从控制面看整件事干净利落,但业务侧的反馈往往是另一番景象:长连接全断、注册中心里服务实例集体上下线、本地缓存全部落空、冷启动把数据库连接池打满,单次发版窗口从三分钟拉到二十分钟。这些问题不是 Kubernetes 做错了什么,而是**删重建(**Recreate)这个动作本身就必然会带来 Pod 身份、网络和存储的重新分配。Kruise 提供的原地升级(In-place Update)解决的正是这件事:它只替换 Pod 里的容器镜像,Pod 对象本身、IP、所在节点、挂载卷一律不动。下面我按照自己在读源码和线上调参过程中的实际顺序,把这条链路从控制器决策一直拆到 kubelet 拉起新容器,中间哪些地方容易误判、哪些地方有坑,一并说清楚。1. Pod 重建的隐性成本:原地升级到底在省什么1.1 Pod 重建时被换掉的不只是容器很多人对重建的理解停留在容器重启了一下,实际远不止。Kubernetes 删除一个 Pod 的时候,整个 Pod 沙箱都会被回收,包括 pause 容器、网络命名空间、Pod IP、Pod UID,以及所有以 Pod 名义挂载的卷和投影。新建的 Pod 拿到的是全新的 IP 和 UID,调度器甚至可能把它放到另一台节点上。对无状态短请求服务来说这没什么,但对以下几类负载就是灾难:长连接型服务:网关、消息消费、gRPC 流式接口,连接一断就要重新握手、重新鉴权、重新同步位点。依赖 IP 白名单的下游:新 IP 上线后需要人工或脚本同步白名单,存在时间差。有本地缓存的读密集型服务:缓存清零后的冷启动会把压力全部打到存储层。启动耗时长的服务:JVM 预热、模型加载、索引构建,一个 Pod 从启动到真正能扛流量可能要几分钟。原地升级把这几件事全部绕开了。Pod 的 UID 不变、IP 不变、NodeName 不变、volumes 不变,变化只发生在容器层:旧容器被杀掉,新容器用新镜像在原位启动。对于上面这几类业务,发版从一次接近全量的抖动变成每个 Pod 一次几秒的容器重启,量级完全不同。1.2 原地升级的能力边界:只动 image 字段这一点必须先划清楚,否则后面看源码会一直有个疑问:为什么控制器要费那么大劲判断能不能原地改。因为原地升级能做的事情非常有限——它只改 Pod spec 里 containers 的 image 字段,其他任何字段都不碰。变更类型原地升级能否处理说明容器镜像 tag / digest可以这是唯一被支持的变更环境变量 env不可以需要重建 Pod资源 requests / limits不可以kubelet 不支持运行中调整 Pod 的 cgroup 规格挂载卷、volumeMounts不可以涉及 Pod 沙箱command / args不可以容器启动参数无法热改新增或删除容器不可以容器列表长度变化必须重建标签、注解不需要升级直接 patch 即可ConfigMap 内容变化间接依赖 kubelet 的挂载刷新机制,与原地升级无关所以 Kruise 的更新策略里有个关键选项叫InPlaceIfPossible:能原地就原地,不能原地就自动回退到重建。这个能不能的判定,就是源码里第一层要看的逻辑。很多人在生产上误以为开了 Kruise 就全都原地升级,结果改了资源规格发现 Pod 还是被重建了,原因就在这里。1.3 和容器热加载、滚动重启的区别在哪还有一个概念经常被混淆。原地升级不等于业务进程热加载。原地升级的执行结果是容器被 kill 然后重新创建,进程是被重启的,只是 Pod 外壳没换。它省掉的是 Pod 沙箱重建、调度、网络配置、卷挂载这些开销,省不掉进程启动本身的时间。如果你的服务本身支持配置热加载或者二进制热替换,那是应用层的优化,和 Kruise 的原地升级是两个正交的层面。反过来说,如果你的服务启动就要两分钟,原地升级只是把两分钟冷启动 三十秒 Pod 重建压缩成两分钟冷启动,收益依然可观,但别指望它变成零中断。另外要说明的是,原生 Deployment 不会被原地升级。Kruise 的原地升级是由它自己的 workload 控制器实现的,落在 CloneSet、Advanced StatefulSet、Advanced DaemonSet 这些 CRD 上。Deployment 背后的 ReplicaSet 控制器走的是原生逻辑,装不装 Kruise 都一样重建。想用这个能力,工作负载本身就得换成对应的 CRD。2. 进入源码前的第一层:控制器怎么判定这个 Pod 能原地改2.1 InPlaceUpdate 接口的四个方法各管什么Kruise 把原地升级的通用逻辑抽成了一个独立的工具包,不绑定具体 workload,由各控制器按需调用。这个抽象层定义了一个接口,核心方法大致四类:type Interface interface { // 判断这个 Pod 是否满足原地升级的前置条件 CanUpdateInPlace(pod *v1.Pod) bool // 计算升级完成后 Pod 应该长什么样,并给出目标 revision GetUpdateSpecAndRevision(ctx context.Context, pod *v1.Pod) (*v1.PodSpec, string, error) // 执行升级:写状态注解 更新 Pod spec Update(ctx context.Context, pod *v1.Pod, opts *UpdateOptions) (bool, error) // 根据 Pod 最新状态刷新注解中的阶段与镜像 ID Refresh(ctx context.Context, pod *v1.Pod, opts *UpdateOptions) error }CanUpdateInPlace是最便宜的一层过滤,只看 Pod 自身属性,不查任何外部资源。GetUpdateSpecAndRevision是纯计算,把 Pod 当前 spec 和目标规格做对比,产出一个如果原地升级,Pod 应该变成什么样的期望 spec。Update是真正写 etcd 的动作。Refresh比较特殊,它不写 spec,只在 Pod 发生变化时把注解里的状态同步一次,由控制器的 Pod 事件回调触发。把职责拆成四块的好处很明显:控制器只需要提供目标规格怎么算这一个业务函数,其余所有状态机、注解格式、重试逻辑都是共享的。代价是这层抽象的入参出参设计得很克制,UpdateOptions里塞了几个回调:type UpdateOptions struct { GracePeriodSeconds int64 PreCheckBeforeUpdate func(pod *v1.Pod) error CheckPodIsUnreleased func(pod *v1.Pod) bool CheckInPlaceUpdateCompleted func(ctx context.Context, pod *v1.Pod) error UpdateSpec func(pod *v1.Pod) error }PreCheckBeforeUpdate给调用方留了一个最后再确认一次的钩子,比如检查 Pod 是不是已经被别的控制器接管了。CheckPodIsUnreleased用来处理一种边缘情况:某些工作负载有未发布的概念,比如 Advanced StatefulSet 的reserveOrdinals或者正在删除的 Pod,这类 Pod 即使镜像不匹配也不应该被推动升级。CheckInPlaceUpdateCompleted是完成判定的可插拔实现,默认实现会去比对容器状态里的 ImageID。2.2 计算目标 spec 时的字段比对逻辑GetUpdateSpecAndRevision的实现思路值得展开。它不是简单地把新模板里的 image 拷过去,而是走了一遍结构化对比:从新的 workload 模板里取出 containers 和 initContainers 的完整定义。和 Pod 现有的 containers 逐个按 name 匹配。如果 name 集合完全一致,只把 image 字段替换成新值,其余字段保持 Pod 原样。如果 name 集合不一致(新增或删除了容器),或者除 image 之外还有其他字段差异,就判定为不可原地升级。第三步里有个容易被忽略的细节:替换 image 之后,还要判断替换前后的 image 字符串是否真的不同。如果新模板里的镜像地址和 Pod 里的一模一样,说明这个 Pod 本来就已经是目标版本了,直接返回无需更新。这个判断看起来很平凡,但它决定了后面Update方法会不会被调用,也就决定了会不会多写一次 etcd、多占用一次并发额度。在几千个 Pod 的规模下,这种无效写入的成本不容忽视。还有一个更隐蔽的问题:镜像相同不代表内容相同。如果你的发布流程是覆盖同一个 tag(比如永远用myapp:latest或者myapp:release),那么新模板和旧 Pod 里的 image 字符串完全一致,Kruise 会认为这个 Pod 已经达标,连注解都不会写,自然也不会触发任何升级。这个坑我后面会专门展开。2.3 那些被直接跳过的 Pod:删除中、非 Always、未就绪CanUpdateInPlace里排除了好几类 Pod,这些排除条件看着琐碎,但每一条都有实际的故障背景:pod.DeletionTimestamp ! nil:Pod 正在删除,再推它升级毫无意义,还会和 GC 抢资源。pod.Spec.RestartPolicy ! v1.RestartPolicyAlways:只有 Always 才能保证容器被 kill 后一定重启。如果 Pod 是 OnFailure 或者 Never,改了镜像之后 kubelet 可能不会重新拉起容器,Pod 会直接进入终态。Pod 还处于 Pending 或者没有分配到节点:没有节点就没有 kubelet,镜像改了什么都不会发生。Pod 已经是目标 revision:通过注解或 label 上的 revision 标识判断,避免重复处理。这里面最值得警惕的是第二条。大部分在线服务的 Pod 都是默认的 Always,但如果你手工创建过测试 Pod,或者某些 Job 类负载被误纳入了 CloneSet 管理,这一条就可能让升级静默失败——不是报错,是直接跳过,日志里只有一行 debug 级别的输出,排查起来很费时间。我的建议是给这类异常 Pod 加一个告警,至少在控制器的 metric 里能看到被跳过的计数。3. 状态机与注解:一次升级在 etcd 里留下的足迹3.1 kruise.io/inplace-update-state 里到底存了什么原地升级的全部过程状态都寄存在 Pod 的一个注解里,key 是kruise.io/inplace-update-state,value 是一段 JSON。这个设计有好有坏:好处是不需要额外的 CRD 存状态,状态和 Pod 同生命周期、天然一致;坏处是注解有大小限制,而且 JSON 结构一旦变更要考虑版本兼容。结构体的核心字段如下:type InPlaceUpdateState struct { Revision string json:revision UpdateTimestamp metav1.Time json:updateTimestamp LastContainerStatuses map[string]InPlaceUpdateContainerStatus json:lastContainerStatuses,omitempty ContainerStates []InPlaceUpdateContainerState json:containerStates } type InPlaceUpdateContainerState struct { ContainerName string json:containerName BeforeImage string json:beforeImage,omitempty BeforeImageID string json:beforeImageID,omitempty AfterImage string json:afterImage AfterImageID string json:afterImageID,omitempty UpdateStage InPlaceUpdateStageType json:updateStage }Revision记录这次升级对应的目标版本号,和 workload 模板的 hash 关联,用来在多次发布之间做区分。UpdateTimestamp是升级开始时间,后面算超时、算发布耗时都靠它。ContainerStates是按容器维度记录的状态,每个容器一份,包含了升级前后的镜像地址和镜像 ID。BeforeImage/BeforeImageID这一对是回滚和完成判定的关键。它记录的是这次升级开始前,这个容器实际运行的镜像是什么。之所以要单独记BeforeImageID而不只是记 image 字符串,是因为字符串可以相同但内容可以不同(又是同一个 tag 覆盖的问题)。只有拿到运行时的 imageID(形如docker-pullable://registry/xxxsha256:...),才能唯一确定基线。LastContainerStatuses记录的是升级前容器的运行状态快照,包括 restartCount、lastState 等。它在排查这次升级是不是导致了多次重启的时候很有用,也用于判断容器在升级前是否处于异常状态——如果升级前就已经 CrashLoopBackOff,那升级后起不来就不能怪新镜像。3.2 五个阶段之间的跃迁条件UpdateStage是一个枚举,默认实现里大致有这么几个值:阶段含义跃迁到下一阶段的条件UpdatePreparing已写入注解,但还没改 spec所有容器的 BeforeImageID 都已记录,且完成预拉取判断UpdatePreImagePulled新镜像已预拉取完成(可选路径)写上 spec 中的新 imageUpdateInPlaceUpdatingspec 已更新,kubelet 正在处理容器状态中的 ImageID 与 AfterImageID 一致UpdateInPlaceUpdatedFailed更新后容器未能正常起来一般停留在此,需要人工介入或触发重建UpdateCompleted升级完成终态,注解可被清理这里有几个设计意图值得说。第一,UpdatePreparing和UpdateInPlaceUpdating被拆成了两个阶段,原因是写注解和改 spec 是两次 etcd 写入,中间存在时间窗。如果一次性写进去,controller 崩溃在中间态时,重启后就无法区分注解写了但 spec 没改和spec 改了但注解没写两种情况。拆开之后,状态机每一步都是幂等的,重入时可以从注解里的阶段继续。第二,UpdateInPlaceUpdatedFailed这个阶段的存在说明作者预设了改完镜像容器起不来的场景。默认的完成判定逻辑里,如果容器在升级后进入了 CrashLoopBackOff 或者长时间不 Ready,会落到这个阶段而不是一直挂在 Updating。落到这个阶段后,控制器会把它当作不可用计算在maxUnavailable里,避免这个 Pod 拖住整个发布。第三,注解的清理时机。理论上UpdateCompleted之后就可以删注解了,但实际实现里通常会保留一小段时间,原因是有些场景需要在升级完成后还能读到 BeforeImage,比如外部的审计或者回滚工具。具体保留策略要看版本,不同版本的实现有差异。3.3 为什么要先等一次基线快照再改 imageUpdatePreparing这个阶段看起来多余,但它解决了一个非常现实的问题:Pod 里某个容器可能还没真正启动过,它的 ImageID 是空的。想象一个刚创建完十几秒的 Pod,容器还在拉镜像,kubelet 已经写了containerStatuses但ImageID字段可能为空或者只有 image 字符串没有 digest。这时候如果直接改 spec 推升级,BeforeImageID就记不下来,后续做完成判定或者失败分析时就失去了基线。更严重的是,如果这个容器本来就是要被升级的那一个,你改了 spec 之后它可能直接就用新镜像启动了,升级前这个状态永远不存在。所以实现里的做法是:第一轮Update只写注解、不改 spec,把 state 里的 BeforeImage 和 BeforeImageID 从当前 Pod status 里抓出来,阶段置为UpdatePreparing,然后返回。下一轮 reconcile 时再检查每个容器的 BeforeImageID 是否都已经非空,全部就绪才真正去改 spec。这个两阶段提交的代价是多一次 etcd 写入和多一个 reconcile 周期,但换来的是完整的状态可追溯性。这个细节对排查特别有用。如果你看到某个 Pod 的注解一直停在UpdatePreparing不动,基本可以断定是这个 Pod 里至少有一个容器从来没有成功启动过——去看 Pod 事件,十有八九是镜像拉不下来或者调度后卷挂载失败。4. kubelet 接到变更之后:镜像切换的下半场4.1 computePodActions 如何发现镜像变了控制器把 Pod spec 里的 image 改完之后,这件事在上半场就结束了。真正把旧容器杀掉、拉新镜像、启动新容器的是 kubelet,触发点是 kubelet 的 Pod worker 感知到 Pod spec 变化。kubelet 侧的核心逻辑在computePodActions这个函数里。它的工作是对比期望的 Pod 状态和实际运行的状态,产出一个需要执行的动作列表。对每个容器,判断逻辑大致是:从pod.Spec.Containers里取出期望的 image。从pod.Status.ContainerStatuses里取出当前容器的 ImageID。通过容器运行时(containerd / CRI-O)解析期望 image 对应的 imageID。如果两者不一致,标记这个容器需要重启。关键在于第 3 步——kubelet 比较的是 imageID,不是 image 字符串。这意味着即使你把 spec 里的 image 从app:v1改成app:v2,kubelet 也必须先向运行时查询app:v2对应的实际 digest,拿到之后才能和当前运行的 digest 比对。如果运行时的 pull policy 是IfNotPresent且本地已经有同名镜像,kubelet 可能直接复用本地的那一份,这时如果 tag 被覆盖过内容,就会出现升级执行了但跑的还是旧代码的情况。还有一个时间差需要考虑。从控制器改完 spec 到 kubelet 真正 kill 容器,中间隔着 kubelet 的 syncLoop 周期、Pod worker 的排队、可能还有 image pull 的时间。这段时间里 Pod 在 etcd 里看起来已经是新版本了,但容器还在跑旧进程。如果你的发布流程是改动完就立刻去验证版本号,大概率会读到旧值。4.2 完成判定:从容器状态反推升级结果控制器怎么知道一个 Pod 的原地升级到底成功了没有?答案是定期回头读 Pod 状态,做一次反推。默认的CheckInPlaceUpdateCompleted逻辑大致是:func checkInPlaceUpdateCompleted(pod *v1.Pod) error { state : getInPlaceUpdateState(pod) for _, cs : range state.ContainerStates { status : findContainerStatus(pod, cs.ContainerName) if status nil { return fmt.Errorf(container %s status not found, cs.ContainerName) } // 容器还没有用上新镜像 if cs.AfterImageID || status.ImageID ! cs.AfterImageID { return fmt.Errorf(container %s imageID not matched, cs.ContainerName) } // 容器虽然用了新镜像,但没有处于运行状态 if status.State.Running nil status.State.Waiting nil { return fmt.Errorf(container %s not running, cs.ContainerName) } } return nil }这段逻辑有几个隐含要求。第一,AfterImageID必须提前被填上。它的来源是Refresh方法——控制器在 Pod 事件回调里看到容器状态更新了,就把实际的 ImageID 写回注解,同时把阶段推进到UpdateCompleted。也就是说,注解里的状态不是控制器预设的,而是从容器实际状态回填的。第二,判定是全部容器都匹配才算完成,不是任意一个。对多容器 Pod 来说这很重要,一个三容器的 Pod 里如果只有主容器升级了、sidecar 还没轮到,整体不会被标记为完成,也就不会被计入可用副本。这个设计选择偏保守,但避免了部分升级的中间态被误认为成功。第三,status.State.Running ! nil这个条件有微妙之处。容器的最终状态可能是Waiting(还在启动)或者Running(已经跑起来),两者被区别对待会因为版本而异。我在自己的实践里见过一种情况:新镜像能启动但立刻退出,容器进入Terminated然后被拉起,此时State.Running为 nil 而State.Waiting也短暂为 nil,判定会失败并被标记为UpdateInPlaceUpdatedFailed。这时候需要去看LastTerminationState里的退出码,才能定位是镜像问题还是应用问题。4.3 预拉取把不可用窗口压到多短如果一个节点的镜像仓库带宽不好,拉一个几百 MB 的镜像可能要一两分钟。这段时间容器是停的,Pod 是不 Ready 的,业务是受影响的。原地升级虽然省了 Pod 重建,但省不掉拉镜像。Kruise 生态里有一个配套能力就是解决这个问题的:在真正改 spec 之前,先把新镜像拉到节点上。实现路径大致有两种,一种是走 daemon 组件监听 Pod 上的预拉取注解,另一种是用独立的镜像预热任务资源批量执行。前者适合跟发布流程绑定的场景,后者适合版本发布前的大规模预热。预拉取生效后,升级窗口就从拉镜像 重启容器缩短到了重启容器,对启动快的服务来说,单 Pod 的不可用时间可以从分钟级压到秒级。这里有一个实操建议:预拉取的注解 key 在不同版本里有过调整,升级前一定要对照你所用版本的常量定义确认,不要凭记忆写。我有一次按旧版本的 key 写了注解,结果预拉取静默不生效,发布窗口翻了三倍才发现问题。5. 灰度节奏与回滚:把原地升级放到生产环境的调参方式5.1 partition、maxUnavailable、paused 三者的组合语义CloneSet 的更新策略里有三个参数会直接影响发布节奏,单独看都很简单,组合起来容易踩坑:apiVersion: apps.kruise.io/v1alpha1 kind: CloneSet spec: replicas: 20 updateStrategy: type: InPlaceIfPossible partition: 5 maxUnavailable: 20% paused: false inPlaceUpdateStrategy: gracePeriodSeconds: 30partition的语义是保留多少个 Pod 不升级,注意是保留数量,不是升级数量。上面这个例子,20 个 Pod、partition 为 5,意思是只升级 15 个,剩下 5 个维持旧版本。它常被用来做灰度:先 partition 设大一点只放少量 Pod 过去,观察一段时间再逐步减小。这个语义和 StatefulSet 的 partition 相反,从原生 StatefulSet 迁过来的人经常搞反。maxUnavailable限制的是同一时刻最多有多少个 Pod 处于不可用状态,它是防止发布把整个服务打垮的闸门。这个值可以是绝对值也可以是百分比,计算基数是不满足replicas - partition的那部分。原地升级在执行时,控制器会把正在升级中的 Pod 计入 unavailable,所以这个值设得太小会让发布变得极其缓慢——每个 Pod 升级完、等 Ready、再等minReadySeconds,才能处理下一个。paused是全局开关,置为 true 后控制器完全停止推进更新,但不会回滚已经升级的 Pod。它适合用来做发布到一半发现问题,先冻结住的应急操作。需要注意的是,paused 期间如果 Pod 因为其他原因(比如节点故障)被重建,重建出来的 Pod 会使用新模板——也就是说 paused 不能保证版本一致,它只是暂停了主动推进。5.2 gracePeriodSeconds 优雅原地升级的实现路径默认的原地升级是说杀就杀:spec 一改,kubelet 检测到差异就发 SIGTERM,等 terminationGracePeriodSeconds 之后 SIGKILL。这对有正在处理中的请求的服务不友好。inPlaceUpdateStrategy.gracePeriodSeconds提供了一个额外的缓冲层。它的实现思路不是去改 kubelet 的行为(那做不到),而是在控制器侧把改 spec这个动作延后:先给 Pod 打上一个标记,让流量先从它身上摘掉,等过了这段时间再去改 spec 触发真正的容器重启。这个能力的实际效果依赖于你的流量摘除机制。如果你的 Service 是通过 readinessProbe 来判断的,那在 gracePeriodSeconds 期间 Pod 依然是 Ready 的,流量不会被摘掉,这段等待就白等了。要让它真正生效,需要配合 readinessGate 或者主动把 Pod 的 Ready 条件置为 false。这一点在文档里往往讲得比较含蓄,但它是决定这个功能有没有用的关键。我在一次实践中把 gracePeriodSeconds 设成了 30 秒,然后观察发布期间的错误率,发现几乎没有变化。查下来原因是业务侧的 readinessGate 没有配置,Service endpoints 在等待期间仍然包含这个 Pod。后来补上了自定义的 readinessGate,由应用在收到 SIGTERM 后主动把 gate 置为 false,才真正实现了平滑下线。5.3 出问题时的回滚路径与它的局限原地升级的回滚比想象中复杂。因为 Pod 本身没被销毁,回滚的方式就是把 spec 里的 image 改回旧值,再走一次原地升级。注解里记录的BeforeImage/BeforeImageID这时候就派上用场了。但这里有几个限制必须清楚:回滚只能回滚镜像,回滚不了其他字段。如果你这次发布同时改了环境变量,那部分变更在就地升级时根本没生效(会被判定为不可原地升级而走重建),回滚时也就无从谈起。旧镜像可能已经从节点上被清理了。kubelet 有镜像回收策略,节点磁盘压力大的时候会清理不常用的镜像。回滚时如果旧镜像不在本地,还是要重新拉,回滚窗口和正向发布一样长。回滚不是原子的。已经升级到新版本的 Pod 会逐个回退,中间存在新旧版本混跑的窗口。如果新版本改过数据库 schema,这个窗口就是危险的。所以我的建议是:把原地升级当成快速修正小问题的手段,而不是完整回滚方案。涉及数据结构变更的发布,还是要走标准的灰度 双写 数据迁移流程,原地升级只是让这个过程里的 Pod 抖动小一点。6. 实测踩过的六个坑6.1 tag 没变导致升级被静默跳过这是最常见也最难察觉的一个。如果你的 CI 流程产出的是固定 tag(比如按分支名打 tag),两次提交之间 tag 不变但镜像内容变了,Kruise 在比对 spec 时会认为 image 字段没有变化,直接判定无需更新。整个发布流程看起来跑完了,kubectl rollout status也返回成功,但节点上跑的还是旧代码。判断方法很简单:看 Pod 注解里有没有inplace-update-state。如果没有,说明控制器认为这个 Pod 根本不需要升级。解决方式是让每次构建产出唯一 tag(用 commit hash 或时间戳),或者直接用 digest 引用镜像。用 digest 的另一个好处是,即使 tag 被覆盖,kubelet 也能准确识别出内容变化。6.2 Pod 卡在 Updating 阶段不前进现象是注解里的 stage 一直停在UpdateInPlaceUpdating,AfterImageID是空的,Pod 的容器状态里 image 已经是新的但 imageID 没更新。这种情况我遇到的原因有三类:第一类是镜像拉取卡住。容器处于ContainerCreating,Waiting 原因是ImagePullBackOff。看 Pod 事件能直接定位。第二类是AfterImageID的匹配逻辑和运行时的返回格式不一致。不同容器运行时返回的 ImageID 格式有差异,有的带docker-pullable://前缀,有的不带。如果集群里混用了不同运行时,匹配可能失败。这种问题的特征是容器明明跑起来了、业务也正常,就是状态机不往前走。第三类是控制器重启导致的注解丢失。如果控制器在写完 spec 但还没来得及写回 AfterImageID 时被重启,下一轮 reconcile 会从注解里读到 Updating 阶段,但注解里可能缺了一些上下文。好在设计上 Refresh 方法会在每次 Pod 事件时重新读容器状态并回填,所以通常会自动恢复,只是会多花几个周期。6.3 多容器 Pod 的完成判定是全部而非任一一个三容器的 Pod,主容器升级成功、两个 sidecar 因为镜像地址在新模板里没变所以不需要升级。这种情况下,ContainerStates里应该只有主容器一条记录,还是三条都有?实际实现里,ContainerStates只为镜像发生变化的容器建条目。没变化的容器不会出现在里面,也就不会参与完成判定。这个行为是合理的,但如果你在写自定义的完成判定回调时假设了所有容器都在列表里,就会出错。6.4 initContainer 与 sidecar 的特殊处理initContainer 的镜像变化能不能原地升级?答案是能,但效果和你想的可能不一样。initContainer 在 Pod 生命周期里只运行一次,原地升级改了它的镜像之后,kubelet 不会重新执行 initContainer——因为 Pod 已经过了初始化阶段。新的 initContainer 镜像只会在下次 Pod 重建时生效。这个行为在语义上其实是对的:initContainer 本来就该是幂等的初始化逻辑,不应该指望它在运行中重跑。但如果你的业务把一些每次启动都要做的动作放在了 initContainer 里(比如注册、拉配置),原地升级后这些动作就不会执行,容器直接起来,可能因为缺少前置条件而失败。这类负载不适合原地升级,应该改用 sidecar 或者应用内自行处理。sidecar 的处理则需要依赖 SidecarSet 这类独立机制。如果你用的是注入式的 sidecar(比如通过 webhook 注入),它的镜像不在 workload 模板里,原地升级的比对逻辑根本看不到它,自然也不会升级它。这是很多服务网格场景下的盲区。6.5 readinessGate 与 Service 摘流量的抖动前面提过,原地升级期间容器的重启会导致 Pod 短暂不 Ready。如果你的 Service 是直接根据 Ready 状态摘流量的,那么这个 Pod 会经历一次从 endpoints 里移除再添加。对短连接服务来说没什么,但对长连接服务来说,摘流量这个动作本身不会断开已有连接,真正断开的是容器被杀的那一刻。要减少抖动,思路是在容器被杀之前就把流量摘掉。这需要:Pod 上配置自定义 readinessGate。应用在收到 SIGTERM 后,先把 gate 置为 false,同时等待一段时间让存量请求处理完。控制器侧的 gracePeriodSeconds 要大于应用侧的等待时间。这三步缺一不可。我见过只做了第一步和第三步、忘了第二步的配置,结果 gate 一直是 true,摘流量完全没发生,白等了几十秒。6.6 资源规格变更导致的 fallback 重建这一条严格说不算 bug,但经常让人困惑。发布时如果同时改了镜像和资源规格,InPlaceIfPossible策略会判定为不可原地升级,自动降级为重建。这时候你在监控上会看到一批 Pod 的 IP 变化、启动时间变长,和没装 Kruise 时一模一样。判断依据是看 Pod 注解:如果注解里没有inplace-update-state但 Pod 确实被换掉了,说明走的是重建路径。想要避免这种情况,就得把资源变更和镜像变更拆成两次发布,先做资源变更(会重建一次),稳定后再做镜像变更(走原地升级)。还有一个容易忽略的字段:terminationGracePeriodSeconds。这个字段在 Pod spec 和容器 spec 里都可以设置,只要有一处变化,同样会导致判定为不可原地升级。改这个值的时候要做好 Pod 会被重建的心理准备。把这条链路完整走一遍会发现,原地升级的技术难度其实集中在状态机的一致性上,而不是改镜像这个动作本身。控制器需要在一个可能被随时中断的环境里,把我要升级这个 Pod拆成若干个可重入的步骤,并且保证每一步失败后重启都能从注解里恢复上下文。理解了这一点,再看那些看起来啰嗦的阶段枚举和双重判定,就会明白它们不是过度设计,而是被真实故障逼出来的。我个人的建议是:第一次接入时不要直接改生产工作负载,先起一个小的 CloneSet,把 partition 设大、maxUnavailable 设成 1,观察几个 Pod 走完全流程的耗时和注解变化,把每个阶段的持续时间记下来。等你对这些数字有手感了,再逐步放开灰度比例。原地升级省下的是 Pod 重建的开销,但状态机本身带来的复杂度是实实在在的,值得花一下午先把它的行为摸清楚。
阅读完成 · 觉得有帮助?
咨询建站