























当你 kubectl apply 一个 Pod 之后,Kubernetes 内部发生了什么事?Pod 是怎么被分配到某个节点上的?为什么同样一份 Deployment,有时会调度到这台机器,有时又跑到另一台?这背后默默工作的核心组件,就是 kube-scheduler(调度器)。
本文深入 kube-scheduler 源码,按调度流水线实际触发顺序,逐一拆解 NodeResourcesFit、NodeAffinity、TaintToleration、InterPodAffinity、PodTopologySpread、VolumeBinding 六大核心插件的职责边界与源码结构。本文源码分析基于 k8s v1.36.1,全部以 pkg/scheduler/framework/plugins/ 目录下的实际代码为依据。
Kubernetes Scheduler Scheduling Framework 事件驱动 Go k8s v1.36.1
🔒 学习重点提示 — 建议先通读全文,再重点回顾标注内容
★ 重点掌握(必须)
☆ 次重点(了解即可)
文章目录
早期 k8s 调度器(v1.14 及之前)是一个 monolithic 大函数,内置几十个 predicate 和 priority 函数,每加一个策略都要改主循环代码。这带来三个根本痛点:改一个策略必须重新编译整个调度器二进制;用户的特殊调度需求无法动态扩展;所有 predicate 必须串行跑完才能进 priority 阶段,没有短路能力。
从 v1.15 起,调度器重构成 Scheduler Framework:将"过滤 / 打分 / 绑定"切成固定扩展点,每个扩展点可挂载多个插件。内置插件是出厂预装,第三方可以 OutOfTree 方式注册自己的插件。
| 维度 | 传统 monolithic(v1.14 及之前) | Scheduler Framework + 内置插件(v1.15+) |
|---|---|---|
| 扩展性 | 改源码 + 重新编译 | OutOfTree 注册自定义插件 |
| 代码组织 | 所有 predicate/priority 堆在主文件 | 每个插件独占一个目录 |
| 替换单个策略 | 无法实现 | SchedulerConfiguration 中 disablePlugin 禁用 |
| 短路能力 | 所有 predicate 必须跑完 | UnschedulableAndUnresolvable 立刻中断 |
| 调试可见性 | 日志看不到哪个 predicate 失败 | 每个插件返回 Status 带 reason,describe pod 可看 |
| 扩展点数量 | 硬编码:预选 -> 优选 -> 绑定 | 14 个扩展点(PreEnqueue ~ Bind) |
注意
不要直接修改 kube-scheduler 源码来定制内置插件的行为。正确做法是在 SchedulerConfiguration 中 disablePlugin: - NodeResourcesFit 后,实现自己的同名插件 OutOfTree 注册。直接改源码会让集群脱离上游,无法升级。
调度器对每个 Pod 的决策分为两个阶段:
TEXT⌘⛶
┌──────────────────────────────────────────────────────────────┐ │ Filter(硬门槛,行/不行) │ │ │ │ NodeResourcesFit ──► NodeAffinity ──► TaintToleration │ │ │ │ │ │ │ 资源够吗? 节点 label 匹配? 容忍污点吗? │ │ │ │ 任一插件返回 UnschedulableAndUnresolvable ──► 短路跳过 │ └──────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────┐ │ Score(软打分,好/更好) │ │ │ │ NodeResourcesFit ──► NodeAffinity ──► TaintToleration │ │ InterPodAffinity ──► PodTopologySpread ──► VolumeBinding │ │ │ │ ▼ NormalizeScore(归一化到 0-MaxNodeScore) │ │ 所有分数加权求和 ──► 选出分数最高节点 ──► Bind │ └──────────────────────────────────────────────────────────────┘
所有内置插件的名字都定义在同一 const 块中,NewInTreeRegistry() 通过这个名字映射到对应的工厂函数:
pkg/scheduler/framework/plugins/names/names.go (L19-43, k8s v1.36.1)
TEXT⌘⛶
1const (
2 PrioritySort = "PrioritySort"
3 DefaultBinder = "DefaultBinder"
4 DefaultPreemption = "DefaultPreemption"
5 DynamicResources = "DynamicResources"
6 ImageLocality = "ImageLocality"
7 InterPodAffinity = "InterPodAffinity"
8 NodeAffinity = "NodeAffinity"
9 NodeResourcesBalancedAllocation = "NodeResourcesBalancedAllocation"
10 NodeResourcesFit = "NodeResourcesFit"
11 NodeUnschedulable = "NodeUnschedulable"
12 PodTopologySpread = "PodTopologySpread"
13 SchedulingGates = "SchedulingGates"
14 TaintToleration = "TaintToleration"
15 VolumeBinding = "VolumeBinding"
16 // ... 共 22 个内置插件,完整定义见 names.go L19-43
17)
pkg/scheduler/framework/plugins/registry.go (L50-79, k8s v1.36.1)
TEXT⌘⛶
1func NewInTreeRegistry() runtime.Registry {
2 fts := plfeature.NewSchedulerFeaturesFromGates(feature.DefaultFeatureGate)
3 registry := runtime.Registry{
4 imagelocality.Name: imagelocality.New,
5 tainttoleration.Name: runtime.FactoryAdapter(fts, tainttoleration.New),
6 nodeaffinity.Name: runtime.FactoryAdapter(fts, nodeaffinity.New),
7 podtopologyspread.Name: runtime.FactoryAdapter(fts, podtopologyspread.New),
8 noderesources.Name: runtime.FactoryAdapter(fts, noderesources.NewFit),
9 volumebinding.Name: runtime.FactoryAdapter(fts, volumebinding.New),
10 interpodaffinity.Name: runtime.FactoryAdapter(fts, interpodaffinity.New),
11 // ... 共 25 个插件,完整注册见 registry.go L50-79
12 }
13 return registry
14}
补充
每个插件工厂函数被 runtime.FactoryAdapter 包裹,以支持 feature gate 动态控制插件启用状态。feature gate 关闭时,对应插件工厂返回 nil,插件不被实例化。
核心职责:判断节点 CPU / 内存 / GPU / 临时存储是否满足 Pod 的 resource.requests。资源不够时 Filter 直接返回 Unschedulable。Score 阶段越闲的节点分越高(默认 LeastAllocated 策略)。
pkg/scheduler/framework/plugins/noderesources/fit.go (k8s v1.36.1)
TEXT⌘⛶
1// Fit struct(L92-105)
2type Fit struct {
3 ignoredResources sets.Set[string]
4 ignoredResourceGroups sets.Set[string]
5 enableInPlacePodVerticalScaling bool
6 enableSidecarContainers bool
7 enableSchedulingQueueHint bool
8 enablePodLevelResources bool
9 enableDRAExtendedResource bool
10 enableInPlacePodLevelResourcesVerticalScaling bool
11 handle fwk.Handle
12 *resourceAllocationScorer
13 placementScorer *resourceAllocationScorer
14}
15
16// PreFilter(L334-346)- 计算 Pod 所有容器的 resource requests
17func (f *Fit) PreFilter(ctx context.Context, cycleState fwk.CycleState,
18 pod *v1.Pod, nodes []fwk.NodeInfo) (*fwk.PreFilterResult, *fwk.Status) {
19 result := computePodResourceRequest(pod, ResourceRequestsOptions{
20 EnablePodLevelResources: f.enablePodLevelResources,
21 })
22 cycleState.Write(preFilterStateKey, result)
23 return nil, nil
24}
25
26// PreScore(L138-155)- 计算 podRequests 存入 CycleState
27func (f *Fit) PreScore(ctx context.Context, cycleState fwk.CycleState,
28 pod *v1.Pod, nodes []fwk.NodeInfo) *fwk.Status {
29 podRequests := f.calculatePodResourceRequestList(pod, f.resources)
30 cycleState.Write(preScoreStateKey, &preScoreState{podRequests: podRequests})
31 return nil
32}
33
34// Score(L768-786)- LeastAllocated 默认策略
35// 公式: score = (capacity - requested) * MaxNodeScore / capacity
36// 越闲 → 分子越大 → 分越高
37func (f *Fit) Score(ctx context.Context, state fwk.CycleState,
38 pod *v1.Pod, nodeInfo fwk.NodeInfo) (int64, *fwk.Status) {
39 s, err := getPreScoreState(state)
40 return f.score(ctx, pod, nodeInfo, s.podRequests, s.draPreScoreState)
41}
42
43// EventsToRegister(L368-398)
44// 关键事件:Pod 删除、节点 Add/UpdateNodeAllocatable/UpdateNodeTaint/UpdateNodeLabel
45// 配合 enableSchedulingQueueHint 时,只注册 Add | UpdateNodeAllocatable
补充
LeastAllocated(默认):最闲优先,适合通用计算。MostAllocated:最忙优先,适合批量回收。RequestedToCapacityRatio:自定义资源权重,适合 GPU/FPGA 异构集群。BalancedAllocation:CPU/内存均衡。placementScorer(v1.36.1 新增)用于 Gang Scheduling 场景,按 MostAllocated 策略对 Pod 组副本做资源均衡分配。
TEXT⌘⛶
1# 触发 NodeResourcesFit 的 Pod(k8s v1.36.1)
2apiVersion: v1
3kind: Pod
4metadata:
5 name: resource-demo
6spec:
7 containers:
8 - name: nginx
9 image: nginx:1.25
10 resources:
11 requests:
12 cpu: "2000m"
13 memory: "512Mi"
14 limits:
15 cpu: "4000m"
16 memory: "1Gi"
核心职责:解析 pod.spec.affinity.nodeAffinity 和 pod.spec.nodeSelector,根据节点 label 判断是否匹配。requiredDuringSchedulingIgnoredDuringExecution 是硬条件(Filter),preferredDuringSchedulingIgnoredDuringExecution 是软打分(Score)。
pkg/scheduler/framework/plugins/nodeaffinity/node_affinity.go (k8s v1.36.1)
TEXT⌘⛶
1// NodeAffinity struct(L39-44)
2type NodeAffinity struct {
3 handle fwk.Handle
4 addedNodeSelector *nodeaffinity.NodeSelector
5 addedPrefSchedTerms *nodeaffinity.PreferredSchedulingTerms
6 enableSchedulingQueueHint bool
7}
8
9// PreFilter(L159-209)- 解析 requiredDuringSchedulingIgnoredDuringExecution
10func (pl *NodeAffinity) PreFilter(ctx context.Context, cycleState fwk.CycleState,
11 pod *v1.Pod, nodes []fwk.NodeInfo) (*fwk.PreFilterResult, *fwk.Status) {
12 state := &preFilterState{requiredNodeSelectorAndAffinity: nodeaffinity.GetRequiredNodeAffinity(pod)}
13 cycleState.Write(preFilterStateKey, state)
14 if nodeNames != nil && len(nodeNames) == 0 {
15 return nil, fwk.NewStatus(fwk.UnschedulableAndUnresolvable, errReasonConflict)
16 } else if len(nodeNames) > 0 {
17 return &fwk.PreFilterResult{NodeNames: nodeNames}, nil
18 }
19 return nil, nil
20}
21
22// Filter(L216-239)- 硬条件匹配
23func (pl *NodeAffinity) Filter(ctx context.Context, state fwk.CycleState,
24 pod *v1.Pod, nodeInfo fwk.NodeInfo) *fwk.Status {
25 node := nodeInfo.Node()
26 if pl.addedNodeSelector != nil && !pl.addedNodeSelector.Match(node) {
27 return fwk.NewStatus(fwk.UnschedulableAndUnresolvable, errReasonEnforced)
28 }
29 match, _ := s.requiredNodeSelectorAndAffinity.Match(node)
30 if !match {
31 return fwk.NewStatus(fwk.UnschedulableAndUnresolvable, ErrReasonPod)
32 }
33 return nil
34}
35
36// PreScore(L252-267)- 解析 preferredDuringSchedulingIgnoredDuringExecution
37func (pl *NodeAffinity) PreScore(ctx context.Context, cycleState fwk.CycleState,
38 pod *v1.Pod, nodes []fwk.NodeInfo) *fwk.Status {
39 preferredNodeAffinity, _ := getPodPreferredNodeAffinity(pod)
40 cycleState.Write(preScoreStateKey, &preScoreState{preferredNodeAffinity: preferredNodeAffinity})
41 return nil
42}
43
44// Score(L269-297)- 软偏好打分
45func (pl *NodeAffinity) Score(ctx context.Context, state fwk.CycleState,
46 pod *v1.Pod, nodeInfo fwk.NodeInfo) (int64, *fwk.Status) {
47 node := nodeInfo.Node()
48 var count int64
49 if pl.addedPrefSchedTerms != nil {
50 count += pl.addedPrefSchedTerms.Score(node)
51 }
52 s, _ := getPreScoreState(state)
53 if s.preferredNodeAffinity != nil {
54 count += s.preferredNodeAffinity.Score(node)
55 }
56 return count, nil
57}
58
59// NormalizeScore(L299-302)
60func (pl *NodeAffinity) NormalizeScore(ctx context.Context, state fwk.CycleState,
61 pod *v1.Pod, scores fwk.NodeScoreList) *fwk.Status {
62 return helper.DefaultNormalizeScore(fwk.MaxNodeScore, false, scores)
63}
64
65// EventsToRegister(L101-115)
66// 关键:enableSchedulingQueueHint 时只注册 Add | UpdateNodeLabel
67// 无 QHint 时注册 Add | UpdateNodeLabel | UpdateNodeTaint(覆盖预检漏掉的 Add 事件)
TEXT⌘⛶
1# 触发 NodeAffinity 的 Pod(k8s v1.36.1)
2apiVersion: v1
3kind: Pod
4metadata:
5 name: affinity-demo
6spec:
7 nodeSelector:
8 az: cn-east-1
9 affinity:
10 nodeAffinity:
11 requiredDuringSchedulingIgnoredDuringExecution:
12 nodeSelectorTerms:
13 - matchExpressions:
14 - key: node-type
15 operator: In
16 values: [general, compute]
17 preferredDuringSchedulingIgnoredDuringExecution:
18 - weight: 100
19 preference:
20 matchExpressions:
21 - key: az
22 operator: In
23 values: [cn-east-1]
核心职责:判断 Pod 的 tolerations 是否覆盖节点的污点。NoSchedule / NoExecute 污点无匹配时 Filter 返回 UnschedulableAndUnresolvable。PreferNoSchedule 污点无匹配时 Score 扣分。NoExecute 的驱逐由 kubelet 处理,不参与调度决策。
pkg/scheduler/framework/plugins/tainttoleration/taint_toleration.go (k8s v1.36.1)
TEXT⌘⛶
1// TaintToleration struct(L35-39)
2type TaintToleration struct {
3 handle fwk.Handle
4 enableSchedulingQueueHint bool
5 enableTaintTolerationComparisonOperators bool
6}
7
8// Filter(L119-132)
9func (pl *TaintToleration) Filter(ctx context.Context, state fwk.CycleState,
10 pod *v1.Pod, nodeInfo fwk.NodeInfo) *fwk.Status {
11 logger := klog.FromContext(ctx)
12 node := nodeInfo.Node()
13 _, isUntolerated := v1helper.FindMatchingUntoleratedTaint(
14 logger, node.Spec.Taints, pod.Spec.Tolerations,
15 helper.DoNotScheduleTaintsFilterFunc(),
16 pl.enableTaintTolerationComparisonOperators)
17 if !isUntolerated {
18 return nil
19 }
20 return fwk.NewStatus(fwk.UnschedulableAndUnresolvable,
21 "node(s) had untolerated taint(s)")
22}
23
24// PreScore(L156-164)- 提取 PreferNoSchedule 类型的 tolerations
25func (pl *TaintToleration) PreScore(ctx context.Context, cycleState fwk.CycleState,
26 pod *v1.Pod, nodes []fwk.NodeInfo) *fwk.Status {
27 tolerationsPreferNoSchedule := getAllTolerationPreferNoSchedule(pod.Spec.Tolerations)
28 cycleState.Write(preScoreStateKey, &preScoreState{tolerationsPreferNoSchedule: tolerationsPreferNoSchedule})
29 return nil
30}
31
32// Score(L195-207)- 计算节点上无法容忍的 PreferNoSchedule 污点数
33func (pl *TaintToleration) Score(ctx context.Context, state fwk.CycleState,
34 pod *v1.Pod, nodeInfo fwk.NodeInfo) (int64, *fwk.Status) {
35 logger := klog.FromContext(ctx)
36 s, _ := getPreScoreState(state)
37 score := int64(pl.countIntolerableTaintsPreferNoSchedule(
38 logger, nodeInfo.Node().Spec.Taints, s.tolerationsPreferNoSchedule))
39 return score, nil
40}
41
42// NormalizeScore(L209-212)
43func (pl *TaintToleration) NormalizeScore(ctx context.Context, _ fwk.CycleState,
44 pod *v1.Pod, scores fwk.NodeScoreList) *fwk.Status {
45 return helper.DefaultNormalizeScore(fwk.MaxNodeScore, true, scores)
46}
47
48// EventsToRegister(L70-91)
49// enableSchedulingQueueHint 时:Node Add|UpdateNodeTaint + Pod UpdatePodToleration
50// 无 QHint 时:Node Add|UpdateNodeTaint|UpdateNodeLabel(覆盖预检漏掉的 Add 事件)
TEXT⌘⛶
1# 触发 TaintToleration 的 Pod(k8s v1.36.1)
2apiVersion: v1
3kind: Pod
4metadata:
5 name: taint-demo
6spec:
7 tolerations:
8 - key: "dedicated"
9 operator: "Equal"
10 value: "true"
11 effect: "NoSchedule"
12 - key: "node.kubernetes.io/not-ready"
13 operator: "Exists"
14 effect: "NoExecute"
15 tolerationSeconds: 300
16 - key: "node.kubernetes.io/unreachable"
17 operator: "Exists"
18 effect: "NoExecute"
19 tolerationSeconds: 300
核心职责:根据 pod.spec.affinity.podAffinity(亲和)和 podAntiAffinity(反亲和)判断 Pod 之间的关系。硬亲和/反亲和在 Filter 阶段强制约束,软打分在 Score 阶段做偏好评估。
pkg/scheduler/framework/plugins/interpodaffinity/ (k8s v1.36.1)
TEXT⌘⛶
1// InterPodAffinity struct(plugin.go L47-53)
2type InterPodAffinity struct {
3 parallelizer fwk.Parallelizer
4 args config.InterPodAffinityArgs
5 sharedLister fwk.SharedLister
6 nsLister listersv1.NamespaceLister
7 enableSchedulingQueueHint bool
8}
9
10// PreFilter(filtering.go L274-312)- 构建拓扑打分数据结构
11func (pl *InterPodAffinity) PreFilter(ctx context.Context, cycleState fwk.CycleState,
12 pod *v1.Pod, allNodes []fwk.NodeInfo) (*fwk.PreFilterResult, *fwk.Status) {
13 state := &preScoreState{topologyScore: make(map[string]map[string]int64)}
14 // 处理 podAffinity + podAntiAffinity 的拓扑打分
15 cycleState.Write(preScoreStateKey, state)
16 return nil, nil
17}
18
19// Filter(filtering.go L412-440)- 三层判断
20func (pl *InterPodAffinity) Filter(ctx context.Context, cycleState fwk.CycleState,
21 pod *v1.Pod, nodeInfo fwk.NodeInfo) *fwk.Status {
22 state, _ := getPreFilterState(cycleState)
23 if !satisfyPodAffinity(state, nodeInfo) {
24 return fwk.NewStatus(fwk.UnschedulableAndUnresolvable, ErrReasonAffinityRulesNotMatch)
25 }
26 if !satisfyPodAntiAffinity(state, nodeInfo) {
27 return fwk.NewStatus(fwk.Unschedulable, ErrReasonAntiAffinityRulesNotMatch)
28 }
29 if !satisfyExistingPodsAntiAffinity(state, nodeInfo) {
30 return fwk.NewStatus(fwk.Unschedulable, ErrReasonExistingAntiAffinityRulesNotMatch)
31 }
32 return nil
33}
34
35// Score(scoring.go L240-255)
36func (pl *InterPodAffinity) Score(ctx context.Context, cycleState fwk.CycleState,
37 pod *v1.Pod, nodeInfo fwk.NodeInfo) (int64, *fwk.Status) {
38 node := nodeInfo.Node()
39 s, _ := getPreScoreState(cycleState)
40 var score int64
41 for tpKey, tpValues := range s.topologyScore {
42 if v, exist := node.Labels[tpKey]; exist {
43 score += tpValues[v]
44 }
45 }
46 return score, nil
47}
48
49// NormalizeScore(scoring.go L258-290)- 归一化到 [0, MaxNodeScore]
50// maxMinDiff = maxCount - minCount,归一化公式同 PodTopologySpread
51
52// EventsToRegister(plugin.go L96-103)
53// enableSchedulingQueueHint 时:Pod Add|UpdatePodLabel|Delete + Node Add|UpdateNodeLabel
54// 无 QHint 时:Pod Add|UpdatePodLabel|Delete + Node Add|UpdateNodeLabel|UpdateNodeTaint
TEXT⌘⛶
1# 触发 InterPodAffinity 的 Pod(k8s v1.36.1)
2apiVersion: v1
3kind: Pod
4metadata:
5 name: web-pod
6spec:
7 affinity:
8 podAntiAffinity:
9 requiredDuringSchedulingIgnoredDuringExecution:
10 - labelSelector:
11 matchLabels:
12 app: redis-cache
13 topologyKey: topology.kubernetes.io/zone
14 podAffinity:
15 preferredDuringSchedulingIgnoredDuringExecution:
16 - weight: 100
17 podAffinityTerm:
18 labelSelector:
19 matchLabels:
20 app: web-frontend
21 topologyKey: topology.kubernetes.io/zone
核心职责:解析 pod.spec.topologySpreadConstraints,让同一组 Pod 按 maxSkew 均匀分布在 zone / node 等拓扑域里。v1.18 引入,替代手动写反亲和规则。对没有声明约束的 Pod,会自动注入 system-defaulted 约束(v1.27+)。
pkg/scheduler/framework/plugins/podtopologyspread/plugin.go (k8s v1.36.1)
TEXT⌘⛶
1// PodTopologySpread struct(plugin.go L60-73)
2type PodTopologySpread struct {
3 systemDefaulted bool
4 parallelizer fwk.Parallelizer
5 defaultConstraints []v1.TopologySpreadConstraint
6 sharedLister fwk.SharedLister
7 services corelisters.ServiceLister
8 replicaSets appslisters.ReplicaSetLister
9 statefulSets appslisters.StatefulSetLister
10 enableNodeInclusionPolicyInPodTopologySpread bool
11 enableMatchLabelKeysInPodTopologySpread bool
12 enableSchedulingQueueHint bool
13 enableTaintTolerationComparisonOperators bool
14}
15
16// system-defaulted 约束(plugin.go L46-57)
17// 当 SchedulerConfiguration 的 defaultingType = SystemDefaulting 时自动注入
18var systemDefaultConstraints = []v1.TopologySpreadConstraint{
19 {TopologyKey: v1.LabelHostname, WhenUnsatisfiable: v1.ScheduleAnyway, MaxSkew: 3},
20 {TopologyKey: v1.LabelTopologyZone, WhenUnsatisfiable: v1.ScheduleAnyway, MaxSkew: 5},
21}
22
23// PreFilter(filtering.go L139-152)
24// 若 defaultingType = SystemDefaulting 且 Pod 无约束,注入 systemDefaultConstraints
25// 若 WhenUnsatisfiable = DoNotSchedule,返回 NodeNames 候选集合
26
27// Filter(filtering.go L314-380)
28// skew = count(当前拓扑域匹配 Pod) - min(所有拓扑域匹配 Pod)
29// 超过 maxSkew → UnschedulableAndUnresolvable
30
31// Score(scoring.go L199-226)- 打分公式
32func (pl *PodTopologySpread) Score(ctx context.Context, cycleState fwk.CycleState,
33 pod *v1.Pod, nodeInfo fwk.NodeInfo) (int64, *fwk.Status) {
34 var score float64
35 for i, c := range s.Constraints {
36 if tpVal, ok := node.Labels[c.TopologyKey]; ok {
37 cnt := int64(countPodsMatchSelector(...))
38 score += scoreForCount(cnt, c.MaxSkew, s.TopologyNormalizingWeight[i])
39 }
40 }
41 return int64(math.Round(score)), nil
42}
43
44// NormalizeScore(scoring.go L229-270)
45// 公式: fScore = MaxNodeScore * (score - minScore) / (maxScore - minScore)
TEXT⌘⛶
# PodTopologySpread skew 计算示例 假设 3 个 zone,各有 Pod 数: zone-a: 5 个 Pod ← skew = 5 - 2 = 3 ← 超过 maxSkew zone-b: 2 个 Pod ← skew = 2 - 2 = 0 ← OK zone-c: 2 个 Pod ← skew = 2 - 2 = 0 ← OK 若 maxSkew = 2: → zone-a skew=3 超过 maxSkew,新 Pod 无法调度到 zone-a → 只能调度到 zone-b 或 zone-c 若 whenUnsatisfiable = ScheduleAnyway: → 允许调度到 zone-a,但记录拓扑违规(分数降低)
TEXT⌘⛶
1# 触发 PodTopologySpread 的 Pod(k8s v1.36.1)
2apiVersion: v1
3kind: Pod
4metadata:
5 name: topology-spread-demo
6spec:
7 topologySpreadConstraints:
8 - maxSkew: 1
9 topologyKey: topology.kubernetes.io/zone
10 whenUnsatisfiable: DoNotSchedule
11 labelSelector:
12 matchLabels:
13 app: demo-app
14 - maxSkew: 2
15 topologyKey: topology.kubernetes.io/hostname
16 whenUnsatisfiable: ScheduleAnyway
17 labelSelector:
18 matchLabels:
19 app: demo-app
核心职责:唯一横跨四个调度阶段的内置插件。解决"并发调度时 PV 被两个 Pod 同时抢占"的并发安全问题。核心机制是 AssumeCache:PreFilter 阶段"先假设能绑",Reserve 占位,PreBind 提交真实绑定,冲突时 Unreserve 回退。
pkg/scheduler/framework/plugins/volumebinding/ (k8s v1.36.1)
TEXT⌘⛶
1// VolumeBinding struct(volume_binding.go L73-79)
2type VolumeBinding struct {
3 Binder SchedulerVolumeBinder
4 PVCLister corelisters.PersistentVolumeClaimLister
5 classLister storagelisters.StorageClassLister
6 scorer volumeCapacityScorer
7 fts feature.Features
8}
9
10// SchedulerVolumeBinder 接口(binder.go L153-196)
11type SchedulerVolumeBinder interface {
12 GetPodVolumeClaims(pod) (*PodVolumeClaims, error)
13 FindPodVolumes(pod, node) (*PodVolumes, ConflictReasons, error)
14 AssumePodVolumes(assumedPod, nodeName, volumes) (allFullyBound bool, err error)
15 RevertAssumedPodVolumes(volumes)
16 BindPodVolumes(ctx, assumedPod, volumes) error
17}
18
19// PreFilter(volume_binding.go L360-390)
20// 获取 Pod 所有 PVC,分为 bound / unboundImmediate / unboundDelayBinding
21// unboundImmediate 未绑定 → 返回 UnschedulableAndUnresolvable
22// 无 PVC → 写空 stateData,返回 Skip
23
24// Filter(volume_binding.go L424-453)
25func (pl *VolumeBinding) Filter(ctx context.Context, cs fwk.CycleState,
26 pod *v1.Pod, nodeInfo fwk.NodeInfo) *fwk.Status {
27 state, _ := getStateData(cs)
28 podVolumes, reasons, _ := pl.Binder.FindPodVolumes(logger, pod, state.podVolumeClaims, node)
29 if len(reasons) > 0 {
30 return fwk.NewStatus(fwk.UnschedulableAndUnresolvable, reasons...)
31 }
32 state.podVolumesByNode[node.Name] = podVolumes
33 return nil
34}
35
36// Reserve(volume_binding.go L531-549)
37func (pl *VolumeBinding) Reserve(ctx context.Context, cs fwk.CycleState,
38 pod *v1.Pod, nodeName string) *fwk.Status {
39 state, _ := getStateData(cs)
40 podVolumes := state.podVolumesByNode[nodeName]
41 allBound, _ := pl.Binder.AssumePodVolumes(logger, pod, nodeName, podVolumes)
42 state.allBound = allBound
43 return nil
44}
45
46// PreBind(volume_binding.go L577-604)
47// 同步等待 PV 控制器完成真实绑定,失败则触发 Unreserve 回退
48
49// Unreserve(volume_binding.go L604-615)
50// 绑定失败时调用 RevertAssumedPodVolumes 回退假设状态
51
52// Score(volume_binding.go L471-530)
53// StorageClass 剩余容量打分,需要 --feature-gates=StorageCapacityScoring=true(v1.36.1 为 alpha)
54
55// EventsToRegister(volume_binding.go L107-143)
56// StorageClass/PVC/PV Add|Update / Node Add|UpdateNodeLabel|UpdateNodeTaint
57// CSINode Add|Update / CSIDriver Update / CSIStorageCapacity Add|Update
注意
PreFilter 阶段调用 GetPodVolumeClaims 把 PVC 分类。Reserve 阶段调用 AssumePodVolumes 把 PVC 信息写入 AssumeCache 占位。PreBind 阶段调用 BindPodVolumes 同步等待 PV 控制器完成真实绑定。如果另一个 Pod 在此期间已经真正绑定了这个 PV,Unreserve 阶段会调用 RevertAssumedPodVolumes 回退。这就是 VolumeBinding 要跨越四个阶段的原因——解决"并发调度时 PV 抢用"的 race condition。PreBindPreFlight(L555)用于判断是否需要真正执行绑定,跳过无 volume 的 Pod。
TEXT⌘⛶
1# 触发 VolumeBinding 的 Pod(k8s v1.36.1)
2apiVersion: v1
3kind: Pod
4metadata:
5 name: volume-demo
6spec:
7 containers:
8 - name: app
9 image: nginx
10 volumeMounts:
11 - name: data
12 mountPath: /usr/share/nginx/html
13 volumes:
14 - name: data
15 persistentVolumeClaim:
16 claimName: my-pvc
17
18---
19apiVersion: storage.k8s.io/v1
20kind: StorageClass
21metadata:
22 name: fast-ssd
23provisioner: pd.csi.storage.gke.io
24parameters:
25 type: pd-ssd
26volumeBindingMode: WaitForFirstConsumer
27allowVolumeExpansion: true
| 插件 | Filter 职责 | Score 职责 | 跨越阶段 | 核心数据结构 |
|---|---|---|---|---|
| NodeResourcesFit | CPU/内存/GPU/临时存储是否足够(L334+) | LeastAllocated 最闲优先(L768) | PreFilter / Filter / PreScore / Score | preFilterState / preScoreState |
| NodeAffinity | nodeSelector + nodeAffinity required 硬匹配(L216) | preferred 软偏好打分(L269) | PreFilter / Filter / PreScore / Score | nodeaffinity.NodeSelector(L90-92) |
| TaintToleration | NoSchedule/NoExecute 污点容忍判断(L119) | PreferNoSchedule 污点越多分越低(L195) | Filter / PreScore / Score | Toleration[] vs NodeSpec.Taints[] |
| InterPodAffinity | 硬亲和/反亲和拓扑域匹配(filtering.go L412) | 软亲和/反亲和加权打分(scoring.go L240) | PreFilter / Filter / PreScore / Score | topologyScore(scoring.go L166-168) |
| PodTopologySpread | 拓扑域 skew 是否超过 maxSkew(filtering.go L314) | 匹配 Pod 数越少分越高(scoring.go L199) | PreFilter / Filter / PreScore / Score | preScoreState / systemDefaultConstraints(plugin.go L46) |
| VolumeBinding | PV 节点亲和/容量/拓扑匹配(volume_binding.go L424) | StorageClass 剩余容量打分(L471,alpha) | PreFilter / Filter / Reserve / PreBind / PreScore / Score | SchedulerVolumeBinder / stateData(L53-64) |
问题现象
Pod 申请了 GPU 资源(nvidia.com/gpu: 1),节点确认有 GPU,但调度反复报 Insufficient nvidia.com/gpu,Pod 始终 Pending。
根因:v1.26 之前 NodeResourcesFit 读取的是 container[].resources.limits 计算资源;v1.26 起改为读取 container[].resources.requests。如果 Pod 只写了 limits 没写 requests,v1.26+ 的行为与之前不一致。
TEXT⌘⛶
# 查看节点真实 Allocatable kubectl describe node <node-name> | grep -A5 "Allocated resources" # 查看 Pod 实际 requests/limits kubectl get pod <pod-name> -o jsonpath='{.spec.containers[*].resources}'
修复:
TEXT⌘⛶
containers: - name: gpu-worker resources: requests: nvidia.com/gpu: "1" limits: nvidia.com/gpu: "1"
问题现象
Pod 的 nodeSelector 写了 node-type: general,节点确认打上了 label,但调度一直失败。
根因:调度器通过 node informer 同步节点 label。如果 Pod 提交后、调度前 label 刚更新但 informer 还未同步完成,调度器看到的是旧数据。Filter 阶段匹配失败。
修复:使用 preferredDuringSchedulingIgnoredDuringExecution 替代硬匹配,或确保 Pod 提交前 label 已存在。
问题现象
两个 Deployment 互为 required 硬亲和,结果双方各有部分 Pod 调度成功,但始终有部分 Pod 一直 Pending。
根因:requiredDuringSchedulingIgnoredDuringExecution 的硬亲和在 Filter 阶段强制要求拓扑域内已有匹配 Pod。初始部署时双方 Pod 互相依赖,出现"鸡生蛋蛋生鸡"的死锁。
修复:将 required 改为 preferred,降低初始部署阶段的耦合度。
问题现象
Pod 没有写任何 topologySpreadConstraints,但调度器仍然给它应用了 zone 打散约束,导致 Pod 被调度到不符合业务预期的 zone。
根因:v1.27+ 中,当 Pod 不声明约束且 SchedulerConfiguration 的 defaultingType = SystemDefaulting 时,PodTopologySpread 自动注入 hostname skew=3 和 zone skew=5 两个 system-defaulted 约束(plugin.go L46-57)。
修复:显式声明自己的约束覆盖默认行为,或在 SchedulerConfiguration 中把 defaultingType 设为 CustomDefaulting 并清空 defaultConstraints。
问题现象
使用 WaitForFirstConsumer PVC 的 Pod 反复调度、始终不绑定,卡在 Pending。
根因:VolumeBinding 在 PreFilter 阶段假设 PVC 能绑定。如果 StorageClass 是 WaitForFirstConsumer,真正绑定要等 Pod 调度到节点后才触发。假设期间节点信息变化(污点/label 更新),AssumeCache 里的假设失效,导致调度循环。
修复:确保 Pod 的 nodeSelector 或亲和性先选定候选节点,再让 PVC 在该节点附近绑定。
Q1: Filter 和 Score 的本质区别是什么?
Answer:Filter 是"行 / 不行"的二元判断,返回 Unschedulable 或 nil。任何 Filter 返回 UnschedulableAndUnresolvable 时调度立即短路,不再调用后续 Filter 和所有 Score。Score 是"好 / 更好"的打分,返回 0-MaxNodeScore 的整数值,所有 Score 分数加权求和后决定节点最终排名。Filter 是硬约束,Score 是软偏好。
Q2: Unschedulable 和 UnschedulableAndUnresolvable 的差别?
Answer:Unschedulable 表示当前节点不行,但其他节点可能行(如资源不够,扩容节点就能解决),调度器会等事件触发重试。UnschedulableAndUnresolvable 表示不仅当前节点不行,任何节点都不行(如 PVC 不存在),调度器直接让 Pod 进入 Unschedulable 队列,不做无意义的重试。
Q3: EventsToRegister 和调度队列重试是什么关系?
Answer:每个插件在 EventsToRegister 中声明自己关心哪些 Kubernetes 事件。当这些事件发生时(如节点 label 更新、Pod 删除、PV 绑定完成),调度器的 SchedulingQueue 把等待中的 Pod 重新入队触发重试,如果没有注册对应事件,即使资源释放了 Pod 也不会被重新调度。
Q4: 为什么 VolumeBinding 是唯一跨越四个阶段的内置插件?
Answer:因为它需要解决并发调度时的 PV 抢用问题。PreFilter 假设能绑(AssumePodVolumes),Reserve 占住节点,PreBind 提交真实绑定。如果另一个 Pod 在此期间已经真正绑定了这个 PV,Unreserve 阶段会发现冲突并 RevertAssumedPodVolumes 回退。这个两阶段设计(假设 + 确认)是并发安全的核心。
Q5: NodeResourcesFit 的四个 Score 策略分别适合什么场景?
Answer:LeastAllocated(默认):最闲优先,适合通用计算。MostAllocated:最忙优先,适合批量回收。RequestedToCapacityRatio:自定义每种资源权重,适合 GPU/FPGA 异构集群。BalancedAllocation:CPU/内存均衡,适合计算密集型。
Q6: PodTopologySpread 的 skew 公式是什么?
Answer:skew = count(当前拓扑域匹配 Pod) - min(所有拓扑域匹配 Pod)。例如 zone-a 有 5 个 Pod,zone-b 有 2 个,zone-c 有 2 个,则 zone-a 的 skew=5-2=3。如果 maxSkew=2,zone-a 超过约束,新 Pod 无法调度到 zone-a。评分公式为 score += scoreForCount(cnt, maxSkew, weight)(scoring.go L222),最终 NormalizeScore 归一化到 [0, MaxNodeScore](scoring.go L229-270)。
Q7: TaintToleration 的 tolerationSeconds 只对哪种 effect 有效?
Answer:只对 NoExecute effect 有效。如果节点被打上 NoExecute 污点,有 tolerationSeconds: 300 的 Pod 会等 300 秒后再驱逐(给节点恢复或 Pod 迁移留出缓冲)。NoSchedule 和 PreferNoSchedule 不支持此字段。
Q8: NodeAffinity 的 addedNodeSelector 和 addedPrefSchedTerms 是什么?
Answer:这两个字段用于 SchedulerConfiguration 级别的 NodeAffinity 扩展(node_affinity.go L41-42)。可以在配置中为所有 Pod 统一追加节点选择器和优先调度条件,让所有 Pod 自动继承额外的亲和性规则,而不需要每个 Pod 单独配置。
Q9: InterPodAffinity 的 Filter 阶段具体计算什么?
Answer:对硬亲和(requiredDuringSchedulingIgnoredDuringExecution)的每个 topologyTerm,统计当前节点所在拓扑域内已运行 Pod 的加权匹配数。Filter 阶段分三层判断(filtering.go L412-440):satisfyPodAffinity、satisfyPodAntiAffinity、satisfyExistingPodsAntiAffinity,任一层不满足就过滤。软打分(preferred)不参与 Filter。
Q10: VolumeBinding 的 SchedulerVolumeBinder 接口有哪些核心方法?
Answer:五个核心方法(binder.go L153-196):GetPodVolumeClaims 获取 Pod 所需 PVC;FindPodVolumes 查找匹配的 PV 并返回冲突原因;AssumePodVolumes 预占 PV(AssumeCache);RevertAssumedPodVolumes 绑定失败时回退;BindPodVolumes 在 PreBind 阶段提交真实绑定到 etcd 并同步等待 PV 控制器完成。
Q11: enableSchedulingQueueHint(调度队列提示)功能是什么?
Answer:v1.28+ beta 特性。每个插件在 EventsToRegister 中除了声明关心的 Kubernetes 事件,还可以注册 QueueingHintFn 智能判断"某个事件发生后,Pod 是否真的需要重新调度"。避免每次节点 label 变化都盲目重试所有 Pending Pod。这能显著减少无效调度循环,降低调度延迟和 API server 压力。
Q12: PodTopologySpread 的 systemDefaulted 字段何时为 true?
Answer:当 Pod 没有声明任何 topologySpreadConstraints 且 SchedulerConfiguration 的 defaultingType = SystemDefaulting(v1.27+ 默认)时,New() 函数(plugin.go L126-129)将 systemDefaultConstraints 自动注入,并将 systemDefaulted 设为 true。不想自动注入可设为 CustomDefaulting 并清空 defaultConstraints。
Q13: VolumeBinding 的 Score 策略 volumeCapacityScoring 是什么?
Answer:VolumeBinding 的 Score 目标不是节点资源,而是指定 StorageClass 的剩余卷容量。按 StorageClass 聚合后采用 broken linear function 公式(volume_binding.go L486-530),节点剩余容量越多得分越高。需要 --feature-gates=StorageCapacityScoring=true 开启(v1.36.1 中为 alpha,默认关闭)。
Q14: 如何确认某个 Pod 最终由哪个插件决定了调度失败?
Answer:kubectl describe pod <pod-name> 的 Events 会按时间顺序列出调度失败原因(如 0/3 nodes are available: 1 node(s) had taints..., 2 node(s) resource fit failed)。开启调度器详细日志 --v=5,每个 Filter 插件的通过/失败都有记录。
Q15: 如何禁用某个内置插件而不影响其他插件?
Answer:在 SchedulerConfiguration 的 profiles[].plugins.multiPoint.disabled 中禁用指定插件名:disabledPlugin: - TaintToleration。禁用后该插件在所有 Profile 所有扩展点都不会被调用。如果只想禁用 Score 而保留 Filter,需要在 Score 扩展点单独配置。
Q16: InterPodAffinity 和 PodTopologySpread 都能做 Pod 打散,区别是什么?
Answer:InterPodAffinity 通过 pod.spec.affinity.podAntiAffinity 做两两 Pod 之间的精确控制,适合"Pod A 必须和 Pod B 在一起/分开"的场景。PodTopologySpread 是 v1.18 引入的声明式拓扑打散,通过 topologySpreadConstraints 自动计算全局最优分布,适合"让 N 个副本均匀散开"的场景。两者可以叠加使用。
Q17: 如何解决 InterPodAffinity 硬亲和导致的新建 Deployment 死锁?
Answer:将 requiredDuringSchedulingIgnoredDuringExecution 改为 preferredDuringSchedulingIgnoredDuringExecution。初始部署阶段使用软约束降低耦合度,Pod 调度成功后自然形成拓扑分布。如果必须用硬亲和,先单独部署一方到目标节点,再部署依赖方。
Q18: VolumeBinding 的 WaitForFirstConsumer 和 Immediate 模式分别适合什么场景?
Answer:Immediate(默认):PVC 创建时立即绑定 PV,适合已知 Pod 会调度到哪个节点的场景。WaitForFirstConsumer:等第一个 Pod 调度到节点后才绑定 PV,让 PV 优先选择 Pod 所在节点或拓扑域,适合云盘等有区域限制的存储类型,可减少跨区流量。
Q19: 六个插件的执行顺序是怎么确定的?
Answer:Filter 阶段按 SchedulerConfiguration/profiles[].plugins.filter.enabled 数组顺序执行(默认按 registry.go 注册顺序)。Score 阶段先并行执行所有 Score 插件,再用 NormalizeScore 归一化到 [0, MaxNodeScore] 范围,最后加权求和。插件执行顺序不影响最终结果,只影响中间短路时机。
Q20: NodeResourcesFit 中 placementScorer 字段是做什么用的?
Answer:placementScorer(fit.go L104)由 v1.36.1 新增,仅在 enableTopologyAwareWorkloadScheduling feature gate 开启时初始化(fit.go L249-258)。它使用 MostAllocated 策略对 Pod 组(Gang)的多个副本做资源均衡分配,是 Gang Scheduling 场景下的资源打分器。普通 Pod 不涉及此字段。
Q21: TaintToleration 的 enableTaintTolerationComparisonOperators 参数有什么用?
Answer:默认情况下 toleration 的 key 和 taint 的 key 必须完全匹配。启用此参数后支持 Exists 操作符:operator: Exists 且不指定 value 表示"容忍所有带此 key 的污点"。例如 tolerations: - operator: Exists 可以容忍所有 NoExecute 污点。
Q22: Pod 调度失败时,SchedulingQueue 的 Unschedulable 队列和 BackoffQ 队列有什么区别?
Answer:Unschedulable 队列存放调度失败的 Pod,等待触发事件重新入队(如节点 label 变化、Pod 删除)。BackoffQ 是退避队列,对反复调度失败的 Pod 做指数退避等待,减少无效重试对 API server 的压力。当有 BackoffPods 或 UnschedulablePods 时,可用 kubectl drain 或调整 Pod 优先级让它优先重试。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。