Skip to content
Charles Shao
Go back

K8s 调度、资源与弹性伸缩

views

上一篇讲清了 Kubernetes 的核心对象——Pod、Deployment、Service,以及声明式 reconcile 的心智。但那里我们跳过了一个关键问题:scheduler 到底凭什么把一个 Pod 放到某台机器上? 更要命的是:你申请了多少 CPU/内存?超了会怎样?流量涨了谁来自动扩容?容器”活着”和”能干活”怎么区分?

这一篇专啃 K8s 里最影响稳定性和成本的三块:资源模型(requests/limits/QoS)、调度约束、弹性伸缩与探针。这三块配错,不是慢,而是Pod 被 OOMKilled、被 CPU throttle、发布瞬间大量报错、扩容扩不动——广告这种流量尖峰剧烈的场景尤其致命。

本文是容器化与 K8s 部署系列的第 3 篇。 全系列 5 篇:

  1. 容器化(开篇)· Docker 原理与镜像分层
  2. Kubernetes 核心对象 · Pod/Deployment/Service/Ingress
  3. K8s 调度、资源与弹性伸缩(本篇)
  4. CI/CD 流水线 · Jenkins 构建镜像与自动部署到 K8s
  5. 广告服务上云实战 · 容器化部署与调优

一句话定位:requests 决定”调度到哪、保底多少”,limits 决定”最多用多少、超了怎么罚”;scheduler 按约束 Filter+Score 选节点;HPA/VPA/CA 三层弹性按指标自动伸缩;三种探针分别管”起没起来、能不能接流量、还活着吗”。 配对了才有稳定和成本双赢。

TL;DR

Table of contents

Open Table of contents

1. 资源模型:requests 与 limits

一切稳定性和成本问题的起点,是你在 Pod 里写的这几行:

resources:
  requests:        # 调度依据 + 保底
    cpu: "500m"    # 0.5 核
    memory: "512Mi"
  limits:          # 使用上限(cgroup 强制)
    cpu: "1000m"   # 1 核
    memory: "1Gi"

requestslimits 是两个完全不同的东西,混淆它们是新手最大的坑:

requests < limits 时,中间的差值就是超卖(overcommit)空间——节点可以在总 requests 内塞下更多 Pod,赌它们不会同时冲到 limits。超卖能提升资源利用率和密度,但赌输了(大家同时飙高)就会资源争抢甚至节点崩溃。

1.1 超限的两种罚则:内存是硬墙,CPU 是软刹车

这是必须刻进肌肉记忆的一点,因为两种资源超限的后果天差地别:

资源超过 limit 时后果
内存触到 memory.max,内核 OOM Killer 直接杀进程容器被 OOMKilled、重启(RestartCount 增加)
CPU被 cgroup throttle 限速(按周期掐掉超出的时间片)进程不死,但变慢、延迟飙升、吞吐下降

内存超限 = 猝死,CPU 超限 = 卡顿。 内存是不可压缩资源,没有”用少点”的中间态——要么够,要么被杀;CPU 是可压缩资源,不够就慢慢来。所以:内存 limit 要留足够 headroom(宁可略大),CPU limit 更多是防止个别 Pod 抢爆节点。这条直接对应 §6 的 war story——竞价服务就是内存 limit 拍脑袋设小了被批量 OOMKilled。

CPU throttle 特别隐蔽:应用没崩、日志没错,但 p99 延迟莫名其妙很高——一查是 container_cpu_cfs_throttled_periods 高企,limit 卡住了。很多”上云后变慢”的谜题都是 CPU throttle。

1.2 QoS 三级:节点告急时谁先被赶走

根据 requests/limits 的设置,K8s 自动给 Pod 打一个 QoS(服务质量)等级,它决定节点内存紧张时的驱逐顺序

QoS 等级条件节点告急时
Guaranteed每个容器都设了 requests,且 requests == limits最稳,最后被驱逐
Burstable设了 requests,但 requests < limits(或部分设置)居中
BestEffort完全不设 requests/limits第一个被驱逐(节点缺资源直接干掉)

结论很直接:核心服务(竞价、网关、检索)一律配成 Guaranteed(requests==limits),换取最强的调度保证和最后被驱逐的待遇;离线批处理、可容忍中断的任务才用 Burstable/BestEffort 去薅超卖红利。生产服务绝不能裸奔成 BestEffort——节点一紧张它就没了。

2. 调度:scheduler 怎么选节点

Pod 创建出来时是”未绑定节点”的 Pending 状态。kube-scheduler 的工作就是给它挑一个节点绑上去,这是一个两阶段过程。

kube-scheduler 两阶段调度示意图。顶部主流程从左到右:Pending Pod(带 requests/亲和/容忍)→ ① Filter 预选(排除不满足的节点,箭头标注淘汰)→ ② Score 优选(给候选节点打分,箭头标注排序)→ ③ Bind 绑定(选最高分节点)。下方左侧粉色面板"① Filter 预选:哪些节点能放"列出四条硬门槛:资源是否够(node 剩余 CPU/内存 ≥ Pod requests)、污点是否容忍(Pod 无对应 toleration 则排除)、亲和/反亲和硬约束(required 不满足直接淘汰)、节点选择器/状态(nodeSelector、是否 Ready/可调度)。右侧琥珀色面板"② Score 优选:候选里放哪个最好"列出四条软偏好:资源均衡(优先负载低/装箱更优)、亲和偏好 preferred(软约束按权重加分)、拓扑分散 spread(同副本尽量分散到多可用区)、镜像本地性(已有镜像的节点加分省拉取)。底部说明 Filter 是硬门槛不满足即淘汰、Score 是软偏好按满足程度打分排序,选总分最高节点绑定;调度只在 Pod 创建时发生一次,绑定后不会自动迁移。

调度分两步:Filter 先按硬约束把”放不下”的节点淘汰掉,Score 再给剩下的候选打分排序,绑定最高分节点。约束要在调度前就写对——调度只发生一次,绑定后不自动迁移。

2.1 Filter(预选)与 Score(优选)

关键认知:调度只在 Pod 创建时发生一次。一旦绑定,除非 Pod 被删除/驱逐/重建,它不会因为后来节点变忙而自动迁移。所以约束必须在调度前就配对——事后发现放错了,只能重建 Pod。

2.2 亲和性:affinity 与 anti-affinity

2.3 污点与容忍:taint + toleration

taint(污点) 打在节点上,表示”这个节点默认拒绝 Pod”;toleration(容忍) 配在 Pod 上,表示”我能容忍这种污点”。只有容忍了对应污点的 Pod 才能被调度上去。典型用途:

2.4 拓扑分散:topologySpreadConstraints

topologySpreadConstraints 让一批 Pod(如同一个 Deployment 的副本)尽量均匀分散到不同的拓扑域(可用区 zone、节点 node)。这样一个可用区挂了,不至于把所有副本一锅端——是跨 AZ 高可用的关键(末篇 反亲和 + PDB 会结合讲)。

topologySpreadConstraints:
  - maxSkew: 1                      # 各 zone 副本数最多差 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: DoNotSchedule
    labelSelector:
      matchLabels: { app: bid-service }

3. 弹性伸缩:HPA、VPA 与 Cluster Autoscaler

广告流量有明显的日内波峰波谷、还有大促尖峰。固定副本数要么高峰不够(挂)、要么低谷浪费(贵)。K8s 的弹性分三个层次,各管一件事:

3.1 HPA:一个负反馈控制环

HPA 本质是个负反馈控制器,周期性地把”当前指标”朝”目标指标”调:

HPA 弹性伸缩负反馈控制环示意图。顶部横向流程构成一个环:metrics 采集(CPU/QPS/自定义,箭头标注每 15s)→ HPA 控制器(对比当前 vs 目标,箭头标注偏差>10%)→ 算期望副本数(desired = ceil(cur×(m/target)),箭头标注受 behavior 限速)→ 调整 Deployment(replicas 扩/缩,箭头标注 reconcile)→ Pod 数变化(负载被摊薄/收拢)。最后一个盒子经底部虚线回环箭头连回第一个盒子,标注"指标随副本数变化 → 闭环收敛"。下方 slate 面板"工程要点:别让弹性自己抖起来"横向列出四点:稳定窗口(scaleDown 默认 5min,防抖动 flapping)、扩容要快缩容要稳(behavior 分别限速)、按业务指标扩(QPS/队列比 CPU 更准)、VPA/CA 配合(调 requests / 加节点)。底部说明 HPA 调 Pod 数(横向)、VPA 调单 Pod 的 requests/limits(纵向)、Cluster Autoscaler 在 Pod 挂 Pending 时加/减节点,三者分工协作。

HPA 是负反馈环:采集指标 → 对比目标算出期望副本数 → 调整 Deployment → Pod 数变化又改变指标 → 闭环收敛。关键是设稳定窗口防抖、扩快缩慢、用业务指标。

核心公式:

desiredReplicas = ceil( currentReplicas × (currentMetric / targetMetric) )

例如当前 4 个副本、目标 CPU 利用率 50%、实际 100%,则期望 = ceil(4 × 100/50) = 8 副本,扩容一倍。指标回落后再反向缩容。

3.2 HPA 的工程陷阱

HPA 配不好,弹性会自己”抖”起来,反而制造故障:

4. 探针:startup、readiness、liveness

K8s 靠探针(probe)判断容器的状态。三种探针回答三个不同的问题,配错会造成”没起来就接流量”或”好好的被反复重启”。

三种探针时序图。顶部有三条竖直时间标记线,分别标注 startup 成功、首次就绪、运行中故障。图分四行泳道。第一行"容器生命周期":从左到右三段——启动中 Starting(琥珀色)、就绪运行 Ready·接流量(绿色,占据中段)、故障→重启(红色,右端)。第二行"startupProbe":从容器启动到 startup 成功一段蓝色条,标注"探测中:给慢启动宽限期,期间不跑 liveness/readiness",末尾一个绿色"成功✓ 交棒"块。第三行"readinessProbe":从 startup 成功之后开始一段灰色条"周期探测就绪",在首次就绪处一个绿色块"就绪→加入 Endpoints",在运行中故障处起一段红色条"失败→摘出 Endpoints"。第四行"livenessProbe":从 startup 成功之后一段灰色条"周期探活(存活即可,不看是否 ready)",在运行中故障处起一段红色条"连续失败→kill 重启"。底部说明:startup 只在启动阶段跑保护慢启动应用不被误杀、成功后让位;readiness 决定是否接流量、失败只摘出 Endpoints 不重启;liveness 决定是否还活着、连续失败直接 kill 重启,配错会造成无谓重启风暴。

三种探针的时序:startup 先跑(保护慢启动),成功后 readiness(管接不接流量)和 liveness(管活着没)接棒。readiness 失败只摘流量不重启,liveness 失败才 kill 重启。

探针回答的问题失败后果何时用
startupProbe”启动完了吗?“未成功前不跑另两个探针;超过失败阈值才判定启动失败启动慢的应用(JVM 预热、大缓存加载)
readinessProbe”现在能接流量吗?“摘出 Endpoints、不接流量,但不重启几乎所有服务都该配
livenessProbe”还活着吗?“kill 容器并重启检测死锁/僵死,谨慎配

4.1 三者的分工与协作

4.2 探针的经典误配

5. 把三块拼起来:一份生产级配置骨架

资源、调度、弹性、探针合在一起,一个核心服务的 Deployment 大致长这样:

spec:
  replicas: 6
  template:
    spec:
      # 调度:副本分散到不同可用区,避免单 AZ 故障全灭
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector: { matchLabels: { app: bid-service } }
      # 反亲和:同一服务副本别挤同一节点
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                topologyKey: kubernetes.io/hostname
                labelSelector: { matchLabels: { app: bid-service } }
      containers:
        - name: bid
          # 资源:Guaranteed(requests==limits),内存留足 headroom
          resources:
            requests: { cpu: "1", memory: "2Gi" }
            limits:   { cpu: "1", memory: "2Gi" }
          # 探针:startup 保护慢启动,readiness 管流量,liveness 宽松
          startupProbe:
            httpGet: { path: /healthz, port: 8080 }
            failureThreshold: 30    # 最多容忍 30×2s=60s 启动
            periodSeconds: 2
          readinessProbe:
            httpGet: { path: /ready, port: 8080 }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /healthz, port: 8080 }
            periodSeconds: 10
            failureThreshold: 6     # 宽松,避免误杀重启风暴

外面再配一个 HPA(按 QPS 或 CPU)+ Cluster Autoscaler。这套骨架就是末篇实战 的地基。

参考


views
Share this post on:

Previous Post
CI/CD 流水线 · Jenkins 构建镜像与自动部署到 K8s
Next Post
Kubernetes 核心对象 · Pod/Deployment/Service/Ingress