1. 从“能跑就行”到“跑得更好”:为什么我们需要关注K8s调度
如果你刚开始接触Kubernetes,或者只是用它来跑几个简单的测试应用,那么你对“调度”这个词可能没什么感觉。毕竟,大部分时候,你写好一个Deployment的YAML文件,kubectl apply一下,Pod就神奇地在某个节点上跑起来了。这感觉就像把包裹扔进快递柜,至于快递公司怎么分拣、派送,你并不关心,只要最后能收到就行。在K8s的早期使用阶段,这种“能跑就行”的心态非常普遍。
但随着你的集群规模扩大,承载的业务越来越核心,这种“黑盒”操作带来的问题就会逐渐浮现。想象一下,你有一个计算密集型的AI推理服务,它被调度到了一个CPU资源已经捉襟见肘的节点上,导致推理延迟飙升;或者,你的一个核心数据库Pod,被调度到了一个网络不稳定、磁盘I/O性能很差的节点上,业务时不时就卡顿一下。更糟糕的是,如果这个节点突然宕机,K8s虽然会重新调度,但如果所有类似的“高需求”Pod都被集中到了少数几个节点上,那么一次节点故障就可能引发雪崩,而不是被优雅地化解。
这时你就会意识到,K8s的调度器(Scheduler)远不是一个简单的“快递分拣机”。它是一个集群的“大脑”和“指挥中心”,负责在数百甚至数千个节点中,为每一个新创建的、未调度的Pod,寻找一个最合适的“家”。这个决策过程的质量,直接决定了集群的资源利用率、应用性能、高可用性和运维成本。一个好的调度策略,能让集群像一支训练有素的交响乐团,每个“乐手”(Pod)都在最合适的位置,奏出和谐的乐章;而一个糟糕的或者默认的调度策略,则可能让集群变成一锅粥,资源浪费、性能瓶颈、单点故障层出不穷。
所以,深入理解K8s调度,不是为了炫技,而是从“能用”走向“用好”的必经之路。它关乎稳定性、成本与效率,是每一个K8s运维、开发乃至架构师都无法回避的核心课题。今天,我们就抛开那些简单的YAML,深入这个“大脑”的内部,看看它是如何思考、如何决策,以及我们如何引导它做出更聪明的选择。
2. 调度器的核心决策逻辑:从过滤到打分
K8s调度器为一个Pod选择节点,不是一个拍脑袋的决定,而是一个严谨的两阶段决策过程:过滤(Filtering)和打分(Scoring)。你可以把它想象成一场“非诚勿扰”:先灭掉所有明显不合适的灯(过滤),再为剩下的嘉宾逐一评分,选出最心动的那一个(打分)。
2.1 过滤阶段:硬性条件的筛选
过滤阶段,也称为Predicates(谓词)。调度器会检查集群中所有节点,判断它们是否满足Pod运行的一系列强制性要求。任何一个条件不满足,该节点就会被立即排除,没有商量余地。这确保了Pod的基本运行条件。
常见的过滤器包括:
- NodeName:如果Pod的
.spec.nodeName已经指定了,调度器会直接尝试绑定到这个节点(前提是该节点满足其他条件),这通常用于静态Pod或经过特殊处理的Pod。 - NodeAffinity/Anti-Affinity:节点亲和与反亲和。这是你主动表达意愿的地方。比如,你可以要求Pod必须(
requiredDuringSchedulingIgnoredDuringExecution)运行在带有disktype=ssd标签的节点上,或者尽量不要(preferredDuringSchedulingIgnoredDuringExecution)和带有app=redis标签的Pod在同一节点。 - PodAffinity/Anti-Affinity:Pod亲和与反亲和。这用于处理Pod之间的关系。例如,让前端Pod和后端Pod尽量靠近(亲和)以减少网络延迟;或者让同一个应用的两个实例分散到不同节点(反亲和)以实现高可用。
- Taints and Tolerations:污点和容忍度。这是节点主动拒绝Pod的机制。节点可以打上“污点”(Taint),比如
node.kubernetes.io/not-ready或dedicated=gpu:NoSchedule。只有声明了相应“容忍度”(Toleration)的Pod才能被调度上去。这是实现专用节点池(如GPU节点、内存数据库节点)的核心手段。 - Node Resources:节点资源。检查节点的可分配CPU、内存是否满足Pod的
requests需求。这是最基础的资源保障。如果Pod请求了2核CPU、4Gi内存,而节点只剩1核、2Gi,那么这个节点在过滤阶段就会被淘汰。 - Volume Zone:对于有区域概念的存储卷(如AWS EBS、GCE PD),调度器会确保Pod被调度到存储卷所在的可用区,避免跨区访问带来的高延迟。
- Pod Fits Host Ports:检查Pod声明的
hostPort在节点上是否已被占用。
注意:过滤阶段是并行检查所有节点的。一旦某个节点被任何一条过滤器淘汰,它就不会进入下一阶段。因此,定义清晰、准确的
requests、nodeSelector、tolerations是保证Pod能被成功调度的第一步。很多调度失败的问题,根源都在于过滤条件设置得太严或太松。
2.2 打分阶段:优中选优的权衡
通过了所有过滤器的节点们,就进入了“决赛圈”。打分阶段,也称为Priorities(优先级)。调度器会为每一个候选节点计算一个分数(0-100分),分数最高的节点就是最终的选择。如果多个节点分数相同,调度器会随机选择一个。
打分策略是一系列函数的组合,每个函数从不同维度给节点评分,最后加权求和。K8s内置了很多打分函数,其中最关键、最常用的是以下几个:
- LeastRequestedPriority:最少请求优先级。这是默认且权重很高的策略。它的逻辑非常直观:优先选择资源(CPU、内存)剩余最多的节点。公式大致是
(NodeFreeCPU / NodeAllocatableCPU + NodeFreeMemory / NodeAllocatableMemory) / 2 * 100。这能有效避免节点过载,实现资源的初步均衡。 - BalancedResourceAllocation:平衡资源分配。这个策略的目的是让节点的CPU和内存使用率保持平衡。它计算
(1 - |cpuUsage - memoryUsage|) * 100。如果一个节点CPU用了80%,内存只用了20%,那么它的这项得分会很低。这能防止出现“CPU爆满但内存空闲”或反之的失衡状态,提升节点整体资源利用率。 - NodeAffinityPriority:节点亲和性优先级。这个函数用于实现“软亲和”(
preferredDuringScheduling...)。如果你表达了“希望”Pod运行在某个标签的节点上,满足这个偏好的节点会获得更高的分数。 - InterPodAffinityPriority:Pod间亲和性优先级。同样,用于实现Pod间的软亲和与软反亲和规则。
- ImageLocalityPriority:镜像本地性优先级。如果节点上已经存在Pod所需的容器镜像,它会获得加分。这能加速Pod的启动过程,特别是对于镜像很大的应用。
- TaintTolerationPriority:污点容忍优先级。它根据Pod容忍的污点数量和程度来调整优先级,是一个较新的策略。
调度器的最终决策,就是这些打分函数加权计算后的结果。默认情况下,LeastRequestedPriority和BalancedResourceAllocation的权重很高,这体现了K8s调度默认的价值观:在保证基本运行条件(过滤)的前提下,优先追求集群资源的均衡与高效利用。
理解这两阶段模型,是进行一切高级调度配置和问题排查的基础。当你发现Pod调度不符合预期时,首先要问的就是:它是在过滤阶段就被淘汰了,还是在打分阶段输给了别的节点?
3. 超越默认:高级调度策略实战
默认调度器已经很强大,但对于复杂的生产场景,我们常常需要更精细的控制。K8s提供了一系列原生对象和机制,让我们能够“教”调度器更聪明地工作。
3.1 亲和性与反亲和性:表达你的“喜好”
亲和性(Affinity)规则是你与调度器沟通的主要语言。它分为节点亲和和Pod亲和/反亲和,每种又包含“硬性要求”(requiredDuringScheduling...)和“软性偏好”(preferredDuringScheduling...)。
实战案例1:将数据库实例分散在不同可用区假设你有一个三节点的Redis集群,你希望这三个实例尽可能部署在不同的物理机架或可用区(AZ),以防止单个机架断电导致整个集群不可用。
apiVersion: apps/v1 kind: StatefulSet metadata: name: redis-cluster spec: serviceName: redis replicas: 3 selector: matchLabels: app: redis template: metadata: labels: app: redis spec: affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: # 硬性反亲和 - labelSelector: matchExpressions: - key: app operator: In values: - redis topologyKey: kubernetes.io/hostname # 确保不在同一节点 preferredDuringSchedulingIgnoredDuringExecution: # 软性反亲和 - weight: 100 podAffinityTerm: labelSelector: matchExpressions: - key: app operator: In values: - redis topologyKey: topology.kubernetes.io/zone # 尽量不在同一可用区 containers: - name: redis image: redis:alpine这个配置实现了两层高可用:首先,通过requiredDuringScheduling...确保两个Redis Pod绝不会被调度到同一个节点(hostname)上。其次,通过preferredDuringScheduling...(权重100,很高),强烈建议它们也不要部署在同一个可用区(zone)内。只有当集群节点数量不足以满足硬反亲和时,调度才会失败。
实战心得:topologyKey是关键,它定义了“拓扑域”。hostname是节点级,zone是可用区级。你可以通过给节点打上自定义标签(如rack、machine-type)来定义自己的拓扑域,实现更灵活的调度策略,例如“避免在同一机架”。
3.2 污点与容忍度:节点的“准入控制”
污点(Taint)和容忍度(Toleration)提供了一种反向控制机制。节点可以“排斥”没有特定容忍度的Pod。
典型场景:创建专用GPU节点池
- 给GPU节点打上污点:
kubectl taint nodes gpu-node-1 dedicated=gpu:NoSchedule kubectl taint nodes gpu-node-2 dedicated=gpu:NoSchedule - 在需要GPU的Pod中声明容忍度:
apiVersion: v1 kind: Pod metadata: name: ai-training spec: tolerations: - key: "dedicated" operator: "Equal" value: "gpu" effect: "NoSchedule" containers: - name: trainer image: training:latest resources: limits: nvidia.com/gpu: 2
这样,只有明确声明容忍dedicated=gpu:NoSchedule的Pod(通常是AI训练任务)才能被调度到这些GPU节点上。其他普通业务Pod会被自动排除在外,保证了GPU资源的专有性。
关于effect的三种类型:
NoSchedule:新的不能调度上来,已存在的可以继续运行。PreferNoSchedule:软性排斥,调度器尽量避免,但不是强制。NoExecute:最严厉。不仅新Pod不能调度,已经运行在该节点上但没有对应容忍度的Pod,也会被驱逐。这常用于节点即将维护或出现故障时。
3.3 资源请求与限制:调度的基石与运行的护栏
requests和limits是调度中最基础也最容易出错的部分。
spec.containers[].resources.requests:这是调度依据。调度器在过滤阶段,只会考虑那些剩余资源大于等于Pod所有容器requests之和的节点。它决定了Pod能被放在哪里。spec.containers[].resources.limits:这是运行限制。它由kubelet在节点上强制执行,决定了Pod最多能用多少资源,防止单个Pod吃光节点资源。
一个常见的坑:只设limits,不设requests。K8s会默认将requests设置为与limits相同。这会导致Pod请求了过多的资源,虽然可能用不到,但会严重浪费节点的可调度容量,导致“资源碎片化”,其他Pod可能因为节点“看起来”资源不足而无法调度。
最佳实践建议:
- 始终设置
requests:并且requests应该基于应用的平均负载或基准测试来设定,而不是峰值。 limits可以略高于requests:为应用突发流量留出余地,但不要高得离谱。- 使用Vertical Pod Autoscaler:对于难以预估资源用量的应用,可以考虑使用VPA,让它自动分析历史负载并调整
requests和limits。
4. 调度器扩展与自定义调度
当原生调度器无法满足极度定制化的需求时,我们有两条进阶路径:调度器扩展和运行自定义调度器。
4.1 调度器扩展:增强而非替换
从Kubernetes v1.16开始,调度框架提供了一组插件化的API,允许你在调度周期的各个扩展点注入自定义逻辑。你不需要重写整个调度器,只需要实现特定的扩展点接口。
主要的扩展点包括:
QueueSort:定义Pod在调度队列中的排序优先级。PreFilter:在过滤之前对Pod或节点信息进行预处理或检查。Filter:相当于自定义的过滤规则。PostFilter:在所有过滤器执行完后、但未找到合适节点时被调用,可用于实现抢占等逻辑。PreScore:在打分前进行预处理。Score:实现自定义的打分规则。Reserve/Unreserve:在绑定前预留资源,或失败时释放资源。Permit:最后一道关卡,可以延迟或拒绝绑定。Bind:自定义绑定逻辑。
使用场景:比如,你想实现一个基于“节点真实负载”(而不仅仅是已分配资源)的打分策略,或者想根据Pod的“业务优先级”来调整调度顺序,就可以通过实现Score或QueueSort插件来完成。这需要一定的Go语言开发能力,并将插件编译进调度器。
4.2 自定义调度器:完全掌控
你可以直接部署一个自己编写的、完全独立的调度器。这个调度器与默认调度器(kube-scheduler)并行运行。Pod可以通过spec.schedulerName字段来指定由哪个调度器负责调度。
如何工作:
- 你编写一个程序,监听API Server中
spec.schedulerName为你指定名称(如my-custom-scheduler)且nodeName为空的Pod。 - 你的程序实现一套自己的过滤、打分逻辑(可以复用K8s客户端库)。
- 为选中的节点执行绑定操作:
PATCH /api/v1/namespaces/{namespace}/pods/{name}/binding。
实战案例:基于自定义指标的调度假设你的应用对磁盘IOPS非常敏感,而集群节点使用了不同类型的云盘(有的高IOPS,有的低)。默认调度器只关心CPU/内存。你可以写一个自定义调度器,它:
- 通过节点上的DaemonSet或监控系统,收集每个节点的实时平均IOPS指标,并作为注解(Annotation)记录在节点对象上。
- 你的调度器在过滤时,只选择IOPS高于某个阈值的节点。
- 在打分时,根据IOPS的剩余能力进行评分。
重要注意事项:
- 复杂性高:你需要处理所有边缘情况,如并发冲突、故障恢复等。
- 资源开销:多个调度器会同时监听API Server,增加其负载。
- 谨慎使用:只有在原生调度器及其扩展完全无法满足需求时才考虑此方案。大多数情况下,通过组合使用亲和性、污点、优先级/抢占,再加上调度器扩展,足以解决95%的问题。
5. 生产环境调度问题排查与优化实践
理论懂了,策略配了,但在生产环境中,调度问题依然会以各种诡异的形式出现。下面分享几个典型的排查思路和优化经验。
5.1 Pod一直处于Pending状态
这是最常见的调度问题。首先,查看Pod的描述信息:
kubectl describe pod <pod-name> -n <namespace>关注Events部分。这里通常会直接告诉你原因。
常见原因及排查步骤:
资源不足:
Insufficient cpu/memory。- 检查:
kubectl describe node <node-name>,查看Allocatable和Allocated resources。 - 解决:扩容节点、优化其他Pod的
requests、清理僵尸Pod、启用集群自动伸缩。
- 检查:
没有匹配的节点:
0/3 nodes are available: 3 node(s) didn't match Pod's node affinity/selector, 3 node(s) didn't match pod anti-affinity rules...- 检查:Pod的
nodeSelector、affinity规则是否过于严格?节点标签是否正确? - 检查:Pod反亲和性是否导致自己与自己冲突(当
replicas>1时)?确保topologyKey设置正确。
- 检查:Pod的
污点排斥:
0/3 nodes are available: 3 node(s) had taint {key:value}, that the pod didn't tolerate.- 检查:
kubectl describe node查看节点的Taints部分。 - 解决:为Pod添加对应的
tolerations,或者移除节点的污点(如果不必要)。
- 检查:
持久卷问题:
0/3 nodes are available: 3 node(s) had volume node affinity conflict.- 检查:Pod使用的PVC/PV是否绑定了特定节点或可用区?例如,AWS EBS卷是区域资源,Pod必须调度到该卷所在的可用区。
- 解决:使用支持跨区挂载的存储类(如CSI驱动配合拓扑感知),或者调整存储配置。
5.2 节点资源利用率不均
有的节点快满了,有的节点还很空。这通常是默认的LeastRequestedPriority策略在复杂场景下的局限性。
优化策略:
使用Descheduler重平衡:Descheduler是一个独立的工具,它可以驱逐那些已经运行但根据当前策略“放错了位置”的Pod,让它们被重新调度,从而达到平衡。
- 它可以配置策略,例如:
LowNodeUtilization(驱逐高负载节点上的Pod以降低其负载)、RemoveDuplicates(驱逐同一节点上同一控制器下的多个Pod,配合反亲和规则使用)。 - 注意:Descheduler是“事后纠正”,且驱逐Pod会有短暂的服务中断,需结合PDB(PodDisruptionBudget)谨慎使用。
- 它可以配置策略,例如:
精细化资源请求:很多团队图省事,给所有Pod设置相同且宽松的
requests(如cpu: 500m, memory: 1Gi),导致调度器对节点负载的判断严重失真。必须根据应用实际使用量精细化设置requests,这是所有高级调度策略生效的基础。利用Pod优先级和抢占:为关键业务Pod设置更高的
priorityClassName。当集群资源紧张时,调度器可以驱逐低优先级的Pod,为高优先级Pod腾出空间。这确保了关键业务的调度成功率,但需要一套清晰的优先级管理体系。
5.3 调度性能瓶颈
当集群节点数超过1000,Pod创建非常频繁时,默认调度器可能成为瓶颈,调度延迟显著增加。
排查与优化:
- 调度器 profiling:启用调度器的性能分析(
--profiling=true),使用pprof工具分析热点。 - 调整
percentageOfNodesToScore:这个参数控制打分阶段需要评估的节点百分比(默认50%)。对于超大集群,可以适当调低(如20%),牺牲一点点最优性来换取调度吞吐量。调度器会随机选择这部分节点进行打分。 - 简化调度规则:过于复杂的亲和/反亲和规则、大量的节点选择器,都会增加调度器的计算负担。评估规则的必要性,尽量简化。
- 考虑多调度器分片:对于超大规模集群,可以部署多个调度器实例,每个实例负责调度指定命名空间或特定标签的Pod,实现水平扩展。
调度是Kubernetes的灵魂,它从简单的资源匹配,演进为一套包含策略、约束、优先级和扩展性的复杂决策系统。理解它,不仅是为了解决问题,更是为了在设计应用和架构时,就能充分考虑“如何运行”,从而构建出更健壮、高效、成本可控的云原生系统。从被动地查看Pending原因,到主动地设计亲和性规则,再到前瞻性地规划节点池和污点策略,这是一个运维者走向平台设计者的思维转变。真正的精通,始于对每一个Pod“安家”过程的敬畏与掌控。