前言

在 Kubernetes 集群里,我们每天打交道的是 Pod、Deployment、Service 这些抽象,但有一个问题常常被后端开发者忽略:一个 Pod 从提交到真正跑起来,中间究竟经历了什么?官方文档在《Scheduling, Preemption and Eviction》一节中开宗明义地给出了三个定义:调度(scheduling)是指把 Pod 匹配到合适的节点上,让 kubelet 能够运行它;抢占(preemption)是指终止优先级较低的 Pod,好让优先级更高的 Pod 能够调度到节点上;驱逐(eviction)则是指在节点上终止一个或多个 Pod 的过程。这三个概念贯穿了 Pod 的整个生命周期,也是生产环境中最容易引发事故的环节。

对于习惯了"申请多少资源就拿到多少资源"的 Java 后端工程师来说,Kubernetes 的资源模型有一套截然不同的世界观:requests 决定调度,limits 决定上限,QoS 等级决定你被驱逐的顺序,优先级决定你能不能"挤掉"别人。线上常见的"Pod 一直 Pending""节点没满却调度不上去""大促扩容时 HPA 生效了但副本起不来"等问题,根因几乎都可以追溯到这一章的官方文档。

这篇文章采用"官方文档带读"的风格,沿着 kube-scheduler 的调度流程主线,逐节解读调度与资源管理的核心机制,并结合配置示例、参数对照和排查思路,帮助你建立一套完整的知识体系。文中所有 API 字段、配置项和行为描述均以当前版本官方文档为准。

整体架构与核心概念

先建立整体图景。kube-scheduler 是 Kubernetes 默认的调度器,作为控制平面(control plane)的一部分运行。官方文档对它的职责描述非常精确:调度器监视新创建、尚未分配节点的 Pod;对于每一个被发现的 Pod,调度器负责为它找到最合适的节点。如果没有任何节点满足条件,这个 Pod 会一直保持未调度(unscheduled)状态,直到调度器能够放置它为止。

满足 Pod 调度需求的节点称为可行节点(feasible nodes)。调度器先找出一组可行节点,再运行一组打分函数(scoring functions)对它们排名,选出得分最高的节点,最后把这个决定通知 API server,这个过程称为绑定(binding)。调度决策需要考虑的因素包括:单个与整体的资源需求、硬件/软件/策略约束、亲和与反亲和规则、数据本地性、工作负载间干扰等等。

                     +-------------------+
                     |   API Server      |
                     | (Pod 创建/绑定)    |
                     +----+---------+----+
                          |         ^
              watch 未调度 Pod   binding(写回 nodeName)
                          |         |
                     +----v---------+----+
                     |  kube-scheduler   |
                     |  (控制平面组件)    |
                     +----+---------+----+
                          |
        +-----------------+------------------+
        |                 |                  |
  1. Filtering      2. Scoring         3. Binding
  (过滤可行节点)    (打分排序)          (通知API Server)
        |                 |                  |
   资源/亲和/污点    优选策略加权         Pod -> Node
        |                 |                  |
+-------v--------+  +-----v-------+  +-------v--------+
| Node 1..N      |  | 得分最高者   |  | kubelet 拉取    |
| (feasible set) |  | (并列随机)   |  | 并运行 Pod      |
+----------------+  +-------------+  +----------------+

官方文档还指出了配置调度行为的两种方式:调度策略(Scheduling Policies)允许配置过滤用的谓词(Predicates)和打分用的优先级(Priorities);调度框架(Scheduling Framework)则允许配置实现不同调度阶段的插件(Plugins),包括 QueueSort、Filter、Score、Bind、Reserve、Permit 等阶段,并且可以让 kube-scheduler 同时运行多个调度配置文件(profiles)。对于普通使用者来说,理解"过滤 → 打分 → 绑定"这三步框架,就足够支撑日常绝大多数调度问题的分析。

kube-scheduler 调度流程:过滤、打分与绑定

官方文档把节点选择明确描述为一个两步操作:过滤(Filtering)与打分(Scoring)。

过滤阶段负责找出可以调度该 Pod 的节点集合。文档举的例子是 PodFitsResources 过滤器:它检查候选节点是否有足够的可用资源来满足 Pod 声明的资源请求(requests)。这一步做完之后,节点列表里剩下的都是合适节点,通常多于一个;如果列表为空,这个 Pod 当前就不可调度。这里的要点是:调度器只看 requests,不看 limits。你给一个容器设置 limits 为 4 核、requests 为 100 毫核,调度器只会按 100 毫核为这台 Pod 记账。这也是为什么"limits 设得很大"不会导致调度失败,但"requests 设得很离谱"会。

打分阶段对过滤后幸存的节点排名,依据当前激活的打分规则给每个节点一个分数,最终选出排名最高的节点。如果多个节点得分相同,kube-scheduler 会在它们之中随机选择一个。这一点对理解拓扑分布约束(Topology Spread Constraints)的 ScheduleAnyway 行为很重要:打分插件可以把"让分布更均衡"的倾向加进分数里,但软约束不保证结果。

绑定阶段,调度器把 Pod 指派给得分最高的节点,并通过 API server 持久化这个决定。在这之后,kubelet 才会真正介入,把容器拉起来。

另一个值得注意的官方细节:你可以通过 API 在创建 Pod 时直接指定 nodeName,但这属于特殊场景下的非常规做法。文档明确提醒,直接写 .spec.nodeName 会绕过调度器——即使目标节点上有 NoSchedule 污点,Pod 也会被绑定上去(但如果该节点同时还有 NoExecute 污点,kubelet 仍会把没有相应容忍的 Pod 踢出去)。

你现在应该掌握了什么:调度的完整链路是"过滤可行节点 → 打分排序 → 绑定写回";过滤的依据是 requests 而不是 limits;打分相同会随机选择;直接写 nodeName 会绕过调度器,属于应避免的非常规操作。

requests/limits 与 QoS 等级

资源管理是调度与驱逐共同的地基。官方文档在《Pod Quality of Service Classes》中说明:Kubernetes 根据 Pod 内容器的资源 requests 以及 requests 与 limits 的关系,为每个 Pod 赋予一个 QoS 等级。当节点资源不足时,Kubernetes 依据这个等级决定驱逐顺序:先驱逐 BestEffort,再驱逐 Burstable,最后才是 Guaranteed。而且由于资源压力引发的驱逐中,只有使用量超过 requests 的 Pod 才会成为驱逐候选。

三个等级的判定标准如下表所示:

QoS 等级 判定条件 驱逐顺序 资源保证
Guaranteed 每个容器都有 CPU/内存的 request 与 limit,且二者相等(均大于零) 最后被驱逐 最严格,不会超过 limit;可使用 static CPU 管理策略获得独占 CPU
Burstable 不满足 Guaranteed,但至少有一个容器声明了 CPU 或内存的 request 或 limit 中间 有下限保证,无 limit 时上限默认为节点容量
BestEffort 所有容器都没有声明 CPU/内存的 request 和 limit 最先被驱逐 只能使用其他等级未占用的剩余资源

一个典型的生产级 Java 服务配置长这样:

apiVersion: v1
kind: Pod
metadata:
  name: order-service
spec:
  containers:
  - name: app
    image: registry.example.com/order-service:1.8.0
    resources:
      requests:
        cpu: "500m"
        memory: "1Gi"
      limits:
        cpu: "2"
        memory: "2Gi"

注意这里 requests 与 limits 不相等,所以该 Pod 属于 Burstable:它有 500m CPU 和 1Gi 内存的"保底",但在节点空闲时最多可以冲到 2 核 2Gi;一旦节点内存吃紧,它比没有任何声明的 Pod 后死,但比 Guaranteed 的 Pod 先死。

官方文档还澄清了几个容易误解的点。第一,任何容器超过自己的资源 limit,都会被 kubelet 杀掉并重启,这不影响同 Pod 的其他容器,也与 QoS 等级无关。第二,Pod 的 request 等于其容器 request 之和,limit 同理。第三,非常关键:kube-scheduler 在选择抢占哪些 Pod 时并不考虑 QoS 等级——抢占只看优先级(Priority)。QoS 等级在 Pod 创建时确定,整个生命周期不变;如果原地调整资源(in-place resize)会导致 QoS 变化,admission 会直接拒绝。

在 cgroup v2 上,官方文档还介绍了 Memory QoS(v1.37 起为 Beta 且默认开启):通过 kubelet 配置项 memoryThrottlingFactor 设置 memory.high 实现节流,阈值公式为 requests + memoryThrottlingFactor × (limits - requests);通过 memoryReservationPolicy: TieredReservation 设置分层内存保护,Guaranteed Pod 的 request 部分被 memory.min 硬性锁定,Burstable 用 memory.low 软性保护,BestEffort 不设保护。这对跑 JVM 这种吃内存大户的服务很有意义,但文档也给出了警告:Guaranteed Pod 的 memory.min 等于 memory.max,如果工作负载 page cache 很大,内核可能在触及 memory.max 前回收不够缓存,反而导致 OOM,所以 limit 要留出足够的 headroom。

你现在应该掌握了什么:requests 决定调度记账与驱逐保护线,limits 决定硬上限与 OOM 边界;QoS 三级的判定规则要背下来;QoS 影响节点压力驱逐的顺序,但不影响调度器抢占的选择;cgroup v2 下还有 memory.high/memory.min 这一层内核级的 QoS 实现。

节点亲和、污点与容忍、拓扑分布约束

当默认的自动放置不满足需求时,官方文档《Assigning Pods to Nodes》给出了四种干预方式:nodeSelector、亲和与反亲和(affinity and anti-affinity)、nodeName、以及 Pod 拓扑分布约束。前三种是"吸引"逻辑,污点则是反方向的"排斥"逻辑。

nodeSelector 是最简单的节点选择约束:在 Pod spec 里声明目标节点必须具有的节点标签,Kubernetes 只会把 Pod 调度到同时具备这些标签的节点上。文档同时给了一个安全提醒:如果用标签做节点隔离,应选择 kubelet 无法修改的标签键,防止被攻陷的节点给自己打上标签"骗"来工作负载。NodeRestriction 准入插件会阻止 kubelet 设置或修改带 node-restriction.kubernetes.io/ 前缀的标签,配合 Node 授权器即可实现安全的节点隔离,例如 example.com.node-restriction.kubernetes.io/fips=true。

节点亲和(nodeAffinity)在概念上类似 nodeSelector,但表达能力更强,且支持软规则。官方定义了两种类型:requiredDuringSchedulingIgnoredDuringExecution 是硬要求,规则不满足就调度不了,功能等同 nodeSelector 但语法更灵活;preferredDuringSchedulingIgnoredDuringExecution 是软偏好,找不到匹配节点也照常用。字段名里的 IgnoredDuringExecution 意味着:Pod 运行之后节点标签再变化,Pod 不会因此被迁走。一个完整示例:

apiVersion: v1
kind: Pod
metadata:
  name: with-node-affinity
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: topology.kubernetes.io/zone
            operator: In
            values:
            - antarctica-east1
            - antarctica-west1
      preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 1
        preference:
          matchExpressions:
          - key: another-node-label-key
            operator: In
            values:
            - another-node-label-value
  containers:
  - name: with-node-affinity
    image: registry.k8s.io/pause:3.8

布尔逻辑需要特别注意:nodeSelectorTerms 里的多个 term 之间是"或"(满足其一即可),单个 matchExpressions 里的多个表达式之间是"且"(必须全部满足);operator 支持 In、NotIn、Exists、DoesNotExist、Gt、Lt,其中 NotIn 和 DoesNotExist 可以实现节点反亲和。软偏好可以带 1 到 100 的 weight,调度器会把节点满足的每条软规则的权重累加进该节点的总分。另外如果同时写了 nodeSelector 和 nodeAffinity,两者必须同时满足。

污点(Taints)与容忍(Tolerations)是另一套机制:节点亲和是 Pod 的属性、把 Pod"吸引"到一组节点上;污点则相反,让节点"排斥"一组 Pod。容忍加在 Pod 上,允许调度器把与污点匹配的 Pod 调度到该节点——但官方措辞很严谨:容忍允许调度,并不保证调度,调度器还会评估其他参数。打污点的命令很直接:

kubectl taint nodes node1 key1=value1:NoSchedule
# 移除污点(键值与效果相同,末尾加 -)
kubectl taint nodes node1 key1=value1:NoSchedule-

Pod 侧声明容忍时,operator 默认为 Equal;operator 为 Exists 时不写 value。三种效果(effect)的语义对照如下:

效果 对未调度 Pod 对已运行 Pod
NoSchedule 不容忍则不调度到该节点 不受影响
PreferNoSchedule 尽量避开,但不保证 不受影响
NoExecute 不容忍则不调度 不容忍的立即驱逐;容忍但设了 tolerationSeconds 的,到期后由节点生命周期控制器驱逐

一个经典的高可用写法是给 NoExecute 加 tolerationSeconds,实现"故障节点上的 Pod 多撑一会儿再迁走":

tolerations:
- key: "key1"
  operator: "Equal"
  value: "value1"
  effect: "NoExecute"
  tolerationSeconds: 3600

多个污点与多个容忍的组合逻辑像过滤器:从节点的全部污点出发,忽略掉 Pod 有匹配容忍的,剩下的未忽略污点才会对 Pod 生效。官方文档还给了一个三污点对两容忍的例子:Pod 因为第三个污点不容忍而无法调度到节点,但如果它已经在节点上运行,则不会被踢走——理解这个"过滤"模型,就不会对混合效果的行为感到意外。

拓扑分布约束(Topology Spread Constraints)解决的是跨故障域的均衡问题。官方场景很有代入感:两三副本时怕同节点故障打翻全部实例,扩容到十几副本后又希望各机房副本数大致相当以降低跨区延迟和流量费用。Pod API 的 spec.topologySpreadConstraints 字段为此提供了声明式手段:

apiVersion: v1
kind: Pod
metadata:
  name: example-pod
spec:
  topologySpreadConstraints:
  - maxSkew: 1
    minDomains: 2
    topologyKey: kubernetes.io/hostname
    whenUnsatisfiable: DoNotSchedule
    labelSelector:
      matchLabels:
        app: foo
    matchLabelKeys:
    - pod-template-hash
  containers:
  - name: app
    image: nginx

核心字段的含义:maxSkew 描述 Pod 允许分布的不均衡程度,DoNotSchedule 模式下它定义的是目标拓扑域中匹配 Pod 数与"全局最小值"之间的最大允许差值;whenUnsatisfiable 为 ScheduleAnyway 时则变成软约束,调度器优先选能减小 skew 的节点。topologyKey 是节点标签的键,相同取值的节点视为同一拓扑域。minDomains 限定最少合格域数量,域数不足时全局最小值按 0 计算。labelSelector 用于统计匹配 Pod,matchLabelKeys(v1.27 起 Beta)可以把 Deployment 自动加的 pod-template-hash 合并进选择器,避免滚动更新时新旧副本互相干扰——注意同一个键不允许同时出现在 matchLabelKeys 和 labelSelector 里。nodeAffinityPolicy 与 nodeTaintsPolicy(均已在近年版本进入 Beta/GA)分别控制计算 skew 时是否考虑 Pod 的节点亲和与节点污点。还有一个硬约束:同一个 topologyKey 和 whenUnsatisfiable 组合只能有一条约束。

你现在应该掌握了什么:吸引式约束(nodeSelector、nodeAffinity、pod 间亲和)与排斥式约束(污点/容忍)是两套互补的工具;硬亲和与软偏好的字段命名、AND/OR 组合规则、weight 机制要能默写;三种污点效果的差异和 tolerationSeconds 的用法是面试与排障高频点;拓扑分布约束用 maxSkew + topologyKey + whenUnsatisfiable 三个要素把"均衡"变成声明式配置。

驱逐与优先级抢占

官方文档把 Pod 的中断(disruption)定义为 Pod 在节点上被自愿或非自愿终止的过程:自愿中断由应用所有者或集群管理员主动发起,非自愿中断则是资源耗尽等不可避免问题或误删除导致的。围绕"谁有权终止 Pod",有两条主线:kubelet 发起的节点压力驱逐,和调度器主导的优先级抢占。

节点压力驱逐(Node-pressure Eviction)是 kubelet 主动终止 Pod 以回收节点资源的过程。kubelet 监控内存、磁盘空间、文件系统 inode 等资源,达到特定消耗水平时主动让 Pod 失败以回收资源、避免饿死。kubelet 使用的驱逐信号包括 memory.available、nodefs.available、nodefs.inodesFree、imagefs.available、imagefs.inodesFree、containerfs.available、containerfs.inodesFree、pid.available,其中 nodefs 是节点主文件系统(容纳 /var/lib/kubelet、日志、emptyDir 等),imagefs 和 containerfs 是容器运行时的镜像层与可写层所在文件系统,三个标识符在常见部署中可能指向同一个底层文件系统。阈值格式为 [信号][运算符][数量],例如 memory.available<100Mi、nodefs.available<10%。

这里有一个官方反复强调的区别:节点压力驱逐不等于 API 发起的驱逐。kubelet 节点压力驱逐不尊重 PodDisruptionBudget,也不尊重 Pod 的 terminationGracePeriodSeconds——软驱逐阈值场景下 kubelet 尊重 eviction-max-pod-grace-period,硬驱逐阈值场景下宽限期直接是 0 秒(立即终止)。而 API 发起的驱逐(API-initiated Eviction)是通过 Eviction API 创建 Eviction 对象触发优雅终止,kubectl drain 走的正是这条路;它会尊重 PodDisruptionBudget 和 terminationGracePeriodSeconds。API server 对驱逐请求的响应有三种:200 OK 表示允许;429 Too Many Requests 表示因 PodDisruptionBudget 限制暂时不允许,可以稍后重试;500 表示配置错误(例如多个 PDB 引用了同一个 Pod)。如果目标 Pod 不属于任何带 PDB 的工作负载,API server 总是返回 200。

优先级抢占(Pod Priority and Preemption)自 v1.14 起稳定。PriorityClass 是非命名空间对象,把名字映射到一个整数值,值越大优先级越高,取值范围是 -2147483648 到 1000000000(更大的数值保留给系统内置的关键 Pod)。集群自带两个 PriorityClass:system-cluster-critical(v1.37 中为 2000000000)与 system-node-critical(2000001000)。globalDefault 字段标记该 PriorityClass 作为未指定 priorityClassName 的 Pod 的默认优先级,全系统只能有一个;没有 globalDefault 时,未指定的 Pod 优先级为零。示例:

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: high-priority
value: 1000000
globalDefault: false
description: "This priority class should be used for XYZ service pods only."

抢占的逻辑是:调度器从队列取出待调度 Pod P,若找不到满足全部要求的节点,就为 P 触发抢占逻辑——寻找这样一个节点:移除该节点上一个或多个比 P 优先级低的 Pod 之后,P 就能调度上去。找到则驱逐那些低优先级 Pod,再调度 P。v1.24 起还支持非抢占式 PriorityClass:preemptionPolicy: Never 的 Pod 在调度队列中排在低优先级 Pod 之前,但不会抢占任何 Pod,一直等到资源自然释放——官方给出的典型用例是数据科学工作负载:希望优先排队,但不想挤掉正在运行的任务。注意非抢占 Pod 依然会被更高优先级的 Pod 抢占,也会受调度退避(back-off)影响。

官方同时给出安全警告:在并非所有用户都可信的集群里,恶意用户可以创建最高优先级的 Pod,导致其他 Pod 被驱逐或无法调度;管理员应使用 ResourceQuota 限制用户创建高优先级 Pod,具体做法是按默认限制 PriorityClass 消耗。

你现在应该掌握了什么:驱逐有三条路径——kubelet 的节点压力驱逐(不认 PDB、不认优雅期)、API 发起的驱逐(认 PDB、认优雅期,drain 用它)、调度器的优先级抢占(认 priority,不认 QoS);PriorityClass 的取值范围、内置两个系统类、globalDefault 唯一性、preemptionPolicy: Never 的排队语义,都是生产上会用到的硬知识。

资源碎片与装箱率

官方文档专门有一页《Resource Bin Packing for Extended Resources》,讲的是如何用 MostAllocated 打分策略对扩展资源(以及默认的 CPU/内存)做装箱调度。虽然该机制主要针对自定义的扩展资源,但它揭示了一个普遍问题:调度器默认倾向于把 Pod 分散到不同节点上(最少化资源碎片化只是众多打分插件中的一个侧面),结果集群可能出现"每个节点都剩一点资源、但没有任何节点能放下一个新 Pod"的碎片状态。

装箱策略的核心做法是让调度器偏好"能把节点填得更满"的选择。在调度配置文件中,可以通过 pluginConfig 为 NodeResourcesFit 插件设置 scoringStrategy 为 MostAllocated,让节点已分配资源比例越高的得分越高,从而把 Pod 尽量压到已有负载的节点上,腾出完整的空节点。一个示意性的调度器配置片段(以当前版本官方文档为准):

apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: bin-packing-scheduler
  pluginConfig:
  - name: NodeResourcesFit
    args:
      scoringStrategy:
        type: MostAllocated

与之配合的官方概念是节点可分配资源(Node Allocatable):kubelet 预留一部分资源给系统与 kubelet 自身,调度记账基于 allocatable 而非 capacity。评估集群装箱率时,应该用已分配 requests 之和除以 allocatable 之和,而不是除以 capacity。碎片问题的排查思路也很直接:kubectl describe node 看 Allocated resources 里 requests 的占比分布;如果各节点 requests 占比都不高但新 Pod 仍 Pending,重点检查是否有节点亲和、拓扑约束或污点把候选节点筛掉了——碎片只是候选原因之一,过滤失败才是更常见的根因。

你现在应该掌握了什么:调度器默认打分偏好分散,装箱需要显式切换打分策略;评估容量要用 allocatable 口径;所谓"碎片导致 Pending"必须先用 describe node 和调度失败事件排除亲和、污点、约束等过滤层面的原因。

HPA/VPA 与调度协同

扩缩容与调度是同一个资源棋局上的两步棋。官方文档对 HorizontalPodAutoscaler(HPA)的定义是:自动更新工作负载资源(如 Deployment、StatefulSet)以匹配需求的 API 资源加控制器,水平扩展的响应方式是部署更多 Pod。它不能用于 DaemonSet 这类不可扩缩的对象。

HPA 控制器作为控制平面内的控制回路间歇性运行(不是连续过程),同步周期由 kube-controller-manager 的 --horizontal-pod-autoscaler-sync-period 决定,默认 15 秒。每个周期内,控制器按 scaleTargetRef 找到目标资源,按其 .spec.selector 选择 Pod,从 resource metrics API(metrics.k8s.io,通常由 Metrics Server 提供)或 custom/external metrics API 取指标。关键点在于:用 CPU 利用率做指标时,利用率的基准是各容器的 resource request——如果某个容器没有设置对应资源的 request,该 Pod 的 CPU 利用率就没有定义,自动扩缩器对该指标不会采取任何动作。这就是"HPA 必须配 requests"这条实践在官方文档里的直接出处。

扩缩算法本身非常朴素:期望副本数 = ceil(当前副本数 × 当前指标值 / 期望指标值)。例如当前 200m、目标 100m,比例 2.0,副本数翻倍。默认容忍度为 0.1,比例足够接近 1 时不动。算法还有几层保守化处理:缺失指标的 Pod 按"缩容时占满、扩容时占零"假设以阻尼波动;未就绪 Pod 被单独搁置;缩容建议取可配置窗口(--horizontal-pod-autoscaler-downscale-stabilization,默认 5 分钟)内的最高值,让缩容平缓进行。针对 Java 类启动慢的应用,两个参数尤为实用:--horizontal-pod-autoscaler-cpu-initialization-period(默认 5 分钟)定义 Pod 启动后忽略其 CPU 使用的时间窗,--horizontal-pod-autoscaler-initial-readiness-delay(默认 30 秒)把刚启动时 Ready/Unready 反复抖动的 Pod 视为仍在初始化——文档明确举例说这正是为了排除 Java 应用预热期造成的虚高 CPU 采样。

HPA 决定"要几个 Pod",调度器决定"这几个 Pod 放哪、放不放得下"。两者协同的要点有三:第一,扩容产生的新 Pod 要过得了过滤这关,集群需要为峰值预留调度余量,否则 HPA 状态里 replicas 涨了、Pod 却一片 Pending;第二,新副本的启动探针与初始化期参数要与 HPA 的采样窗口匹配,避免预热期指标误导缩容;第三,缩容是渐进的(5 分钟稳定窗口),而节点压力驱逐可以瞬间发生,QoS 与 PDB 的配置决定了震荡期谁能活下来。一个典型的 HPA 配置:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: order-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-service
  minReplicas: 2
  maxReplicas: 20
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70

至于垂直方向,VPA 调的是单个 Pod 的 requests/limits,与调度直接相关:requests 变了,Pod 的记账与 QoS 都可能变化,文档也提到原地调整资源若导致 QoS 等级变化会被 admission 拒绝。因此 VPA 与 HPA 一般不作用于同一指标(如都基于 CPU),这是社区共识性的搭配禁忌,具体行为以你所用版本组件的文档为准。

你现在应该掌握了什么:HPA 的利用率分母是 requests,没设 request 的容器会让该指标失效;算法是 ceil(副本数 × 当前值/目标值),带 0.1 容忍度、5 分钟缩容稳定窗和启动期 CPU 忽略窗;HPA 管数量、调度器管落位,扩容余量、预热期参数与 QoS/PDB 配置是两者协同的三个抓手。

常见调度失败排查

当 Pod 卡在 Pending,官方机制给了我们一条清晰的排查链。第一步永远是看事件:

kubectl describe pod <pod-name> -n <namespace>
# 关注 Events 里的 FailedScheduling 原因,例如:
# 0/5 nodes are available: 3 Insufficient memory, 2 node(s) had taint {key1: value1}, that the pod didn't tolerate.

第二步按失败信息归类。如果是 Insufficient cpu/memory/ephemeral-storage,说明过滤阶段 PodFitsResources 没过关:用 kubectl describe node 查看各节点 Allocated resources,确认是真实容量不足,还是碎片导致没有单节点放得下(此时装箱策略与请求合理化是解法)。如果是 node(s) didn't match node affinity / node selector,检查节点标签是否与硬亲和匹配,注意 term 是 OR、表达式是 AND。如果是 taint 未容忍,核对污点效果与容忍声明,重点看 effect 字符串是否完全一致。如果是 didn't match pod topology spread constraints,检查 maxSkew 是否定得太严、合格域数量是否被 minDomains 放大、selector 是否误统计了其他工作负载的 Pod。

第三步看抢占痕迹。高优先级 Pod 触发抢占后,被抢占的低优先级 Pod 会带着被驱逐的原因出现在事件中;反过来,如果 Pending 事件里出现 preemption 相关描述但 Pod 仍未调度,说明抢占计算了但没有节点能腾出来。第四步区分驱逐类型:Pod .phase 变为 Failed 且伴随节点资源告警,多半是 kubelet 的节点压力驱逐(不优雅、不认 PDB);而 kubectl drain 操作走的是 Eviction API,会优雅删除并尊重 PDB——429 响应意味着 PDB 在挡,500 则提示 PDB 配置冲突。

最后别忘了两个"隐形杀手":其一,镜像与节点架构不匹配(官方文档中 PodFitsHostPorts、NodeAffinity 等过滤器之外的常见原因);其二,挂载的卷有拓扑限制(volume zone 约束会把候选节点限制在特定可用区)。排查的总原则:先定位失败发生在过滤、打分、绑定还是运行后驱逐,再用对应机制的配置项逐项核对。

你现在应该掌握了什么:describe pod 的 FailedScheduling 事件是排查入口;按"资源不足/亲和不匹配/污点未容忍/拓扑约束不满足/抢占无解/驱逐归类"六个篮子归类信息;先定阶段再查配置,能避免大多数无效排查。

踩坑经验

第一组:资源声明。
- ❌ 错误做法:只写 limits 不写 requests,以为"限了上限就行"。HPA 按 request 算利用率,缺失 request 的容器会让该指标无定义,自动扩缩器直接不动作;同时这类 Pod 在节点压力时几乎没有保护。
- ✅ 正确做法:requests 与 limits 成对书写, requests 按真实基线评估。HPA 场景必须保证每个容器都有对应指标的 request(见前文 HPA 配置示例)。

第二组:QoS 与驱逐预期。
- ❌ 错误做法:给核心数据库 Pod 配上 requests≠limits 就以为"高枕无忧"。它是 Burstable,节点内存压力时排在 BestEffort 之后但仍然会被驱逐,而且 kubelet 的节点压力驱逐不执行优雅停机,JVM 来不及做 heap dump 与连接清理。
- ✅ 正确做法:核心有状态服务申请 Guaranteed(内存 request=limit),配合 PodDisruptionBudget 约束自愿中断,并给内存 limit 预留 page cache headroom 以规避 Memory QoS 下 memory.min 等于 memory.max 的 OOM 风险。

第三组:污点与容忍。
- ❌ 错误做法:用 nodeSelector 把 Pod 钉到专用节点,却不给节点打污点。任何未设节点约束的 Pod 也可能被调度器"顺手"放到这些节点上,隔离形同虚设。
- ✅ 正确做法:专用节点组合使用污点(NoSchedule)与容忍:节点执行 kubectl taint nodes node1 dedicated=order:NoSchedule,对应工作负载 Pod 声明等值 tolerations,形成"双向确认"的隔离。

第四组:拓扑分布约束。
- ❌ 错误做法:maxSkew 设为 1、whenUnsatisfiable 用 DoNotSchedule,却忘了集群里只有 1 个可用区且节点数少于副本数。约束永远不满足,扩容出来的副本全部 Pending,而错误信息不会直接告诉你"约束本身不可满足"。
- ✅ 正确做法:先用 kubectl get nodes -L topology.kubernetes.io/zone 盘点拓扑域数量;对域数有硬性要求时设置 minDomains 并配合 ScheduleAnyway 兜底,滚动更新场景用 matchLabelKeys 引用 pod-template-hash 避免新旧副本互相计数。

第五组:优雅中断预期。
- ❌ 错误做法:以为配置了 terminationGracePeriodSeconds,节点内存耗尽时 JVM 也能优雅退出。节点压力驱逐对硬阈值直接 0 秒终止,根本不进优雅流程。
- ✅ 正确做法:把"必须优雅"的工作负载从"会被压力驱逐"的处境中解出来:Guaranteed QoS + 合理的节点水位告警 + 对主动维护使用 kubectl drain(走 Eviction API,尊重 PDB 与优雅期),并在 PDB 上 minAvailable 留出可中断余量,避免 drain 时陷入 429 循环。

最佳实践清单

  1. 每个容器都显式声明 requests 与 limits,requests 取压测基线,limits 取基线的 2 到 3 倍;HPA 使用的指标资源必须设 request。
  2. 核心在线服务申请 Guaranteed QoS;离线批处理、可重试任务使用低优先级 PriorityClass,必要时配 preemptionPolicy: Never。
  3. 节点隔离采用"污点 + 容忍"双向确认,涉及安全合规的节点选择使用 node-restriction.kubernetes.io/ 前缀标签并启用 NodeRestriction 准入插件。
  4. 多副本服务配置 topologySpreadConstraints:maxSkew 取 1 到 2,topologyKey 先按 hostname 再按 zone 分层,滚动更新务必配 matchLabelKeys: [pod-template-hash]。
  5. 主动维护一律走 kubectl drain,给关键工作负载配 PodDisruptionBudget;不要用直接删除 Pod 的方式代替,也不要指望节点压力驱逐会尊重优雅期。
  6. 用 ResourceQuota 限制各命名空间的高优先级 Pod 总量,防止优先级机制被滥用。
  7. 监控节点 allocatable 口径的装箱率与各节点 requests 分布,发现碎片倾向时在调度配置中评估 MostAllocated 打分策略。
  8. 排查 Pending 先看 kubectl describe pod 的 FailedScheduling 事件,按资源、亲和、污点、拓扑、抢占、卷拓扑六个方向归类,先定阶段再改配置。

总结

  1. 调度三阶段是过滤、打分、绑定:过滤看 requests 与硬约束,打分决定最优落点(同分随机),绑定写回 API server;直接指定 nodeName 会绕过调度器,属于非常规操作。
  2. requests/limits 与 QoS 是资源管理的基石:Guaranteed 要求每个容器 CPU/内存的 request 等于 limit;节点压力驱逐按 BestEffort → Burstable → Guaranteed 的顺序执行,但调度抢占只看优先级不看 QoS。
  3. 亲和是吸引、污点是排斥、拓扑分布约束是均衡,三者组合覆盖全部落位意图;AND/OR 组合规则、三种污点效果与 tolerationSeconds、maxSkew 与 whenUnsatisfiable 的软硬之分,是最常用的配置细节。
  4. 驱逐分三条路径:kubelet 节点压力驱逐(无视 PDB 与优雅期)、API 发起的驱逐(drain 路径,尊重 PDB,429/500/200 三种响应语义明确)、调度器优先级抢占(PriorityClass 取值上限 10 亿,内置 system-cluster-critical 与 system-node-critical)。
  5. HPA 与调度协同的关键是:利用率的基准是 requests、算法带容忍度与稳定窗、启动期 CPU 采样可被初始化参数排除;扩容余量、预热参数与 PDB/QoS 共同决定大促场景下的稳定性。
  6. 排查调度问题的标准动作是 describe pod 看事件、按六类原因归类、先定失败阶段再核对配置——这套流程能系统化解决绝大多数 Pending 与意外驱逐。

参考资料

  • Kubernetes 官方文档:Scheduling, Preemption and Eviction(调度、抢占与驱逐总览) https://kubernetes.io/docs/concepts/scheduling-eviction/
  • Kubernetes Scheduler(kube-scheduler 与过滤/打分/绑定流程) https://kubernetes.io/docs/concepts/scheduling-eviction/kube-scheduler/
  • Assigning Pods to Nodes(nodeSelector、亲和与反亲和) https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/
  • Taints and Tolerations(污点与容忍) https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/
  • Pod Topology Spread Constraints(拓扑分布约束) https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/
  • Pod Priority and Preemption(优先级与抢占) https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/
  • Node-pressure Eviction(节点压力驱逐) https://kubernetes.io/docs/concepts/scheduling-eviction/node-pressure-eviction/
  • API-initiated Eviction(API 发起的驱逐) https://kubernetes.io/docs/concepts/scheduling-eviction/api-eviction/
  • Pod Quality of Service Classes(QoS 等级) https://kubernetes.io/docs/concepts/workloads/pods/pod-qos/
  • Horizontal Pod Autoscaling(HPA) https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/