一次扩容事故: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% |
| 调度耗时P99 | 1.8s | 2.4s(稍高,属正常) |
盲区二:跨可用区/机柜的网络开销
如果订单服务Pod和Redis Pod分散在不同机柜,每次查询多一跳。同机柜延迟0.3ms,跨机柜1.2ms(我们用ping测试均值)。高频查询时,这个差距直接反映到接口耗时上。
盲区三:有状态服务的启动风暴
某个有状态服务的Pod被驱逐后,调度器把它放到和原来完全不同的节点上。如果那个节点还有其他同类Pod,启动时资源争抢严重。
四种调度控制方案对比
K8s提供了4种控制Pod位置的机制。我全部实测过,按推荐程度排序:
| 方案 | 控制力度 | 适用场景 | 实时生效 | 配置复杂度 | 我的评价 |
|---|---|---|---|---|---|
| nodeSelector | 节点标签 | 快速固定到某类节点 | 不支持 | ★ | 只能做粗粒度过滤,无法表达「优先」 |
| nodeAffinity | 节点标签+表达式 | 按硬件/地域调度 | 支持(软性) | ★★ | 最常用,支持硬性/软性规则 |
| podAffinity | Pod标签+拓扑域 | 和有依赖的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用表达式匹配节点标签,支持 In、NotIn、Exists、DoesNotExist、Gt、Lt 六种操作符。
关键理解点: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延迟 | 87ms | 52ms(读到SSD的缓存受益) |
| 调度失败(Pending)次数 | 0 | 0(软性规则不阻断调度) |
方案三: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.2ms | 0.9ms |
| 跨机柜网络包占比 | 67% | 0% |
| 总QPS | 11,200 | 17,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数量的最大差值为1topologyKey: 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.7s | 18.6 | 0 |
| +nodeSelector | 1.5s | 8.2 | 0 |
| +nodeAffinity + podAntiAffinity | 2.2s | 3.9 | 0 |
| +topologySpreadConstraints | 2.8s | 1.1 | 1(第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 值调小即可。