资讯动态

Kubernetes控制器与调度器:从声明到运行的完整链路

发布时间:2026/10/21 3:00:59 来源:尧图企业网站定制
1. 控制器与调度器先搞懂谁在干活很多刚接触Kubernetes的同学一上来就背概念ReplicaSet保证副本数、Deployment管理滚动更新、kube-scheduler负责调度。背得滚瓜烂熟真到排查问题的时候却一脸懵——Pod一直Pending到底是调度器的问题还是控制器的问题Deployment滚动更新卡住了一半该去看RS还是看Pod这就是典型的“只知道是什么不知道为什么”。我个人的理解方式是把整个调度和控制体系拆成两个层次来看。第一个层次是“决定做什么”。你在YAML里写replicas: 3写image: nginx:1.20写strategy: RollingUpdate——这些是期望状态是一份声明。谁来把声明变成现实是控制器。控制器是一类“监工”进程它通过API Server持续watch集群中资源对象的实际状态一旦发现实际状态和期望状态不一致就采取行动修正。比如ReplicaSet发现现在只有2个副本而期望是3个它就会去创建第3个。第二个层次是“决定怎么做”。当控制器决定要创建一个新的Pod它仅仅是把这个Pod对象写入etcd。真正决定这个Pod“落在哪台宿主机上”的是调度器kube-scheduler。调度器是一个独立的组件专门负责给新建的Pod找到一个最合适的节点。更准确地说调度器解决的是“bin packing”问题一大批待调度的Pod一堆备选节点怎么放最合理。把这两个层次分开之后很多现象就能对上了Pod一直Pending十有八九是调度器的问题节点资源不够、污点不匹配、节点不可调度Pod一直在CrashLoopBackOff那是控制器管不了的事得去查应用本身。ReplicaSet创建的Pod有3个但其中1个被手动删了RS会立刻补一个新的——这是控制器的“自我修复”能力在工作。这一篇我打算从ReplicaSet和Deployment这两个最常用的控制器入手结合调度器的使用细节把整个“从声明到运行”的链路串起来讲。标题叫“调度器的使用”但其实调度器很少被直接操作大多数时候我们是通过控制器间接地和它打交道。理解这一点比会敲几个命令重要得多。2. ReplicaSet一切副本控制的基石2.1 RS到底在守护什么先看一个最基础的ReplicaSet定义apiVersion: apps/v1 kind: ReplicaSet metadata: name: web-rs spec: replicas: 3 selector: matchLabels: app: web template: metadata: labels: app: web spec: containers: - name: nginx image: nginx:1.20 ports: - containerPort: 80注意看这个YAML的结构RS本身只做三件事声明要多少个副本replicas、声明要找哪些Podselector、声明新Pod长什么样template。它不关心Pod里跑的是什么业务也不关心Pod所在节点的状态它只管“数量对不对”。selector是最容易写错的部分。RS通过标签选择器圈定自己管理的Pod范围。只要一个Pod带有app: web这个标签RS就认为它是自己管的对象。如果这个Pod是你手动创建的但标签恰好匹配也会被RS收编——这个特性在排查问题的时候特别容易踩坑。我实际见过一次很有意思的故障某位同事手动创建了一个临时调试Pod为了省事直接复制了RS模板里的标签。结果RS发现副本数超了已有的3个副本还在跑然后又多出一个匹配标签的Pod直接把多余的那个Pod给杀了。临时Pod消失了调试到一半的现场也没了。这就是RS的工作机制——它不认“你是谁创建的”只看“你是否匹配我的选择器”。2.2 集合式选择器与matchExpressionsRS支持两种选择器写法第一种是matchLabels简单直观精确匹配第二种是matchExpressions支持集合运算。selector: matchExpressions: - key: app operator: In values: [web, api]常见的operator有四种In在集合内、NotIn不在集合内、Existskey存在、DoesNotExistkey不存在。这个在旧版的ReplicationController中是不支持的RC只能用等值选择器。所以如果你看到旧项目里还在用RC大概率是因为它的选择逻辑太死板升级到RS之后才能做更灵活的Pod圈定。实践中我用matchExpressions最多的场景是一个RS管理多个版本的应用Pod但标签前缀一致。比如environment in (dev, staging)然后搭配Deployment做金丝雀发布。这个用法Deployment是实现不了的因为Deployment本身也是靠RS来实现副本管理的它只有一个selector。2.3 扩缩容的正确姿势RS的扩缩容可以直接改YAML里的replicas然后kubectl apply也可以走命令行的快捷方式kubectl scale rs web-rs --replicas5这两条路最终的效果是一样的都是把RS的期望副本数从3改成5。但有一个细微的区别直接改YAML再apply会触发一次RS对象的整体更新如果你动了template里的字段还会因此创建新的Pod模板虽然RS不像Deployment那样做版本管理但它内部其实也有一个template hash机制来区分模板版本。快速扩容的时候RS会一次性创建2个新Pod吗实际不是。Kubernetes的Pod创建是有速率限制的默认大概每秒创建一批所以你会观察到Pod的状态是逐步从Pending变成Running的。这个细节在压测场景下比较重要——如果你的副本数一下子从3提到30Pod并不是瞬间全部就绪的而是分批启动每一批之间还有容器镜像拉取的时间。等全部Running之后还要看kubectl get pods里每个Pod的READY列是不是1/1。1/1表示容器起来了、就绪探针通过了这才是真正可以承受流量的状态。注意kubectl scale不会修改你磁盘上的YAML文件。如果你后续执行kubectl apply -f副本数会被重置回YAML里写的值。这是新手最容易困惑的一个点——怎么刚扩容完又缩回去了因为你用的是apply声明式操作它以文件为准。2.4 RS的“直接创建”与“间接管理”RS可以被单独使用也可以被Deployment间接管理。单独创建RS意味着你放弃了版本回滚、滚动更新这类高级能力换来的是一个纯粹简单的副本控制器。在生产环境直接用RS的场景其实不多绝大多数时候我们直接上Deployment。但理解RS依然是必须的因为Deployment的所有能力都是建立在RS之上的。你可以把Deployment想象成一个“领导”RS是“中层管理者”。领导发布一个新版本指令中层管理者换一批人执行——旧RS负责旧版本Pod的维护新RS负责新版本Pod的创建Deployment在中间协调两者的节奏。后面讲Deployment滚动更新的时候你会反复看到RS的影子。3. Deployment生产环境真正的主角3.1 Deployment包了一层什么Deployment解决了RS的三大痛点无法滚动更新、无法一键回滚、无法记录发布历史。它的YAML结构看起来比RS多了一个strategy字段apiVersion: apps/v1 kind: Deployment metadata: name: web-deploy namespace: default spec: replicas: 3 revisionHistoryLimit: 10 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 selector: matchLabels: app: web template: metadata: labels: app: web spec: containers: - name: nginx image: nginx:1.20 ports: - containerPort: 80核心差异就在strategy。RollingUpdate表示滚动更新更新过程中新老Pod共存。maxSurge: 1表示允许超出期望副本数的最大Pod数是1个maxUnavailable: 0表示在更新过程中不允许出现可用Pod低于期望数量的情况。这两个参数合起来的意思是最多多创建1个Pod但绝不允许少于3个可用Pod。3.2 滚动更新的底层流程执行一次镜像更新kubectl set image deployment/web-deploy nginxnginx:1.21或者改YAML里的image字段再kubectl apply。触发更新之后Deployment不会直接修改Pod的模板让它原地更新而是做这样一系列动作创建新版本RSweb-deploy-7c9d5f4b9replicas初始为0。逐步扩容新RS每扩一个同时缩一个旧RS。因为maxUnavailable: 0所以每批次先保证新Pod Ready之后再杀掉一个旧Pod。直到新RS的副本数变成3旧RS的副本数归0滚动更新完成。这里有一个关键概念Deployment的selector在创建之后就不能改了。因为一旦改动selectorDeployment就没法通过标签正确关联它历史版本的所有RS版本管理和回滚都会乱套。这在Kubernetes官方文档里是明确警告过的。滚动更新过程中你可以通过下面的命令观察动态kubectl rollout status deployment/web-deploy实时看到类似Waiting for deployment web-deploy rollout to finish: 1 out of 3 new replicas have been updated...的输出这个过程最直观地展示了Deployment如何调度新旧RS的副本分配。3.3 版本回滚与发布历史我经常跟团队同学说Deployment最值钱的功能不是滚动更新而是回滚。因为发布总会出问题回滚快慢决定故障时长。# 查看历史版本 kubectl rollout history deployment/web-deploy # 回滚到上一个版本 kubectl rollout undo deployment/web-deploy # 回滚到指定版本 kubectl rollout undo deployment/web-deploy --to-revision2回滚的本质不是“把Pod里的镜像改回去”而是“调整新旧RS的副本数”。Deployment会新建一个RS或者复用镜像版本对应的旧RS如果镜像没变然后反向执行一次滚动更新——从当前的新版本逐步替换回旧版本。revisionHistoryLimit: 10表示最多保留10个历史版本。这个值不要设得太大因为每个历史RS对象都会占API Server的内存太多版本会让kubectl get rs的输出变得极其冗长。但也不要设太小比如1那样你只能回滚到上一个版本出事之后连再往前一个版本都回不去。10是个合理的默认值有特殊需求再调整。3.4 暂停与恢复金丝雀发布的土办法Deployment还有一个不太起眼但实测很好用的能力kubectl rollout pause和kubectl rollout resume。# 暂停Deployment的滚动更新 kubectl rollout pause deployment/web-deploy暂停期间你对Deployment做的任何修改改镜像、改环境变量都不会触发更新。这个特性可以配合手工金丝雀发布先把新版本发布出去maxSurge和maxUnavailable都设成比较保守的值比如各1让新版Pod少量跑起来观察一段时间的日志和监控指标确认没问题再resume让滚动更新继续跑完。这不算完美的金丝雀方案因为没有流量权重分配只是“跑了一小批新版Pod”。但对于没有引入高级发布工具的团队来说原生Deployment配合pause/resume已经能挡住大多数发布事故。4. 调度器控制器决策之后谁来落地4.1 调度器的工作流程如果你在裸机上直接启动Pod不经过Deployment新建的Pod会进入Pending状态等待kube-scheduler给它分配节点。调度不是随机充数而是两阶段决策第一阶段是过滤Predicates。调度器从集群中筛选出所有满足Pod“硬性要求”的节点。比如Pod声明了resources.requests.cpu: 500m那CPU剩余不足500m的节点直接出局Pod声明了nodeSelector: disktypessd那不匹配标签的节点出局节点上有污点而Pod没有对应的容忍也出局。第二阶段是打分Priorities。通过过滤的节点进入评分环节从资源余量、Pod分布均匀度、亲和性等维度打分。得分最高的节点被选中。整个流程很快通常毫秒级完成。但理解这两个阶段的区别很重要——当你看到一个Pod长时间Pending首先要想的是它是被过滤掉了没有满足条件的节点还是打分不够高有条件但没被选中前者通常需要调整资源请求或者放宽节点约束后者一般不会发生因为调度器几乎总能选出最优解。4.2 节点亲和与反亲和让Pod去该去的地方Deployment里的Pod模板可以携带调度约束最常见的是nodeSelector和affinity。最简单的nodeSelectorspec: template: spec: nodeSelector: disktype: ssd这个之前已经提过了它是硬性约束Pod只跑在有这个标签的节点上。如果集群里没有节点打上disktype: ssd标签Pod就会一直Pending。nodeAffinity比nodeSelector灵活支持硬性要求和软性偏好spec: template: spec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: disktype operator: In values: [ssd] preferredDuringSchedulingIgnoredDuringExecution: - weight: 50 preference: matchExpressions: - key: zone operator: In values: [east]注意到这段表达式中有一长串奇怪的名称requiredDuringSchedulingIgnoredDuringExecution。翻译成人话就是调度时必须满足但Pod运行期间如果节点标签变了已运行的Pod不受影响。Kubernetes目前只有这一种实现未来的版本可能会有“运行期也强制”的模式。4.3 污点与容忍反向调度nodeSelector和nodeAffinity是“Pod主动挑选节点”污点Taint与容忍Toleration则是“节点主动排斥Pod”。给节点打污点kubectl taint nodes node1 appspecial:NoSchedule这条命令会让node1拒绝所有不带对应容忍的Pod。如果Pod需要调度到node1上必须在Pod模板中声明容忍spec: template: spec: tolerations: - key: app operator: Equal value: special effect: NoSchedule我常用的场景是分离计算密集型和IO密集型任务。给专用的GPU节点打上污点只有声明了容忍的AI训练Pod才能上去普通业务Pod不会误入。这是控制调度最直接的手段比nodeAffinity还好使因为它能防止其他Pod“误闯”而不只是“引导”你的Pod过去。4.4 请求与限制调度器看数字下菜碟调度器做过滤决策时看的不是节点的总资源而是“可分配资源”和“已请求资源”的差值。所以Pod里的resources.requests填多少直接影响调度成败。resources: requests: memory: 512Mi cpu: 500m limits: memory: 1Gi cpu: 1000m我见过不少生产问题根源是requests和limits填得过于悬殊。比如requests才64Milimits到了2Gi调度器认为这个Pod很轻量把它塞进了一个内存已经很紧张的节点。结果Pod一运行就触顶被OOMKilled重启之后又被打到同一台节点继续OOM形成恶性循环。反过来requests填太大也有问题。申请了2Gi但实际只用500Mi会让调度器认为节点资源不足导致其他Pod无法调度造成资源浪费。设置requests的黄金法则是观察一段时间内的真实用量取P95或者P99值而不是取均值也不是照抄镜像文档里的推荐值。5. 组合实战Deployment 调度约束的完整Demo说了一堆原理下面用一个完整例子把这几个角色串起来。5.1 准备集群环境这里我用的是一个三节点的模拟集群master节点控制平面 调度器、worker1节点、worker2节点。生产环境的master节点默认不调度业务Pod演示环境可以暂时不做限制。先给worker2打上SSD标签模拟异构节点kubectl label nodes worker2 disktypessd再给两个worker分别打上拓扑标签方便后续演示反亲和性kubectl label nodes worker1 zoneeast kubectl label nodes worker2 zonewest5.2 编写带调度约束的Deployment这个Deployment模拟的是一个计算任务服务需要跑在SSD节点上同时希望两个副本尽量不要落在同一个节点为了高可用apiVersion: apps/v1 kind: Deployment metadata: name: compute-deploy namespace: default spec: replicas: 2 revisionHistoryLimit: 5 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 selector: matchLabels: app: compute template: metadata: labels: app: compute spec: nodeSelector: disktype: ssd affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app: compute topologyKey: kubernetes.io/hostname containers: - name: compute image: registry.example.com/compute:1.2.0 resources: requests: cpu: 500m memory: 512Mi limits: cpu: 1000m memory: 1Gi readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 3 periodSeconds: 5这里有几个值得仔细看的点。nodeSelector让Pod只跑在disktypessd的节点上因此worker1直接被过滤掉只有worker2进入候选。podAntiAffinity要求同一个Deployment的Pod尽量这里是强制分散在不同的节点。topologyKey: kubernetes.io/hostname的意思是“以节点为维度进行反亲和”两个Pod不能落在同一个节点上。这两个约束放在一起再加上只有worker2满足SSD标签只有1个可用节点会发生什么创建出来的Pod会卡在Pending状态因为调度器找不到同时满足“SSD节点”和“不同节点”两个条件的两个位置。这就是控制器和调度器的“矛盾”场景Deployment声明要2个副本RS负责创建2个Pod但调度器过滤之后一个都塞不进去。两个Pod都会PendingRS会不停重试调度但始终失败。5.3 排查与解决看调度事件遇到这种情况第一件事不是删Deployment而是用kubectl describe pod看调度事件kubectl describe pod pod-name在Events区域你会看到类似这样的输出0/3 nodes are available: 1 node(s) didnt match Pods node affinity/selector, 2 node(s) didnt match Pods anti-affinity rules.这句话直接告诉你答案1个节点不满足nodeSelectorworker1没有SSD标签2个节点不满足反亲和规则因为只有worker2满足SSD而两个Pod不能同时放上去。解决问题有两个方向。如果确实想让两个副本都在SSD节点上跑那么应该放宽反亲和为“软性”把requiredDuringSchedulingIgnoredDuringExecution改成preferredDuringSchedulingIgnoredDuringExecution并设置weight。这样调度器会尽量分散但实在分不开时会允许落在同一节点。或者把副本数临时降到1至少先保证服务可用再逐步扩容。我在实际中比较推荐先缩到1保住可用性再评估约束条件是否需要调整。5.4 完整验证链路等Pod全部Running之后按顺序验证# 1. 查看Deployment状态 kubectl get deployment compute-deploy # 2. 查看RS情况 kubectl get rs -l appcompute # 3. 查看Pod分布 kubectl get pods -o wide # 4. 模拟滚动更新 kubectl set image deployment/compute-deploy computeregistry.example.com/compute:1.3.0 # 5. 观察滚动过程 kubectl rollout status deployment/compute-deploy # 6. 查看发布历史 kubectl rollout history deployment/compute-deploykubectl get pods -o wide会显示每个Pod所在的节点。你会看到Pod被调度到了SSD节点上和nodeSelector的预期一致。滚动更新时新RS创建的新Pod也会经过同样的调度过滤流程所以如果你的集群资源已经不足以同时容纳新旧两个版本的Pod滚动更新也会卡住。这也是生产环境经常出现的问题只预留了单版本副本数的资源量没有给maxSurge留出余量。6. 常见问题速查与避坑指南现象可能原因排查命令解决方向Pod一直Pending节点资源不足、nodeSelector不匹配、污点未容忍kubectl describe pod pod看Events里的过滤原因针对性调整调度约束Pod反复重启状态CrashLoopBackOff应用启动失败、探针失败、镜像不存在kubectl logs pod、kubectl describe pod修应用配置或镜像不是控制器的问题Deployment滚动更新卡住新Pod Ready失败、资源不足、maxUnavailable过严kubectl rollout status、kubectl describe rs检查新Pod的启动日志放宽maxUnavailable扩容后Pod数量没变修改了selector、apply时使用了旧YAMLkubectl get rs -o yaml确认selector和模板标签一致手动Pod被意外删除Pod标签匹配到了某个RS的selectorkubectl get pods --show-labels手动Pod使用独立的标签不要复用受管Pod的标签回滚后镜像没变回滚到了同一镜像版本、历史记录被清理kubectl rollout history检查revisionHistoryLimit回滚到正确版本6.1 “手动Pod被删”的详细排查过程这个坑值得单独拎出来说。某次我在一个测试集群里手动起了个调试Podkubectl run debug-pod --imagebusybox --labelsappweb结果几秒钟后这个Pod消失了。查了kubectl get events --sort-by.metadata.creationTimestamp发现是RS把它删了删除原因是“ReplicaSet does not own the pod but matched its selector”。这个事件信息很关键RS不“拥有”这个Pod不是它创建的但因为标签匹配它认为这是多余的副本于是执行了收缩。所以——手动创建的调试Pod标签一定要和工作负载的管理标签错开。比如统一加一个purposedebug标签确保不会被任何生产环境的selector匹配到。6.2 调度器和控制器协作的另一个坑还有一个我印象深刻的案例某团队把Deployment的副本数从2扩容到10结果只有4个Pod Running6个一直Pending。排查发现节点的可用CPU总量只有4个核心每个Pod的requests.cpu是1000m调度器一算只能再放4个剩下6个没地方去。这不是控制器出了问题而是调度器的过滤阶段把候选节点都毙了。这里反映了控制器世界的两个关键事实第一Deployment/RS只负责创建Pod并维持副本数它们不会去“协调”节点的资源第二调度器只负责为单个Pod选节点它不会因为“集群放不下”而反过来修改Deployment的副本数。最终你会发现扩容前先检查集群余量比扩容后发现Pending再处理要省事得多——这也是资源规划要走在扩容前面的原因。7. 一些实操体会多敲几次YAML、多观察几次Pod状态变化比看多少篇原理文章都管用。我个人的两个小习惯可以分享给你。第一个习惯每次写Deployment之前先在草稿纸上把selector、template的labels、podAntiAffinity的matchLabels单独列出来逐个确认它们之间的匹配关系。Kubernetes报错信息有时候模棱两可但selector不匹配这个问题大多数时候在apply阶段就会被API Server拦截报错信息会直接告诉你“selector does not match template labels”。如果apply过了但Pod起不来再用kubectl describe rs看左右两侧信息核对模板标签和选择器是否一致。第二个习惯生产集群上永远不要直接改RS的template字段。RS不像Deployment那样有历史版本管理一旦改了template导致Pod重建旧Pod的规格就找不回来了。所有变更都走Deployment让RS做底层干活的人。还有一个关于调度器的心得如果你对Pod的调度位置有特殊要求优先考虑污点容忍其次考虑nodeAffinity最后才考虑nodeSelector。因为nodeSelector太死板只支持等值匹配而实际生产环境的节点标签系统通常比较复杂用亲和性能表达的范围更广。而且污点容忍是从节点侧做“隔离”天然防止业务Pod误入专用节点效果比Pod侧的单向选择更可靠。这些控制器和调度器的配合关系看起来概念多但实际生产中用得最频繁的就上面这些。把RS的维护逻辑、Deployment的滚动更新流程、调度器的两阶段筛选吃透日常的Pod异常排查基本都能覆盖到。遇到实在没头绪的问题记住一条啊——先kubectl describe再kubectl get events。大多数答案都写在事件里只是你还没养成去看的习惯。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑