
这段时间我排查了一起挺有意思的线上故障订单服务做了一次常规版本更新结果关联的支付服务Pod竟然也跟着重启迁徙了。单看两条事件记录一个Deployment滚动更新一个Pod被驱逐重建好像互不相干但串起来看背后是Kubernetes调度器在“Pod更新”时如何重新评估位置以及它如何带动“关联Pod”一起迁移的完整链路。这块内容属于K8s里比较进阶的知识点涉及调度器的调度时机、滚动更新机制、反亲和性约束以及Descheduler这类事后重调度组件。对K8s运维、SRE以及想深入理解调度器原理的开发者来说把这条链路理清楚以后排查类似“明明只动了A服务B服务却出现了异常”的问题会快很多。这篇文章就从我那次故障入手把Pod更新触发关联Pod迁移的逻辑拆开揉碎讲一讲并附上可以完全复现的实验步骤和排查经验。1. 场景拆解一次更新为什么会带崩关联应用1.1 从一次滚动更新说起我们的环境是一个三节点的K8s集群订单服务和支付服务各跑一个副本。订单服务需要依赖支付服务两个Pod之间有一条反亲和性规则订单Pod和支付Pod不能落在同一个节点上。之所以这么设计是为了避免单节点故障时两个核心应用同时挂掉算是一种高可用策略。某次版本迭代订单服务的镜像从 nginx:1.24 升到 nginx:1.25同时我们把资源requests从cpu: 100m, memory: 256Mi调整到了cpu: 200m, memory: 512Mi。改完后我执行了kubectl set image deployment/order ordernginx:1.25滚动更新正常开始。但几分钟后监控告警弹出支付服务延迟飙升随后支付Pod在事件里出现了一条Evicted记录。当时我第一反应是节点资源不够被驱逐了但查了node状态发现各节点负载都正常。看了调度器事件才明白订单服务更新后的新Pod被调度到了原本支付Pod所在的节点而支付Pod的反亲和规则是基于“节点上是否存在apporder的Pod”判断的。新订单Pod落进来后这两个Pod在物理上已经违反了反亲和约束而Descheduler组件检测到违规直接对支付Pod执行了驱逐重建。1.2 关联Pod迁移的完整触发条件很多人对调度器有个误解以为K8s会在Pod运行期间持续检查约束一旦发现违反就自动纠正。实际上不是这样。调度器只在Pod进入Pending状态时参与决策一旦Pod被绑定到一个Node上并成功运行调度器就不再管它了除非这个Pod被删除、驱逐或者节点异常导致它重新创建。所以“Pod更新触发关联Pod迁移”至少需要满足三个条件更新操作改变了新Pod的调度结果比如改了资源requests、节点亲和性、nodeSelector或者集群里可用资源发生变化新Pod的新位置和某个关联Pod的分布约束产生了冲突集群里有一套“事后纠正”机制比如Descheduler、自定义控制器或者有人手动kubectl drain/kubectl delete pod。缺了任何一个环节你看到的更新就只是单纯更新不会引发关联Pod的连锁反应。这也是为什么“只更新一个服务”变成“两个服务一起抖动”时很多人会抓瞎的原因——他们没意识到调度结果的变化会影响到第三方的约束判断。2. 调度器的角色为什么运行中的Pod不会被自动重调度2.1 调度时机和调度流程K8s默认调度器kube-scheduler的工作流程可以简化成四步监听Pod变化、过滤可行节点、给可行节点打分、绑定最优节点。整个过程发生在Pod被创建之后、真正落到Node上之前的这段时间窗口内。一旦Pod通过APIServer写入到了某个Node的绑定信息Pod的调度过程就结束了。之后无论这个节点的负载多高、亲和性约束多不合理kube-scheduler都不会来过问。这就是“IgnoredDuringExecution”语义的核心——约束只在调度时生效运行期间被忽略。这里用一个类比帮助理解调度器像酒店前台办理入住时按你的要求无烟房、高层、安静分配房间。但入住之后前台是不会隔三差五来检查你是否真的在无烟房抽烟的。除非你自己退房重开或者酒店安保对应Descheduler把你请出去重新办入住否则你就在那间房一直住下去。2.2 Pod更新如何改变调度结果Deployment的滚动更新本质上是创建了一个新的ReplicaSet新RS里的Pod会重新走一遍调度流程。也就是说“更新”这个动作天然自带“重新调度”的基因——只要Pod模板发生变化就会冒出一批需要调度器决策的新Pod。那么有哪些常见变更会导致新Pod和旧Pod的调度结果完全不同第一是资源requests变化。旧Pod如果memory: 256Mi调度器会优先放在资源余量大的节点改成memory: 512Mi后可能原来那个节点装不下了只能去别的节点。这是最常见的原因。第二是节点亲和性、nodeSelector、拓扑分布约束的变更。比如给订单服务增加了一条nodeSelector强制让它调度到node2那不管别的节点多空闲新Pod都只会去node2。第三是镜像大小和启动时的资源峰值。镜像从100MB变成1GB拉取时间变长新Pod长时间处于ContainerCreating状态调度器在打分时不会直接考虑镜像大小但Pod一直未Ready会影响到依赖它的关联服务的可用性判断。第四是集群当前的空闲资源状态。旧Pod创建时节点A还有2G可用过了几天节点A被其他业务堆满了新Pod即使资源规格完全不变也可能被调度器分到节点B。2.3 为什么关联Pod会被“牵连”回到我们的反亲和场景。支付服务的是这么定义的affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app: order topologyKey: kubernetes.io/hostname它的意思是支付Pod调度时目标节点上不能存在带有app: order标签的Pod。这个规则在支付Pod创建时是满足的——订单在node1支付就去了node2。问题在于更新后的订单Pod被调度到node2后node2上同时存在app: order和app: payment两个Pod。这个状态是违规的。如果没有任何事后纠正机制支付Pod会一直待在node2上直到它被删除或者节点故障。所以我们常说“反亲和只保证调度那一刻的合理不保证集群时刻合理。”2.4 承担“事后纠正”职责的组件那谁来当“酒店安保”最常用的是Descheduler它是在K8s社区里专门用来处理“运行中Pod分布不合理”的组件。它默认每隔一段时间扫描一次集群根据策略找出可以被驱逐的Pod然后调用Eviction API优雅终止这些Pod让它们重新走一遍调度流程。Descheduler的典型策略包括PodAntiAffinity检查违反反亲和/亲和约束的PodNodeAffinity检查不再满足节点亲和性规则的PodLowNodeUtilization将高负载节点上的Pod迁移到低负载节点TopologySpread检查拓扑分布是否为前缀。在订单更新这个场景中真正生效的就是PodAntiAffinity策略。3. 原理解析从更新触发到关联迁移的完整链路3.1 滚动更新内部的调度时序为了精确描述这个链路我画一条时间线出来虽然不能画图但用文字描述更清晰你执行kubectl set image deployment/order ordernginx:1.25Deployment Controller创建新RS新RS根据模板创建新Pod新Pod进入Pendingkube-scheduler发现它开始过滤和打分调度器根据当前资源、亲和性、反亲和、污点容忍等条件将新Pod绑定到某个Nodekubelet在新Node上拉取镜像、启动容器Pod进入Running/ReadyDeployment Controller发现新RS副本数满足预期开始缩容旧RS删除旧订单Pod此时如果新Node上存在支付Pod且支付Pod的反亲和规则与新订单Pod冲突Descheduler在下一个扫描周期发现违规驱逐支付Pod支付Pod被驱逐后由它所属的Deployment Controller重新创建新Pod重新调度到不冲突的节点。整个链路里关联Pod的迁移其实分为“违规检测”和“重新调度”两个阶段。检测靠的是约束匹配重新调度靠的还是调度器本身。3.2 约束冲突的判定逻辑Descheduler的PodAntiAffinity策略是怎么判定违规的呢核心逻辑其实很简单它会遍历集群中所有带有反亲和规则的Pod找出该Pod的规则里选中的LabelSelector再去所有节点上检查是否存在匹配该Selector的其他Pod。如果找到了说明这个Pod的调度位置和它的反亲和规则冲突就把它标记为可驱逐对象。为了不误杀太多Descheduler还提供了threshold参数。比如设置threshold: 10意思是至少检测到10个违反规则的Pod才执行驱逐。我们测试环境规模小一般配置成1或2就够了生产环境建议设个合理阈值避免集群抖动。3.3 重新调度的目标选择支付Pod被驱逐后新支付Pod进入Pending调度器会重新计算可行节点。此时node2上有订单Pod根据required反亲和规则node2被过滤掉。如果集群里还有node1和node3调度器会从中选一个打分最高的节点绑定。这就完成了“Pod更新触发关联Pod迁移”的完整闭环更新→新Pod换个位置→关联Pod违规→被驱逐→重新调度到合适位置。4. 实操复现Pod更新触发关联Pod迁移这一节我给出一个可以完整复现的实验你只需要一个测试环境建议至少两个Node。如果只有单节点可以把反亲和的topologyKey改成kubernetes.io/os之类的其他拓扑域来模拟但最理想还是两个节点。4.1 准备两个Deployment先创建一个订单Deployment不带任何调度约束让它自然调度到node1apiVersion: apps/v1 kind: Deployment metadata: name: order labels: app: order spec: replicas: 1 selector: matchLabels: app: order template: metadata: labels: app: order spec: containers: - name: order image: nginx:1.24 resources: requests: cpu: 100m memory: 256Mi再创建支付Deployment加上硬反亲和要求不能和app: order的Pod在同一个Node上apiVersion: apps/v1 kind: Deployment metadata: name: payment labels: app: payment spec: replicas: 1 selector: matchLabels: app: payment template: metadata: labels: app: payment spec: affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app: order topologyKey: kubernetes.io/hostname containers: - name: payment image: nginx:1.24 resources: requests: cpu: 100m memory: 256Mi依次部署kubectl apply -f order-deployment.yaml kubectl apply -f payment-deployment.yaml然后观察Pod分布kubectl get pods -o wide正常情况下两个Pod会分别在两个不同节点上。4.2 修改调度约束让更新的订单Pod换节点现在给订单Deployment打一个patch强制新Pod只能调度到node2kubectl patch deployment order -p {spec:{template:{spec:{nodeSelector:{kubernetes.io/hostname:node2}}}}}这个操作会触发一次滚动更新新的订单Pod将无视其他节点直接绑定到node2。等到新的订单Pod变成Running后你会发现一个问题node2上同时跑着订单Pod和支付Pod而支付Pod的反亲和规则已经被破坏了。这时立刻查看支付Pod的事件你会发现它没有任何动静还好好地待在node2上。这是因为没有事后纠正机制运行中的Pod不会自己跑路。4.3 接入Descheduler让关联Pod自动迁移接下来部署Descheduler。最简单的方式是用Helmhelm repo add descheduler https://kubernetes-sigs.github.io/descheduler/ helm install descheduler descheduler/descheduler \ --namespace kube-system \ --set deschedulerPolicy.strategies.podAntiAffinity.enabledtrue \ --set deschedulerPolicy.strategies.podAntiAffinity.params.threshold1Descheduler默认以CronJob或DaemonSet方式运行扫描周期可以通过--descheduling-interval参数控制。默认是两分钟一次测试时可以缩短helm install descheduler descheduler/descheduler \ --namespace kube-system \ --set deschedulerPolicy.strategies.podAntiAffinity.enabledtrue \ --set deschedulerPolicy.strategies.podAntiAffinity.params.threshold1 \ --set deschedulerInterval30s大约30秒后你就会在事件里看到支付Pod被驱逐的记录kubectl get events --sort-by.lastTimestamp | grep payment被驱逐的支付Pod会被Deployment Controller重建新Pod调度时会重新检查反亲和条件发现node2上已经有订单Pod于是选择其他节点。此时再执行kubectl get pods -o wide支付Pod已经换节点了。4.4 观察验证整个迁移链路为了让整个过程更直观建议用一个终端循环观察Pod分布变化watch -n 2 kubectl get pods -o wide | grep -E NAME|order|payment你大概会看到这样一个变化过程阶段订单Pod节点支付Pod节点初始node1node2订单更新后node2node2Descheduler驱逐后node2node1这个表格就是Pod更新触发关联Pod迁移最精简的验证结果。我把“节点”换成真实节点名做实验时整个验证过程不过几分钟。5. 进阶自定义控制器实现“更新后主动迁移关联Pod”Descheduler是通用方案但如果你的关联迁移规则业务属性很强比如“订单服务每次更新后支付服务必须跟着迁移到另一个可用区”那Descheduler就不够灵活了。这时候可以写一个自定义控制器监听指定Deployment的更新事件一旦发现Pod模板变化就主动驱逐关联的Pod。5.1 设计思路控制器的核心逻辑就三步监听目标Deployment比如order的ReplicaSet版本变化当检测到新的ReplicaSet创建即发生滚动更新时获取关联Pod列表对这些Pod调用Eviction API让它们重新调度。这里的关键是“如何判断一次更新是否触发迁移”。简单做法是监听Deployment的generation或observedGeneration变化复杂一点可以比较PodTemplate的hash值。为了减少重复迁移建议在Deployment上打一个annotation记录上次触发迁移的版本号只有版本变化时才执行。5.2 最小实现参考下面是一个用Python和官方client实现的简化版逻辑核心函数做了注释from kubernetes import client, config, watch def on_deployment_update(deploy, namespacedefault): # 检查是否是目标deployment if deploy.metadata.name ! order: return current_rev deploy.metadata.generation if deploy.metadata.annotations.get(migrate-trigger-rev) str(current_rev): return # 已经处理过避免重复触发 # 找到关联的payment pod pods v1.list_namespaced_pod( namespacenamespace, label_selectorapppayment ).items for pod in pods: # 调用Eviction API 优雅驱逐 eviction client.V1Eviction( metadataclient.V1ObjectMeta(namepod.metadata.name, namespacenamespace) ) api_instance.create_namespaced_pod_eviction( namepod.metadata.name, namespacenamespace, bodyeviction ) # 记录本次已触发的版本 patch { metadata: { annotations: { migrate-trigger-rev: str(current_rev) } } } apps_v1.patch_namespaced_deployment(nameorder, namespacenamespace, bodypatch) if __name__ __main__: config.load_kube_config() apps_v1 client.AppsV1Api() v1 client.CoreV1Api() w watch.Watch() for event in w.stream(apps_v1.list_namespaced_deployment, namespacedefault): if event[type] in (ADDED, MODIFIED): on_deployment_update(event[object])实际生产环境我建议直接用现有事件框架比如kubewatch或者Keptn或者用Golang写个OperatorPython版本的适合快速验证思路。5.3 这个方案的坑第一个坑是驱逐接口有PDB限制。如果关联Pod有PodDisruptionBudget且minAvailable正好等于当前副本数Eviction请求会被拒绝控制器需要重试。第二个坑是避免频繁迁移造成集群震荡最好加一个冷却时间窗口比如10分钟内不重复处理同一个Deployment的版本。第三个坑是关联关系的发现方式最好不要硬编码label可以用OwnerReference或者自定义CRD来维护关联关系这样以后新增关联服务时不用改代码。6. 常见问题与排查实录这次实验前后我踩了不少坑也帮同事排查过几个类似问题把高频率出现的问题整理在一起供大家参考。6.1 更新后关联Pod纹丝不动这是最常见的问题原因很可能不是你的规则写错了而是集群里压根没有事后纠正机制。Descheduler没装或者装了但podAntiAffinity策略没启用那运行中的Pod无论多么违反约束都不会被自动迁移。排查思路kubectl get pods --namespace kube-system | grep descheduler如果没有Descheduler那你看到的不迁移是符合预期的。另外还要检查策略参数threshold如果设置得过大比如100小规模集群永远不会触发迁移。6.2 Pod被驱逐后一直Pending支付Pod被驱逐后进入Pending状态但迟迟调度不到新节点。这个问题大概率是反亲和规则太死集群里没有别的节点可用。比如你有两个节点一个跑了订单另一个曾经打过污点那支付Pod只能在两个不可用节点之间排队。排查命令kubectl describe pod payment-xxxx kubectl get nodes -o wide kubectl describe node node2 | grep Taints解决方案一般是把required反亲和改成preferred软反亲和或者确保至少有三个以上的工作节点。6.3 Descheduler频繁驱逐导致服务抖动有同学试过把threshold设成1结果Descheduler每两分钟扫一次一旦检测到任何违规就立刻驱逐而业务Pod启动又慢服务反复重启。这个场景下的经验是threshold至少设置为集群副本数的10%~20%并且给关键服务配置好PDB让驱逐过程受控。6.4 更新后新旧Pod短暂共存反亲和判断混乱滚动更新期间旧订单Pod还没被删除新订单Pod已经创建两个订单Pod可能在不同节点上。此时支付Pod的反亲和规则在调度时会看到两个标签相同的Pod即使你原本想让它避开node2但旧订单Pod在node1新订单Pod在node2调度器会把两个节点都过滤掉导致支付Pod无法调度。这种问题没有完美解法只能通过控制滚动更新的maxSurge和maxUnavailable参数让新旧Pod的重叠时间尽可能短或者调整反亲和规则让它针对更稳定的标签比如app: order-stable但这个标签只在某个版本之后的Pod上打。6.5 常见问题速查表问题现象可能原因排查与解决更新后关联Pod不动无Descheduler/控制器做事后纠正部署Descheduler启用podAntiAffinity策略关联Pod被驱逐后Pending反亲和硬规则导致无可用节点改用preferred增加节点检查污点驱逐请求被拒绝PDB限制调整minAvailable或捕获Eviction错误并重试反复迁移震荡threshold太小调大threshold增加冷却时间新旧Pod共存导致调度失败滚动更新期间多个实例同时存在调整maxSurge/maxUnavailable优化标签选择7. 实操总结与经验备忘把整个实验做下来我个人最大的收获是理解了K8s调度器的边界它负责“调度”而不是“维护”。Pod运行期间的资源与分布健康需要额外的机制去维护。Descheduler补上了这个缺口但它又是一把双刃剑配置激进时会造成比故障本身更严重的副作用。我建议生产环境这样落地关联迁移策略用软反亲和或者按百分比打散的方式兜底日常情况再配一个Descheduler策略专门处理“违反硬约束”的Pod并且把threshold设到超过单副本数的级别防止单点问题被过度放大。自定义控制器的方案适合对迁移时机、迁移目标有明确业务预期的场景但一定要引入幂等保护和版本记录否则更新一次业务迁移一次整个集群都会被拖垮。最后再说一个调试小技巧判断一个Pod是否真的违反反亲和约束时不用等Descheduler去检测可以用下面的命令快速检查目标节点上是否存在匹配标签的其他Podkubectl get pods -A -owide | grep node-name | grep pod-label比如要检查node2上是否有app: order标签的Pod执行kubectl get pods -A -owide | grep node2 | grep apporder有输出就说明违规状态已经形成再根据实际需求决定是手动驱逐还是交给Descheduler处理。这个习惯养成了排查类似问题就会顺手很多。