拓扑域:就 Kubernetes 而言,它们是按节点标签定义和分组的一系列 Node,例如属于相同区域/可用区/机架/主机名都可以作为划分拓扑域的依据。
亲和性:在本文指的是 NodeAffinity、PodAffinity 以及 PodAntiAffinity。
在 k8s 集群调度领域,“亲和性”相关的概念本质上都是控制 Pod 如何被调度——堆叠或是打散。目前 k8s 提供了 podAffinity 以及 podAntiAffinity 两个特性对 Pod 在不同拓扑域的分布进行了一些控制,podAffinity 可以将无数个 Pod 调度到特定的某一个拓扑域,这是堆叠的体现;podAntiAffinity 则可以控制一个拓扑域只存在一个 Pod,这是打散的体现。但这两种情况都太极端了,在不少场景下都无法达到理想的效果,例如为了实现容灾和高可用,将业务 Pod 尽可能均匀得分布在不同可用区就很难实现。
+---------------+---------------+| zoneA | zoneB |+-------+-------+-------+-------+| node1 | node2 | node3 | node4 |+-------+-------+-------+-------+| P P | P P P | P | | +-------+-------+-------+-------+ |
---|
PodTopologySpread 特性的提出正是为了对 Pod 的调度分布提供更精细的控制,以提高服务可用性以及资源利用率。PodTopologySpread 由 EvenPodsSpread 特性门控所控制,在 v1.16 版本第一次发布,并在 v1.18 版本进入 beta 阶段默认启用。
PodTopologySpread 特性的目标包括:
在 Pod 的 spec 中新增了一个字段 `topologySpreadConstraints` :
spec: topologySpreadConstraints: - maxSkew: <integer> topologyKey: <string> whenUnsatisfiable: <string> labelSelector: <object> |
---|
由于这个新增的字段是在 Pod spec 层面添加,因此更高层级的控制 app (Deployment、DaemonSet、StatefulSet) 也能使用 PodTopologySpread 功能。
让我们结合上图来理解 `topologySpreadConstraints` 中各个字段的含义和作用:
假设我们有一个 4 Node 的集群分布在两个可用区,并且有着如下的标签:
NAME STATUS ROLES AGE VERSION LABELS
node1 Ready <none> 4m26s v1.18.2 node=node1,zone=zoneA
node2 Ready <none> 3m58s v1.18.2 node=node2,zone=zoneA
node3 Ready <none> 3m17s v1.18.2 node=node3,zone=zoneB
node4 Ready <none> 2m43s v1.18.2 node=node4,zone=zoneB
集群中有 3 个打有 `foo:bar` 标签的 Pod, 分别位于 node1、 node2 以及 node3:
+---------------+---------------+| zoneA | zoneB |+-------+-------+-------+-------+| node1 | node2 | node3 | node4 |+-------+-------+-------+-------+| P | P | P | |+-------+-------+-------+-------+ |
---|
如果我们想再添加一个 Pod 并且均匀分布不同可用区,我们可以提交如下 yaml:
kind: PodapiVersion: v1metadata: name: mypod labels: foo: barspec: topologySpreadConstraints: - maxSkew: 1 topologyKey: zone whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: foo: bar containers: - name: nginx image: nginx |
---|
`topologyKey: zone` 表明了 Pod 均匀分布的应用范围只局限于拥有 "zone:<any value>" 标签的 Node。`whenUnsatisfiable: DoNotSchedule` 则告诉调度器如果 Pod 无法满足均匀分布的约束条件,就一直让它 pending。
如果调度器把新建的 Pod 调度到 zoneA,那么 Pod 分布将会变成 3/1,zoneA 的实际 skew 变成 2 (3 - 1),因此不满足 `maxSkew: 1` 的约束条件,因此该 Pod 只能被调度到 zoneB,可能的 Pod 分布有两种:
+---------------+---------------+ +---------------+---------------+| zoneA | zoneB | | zoneA | zoneB |+-------+-------+-------+-------+ +-------+-------+-------+-------+| node1 | node2 | node3 | node4 | OR | node1 | node2 | node3 | node4 |+-------+-------+-------+-------+ +-------+-------+-------+-------+| P | P | P | P | | P | P | P P | |+-------+-------+-------+-------+ +-------+-------+-------+-------+ |
---|
我们也可以调整 Pod 的 spec 来满足不同需求:
`topologySpreadConstraints` 默认遍历搜索所有 Node 并且根据 topologyKey 将 Node 分成不同拓扑域,但在某些场景下并不需要遍历所有 Node。例如,假设有一个集群中的节点分别被标记为 "env=Prod"、"env=staging" 以及 "env=qa",然后我们仅仅想要让 Pod 均匀分布在 qa 环境的多个 zone 。我们可以利用 NodeSelector 或者 NodeAffinity spec,通过指定 `spec.affinity.nodeAffinity`,可以限制搜索域为 qa 环境。在上图中,新建的 Pod 遵循 topology spread constraint 只能被调度到 zone2。
多个 `topologySpreadConstraints` 共同约束也是一个常见需求。上图中,如果我们想要在调度 Pod 时候同时满足以下两个约束条件:
对于第一个约束条件,zone1 有 3 个 Pod,zone2 有 2 个 Pod,所以新建的 Pod 只能被调度到 zone2 以满足 `maxSkew = 1` 的约束条件。换句话说,第一个约束条件的结果集合是 nodeX 与 nodeY。
对于第二个约束条件,nodeB 和 nodeX 分别有 3 个和 2 个 Pod,因此新建的 Pod 只能被调度到 nodeA 和 nodeY 上。
将两个约束的结果集合做交集,我们就可以得出结论,同时符合两个约束条件的节点只能是 nodeY。
+-------------+-------------+--------------------+| zone1 | zone2 | zone3 (infeasible) | +-------------+-------------+--------------------+| pod,pod,pod | pod,pod,pod | |+-------------+-------------+--------------------+ spec: topologySpreadConstraints: - maxSkew: 1 topologyKey: zone whenUnsatisfiable: <Action> labelSelector: matchLabels: foo: bar |
---|
假设有一个集群分布在 3 个 zone,所有 Pod 都含有标签 "foo: bar",目前 Pod 在 3 个 zone 的分布是 3/3/0,但是 zone3 所有的 Node 由于 taint 或者资源不足无法被调度。现在有一个新建的 Pod 含有标签 "foo: bar" 并且 "maxSkew: 1"。
如果 Action 是 DoNotSchedule (也就是硬需求),调度器则会认为拓扑域全局的最小匹配数是 0,所以该 Pod 无法被调度。
如果 Action 是 ScheduleAnyway (也就是软需求),调度器则会认为拓扑域全局的最小匹配数是 3,所以该 Pod 会以同样的概率调度到 zone1 或者 zone2。
PodTopologySpread 特性的实现主要分为两个部分:Filter 和 Score,这部分代码在 v1.17~v.1.19 间变动较大,但背后的思想却是一致的,用伪代码可以清晰描述这两个关键流程的步骤。
Filter
for each candidate node; do
if "TopologySpreadConstraint" is enabled for the pod being scheduled; then
# minMatching num is globally calculated
count number of matching pods on the topology domain this node belongs to
if "matching num - minMatching num" < "MaxSkew"; then
approve it
fi
fi
done
Score
for each candidate node; do
if "TopologySpreadConstraint" is enabled for the pod being scheduled; then
# minMatching num is calculated across node list filtered by Predicate phase
count number of matching pods on the topology domain this node belongs to
calculate the value of "matching num - minMatching num" minus "MaxSkew"
the lower, the higher score this node is ranked
fi
done
由于篇幅限制,我们只介绍 PodTopologySpread Filter 部分的核心代码,k8s 版本为 v1.18.2。
// 代码摘自 kubernetes/pkg/scheduler/framework/plugins/podtopologyspread/filtering.go
// Filter invoked at the filter extension point.
// cycleState 类似 golang 中的 context,在整个调度 framework 流程中传递数据
// pod 待调度的 Pod
// nodeInfo 候选节点的信息
func (pl *PodTopologySpread) Filter(ctx context.Context, cycleState *framework.CycleState, pod *v1.Pod, nodeInfo *nodeinfo.NodeInfo) *framework.Status {
node := nodeInfo.Node()
if node == nil {
return framework.NewStatus(framework.Error, "node not found")
}
// 获取 preFilterState
// preFilterState 在 PreFilter 扩展点通过扫描所有节点计算得出,包含了每个拓扑域的匹配数和全局最小匹配数
s, err := getPreFilterState(cycleState)
if err != nil {
return framework.NewStatus(framework.Error, err.Error())
}
// However, "empty" preFilterState is legit which tolerates every toSchedule Pod.
// 如果 Pod Spec 没有指定 PodTopologySpread Constraint,直接通过过滤
if len(s.TpPairToMatchNum) == 0 || len(s.Constraints) == 0 {
return nil
}
podLabelSet := labels.Set(pod.Labels)
for _, c := range s.Constraints {
tpKey := c.TopologyKey
tpVal, ok := node.Labels[c.TopologyKey]
if !ok {
klog.V(5).Infof("node '%s' doesn't have required label '%s'", node.Name, tpKey)
return framework.NewStatus(framework.Unschedulable, ErrReasonConstraintsNotMatch)
}
selfMatchNum := int32(0)
// 如果待调度 Pod 也符合 PodTopologySpread Constraint 中的 labelSelector, 则 selfMatchNum = 1
if c.Selector.Matches(podLabelSet) {
selfMatchNum = 1
}
// 组装节点所在 topology 的结构体
pair := topologyPair{key: tpKey, value: tpVal}
paths, ok := s.TpKeyToCriticalPaths[tpKey]
if !ok {
// error which should not happen
klog.Errorf("internal error: get paths from key %q of %#v", tpKey, s.TpKeyToCriticalPaths)
continue
}
// judging criteria:
// 'existing matching num' + 'if self-match (1 or 0)' - 'global min matching num' <= 'maxSkew'
// 核心判定条件
minMatchNum := paths[0].MatchNum // 全局最小匹配数
matchNum := s.TpPairToMatchNum[pair] // 本节点所在拓扑域的匹配数量
skew := matchNum + selfMatchNum - minMatchNum // Pod 调度到本节点后的实际 skew 值
// 实际 skew 大于 maxSkew,则本节点未通过过滤
if skew > c.MaxSkew {
klog.V(5).Infof("node '%s' failed spreadConstraint[%s]: MatchNum(%d) + selfMatchNum(%d) - minMatchNum(%d) > maxSkew(%d)", node.Name, tpKey, matchNum, selfMatchNum, minMatchNum, c.MaxSkew)
return framework.NewStatus(framework.Unschedulable, ErrReasonConstraintsNotMatch)
}
}
// 如果符合所有 Constraint,节点通过本次过滤
return nil
}
https://kubernetes.io/blog/2020/05/introducing-podtopologyspread/
https://kubernetes.io/docs/concepts/workloads/pods/pod-topology-spread-constraints/
https://github.com/kubernetes/enhancements/tree/master/keps/sig-scheduling/895-pod-topology-spread#goals
https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#affinity-and-anti-affinity
文章转载自云原生计算。