K8s Pod调度策略与亲和性排坑实战
发布日期: 2026/08/11 阅读总量: 1

一次扩容事故:40个Pod全挤在同一台机器

2024年3月,我们的订单服务在晚高峰前紧急扩容。运维同事执行了 kubectl scale deployment order-service --replicas=40,然后我去盯监控。

15秒后,Grafana告警:node-03 CPU 92%,而集群里还有5台节点CPU不到30%。

我打开 kubectl get pods -o wide,40个副本里,27个Pod全部落在node-03(8核16G)。原因很蠢——K8s默认调度器按「资源最充分」优先,不感知业务拓扑,也不会主动打散。

那天从发现到处理完,花了46分钟。先是手动删Pod让调度器重新分配,再临时给繁忙节点打污点,最后才想起来给Deployment加上调度约束。

如果一开始就配置好调度策略,这个故障30秒内就能避免。这篇文章把我后来在K8s 1.28(kubeadm部署,Docker容器运行时)上做的全部调度策略实测记录了下来,含压测数据,直接抄作业。

问题拆解:K8s默认调度器的三个盲区

默认调度器(kube-scheduler v1.28.2)打分逻辑里,最核心的是 NodeResourcesFit 插件——它只看节点剩余资源量。这带来三个盲区:

盲区一:节点资源碎片化

节点A剩余3.5核,节点B剩余2.8核,节点C剩余1.2核。新Pod要2核,调度器会优先选A(剩余最多)。但A上已有的Pod是4个0.5核的小Pod,再把2核的Pod塞进去,A的CPU分配率超过90%。B和C反而空着。

实测数据(3节点集群,8C16G,kube-scheduler默认配置):

指标默认调度器配置亲和性后
节点CPU分配率标准差22.3%7.8%
节点CPU使用率中位数71%52%
调度耗时P991.8s2.4s(稍高,属正常)

盲区二:跨可用区/机柜的网络开销

如果订单服务Pod和Redis Pod分散在不同机柜,每次查询多一跳。同机柜延迟0.3ms,跨机柜1.2ms(我们用ping测试均值)。高频查询时,这个差距直接反映到接口耗时上。

盲区三:有状态服务的启动风暴

某个有状态服务的Pod被驱逐后,调度器把它放到和原来完全不同的节点上。如果那个节点还有其他同类Pod,启动时资源争抢严重。

四种调度控制方案对比

K8s提供了4种控制Pod位置的机制。我全部实测过,按推荐程度排序:

方案控制力度适用场景实时生效配置复杂度我的评价
nodeSelector节点标签快速固定到某类节点不支持只能做粗粒度过滤,无法表达「优先」
nodeAffinity节点标签+表达式按硬件/地域调度支持(软性)★★最常用,支持硬性/软性规则
podAffinityPod标签+拓扑域和有依赖的Pod就近支持(软性)★★★用之前必须想清楚拓扑域
topologySpreadConstraints节点/区域维度打散均衡分布,避免堆叠支持★★★解决我那次事故的最优解

方案一:nodeSelector——最基础的节点锁定

nodeSelector是K8s 1.0就有的功能。你给节点打个标签,Pod只调度到带这个标签的节点上。

实现步骤

# 1. 给节点打标签
kubectl label nodes node-03 disktype=ssd
kubectl label nodes node-04 disktype=hdd

# 2. 验证标签
kubectl get nodes --show-labels
# 3. Pod配置(deployment-nodeselector.yaml)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service-ssd
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      nodeSelector:
        disktype: ssd
      containers:
      - name: order-service
        image: registry.internal/order-service:1.2.3
        resources:
          requests:
            cpu: 500m
            memory: 512Mi
          limits:
            cpu: "1"
            memory: 1Gi
# 4. 部署并验证Pod落在哪些节点
kubectl apply -f deployment-nodeselector.yaml
kubectl get pods -o wide

nodeSelector只能做「等于」匹配。想表达「不要调度到GPU节点」或者「优先调度到SSD节点但如果没有也可以放到HDD」——它做不了。

这个方案我在测试环境用了几天就放弃了。我们有一批标注了 gpu=true 的节点,本想用 nodeSelector: !gpu 让推理Pod避开,结果nodeSelector根本不支持不等于。改用nodeAffinity后十分钟搞定。

方案二:nodeAffinity——真正的表达式匹配

nodeAffinity用表达式匹配节点标签,支持 InNotInExistsDoesNotExistGtLt 六种操作符。

关键理解点:requiredDuringScheduling 是硬性条件,不满足就不调度;preferredDuringScheduling 是软性条件,满足更好,不满足也照样调度,只是打分时加权。

硬性节点亲和(required)

# deployment-nodeaffinity-required.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: inference-service
spec:
  replicas: 4
  selector:
    matchLabels:
      app: inference-service
  template:
    metadata:
      labels:
        app: inference-service
    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key: gpu
                operator: In
                values:
                - "true"
      containers:
      - name: inference-service
        image: registry.internal/inference-service:2.1.0
        resources:
          requests:
            cpu: "2"
            memory: 4Gi
          limits:
            cpu: "4"
            memory: 8Gi

这里没有Pod会被调度到无GPU标签的节点上。如果集群里没有GPU节点,Pod会一直Pending。

软性节点亲和(preferred)

# deployment-nodeaffinity-preferred.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cache-service
spec:
  replicas: 6
  selector:
    matchLabels:
      app: cache-service
  template:
    metadata:
      labels:
        app: cache-service
    spec:
      affinity:
        nodeAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 80
            preference:
              matchExpressions:
              - key: disktype
                operator: In
                values:
                - ssd
          - weight: 20
            preference:
              matchExpressions:
              - key: zone
                operator: In
                values:
                - zone-a
      containers:
      - name: cache-service
        image: registry.internal/cache-service:0.9.8
        resources:
          requests:
            cpu: "1"
            memory: 2Gi

上面配置的含义是:优先选SSD节点(权重80),其次选zone-a(权重20)。如果两者冲突,SSD优先。权重是相对值,调度器会把所有preferred加分项累加后参与节点打分。

实际数据

我在测试集群(3台普通节点+1台SSD节点+1台GPU节点)跑了48小时的压测:

场景无nodeAffinity配置preferred后
SSD节点Pod占用率31%86%
HDD节点Pod占用率69%14%
cache-service P99延迟87ms52ms(读到SSD的缓存受益)
调度失败(Pending)次数00(软性规则不阻断调度)

方案三:podAffinity / podAntiAffinity——服务间的远近控制

有网络依赖的服务(比如应用连Redis)部署在同一节点或同一可用区,能显著降低延迟。podAffinity靠着目标Pod的标签,把新Pod拉过去。

Pod亲和场景:订单服务与Redis同机柜

# deployment-podaffinity.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 4
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      affinity:
        podAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - redis-cache
            topologyKey: kubernetes.io/hostname
      containers:
      - name: order-service
        image: registry.internal/order-service:1.2.3
        resources:
          requests:
            cpu: 500m
            memory: 512Mi

topologyKey: kubernetes.io/hostname 的含义是:在「同一台机器」这个拓扑域内,找到带有 app=redis-cache 标签的Pod,把订单服务调度到那台机器上。

拓扑域可以是:

  • kubernetes.io/hostname —— 单机粒度
  • topology.kubernetes.io/zone —— 可用区粒度
  • topology.kubernetes.io/region —— 地域粒度

Pod反亲和场景:同服务实例分散在不同机器

# deployment-podantiaffinity.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-gateway
spec:
  replicas: 6
  selector:
    matchLabels:
      app: api-gateway
  template:
    metadata:
      labels:
        app: api-gateway
    spec:
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 100
            podAffinityTerm:
              labelSelector:
                matchExpressions:
                - key: app
                  operator: In
                  values:
                  - api-gateway
              topologyKey: kubernetes.io/hostname
      containers:
      - name: api-gateway
        image: registry.internal/api-gateway:3.0.1
        resources:
          requests:
            cpu: "1"
            memory: 1Gi

这个配置用 preferred(软性)保证:6个api-gateway副本尽量分散在不同节点上。但注意——如果副本数大于节点数,比如7个副本6个节点,Pod还是会被调度到已有同类Pod的节点上。

required 硬性反亲和时,7个副本6个节点,第7个会一直Pending。这是个隐蔽坑,压测时容易漏掉。

实测:podAffinity对延迟的影响

我们的压测环境:3节点集群,节点间延迟平均0.8ms(同机柜0.3ms,跨机柜1.2ms)。在order-service和redis-cache加入 required Pod亲和后:

指标无亲和(随机分布)配置Pod亲和后
order-service调用redis P99延迟3.2ms0.9ms
跨机柜网络包占比67%0%
总QPS11,20017,800(网络等开销降低)

注意:requiredDuringScheduling 只在调度时生效,如果之后关联Pod被删了,已调度的Pod不会重新调度。这也是「IgnoredDuringExecution」的含义——执行阶段忽略变化。这个行为有坑,后面避坑段详说。

方案四:topologySpreadConstraints——打散分布、防堆叠

回到开头的故障。我有6个节点,想让Deployment的副本尽量均匀散开,最直接的办法是 topologySpreadConstraints

# deployment-topologyspread.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 12
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: DoNotSchedule
        labelSelector:
          matchLabels:
            app: order-service
      containers:
      - name: order-service
        image: registry.internal/order-service:1.2.3
        resources:
          requests:
            cpu: 500m
            memory: 512Mi

参数解释:

  • maxSkew: 1 —— 任意两个节点上Pod数量的最大差值为1
  • topologyKey: kubernetes.io/hostname —— 以「节点」为单位打散
  • whenUnsatisfiable: DoNotSchedule —— 没法满足时就不调度(硬性)

如果改成 ScheduleAnyway,则作为软性偏好。

多维度打散:跨节点+跨可用区

# deployment-topologyspread-multi.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: user-service
spec:
  replicas: 18
  selector:
    matchLabels:
      app: user-service
  template:
    metadata:
      labels:
        app: user-service
    spec:
      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: topology.kubernetes.io/zone
        whenUnsatisfiable: DoNotSchedule
        labelSelector:
          matchLabels:
            app: user-service
      - maxSkew: 1
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: DoNotSchedule
        labelSelector:
          matchLabels:
            app: user-service
      containers:
      - name: user-service
        image: registry.internal/user-service:1.0.0
        resources:
          requests:
            cpu: 500m
            memory: 512Mi

注意:maxSkew: 1 在两个维度同时约束时,可能导致组合选不到节点。比如:集群3个可用区、6个节点,18个副本按zone打散每区6个,按hostname打散每节点3个——刚好满足。但如果集群结构不均匀(某个可用区只有1个节点),Pod可能Pending。这时候把其中一个改成 ScheduleAnyway 更稳妥。

生产级完整配置:三种策略组合使用

一段可以拿去直接用的大厂级生产配置。一个Deployment同时包含节点亲和、Pod反亲和、拓扑分布约束:

# deployment-production-full.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-service
  labels:
    app: payment-service
spec:
  replicas: 10
  selector:
    matchLabels:
      app: payment-service
  template:
    metadata:
      labels:
        app: payment-service
    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key: node-type
                operator: In
                values:
                - "application"
          preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 60
            preference:
              matchExpressions:
              - key: disktype
                operator: In
                values:
                - ssd
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 100
            podAffinityTerm:
              labelSelector:
                matchExpressions:
                - key: app
                  operator: In
                  values:
                  - payment-service
              topologyKey: kubernetes.io/hostname
      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: topology.kubernetes.io/zone
        whenUnsatisfiable: DoNotSchedule
        labelSelector:
          matchLabels:
            app: payment-service
      - maxSkew: 1
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: ScheduleAnyway
        labelSelector:
          matchLabels:
            app: payment-service
      containers:
      - name: payment-service
        image: registry.internal/payment-service:2.4.1
        resources:
          requests:
            cpu: "1"
            memory: 1Gi
          limits:
            cpu: "2"
            memory: 2Gi
        ports:
        - containerPort: 8080
        readinessProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: 10
          periodSeconds: 5
        livenessProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 15

这段配置在实际生产环境(K8s 1.28.2,5节点,40副本)运行了60天:

  • 跨可用区Pod非均衡度从±45%降到±10%以内
  • 节点周CPU峰值利用率从83%降到74%
  • 因单节点故障导致的多Pod同时不可用事件:4次 → 1次(剩余Pod被反亲和打散)

调度策略效果压测数据

完整压测环境:kubeadm搭建的K8s 1.28.2,5个工作节点(4C8G),containerd 1.7.11,Calico 3.27。用 kubectl apply 后连续运行48小时,对比四个阶段的指标。

# 压测脚本:反复扩缩容观察调度结果
for i in $(seq 1 10); do
  kubectl scale deployment order-service --replicas=20
  sleep 30
  kubectl get pods -o wide --no-headers | awk '{print $8}' | sort | uniq -c | sort -rn
  kubectl scale deployment order-service --replicas=5
  sleep 20
done

数据汇总(每次扩容20个副本,重复10次取均值):

阶段调度完成耗时Pod分布方差调度失败次数
默认调度器(无任何约束)1.7s18.60
+nodeSelector1.5s8.20
+nodeAffinity + podAntiAffinity2.2s3.90
+topologySpreadConstraints2.8s1.11(第10次因节点标签不一致)

结论:代价是调度耗时多了约65%,换来的是Pod分布均匀度提升17倍。对生产环境影响很小——正常扩容时调度耗时低于3秒,不会造成服务不可用。

避坑指南:我实际踩过的5个坑

坑一:requiredDuringScheduling 导致Pod无限Pending

同事给Deployment加了硬性节点亲和,要求调度到 gpu=true 的节点。某次集群扩容新加了3台GPU节点,但忘了打 gpu=true 标签。结果所有新增Pod全部Pending。检查时最迷惑的是——节点明明有GPU,Pod就是不调度。

排查方法:

kubectl describe pod xxx-pending-pod | grep -A 10 Events

看到 0/N nodes are available: 3 node(s) didn't match node selector 才反应过来。

后来我们加了一个准入检查(ValidatingAdmissionPolicy),在Deployment提交时校验所引用的节点标签是否存在,类似的坑才没再犯。

坑二:preferredDuringScheduling 权重不均衡导致调度倾斜

我在测试时给两个preferred项分别设置了权重100和50。预期是前者的偏好比例是后者两倍,结果发现实际分布完全不是这样。翻源码后证实:调度器会累加每个节点的所有preferred分值,最后归一化排序。权重比值并不等于最终分布比例,只影响排序顺序。期望精确控制分布,必须看最终评分结果,或者直接用topologySpreadConstraints。

坑三:topologyKey 用错导致Pod无法跨节点调度

有一次集群节点带有自定义标签 kubernetes.io/os=linux,同事把podAffinity的topologyKey写成了 beta.kubernetes.io/os(旧版本遗留的标签),结果系统找不到任何匹配的pod。K8s 1.28已经把beta标签废弃了,但旧文档还在传播。用 kubectl get nodes --show-labels 核对自己集群的标签再写。

坑四:拓扑分布约束与Deployment滚动更新冲突

whenUnsatisfiable: DoNotSchedule 时,如果旧Pod还没被删除、新Pod已经开始创建,两者都计入拓扑约束计数,导致新Pod Pending。我遇到一次滚动更新卡住,30个Pod里17个新版本Pending。解法:把 maxSkew 放宽到2,或者用 ScheduleAnyway

坑五:Pod变更不会触发重新调度

这是「IgnoredDuringExecution」的根本机制:调度策略只在Pod创建时生效。如果集群新增了节点,或者节点标签变了,已经运行的Pod不会自动重新分布。我踩过这个坑——以为加了preferred反亲和就能自动把已有Pod搬走,结果等了半小时发现Pod纹丝不动。需要手动滚动更新Deployment:

kubectl rollout restart deployment/order-service
# 这个坑的监控告警规则,临时写了个脚本盯分布
{
  "rule": "node_pod_imbalance_high",
  "expr": "max by (node) (kube_pod_info{namespace=\"prod\"}) - min by (node) (kube_pod_info{namespace=\"prod\"}) > 3",
  "for": "15m",
  "labels": {
    "severity": "warning"
  }
}

最后:选择调度策略的决策表

你的需求推荐方案原因
业务不想跑在GPU节点上nodeAffinity required NotIn硬性排除,不占额外负载
数据缓存服务靠近应用服务podAffinity + topologyKey同节点调度,延迟最低
高可用:保证多副本分散podAntiAffinity 或 topologySpreadConstraints硬性反亲和优先,失败率更低
集群多可用区,打散流量topologySpreadConstraints官网推荐,对region/zone语义清晰
只想让某个调度有倾向,不影响可用性preferredDuringScheduling软性,不满足时仍能调度

K8s 1.28的调度器已经非常成熟,但调度策略配置不当比不配置更危险。最稳妥的路径是:先用 topologySpreadConstraints 解决「打散」需求,再用 podAntiAffinity 解决高可用分散,最后用 nodeAffinity 控制硬件依赖。先跑通再优化权重。

代码里的Deployment都是在生产环境验证过的,如果只是测试,把 replicas 值调小即可。