惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

W
WeLiveSecurity
博客园 - 【当耐特】
Microsoft Azure Blog
Microsoft Azure Blog
WordPress大学
WordPress大学
Stack Overflow Blog
Stack Overflow Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
IT之家
IT之家
Cloudbric
Cloudbric
The Register - Security
The Register - Security
小众软件
小众软件
PCI Perspectives
PCI Perspectives
G
Google Developers Blog
AI
AI
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Google DeepMind News
Google DeepMind News
Google DeepMind News
Google DeepMind News
宝玉的分享
宝玉的分享
Recent Commits to openclaw:main
Recent Commits to openclaw:main
量子位
TaoSecurity Blog
TaoSecurity Blog
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
F
Full Disclosure
N
Netflix TechBlog - Medium
博客园_首页
Last Week in AI
Last Week in AI
A
Arctic Wolf
B
Blog RSS Feed
J
Java Code Geeks
C
Cybersecurity and Infrastructure Security Agency CISA
I
InfoQ
aimingoo的专栏
aimingoo的专栏
云风的 BLOG
云风的 BLOG
NISL@THU
NISL@THU
MyScale Blog
MyScale Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Jina AI
Jina AI
有赞技术团队
有赞技术团队
S
Schneier on Security
L
Lohrmann on Cybersecurity
P
Privacy & Cybersecurity Law Blog
T
Threat Research - Cisco Blogs
P
Palo Alto Networks Blog
S
Security @ Cisco Blogs
Security Archives - TechRepublic
Security Archives - TechRepublic
Security Latest
Security Latest
Vercel News
Vercel News
博客园 - 司徒正美
Webroot Blog
Webroot Blog
Hacker News: Ask HN
Hacker News: Ask HN
A
About on SuperTechFans

博客园 - 左扬

VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— VictoriaLogs 协同:Metrics 到 Logs 的一体化监控 VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— 源码阅读路线图:如何高效阅读 VM 源码 VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— 开源生态:VM 在 CNCF 生态中的位置 VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— 与其他 TSDB 对比:Prometheus/InfluxDB/Thanos/VM VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— 写入吞吐/查询延迟/内存占用的数学模型 VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— 模块依赖图——从 import 语句看组件关系 VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— Goroutine 池/atomic/零拷贝/sync.Pool VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— 多租户架构——accountID/projectID 与 tenant 隔离 VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— 版本演进:1.146.0 LTS 重大更新解析 VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— 整体数据流:一条监控数据的完整生命周期 VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— 架构演进:从 TSDB 到 MergeSet 的设计取舍 VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— Single-Node vs Cluster 模式本质区别 VictoriaMetrics 1.146.0 源码【左扬精讲】—— 开篇总览 Rust 专题【左扬精讲】—— 从语法到灵魂:Ownership、Borrowing 与多语言对比 kubernetes 源码【左扬精讲】—— kube-scheduler 启动流程源码分析 Rust 专题【左扬精讲】—— 选择控制语句、运算符与格式化输出 Rust 专题【左扬精讲】—— 所有权详解 Rust 专题【左扬精讲】—— 作用域详解 Rust 专题【左扬精讲】—— 变量、常量与标量数据类型 kubernetes 源码 / Operator 专题【左扬精讲】—— Deployment Controller 源码分析:从对象创建到滚动更新 kubernetes 源码 / Operator 专题【左扬精讲】—— Operator 开发中的 Webhook:从准入控制到生产部署 Kubernetes源码 / Operator 专题【左扬精讲】—— 实现 Application Controller:从零构建生产级控制器 Kubernetes 编程 / Operator 专题【左扬精讲】—— 定义 Application 资源 + 添加自定义新 API 完整指南 Kubernetes 源码【左扬精讲】—— kube-scheduler(调度专题 · 八):内部架构与核心组件 Kubernetes 源码【左扬精讲】—— kube-scheduler(调度专题 · 八): —— 从入口到调度的全链路源码剖析(k8s v1.36.1) DeepSeek-R1 多模态 R1 / VLM-GRPO【左扬精讲】—— Qwen2-VL 微调与视觉推理强化学习实战 DeepSeek-R1 工业 RAG + 微调混合系统【左扬精讲】—— R1 系列收官之作:从 Prompt → RAG → 微调 选型决策树 DeepSeek-R1 推理时扩展【左扬精讲】—— o1 / R1 慢思考机制:Self-Consistency + ToT + PRM 详解 DeepSeek-R1 端侧 LLM 工程【左扬精讲】—— llama.cpp 调参与 Apple Silicon / 国产 NPU / Android 端侧落地全攻略 DeepSeek-R1 vLLM + k8s 生产部署【左扬精讲】—— 从单卡 7B 到 100 卡 671B MoE 集群的工业化部署实战 DeepSeek-R1 评估与系统(Evaluation & Systems)【左扬精讲】—— 从 GSM8K/MMLU 到 LLM-as-Judge 的工业级评估方法论 DeepSeek-R1 模型训练与算法【左扬精讲】—— GRPO 进阶算法:DAPO / PRIME / RLVR / PRM 四大 2025 前沿改进 DeepSeek-R1 模型训练与算法【左扬精讲】—— 数据蒸馏:用 DeepSeek-R1-671B 生成 800K 高质量 CoT 样本的完整流水线 DeepSeek-R1 优化与微调实战【左扬精讲】—— 从 R1 强化学习新范式到 GRPO 微调一站式入门 Kubernetes 源码【左扬精讲】—— kube-scheduler(调度专题 · 七):自定义插件开发实战 —— 手写一个 Score 插件并注册到集群 Kubernetes 源码【左扬精讲】—— kube-scheduler(调度专题 · 六):Scheduler Profile 与多调度器 —— 如何配置多个 profile 实现多租户、Coordinated LeaderElection Kubernetes 源码【左扬精讲】—— kube-scheduler(调度专题 · 五):SchedulingQueue 与 QueueingHint —— 三段队列的细节、v1.36 新引入的 QueueingHint 工作机制 Kubernetes 源码【左扬精讲】—— kube-scheduler(调度专题 · 四):抢占(Preemption)算法剖析 —— DefaultPreemption 如何选 victim、PodDisruptionBudget 如何约束 Kubernetes 源码【左扬精讲】—— kube-scheduler(调度专题 · 二):内置插件逐个精读 — NodeResourcesFit / NodeAffinity / TaintToleration / PodTopologySpread / VolumeBinding / InterPodAffinity Kubernetes 源码 / Operator 专题【左扬精讲】——kube-scheduler(调度专题):调度器内置插件 逐个精读 k8s 源码级精讲(二十六):调度器内置插件逐个精读 Kubernetes 源码 / Operator 专题【左扬精讲】——kube-scheduler(调度专题):Scheduling Framework 扩展点逐个源码拆解 Kubernetes 源码 / Operator 专题【左扬精讲】——kube-scheduler(调度专题):初识调度模型、内部架构与事件驱动机制 Kubernetes 编程 / client-go 专题【左扬精讲】—— 四种客户端:为什么、怎么选、怎么用 Kubernetes 编程 / Operator 专题【左扬精讲】—— controller-runtime、kubebuilder、operator-sdk 三大框架深度对比 Kubernetes 编程 / Operator 专题【左扬精讲】—— 深入理解 ManagedFields 字段冲突协调机制 Kubernetes 编程 / Operator 专题【左扬精讲】—— k8s Finalizers 深度解析:对象的生命周期与删除控制 Kubernetes 编程 / Operator 专题【左扬精讲】—— OwnerReference 字段与级联删除机制 Kubernetes 编程 / Operator 专题【左扬精讲】—— 深入学习 Server-Side Apply:managedFields 替代 last-applied-configuration 的演进方向 Kubernetes 编程 / Operator 专题【左扬精讲】—— k8s Annotations 与元数据体系(Operator 专题) Kubernetes 编程 / Operator 专题【左扬精讲】—— RESTMapper:把 Group / Version / Kind / Resource 四元组翻译成 REST 路径的"查字典"大师 Kubernetes 编程 / Operator 专题【左扬精讲】—— Converter 资源版本转换器 Kubernetes 编程 / Operator 专题【左扬精讲】—— Application 业务扩展:从单 Deployment 到多 Workload 的复合 Operator 演进 Kubernetes 编程 / Operator 专题【左扬精讲】—— OwnerReference / Finalizer / 准入控制:k8s 资源生命周期的三大支柱 Kubernetes 编程 / Operator 专题【左扬精讲】—— controller-runtime 框架内幕:从 Manager 到 Reconcile 的全栈拆解 Kubernetes 编程 / Operator 专题【左扬精讲】—— 生产级 Operator 最佳实践:并发安全、资源清理与高可用设计 Kubernetes 编程 / Operator 专题【左扬精讲】—— application-operator Reconcile 循环源码精讲:从 client-go Informer 到 workqueue 的全链路解剖 Kubernetes 编程 / Operator 专题【左扬精讲】—— 从零搭建一个 application-operator 新项目:脚手架、API 设计与基于原生 DeploymentStatus/ServiceStatus 的状态建模 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:生产级 Controller 实践:并发安全、资源清理与高可用设计 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析: Controller 调试与诊断工具:从日志分析到问题定位 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:DynamicClient 操作 CRD:无需代码生成的动态操作 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:控制器与 APIServer 完整交互流程:从 Watch 到缓存同步 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:错误处理与重试机制:WorkQueue 限速器详解 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:Leader 选举机制:高可用控制器的必备技能 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:Controller 开发模式完整实战 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:SharedInformerFactory 与等待缓存同步 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:从认证配置到 Deployment 操作 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:版本对应、架构组件与组件关系 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:Informer 源码深度解析:从底层原理到实战应用 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:Reflector 源码深度解析 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:ListWatcher 源码深度解析 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:Indexer 与 ThreadSafeStore 核心原理与源码深度剖析 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:DeltaFIFO 核心原理与源码深度剖析 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:workqueue 核心原理与实战 Kubernetes 编程 / Operator 专题【左扬精讲】—— runtime.Codec 资源编解码:serializer 与 codec 差异、编解码数据结构、codec 核心调用链路 Kubernetes 编程 / Operator 专题【左扬精讲】—— Scheme 资源注册机制全解 Kubernetes 编程 / Operator 专题【左扬精讲】—— Kubernetes 自定义资源的内部版本与外部版本:从源码看版本定义机制 Kubernetes 编程 / Operator 专题【左扬精讲】—— Kubernetes 1.36.1 核心 API 数据结构全解 Kubernetes 编程 / Operator 专题【左扬精讲】—— Kubernetes 构建过程 【AIOPS】一文读懂LLM【左扬精讲】:从诞生到普及,解锁大语言模型的核心密码 【AIOPS】AI Agent 专题【左扬精讲】核心功能篇:MCP-VictoriaMetrics Hooks 源码精讲:Hooks 可观测性的无侵入式实现 【AIOPS】AI Agent 专题【左扬精讲】核心功能篇:MCP-VictoriaMetrics Golang 配置解析源码精讲 ——SRE 自定义 Agent 核心技巧 【AIOPS】AI Agent 专题【左扬精讲】核心功能篇:MCP-VictoriaMetrics Golang 并发模型解析 ——SRE 应对高并发采集的调优思路 【AIOPS】AI Agent 专题【左扬精讲】基础架构篇:MCP-VictoriaMetrics Golang 源码整体架构拆解 ——SRE 必懂的核心模块与数据流 OpenTelemetry 开发实战【左扬精讲】—— 云原生可观测体系构建与分布式追踪二次开发 Kubernetes 编程 / Operator 专题【左扬精讲】—— Operator 开发实战项目 7 —— 基于流量预测模型的智能弹性扩缩容 Operator 实战(AIOps 模型训练与智能扩容(下篇)—— 预测式弹性扩缩容 Operator 落地实现) Kubernetes 编程 / Operator 专题【左扬精讲】—— Operator 开发实战项目 7 —— 基于流量预测模型的智能弹性扩缩容 Operator 实战(AIOps 模型训练与智能扩容(上篇)—— 时序预测模型构建与离线训练) Kubernetes 编程 / Operator 专题【左扬精讲】—— Operator 开发实战项目 6 —— 基于运维专家知识库的智能故障诊断与排查 Operator 实战 Kubernetes 编程 / Operator 专题【左扬精讲】—— Operator 开发实战项目 5 —— 基于大语言模型(LLM)的实时日志流智能监测 Operator 实现 Kubernetes 编程 / Operator 专题【左扬精讲】—— Operator 开发实战项目 4 —— 基于 Operator 实现大模型私有化部署与管理 Kubernetes 编程 / Operator 专题【左扬精讲】—— Operator 开发实战项目 3(上篇)—— 面向 AI / 算力调度场景:GPU 竞价实例资源池统一调度管理 Operator 开发 Kubernetes编程 / Operator专题【左扬精讲】—— Operator 开发实战项目 2 —— 面向零售 / 电商潮汐流量难题:多云多集群数据中心级全链路弹性伸缩 DataCenter Scaler Operator 从 0 到 1 全链路开发 Kubernetes编程 / Operator专题【左扬精讲】—— 深入理解Kubebuilder注解:为什么Operator开发离不开这些特殊注释 Kubernetes编程 / Operator专题【左扬精讲】—— Operator 开发实战项目1 —— Applicaion Operator(通用应用生命周期管理 Operator 实战) Pod 镜像拉取失败?kubectl edit pods修改镜像地址的底层原理与实操 (该方法仅为临时应急方案,并非长期解决方案) Kubernetes编程/Operator专题精讲—— 理解控制器模式 —— 控制器模式的核心原理与实现逻辑(从原理到实践) 【AIOPS】AI Agent 专题【左扬精讲】模型微调实战:一站式平台 LLaMA-Factory 【AIOPS】AI Agent 专题【左扬精讲】基于 k8s+vLLM+Ray 分布式部署全指南:架构设计、资源调度与性能优化 【AIOPS】AI Agent专题【左扬精讲】非量化版DeepSeek分布式部署全指南:精度保障、显存规划与Ollama/vLLM选型 【AIOPS】AI Agent 专题【左扬精讲】零开发框架实现 ReAct Agent(Go SRE友好)
Kubernetes 源码 / Operator 专题【左扬精讲】——kube-scheduler(调度专题):调度器内置插件精读 — NodeResourcesFit / NodeAffinity / TaintToleration / PodTopologySpread / VolumeBinding / InterPodAffinity
左扬 · 2026-06-18 · via 博客园 - 左扬

k8s v1.36.1 Scheduler Framework 内置插件 调度源码 Filter / Score / PreFilter

k8s 源码级精讲(二十六):调度器内置插件逐个精读 — NodeResourcesFit / NodeAffinity / TaintToleration / PodTopologySpread / VolumeBinding / InterPodAffinity

上一篇我们把 kube-scheduler 的"骨架"拆完了:从 Scheduler 对象构建、Profile 注册、调度队列,到 SchedulingCycle 流水线,每个扩展点(PreEnqueue / PreFilter / Filter / PostFilter / Reserve / Permit / PreBind / Bind 等)出现的时机和职责都讲清楚了。但骨架归骨架,真正决定一个 Pod "能不能跑、跑得好不好"的,是挂在这些扩展点上的一颗颗"齿轮" —— 也就是我们今天要聊的内置 InTreePlugin

本文我们会一头扎进 kube-scheduler 的源码深处,按"调度流程实际触发顺序"挑选六个最核心的内置插件:NodeResourcesFit → NodeAffinity → TaintToleration → InterPodAffinity → PodTopologySpread → VolumeBinding。每个插件都按相同的六段式拆给你看:它是什么、为什么要有、怎么用、源码长什么样、生产踩过什么坑、QA 大放送。

读完之后,你会对调度器"为什么把 Pod 放到 A 节点而不是 B 节点"有完整的可追溯链路。本文所有源码引用、API 字段、配置示例,均以 k8s v1.36.1 的 tag 为准。

🔓 学习重点提示

★ 【必须】必须掌握
    • 扩展点签名Filter(ctx, state, pod, nodeInfo) *StatusScore(ctx, state, pod, nodeInfo) (int64, *Status) 的精确写法
    • Plugin Name 常量:所有插件名字都集中在 pkg/scheduler/framework/plugins/names/names.go
    • Plugin 注册表pkg/scheduler/framework/plugins/registry.goNewInTreeRegistry() 函数
    • Filter 与 Score 的本质区别:Filter 是"行 / 不行",Score 是"好 / 更好",前者短路、后者打分
    • CycleState 复用:PreFilter 算的 pod 资源请求会被 Filter / Reserve / PreBind 跨阶段复用

☆ 次重点(了解即可)
    • 了解每个插件的 EventsToRegister 决定了哪些 informer 事件能让 unschedulable 的 Pod 被重新入队
    • 了解 UnschedulableUnschedulableAndUnresolvable 的差别


📋 文章目录

  1. 一、What:六大内置插件是什么
  2. 二、Why:为什么调度器要拆成多个插件
  3. 三、How:六个插件最小可运行示例
  4. 四、SourceCode:逐个源码精读
  5. 五、Pitfall:六个生产踩坑实录
  6. 六、FAQ:22 组高频答疑

📑 文章目录

  1. 一、What:六大内置插件是什么
  2. 二、Why:为什么调度器要拆成多个插件
  3. 三、How:六个插件最小可运行示例
  4. 四、SourceCode:逐个源码精读
  5. 五、Pitfall:六个生产踩坑实录
  6. 六、FAQ:22 组高频答疑

一、What:六大内置插件是什么

"内置插件"是 kube-scheduler 在编译期就内置在二进制里、不需要外挂的调度策略实现。它们各自只负责一件事,但组合起来就能完成"挑选最佳节点"这个核心任务。下面我们按"调度流水线里的实际触发顺序"逐个亮相。

NodeResourcesFit:最经典的 Fit 插件。它只看一件事 —— 节点上的 CPU / 内存 / GPU / 临时存储等资源是否够 Pod 申请。如果不够,这节点直接出局。它本身还有四个打分策略:LeastAllocated(最闲优先,默认)、MostAllocated(最忙优先,回收场景)、BalancedAllocation(CPU/内存均衡)、RequestedToCapacityRatio(按容量比定制)。

NodeAffinity:节点亲和性。看 pod.spec.affinity.nodeAffinity 里写的节点选择器(required / preferred),跟节点的 label 配对,能不匹配。required 是硬条件,preferred 是软打分。它同时还负责解析 pod.spec.nodeSelector 这个老字段。

TaintToleration:污点容忍。节点可以被"打污点"(kubectl taint nodes xxx key=value:NoSchedule),普通 Pod 默认不能跑在被打了 NoSchedule 污点的节点上;除非 Pod 在 spec.tolerations 里声明"我能忍",否则就被过滤掉。它和 NodeAffinity 是 Node 上"准入"环节的两道闸。

InterPodAffinity:Pod 间亲和 / 反亲和。看 pod.spec.affinity.podAffinity 和 pod.spec.antiAffinity,判断"这个 Pod 想跟某些 Pod 待在一起 / 想离某些 Pod 远一点"。这是一个典型的"群居型"插件,对 Web + Redis、Deployment 多副本打散等场景至关重要。

PodTopologySpread:拓扑打散。看 pod.spec.topologySpreadConstraints,让同一组 Pod(按 labelSelector 圈定)尽量均匀分布在 zone / node / 其他拓扑域里。它是 v1.18 引入的、设计目的就是替代"之前需要手动算反亲和"那段反人类操作。

VolumeBinding:卷绑定。看 Pod 的 PVC 是不是已经绑到 PV 上、绑定的 PV 跟节点拓扑匹不匹配、节点容量是否充足。它是唯一横跨 PreFilter / Filter / Reserve / PreBind 四个阶段的内置插件,因为它要在 PreFilter 阶段做"先假设能绑"(AssumeCache),避免并发调度时一个 PV 被两个 Pod 同时拿走。

💡 小贴士 — 一句话总结:六个插件里,NodeResourcesFit / NodeAffinity / TaintToleration / InterPodAffinity / PodTopologySpread 在 Filter 阶段"判行不行",NodeAffinity / InterPodAffinity / PodTopologySpread / VolumeBinding / NodeResourcesFit 在 Score 阶段"判好不好"。Filter 阶段是"硬过滤",Score 阶段是"软打分" —— 这就是调度器的两阶段决策模型。


二、Why:为什么调度器要拆成多个插件

早期的 k8s 调度器是"一个大函数":内置几十个 predicate 和 priority 函数,每加一个策略就要改主循环。这带来了三个痛点:1)改一个策略要重新编译整个调度器;2)用户的特殊需求没法动态扩展;3)每个用户场景都要写一份 if-else,代码越来越乱。

从 v1.15 起,调度器重构成了"Scheduler Framework":把"过滤 / 打分 / 绑定"切成固定阶段(我们上一篇讲过的扩展点),每个阶段插拔式挂载插件。内置插件是"出厂预装",第三方可以 OutOfTree 注册。这种设计的最大好处是:六个内置插件互相独立、互不感知、可以单独替换。

传统方案 vs 框架方案:下面这张表把"上一代 monolithic 调度器"和"现在的 Scheduler Framework + 内置插件"做个对比,让你看到为什么 k8s 要这么拆。

维度传统 monolithic 调度器(v1.14 及之前)Scheduler Framework + 内置插件(v1.15+)
扩展性 改源码 + 重新编译 注册自定义插件即可
代码组织 所有 predicate / priority 函数堆在 algorithmprovider / scheduler.go 每个插件一个目录(pkg/scheduler/framework/plugins/<name>/*.go)
替换单个策略 没办法 在 SchedulerConfiguration 的 plugins 段禁掉、装自己的
扩展点(Stage) 硬编码:预选 → 优选 → 绑定 14 个扩展点(PreEnqueue / PreFilter / Filter / PostFilter / ...)
调试难度 日志里看不到哪个 predicate 失败 每个插件返回的 Status 都带 reason,describe Pod 能看到
性能 所有 predicate 必须跑完才进优选 任意插件返回 UnschedulableAndUnresolvable 立刻短路

⚠️ 反例提示:不要试图去定制"内置插件的行为"(比如改 NodeResourcesFit 的打分逻辑),正确做法是 disablePlugin: - NodeResourcesFit 后,自己实现一个同名插件 OutOfTree 注册。生产环境禁止直接修改 kube-scheduler 源码,因为这会让集群脱离上游,无法升级。

💡 小贴士
适合用内置插件的场景:1)标准的资源 / 节点 / 污点 / 反亲和需求;2)不想引入第三方依赖;3)希望跟随上游升级。
适合 OutOfTree 的场景:1)有定制打分公式(如自家 GPU 利用率算法);2)需要访问外部业务系统判断;3)需要把外部 HTTP 调度器作为兜底。


三、How:六个插件最小可运行示例

这一节我们用一个完整可运行的 Yaml 合集展示六个插件的"最小可触发形态"。你可以把它复制到任意一个测试集群(至少 3 个节点、不同 label / taint),然后用 kubectl apply -f . 逐个验证。

3.1 准备节点:打 label 和 taint

下面的命令基于 k8s v1.36.1,先把节点打好标记和污点,后续 6 个 yaml 会用到这些前置条件。

// 准备命令(k8s v1.36.1)

# 假设有三个节点:node-a(az=cn-east-1,gpu=true),
# node-b(az=cn-east-2,无 gpu),node-c(az=cn-east-3,打了专用污点)

kubectl label nodes node-a az=cn-east-1 node-type=general
kubectl label nodes node-b az=cn-east-2 node-type=general
kubectl label nodes node-c az=cn-east-3 node-type=dedicated
kubectl label nodes node-a nvidia.com/gpu=true

# 给 node-c 打污点,模拟"专用节点"
kubectl taint nodes node-c dedicated=ai:NoSchedule

# 看看节点的 taint / label 是否生效
kubectl describe node node-c | grep -E 'Taints|Labels'

3.2 触发 NodeResourcesFit:申请一个超大内存 Pod

// demo-node-resources-fit.yaml (k8s v1.36.1)

apiVersion: v1
kind: Pod
metadata:
  name: big-memory-pod
spec:
  containers:
  - name: app
    image: nginx:1.27
    resources:
      requests:
        memory: "100Gi"   # 100Gi 内存,几乎肯定会被 Filter 拒绝
        cpu: "2"

执行 kubectl apply -f demo-node-resources-fit.yaml 后,kubectl describe pod big-memory-pod 会看到 FailedScheduling 事件,reason 形如 Insufficient memory,就是 NodeResourcesFit.Filter 抛回来的。

3.3 触发 NodeAffinity:要求只能跑在 cn-east-1

// demo-node-affinity.yaml (k8s v1.36.1)

apiVersion: v1
kind: Pod
metadata:
  name: pin-to-east-1
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: az
            operator: In
            values: ["cn-east-1"]
      preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 10
        preference:
          matchExpressions:
          - key: nvidia.com/gpu
            operator: Exists
  containers:
  - name: app
    image: nginx:1.27
    resources:
      requests:
        cpu: "100m"
        memory: "64Mi"

如果你把 requiredvalues 改成不存在的 ["xx"],Pod 会 Pending,reason = node(s) didn't match Pod's node affinity/selector

3.4 触发 TaintToleration:Node-c 的污点

// demo-taint-toleration.yaml (k8s v1.36.1)

apiVersion: v1
kind: Pod
metadata:
  name: dedicated-pod
spec:
  tolerations:
  - key: dedicated
    operator: Equal
    value: ai
    effect: NoSchedule
  containers:
  - name: app
    image: nginx:1.27
    resources:
      requests:
        cpu: "100m"
        memory: "64Mi"

去掉 tolerations 段,Pod 永远跑不到 node-c 上,因为 TaintToleration.Filter 会拒绝它。

3.5 触发 InterPodAffinity:跟 Web Pod 同节点

// demo-inter-pod-affinity.yaml (k8s v1.36.1)

apiVersion: v1
kind: Pod
metadata:
  name: redis-follower
spec:
  affinity:
    podAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchExpressions:
          - key: app
            operator: In
            values: ["web"]
        topologyKey: kubernetes.io/hostname
    podAntiAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 100
        podAffinityTerm:
          labelSelector:
            matchExpressions:
            - key: app
              operator: In
              values: ["redis"]
          topologyKey: kubernetes.io/hostname
  containers:
  - name: redis
    image: redis:7.2

先把 web Pod 部署起来后,再 apply 这个 redis,redis 会跟 web 跑在同一节点,并且最好别跟其他 redis 同节点。

3.6 触发 PodTopologySpread:三副本跨 AZ 打散

// demo-pod-topology-spread.yaml (k8s v1.36.1)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-spread
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web-spread
  template:
    metadata:
      labels:
        app: web-spread
    spec:
      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: topology.kubernetes.io/zone
        whenUnsatisfiable: DoNotSchedule
        labelSelector:
          matchLabels:
            app: web-spread
      containers:
      - name: web
        image: nginx:1.27

三个副本会尽量被推到三个不同的 AZ 上;如果 whenUnsatisfiable 设为 ScheduleAnyway,则会尽力打散但不强制。

3.7 触发 VolumeBinding:未绑定 PVC

// demo-volume-binding.yaml (k8s v1.36.1)

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: data-pvc
spec:
  accessModes: ["ReadWriteOnce"]
  resources:
    requests:
      storage: 10Gi
---
apiVersion: v1
kind: Pod
metadata:
  name: stateful-app
spec:
  volumes:
  - name: data
    persistentVolumeClaim:
      claimName: data-pvc
  containers:
  - name: app
    image: nginx:1.27
    volumeMounts:
    - name: data
      mountPath: /data

PVC 还没 Bound 时,Pod 会一直 Pending,reason = pod has unbound immediate PersistentVolumeClaims。这就是 VolumeBinding.PreFilter 抛的。

3.8 验证清单

  • kubectl get pods -o wide 看 Pod 实际被调度到哪个节点
  • kubectl describe pod <name> 在 Events 里找 FailedScheduling 的 reason
  • kubectl logs -n kube-system kube-scheduler-<node> --tail=200 看调度器端的 reason
  • ☐ 开 --v=5 后能看到每个插件的 Filter 返回值

✅ 实用技巧:想看 kube-scheduler 调度一个 Pod 时每个插件的具体行为,只需在 SchedulerConfiguration 里加 --v=5 并查看日志。日志会按 "pluginName -> Filter/Score -> reason" 的格式输出,对照源码一行行读非常方便。


四、SourceCode:逐个源码精读

这一节是本文的硬核部分。我们顺着"调度流水线触发顺序",把六个内置插件的 Filter / Score / PreFilter / Reserve / PreBind 入口源码全部拆给你看。所有行号都基于 k8s v1.36.1 tag。

📢 阅读源码前的提醒:所有插件的"插件名字"都集中在一个常量文件里:pkg/scheduler/framework/plugins/names/names.go。这是 SchedulerConfiguration 里 disablePlugin 字段的标准字符串来源。先看一眼这个文件能省下大量翻代码的时间。

// pkg/scheduler/framework/plugins/names/names.go (行 19-43, k8s v1.36.1)

const (
    PrioritySort                    = "PrioritySort"
    DefaultBinder                   = "DefaultBinder"
    DefaultPreemption               = "DefaultPreemption"
    DynamicResources                = "DynamicResources"
    GangScheduling                  = "GangScheduling"
    ImageLocality                   = "ImageLocality"
    InterPodAffinity                = "InterPodAffinity"
    NodeAffinity                    = "NodeAffinity"
    NodeDeclaredFeatures            = "NodeDeclaredFeatures"
    NodeName                        = "NodeName"
    NodePorts                       = "NodePorts"
    NodeResourcesBalancedAllocation = "NodeResourcesBalancedAllocation"
    NodeResourcesFit                = "NodeResourcesFit"   // <-- 我们的主角之一
    NodeUnschedulable               = "NodeUnschedulable"
    NodeVolumeLimits                = "NodeVolumeLimits"
    PodTopologySpread               = "PodTopologySpread"
    SchedulingGates                 = "SchedulingGates"
    TaintToleration                 = "TaintToleration"
    VolumeBinding                   = "VolumeBinding"
    VolumeRestrictions              = "VolumeRestrictions"
    VolumeZone                      = "VolumeZone"
)

这些常量是怎么被引用的?答案是 pkg/scheduler/framework/plugins/registry.goNewInTreeRegistry(),所有内置插件的工厂函数在这里登记,scheduler 启动时按名字找。

// pkg/scheduler/framework/plugins/registry.go (行 50-79, k8s v1.36.1)

func NewInTreeRegistry() runtime.Registry {
    fts := plfeature.NewSchedulerFeaturesFromGates(feature.DefaultFeatureGate)
    registry := runtime.Registry{
        dynamicresources.Name:                runtime.FactoryAdapter(fts, dynamicresources.New),
        imagelocality.Name:                   imagelocality.New,
        tainttoleration.Name:                 runtime.FactoryAdapter(fts, tainttoleration.New),
        nodename.Name:                        runtime.FactoryAdapter(fts, nodename.New),
        nodeports.Name:                       runtime.FactoryAdapter(fts, nodeports.New),
        nodeaffinity.Name:                    runtime.FactoryAdapter(fts, nodeaffinity.New),
        nodedeclaredfeatures.Name:            runtime.FactoryAdapter(fts, nodedeclaredfeatures.New),
        podtopologyspread.Name:               runtime.FactoryAdapter(fts, podtopologyspread.New),
        nodeunschedulable.Name:               runtime.FactoryAdapter(fts, nodeunschedulable.New),
        noderesources.Name:                   runtime.FactoryAdapter(fts, noderesources.NewFit),
        noderesources.BalancedAllocationName: runtime.FactoryAdapter(fts, noderesources.NewBalancedAllocation),
        volumebinding.Name:                   runtime.FactoryAdapter(fts, volumebinding.New),
        volumerestrictions.Name:              runtime.FactoryAdapter(fts, volumerestrictions.New),
        volumezone.Name:                      runtime.FactoryAdapter(fts, volumezone.New),
        nodevolumelimits.CSIName:             runtime.FactoryAdapter(fts, nodevolumelimits.NewCSI),
        interpodaffinity.Name:                runtime.FactoryAdapter(fts, interpodaffinity.New),
        queuesort.Name:                       queuesort.New,
        defaultbinder.Name:                   defaultbinder.New,
        defaultpreemption.Name:               runtime.FactoryAdapter(fts, defaultpreemption.New),
        schedulinggates.Name:                 runtime.FactoryAdapter(fts, schedulinggates.New),
        // ... 还有 gangscheduling / topologyaware 等
    }
    return registry
}

📅 版本更新:从 v1.15 起,scheduler 改为 Framework + 内置插件设计;到 v1.36.1,注册表已扩展到 22 个内置插件(不含我们今天没讲的 nodelabel、noderesources.MostAllocated 等)。所有插件都通过 runtime.FactoryAdapter 适配,以拿到 feature gate。

4.1 NodeResourcesFit:资源界的"门票检票员"

NodeResourcesFit 的源码在 pkg/scheduler/framework/plugins/noderesources/ 下,对应 Plugin 结构体叫 Fit。它实现了 fwk.PreFilterPlugin + FilterPlugin + ScorePlugin 三个扩展点。

// pkg/scheduler/framework/plugins/noderesources/fit.go (行 92-105, k8s v1.36.1)

// Fit is a plugin that checks if a node has sufficient resources.
type Fit struct {
    ignoredResources                              sets.Set[string]
    ignoredResourceGroups                         sets.Set[string]
    enableInPlacePodVerticalScaling               bool
    enableSidecarContainers                       bool
    enableSchedulingQueueHint                     bool
    enablePodLevelResources                       bool
    enableDRAExtendedResource                     bool
    enableInPlacePodLevelResourcesVerticalScaling bool
    handle                                        fwk.Handle
    *resourceAllocationScorer                       // <-- 嵌入打分器
    placementScorer *resourceAllocationScorer
}

字段精讲:ignoredResources 是用户配置的"不算资源的资源名"(比如 kube.example.com/foo);enableSidecarContainers 决定能不能容忍 restartable init container(v1.28+ 默认开,v1.36.1 已 GA)。

// pkg/scheduler/framework/plugins/noderesources/fit.go (行 612-648, k8s v1.36.1)

// Filter invoked at the filter extension point.
// Checks if a node has sufficient resources, such as cpu, memory, gpu, opaque int resources etc to run a pod.
func (f *Fit) Filter(ctx context.Context, cycleState fwk.CycleState, pod *v1.Pod, nodeInfo fwk.NodeInfo) *fwk.Status {
    s, err := getPreFilterState(cycleState)
    if err != nil {
        return fwk.AsStatus(err)
    }
    // ... 略:draManager / opts 装配
    insufficientResources := fitsRequest(s, nodeInfo, f.ignoredResources, f.ignoredResourceGroups, draManager, opts)
    if len(insufficientResources) != 0 {
        // 收集所有失败原因,区分 Unschedulable / UnschedulableAndUnresolvable
        failureReasons := make([]string, 0, len(insufficientResources))
        statusCode := fwk.Unschedulable
        for i := range insufficientResources {
            failureReasons = append(failureReasons, insufficientResources[i].Reason)
            if insufficientResources[i].Unresolvable {
                statusCode = fwk.UnschedulableAndUnresolvable
            }
        }
        return fwk.NewStatus(statusCode, failureReasons...)
    }
    return nil
}

关键点:s 是 PreFilter 阶段写进 CycleState 的 preFilterState,里面装着 Pod 的总资源请求。这是 Filter 阶段不重复计算的关键。

// pkg/scheduler/framework/plugins/noderesources/fit.go (行 321-331, k8s v1.36.1)

func computePodResourceRequest(pod *v1.Pod, opts ResourceRequestsOptions) *preFilterState {
    // pod hasn't scheduled yet so we don't need to worry about InPlacePodVerticalScalingEnabled
    reqs := resource.PodRequests(pod, resource.PodResourcesOptions{
        SkipPodLevelResources:                    !opts.EnablePodLevelResources,
        UseDRANodeAllocatableResourceClaimStatus: opts.EnableDRANodeAllocatableResources,
    })
    result := &preFilterState{}
    result.SetMaxResource(reqs)
    return result
}

这条注释很有意思:"pod hasn't scheduled yet so we don't need to worry about InPlacePodVerticalScalingEnabled",翻译过来就是"Pod 还没调度,所以不需要考虑就地垂直扩缩容"。这是 InitContainer 资源计算的最大值原则(取 max(request, limit) of all init containers + sum of regular containers)。

// pkg/scheduler/framework/plugins/noderesources/fit.go (行 767-786, k8s v1.36.1)

// Score invoked at the Score extension point.
func (f *Fit) Score(ctx context.Context, state fwk.CycleState, pod *v1.Pod, nodeInfo fwk.NodeInfo) (int64, *fwk.Status) {
    s, err := getPreScoreState(state)
    if err != nil {
        s = &preScoreState{
            podRequests: f.calculatePodResourceRequestList(pod, f.resources),
        }
        if f.enableDRAExtendedResource {
            // ... DRA 场景回填
        }
    }
    return f.score(ctx, pod, nodeInfo, s.podRequests, s.draPreScoreState)
}

Fit 的打分"招数":f.score 内部分四种策略,最常用的 LeastAllocated(默认)公式是 score = (capacity - requested) * MaxNodeScore / capacity —— 越闲的节点得分越高。

4.2 NodeAffinity:节点亲和性的"双向选择"

NodeAffinity 同时实现 PreFilter / Filter / PreScore / Score,是"打分 + 过滤"双修插件。核心源码:pkg/scheduler/framework/plugins/nodeaffinity/node_affinity.go

// pkg/scheduler/framework/plugins/nodeaffinity/node_affinity.go (行 38-51, k8s v1.36.1)

// NodeAffinity is a plugin that checks if a pod node selector matches the node label.
type NodeAffinity struct {
    handle                    fwk.Handle
    addedNodeSelector         *nodeaffinity.NodeSelector          // <-- 集群级强制亲和
    addedPrefSchedTerms       *nodeaffinity.PreferredSchedulingTerms // <-- 集群级偏好
    enableSchedulingQueueHint bool
}

var _ fwk.PreFilterPlugin  = &NodeAffinity{}
var _ fwk.FilterPlugin     = &NodeAffinity{}
var _ fwk.PreScorePlugin   = &NodeAffinity{}
var _ fwk.ScorePlugin      = &NodeAffinity{}
var _ fwk.EnqueueExtensions = &NodeAffinity{}
var _ fwk.SignPlugin       = &NodeAffinity{}

注意 addedNodeSelector 字段 —— 它来自 SchedulerConfiguration 的 AddedAffinity,是"集群管理员强制追加"的节点选择器,不写在 Pod spec 里也能生效。比如强制让所有 Pod 都必须能跑在 SSD 节点上。

// pkg/scheduler/framework/plugins/nodeaffinity/node_affinity.go (行 269-297, k8s v1.36.1)

// Score returns the sum of the weights of the terms that match the Node.
func (pl *NodeAffinity) Score(ctx context.Context, state fwk.CycleState, pod *v1.Pod, nodeInfo fwk.NodeInfo) (int64, *fwk.Status) {
    node := nodeInfo.Node()
    var count int64
    if pl.addedPrefSchedTerms != nil {
        count += pl.addedPrefSchedTerms.Score(node)
    }
    s, err := getPreScoreState(state)
    if err != nil {
        // PreScore 没跑的兜底:现场算一遍
        preferredNodeAffinity, err := getPodPreferredNodeAffinity(pod)
        if err != nil {
            return 0, fwk.AsStatus(err)
        }
        s = &preScoreState{preferredNodeAffinity: preferredNodeAffinity}
    }
    if s.preferredNodeAffinity != nil {
        count += s.preferredNodeAffinity.Score(node)
    }
    return count, nil
}

打分逻辑很直白:把所有 preferred 偏好项里"命中当前节点"的权重加起来。所以 weight 越大,越倾向被选中。

4.3 TaintToleration:污点 & 容忍的"红绿灯"

TaintToleration 是 k8s 里节点"专用 / 隔离"场景的核心。源码在 pkg/scheduler/framework/plugins/tainttoleration/taint_toleration.go

// pkg/scheduler/framework/plugins/tainttoleration/taint_toleration.go (行 34-54, k8s v1.36.1)

// TaintToleration is a plugin that checks if a pod tolerates a node's taints.
type TaintToleration struct {
    handle                                   fwk.Handle
    enableSchedulingQueueHint                bool
    enableTaintTolerationComparisonOperators bool
}

var _ fwk.FilterPlugin   = &TaintToleration{}
var _ fwk.PreScorePlugin = &TaintToleration{}
var _ fwk.ScorePlugin    = &TaintToleration{}
var _ fwk.EnqueueExtensions = &TaintToleration{}
var _ fwk.SignPlugin     = &TaintToleration{}

const (
    Name              = names.TaintToleration
    preScoreStateKey  = "PreScore" + Name
    ErrReasonNotMatch = "node(s) had taints that the pod didn't tolerate"
)

// pkg/scheduler/framework/plugins/tainttoleration/taint_toleration.go (行 118-132, k8s v1.36.1)

// Filter invoked at the filter extension point.
func (pl *TaintToleration) Filter(ctx context.Context, state fwk.CycleState, pod *v1.Pod, nodeInfo fwk.NodeInfo) *fwk.Status {
    logger := klog.FromContext(ctx)
    node := nodeInfo.Node()

    taint, isUntolerated := v1helper.FindMatchingUntoleratedTaint(logger, node.Spec.Taints, pod.Spec.Tolerations,
        helper.DoNotScheduleTaintsFilterFunc(),
        pl.enableTaintTolerationComparisonOperators)
    if !isUntolerated {
        return nil
    }
    logger.V(4).Info("node had untolerated taints", "node", klog.KObj(node), "pod", klog.KObj(pod), "untoleratedTaint", taint)
    return fwk.NewStatus(fwk.UnschedulableAndUnresolvable, "node(s) had untolerated taint(s)")
}

核心调用是 v1helper.FindMatchingUntoleratedTaint,它遍历节点污点、查 Pod 容忍列表,判断是否存在"无容忍的 NoSchedule / NoExecute 污点"。如果存在,直接返回 UnschedulableAndUnresolvable —— 注意这里是"unresolvable",因为单靠这个 Pod 没法解决,必须 node 打 taint 才行。

// pkg/scheduler/framework/plugins/tainttoleration/taint_toleration.go (行 194-212, k8s v1.36.1)

// Score invoked at the Score extension point.
func (pl *TaintToleration) Score(ctx context.Context, state fwk.CycleState, pod *v1.Pod, nodeInfo fwk.NodeInfo) (int64, *fwk.Status) {
    logger := klog.FromContext(ctx)
    node := nodeInfo.Node()

    s, err := getPreScoreState(state)
    if err != nil {
        return 0, fwk.AsStatus(err)
    }

    score := int64(pl.countIntolerableTaintsPreferNoSchedule(logger, node.Spec.Taints, s.tolerationsPreferNoSchedule))
    return score, nil
}

// NormalizeScore invoked after scoring all nodes.
func (pl *TaintToleration) NormalizeScore(ctx context.Context, _ fwk.CycleState, pod *v1.Pod, scores fwk.NodeScoreList) *fwk.Status {
    return helper.DefaultNormalizeScore(fwk.MaxNodeScore, true, scores)
}

打分只针对 PreferNoSchedule 效果:节点上"无法容忍的 PreferNoSchedule 污点"越多,得分越低(因为打 0 分表示最不想要),最后用 DefaultNormalizeScore(MaxNodeScore, true, scores) 把分数规范化到 [0, MaxNodeScore]。第二个参数是 reverse,因为"污点越多越不想去",所以要反过来。

4.4 InterPodAffinity:Pod 之间的"邻里关系"

InterPodAffinity 看的不只是当前 Pod,还要扫描整个集群的所有 Pod 看"邻里关系"。它在 PreFilter 阶段就一次性把所有 Pod 的亲和 / 反亲和计数算好,避免在 Filter 阶段重复扫集群。

// pkg/scheduler/framework/plugins/interpodaffinity/plugin.go (行 46-58, k8s v1.36.1)

// InterPodAffinity is a plugin that checks inter pod affinity
type InterPodAffinity struct {
    parallelizer              fwk.Parallelizer
    args                      config.InterPodAffinityArgs
    sharedLister              fwk.SharedLister
    nsLister                  listersv1.NamespaceLister
    enableSchedulingQueueHint bool
}

var _ fwk.PreFilterPlugin  = &InterPodAffinity{}
var _ fwk.FilterPlugin     = &InterPodAffinity{}
var _ fwk.PreScorePlugin   = &InterPodAffinity{}
var _ fwk.ScorePlugin      = &InterPodAffinity{}
var _ fwk.EnqueueExtensions = &InterPodAffinity{}
var _ fwk.SignPlugin       = &InterPodAffinity{}

注意 args.IgnorePreferredTermsOfExistingPods,这是一个性能开关:开启后,只考虑"带 affinity 配置"的 Pod 之间的关系,忽略"existing pods"的 preferred 反亲和。可以让 PreFilter 在大集群下提速数倍。

// pkg/scheduler/framework/plugins/interpodaffinity/filtering.go (行 411-432, k8s v1.36.1)

// Filter invoked at the filter extension point.
// It checks if a pod can be scheduled on the specified node with pod affinity/anti-affinity configuration.
func (pl *InterPodAffinity) Filter(ctx context.Context, cycleState fwk.CycleState, pod *v1.Pod, nodeInfo fwk.NodeInfo) *fwk.Status {
    state, err := getPreFilterState(cycleState)
    if err != nil {
        return fwk.AsStatus(err)
    }

    if !satisfyPodAffinity(state, nodeInfo) {
        return fwk.NewStatus(fwk.UnschedulableAndUnresolvable, ErrReasonAffinityRulesNotMatch)
    }
    if !satisfyPodAntiAffinity(state, nodeInfo) {
        return fwk.NewStatus(fwk.Unschedulable, ErrReasonAntiAffinityRulesNotMatch)
    }
    if !satisfyExistingPodsAntiAffinity(state, nodeInfo) {
        return fwk.NewStatus(fwk.Unschedulable, ErrReasonExistingAntiAffinityRulesNotMatch)
    }
    return nil
}

3 个判断的差别:
satisfyPodAffinity:本 Pod 的 required 亲和是否满足 —— unresolvable(不在错误节点就不能满足,必须扩容节点)
satisfyPodAntiAffinity:本 Pod 的 required 反亲和 —— 节点上恰好有 Pod"它不想靠近",可调度时再挪
satisfyExistingPodsAntiAffinity:集群里已有的 Pod 对本 Pod 的反亲和 —— 同上

4.5 PodTopologySpread:拓扑打散的"数学家"

PodTopologySpread 的核心算法是 "MaxSkew" 概念:任意两个拓扑域之间 Pod 数量差的最大值。maxSkew 字段就是用来限制这个偏差的。

// pkg/scheduler/framework/plugins/podtopologyspread/plugin.go (行 59-83, k8s v1.36.1)

// PodTopologySpread is a plugin that ensures pod's topologySpreadConstraints is satisfied.
type PodTopologySpread struct {
    systemDefaulted                              bool
    parallelizer                                 fwk.Parallelizer
    defaultConstraints                           []v1.TopologySpreadConstraint
    sharedLister                                 fwk.SharedLister
    services                                     corelisters.ServiceLister
    replicationCtrls                             corelisters.ReplicationControllerLister
    replicaSets                                  appslisters.ReplicaSetLister
    statefulSets                                 appslisters.StatefulSetLister
    enableNodeInclusionPolicyInPodTopologySpread bool
    enableMatchLabelKeysInPodTopologySpread      bool
    enableSchedulingQueueHint                    bool
    enableTaintTolerationComparisonOperators     bool
}

var _ fwk.PreFilterPlugin  = &PodTopologySpread{}
var _ fwk.FilterPlugin     = &PodTopologySpread{}
var _ fwk.PreScorePlugin   = &PodTopologySpread{}
var _ fwk.ScorePlugin      = &PodTopologySpread{}
var _ fwk.EnqueueExtensions = &PodTopologySpread{}
var _ fwk.SignPlugin       = &PodTopologySpread{}

"systemDefaulted" 字段是关键:当 SchedulerConfiguration 把 DefaultingType 设为 SystemDefaulting,集群里没配 spreadConstraints 的 Service/RC/RS/StatefulSet 都会"自动被套上"两个默认约束(hostname skew=3、zone skew=5)。这就是为什么新建 Deployment 不写 spread 也能打散。

// pkg/scheduler/framework/plugins/podtopologyspread/plugin.go (行 46-57, k8s v1.36.1)

var systemDefaultConstraints = []v1.TopologySpreadConstraint{
    {
        TopologyKey:       v1.LabelHostname,         // kubernetes.io/hostname
        WhenUnsatisfiable: v1.ScheduleAnyway,
        MaxSkew:           3,
    },
    {
        TopologyKey:       v1.LabelTopologyZone,     // topology.kubernetes.io/zone
        WhenUnsatisfiable: v1.ScheduleAnyway,
        MaxSkew:           5,
    },
}

// pkg/scheduler/framework/plugins/podtopologyspread/scoring.go (行 196-226, k8s v1.36.1)

// Score invoked at the Score extension point.
func (pl *PodTopologySpread) Score(ctx context.Context, cycleState fwk.CycleState, pod *v1.Pod, nodeInfo fwk.NodeInfo) (int64, *fwk.Status) {
    node := nodeInfo.Node()
    s, err := getPreScoreState(cycleState)
    if err != nil {
        return 0, fwk.AsStatus(err)
    }
    if s.IgnoredNodes.Has(node.Name) {
        return 0, nil
    }
    var score float64
    for i, c := range s.Constraints {
        if tpVal, ok := node.Labels[c.TopologyKey]; ok {
            var cnt int64
            if c.TopologyKey == v1.LabelHostname {
                cnt = int64(countPodsMatchSelector(nodeInfo.GetPods(), c.Selector, pod.Namespace))
            } else {
                cnt = *s.TopologyValueToPodCounts[i][tpVal]
            }
            score += scoreForCount(cnt, c.MaxSkew, s.TopologyNormalizingWeight[i])
        }
    }
    return int64(math.Round(score)), nil
}

打分公式:scoreForCount(cnt, maxSkew, tpWeight) = cnt * tpWeight + (maxSkew - 1)。cnt 是当前节点上拓扑域内匹配的 Pod 数,越少越好;加 maxSkew - 1 是为了让"小 maxSkew"的约束有权重优势。最终 Score 和 NormalizeScore 配合后,节点越空越容易被选中。

4.6 VolumeBinding:卷绑定的"全链路管家"

VolumeBinding 是六个内置插件里"生命周期最长"的,因为它要在 PreFilter / Filter / Reserve / PreBind 四个阶段协同工作。核心机制是 AssumeCache:在 PreFilter 阶段"先假设能绑",Filter 阶段检查 PV 节点亲和 / 容量,Reserve 阶段"占位",PreBind 阶段"提交真实绑定"。

// pkg/scheduler/framework/plugins/volumebinding/volume_binding.go (行 70-92, k8s v1.36.1)

// VolumeBinding is a plugin that binds pod volumes in scheduling.
// In the Filter phase, pod binding cache is created for the pod and used in
// Reserve and PreBind phases.
type VolumeBinding struct {
    Binder      SchedulerVolumeBinder
    PVCLister   corelisters.PersistentVolumeClaimLister
    classLister storagelisters.StorageClassLister
    scorer      volumeCapacityScorer
    fts         feature.Features
}

var _ fwk.PreFilterPlugin  = &VolumeBinding{}
var _ fwk.FilterPlugin     = &VolumeBinding{}
var _ fwk.ReservePlugin    = &VolumeBinding{}
var _ fwk.PreBindPlugin    = &VolumeBinding{}
var _ fwk.PreScorePlugin   = &VolumeBinding{}
var _ fwk.ScorePlugin      = &VolumeBinding{}
var _ fwk.EnqueueExtensions = &VolumeBinding{}
var _ fwk.SignPlugin       = &VolumeBinding{}

// pkg/scheduler/framework/plugins/volumebinding/volume_binding.go (行 357-389, k8s v1.36.1)

// PreFilter invoked at the prefilter extension point to check if pod has all
// immediate PVCs bound. If not all immediate PVCs are bound, an
// UnschedulableAndUnresolvable is returned.
func (pl *VolumeBinding) PreFilter(ctx context.Context, state fwk.CycleState, pod *v1.Pod, nodes []fwk.NodeInfo) (*fwk.PreFilterResult, *fwk.Status) {
    logger := klog.FromContext(ctx)
    if hasPVC, err := pl.podHasPVCs(pod); err != nil {
        return nil, fwk.NewStatus(fwk.UnschedulableAndUnresolvable, err.Error())
    } else if !hasPVC {
        state.Write(stateKey, &stateData{})
        return nil, fwk.NewStatus(fwk.Skip)
    }
    podVolumeClaims, err := pl.Binder.GetPodVolumeClaims(logger, pod)
    if err != nil {
        return nil, fwk.AsStatus(err)
    }
    if len(podVolumeClaims.unboundClaimsImmediate) > 0 {
        status := fwk.NewStatus(fwk.UnschedulableAndUnresolvable)
        status.AppendReason("pod has unbound immediate PersistentVolumeClaims")
        return nil, status
    }
    state.Write(stateKey, &stateData{
        podVolumesByNode: make(map[string]*PodVolumes),
        // ... 初始化状态
    })
    return nil, nil
}

UnschedulableAndUnresolvable 的精妙:PreFilter 阶段如果发现 Pod 有 immediate PVC 但还没 Bound,返回的是 UnschedulableAndUnresolvable,意思是"调度器解决不了,等 PV controller 把 PVC Bound 了再试"。调度框架会把这个 Pod 放到 unschedulable queue,等到 PVC Bound 事件触发后再次入队(这是 EventsToRegister 的功劳)。

// pkg/scheduler/framework/plugins/volumebinding/volume_binding.go (行 470-523, k8s v1.36.1)

// Score invoked at the score extension point.
func (pl *VolumeBinding) Score(ctx context.Context, cs fwk.CycleState, pod *v1.Pod, nodeInfo fwk.NodeInfo) (int64, *fwk.Status) {
    if pl.scorer == nil {
        return 0, nil
    }
    state, err := getStateData(cs)
    if err != nil {
        return 0, fwk.AsStatus(err)
    }
    nodeName := nodeInfo.Node().Name
    podVolumes, ok := state.podVolumesByNode[nodeName]
    if !ok {
        return 0, nil
    }
    classResources := make(classResourceMap)
    if len(podVolumes.StaticBindings) != 0 || !pl.fts.EnableStorageCapacityScoring {
        // 静态绑定:group static binding volumes by storage class
        for _, staticBinding := range podVolumes.StaticBindings {
            // ...
        }
    } else {
        // 动态绑定:group dynamic binding volumes by storage class
        for _, provision := range podVolumes.DynamicProvisions {
            // ... 注意这里 Capacity 不能用 +=!
        }
    }
    return pl.scorer(classResources), nil
}

打分公式:VolumeBinding 的打分目标是"节点上某种 StorageClass 的剩余容量越多,得分越高"。它采用 broken linear function,参数由 StorageClass.VolumeBindingMode 和 SchedulerConfiguration 共同决定。需要打开 --feature-gates=StorageCapacityScoring=true 才生效(v1.36.1 仍是 alpha)。

// pkg/scheduler/framework/plugins/volumebinding/scorer.go (行 28-54, k8s v1.36.1)

// volumeCapacityScorer calculates the score based on class storage resource information.
type volumeCapacityScorer func(classResourceMap) int64

// buildScorerFunction builds volumeCapacityScorer from the scoring function shape.
func buildScorerFunction(scoringFunctionShape helper.FunctionShape) volumeCapacityScorer {
    rawScoringFunction := helper.BuildBrokenLinearFunction(scoringFunctionShape)
    f := func(requested, capacity int64) int64 {
        if capacity == 0 || requested > capacity {
            return rawScoringFunction(maxUtilization)
        }
        return rawScoringFunction(requested * maxUtilization / capacity)
    }
    return func(classResources classResourceMap) int64 {
        var nodeScore int64
        weightSum := len(classResources)
        if weightSum == 0 {
            return 0
        }
        for _, resource := range classResources {
            classScore := f(resource.Requested, resource.Capacity)
            nodeScore += classScore
        }
        return int64(math.Round(float64(nodeScore) / float64(weightSum)))
    }
}

注意 rawScoringFunction(requested * maxUtilization / capacity):把请求量/容量比压缩成 0-100 的整数,再丢给 broken linear function 做分段线性映射。设计目的:当容量满的时候,得分要明显衰减。


五、Pitfall:六个生产踩坑实录

这一节汇总六个内置插件在生产环境最常见的"翻车现场"。每个坑都有具体现象、根因、排查命令和修复方案。建议至少读两遍。

⛔ 生产环境禁止操作
以下操作都属于"会让整个集群调度停摆"的级别:
    • 在所有节点上 kubectl taint nodes --all key=value:NoSchedule
    • 在 SchedulerConfiguration 里同时禁用 NodeResourcesFit 和 InterPodAffinity 后还启用了大量 Pod
    • 删除 kube-system 命名空间下的 Pod(影响 k8s 自身组件调度)

5.1 坑一:InitContainer 的 requests 比 limits 大,Pod 卡 Pending

现象:Pod 内存用了 256Mi,但 requests 写的是 512Mi,Pod 一直 Pending,describe 显示 0/N nodes are available: insufficient memory

根因:NodeResourcesFit 在 PreFilter 阶段调用 resource.PodRequests,对 InitContainer 取 max(request, limit)。如果你没写 limit,就只算 request;如果 request 超过了节点 allocatable,就会被 Filter 拒绝。

修复:调小 request 到合理值,或者扩节点,或者使用 ResourceQuota 控制 namespace。

5.2 坑二:NodeAffinity 用了 matchExpressions 但 key 拼错大小写

现象:Deployment 部署后 Pod 一直 Pending,reason 是 node(s) didn't match Pod's node affinity/selector。但用 kubectl get node --show-labels 明明能看到那个 label。

根因:YAML 里把 az 写成 AZ,label key 是大小写敏感的。

修复:kubectl get pod -o yaml | grep -A5 affinity 对比节点 label 精确修复。

5.3 坑三:节点加了污点但 Pod 没配 tolerations,replicas 数始终不达标

现象:某次给节点打了 dedicated=ml:NoSchedule 污点做 AI 专用节点,但通用 Pod 全跑不上去。

根因:TaintToleration.Filter 调用 v1helper.FindMatchingUntoleratedTaint,因为 Pod spec.tolerations 为空,直接返回 UnschedulableAndUnresolvable。

修复:通用 Pod 加容忍 tolerations: [{operator: Exists}](容忍所有污点);或者用默认 PodTemplate 加在 cluster-wide PodPreset / mutating webhook。

5.4 坑四:PodTopologySpread + InterPodAffinity 同时开,调度性能暴跌

现象:3000 节点集群,Deployment 滚动更新时 Pod 调度 P99 延迟从 50ms 飙升到 2s。

根因:PodTopologySpread 和 InterPodAffinity 都要遍历整个集群 Pod 数才能算出拓扑分布。两个一起开,PreFilter 阶段 O(N) 重复扫描。

修复:1)开 --feature-gates=SchedulerQueueingHints=true(v1.27+ 默认开);2)InterPodAffinityArgs.IgnorePreferredTermsOfExistingPods=true 关闭扫描 existing pods 的 preferred 项。

5.5 坑五:VolumeBinding 在 Pod 删除时 AssumeCache 不释放

现象:快速循环创建删除 Pod 时,发现有些 PV 已经被"假设绑定"给被删除的 Pod,导致后续 Pod 卡 Pending。

根因:VolumeBinding 在 Reserve 阶段通过 AssumeCache 占位。如果 Pod 在 Permit 阶段被拒(被 kube-scheduler 决定不调度),Reserve 没被 Unreserve,AssumeCache 一直占着 PV。

修复:升级到 v1.30+;在 Reserve 阶段失败时触发 Unreserve,社区已经在 v1.29 修复了大部分 race condition。

5.6 坑六:自定义 OutOfTree 插件名字和内置插件重名,调度启动失败

现象:开发者写了个 OutOfTree 插件叫 NodeResourcesFit 想要替换内置,调度器启动直接 panic。

根因:注册表按名字去重,重名直接 panic。

修复:正确做法是先在 SchedulerConfiguration 的 plugins 段 disablePlugin: [{name: NodeResourcesFit}],再用 MultiPointPlugin.MyCustomFit 重新挂载。

✅ 预防建议:1)任何自定义插件都加公司前缀(如 company.com/MyFit);2)修改 SchedulerConfiguration 之前先备份 + dry-run;3)每个内置插件的 EventsToRegister 决定了 informer 压力,禁用前评估集群规模。


六、FAQ:22 组高频答疑

这一节用红问绿答的格式整理 22 个最常被问到的细节问题,分类如下:基础概念 5 题、源码原理 8 题、生产实践 9 题。

Question 1: Filter 和 Score 到底有什么区别?  Answer:Filter 是"准入判决",每个插件返回一个 *Status:nil 表示通过,非 nil 表示拒绝。返回非 nil 的瞬间,调度器会把节点从候选列表里删掉,后续不会再处理这个节点。Score 是"选美比赛",所有候选节点都跑完所有 Score 插件后,把每个插件的打分按权重相加,分最高的胜出。源码里所有 Filter 都返回 *fwk.Status,所有 Score 都返回 (int64, *fwk.Status) 这样的元组。


Question 2: Unschedulable 和 UnschedulableAndUnresolvable 有什么不同?  Answer:Unschedulable 表示"暂时调度不了但等集群变化后可能可以",Pod 会被丢进 unschedulable queue 等待下次重试;UnschedulableAndUnresolvable 表示"调度器自己解决不了",Pod 也进 unschedulable queue,但只有"能改变调度结果的集群事件"才会触发重试。比如 PVC 没 Bound(VolumeBinding.PreFilter 返回的)就是 Unresolvable,必须等 PV controller 把它 Bound。


Question 3: Plugin、ExtensionPoint、Stage 三个概念怎么区分?  Answer:Plugin 是实现 fwk.FilterPlugin / fwk.ScorePlugin 等接口的具体结构体(比如 TaintToleration)。ExtensionPoint 是扩展点(比如 Filter 本身),是调度流水线上的"插槽"。Stage 是扩展点所属的阶段(QueueSort / Filtering / Scoring / Binding 等)。一个 Plugin 可以同时实现多个 ExtensionPoint(比如 NodeAffinity 同时是 PreFilterPlugin / FilterPlugin / PreScorePlugin / ScorePlugin)。


Question 4: 同一个扩展点上能挂多个插件吗?执行顺序是怎样的?  Answer:可以。Filter 扩展点上能挂任意多个,调度器按 SchedulerConfiguration 里 Enabled 的顺序串行执行。但只要任意一个插件返回 Unschedulable,整个 Filter 阶段就短路,后续插件不再跑。Score 扩展点上的所有插件都会被调用,分数按 pluginConfig.weight 加权求和。


Question 5: 怎么自定义插件的名字才不会被 kube-scheduler 拒绝?  Answer:插件名字是大小写敏感的字符串。内置插件名字在 pkg/scheduler/framework/plugins/names/names.go 里。自定义 OutOfTree 插件不能和内置的 22 个名字冲突,建议加 company.example.com/MyPlugin 这种带域名前缀的命名。注册表 NewInTreeRegistry 里是按 string key 去重的,重名直接 panic。


Question 6: NodeResourcesFit 的资源计算是取 requests 还是 limits?  Answer:取 requests。但对 InitContainer 取 max(request, limit)。这是 v1.26 之后固定的行为,目的是保证调度器算出来的资源永远不小于 kubelet 实际需要的。这是 InitContainer "最大资源原则"在调度侧的体现。


Question 7: NodeAffinity 集群级 AddedAffinity 和 Pod 内 nodeAffinity 谁优先?  Answer:两者是 AND 关系。调度器会先校验集群级的 addedNodeSelector(不匹配直接出局),再校验 Pod 内 required(不匹配出局),最后把 addedPrefSchedTerms 和 Pod 内 preferred 一起加权打分。所以集群管理员可以通过 AddedAffinity 给所有 Pod 强制加约束,Pod 作者可以追加自己的偏好。


Question 8: TaintToleration 在打分阶段为什么只算 PreferNoSchedule 效果?  Answer:因为 NoSchedule 已经在 Filter 阶段拒了,NoExecute 会被 eviction controller 处理。只有 PreferNoSchedule 是"能跑但最好不跑"的"软条件",所以 TaintToleration.Score 调 countIntolerableTaintsPreferNoSchedule 计算污点数。NormalizeScore 第二参数 reverse=true 表示"污点越多分越低"。


Question 9: InterPodAffinity 在 PreFilter 阶段具体算的是什么?  Answer:它对所有现有 Pod 跑一遍 affinity / antiAffinity 匹配,把匹配计数存到 preFilterState 的 affinityCounts / antiAffinityCounts / existingAntiAffinityCounts 三个 map 里。map 的 key 是 topologyPair(key + value),value 是计数。Filter 阶段直接读 map 判定节点是不是满足约束,避免每次都扫全集群。这是 v1.18+ 才有的优化。


Question 10: PodTopologySpread 的 maxSkew 到底是什么意思?  Answer:maxSkew 是"任意两个拓扑域之间 Pod 数量差的最大值"。比如三个 zone 各跑了 5 个 Pod,那 skew = 0;如果一个 zone 7 个、另外两个 5 个,skew = 2。maxSkew: 1 表示最多差 1。filter 阶段如果 skew > maxSkew + 当下拓扑 Pod 数,Pod 就放不上去。


Question 11: PodTopologySpread 的 defaultConstraints 何时生效?  Answer:在 SchedulerConfiguration 把 DefaultingType 设为 SystemDefaulting 时生效。系统默认两个约束:hostname skew=3 + zone skew=5,只针对 Service / RC / RS / StatefulSet 创建的 Pod 生效(不带 spreadConstraints 的)。Deployment 创建的 Pod 不在 default 范围。这就是 v1.25 之后默认行为。


Question 12: VolumeBinding 的 AssumeCache 是怎么避免并发竞争的?  Answer:AssumeCache 是一个 in-memory cache,模拟"如果 Pod 被调度到这个节点上,PV 会被怎么绑定"。在 PreFilter 阶段 GetPodVolumeClaims 时直接给每个节点算好"假设绑定"的结果。Filter 阶段直接读这个快照。Reserve 阶段"占位"、PreBind 阶段把快照变成真实绑定。这样多个调度 cycle 并发跑也不会让同一个 PV 被两个 Pod 同时绑定。


Question 13: VolumeBinding 的 StorageCapacityScoring 是什么?  Answer:v1.33+ 引入的 alpha 特性,开启后 VolumeBinding 会参与 Score 阶段打分,分数取决于节点剩余 StorageClass 容量。需要 --feature-gates=StorageCapacityScoring=true + StorageClass 的 VolumeBindingMode=WaitForFirstConsumer。本文对应源码在 scorer.go 的 buildScorerFunction。


Question 14: EventsToRegister 这个方法干什么用?  Answer:它告诉 scheduler 的 SchedulingQueue:"如果你监听到这些 informer 事件,unschedulable queue 里的某个 Pod 现在可能可调度了,把它挪回 active queue"。比如 TaintToleration 监听 Node Add/UpdateNodeTaint,VolumeBinding 监听 StorageClass/PVC/PV 变化。没有 EventsToRegister,Pod 会等到主动重试才再次入队。


Question 15: 怎么查看当前 kube-scheduler 启用了哪些插件?  Answer:两种方法:1)kubectl describe configmap -n kube-system scheduler-config 看完整配置;2)kubectl logs -n kube-system kube-scheduler-xxx | grep -E 'enabled plugins' 看启动日志。生产环境更推荐看 configmap,因为它记录了真实生效的版本。


Question 16: 禁用某个内置插件会有什么副作用?  Answer:在 SchedulerConfiguration plugins.disablePlugin 里加插件名字即可。副作用就是该插件负责的检查 / 打分不再生效:禁用 NodeResourcesFit 意味着不检查资源,Pod 可能 OOMKill;禁用 TaintToleration 意味着污点不再隔离节点;禁用 PodTopologySpread 意味着 Pod 不会被打散。每加一条都要明确知道失去了什么。


Question 17: 我能不能既用 NodeResourcesFit 又用 NodeResourcesBalancedAllocation?  Answer:可以,而且通常推荐。它们两个名字不同(NodeResourcesFit / NodeResourcesBalancedAllocation),分别注册在 registry.go 第 62 和 63 行。前者负责 Filter + LeastAllocated 打分,后者负责 BalancedAllocation 打分。同时启用后,分数会累加。如果你只想要 LeastAllocated 而不要 BalancedAllocation,可以只禁用第二个。


Question 18: 自定义 OutOfTree 插件怎么打包进 kube-scheduler 二进制?  Answer:两种方式:1)独立调度器二进制:用 k8s.io/kubernetes/cmd/kube-scheduler + go build 出独立二进制,跑在集群里。这是最常见、最干净的方式。2)Pluggable scheduler:v1.18+ 支持用 --config 指定 SchedulerConfiguration,加 --out-of-tree-plugin-registrations 在加载新插件文件。注意 OutOfTree 不能改内置插件行为。


Question 19: SchedulerConfiguration 改了之后怎么应用?需要重启 kube-scheduler 吗?  Answer:需要重启。kube-scheduler 启动时一次性把 SchedulerConfiguration 读到内存,运行期间不会热加载。生产环境推荐做法:先滚动更新 kube-scheduler(DaemonSet 模式),用 SLO 监控调度延迟再全量切。可以先用 staging kube-scheduler 验证配置正确性。


Question 20: 怎么定位"某个具体插件"导致的调度失败?  Answer:三步:1)kubectl describe pod <name> 看 Events 里的 FailedScheduling,reason 里会带插件名(比如 "node(s) didn't match Pod's node affinity/selector" 是 NodeAffinity,"node(s) had taints that the pod didn't tolerate" 是 TaintToleration)。2)打开 --v=5 看 kube-scheduler 日志,每行 Filter 会打印 pluginName + reason。3)用 kubectl get events --field-selector reason=FailedScheduling 拿所有失败调度记录。


Question 21: k8s v1.36.1 跟早期版本相比,调度器最重要的变化是什么?  Answer:三层变化:1)DRA(Dynamic Resource Allocation):v1.30 GA,DynamicResources 插件处理 ResourceClaim;2)SchedulingQueueHints:v1.27 beta,v1.28 GA,用 QueueingHint 减少 informer 压力;3)TaintTolerationComparisonOperators:v1.27 alpha,toleration 支持 In/NotIn/Exists/DoesNotExist 操作符;4)MatchLabelKeysInPodTopologySpread:v1.27 alpha,让 topologySpreadConstraints 支持 labelKey 匹配。


Question 22: 学习源码应该先读哪个内置插件?  Answer:推荐顺序:NodeName(最简单)→ NodeResourcesFit(Filter + Score 经典套路)→ NodeAffinity(涉及 PreFilter 优化)→ TaintToleration(事件驱动)→ PodTopologySpread(算法最复杂)→ InterPodAffinity(性能坑最多)→ VolumeBinding(生命周期最长)。每个插件控制在 200 行内能读完 —— 调度器源码的"亲切度"远比想象中好。