Descheduler 策略、驱逐保护与安全回滚
一次节点故障把十几个无状态副本挤到两个幸存节点。新节点补齐后,所有 Pod 都是 Ready,业务也恢复了,但旧 Pod 不会因为出现了更好的节点而自行迁移:调度器只为尚未绑定的 Pod 选节点,已经写入 spec.nodeName 的 Pod 不会重新排队。高峰一来,热点节点先触发 CPU 节流,新节点却保持低负载。
团队第一次启用 Descheduler 时又走向另一个极端:把低利用率策略直接覆盖整个集群,CronJob 一轮驱逐了大量副本。PDB、终止宽限期、镜像冷启动和卷拓扑同时生效,驱逐本身成功,业务容量却在重建窗口里下降。问题并不在于“会不会搬 Pod”,而在于是否能证明每个候选 Pod 可以安全离开、由谁重建、能否重新放置,以及何时必须停止。
先把“重平衡”拆成五个不同责任
Descheduler v0.36.0 使用 descheduler/v1alpha2 策略 API,并以 Kubernetes 1.36 客户端库构建。它读取节点、Pod、工作负载与策略事实,策略插件找出不再理想的存量 Pod,Evictor 插件过滤受保护对象,随后通过 Kubernetes eviction API 请求驱逐。Deployment、StatefulSet 等原控制器发现副本不足后创建替代 Pod,最后由 kube-scheduler 重新执行 Filter、Score 与 Bind。官方 项目说明明确指出 Descheduler 不负责调度替代 Pod。
这条链路有五个可独立失败的节点:策略可能选错对象;DefaultEvictor 可能拒绝候选;API Server 可能因 PDB 返回 429;控制器可能无法创建替代 Pod;新 Pod 也可能因资源、标签、污点或卷拓扑而 Pending。因此 pods_evicted_total 增长只能证明驱逐动作发生,不能证明重平衡成功。真正的结束证据是期望副本恢复、Pod Ready、分布偏斜改善、业务 SLO 未破坏,并且没有持续 Pending。
策略分为 deschedule 与 balance 两类扩展点。前者逐个处理违反节点亲和、污点或生命周期条件的 Pod;后者观察一组节点与 Pod 后决定如何降低重复副本、利用率偏斜或拓扑偏斜。LowNodeUtilization 依据 requests 或已配置的指标源判断节点类别,不等于按 kubectl top 的瞬时值自动“搬空热点”。
用暂停的 CronJob 先建立只读观察窗口
Descheduler 可以运行成 Job、CronJob 或 Deployment。第一次接入应从一次性 Job 或低频 CronJob 开始,固定镜像 registry.k8s.io/descheduler/descheduler:v0.36.0,并使用对应的 release-1.36 文档。持续 Deployment 适合需要周期循环、且已经验证每轮速率与高可用行为的集群;它不是默认更安全的选择。
官方 Helm 仓库提供 chart,先检查集群版本、Helm、Pod 驱逐权限与目标 namespace 中的 PDB:
kubectl version
helm version
kubectl auth can-i list pods --all-namespaces
kubectl auth can-i create pods/eviction --all-namespaces
kubectl get pdb -A
helm repo add descheduler https://kubernetes-sigs.github.io/descheduler/
helm repo update
helm search repo descheduler/descheduler --versions | head固定 chart 版本而不是直接跟随仓库最新值。Chart 0.36.0 默认创建每两分钟运行一次且未暂停的 CronJob,因此不能把默认 values 原样安装后再慢慢检查。先下载并审查默认 RBAC、镜像、运行形态和策略,首装时同时设置 suspend=true 与 cmdOptions.dry-run=true,确保没有自动周期,也不会真正驱逐:
helm show values descheduler/descheduler --version 0.36.0 > descheduler-values.yaml
helm template descheduler descheduler/descheduler \
--namespace kube-system --version 0.36.0 \
-f descheduler-values.yaml \
--set suspend=true --set cmdOptions.dry-run=true \
> rendered-descheduler.yaml
kubectl diff -f rendered-descheduler.yaml
helm upgrade --install descheduler descheduler/descheduler \
--namespace kube-system --version 0.36.0 \
-f descheduler-values.yaml \
--set suspend=true --set cmdOptions.dry-run=true --wait
kubectl -n kube-system get cronjob,job,pod -l app.kubernetes.io/name=deschedulerchart 值会随发行线演进,helm show values 的结果才是本次安装的字段依据。若使用 Kustomize,应固定 ?ref=release-1.36;不能从 master 拉取清单再搭配旧镜像。0.36.0 的 --dry-run 只跳过真实驱逐,不会把候选判断变成授权隔离;仍应保留窄 selector、只读观察日志,并确认渲染结果中的 CronJob 确实处于 suspend。等实验 policy 写入 values 并通过渲染检查后,才能只关闭 dry-run、继续保持 CronJob 暂停,使真实驱逐只能由显式创建的单轮 Job 触发。
Policy、Profile 与 DefaultEvictor 怎样共同决定一次驱逐
一个克制的实验策略可以把扫描范围限制到带标签的 Pod,设置全局驱逐上限,并打开 nodeFit 与额外保护:
apiVersion: descheduler/v1alpha2
kind: DeschedulerPolicy
maxNoOfPodsToEvictPerNode: 1
maxNoOfPodsToEvictPerNamespace: 1
maxNoOfPodsToEvictTotal: 2
gracePeriodSeconds: 30
evictionFailureEventNotification: true
profiles:
- name: safe-rebalance
pluginConfig:
- name: DefaultEvictor
args:
nodeFit: true
minReplicas: 2
minPodAge: 10m
labelSelector:
matchLabels:
descheduler.example.com/enabled: "true"
podProtections:
extraEnabled:
- PodsWithPVC
- PodsWithoutPDB
- PodsWithResourceClaims
- name: RemovePodsViolatingNodeAffinity
args:
nodeAffinityType:
- requiredDuringSchedulingIgnoredDuringExecution
plugins:
deschedule:
enabled:
- RemovePodsViolatingNodeAffinityDeschedulerPolicy 是进程读取的组件配置,不是由 API Server 持久化的 CR,不能把这段 YAML 直接交给 kubectl apply。将它完整写入 descheduler-values.yaml 的 deschedulerPolicy 值后重新执行 helm template,确认生成的 ConfigMap 与 Pod 参数引用了同一份 policy;再保持 CronJob 暂停、只关闭 dry-run 更新 release。后面的单轮 Job 必须从这次更新后的 CronJob 创建:
helm template descheduler descheduler/descheduler \
--namespace kube-system --version 0.36.0 \
-f descheduler-values.yaml \
--set suspend=true --set cmdOptions.dry-run=false \
> rendered-descheduler-active.yaml
kubectl diff -f rendered-descheduler-active.yaml
helm upgrade descheduler descheduler/descheduler \
--namespace kube-system --version 0.36.0 \
-f descheduler-values.yaml \
--set suspend=true --set cmdOptions.dry-run=false --wait
kubectl -n kube-system get cronjob descheduler \
-o jsonpath='{.spec.suspend}{"\n"}'顶层三个 maxNoOfPodsToEvict... 是每轮总保险丝,分别限制单节点、单 namespace 和整个循环。gracePeriodSeconds 改变 Pod 终止窗口,过短会截断请求,过长会让旧 Pod 与替代 Pod 的容量重叠更久。minReplicas 防止小副本控制器进入候选,minPodAge 避免刚启动的 Pod 被立即再次扰动,label selector 则建立显式准入,而不是让全集群默认参加实验。
podProtections.extraEnabled 是增加保护。PodsWithPVC、PodsWithoutPDB、PodsWithResourceClaims 启用后,相应 Pod 不会被驱逐。相反,defaultDisabled 会关闭默认保护,例如允许本地存储、DaemonSet 或系统关键 Pod 进入候选,风险方向完全相反。旧的 evictLocalStoragePods、ignorePvcPods 等字段已经迁移到 podProtections,升级时应逐项翻译,不要把布尔值机械改名。完整字段以 v0.36 用户指南为准;PDB 对 eviction 子资源的限制语义则应与 Kubernetes 自愿中断文档一起核对。
正向实验:让节点事实迁移而 Pod spec 保持不变
选择两台非生产实验节点,把 <source-node>、<target-node> 替换为真实节点名。先只给源节点打上实验池标签,再创建三副本 Deployment:
kubectl create namespace descheduler-lab
kubectl label node <source-node> descheduler.example.com/pool=rebalance-lab
kubectl -n descheduler-lab create deployment spread-demo \
--image=registry.k8s.io/pause:3.10 --replicas=3
kubectl -n descheduler-lab label deployment spread-demo \
descheduler.example.com/enabled=true
kubectl -n descheduler-lab patch deployment spread-demo --type merge -p \
'{"spec":{"template":{"metadata":{"labels":{"descheduler.example.com/enabled":"true"}},"spec":{"affinity":{"nodeAffinity":{"requiredDuringSchedulingIgnoredDuringExecution":{"nodeSelectorTerms":[{"matchExpressions":[{"key":"descheduler.example.com/pool","operator":"In","values":["rebalance-lab"]}]}]}}}}}}}'
kubectl -n descheduler-lab rollout status deploy/spread-demo
kubectl -n descheduler-lab get pods -o wide三个 Pod 都绑定源节点后,把同一个节点事实迁移到目标节点:先给目标节点加标签,再从源节点移除标签。Pod spec 与 Deployment template 完全不变,因此不会触发 rollout;现有 Pod 继续运行,但已违反 requiredDuringSchedulingIgnoredDuringExecution,新建 Pod 只会把目标节点视为可行节点。随后创建允许一次自愿中断的 PDB:
kubectl label node <target-node> descheduler.example.com/pool=rebalance-lab
kubectl label node <source-node> descheduler.example.com/pool-
kubectl -n descheduler-lab create pdb spread-demo --selector app=spread-demo --min-available=2
kubectl -n descheduler-lab get pods -o wide这里的关键观察是标签变化不会驱逐 IgnoredDuringExecution 的既有 Pod。手工触发 chart 生成的 CronJob,并在执行前后记录 Pod UID、Node 与 Ready:
kubectl -n kube-system create job --from=cronjob/descheduler descheduler-positive
kubectl -n kube-system logs -f job/descheduler-positive
kubectl -n descheduler-lab get pods -w
kubectl -n descheduler-lab get events --sort-by=.lastTimestamp预期一轮最多驱逐一个该 namespace 的 Pod;PDB 可用中断数不会低于零;替代 Pod 获得新 UID,并落到 <target-node> 后 Ready。日志应出现策略、候选与 eviction 结果;如果监控系统在短生命周期 Job 退出前完成抓取,pods_evicted_total{strategy=...} 应增加,Job 结束后不能再假设临时指标端点仍可读取。若未找到候选,日志和计数保持稳定也属于有效结论,不能为了“看到迁移”放宽保护。
反向实验:让 PDB 和不可放置约束阻止危险动作
先保存原对象用于恢复,再把目标节点的实验池标签移除,让现有 Pod 与未来替代 Pod 都没有可行节点。这个阶段保持 PDB 的 minAvailable=2,单独验证 nodeFit,避免两个保护同时生效后无法判断究竟是哪一层阻止了驱逐:
kubectl -n descheduler-lab get deploy spread-demo -o yaml > spread-demo.before.yaml
kubectl label node <target-node> descheduler.example.com/pool-
kubectl -n kube-system create job --from=cronjob/descheduler descheduler-negative-nodefit
kubectl -n kube-system logs -f job/descheduler-negative-nodefit启用 nodeFit: true 时,不存在另一个满足 requests、node affinity、taint 和 anti-affinity 的节点,候选应被 PreEvictionFilter 排除,Pod UID 不变且不产生替代 Pod。随后恢复目标节点标签,让替代 Pod 再次有可行落点,并把 PDB 收紧到 minAvailable=3;第二个独立 Job 才用于验证 API Server 对 eviction 的拒绝:
kubectl label node <target-node> descheduler.example.com/pool=rebalance-lab
kubectl -n descheduler-lab patch pdb spread-demo --type merge -p \
'{"spec":{"minAvailable":3}}'
kubectl -n kube-system create job --from=cronjob/descheduler descheduler-negative-pdb
kubectl -n kube-system logs -f job/descheduler-negative-pdb
kubectl -n kube-system logs job/descheduler-negative-pdb | grep -Ei 'evict|pdb|error|429'
kubectl -n descheduler-lab get pdb spread-demo -o yaml
kubectl -n descheduler-lab get pods -o custom-columns=NAME:.metadata.name,UID:.metadata.uid,NODE:.spec.nodeName,READY:.status.containerStatuses[0].ready
kubectl -n descheduler-lab get events --sort-by=.lastTimestamp第二阶段的安全结果是 Pod UID 仍不变、PDB 的 disruptionsAllowed 为 0、失败日志或事件能够关联到 eviction 拒绝,并且没有替代 Pod Pending。若没有失败事件,先确认 evictionFailureEventNotification 已进入实际挂载的 policy,再以 Job 日志和 Pod UID 作为主证据,不能把“没有 Event”解释为驱逐成功。nodeFit 是尽力而为的预检查,不是 kube-scheduler 的完整事务预留;检查结束到真正重建之间,其他 Pod 仍可能抢占容量,卷绑定和外部准入也可能改变结果。因此 PDB、速率上限和业务容量余量不能被 nodeFit: true 替代。
命令、事件、指标与日志怎样拼成可审计证据
每轮至少保存策略 ConfigMap 摘要、Job UID、开始与结束相对时间、候选与驱逐数、失败原因、替代 Pod Ready 延迟和最终分布。Descheduler 默认通过安全端口暴露指标;pods_evicted_total、loop_duration_seconds 与 strategy_duration_seconds 可分别回答驱逐量、整轮耗时和策略耗时。旧的 pods_evicted、descheduler_loop_duration_seconds 等指标已弃用,不应继续作为新告警基线。
kubectl -n kube-system get job descheduler-positive -o yaml
kubectl -n kube-system logs job/descheduler-positive --timestamps
kubectl get --raw /apis/policy/v1/namespaces/descheduler-lab/poddisruptionbudgets
kubectl -n descheduler-lab get pods -o wide
kubectl -n descheduler-lab get events --sort-by=.lastTimestamp事件文字会随组件版本变化,自动化应依赖 reason、对象 UID、状态字段与计数器,不要匹配整句英文。若使用 LowNodeUtilization 的实际利用率模式,metricsProviders 可以接 Kubernetes Metrics 或 Prometheus;旧 metricsCollector 已弃用。Prometheus 令牌应通过 secretReference 读取,日志与 chart values 都不能出现明文 token。指标源缺失、陈旧或标签选择错误时,策略应失败关闭或跳过本轮,而不是默认为“节点空闲”。
清理与紧急回滚要先停执行器再归还对象
发现驱逐速率、Pending 或错误 namespace 异常时,第一步是暂停新的 CronJob,并删除尚未开始的手工 Job;不要先删除 PDB 或放宽保护来“让本轮完成”:
kubectl -n kube-system patch cronjob descheduler -p '{"spec":{"suspend":true}}'
kubectl -n kube-system delete job descheduler-positive \
descheduler-negative-nodefit descheduler-negative-pdb --ignore-not-found
kubectl -n descheduler-lab get pods,pdb -o wide已经驱逐的 Pod 无法原地复活,只能由控制器重建。恢复旧策略 ConfigMap 或 Helm values 后,先确认副本 Ready、Pending 清零和业务指标恢复,再决定是否重新启用。实验结束按所有权逆序清理:
kubectl delete namespace descheduler-lab --wait=true
kubectl label node <source-node> descheduler.example.com/pool- || true
kubectl label node <target-node> descheduler.example.com/pool- || true
helm uninstall descheduler -n kube-system
kubectl get clusterrole,clusterrolebinding | grep descheduler || trueHelm 卸载后还要确认是否遗留手工创建的 ServiceAccount、ClusterRole、ClusterRoleBinding、ConfigMap、Secret、ServiceMonitor 与历史 Job。生产退出时先 suspend、观察至少一个原调度周期、归还字段所有权,再卸载;直接删除执行器无法撤回已发生的中断。
策略选型取决于偏斜原因而不是“利用率不好看”
节点标签、污点或 required affinity 在 Pod 运行后变化,可选择对应的违反约束策略;相同 owner 的副本重复落在节点上可评估 RemoveDuplicates;拓扑分布发生漂移时使用对应 topology spread 策略。利用率策略适合 requests 与实际容量模型可信、且重建成本可预算的无状态池。它不适合用来修复错误 requests、状态服务主从拓扑、昂贵 GPU 初始化或大体量本地缓存。
驱逐不是压缩节点成本的直接保证。若目标是释放整台节点,需要 Descheduler、节点供给器、调度约束与缩容器共同形成闭环:Pod 被安全迁走,节点达到可缩容条件,节点供给器才删除节点。频繁重平衡还会增加镜像拉取、缓存回暖、网络连接重建、存储 attach/detach、GPU 上下文初始化和日志量。团队应同时观察节省的节点时与新增的重建成本。
当问题只发生在一次发布,优先修正 Deployment 的 topology spread、requests 或滚动策略;当节点需要维护,使用 cordon/drain;当存量放置持续因集群变化偏离声明,再考虑 Descheduler。能在创建时表达的硬约束,不应长期依赖事后驱逐补救。
权限、凭证与敏感容量事实必须分层保管
Descheduler 需要跨 namespace 读取 Pod、Node、工作负载、PDB,并创建 pods/eviction。这是可以主动中断业务的高权限身份,不能与应用 ServiceAccount 共用。RBAC 应由平台团队维护,审计规则重点记录 ServiceAccount 对 eviction 子资源的调用;测试身份只应获得实验 namespace 的 eviction 权限。生产 policy 中的 label/namespace selector 只能收窄正常执行范围,不能限制凭证被盗后的 API 权限;若只允许部分 namespace 被驱逐,应把集群级只读权限与各目标 namespace 的 eviction RoleBinding 分开设计,并用准入和审计补上策略变更边界。
读取 Prometheus 时优先使用工作负载身份或专用短期 token。Secret 仅存引用,不进入 policy、Git 明文、命令历史或日志。指标标签、节点名、namespace、实例类型、PDB 和副本数会暴露租户拓扑与容量余量,对外分享日志时必须脱敏。禁止把真实云实例 ID、内部域名、客户标签或监控 bearer token 放入故障工单。
安全团队拥有高风险保护项的例外审批,应用团队拥有 PDB、终止窗口和就绪语义,平台团队拥有策略、RBAC 与执行器,容量团队拥有节点池和缩容结果。任何允许驱逐系统关键 Pod、本地存储 Pod、PVC Pod 或 ResourceClaim Pod 的例外,都应有 owner、到期条件与回退命令。
升级、双轨验证与长期责任围绕行为不变量
Descheduler policy 仍是 v1alpha2,升级不能只比较镜像 tag。先用 helm show values 和发行说明比较 chart 值、参数、默认保护、策略插件与指标名;特别检查旧 eviction 字段到 podProtections、metricsCollector 到 metricsProviders 的迁移。镜像与 chart 分别固定,并在 canary 集群或隔离节点池回放生产策略。
双轨阶段可让新版本只 dry-run 或只处理 canary 标签,旧版本保持生产执行;两个实例绝不能同时对同一 Pod 集合执行 eviction。比较每轮候选数、被保护数、PDB 拒绝数、实际驱逐数、替代 Pod Ready 延迟、Pending 数和最终偏斜。如果新版本候选集合无解释地扩大、失败关闭变成继续驱逐,或指标失去可比性,应回到旧镜像与旧策略。
稳定运行后仍要定期审查没有 PDB 的工作负载、低副本 owner、长终止窗口、ResourceClaim/PVC、本地存储、系统关键 Pod 与策略 selector。策略 owner 负责版本和回滚,应用 owner 对可中断性签字,值班人员拥有 suspend 权限,容量 owner 验证节点是否真的被释放。只有驱逐、恢复、业务和成本四条证据同时闭环,重平衡才算完成。
