跳到主要内容

调度与资源管理

Pod 最终落在哪个节点上、能吃到多少资源、资源紧张时谁先被赶走,这三个问题贯穿了 Kubernetes 的日常运维。本篇把调度器的工作流程、Request/Limit、QoS、亲和与反亲和、污点与容忍、拓扑打散、驱逐机制、集群自动扩缩串起来讲一遍,配好可直接套用的清单。

Scheduler​

kube-scheduler 的职责很单一:监听所有 nodeName 为空的 Pod,为它挑一个最合适的节点,然后把结果通过 Binding 写回 API Server。之后的事就交给对应节点上的 kubelet 了,调度器不再参与。

调度过程分两个阶段:

  1. Filter(过滤):把不满足条件的节点直接剔除,剩下的叫「可行节点」。常见的过滤项包括:
    • 节点剩余可分配资源是否满足 Pod 的 requests(CPU、内存、扩展资源);
    • 端口是否冲突(hostPort)、卷是否挂得上来、节点是否处于 NotReady 或被驱逐状态;
    • nodeSelector、NodeAffinity 硬约束、污点后是否还有容忍。
  2. Score(打分):对可行节点逐一打分,选出得分最高者。打分插件会综合考虑资源均衡(避免把节点塞得太满或太零散)、亲和性软约束、镜像是否已本地缓存(减少拉取时间)等因素。得分相同的节点随机取一个。

可以用 kubectl describe pod 查看调度决策的过程与失败原因:

kubectl describe pod web-7d9c4f5b6-x2abc
Events:
Type Reason Message
---- ------ -------
Warning FailedScheduling 0/3 nodes are available: 1 Insufficient cpu, 2 node(s) had untolerated taint.
Normal Scheduled Successfully assigned default/web-7d9c4f5b6-x2abc to node-2
调度结果不可变

Pod 一旦被绑定到节点,调度器就不会再为它做二次决策:节点后来被打上污点、资源被别的 Pod 抢走,正在运行的 Pod 都不会被自动挪走(NoExecute 驱逐除外)。想让调度器重新选择节点,只能重建 Pod,比如 Deployment 滚动更新、kubectl delete pod 让控制器补新副本。需要周期性「纠偏」存量 Pod 的布局时,可以引入社区的 Descheduler 项目。

Resource Request​

requests 声明容器期望至少拿到多少资源,它是调度器 Filter 阶段的判断依据:节点 allocatable 减去已分配出去的 requests 之和后,还能容纳新 Pod 的 requests,这个节点才算可行。

两个资源的单位写法不同:

  • CPU:以「核」为单位,1 表示 1 个核,500m 表示 0.5 个核(m 即 millicore,毫核)。最小粒度是 1m。
  • 内存:以字节为单位,常用 Mi、Gi(1024 进制),也接受 M、G(1000 进制)和纯字节数。
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: app
image: nginx:1.27
resources:
requests:
cpu: 250m
memory: 256Mi

查看节点当前已被 requests 占用了多少:

kubectl describe node node-2
Allocated resources:
Resource Requests Limits
-------- -------- ------
cpu 1250m (62%) 2 (100%)
memory 1792Mi (45%) 3Gi (76%)
requests 不是预留,只是账

requests 只在调度时记账,并不会在节点上真的「圈出」一块资源锁给这个 Pod。如果节点上所有容器的实际用量加起来超过物理资源,照样会竞争、会驱逐。它只是让调度器按账面数字做摆放决策。

Limit​

limits 是容器运行期的资源上限,由节点上的 kubelet / cgroup 强制执行。两类资源超限后的命运完全不同:

资源类型超 limit 的后果说明
CPU可压缩(compressible)被限速(throttling)进程不会被杀,只是 cgroup 把 CPU 时间片压回限额内,表现为延迟升高、CPU Throttled 指标上涨
内存不可压缩(incompressible)OOMKilled内存没法「限速」,超限后内核 OOM Killer 直接杀进程,容器退出码 137,按 restartPolicy 重启

一个同时带 requests 和 limits 的例子:

resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
只配 request 不配 limit 的争议

社区长期有两种声音:

  • 不设 CPU limit:CPU 限速会带来隐蔽的延迟毛刺,尤其是限流严重的服务;只设 requests 让空闲核自由借用,利用率更高。Tim Hockin 等 Kubernetes 核心成员也公开推荐过这种配法。
  • 必须设 limit:不设 limit 的容器在节点上等于「裸奔」,一个进程跑飞就会挤爆同节点其他 Pod 的账面资源,干扰排查。

实践上比较稳妥的折中是:CPU 只设 requests 不设 limits,内存 requests 与 limits 都设且相等(让内存变成强约束,避免不可预期的 OOMKill,同时拿到 Guaranteed QoS)。团队有 limitrange 强制限额要求时,再按规范补齐。

QoS​

kubelet 根据每个 Pod 全部容器的 requests/limits 配置,算出三个 QoS 等级,资源紧张时按等级决定处置顺序:

QoS 等级判定规则典型场景
GuaranteedPod 内每个容器都同时设置了 CPU 和内存的 requests 与 limits,且 request 等于 limit核心在线服务、有状态服务
Burstable至少一个容器设了 requests 或 limits,但不满足 Guaranteed 条件普通业务默认落在这档
BestEffortPod 内所有容器都没设任何 requests 和 limits批处理、临时调试容器

驱逐(Eviction)时的淘汰顺序是:BestEffort 先死,然后是实际用量超出 requests 最多的 Burstable,Guaranteed 最后。内存 OOM 场景下,OOM Killer 的打分也遵循类似倾向——用量相对 requests 超出越多的容器越容易被选中。

查看 Pod 的 QoS 等级:

kubectl get pod web-7d9c4f5b6-x2abc -o jsonpath='{.status.qosClass}'
Burstable

PriorityClass​

PriorityClass 是集群级对象,给 Pod 一个全局优先级数值(int32)。value 越大优先级越高,调度器会先安顿高优先级 Pod,资源不够时还可以把低优先级 Pod 抢占(Preemption)掉腾出位置。

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: high-priority-online
value: 1000000
preemptionPolicy: PreemptLowerPriority
globalDefault: false
description: "在线核心业务"

Pod 通过 priorityClassName 引用:

spec:
priorityClassName: high-priority-online
containers:
- name: app
image: nginx:1.27

关键点:

  • preemptionPolicy 默认为 PreemptLowerPriority(允许抢占低优先级 Pod);设为 Never 则只享受排队优先,不踢人,适合「重要但不想误伤别人」的离线任务。
  • 抢占发生时,被牺牲的低优先级 Pod 会收到优雅终止信号,给足 terminationGracePeriodSeconds 后再强杀。
  • Kubernetes 自带两个系统级 PriorityClass:system-cluster-critical 和 system-node-critical,value 分别是 2000001000 和 2000000000,留给 CoreDNS、kube-proxy 这类集群组件。业务 PriorityClass 的 value 不要超过它们,否则可能把系统组件挤下线。
优先级与 QoS 是两套独立机制

PriorityClass 影响调度与抢占(谁先有节点可用),QoS 影响节点资源紧张时的驱逐(谁先被赶)。生产上两者要配合设计:高优先级 Pod 通常也配成 Guaranteed,避免出现「调度时很横、驱逐时先死」的尴尬组合。

NodeSelector​

nodeSelector 是最简单的定向调度手段:Pod 声明一组键值对,只有带着完全相同标签的节点才算可行节点。

先给节点打标签:

kubectl label node node-2 disktype=ssd

然后在 Pod 里指定:

spec:
nodeSelector:
disktype: ssd
containers:
- name: app
image: nginx:1.27

多个键值对之间是 AND 关系,全部满足才可调度。nodeSelector 够用但表达能力弱:只能做精确等值匹配,写不出「ssd 或 nvme」「不要某类节点」这种条件,那就该上 NodeAffinity 了。

NodeAffinity​

NodeAffinity 是 nodeSelector 的增强版,按约束强度分两种:

类型含义调度行为
requiredDuringSchedulingIgnoredDuringExecution硬约束不满足就 Pending;节点标签后来变了也不影响已运行的 Pod(IgnoredDuringExecution)
preferredDuringSchedulingIgnoredDuringExecution软约束尽量满足,满足不了也能调度;通过 weight(1-100)参与 Score 阶段打分

匹配条件用 matchExpressions 表达,操作符支持 In、NotIn、Exists、DoesNotExist、Gt、Lt:

spec:
affinity:
nodeAffinity:
# 硬约束:必须落在 SSD 节点
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: disktype
operator: In
values:
- ssd
- nvme
# 软约束:尽量落在 cn-hangzhou 区域
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80
preference:
matchExpressions:
- key: topology.kubernetes.io/region
operator: In
values:
- cn-hangzhou
containers:
- name: app
image: nginx:1.27

注意 nodeSelectorTerms 数组之间是 OR 关系,同一个 term 内的多个 matchExpressions 是 AND 关系。

PodAffinity​

PodAffinity / PodAntiAffinity 让调度决策参考其他 Pod 的位置:「跟某些 Pod 待在一起」或「离某些 Pod 远一点」。

理解它关键是 topologyKey:它是节点标签的 key,定义了「同一个拓扑域」的边界。比如 topologyKey: kubernetes.io/hostname 表示「同一台节点」,topologyKey: topology.kubernetes.io/zone 表示「同一个可用区」。亲和条件里的 labelSelector 选中一批参照 Pod,调度器再按 topologyKey 判断候选节点与参照 Pod 是否同域。

示例一:同机部署(PodAffinity)——让缓存紧贴应用,减少网络开销:

spec:
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: web
topologyKey: kubernetes.io/hostname
containers:
- name: cache
image: redis:7

示例二:反亲和打散(PodAntiAffinity)——同一应用的副本不许落在同一台节点,防止单点故障一锅端:

spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: web
topologyKey: kubernetes.io/hostname
containers:
- name: app
image: nginx:1.27
反亲和硬约束的坑

required 级反亲和在副本数超过可用拓扑域数量时会让多出的副本永远 Pending(比如 3 副本应用只有 2 台节点,都用硬反亲和按 hostname 打散)。生产上更常用 preferred 软约束,或者直接用后文的 TopologySpreadConstraints——它是专为打散设计的,行为更可控。

Taints​

亲和性是 Pod「主动选节点」,污点(Taint)则相反,是节点「主动拒绝 Pod」。只有带了对应容忍(Toleration)的 Pod 才能调度上来,用于节点专用(GPU 池、入口节点)、故障隔离等场景。

三种效果(effect):

Effect行为
NoSchedule不容忍的 Pod 不会被调度到该节点,已在运行的 Pod 不受影响
PreferNoSchedule软版本的 NoSchedule,尽量不调度,实在没地方去也能上来
NoExecute不容忍的 Pod 不仅不能调度,已在运行的也会被驱逐

给节点打污点和移除污点:

kubectl taint node node-3 dedicated=gpu:NoSchedule
kubectl taint node node-3 dedicated=gpu:NoSchedule-

控制平面节点默认带污点,所以业务 Pod 不会跑到 master 上:

kubectl describe node master-1 | grep Taints
Taints: node-role.kubernetes.io/control-plane:NoSchedule

Tolerations​

Toleration 写在 Pod 里,表示「我能忍这个污点」。字段与污点一一对应:

spec:
tolerations:
- key: dedicated
operator: Equal
value: gpu
effect: NoSchedule
containers:
- name: train
image: pytorch/pytorch:2.4.0-cuda12.1-cudnn9-runtime

写法上的细节:

  • operator: Equal 要求 key、value、effect 全部匹配;operator: Exists 只匹配 key(和 effect,若指定),不写 value,一条容忍覆盖该 key 的所有污点。
  • effect 留空表示容忍该 key 的所有效果。
  • 配合 NoExecute 可以再加 tolerationSeconds:节点被打上 NoExecute 污点后,Pod 还能宽限存活多少秒才被驱逐,超时后照样赶走。 DaemonSet 的系统组件常靠这个字段在节点短暂 NotReady 时保持稳定。
tolerations:
- key: node.kubernetes.io/not-ready
operator: Exists
effect: NoExecute
tolerationSeconds: 300

上面这条就是 Kubernetes 自动给 Pod 注入的默认容忍之一:节点失联后给 5 分钟宽限,再决定是否把 Pod 驱逐重建。

TopologySpreadConstraints​

拓扑打散约束是比 PodAntiAffinity 更灵活的「均匀分布」工具,核心字段三个:

字段含义
maxSkew允许的最大不均衡度:任意两个拓扑域中,匹配 Pod 数量之差不能超过它,最小为 1
topologyKey拓扑域边界,同 PodAffinity,常用 zone、hostname
whenUnsatisfiable无法满足时的策略:DoNotSchedule(硬约束,宁可 Pending)或 ScheduleAnyway(软约束,尽力打散)

跨可用区打散的典型示例——3 个 AZ 各放尽量均等的副本:

spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: web
containers:
- name: app
image: nginx:1.27

maxSkew: 1 + 3 个 zone + 6 副本,最终每个 zone 恰好 2 个 Pod;如果某 zone 挂了,剩余副本宁可 Pending 也不会全挤进一个 zone(DoNotSchedule)。想在故障时也能调度,就换成 ScheduleAnyway,或再加一条按 hostname 的软打散兜底。

与反亲和怎么选

只要「同一应用的副本均匀铺开」这一个诉求,优先用 TopologySpreadConstraints:它按计数控制倾斜度,副本数超过拓扑域数量时也能正确分布,而 PodAntiAffinity 的 required 模式会直接卡死。反亲和留给「两类不同 Pod 必须分开」这类跨应用的场景。

Eviction​

「驱逐」这个词在 Kubernetes 里其实有两套机制,触发方和行为完全不同,别混为一谈:

1. kubelet 资源压力驱逐(节点自保)

节点内存、磁盘吃紧时,kubelet 不等 API Server 指令,直接按阈值驱逐本地 Pod 自救。常用信号与默认硬阈值:

驱逐信号含义默认硬阈值
memory.available节点可用内存小于 100Mi
nodefs.available根文件系统(容器镜像与可写层所在盘)可用比例小于 10%
imagefs.available镜像存储独立文件系统可用比例小于 15%
  • 硬阈值(eviction-hard):一旦越过立即驱逐,没有缓冲。
  • 软阈值(eviction-soft):越过后进入宽限期(eviction-soft-grace-period,如 memory.available=1.5Gi 配 30s),持续超阈值满宽限期才驱逐,给节点自我恢复的机会。

驱逐顺序按 QoS 与「实际用量超出 requests 的幅度」排队,BestEffort 先死。被驱逐的 Pod 状态变为 Failed,reason 是 Evicted,需要控制器重建。

2. API 驱逐(主动驱逐 / Pod Disruption)

kubectl drain 节点维护时用的就是这套:drain 对每个 Pod 调用 Eviction API,而不是直接 delete。走 API 的好处是会尊重 PodDisruptionBudget(PDB)——PDB 规定某应用至少保留多少可用副本,驱逐请求若会击穿底线就被拒绝,drain 阻塞等待。

两者的区别一张表总结:

维度kubelet 压力驱逐API 驱逐(drain)
触发方节点 kubelet,本地自救管理员 / 控制器,经 API Server
是否尊重 PDB不尊重,命都快没了顾不上尊重,超预算会被拒绝
是否优雅终止只发 SIGTERM,宽限时间有限走完整优雅终止流程
Pod 终态Failed / Evicted正常删除,控制器补新副本

Cluster Autoscaler​

调度层面的最后一个闭环:当集群资源总量不够、Pod 怎么都调度不出去时,光靠调度器没用了,得加节点——这就是 Cluster Autoscaler(CA)的活。

工作原理围绕两个方向:

  • 扩容:Pod 长时间处于 Pending 且原因是「没有可行节点」(资源不足、污点、亲和性不满足现有节点),CA 判断某个节点组扩容出新节点后能安顿它,就调用云厂商 API 加机器。
  • 缩容:节点持续一段时间利用率偏低(默认 CPU/内存 requests 合计低于 50%)、且上面的 Pod 都能安全挪走(尊重 PDB、不驱逐 kube-system 关键组件、无本地存储的 Pod 等),CA 把 Pod 驱逐到别处后回收节点。

Cluster Autoscaler 扩容流程

CA 本身不造机器,它依赖云厂商的节点组接口:AWS 的 Auto Scaling Group / EKS Managed Node Group、阿里云的 ACK 节点池与弹性伸缩、GCP 的 MIG 等,每个节点组配置 min/max 节点数,CA 在区间内调整期望值。

裸机集群用不了 CA

CA 的全部能力建立在「调 API 就能变出一台装好 kubelet 的机器」上,裸金属、私有云没有这套接口时 CA 无能为力。可以关注的替代方向:Karpenter(新一代自动扩缩器,绕开节点组、直接按 Pending Pod 的画像挑实例规格,目前主要支持 AWS,也有 Azure 等实现);裸机环境则更多靠规划容量 + 定时伸缩,或对接自有 CMDB 写自定义 provisioner。

至此,从「Pod 怎么选节点」到「集群怎么长节点」的整条链路就闭环了。调度器做摆放、requests/QoS 定待遇、亲和污点定规则、驱逐机制保底、自动扩缩管总量,生产环境的稳定性大半建立在这套组合的正确配置上。