Kubernetes 基础体系 · 第 25/83 篇。示例基于 Kubernetes 当前稳定 API;弃用、版本偏差、云厂商差异和生产风险会明确说明。

Pod 拓扑设计:Zone、Node、故障域、反亲和和数据局部性

Pod 拓扑设计解决的不是“Pod 应该放在哪台机器上”这么简单的问题,而是要同时回答以下问题:

  • 一个 Pod 与另一个 Pod 放在同一台 Node、同一个 Zone,还是不同 Zone?
  • 某个 Zone 整体故障时,副本是否仍然可用?
  • 节点故障、机架故障、可用区故障分别会破坏哪些副本?
  • Pod 的计算位置是否接近它访问的数据?
  • 为了高可用而跨 Zone 分布,是否会增加网络延迟和跨区流量?
  • 调度约束之间冲突时,Pod 是被调度到次优位置,还是直接变成 Pending?

这些问题分别由 Node、拓扑域、故障域、亲和与反亲和、Topology Spread Constraints,以及存储拓扑共同决定。


一、先建立几个不同层次的概念

1. Node 是 Kubernetes 的调度单位

Node 是 Kubernetes 调度器可以选择的基本计算节点。一个 Pod 通常会被绑定到一个 Node,并由该节点上的 kubelet 创建和管理。

可以查看节点及其标签:

kubectl get nodes --show-labels

更适合阅读的方式是只显示常用拓扑标签:

kubectl get nodes \
  -L kubernetes.io/hostname \
  -L topology.kubernetes.io/zone \
  -L topology.kubernetes.io/region

典型输出可能类似:

NAME       STATUS   ROLES    AGE   VERSION   HOSTNAME   ZONE      REGION
worker-a1  Ready    <none>   20d   v1.31.2   worker-a1  zone-a    region-1
worker-a2  Ready    <none>   20d   v1.31.2   worker-a2  zone-a    region-1
worker-b1  Ready    <none>   20d   v1.31.2   worker-b1  zone-b    region-1
worker-c1  Ready    <none>   20d   v1.31.2   worker-c1  zone-c    region-1

Kubernetes 通常使用以下标准标签描述拓扑:

kubernetes.io/hostname
topology.kubernetes.io/zone
topology.kubernetes.io/region

其中:

  • kubernetes.io/hostname 通常表示节点主机名;
  • topology.kubernetes.io/zone 表示 Zone;
  • topology.kubernetes.io/region 表示 Region。

这些标签是 Kubernetes 识别拓扑的输入,但标签本身并不自动保证物理含义。具体 Zone 是否对应独立供电、独立网络设备、独立机房,取决于云厂商或集群运营方的实现。裸机环境中如果管理员错误地给两个物理机房打上相同的 Zone 标签,Kubernetes 也无法自行纠正。

2. Zone 是一种拓扑域,不等于任意一种“机房”

拓扑域是由某个 topologyKey 划分出来的一组节点。

例如:

topologyKey = kubernetes.io/hostname

表示按 Node 划分拓扑域:

worker-a1
worker-a2
worker-b1
worker-c1

而:

topologyKey = topology.kubernetes.io/zone

表示按 Zone 划分拓扑域:

zone-a: worker-a1, worker-a2
zone-b: worker-b1
zone-c: worker-c1

因此,Zone 不是调度器中的特殊魔法对象,而是节点标签值形成的逻辑分组。调度器关心的是:

  1. 哪些节点存在;
  2. 这些节点有哪些拓扑标签;
  3. 某个约束使用哪个 topologyKey
  4. 每个拓扑域中已有多少匹配 Pod。

Region 通常包含多个 Zone。Zone 通常包含多个 Node,但这不是 Kubernetes API 层面的强制关系,而是基础设施常见的组织方式。

3. 故障域描述“哪些组件可能一起失败”

故障域是具有相近故障原因或故障传播路径的一组资源。

常见层次如下:

Region
└── Zone
    └── 机架 / 电源域 / 网络故障域
        └── Node
            └── Pod

这不是 Kubernetes 规定的唯一层级,而是一种设计模型。不同基础设施中,故障域可能是:

  • 单个 Node;
  • 一个机架;
  • 一组共享电源的服务器;
  • 一个网络交换机下的节点;
  • 一个 Zone;
  • 一个 Region;
  • 一个存储系统的副本组。

如果两个副本放在不同 Node,但这些 Node 共享同一个机架和电源,那么它们只实现了 Node 级分散,没有实现机架级故障隔离。

因此,“副本分散”必须先说明分散到哪一级:

目标 常用 topologyKey
不在同一台 Node kubernetes.io/hostname
不在同一个 Zone topology.kubernetes.io/zone
不在同一个 Region topology.kubernetes.io/region
不在同一个机架 使用集群实际提供的机架标签
不在同一个电源或网络故障域 使用基础设施实际提供的标签

如果不存在真实可靠的标签,Kubernetes 不能凭空推导故障域。


二、调度约束的共同基础:硬约束、软约束和候选节点

一个 Pod 的调度可以抽象为两个阶段:

  1. 过滤(Filter):排除不满足硬约束的 Node;
  2. 评分(Score):在剩余 Node 中选择更符合软约束的 Node。

可以把最终候选节点集合写成:

C=NreadyNresourcesNtaintNaffinityNtopologyNstorageC = N_{\text{ready}} \cap N_{\text{resources}} \cap N_{\text{taint}} \cap N_{\text{affinity}} \cap N_{\text{topology}} \cap N_{\text{storage}}

其中:

  • NreadyN_{\text{ready}}:状态可调度的节点;
  • NresourcesN_{\text{resources}}:满足 CPU、内存等资源请求的节点;
  • NtaintN_{\text{taint}}:Pod 的 toleration 允许使用的节点;
  • NaffinityN_{\text{affinity}}:满足节点亲和约束的节点;
  • NtopologyN_{\text{topology}}:满足 Pod 亲和、反亲和和拓扑分布约束的节点;
  • NstorageN_{\text{storage}}:满足存储卷拓扑和节点关联的节点。

如果某个硬约束导致 C=C = \varnothing,Pod 就无法调度。软约束不会把节点从候选集合中删除,而是影响评分。

nodeSelector 和节点亲和

最简单的硬约束是 nodeSelector

spec:
  nodeSelector:
    topology.kubernetes.io/zone: zone-a

这表示 Pod 只能调度到具有该标签且值为 zone-a 的 Node。

节点亲和可以表达更复杂的条件:

spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: topology.kubernetes.io/zone
            operator: In
            values:
            - zone-a
            - zone-b

requiredDuringSchedulingIgnoredDuringExecution 的含义是:

  • 调度时必须满足;
  • Pod 调度完成后,如果节点标签后来发生变化,Kubernetes 不会仅因为这个变化主动驱逐 Pod。

软约束使用:

spec:
  affinity:
    nodeAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 80
        preference:
          matchExpressions:
          - key: topology.kubernetes.io/zone
            operator: In
            values:
            - zone-a

weight 只参与评分,不是保证。若 zone-a 没有资源,而 zone-b 可以运行 Pod,Pod 仍可能进入 zone-b

Taint 和 toleration 不是拓扑分布机制

Taint 通常用于表达“某类 Pod 不应进入这个节点”,例如专用节点、故障节点或基础设施节点:

kubectl taint nodes worker-a1 dedicated=storage:NoSchedule

只有声明对应 toleration 的 Pod 才能被调度到该节点:

spec:
  tolerations:
  - key: dedicated
    operator: Equal
    value: storage
    effect: NoSchedule

Taint 解决的是节点准入,不是“副本之间应该如何分散”。一个 Pod 即使能够进入某个节点,也不代表它应该和同应用的其他副本位于同一个 Zone。


三、Pod 亲和与反亲和:按已有 Pod 进行约束

Pod 亲和和反亲和根据其他 Pod 的标签决定放置位置。

  • Pod affinity:希望与匹配 Pod 靠近;
  • Pod anti-affinity:希望与匹配 Pod 分开。

下面的示例表示:这个 Pod 不得与具有 app=web 标签的 Pod 位于同一个 Node:

spec:
  affinity:
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchLabels:
            app: web
        topologyKey: kubernetes.io/hostname

这里的 topologyKey 非常关键。相同的 labelSelector,换一个 topologyKey,含义就完全不同:

topologyKey: kubernetes.io/hostname

表示不在同一台 Node。

topologyKey: topology.kubernetes.io/zone

表示不在同一个 Zone。

反亲和的完整推导

假设当前有三个节点:

zone-a / node-a1
zone-a / node-a2
zone-b / node-b1

已有两个 app=web Pod:

web-0 -> node-a1
web-1 -> node-b1

新 Pod 使用如下硬反亲和:

podAntiAffinity:
  requiredDuringSchedulingIgnoredDuringExecution:
  - labelSelector:
      matchLabels:
        app: web
    topologyKey: topology.kubernetes.io/zone

检查每个候选节点:

  1. node-a1 属于 zone-a,该 Zone 已有 web-0,排除;
  2. node-a2 也属于 zone-a,虽然本节点没有 Pod,但同 Zone 已有 web-0,排除;
  3. node-b1 属于 zone-b,已有 web-1,排除;
  4. 如果不存在第三个 Zone,则没有候选节点。

这说明“反亲和到 Zone”不是“尽量跨 Zone”,而是强制要求每个匹配副本占据不同 Zone。副本数超过 Zone 数时,硬反亲和必然导致 Pending。

反亲和的常见误解

误解一:Node 反亲和能防止同一故障域故障

如果两个 Node 位于同一个机架或 Zone,使用:

topologyKey: kubernetes.io/hostname

只能避免单 Node 故障,不能避免机架或 Zone 故障。

误解二:反亲和会重新平衡已经运行的 Pod

IgnoredDuringExecution 表示约束主要在调度时检查。集群扩容、标签变化或新增 Zone 后,已有 Pod 不会自动为了满足更理想的分布而迁移。

误解三:软反亲和是严格保证

preferredDuringSchedulingIgnoredDuringExecution:

只是评分偏好。当资源不足、节点污点、存储拓扑或其他约束冲突时,Pod 仍可能与匹配 Pod 放在一起。


四、Topology Spread Constraints:表达“尽量均匀分布”

Pod 反亲和更适合表达“不能共处”,而 topologySpreadConstraints 更适合表达“在多个拓扑域之间尽量均匀”。

一个典型 Deployment 如下:

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

这段配置同时要求:

  1. app=web 的 Pod 在 Zone 之间最多相差 1 个;
  2. app=web 的 Pod 在 Node 之间最多相差 1 个;
  3. 如果无法满足,Pod 不调度,而不是接受更差的分布。

maxSkew 的数学含义

对每个拓扑域 dd,定义:

count(d)=该拓扑域内匹配 labelSelector 的 Pod 数量count(d) = \text{该拓扑域内匹配 labelSelector 的 Pod 数量}

令:

globalMin=mincount(d)globalMin = \min count(d)

则某个拓扑域的偏斜度为:

skew(d)=count(d)globalMinskew(d) = count(d) - globalMin

当新 Pod 放入某个候选域后,若:

skew(d)maxSkewskew(d) \leq maxSkew

才满足该约束。

假设有三个 Zone,当前副本分布为:

zone-a: 2
zone-b: 1
zone-c: 0

如果 maxSkew: 1

  • 放入 zone-a 后:3,1,0,最小值为 0,最大偏斜为 3,不满足;
  • 放入 zone-b 后:2,2,0,最大偏斜为 2,不满足;
  • 放入 zone-c 后:2,1,1,最大偏斜为 1,满足。

因此调度器会优先或只能选择 zone-c

如果当前分布为:

zone-a: 1
zone-b: 1
zone-c: 0

新 Pod 放入 zone-c 后得到:

zone-a: 1
zone-b: 1
zone-c: 1

这正是最均匀的结果。

whenUnsatisfiable 的两种语义

whenUnsatisfiable: DoNotSchedule

表示这是硬约束。无法满足时,Pod 保持 Pending。

whenUnsatisfiable: ScheduleAnyway

表示这是软约束。调度器会尽量改善分布,但在资源或其他硬约束限制下仍可接受偏斜。

生产系统通常需要根据业务选择:

  • 无法接受单 Zone 集中时,使用 DoNotSchedule
  • 更重视始终保持可调度,允许暂时不均匀时,使用 ScheduleAnyway
  • 副本数小于拓扑域数时,硬约束可能让部分域为空,但这不一定是错误。

拓扑域必须有一致的标签

如果部分 Node 没有 topology.kubernetes.io/zone,调度器无法可靠地把它们归入 Zone。此时可能出现:

  • Pod 被调度到不具备预期 Zone 标签的节点;
  • Spread 计算的候选域不符合运维人员直觉;
  • 使用 Zone 相关存储时,Pod 无法找到兼容节点;
  • 节点标签变化后,新旧 Pod 的分布语义不一致。

因此,应把拓扑标签纳入节点注册、云控制器或基础设施自动化流程,而不是在故障发生时手工临时补标签。


五、反亲和与拓扑分布应该如何选择

设有 3 个 Zone 和 6 个副本。

方案一:Zone 级硬反亲和

podAntiAffinity:
  requiredDuringSchedulingIgnoredDuringExecution:
  - labelSelector:
      matchLabels:
        app: web
    topologyKey: topology.kubernetes.io/zone

这个约束要求同一 Zone 最多有一个匹配 Pod。因此最多只能调度 3 个副本,剩余 3 个会 Pending。

方案二:Zone 级拓扑分布

topologySpreadConstraints:
- maxSkew: 1
  topologyKey: topology.kubernetes.io/zone
  whenUnsatisfiable: DoNotSchedule
  labelSelector:
    matchLabels:
      app: web

6 个副本可以分布为:

zone-a: 2
zone-b: 2
zone-c: 2

这符合均匀分布要求。

方案三:同时做 Zone 和 Node 分布

topologySpreadConstraints:
- maxSkew: 1
  topologyKey: topology.kubernetes.io/zone
  whenUnsatisfiable: DoNotSchedule
  labelSelector:
    matchLabels:
      app: web
- maxSkew: 1
  topologyKey: kubernetes.io/hostname
  whenUnsatisfiable: DoNotSchedule
  labelSelector:
    matchLabels:
      app: web

这表达了两个独立目标:

  • Zone 之间均衡;
  • 同一 Node 上不集中多个副本。

但这并不保证每个 Zone 中的每台 Node 都恰好只有一个副本。它只保证每个约束定义的计数偏斜不超过指定值。

约束冲突时的结果

假设只有两个 Zone:

zone-a: 2 个可用节点
zone-b: 1 个可用节点

Deployment 需要 6 个副本,同时要求:

  • Zone maxSkew: 1
  • Node maxSkew: 1
  • 每个 Pod 请求 4 CPU;
  • zone-b 的唯一节点只有 2 CPU 可用。

即使 Zone 级分布从逻辑上可行,资源约束也会使 zone-b 无法承载副本。此时:

  • 若 Zone Spread 是 DoNotSchedule,Pod 可能 Pending;
  • 若是 ScheduleAnyway,可能在 zone-a 集中;
  • 若另有硬反亲和,候选集合可能直接为空。

拓扑设计不是独立的装饰性配置,而是资源、污点、节点亲和、存储拓扑等约束的交集。


六、调度器不会为“高可用”自动定义你的业务目标

Kubernetes 只执行声明的约束。它不知道:

  • 两个 Zone 是否真的独立;
  • 跨 Zone 流量是否昂贵;
  • 应用是否支持跨 Zone 复制;
  • 一个副本是否拥有独立数据;
  • 某个 Node 上运行的多个 Pod 是否共享同一物理故障点;
  • 业务需要“低延迟”还是“跨故障域容灾”。

例如,只写:

replicas: 3

并不能保证:

zone-a: 1
zone-b: 1
zone-c: 1

三个 Pod 可能都被调度到同一个 Zone 的不同 Node,甚至在资源充足时位于同一 Node。

同样,写了 Zone Spread 也不代表应用数据已经跨 Zone 复制。它只控制 Pod 的计算位置;数据是否分布、是否可恢复,取决于存储系统。


七、数据局部性:计算位置和数据位置必须一起分析

数据局部性是指计算 Pod 与其访问的数据尽可能位于接近的拓扑域中。

“接近”可能意味着:

  • 同一 Node:本地磁盘或本地缓存;
  • 同一 Zone:区域内网络访问;
  • 同一 Region:跨 Zone 但仍在区域内;
  • 同一存储故障域:数据副本和计算副本具有可接受的故障关系。

数据局部性通常降低网络路径和延迟,但可能减少高可用性。例如:

  • Pod 与 Zone 内的云盘靠近,访问延迟较低;
  • 如果整个 Zone 故障,Pod 可能无法在其他 Zone 使用该云盘;
  • 把无状态副本跨 Zone 分布可以提高可用性;
  • 把每个有状态副本和它的卷强行跨 Zone 迁移,可能反而不可行。

因此,局部性和高可用不是同一个优化目标。


八、PVC、PV 和存储拓扑如何参与调度

1. PV 可能带有节点或拓扑限制

一个 PersistentVolume 可以通过 nodeAffinity 声明它只能被哪些节点使用。下面是一个静态 Local PV 示例:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: local-pv-a1
spec:
  capacity:
    storage: 100Gi
  volumeMode: Filesystem
  accessModes:
  - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-storage
  local:
    path: /var/lib/local-data/app-1
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - worker-a1

这个 PV 只能在 worker-a1 上使用。它不是一个可以被任意节点挂载的远程卷。

对应的 StorageClass:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: local-storage
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer

其中:

  • kubernetes.io/no-provisioner 表示没有动态 provisioner;
  • Local PV 通常由管理员预先创建;
  • WaitForFirstConsumer 表示不要在 PVC 创建时立刻绑定,而要等到有 Pod 使用它时,再结合 Pod 的调度条件选择合适的 PV。

创建 PVC:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: app-data
spec:
  accessModes:
  - ReadWriteOnce
  storageClassName: local-storage
  resources:
    requests:
      storage: 100Gi

创建使用该 PVC 的 Pod:

apiVersion: v1
kind: Pod
metadata:
  name: app
spec:
  containers:
  - name: app
    image: nginx:1.27
    volumeMounts:
    - name: data
      mountPath: /data
  volumes:
  - name: data
    persistentVolumeClaim:
      claimName: app-data

2. 延迟绑定的调度过程

使用 WaitForFirstConsumer 时,过程大致如下:

sequenceDiagram
    participant U as 用户
    participant API as API Server
    participant S as Scheduler
    participant B as PV/PVC 绑定控制器
    participant K as kubelet
    participant N as Node

    U->>API: 创建 PVC
    API-->>U: PVC Pending
    U->>API: 创建使用 PVC 的 Pod
    API-->>S: Pod 进入待调度队列
    S->>S: 检查资源、亲和、拓扑和可用 PV
    S->>B: 为 PVC 选择并绑定兼容 PV
    B-->>API: 更新 PVC/PV 绑定状态
    S->>API: 将 Pod 绑定到 Node
    API-->>K: Pod 期望状态
    K->>N: 挂载或准备卷
    K-->>API: Pod Running 或报告挂载错误

关键点是:存储约束会反过来影响 Pod 的候选节点。

例如:

  • PV 只能在 worker-a1
  • Pod 的节点亲和只允许 zone-b
  • worker-a1 位于 zone-a

那么候选集合为空,Pod 会 Pending,而不是“先调度再尝试挂载”。

3. 云盘的 Zone 局部性

许多云厂商的块存储卷带有 Zone 属性。一个卷可能只能挂载到同 Zone 的节点,或者支持有限的跨 Zone 附加能力。具体行为由 CSI 驱动和云厂商实现决定,不能把某一家云的行为当成 Kubernetes 规范。

使用 CSI 动态存储时,通常需要确认:

  • StorageClass 的 volumeBindingMode
  • CSI 驱动是否正确发布拓扑信息;
  • 卷是否具有 Zone 或 Region 限制;
  • ReadWriteOnce 是否限制为单节点挂载;
  • 节点故障后卷能否重新附加;
  • 跨 Zone 重新调度时是否需要复制数据或恢复卷。

WaitForFirstConsumer 能避免卷在错误 Zone 提前创建,但它不能把单 Zone 卷变成跨 Zone 高可用存储。


九、Local PV 的局部性与故障恢复边界

Local PV 的数据直接位于某个 Node 的本地路径。它具有很强的数据局部性,但也暴露了明确的故障边界:

Pod -> Node 本地路径

当 Node 故障时,通常同时发生:

  1. Pod 无法继续在该 Node 上运行;
  2. Local PV 也无法在其他 Node 上直接访问;
  3. Deployment 控制器可能创建替代 Pod;
  4. 替代 Pod 找不到可用的同一数据卷;
  5. PVC 仍然绑定原 PV,业务可能保持 Pending 或等待人工恢复。

因此,Local PV 的“节点故障自动恢复”能力不能与远程复制存储混为一谈。

如果业务数据必须在 Node 故障后恢复,通常需要额外机制,例如:

  • 应用自身的数据复制;
  • 存储系统的副本和故障转移;
  • 定期备份与恢复;
  • 将数据放在可跨节点附加的远程存储;
  • 预先设计卷替换和数据重建流程。

Local PV 适合对本地 I/O 或数据局部性有强需求、且应用或存储层已经处理复制和恢复的场景。仅仅因为它“快”,就把唯一数据放在 Local PV 上,会把 Node 故障直接扩大为数据不可用风险。


十、StatefulSet 中的拓扑和数据关系

StatefulSet 为每个副本提供稳定身份和通常独立的 PVC。假设有 3 个副本:

db-0 -> data-db-0
db-1 -> data-db-1
db-2 -> data-db-2

这与 Deployment 的共享 PVC 语义不同。每个 Pod 的存储绑定关系可能具有独立拓扑限制。

一个简化的 StatefulSet 示例:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: db
spec:
  serviceName: db
  replicas: 3
  selector:
    matchLabels:
      app: db
  template:
    metadata:
      labels:
        app: db
    spec:
      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: topology.kubernetes.io/zone
        whenUnsatisfiable: DoNotSchedule
        labelSelector:
          matchLabels:
            app: db
      containers:
      - name: db
        image: postgres:17
        volumeMounts:
        - name: data
          mountPath: /var/lib/postgresql/data
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes:
      - ReadWriteOnce
      storageClassName: zonal-block
      resources:
        requests:
          storage: 100Gi

这段配置只表达了 Pod 的 Zone 分布和每个副本的 PVC 模板。它并不保证:

  • PostgreSQL 已经配置了跨 Zone 复制;
  • 每个 PVC 都一定创建在不同 Zone;
  • 主库故障后应用能够自动选主;
  • 卷中的数据能够跨 Zone 恢复。

如果 StorageClass 的动态卷创建在某个 Zone,而 StatefulSet 又要求 Pod 分散到多个 Zone,必须确认 CSI 驱动和延迟绑定能让两者共同成立。否则某个副本可能因为卷拓扑与 Pod 拓扑冲突而 Pending。


十一、一个可验证的无状态应用拓扑示例

下面的 Deployment 同时表达 Zone 和 Node 两级分布:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 6
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: topology.kubernetes.io/zone
        whenUnsatisfiable: DoNotSchedule
        labelSelector:
          matchLabels:
            app: api
      - maxSkew: 1
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: ScheduleAnyway
        labelSelector:
          matchLabels:
            app: api
      containers:
      - name: api
        image: nginx:1.27
        resources:
          requests:
            cpu: 100m
            memory: 128Mi

应用配置:

kubectl apply -f api.yaml
kubectl rollout status deployment/api
kubectl get pods -l app=api -o wide

查看 Pod 到 Zone 的分布:

kubectl get pods -l app=api -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.nodeName}{"\n"}{end}'

再结合节点标签检查:

kubectl get pods -l app=api -o wide --no-headers |
while read pod ready status restarts age ip node rest; do
  zone=$(kubectl get node "$node" -o jsonpath='{.metadata.labels.topology\.kubernetes\.io/zone}')
  printf '%s\t%s\t%s\n' "$pod" "$node" "$zone"
done

预期可以看到类似:

api-xxx-1  worker-a1  zone-a
api-xxx-2  worker-a2  zone-a
api-xxx-3  worker-b1  zone-b
api-xxx-4  worker-b2  zone-b
api-xxx-5  worker-c1  zone-c
api-xxx-6  worker-c2  zone-c

实际分布可能不同,但在资源和标签条件满足时,Zone 计数应符合 maxSkew: 1

如果 Pod Pending,先查看事件:

kubectl describe pod <pod-name>

常见事件包括:

0/6 nodes are available:
2 node(s) didn't match pod topology spread constraints,
2 node(s) didn't match pod anti-affinity rules,
2 Insufficient cpu.

这类信息说明问题通常不是“调度器随机选错了节点”,而是多个过滤条件叠加后已经没有合适候选节点。


十二、故障路径:不同拓扑设计会产生不同结果

1. 单 Node 故障

假设 3 个副本分布为:

zone-a / node-a1: api-0
zone-b / node-b1: api-1
zone-c / node-c1: api-2

node-a1 故障后:

  1. api-0 所在节点不可用;
  2. Deployment 发现可用副本数下降;
  3. 创建替代 Pod;
  4. 调度器依据当前约束选择可用节点;
  5. 如果 zone-a 仍有其他节点且资源足够,替代 Pod 可能回到 zone-a
  6. 如果 Zone 级分布要求不允许继续进入 zone-a,则可能选择其他 Zone;
  7. 如果所有符合约束的节点都没有资源,替代 Pod Pending。

控制器只负责维持副本数,不负责保证每个副本都有可恢复的数据。

2. 整个 Zone 故障

如果原来是:

zone-a: 2
zone-b: 2
zone-c: 2

zone-a 故障后,剩余副本为 4 个。对于无状态应用,只要剩余 Zone 有足够资源,Deployment 可以重建丢失副本。

但如果每个副本使用只能位于 zone-a 的卷,计算 Pod 即使能被重新调度,也无法使用原数据。此时计算层高可用不能抵消存储层的 Zone 限制。

3. 重新调度与卷重新挂载之间的并发

对于远程卷,Node 故障后常见过程是:

  1. kubelet 不再报告原 Pod 正常运行;
  2. 控制器创建或确认替代 Pod;
  3. 卷控制器尝试从旧节点卸载或确认旧节点已失效;
  4. CSI 驱动尝试把卷附加到新节点;
  5. kubelet 挂载文件系统;
  6. 容器启动。

旧节点状态不明确时,卷可能因为仍被认为已附加而暂时无法挂载到新节点。这个过程受 CSI 驱动、云厂商控制面和节点故障类型影响,不能通过 Pod 反亲和解决。


十三、生产中最容易出现的设计错误

错误一:副本数大于 Zone 数,却使用 Zone 硬反亲和

requiredDuringSchedulingIgnoredDuringExecution:
- topologyKey: topology.kubernetes.io/zone

这意味着同一 Zone 只能有一个匹配 Pod。副本数为 5、Zone 数为 3 时,至少 2 个 Pod 会 Pending。

如果目标是均衡而不是绝对互斥,应考虑:

topologySpreadConstraints:
- maxSkew: 1
  topologyKey: topology.kubernetes.io/zone
  whenUnsatisfiable: DoNotSchedule

错误二:只按 Node 分散,却误以为实现了跨机架高可用

Node 级分散不能抵御共享机架、电源或网络设备故障。需要使用真实的机架或故障域标签。

错误三:只设置 Zone Spread,却忽略 Zone 容量

三个 Zone 的节点数量和可用资源可能完全不同:

zone-a: 20 个节点
zone-b: 3 个节点
zone-c: 1 个节点

maxSkew: 1 仍然试图让 Pod 数量接近均匀,而不是按容量比例分配。如果 zone-c 容量不足,硬约束会造成 Pending。

可以选择:

  • 增加各 Zone 容量;
  • 使用 ScheduleAnyway 接受容量约束下的偏斜;
  • 按业务容量重新规划故障域;
  • 将不同工作负载拆分到不同调度策略。

错误四:以为调度分布等于数据复制

Pod 跨 Zone 只说明计算实例跨 Zone。数据库数据是否安全,取决于数据库复制、卷复制、备份和恢复流程。

错误五:忽略服务流量的拓扑

Pod 分散后,请求可能通过 Service 访问跨 Zone 后端。计算高可用可能提高网络成本和延迟。

Service 的拓扑感知流量分配需要单独配置和验证,例如使用 trafficDistribution 等能力时,必须确认目标 Kubernetes 版本、kube-proxy 或实现组件是否支持。它与 Pod 调度约束是两个不同层面:

调度约束:Pod 放在哪里
Service 流量:请求转发到哪里

前者不会自动改变后者。


十四、如何诊断“分布不符合预期”

第一步:确认节点标签

kubectl get nodes \
  -L kubernetes.io/hostname \
  -L topology.kubernetes.io/zone \
  -L topology.kubernetes.io/region

检查:

  • 是否所有目标 Node 都有 Zone 标签;
  • Zone 值是否拼写一致;
  • 是否存在错误标签或过期标签;
  • 标签是否真的对应预期故障域。

第二步:确认 Pod 实际所在位置

kubectl get pods -A -o wide

按应用过滤:

kubectl get pods -l app=api -o wide

调度分布只能通过实际 Pod 和 Node 位置验证,不能只看 Deployment 的 replicas

第三步:查看调度事件

kubectl describe pod <pod-name>

重点关注:

  • Insufficient cpuInsufficient memory
  • didn't match Pod's node affinity/selector
  • didn't match pod anti-affinity rules
  • didn't match pod topology spread constraints
  • volume node affinity conflict;
  • PVC 未绑定或卷无法挂载。

第四步:检查 PVC、PV 和卷拓扑

kubectl get pvc,pv
kubectl describe pvc <pvc-name>
kubectl describe pv <pv-name>

对于 CSI 卷,还应检查 StorageClass:

kubectl get storageclass <storage-class-name> -o yaml

确认:

volumeBindingMode: WaitForFirstConsumer

是否符合预期,并检查 CSI 驱动是否正常运行:

kubectl get pods -A | grep -i csi

第五步:区分“没有调度”与“调度后启动失败”

  • Pod 处于 Pending:优先看调度、PVC 和资源;
  • Pod 已经绑定 Node 但处于 ContainerCreating:优先看卷挂载、网络、镜像;
  • Pod 进入 CrashLoopBackOff:拓扑可能已经满足,问题转向应用启动和数据状态;
  • Pod 被驱逐:检查节点压力、资源请求和 NoExecute taint。

不能因为 Pod 没有 Running 就笼统归因于“拓扑配置错误”。


十五、版本和实现边界

本文示例使用当前 Kubernetes 稳定 API 组:

  • apps/v1:Deployment、StatefulSet;
  • v1:Pod、PV、PVC;
  • policy/v1:PodDisruptionBudget;
  • storage.k8s.io/v1:StorageClass;
  • topologySpreadConstraints:Pod 规范中的稳定调度字段。

需要特别区分以下事项:

  1. topology.kubernetes.io/zonetopology.kubernetes.io/region 的具体标签值依赖云厂商或集群基础设施;
  2. 云盘是否跨 Zone 可用、是否支持自动重新附加,由 CSI 驱动和云厂商决定;
  3. WaitForFirstConsumer 能协调初次绑定与调度,但不保证故障后的数据恢复;
  4. 新版本中可能增加拓扑分布的节点纳入策略、最小域数等字段,使用前应以目标集群版本的 API 文档和服务器 OpenAPI 定义为准;
  5. 不同调度器配置、默认插件配置和云控制器行为可能影响评分与拓扑标签同步;
  6. Kubernetes 的调度约束不替代应用层复制、备份、选主和灾备演练。

十六、建立拓扑设计时应先回答的形式化问题

对一个工作负载,先定义:

副本数 R
故障域集合 D
每个故障域的容量 capacity(d)
每个 Pod 的资源需求 request
数据卷允许的拓扑集合 V
业务允许的最大延迟和跨域流量

然后分别判断:

计算可行性

是否存在一个分配函数:

assign:PodsNodesassign: Pods \rightarrow Nodes

使得每个 Pod:

  1. Node 资源足够;
  2. 满足节点亲和与污点条件;
  3. 满足 Pod 亲和、反亲和;
  4. 满足拓扑分布的 maxSkew
  5. 满足卷的节点或 Zone 限制。

故障域可用性

对任意目标故障域 ff,故障后剩余容量是否足以运行所需副本:

dfcapacity(d)Rrequired\sum_{d \notin f} capacity(d) \geq R_{\text{required}}

如果这个条件不成立,调度配置再严格,也无法在该故障后恢复全部副本。

数据恢复性

若某个卷只能在故障域 dd 使用,则必须额外回答:

d 故障时,数据是否有副本?
副本是否位于 d 之外?
恢复需要多长时间?
恢复期间业务是否只读、降级或不可用?

这部分不由 Pod 的 replicas 或反亲和字段自动回答。


结语

Pod 拓扑设计的核心不是简单地给 Deployment 增加一段反亲和配置,而是把以下关系明确建模:

Node 是调度位置
Zone 是标签定义的拓扑域
故障域是共同失败的边界
反亲和限制副本不能共处
Topology Spread 约束副本分布偏斜
存储拓扑限制数据可访问的位置

Node 级分散主要抵御单节点故障,Zone 级分散主要抵御可用区故障,更高层故障域则需要基础设施提供可靠标签。无状态工作负载通常适合使用 Zone 与 Node 两级拓扑分布;有状态工作负载还必须把 PVC、PV、CSI 拓扑、数据复制和恢复路径一起设计。

只有当计算位置、数据位置、资源容量和故障恢复流程彼此一致时,“跨 Zone 高可用”才是实际成立的系统属性,而不是 YAML 中的一组看起来合理的字段。


系列导航与关联阅读

官方资料

本文依据 Kubernetes、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。