1. 算力中心的“超级调度员”到底在调度什么一台算力中心如果只堆机器不调度就像一家餐厅同时涌入几百号客人却没有领位员——有人挤在门口有人占着空桌后厨还闲着。Kubernetes大家通常叫它K8s就是那个领位员更准确点说是算力中心的“超级调度员”。它每天要回答的问题永远只有一个手里这批计算任务到底该放到哪台机器上跑才能又快、又省、还能稳。这篇文章我从调度这个角度切入把K8s的核心原理拆开讲适合刚入门Kubernetes的人也适合正在搭算力平台、想搞懂调度器到底在做什么的运维和开发。这里说的“算力中心”不完全等于大型数据中心也可以是一间机房里几台GPU服务器、公司内部一套混合云资源池甚至你电脑上用Docker跑起来的一组容器环境。只要有若干台机器组成可复用的计算资源就会碰到同一个问题任务来了怎么分配。传统做法是人肉分配或者用脚本按固定规则派单但机器规模一上去节点之间的CPU、内存、GPU、磁盘差异一拉开手工就彻底玩不转了。K8s把所有机器抽象成“节点”把所有要被运行的业务抽象成“Pod”调度器专门负责把Pod送到最合适的节点上。1.1 没有调度员之前是什么样早年间我们搞服务部署最常见的方式是把应用和机器绑死这台跑Web那台跑数据库再留一台给定时任务。看似清晰实际问题很多。比如跑数据分析任务的机器CPU整天满载旁边Web服务器却闲得发慌某个节点磁盘告警业务迁移要重新改配置、重启进程中间还会出现服务不可用。就算上了负载均衡器解决的也只是“外部流量该转发给谁”对节点内部的CPU、内存、GPU这类计算资源的全局分配仍然无能为力。后来大家开始写自动化脚本用类似“轮询”“按空闲内存排序”的方式挑机器。这类脚本在几十台机器范围内勉强能用一旦遇到节点标签、资源配额、故障自愈、批量任务优先级这些复杂条件就变得又长又脆。而且脚本本身没有状态任务中途宕机了没人负责接手。K8s这一层调度本质上是把“给任务找机器”这件事从人工决策变成了一套可以声明、可扩展、可自愈的通用机制。1.2 K8s出现后解决的四个问题调度员这个角色解决了四个以前特别头疼的问题。第一是资源视图统一所有节点的CPU、内存、GPU、磁盘都汇入同一个资源池对外只暴露“还剩多少可用”没人关心具体落在哪台机器。第二是声明式运行你写清楚“我要3个副本、每个需要1核2G”K8s持续对比当前状态和期望状态多了就回收少了就补齐。第三是故障自愈节点突然宕机调度器会把上面的Pod自动挪到健康节点不需要人半夜爬起来操作。第四是公平分配通过request、limit、namespace配额保证一个租户或一个应用不会把整池资源吃光。这四个能力叠加起来K8s就不再只是容器编排工具而是算力中心真正的“超级调度员”。理解了这一点再去看它的组件和工作原理就不会被一堆名词绕晕。2. 核心组件拆解调度员的眼、手、脑和记录本要搞懂K8s怎么调度得先认识它的几个核心组件。我习惯把它们类比成一家调度中心里不同角色API Server是前台接线员etcd是工作记录本kube-scheduler是真正做排班的大脑controller-manager是监工kubelet则是派到各节点的班组长。各司其职缺一个都会出乱子。2.1 控制面板API Server与etcd你在命令行敲的每一条kubectl命令本质上都是发给API Server的一个请求。API Server是集群里唯一对外提供操作入口的组件所有创建、修改、删除动作都必须经过它。它负责认证你要不要用这个集群校验你提交的配置对不对然后把结果写进etcd。etcd是一个分布式键值数据库也是整个集群的“唯一事实来源”谁在哪个节点上、哪些节点在线、资源水位多少全部记录在etcd里。这两个组件的关系就像餐厅的迎宾台和订座本。客人来了迎宾台登记信息订座本记住哪桌坐了谁。如果订座本丢了调度员就什么都不知道所以etcd一定要做备份和节点冗余。很多人在生产环境里遇到脑裂或者写超时问题最后都出在etcd这里。上生产集群时务必单独规划etcd的磁盘性能和网络延迟千万别把它和业务服务混装在高负载机器上。2.2 大脑kube-schedulerkube-scheduler是本文真正的主角。它独立以一个Pod的形式运行在控制面节点上模式是“死循环监听”一旦发现API Server里出现一个还没有节点归属的Pod就进入调度流程给这个Pod选出最优节点然后更新它的nodeName字段。注意它只负责做决策不负责真正启动容器。好比调度员在排班表上写了“张三去三号停车位”真正把车停进去的是停车场的执行人员。kube-scheduler的选节点逻辑不是一拍脑袋定的而是经过一整套可插拔的调度算法。默认情况下它先做预选把不合格节点筛掉再做优选给每个候选节点打分最后选分数最高的。后续章节我会把这一过程展开。这里只需要记住K8s的调度的“大脑”不是黑盒你可以通过调度框架在特定节点干预它的决策这正是很多算力中心做个性化调度自定义的入口。2.3 手臂controller-manager与kubelet光靠调度员排班是不够的还得有人保证排班结果不会“走样”。controller-manager是一组控制器的集合里面最常打交道的是ReplicaSet控制器它负责持续检查当前Pod副本数对不对。如果业务声明要3个副本而当前只剩2个它会自动创建第3个Pod对象。这个新Pod又被调度器发现、绑定节点形成一个闭环。监听靠的是“控制循环”也就是每隔几秒或收到事件时拿期望状态和实际状态对比一次。kubelet则运行在每个节点上它是调度决策的最终执行者。kubelet看到某个Pod的nodeName指向自己后会调用容器运行时把Pod里的容器拉起来还会定期上报节点上的CPU、内存、磁盘使用情况并执行探针检查。如果容器反复崩溃kubelet会按重启策略处理。简单说controller-manager盯着全局副本kubelet盯着单机容器中间由调度员把任务派下去。2.4 组件之间怎么配合我们走一遍完整流程感受它们怎么衔接。运行kubectl apply -f deployment.yaml后请求先到API Server经过校验被写进etcdReplicaSet控制器发现想要的3个Pod还没齐全就创建3个Pod对象kube-scheduler看到这些Pod没有nodeName分别选出合适节点并写回绑定信息目标节点的kubelet监听到新Pod被派给自己立刻拉镜像、创建容器并把结果上报。整个过程通常几秒内完成这还不算镜像拉取时间。这个流程里最关键的是“写回到etcd”这种状态机制。每个组件都不直接命令另一个组件执行而是通过API Server读写公共状态互相观察、互相触发。好处是即使某个组件崩溃其他组件依然能感知状态变化继续工作集群有了很强的韧性。坏处是一旦API Server或etcd抖动整个调度链条都会等待这也是很多K8s故障最先体现在调度变慢的原因。3. 一次调度的完整过程预选、打分、落位理解了组件分工我们再走进调度器内部看看它收到一个待调度的Pod之后到底做了哪些事。整个过程可以简化为三个阶段预选、优选、绑定。很多文档会把这三个阶段叫成Filter、Score、Bind意思一样。3.1 预选先把不合格的机器剔除预选阶段的任务很粗暴把不满足硬性条件的节点全部排除。K8s逐个遍历当前集群里的节点检查下面几类条件。资源是否足够是最基本的检查调度器会把该节点上所有Pod声明的requests值累加再加上新Pod的request如果超出节点总容量就淘汰。注意这里不是看实时用量而是看声明的request。很多传统运维习惯盯着top看到的CPU使用率但K8s做资源判断用的是预留值也就是“至少需要多少资源”避免两个应用嘴上都说自己只要一点结果同时尖峰把机器打爆。除了资源还会检查端口是否冲突如果Pod声明了宿主机的某个端口而该节点的端口已被占用就不能调过去。节点亲和性和标签选择器也必须匹配比如Pod要求diskTypessd那机械盘节点直接出局。污点与容忍是另一道关卡节点打了gpuyes:NoSchedule污点Pod如果没有对应容忍就不能调度上去。数据卷相关条件也要考虑比如Pod挂载的PVC只能在某个可用区创建调度器会尽量把Pod放到同一区域。预选的结果是一个候选节点列表。如果列表为空Pod就进入Pending状态。生产环境里最常看到的Pending原因十有八九是资源不足或者节点标签不匹配后面排查部分会再细说。3.2 优选从候选者里挑最合适的节点预选只是“能不能放”优选解决“放哪最好”。kube-scheduler会对每个候选节点进行打分默认的评分插件里有几个经典选项。LeastRequested是其中最直接的它优先选择空闲资源最多的节点保证资源在整个集群里摊得更均匀避免热点。BalancedResourceAllocation则看CPU和内存的均衡度如果一个节点CPU很闲但内存已经快满了这个插件会适当下调它的分数因为一旦调度过去新任务可能推高内存压力。ImageLocality考虑的是目标节点是否已经存在所需镜像如果节点本地已缓存镜像能省去拉镜像时间会得到额外加分。每个插件会给节点打0到100分最后加权求和。默认情况下所有插件权重是1算力中心完全可以根据自己的场景调整权重比如希望优先把任务堆到少数节点以节省空转功耗就调高LeastRequested的权重或者直接用自定义评分插件。评分完成后调度器选出分数最高的节点如果有多个同分节点会采用随机方式打破平局。这里有个容易被忽略的点默认调度器偏向“分散”而不是“聚集”。如果你希望容器尽量集中在几台机器上原生调度器反而会跟你较劲。这也是为什么很多离线训练、大数据场景要做自定义调度策略。3.3 绑定与执行从决定到容器启动打分完毕调度器选定节点后需要把结果固化下来。它会通过API Server创建一个Binding对象并在Pod的spec.nodeName字段写入目标节点名。这一步就是“绑定”。Pod一旦绑定了节点调度器后续就不会再去管它除非Pod被删除或发生特殊原因需要重新调度。目标节点的kubelet看到nodeName指向自己后会走容器创建流程拉镜像、创建沙箱、启动容器、执行探针。整个阶段通常叫“调度执行”但严格来说已经不属于调度器职责。所以如果你在节点上用docker ps看到容器已经创建但Pod还没Ready多半是镜像拉取慢、探针没过或容器启动失败这时候排查重心要放到节点侧而不是调度器。3.4 调度器插件化v1.26后的Scheduling FrameworkK8s从1.19版本开始逐步引入了Scheduling Framework到了v1.26版本已经非常成熟。这套框架把调度流程拆成多个扩展点QueueSort、PreFilter、Filter、PostFilter、PreScore、Score、NormalizeScore、Reserve、Permit、PreBind、Bind、PostBind、Unreserve。我们不需要逐个背名字只要理解两点。第一每个扩展点都允许你通过插件插入自定义逻辑。比如想在打分结果上再加一个“优先调度到成本更低的节点”的权重可以在Score阶段写一个插件。第二我们平时用的默认调度器本身也是由一批插件组成的K8s官方把这些插件打包在kube-scheduler里你可以通过配置文件动态启用、禁用、调权重而不需要改代码。v1.26的kubeadm初始化输出里看到[init] using kubernetes version: v1.26.0时默认调度器配置文件一般放在/etc/kubernetes/scheduler/下真正生产的自定义调度策略可以通过修改这个配置实现。如果你正在建设自己的算力调度平台优先研究Scheduling Framework而不是直接改kube-scheduler源码会省力很多。改源码意味着每次版本升级都要重新适配而插件化方式可以跟着社区版本平滑升级。4. 动手观察超级调度员最小集群实操原理说再多不如亲手看一次调度过程。我建议你在一台有Docker环境的Linux机器上先跑一个最小集群用Kind或Minikube都行。Kind的优势是快一条命令就能把整个控制面和节点跑在容器里很适合做实验。这里我用Kind来演示。4.1 用Kind五分钟起一个本地集群先确认已安装好Docker然后执行kind create cluster --name demo --image kindest/node:v1.26.0这条命令会创建一块单节点的K8s集群节点本身运行在Docker容器里。版本指定为v1.26.0正好贴合很多人在kubeadm初始化日志里看到的版本信息。创建完成后检查一下集群状态kubectl cluster-info kubectl get nodes如果一切正常你会看到控制面和CoreDNS都处于Running状态。单节点环境里调度器还是会跑一次完整流程只不过它只能把Pod放到这唯一一个节点上。要做更有意思的调度实验建议用kind create cluster --config创建一个包含三个节点的集群一个控制面、两个工作节点。配置文件大概长这样kind: Cluster apiVersion: kind.x-k8s.io/v1alpha4 nodes: - role: control-plane - role: worker - role: worker保存为kind-config.yaml后执行kind create cluster --name demo --config kind-config.yaml等集群Ready用kubectl get nodes -o wide可以看到两个worker节点。接下来的实验就有意义了。4.2 部署一个Pod并观察调度结果创建一个最简单的DeploymentapiVersion: apps/v1 kind: Deployment metadata: name: demo-app spec: replicas: 3 selector: matchLabels: app: demo template: metadata: labels: app: demo spec: containers: - name: demo image: nginx:latest ports: - containerPort: 80保存为demo.yaml然后应用并观察kubectl apply -f demo.yaml kubectl get pods -o wide-o wide会多显示每个Pod被调度到的节点名。你会发现3个副本通常被分散到两个worker节点上这正是默认调度器在打分阶段“摊平资源”的效果。如果想看更细节的调度原因用kubectl describe pod查Pod事件末尾的Events部分会显示调度成功记录类似Successfully assigned demo/xx to kind-worker。4.3 通过资源请求影响调度行为默认情况下很多人的Demo配置不写resources字段K8s会把容器当作请求0资源。这时调度器更看重节点整体水位和镜像分布。我们给容器加上资源请求就能直观看到调度变化resources: requests: cpu: 500m memory: 512Mi limits: cpu: 1 memory: 1Gi这里出现的关键单位m是“毫核”的意思1000m等于1个CPU核心。500m就是半个核心。调度器在计算节点剩余容量时用的是所有Pod的request总和而不是实际CPU使用。比如一个worker节点总共有2核已经被3个各请求500m的Pod占满再来一个同样请求500m的新Pod调度器会认为节点没有足够CPU从而把这个节点排除。你可以在多节点集群里试试把replicas设为5每个容器请求700m两台2核的worker节点各自只能容纳2-3个Pod最终大概率会出现Pending Pod因为整个集群的可用request额度已经满了。打开事件记录FailedScheduling原因会写得很清楚Insufficient cpu。这里就明白为什么容器化应用一定要配置恰当的request而不是只配置limit。不写request调度器会当你不占资源等业务流量一来节点直接过载。生产环境里所有工作负载都应该设置request并且尽量接近真实水位留一点余量即可。4.4 使用nodeSelector与亲和性干预落位如果你希望某个业务只跑到特定机器最简单的办法是给节点打标签再用nodeSelector硬约束。比如给GPU节点打标签kubectl label node kind-worker gpu-typenvidia-a100然后在Pod模板里声明spec: nodeSelector: gpu-type: nvidia-a100这样调度器在预选阶段只会保留带该标签的节点。但nodeSelector是“必须满足”的关系如果只想“优先”不要“强制”就得用节点亲和性affinity: nodeAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 80 preference: matchExpressions: - key: gpu-type operator: In values: - nvidia-a100这段配置表示带nvidia-a100标签的节点在打分时会额外获得权重80但不带标签的节点也能调度只是分数低一些。实际算力平台里我更喜欢用亲和性而不是selector因为业务连续性往往比“必须落在某类机器”更重要。节点池一旦整体不可用强制约束会导致服务全部Pending反不如用偏好让调度器尽量满足同时保留弹性。污点和容忍也是控制调度的常用手段。给节点打上污点kubectl taint nodes kind-worker dedicatedmoney:NoSchedule没有对应容忍的Pod就不会调度到这个节点。只有你专门开启“容忍此污点”的Pod才能上去。这适合隔离高负载任务或预留独占机器。注意容忍不代表优先只表示“允许”。5. 从单集群到算力中心进阶调度玩法跑通了单集群基础调度我们再往“算力中心”维度看。算力中心的调度难点不只是把Pod放到某台机器那么简单还涉及异构算力、多租户、批量任务、跨集群等几个层面。5.1 GPU等异构资源的调度默认的K8s不认识GPU。它只能感知CPU和内存因为这两个资源直接由节点操作系统提供。GPU要纳入调度必须借助设备插件机制。以NVIDIA为例你需要在每个GPU节点上运行NVIDIA Device Plugin这个DaemonSet它会把GPU数量上报给kubelet然后节点资源里会出现类似nvidia.com/gpu: 4的可分配资源。业务Pod想用GPU只需要在resources里声明resources: limits: nvidia.com/gpu: 1注意GPU通常只写limits不写requests主要是NVIDIA插件设计如此它用的是limits字段做设备分配。调度器看到后在预选阶段就会筛选“可用GPU数量大于等于1”的节点。由于GPU资源不可拆分一旦某张卡被一个Pod占用另一个Pod就无法再申请这张卡。这里就会遇到一个常见问题GPU利用率不高因为一卡一任务粒度太粗。想要切分GPU需要上虚拟化或MIG方案也可以通过调度层面的“整卡聚合”或“显存共享”插件来做但这已经属于厂商定制能力了。5.2 多租户配额与算力计费算力中心怎么挣钱核心是把资源切成小块租给不同团队或客户并按用量计费。K8s原生支持namespace配额用ResourceQuota可以限制某个命名空间下CPU和内存的总request用LimitRange限制单个Pod的上下限。管理员为每个租户规划好配额调度器自然就能拦住超用。比如给“用户A”这个namespace设置总量apiVersion: v1 kind: ResourceQuota metadata: name: quota-a namespace: user-a spec: hard: requests.cpu: 16 requests.memory: 32Gi limits.cpu: 32 limits.memory: 64Gi用户A里的所有Pod合计声明的CPU请求总量不能超过16核。这个口径就是计费的基础因为调度器保证“至少预留了多少资源”而不是“实际用了多少”。企业内部分账也可以照这个口径做按request统计成本比按瞬时流量统计更稳定。5.3 批量任务与大模型训练调度原生K8s的Pod调度是“一个一个来”但对于一个大模型训练任务往往需要同时申请几十张GPU卡而且要求这些卡上的Pod全部调度成功后才开始运行否则就会浪费等待时间。这种需求叫“Gang Scheduling”原生kube-scheduler做不了需要用Volcano或Kueue这类扩展调度器。Volcano是CNCF项目支持队列、优先级、抢占以及Gang调度。Kueue则偏向“配额队列”适合把任务排到池子里等资源足够时再释放。它们和DolphinScheduler这类任务流调度有本质区别DolphinScheduler管的是“哪些任务按什么依赖关系触发”K8s调度器管的是“提交上来的容器跑到哪台机器”。理论上两者可以配合DolphinScheduler把任务一步步拆成Pod提交到K8sK8s负责具体资源落位。如果你的算力中心主要跑机器学习、渲染、数值仿真这类批量任务别直接在原生Job上硬扛。尽快引入批量调度器否则一旦任务多了排队和抢占策略会让你欲哭无泪。5.4 跨集群调度计算资源池的想象一台K8s集群能管的节点上限大概几千个再往上就要考虑多集群架构。跨集群调度通常有两种风格一是上层用Karmada这类平台维护多个K8s集群Pod会被分发到不同集群再由各集群内部的调度器决定节点二是用Cluster API做集群生命周期管理按需求动态创建或销毁集群然后借助负载调度器分流。跨集群的好处是可以把零散的机房资源、云资源抽象成一个“大算力池”坏处是运维复杂度指数级上升网络互通、存储复制、故障迁移处处是坑。我的建议是先用好单集群的调度能力把节点亲和、污点、配额、批量调度玩透了再考虑跨集群。不要为了概念去建大规模多集群调度问题不是集群越多越能解决。6. 调度出问题怎么办常见故障与排查实录调度器再聪明也会出问题。这里记录几种常见故障和排查思路都是实际环境里反复踩过的坑。6.1 Pod一直Pending卡在预选Pod一直无法调度到节点最常见的原因是资源不足。用kubectl describe pod pod-name查看事件如果看到FailedScheduling和Insufficient cpu/memory说明集群资源余额不够或者requests值设置得太高。排查时先看每个节点可分配资源kubectl describe nodes注意Allocated resources这一节展示的是当前所有Pod声明的request总量剩余量才是调度器眼里的空闲。如果节点明明CPU没跑满但调度器认为没有空间大概率是requests设置过高。这时可以调整Pod的requests或清理一些不重要的工作负载。还有一种情况是节点存在污点而Pod没有对应的容忍。事件里会写did not match taint。CPU和内存都看似足够但就是因为污点被卡住。确认方法是用kubectl describe node查看Taints字段再对比Pod的tolerations。多想想“预选”的几个条件逐项排查Pending原因一定能找到。6.2 资源足够但调度不散有些时候资源明明够新Pod却全部挤到一台节点上。可能原因有两个。一是那台节点的镜像已经被提前缓存ImageLocality插件给它加了很多分而其他节点从零拉镜像需要时间。二是某些Pod通过节点亲和性绑定偏好导致所有任务都优先落到同一批机器。遇到这类情况先看调度事件里有没有调度成功但节点分布不均匀的现象。如果想改变可以把默认调度器的score权重调整比如让LeastRequested的权重高于ImageLocality或者给业务加podAntiAffinity让同一应用的Pod尽量分散在不同节点。也可以用topologySpreadConstraints做拓扑分布约束强制Pod在节点、可用区之间均匀打散。6.3 看调度日志与事件调度日志是排查利器。控制面用kubeadm部署的集群里kube-scheduler以静态Pod运行在kube-system命名空间。查看日志的命令是kubectl logs -n kube-system kube-scheduler-your-node-name --tail100如果日志不够细可以在kube-scheduler的配置里增加--v4或更高等级重启后会输出更详细的调度评分记录。不过生产环境不建议长期开高日志级别会影响性能。很多时候光看事件就够了。kubectl describe pod最底部的Events可以回答“为什么Pending”和“为什么被调度到某节点”。在自动化运维里我常会用脚本定期抓取所有Pending Pod把FailedScheduling原因汇总再结合调度器日志判断是资源不足还是策略冲突。6.4 自定义调度器容易踩的坑不少团队尝试用自定义调度器替代或扩展默认调度。最容易踩的坑有三个。第一个是不保存好调度策略的兼容性。K8s社区版本更新很快调度框架的接口也在微调一旦升级集群你的插件代码可能编译不过生产环境只能停在旧版本。建议封装时尽量少调用内部函数多用公开扩展点。第二个是忘记处理抢占和抢占后的“后悔”机制。调度器决定把一个Pod放到某节点之后如果同节点的另一高优先级Pod突然需要使用同一份资源默认调度器可能会驱逐低优先级Pod自定义调度器如果不实现Unreserve和抢占回滚就会留下孤儿Pod或者双跑风险。第三个是只关心调度器忽略被调度Pod的Provider依赖。比如Pod依赖的存储卷只能在某个可用区自定义调度器必须匹配卷拓扑约束否则调度到别的区域后Pod永远起不来。由于调度器只管绑定kubelet启动失败或者卷挂载失败又会触发新的Pod生成形成恶性循环。所以自定义调度之前先把你业务里所有硬约束列全。7. 个人经验对调度这件事的三点体会做久了之后我越来越觉得K8s的“超级调度员”不是万能药。它解决了手工分配的低效和混乱但也把“决策质量”变成一个需要持续运维和调优的活。调度器不是配置一次就完事节点规格变化、业务流量模型变化、镜像缓存分布变化都会影响打分结果。你需要经常看节点水位定期清理不合理requests让调度器的决策依据“保持干净”。第二个体会是做算力中心也好做内部容器平台也好调度策略一定要比业务提前一步规划。不要等业务跑起来才发现GPU没法调度或者租户配额超卖导致互相抢占。宁可先少接入几个业务把调度框架、配额模型、监控告警这些底座打好后面业务扩张会顺利很多。最后一个小技巧刚开始排查调度问题时别急着看高级特性。先看Pod事件再看节点资源再看调度日志按这个顺序走下来80%的问题都能定位。真正难搞的往往是跨存储、跨网络、跨集群那类复杂场景那已经不是“调度器”单点能背的锅了。先把简单问题处理干净再逐步深入调度这条路会越走越顺。