TT Lab
开始
学习 学习路径 课程

Kubernetes 运维实务

读懂一个永不结束的 drain

在 TT Lab 中继续学习

一句话总结

drain 不是删除 Pod,而是调用驱逐 API,这个请求由 PodDisruptionBudget 来评估, 预算不够就返回 429。被卡住的 drain,原因几乎总是这三种之一:预算为 0、选择器重叠, 或者未就绪的 Pod 已经把预算打破了。

为什么需要专门的诊断

创建 PDB 并不难。一页文档就够了。然而在现场,耗费时间的不是创建的一方,而是被卡住的时候。 为了清空一个节点而敲下的命令,30 分钟里一直重复打印同一行,而这一行里 除了 error when evicting pods 之外什么都没有。是哪个预算在卡着、还差几个、 现在能赶出去的 Pod 有几个,这些在那个画面里都看不到。

所以要先弄清 drain 实际上做了什么。kubectl drain 并不会对 Pod 执行 delete。 它会对每个 Pod 向驱逐子资源(/api/v1/namespaces/<ns>/pods/<pod>/eviction)发送 POST。这个请求 会在 API 服务器内部经过 PDB 评估,预算不够时会以 429 Too Many Requests 拒绝。而 drain 不会放弃,而是重试。这就是被卡住的 drain 不会以失败告终、而是永远持续下去的原因。 相反,kubectl delete pod 会整个跳过这个评估——所以在紧急时会让人想用它,但那是 宣布无视预算。

工作原理

PDB 的状态不是一个数字,而是四个。查看 kubectl get pdb <이름> -o json(占位符为 PDB 名称)的 .status,内容如下。

字段 含义
expectedPods 预期有多少个 Pod 匹配选择器
currentHealthy 其中当前健康的 Pod 有多少个
desiredHealthy 驱逐之后仍必须留下的最少个数
disruptionsAllowed 现在立刻可以赶出去的个数

这里的“健康”一词有准确的定义。官方文档写明,.status.conditions 中有 type=Ready 且 status=True 这一项的 Pod,才算作健康的 Pod。处于 Running 并不代表健康。

minAvailable 和 maxUnavailable 只能二选一。使用百分比时,向上取整的方向很重要。 Pod 有 7 个、minAvailable: "50%" 时是 3.5,而 Kubernetes 会向上取整,要求 4 个。这是 安全的一侧。但如果把 maxUnavailable 用百分比来写,可赶出去的个数一侧会被向上取整。因此,按官方 文档的说法,实际的中断可能超过所写的百分比,当副本只有一个时, maxUnavailable: 30% 会使这一个可以被赶出去,最终变成 100% 中断。

第二个陷阱是选择器重叠的预算。如果一个 Pod 匹配两个或更多 PDB,即使每个预算都有余量, 驱逐也会被拒绝。因为驱逐子资源不支持这种情况。文档也写道要避免重叠的选择器, 合理的用法只举出了把 Pod 从一个预算转移到另一个预算的过渡期。

第三个是 unhealthyPodEvictionPolicy。这个字段在 1.31 中稳定,默认值是 IfHealthyBudget。 在这个默认值下,要赶出尚不健康的 Pod,该应用必须尚未打破预算。 也就是说,只要有一个陷入 CrashLoopBackOff 的 Pod 或无法报告 Ready 的 Pod, 连这个坏掉的 Pod 本身也无法被驱逐。drain 就会在那里永远停住。改成 AlwaysAllow 后, 正在运行但不健康的 Pod 可以不受预算限制被赶出去。这就是官方文档写明,为了支持节点 drain 而推荐这个值的原因。

最后,还要知道预算无法保护的事情。PDB 只阻止自愿中断。它无法阻止节点 直接死掉,所以文档明确指出,预算并不总是保证那个个数。 并且,把 maxUnavailable: 0 或 minAvailable 设成与副本数相同,就等于把自愿驱逐设为 0, 于是有这个 Pod 的节点drain 永远无法结束。这不是 bug,而是文档所写的本来含义。

在现场相遇的样子

最常见的是给只有 1 个副本的工作负载设置了 minAvailable: 1。本意是“这个服务绝对 不能中断”,结果却是有该工作负载的节点永远无法被清空。升级 计划整个停滞。

第二种是 drain 被卡住之后没有把节点恢复。drain 在开始驱逐之前,会先把节点 改为禁止调度的状态。即使驱逐被卡住而中断了命令,这个标记也会留下。没有人察觉, 集群容量就少了一个节点,几周后流量涌来时为此付出代价。

第三种是没有预算的工作负载。这是相反方向的事故——drain 毫无阻力地通过, 副本一下子全部下线。所以在设计维护窗口时,最先要做出来的资料 就是“副本数在 2 以上、却没有被任何 PDB 覆盖的工作负载”清单。

本实验环境的局限

kwok 的 Pod 不是真正的容器,所以无法真正制造出 CrashLoopBackOff。作为替代,直接修改 Pod 的 status.conditions,把 Ready 改为 False——PDB 看的正是这个状况, 所以中断控制器真的会让 currentHealthy 减少一个,驱逐的判定也真的会改变。确认之后发现, 默认策略下是 429,改为 AlwaysAllow 后则通过。另外,如果真的执行驱逐,后面步骤要用的 Pod 会消失,所以本实验的大部分判定都用 ?dryRun=All 来获取。在实际工作中,在确定维护窗口之前, 也可以用同样的方法提前询问。

下一项实验要做什么

把工作负载集中到一个节点上,创建允许中断为 0 的预算,然后直接调用驱逐 API, 亲自收到 429。接着看到 drain 真的停住,把节点恢复后,修改预算,确认同样的请求可以通过。 然后给 7 个副本设置 50% 的预算,用集群计算出的数字确认向上取整的方向, 再用选择器重叠的预算制造死锁,并读取它的消息。创建未就绪的 Pod,测量默认策略与 AlwaysAllow 的差异,最后编写一个在整个集群中查找没有预算的工作负载的审计 脚本。

参考文档: