滚动更新卡在 2/5 二十分钟:配额、maxUnavailable 与 PDB

Events 一片空白,五个旧 Pod 一个不退,两个新的 Ready 却不增加。真因在 status.conditions 里:Pod 配额满了,而 maxUnavailable: 0 要求先扩后缩。

周三下午发版,订单服务推了个小改动,就改了一行日志格式。流水线跑完,kubectl rollout status 卡在 Waiting for deployment "order-service" rollout to finish: 2 out of 5 new replicas have been updated... 这句话上,整整二十分钟没动过。

我先确认了一堆常规项:新镜像能拉、Pod 是 Running、日志没有异常、健康检查接口手动 curl 也通。但 kubectl get pods 里五个旧 Pod 一个都没退,两个新 Pod 变成了 Ready 1/1 却也没增加。

最诡异的是 kubectl describe deployment order-service 里 Events 一片空白,没有 FailedCreate、没有 Unhealthy、没有任何 warn。Available 一直是 5,Up-to-date 是 2,Updated Replicas 卡在 2。

这篇记录这次「什么都不报错、但就是不动」的排查,最后发现是两个本来都很正确的配置互相抵消了。

一、Deployment 的状态字段先说清楚

kubectl get deploy 那一行有四个数字,卡住的时候它们长这样:

NAME            READY   UP-TO-DATE   AVAILABLE   AGE
order-service   5/5     2            5           218d

这四个字段的含义经常被混着理解,但排查时它们是判断卡在哪一环的关键:

字段 含义 卡住时说明什么
READY Ready 的 Pod 数 / 期望副本数 5/5 说明现有服务能力是够的
UP-TO-DATE 已经是新版本的副本数 2 说明只有 2 个被更新
AVAILABLE 处于 Available 状态的副本数 5 说明没掉可用性
Replicas(describe 里的) 实际存在的 Pod 总数 如果等于期望数,说明没有多创建

我们这个组合说明:控制器想创建新 Pod,但创建不出来;同时旧 Pod 也不能删。而它既不报错也不推进,说明是被某个约束挡住了,不是被错误挡住了。

二、把控制器的心路历程打印出来

Deployment 的所有决策都会写到 status.conditions 里,rollout status 只显示其中一句话,完整信息得自己读:

kubectl get deploy order-service -o jsonpath='{range .status.conditions[*]}{.type}{"\t"}{.status}{"\t"}{.reason}{"\t"}{.message}{"\n"}{end}'

输出:

Available       True    MinimumReplicasAvailable   Deployment has minimum availability.
Progressing     False   ProgressDeadlineExceeded   ReplicaSet "order-service-7d9f" has timed out progressing.
ReplicaFailure  True    FailedCreate               ...

ReplicaFailure=True、reason=FailedCreate,但 message 被截断了。展开看完整 message:

kubectl get deploy order-service \
  -o jsonpath='{.status.conditions[?(@.type=="ReplicaFailure")].message}'
pods "order-service-7d9f8b6c5-" is forbidden: 
exceeded quota: compute-quota, requested: pods=1, used: pods=50, hard: pods=50

是 ResourceQuota 挡住了。这个 namespace 的 Pod 配额是 50,当前已经用了 50。

这条信息解释了「为什么不报错」:FailedCreate 在 Deployment 的 conditions 里,不在 Events 里。而 rollout status 卡住的时候不会主动打印 condition 的 message。如果一开始就去读 conditions,这个问题三十秒就能定位。

三、配额满了,为什么旧 Pod 不先删

配额满了通常是「加配额」或者「腾空间」。但这里有个更值得追问的点:滚动更新的正常逻辑是「先扩后缩」——先创建一个新 Pod,等它 Ready,再删一个旧的。如果配额只够当前副本数,这个策略天然就卡住了。

那能不能改成「先缩后扩」?这就是 maxSurge 和 maxUnavailable 的作用:

spec:
  replicas: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1          # 允许临时多出 1 个 Pod(6 个)
      maxUnavailable: 0    # 不允许任何时刻少于 5 个可用

我们的配置是 maxSurge: 1、maxUnavailable: 0。这个组合的含义是:任何时刻可用副本不能少于 5,所以必须先创建新 Pod 并等它 Ready,才能删旧的。 于是它必然需要第 6 个 Pod 的位置——而配额正好用完了。

maxUnavailable: 0 是我们当初为了「发版零中断」加的,看起来非常正确。但它有一个隐含前提:集群必须有超出当前副本数的余量。 没有余量时,这个配置就从「保证可用性」变成了「保证不动」。

四个组合的实际行为:

maxSurge maxUnavailable 峰值 Pod 数 最低可用数 需要额外配额
1 0 6 5 是,至少 1 个
1 1 6 4 是,至少 1 个
0 1 5 4 否
0 0 不允许(会被 API 拒绝) — —

maxSurge: 0 + maxUnavailable: 0 是非法组合,API Server 会直接拒绝,因为无法在不增加也不减少的前提下完成替换。这条规则本身就是对这个矛盾的承认。

我们的处境是:maxSurge: 1 要额外 1 个 Pod,配额没有;想改成 maxSurge: 0 + maxUnavailable: 1,但这是降可用性的操作,而且——这里才是真正的问题——我们的 PDB 不允许。

四、PDB 又插了一脚

查一下 PDB:

kubectl get pdb -n order
NAME                MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
order-service-pdb   5               N/A               0                     194d

minAvailable: 5,ALLOWED DISRUPTIONS: 0。

PDB 的语义是:自愿中断(eviction,比如节点排空、kubectl drain)时,被中断后剩余的可用副本不能低于 minAvailable。滚动更新删除旧 Pod 算不算自愿中断?

严格说,Deployment 控制器删除 Pod 走的是 API 的 delete,不经过 eviction 子资源,所以 PDB 不直接约束滚动更新。这也是很多人误以为 PDB 只影响 drain 的原因。

但 PDB 在这里仍然间接生效了,通过另一条路:我们的集群装了 descheduler 和一个自研的「容量均衡器」,它们会定期做 eviction 来平衡节点负载。当 ALLOWED DISRUPTIONS: 0 时,这些组件被完全挡住,节点负载开始倾斜。而倾斜导致的后果是:新 Pod 被调度到的节点资源碎片化,Pending 时间变长。

那天的情况更直接一些:配额满 + PDB 允许中断数为 0,让运维同学在下意识的操作里也不敢手动删旧 Pod——因为一删就会触发告警「PDB violation」。于是三件事叠加成了一个死锁:

  1. maxUnavailable: 0 要求先扩后缩
  2. 配额满了,扩不出来
  3. PDB minAvailable: 5 让「手动删一个旧的」变成告警事件

三个单独看都正确的配置,凑在一起就是「谁都不许动」。

五、解开死锁的正确顺序

那天我做的操作顺序是这样的,值得记下来:

# 1. 先确认到底是配额还是调度问题
kubectl describe resourcequota -n order

# 2. 临时提高配额(这是唯一能立刻解开的方式)
kubectl patch resourcequota compute-quota -n order \
  --type=merge -p '{"spec":{"hard":{"pods":"60"}}}'

# 3. 观察 rollout 是否恢复推进
kubectl rollout status deploy/order-service --timeout=180s

# 4. 恢复发版策略:允许 1 个不可用,峰值不再超配额
kubectl patch deploy order-service --type=merge -p '{
  "spec":{"strategy":{"rollingUpdate":{"maxSurge":0,"maxUnavailable":1}}}
}'

# 5. 配额回落到原值(留 5 个余量给未来发版)
kubectl patch resourcequota compute-quota -n order \
  --type=merge -p '{"spec":{"hard":{"pods":"55"}}}'

第 4 步是关键的长久修复:maxSurge: 0 + maxUnavailable: 1 意味着峰值 Pod 数等于副本数,不再需要额外配额;代价是发版过程中可用副本会短暂变成 4 个。

对我们的订单查询服务,4/5 的容量完全够用(平时 CPU 峰值 40%)。而对那些必须保持满员的场景(比如有状态的选主服务),就不能这么改,得反过来保证配额余量。

ALLOWED DISRUPTIONS: 0 这件事单独处理:把 PDB 从 minAvailable: 5 改成 minAvailable: 4(或者用 maxUnavailable: 1 的写法,语义更直观),让 descheduler 有腾挪空间,同时仍然保证 80% 的可用性。

maxUnavailable: 1 比 minAvailable: 5 更好的地方在于:它跟着副本数走。以后副本数从 5 改到 8,minAvailable: 5 会让允许中断数变成 3(太宽松),而 maxUnavailable: 1 永远是「最多少 1 个」,不需要跟着调。

六、让这类问题下次自己暴露

排查花的时间是二十分钟,但真正可惜的是这二十分钟里没有任何告警。我们的监控只盯 Pod 的 Running 状态和接口错误率,而这次 Pod 全都 Running、接口全都正常,从 SLO 角度看服务是健康的——只是新版没上去。

补了三类检查。

第一类,发版超时告警。流水线里原本有 kubectl rollout status --timeout=600s,超时会失败,但那天的流水线恰好把这一步的超时设成了 1800 秒。改成 300 秒,并且失败时自动把 conditions 打出来:

if ! kubectl rollout status deploy/order-service --timeout=300s; then
  echo "=== rollout 卡住,dump 诊断信息 ==="
  kubectl get deploy order-service -o jsonpath='{range .status.conditions[*]}{.type}={.status} {.reason}: {.message}{"\n"}{end}'
  kubectl describe rs -l app=order-service | tail -30
  kubectl describe resourcequota -n order
  kubectl get pdb -n order
  exit 1
fi

这几行把这次排查的所有信息在失败瞬间就打全了,比事后回忆快得多。

第二类,ReplicaFailure 直接告警。这是个被严重低估的信号,它出现就说明控制器想创建 Pod 但失败了,原因可能是配额、可能是 LimitRange 拒绝、可能是准入 webhook 拦截:

- alert: DeploymentReplicaFailure
  expr: kube_deployment_status_condition{condition="ReplicaFailure",status="true"} == 1
  for: 2m
  annotations:
    summary: "Deployment {{ $labels.deployment }} 无法创建 Pod"

第三类,配额水位预警。kubectl describe resourcequota 的 used/hard 应该被采集,超过 90% 就提示。配额满了不是突发事件,它通常是慢慢涨上来的——我们那个 namespace 半年前就 46/50 了,一直没人管,直到发版需要第 51 个。

小结

  • Deployment 卡住但 Events 空白时,先读 status.conditions;ReplicaFailure 的 message 里才有真实原因(这次是 Pod 配额超限)。
  • maxSurge: 1 + maxUnavailable: 0 要求必须有额外一个 Pod 的位置,配额按「副本数」配的集群会在第一次发版就死锁。
  • 没有配额余量时改用 maxSurge: 0 + maxUnavailable: 1,峰值不超配额,代价是发版期间可用副本短暂少一个;必须满员的场景则要反过来保证配额余量。
  • maxSurge 与 maxUnavailable 同时为 0 是非法组合,API 会直接拒绝。
  • PDB 约束的是 eviction 而不是滚动更新,但 ALLOWED DISRUPTIONS: 0 会让 drain、descheduler 停摆,间接影响调度;用 maxUnavailable 写法比 minAvailable 固定值更能跟随副本数变化。
  • 配额水位要按 90% 预警,发版流水线的 rollout status 超时要短(300s)并在失败时自动 dump conditions、RS、配额、PDB 四份信息。
查看「容器与编排」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。