节点池不是云厂商才有的概念。即使所有节点都来自本地虚拟化平台,也应该用标签、污点和亲和性把不同故障域分开。

系列导航:Kubernetes 重建迁移教程系列。本篇在基础组件验收通过后执行。

这篇文章从一份节点清单开始,把规划真正落实到 Kubernetes 调度规则中。

第 1 步:先按职责分池

一个通用的起点是:

节点池典型工作负载是否建议独占
control-planeAPI Server、etcd是
ingressIngress Controller、四层入口是
platformNacos、DevOps、核心平台视容量而定
app-*各业务环境生产建议
dataMySQL、Redis、Kafka、RabbitMQ、ZooKeeper是
observabilityPrometheus、日志、追踪建议
shared小型无状态服务否
reserve故障和滚动升级预留不部署常规业务

先给每个节点分配一个主池,不要让同一台机器同时出现在两个独占池。

第 2 步:把规划写成 CSV 并校验

hostname,pool,cpu,memory_gib,disk_type,failure_domain
worker-a,app-test,8,32,ssd,host-a
worker-b,app-test,8,32,ssd,host-b
worker-c,app-test,8,32,ssd,host-c

检查重复:

cut -d, -f1 inventory/node-pools.csv | tail -n +2 | sort | uniq -d

检查每个池的节点数:

cut -d, -f2 inventory/node-pools.csv | tail -n +2 | sort | uniq -c

规划高可用三副本时,要同时考虑 Kubernetes 节点和虚拟化宿主机两个层面。三个 Pod 落在三台虚拟机,但若三台虚拟机位于同一台物理宿主机上,仍是单点。

第 3 步:先添加描述性标签

标签描述“节点是什么”,不产生排他效果:

kubectl label node worker-a workload.yj/pool=app-test
kubectl label node worker-a topology.yj/failure-domain=host-a
kubectl label node worker-a hardware.yj/disk=ssd

批量完成后检查:

kubectl get nodes -L workload.yj/pool,topology.yj/failure-domain,hardware.yj/disk

自定义标签应使用自己的 DNS 前缀,避免与 Kubernetes 保留标签冲突。

第 4 步:再决定哪些池需要污点

污点表示“默认不允许谁来”。对于独占数据池,可以添加:

kubectl taint node data-a workload.yj/pool=data:NoSchedule
kubectl taint node data-b workload.yj/pool=data:NoSchedule
kubectl taint node data-c workload.yj/pool=data:NoSchedule

检查:

kubectl describe node data-a | sed -n '/Taints:/,/Unschedulable:/p'

不要先给几十个节点加污点,再回头修改 Deployment。正确顺序是:

准备应用 toleration 和 nodeAffinity
→ server-side dry-run
→ 给第一台节点加污点
→ 验证调度
→ 批量推广

第 5 步:应用同时声明容忍和目标节点

仅配置 toleration 表示“允许进入”,并不保证 Pod 一定进入数据池,应同时使用 nodeAffinity:

spec:
  template:
    spec:
      tolerations:
        - key: workload.yj/pool
          operator: Equal
          value: data
          effect: NoSchedule
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: workload.yj/pool
                    operator: In
                    values: [data]

先做 dry-run:

kubectl apply --server-side --dry-run=server -f workload.yaml

第 6 步:让有状态副本分散

Kafka、RabbitMQ、ZooKeeper、MySQL 副本不能落在同一节点。使用 Pod 反亲和:

affinity:
  podAntiAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchLabels:
            app.kubernetes.io/name: zookeeper
        topologyKey: kubernetes.io/hostname

如果还要跨虚拟化宿主机,可再使用自定义故障域标签:

topologyKey: topology.yj/failure-domain

节点不足时,required 会让 Pod 保持 Pending。这比把三个副本挤到同一节点上更诚实;应扩容节点或调整容量规划,而不是默默降级故障域。

第 7 步:为滚动升级保留容量

假设某业务有两个 Pod,每个 request 为 2 核 6 GiB。滚动升级使用默认 maxSurge: 25% 时,至少需要临时容纳第三个 Pod。

可用资源要按 request 计算,而不是按实际瞬时使用量:

kubectl describe node <node> | sed -n '/Allocated resources:/,$p'
kubectl top node <node>

两者用途不同:

  • describe 的 Allocated resources 是调度器已经承诺的 request/limit。
  • top 是当前实际使用量。

不要让低优先级业务长期占用预留池,否则故障迁移和滚动发布都会卡住。

第 8 步:验证调度结果

部署后执行:

kubectl get pods -A -o custom-columns='NS:.metadata.namespace,POD:.metadata.name,NODE:.spec.nodeName' \
  | sort

kubectl get pods -n <namespace> -l app.kubernetes.io/name=<app> -o wide
kubectl get events -A --field-selector reason=FailedScheduling --sort-by=.lastTimestamp

重点检查:

  • 独占池是否混入无关 Pod。
  • 三副本是否落在不同节点和故障域。
  • DaemonSet 是否因缺少 toleration 而无法调度到带污点的节点。
  • 预留节点是否仍为空闲。
  • 小规格节点是否承载了不适合的重型服务。

实战复盘:0/69 nodes are available 应该怎么读

一次双副本服务发布时,事件持续报告:

0/69 nodes are available:
several nodes had untolerated taint,
some nodes didn't match Pod's node affinity/selector,
some nodes had insufficient memory

这不表示 69 台机器全部内存不足。调度器会依次过滤:

flowchart LR
  A["69 个节点"] --> B["Taint / Toleration"]
  B --> C["nodeSelector / nodeAffinity"]
  C --> D["CPU / Memory request"]
  D --> E["Topology spread / anti-affinity"]
  E --> F["可调度节点"]

应先把 Pod 的约束完整打印出来:

kubectl -n <namespace> get pod <pending-pod> -o jsonpath='
nodeSelector={.spec.nodeSelector}{"\n"}
affinity={.spec.affinity}{"\n"}
tolerations={.spec.tolerations}{"\n"}
requests={.spec.containers[*].resources.requests}{"\n"}
spread={.spec.topologySpreadConstraints}{"\n"}'

再逐层检查候选节点:

kubectl get node --show-labels
kubectl describe node <candidate-node>
kubectl get events -n <namespace> \
  --field-selector reason=FailedScheduling \
  --sort-by=.lastTimestamp

为什么两个 Pod 实际只用 2G,第三个仍提示内存不足

调度器依据的不是“两个现有 Pod 当前合计用了多少”,而是新 Pod 的 memory request 与节点上剩余可用的 allocatable:

可调度
⇔ node allocatable
  - 已调度 Pod requests
  ≥ incoming Pod request

如果每个 Pod request 为 3.5 GiB,即使两个现有实例当前各用约 1 GiB,新 revision 的 surge Pod 仍要完整申请 3.5 GiB。

这也是资源治理后来把普通服务、仓储服务和重型服务分档的原因:request 应覆盖稳态负载与可解释的峰值,但不能用一个过大的通用值占满所有专属节点。

为什么看起来有空节点,Pod 仍然不能使用

“节点没有业务 Pod”不等于“这个 Pod 可以调度进去”。它可能:

  • 带有当前 Pod 不容忍的 NoSchedule 污点。
  • 缺少 required nodeSelector 标签。
  • 不属于 topology spread 的 eligible domain。
  • 已经被其他 Pod 的 request 预留。
  • 是故障或滚动升级预留节点。

因此不能仅凭节点列表推断“还有 9 台服务器空闲”。先把污点、标签和 allocated requests 一起统计。

火山引擎为什么把手工删除的标签和污点写回来

托管节点池的配置是控制面的期望状态。若节点池中仍声明:

app-type=<dedicated-service>
taint app-type=<dedicated-service>:NoSchedule

只执行:

kubectl label node <node> app-type-
kubectl taint node <node> app-type-

可能只是临时改变 Kubernetes Node 对象。节点池控制器再次同步时会恢复这些字段。

火山引擎 VKE 节点池文档明确说明,节点池中新增或修改标签、污点,会自动更新新建节点和存量节点。

要把专属节点永久迁入公共池,应:

  1. 先确认该节点不再承担独占业务和特殊硬件职责。
  2. 在 VKE 节点池配置中删除或修改目标标签、污点。
  3. 等控制器完成同步。
  4. 再检查 Kubernetes Node 对象。
  5. 迁移一个低风险 Pod 验证公共池调度。
  6. 确认重启、扩缩容或节点替换后配置仍不会恢复。

如果原节点池的所有成员都必须共享同一配置,单台机器不应继续留在该节点池;需要将它迁移到公共节点池,或新建一个没有专属污点的节点池。

实战复盘:两个副本为什么仍在同一节点

只有 preferred node affinity 或 ScheduleAnyway 时,调度器可能会为了更好的资源评分把两个副本放到同一节点上。这属于合法行为,但无法抵御单节点故障。

在 Kubernetes 1.30 的 Deployment 中,可以按当前 ReplicaSet revision 强制分散:

topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: kubernetes.io/hostname
    whenUnsatisfiable: DoNotSchedule
    matchLabelKeys:
      - pod-template-hash
    labelSelector:
      matchLabels:
        app: <application>
        version: <version>

matchLabelKeys: [pod-template-hash] 让新旧 ReplicaSet 分开计算。这样在 maxUnavailable: 0、maxSurge: 1 的滚动发布中,旧 revision 不会因为参与 skew 计算而阻塞新 revision。

Kubernetes topology spread 文档也以 pod-template-hash 作为按 Deployment revision 分散的典型示例。

强制分散前必须保证:

  • 至少有两个节点同时匹配 nodeSelector/affinity。
  • 两个节点都能被 Pod 的 toleration 容忍。
  • 每个节点都能容纳一个副本的 request。
  • 发布期间仍有容纳 surge Pod 的余量。

如果只有一个合格节点,DoNotSchedule 会让第二个 Pod 保持 Pending。这是故障域约束下的正常结果,不应通过删除 spread 规则悄悄恢复同机部署。

如何安全撤销

删除污点:

kubectl taint node data-a workload.yj/pool=data:NoSchedule-

删除标签:

kubectl label node data-a workload.yj/pool-

撤销前先修改工作负载,避免它仍要求一个已经不存在的标签。

完成定义

  • 每个节点只有一个明确主池。
  • 独占池同时配置标签和污点。
  • 业务清单同时配置 toleration 与 nodeAffinity。
  • 有状态三副本跨节点,关键服务还要跨物理故障域。
  • 滚动升级时至少能容纳一个 surge Pod。
  • 监控、CNI、CSI 等 DaemonSet 已验证能容忍必要污点。
  • 预留节点没有被常规业务长期占用。
  • 托管节点池与 Kubernetes Node 上的标签、污点保持一致。
  • FailedScheduling 已按污点、亲和性、request 和 spread 分层解释。
  • 双副本使用 revision-aware topology spread 强制跨节点。