Featured image of post Kubernetes 亲和性与反亲和性配置记录

Kubernetes 亲和性与反亲和性配置记录

记录 nodeAffinity、podAffinity、podAntiAffinity 的配置方式,以及 required/preferred、拓扑域和 Pending 排查思路。

最近看一个服务的调度配置,发现它同时用了 nodeSelectornodeAffinitypodAntiAffinity。表面上看是“让服务跑到指定节点池,并尽量分散”,但真正排查 Pending 的时候,发现这些规则叠在一起以后不太直观。

这篇简单记录一下我自己看调度规则时的判断顺序。

我一开始看错的地方是:以为 preferred 写了就一定会分散,后来才意识到它只是调度器打分时的偏好。只要资源、污点、PVC 或其他硬条件把节点集合压小,软亲和性很容易被牺牲。

先看节点标签

亲和性本质上还是围绕 label 做匹配。排查前先看节点上到底有没有这些 label:

1
2
3
4
5
6
kubectl get nodes --show-labels

kubectl get nodes \
  -L kubernetes.io/os \
  -L node-role.kubernetes.io/worker \
  -L topology.kubernetes.io/zone

如果 YAML 里写了 topology.kubernetes.io/zone,但节点上根本没有这个 label,后面分析半天都是偏的。

nodeSelector 和 nodeAffinity

nodeSelector 比较直接,只能做简单的等值匹配:

1
2
3
spec:
  nodeSelector:
    node-role.kubernetes.io/worker: "true"

nodeAffinity 更灵活,可以写 InNotInExistsDoesNotExistGtLt 这些操作符,也可以区分硬约束和软约束。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api-server
  template:
    metadata:
      labels:
        app: api-server
    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: node-role.kubernetes.io/worker
                    operator: Exists
                  - key: kubernetes.io/os
                    operator: In
                    values:
                      - linux
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 80
              preference:
                matchExpressions:
                  - key: topology.kubernetes.io/zone
                    operator: In
                    values:
                      - cn-east-1a
      containers:
        - name: api-server
          image: nginx:1.27

requiredDuringSchedulingIgnoredDuringExecution 是硬条件,不满足就不会调度。preferredDuringSchedulingIgnoredDuringExecution 是软条件,调度器会尽量满足,但不满足也可以调度。

IgnoredDuringExecution 的意思是:Pod 已经调度成功后,如果节点 label 后面变了,Kubernetes 不会因为这个亲和性规则把 Pod 赶走。

还有一个容易忽略的点:如果同时写了 nodeSelectornodeAffinity,它们都必须满足。不是二选一。

多个 nodeSelectorTerms 之间是 OR,只要满足其中一个 term 就可以。同一个 term 里的多个 matchExpressions 是 AND,必须全部满足。

我通常会把它翻译成一行布尔表达式再看:

1
2
3
4
5
6
7
8
9
nodeSelector
AND
(term1.expressionA AND term1.expressionB OR term2.expressionC)
AND
pod anti-affinity hard rules
AND
taints/tolerations
AND
resources

这么写出来以后,很多 Pending 就不神秘了。不是 Kubernetes 调度器“抽风”,而是自己把可用节点交集写没了。

podAffinity 和 podAntiAffinity

podAffinity 不是看节点 label,而是看“某个拓扑域里有没有符合 label 的 Pod”。

比如希望业务 Pod 尽量跟缓存 Pod 在同一个可用区,减少跨可用区访问:

1
2
3
4
5
6
7
affinity:
  podAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchLabels:
            app: redis
        topologyKey: topology.kubernetes.io/zone

这个规则要小心。如果 redis 本身还没起来,或者 redis 不在当前 namespace,新的业务 Pod 就可能 Pending。podAffinity 默认只匹配当前 namespace,如果要跨 namespace,需要显式写 namespacesnamespaceSelector

更常见的是 podAntiAffinity,比如同一个 Deployment 的多个副本尽量不要落在同一个节点:

1
2
3
4
5
6
7
8
9
affinity:
  podAntiAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 100
        podAffinityTerm:
          labelSelector:
            matchLabels:
              app: api-server
          topologyKey: kubernetes.io/hostname

这里用的是 preferred,所以只是尽量分散。如果节点资源紧张,多个 Pod 仍然可能被放到同一个节点。

这个配置看起来短,但里面有几个字段不能只按字面理解。

weight: 100 只在 preferredDuringSchedulingIgnoredDuringExecution 里出现,范围是 1 到 100。它不是“强制程度”,而是调度器打分时这个偏好的权重。多个 preferred 规则同时存在时,调度器会把满足偏好的节点加分,最后选择综合分数更高的节点。也就是说,weight: 100 仍然是软偏好,不会让 Pod Pending。

podAffinityTerm 才是真正描述“跟谁保持距离”的规则。它至少要看三个东西:

  • labelSelector:选择已有 Pod。
  • topologyKey:定义“同一个拓扑域”。
  • namespaces / namespaceSelector:决定跨不跨 namespace 查已有 Pod。

topologyKey: kubernetes.io/hostname 的意思不是“按节点名匹配 Pod label”,而是看节点上这个 label 的值。通常每个节点的 kubernetes.io/hostname 不同,所以效果就是“尽量不要放到同一个节点”。如果换成 topology.kubernetes.io/zone,含义就变成“尽量不要放到同一个可用区”。

这个差别很大:

1
2
3
kubernetes.io/hostname        节点级分散
topology.kubernetes.io/zone   可用区级分散
topology.kubernetes.io/region 地域级分散,通常不这么用

如果只是想让副本分散到不同节点,用 hostname 就够了。如果想跨可用区做容灾,要用 zone,但这时候更推荐一起评估 topologySpreadConstraints,因为它表达“分布倾斜度”会更直观。

如果要强制分散,可以写成 required:

1
2
3
4
5
6
7
affinity:
  podAntiAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchLabels:
            app: api-server
        topologyKey: kubernetes.io/hostname

如果集群只有 2 个可用节点,但 Deployment 要 3 个副本,第 3 个 Pod 很可能 Pending。

大集群里还要注意性能。官方文档提醒 inter-pod affinity/anti-affinity 在大规模集群中可能拖慢调度,因为调度器需要跨节点检查已有 Pod 的标签和拓扑域。简单说,节点和 Pod 越多,这类规则越不便宜。能用 topologySpreadConstraints 表达“均匀分布”的场景,我会优先考虑它。

还有一个准入层面的细节:对于 requiredDuringSchedulingIgnoredDuringExecution 的 Pod anti-affinity,Kubernetes 默认的 LimitPodHardAntiAffinityTopology 准入控制器会把 topologyKey 限制为 kubernetes.io/hostname。也就是说,hard anti-affinity 想直接用 zone 做强约束,可能会被准入策略拦住。这个限制是为了避免过重、过宽的硬反亲和规则影响调度。

podAntiAffinity 里容易漏掉的配置

上面的例子只用了 matchLabels,实际项目里经常不够。

matchExpressions:不要只会 matchLabels

如果同一个服务有多个组件,不想跟某几类组件混在一起,可以用 matchExpressions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
affinity:
  podAntiAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 80
        podAffinityTerm:
          labelSelector:
            matchExpressions:
              - key: app
                operator: In
                values:
                  - api-server
              - key: component
                operator: NotIn
                values:
                  - canary
          topologyKey: kubernetes.io/hostname

这个规则的意思是:调度当前 Pod 时,尽量避开已经运行了 app=api-servercomponent 不是 canary 的节点。

matchLabelsmatchExpressions 是 AND 关系,不是 OR。这个点很容易写错。如果你既写了:

1
2
3
4
5
6
7
matchLabels:
  app: api-server
matchExpressions:
  - key: component
    operator: In
    values:
      - stable

那它匹配的是 app=api-server AND component in (stable)

namespaces / namespaceSelector:默认只看当前 namespace

podAffinityTerm 默认只匹配当前 Pod 所在 namespace 里的 Pod。这个默认行为有时候会让人误判。

比如你有两个 namespace:

1
2
prod-a/api-server
prod-b/api-server

如果你希望两个 namespace 的 api-server 也不要落到同一个节点,必须显式指定 namespace:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
affinity:
  podAntiAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchLabels:
            app: api-server
        namespaces:
          - prod-a
          - prod-b
        topologyKey: kubernetes.io/hostname

如果 namespace 很多,用 namespaceSelector 更合适。比如只匹配带有 tenant=prod 标签的 namespace:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
affinity:
  podAntiAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 100
        podAffinityTerm:
          labelSelector:
            matchLabels:
              app: api-server
          namespaceSelector:
            matchLabels:
              tenant: prod
          topologyKey: kubernetes.io/hostname

这里要先给 namespace 打标签:

1
2
kubectl label namespace prod-a tenant=prod
kubectl label namespace prod-b tenant=prod

如果 namespacesnamespaceSelector 同时写,匹配范围是两者的并集。都不写时,就是当前 Pod 的 namespace。

matchLabelKeys:跟当前 Pod 的标签值绑定

Kubernetes 还支持 matchLabelKeys。它会从“当前正在调度的 Pod”上取指定 key 的 value,再和 labelSelector 合并。

这个字段适合 Deployment 滚动发布时使用,避免新旧 ReplicaSet 的 Pod 互相干扰。比如不同 revision 的 Pod 都有 pod-template-hash

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
affinity:
  podAntiAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 100
        podAffinityTerm:
          labelSelector:
            matchLabels:
              app: api-server
          matchLabelKeys:
            - pod-template-hash
          topologyKey: kubernetes.io/hostname

含义是:不仅要匹配 app=api-server,还要拿当前 Pod 的 pod-template-hash 值去匹配已有 Pod。这样调度器更关注同一个 rollout revision 的 Pod 分布,而不是把历史 revision 也算进去。

这个配置比简单的 app=api-server 更细,尤其适合滚动发布时避免新旧版本互相影响调度判断。

mismatchLabelKeys:租户隔离时更有用

mismatchLabelKeys 是另一个容易忽略的字段。它会从当前 Pod 标签里取 key 的值,然后匹配“这个 key 的值不相同”的已有 Pod。

一个典型场景是多租户隔离:不要把不同 tenant 的 Pod 放到同一个节点池或同一个节点。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
affinity:
  podAntiAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      - mismatchLabelKeys:
          - tenant
        labelSelector:
          matchExpressions:
            - key: tenant
              operator: Exists
        topologyKey: kubernetes.io/hostname

假设当前 Pod 是 tenant=team-a,这个规则会让它排斥同一 hostname 上已有的 tenant!=team-a 的 Pod。

这个字段不要随便用在经常变的 label 上。官方文档也提醒,不要对调度后还可能直接修改的 label 使用这类动态匹配字段,否则 kubelet 不会因为 label 后续变化而重新触发调度。

一个更完整的 Deployment 例子

如果我要给一个生产 API 写“尽量分散,但不要因为节点不足卡死”的配置,会更像这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
  namespace: prod-a
spec:
  replicas: 4
  selector:
    matchLabels:
      app: api-server
  template:
    metadata:
      labels:
        app: api-server
        tenant: team-a
    spec:
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                labelSelector:
                  matchLabels:
                    app: api-server
                namespaceSelector:
                  matchLabels:
                    tenant: prod
                topologyKey: kubernetes.io/hostname
            - weight: 50
              podAffinityTerm:
                labelSelector:
                  matchLabels:
                    app: api-server
                topologyKey: topology.kubernetes.io/zone
      containers:
        - name: api-server
          image: nginx:1.27

这个配置表达的是:

  1. 优先不要和其他 api-server 放在同一节点。
  2. tenant=prod 的 namespace 一起考虑。
  3. 次一级偏好是不要都堆在同一个 zone。
  4. 因为都是 preferred,所以资源紧张时不会导致 Pod Pending。

如果业务真的要求“一台节点只能跑一个副本”,再把第一条改成 required。但我一般不会一上来就这么做,除非副本数、节点数、扩容策略都已经算清楚。

排查时先看 Events

如果调度失败,不要先猜,直接 describe:

1
kubectl describe pod api-server-xxx -n default

重点看最后的 Events,常见会看到类似:

1
2
3
Warning  FailedScheduling  default-scheduler  0/3 nodes are available:
1 node(s) didn't match Pod's node affinity/selector,
2 node(s) didn't match pod anti-affinity rules.

然后再反查:

1
2
3
4
kubectl get pod -n default -l app=api-server -o wide
kubectl get nodes \
  -L topology.kubernetes.io/zone \
  -L kubernetes.io/hostname

亲和性配置本身不复杂,复杂的是这些规则会跟资源、污点、PVC、拓扑分布一起叠加。我的习惯是先确认硬约束,再看软约束,不要一上来就改 replicas 或重启。

最后还有一个现实建议:不要为了“看起来高可用”把所有规则都写成 required。亲和性配置的目的应该是表达业务边界,不是把调度器绑死。比如核心服务跨节点分散可以硬一点,普通后台任务用 preferred 往往更合适。

参考资料

记录工程实践和排障过程。
使用 Hugo 构建
主题 StackJimmy 设计