Kubernetes 跨集群 Pod 可用性保护
多集群部署微服务带来了可扩展性和容灾性等优势,但也引入了全局层面的脆弱性——中心控制平面的任何问题都会级联影响所有被管理集群,造成灾难性后果。其中最严重的场景之一是由于 Pod 删除导致的服务容量丢失。这在 Kubernetes 复杂的事件链中可能由多种原因引发,例如:
意外删除所有 Deployment 的 owner 资源类型的 CRD
集群拓扑配置错误,导致用其他集群的 spec 覆盖当前集群
多集群滚动更新实现缺陷,同时在所有集群触发更新
联邦主集群的 etcd 磁盘损坏,导致 Deployment 对象从索引中移除
多个集群同时独立进行 Pod 驱逐操作,并发度不受控
虽然这些问题均可单独解决,但成因多样且在持续变化的基础设施中难以穷举。更便捷的方式是采用端到端处理:只要全局要求未满足就阻止 Pod 删除。因此我们开发了 Podseidon 项目——当跨集群的最小可用性要求不满足时,拒绝删除请求的准入 webhook。

整体设计
Podseidon 引入 PodProtector CRD,其 spec 与原生 PodDisruptionBudget 相近:
为与现有工作负载集成,Podseidon 提供可定制插件的控制器 podseidon-generator,用于从 Deployment 等根属主工作负载自动生成并同步 PodProtector。部署在每个集群的 podseidon-aggregator 控制器将集群内 Pod 当前状态聚合写入 PodProtector 的 status 字段,而各集群并配置了准入验证 webhook podseidon-webhook,在可用性底线未能满足时拒绝 Pod 的删除请求。

同步路径 vs 最终一致性
Pod 删除事件有两种数据源:list-watch 与准入 webhook,各有优劣:

举个图表化的例子:

(实际的 pod 删除过程涉及多个步骤,但为了讨论简单起见,我们假设在成功执行 DELETE 请求并设置 deletionTimestamp 后,pod 会立即被删除)
list-watch 低于实际值,这意味着突然大量删除事件会被错误地允许爆发,所以这并非一个安全的选择。但是使用来自 webhook 的事件计数也不可行,因为它会无限制地偏离实际状态。
相反,我们结合了两个数据源:webhook 存储已批准的 pod 删除的历史记录,aggregator 将其视图时间之前的历史记录压缩为最终正确的状态。PodProtector 对象同时包含来自 aggregator 最后观察到的状态以及其后来自 webhook 的增量历史,后者作为临时缓冲等待 aggregator 进行权威聚合。当删除突发过大,无法在单个 PodProtector 对象内存储(因为可能包含上万副本 Deployment 的每个副本的条目)时,最早的 Pod 删除事件将被压缩到某个时间范围内,避免超大 PodProtector 对象影响存储集群性能。
示例时间线:

因此,分歧的 webhook 线会定期校准到实际线。考虑之前的示例,其中 aggregator 每秒报告一秒前的状态,校准后的线与实际线更接近:

除了压缩删除历史之外,aggregator 还会将扩缩容、数据面等非删除事件导致的可用性变化同步到基线值。由于 Podseidon 仅旨在防止控制平面导致的不可用,这些状态变化的延迟相对可以接受。使用此双数据源方案,可以平衡正确性和及时性的要求。
Aggregator 快照时间推断
为准确截断准入历史并保留当前快照后的增量删除事件,需从 aggregator 的 pod list-watch 事件中获取事件时间戳,然而 Kubernetes 原生未提供该功能。
最理想的方案本应使 webhook 与 aggregator 共用同一时钟,但由于删除请求无法通过 mutating webhook 修改对象字段,这个想法并不可行。故此,我们需通过其他不可靠渠道推断快照时间戳,包括:
clock:使用 aggregator 系统时间
status:使用 Pod 字段(creationTimestamp、deletionTimestamp、conditions 等)
这些方法均存在偏差:

受 webhook 响应延迟和 watch 延迟影响,aggregator 系统时间会晚于准入 webhook 录入 PodProtector 准入历史的时间
Pod 字段时间数据源不一致,部分早于、部分晚于 webhook 响应,但无 watch 延迟问题。尤其当 status.conditions 推断的快照时间早于 webhook 准入时间时,可能导致快照无法清除引发自身触发删除事件的准入记录。此外,部署于不同机器的组件间可能存在系统时钟偏差。
在字节跳动的实践中,我们部署的定制版 kube-apiserver 会在 etcd 存储的 GuaranteedUpdate 调用中添加 annotation,其值为当前 apiserver 系统时间戳(功能上就是个 lastUpdateTimestamp)。这使得准入历史时间戳与推断的快照时间之间的延迟更可预测,在生产环境中验证该方案,余下的竞态问题机率较小可忽略。对于标准 Kubernetes,推荐采用 aggregator 系统时间方案 (clock),至少避免了快照无法清除自身触发事件的问题。
不过理论上,即使忽略时钟偏差,当 watch 延迟过高时,clock 仍可能导致假阴性(错误允许 pod 删除):

在 00:01 之后,pod X 和 pod Y 被允许删除,此时 PodProtector 状态中包含两个准入历史记录条目{X: 00:00}及{Y: 00:01}。在 00:02 时,aggregator 收到 pod X 删除的 watch 事件(延迟了 2 秒)。通过其 informer 中的新缓存状态,它观察到 00:02 时的快照包含 pod Y 而不包含 pod X,因此 PodProtector 状态中的两个准入历史条目都被清除了。实际上这是预期的行为,因为这符合我们的假设:如果在 00:02 发生事件而前面没有收到 pod Y 的删除事件,则意味着 pod Y 实际上并未被删除;但事实上,该请求仍在处理中,只是时间参照系不一致导致误判。虽然 aggregator 最终会在 00:04 更新,正确地排除 X 和 Y 影响的 pod 数,但如果 webhook 在 00:03 收到另一个其他 pod 的删除请求(下称 pod Z),则会被错误允许准入,因为系统假定只有 pod X 被删除而 pod Y 未被删除,从而导致最后一个不可用配额同时被 pod Y 和 pod Z 重复使用。
在灾难性事件中,大量 pod 在短时间内被删除时,这个问题尤其显著。假设有个控制器在同一个 Deployment 下,在 10 毫秒内并发 100 个删除不同 pod 的请求:由于 webhook 推送了 100 次准入历史,临时不可用配额下降了 100;但如果 aggregator 在观察到首个删除事件后、观察到后续事件之前过快进行对账,其中 99 个会立即被撤销。如果前面的控制器再对其他 pod 激增 99 个删除请求,不可用约束便会突破近双倍了。
为缓解此问题,Podseidon 提供了两个配置项,可全局配置或针对单个 PodProtector 进行覆盖:
maxConcurrentLag 限制限制单个 PodProtector 中准入历史的最大条目数。当该值小于 maxUnavailable 阈值时,可以确保 aggregator 单次最多清除 maxConcurrentLag 个条目。然而,将其设置得过小会导致 PodProtector 缓冲挤拥,引致更多假阳性(误拒绝)返回值,从而可能损害发布、缩容等操作的效率,尤其是当频繁失败触发控制器退避逻辑的更长间隔时尤其显著。
aggregationRateMillis 为 aggregator 添加从接收 pod 事件到执行聚合的延迟,限制同一 PodProtector 的聚合频率。如果一个 PodProtector 最近没有新的 pod 事件,收到第一个事件后会先等待 aggregationRateMillis,以容许更多激增性事件进入 informer,然后再执行聚合;每次聚合后至少 aggregationRateMillis 内不会对同一 PodProtector 进行重聚合。虽然聚合触发有所延迟,但聚合过程使用的是最新的快照,而非事件接收时的快照。这有助于缓解由上述相同对象突然大量删除引起的竞态条件问题(除非我们不幸接收到在第一个事件后正好过了 aggregationRateMillis 毫秒才开始发生删除激增)。然而,将该值设置得过高可能会导致更多由于控制器响应变慢帶來的误拒绝,例如在正常的滚动更新过程中,当新版本的 pod X 状态切成可用,可以把旧版本的 pod Y 下线时:

虽然这可能会导致偶尔的误判拒绝,但 replicaset controller 或 GC controller 的重试退避通常能够在第二次尝试时成功删除。

一个生产机房内的实际 pod 删除准入率。在有开启 Podseidon 保护的 pod 删除中,
拒绝率甚低,基本对用户无影响。
触发最终压缩
Kubernetes 的一个重要特性是系统必须具备自愈能力,状态最终趋向 spec 的要求。然而,上述算法单独使用时无法实现这一目标:当 webhook 插入了错误的删除事件时,aggregator 不会主动将其移除,而需等待 kube-apiserver 发送新的 pod 事件才能清除该记录。这可能会在低副本场景中导致死锁——pod 删除请求被 webhook 拦截,webhook 正在等待 aggregator 清除先前无效的删除事件以释放不可用配额,而 aggregator 则在等待 apiserver 发送事件,但由于 pod 删除被阻塞,apiserver 根本没有事件可发送。
为解决这一问题,我们利用了单 list-watch 流中 pod 事件的强序特性:每个快照都是一个原子视图,涵盖了快照之前所有由 list-watch 选择器覆盖对象的所有事件。换句话说,如果我们在 t₀收到一个 pod X 的事件,在 t₀到 t₁之间没有其他事件,然后在 t₁收到 pod Y 的事件,我们就可以确认自 t₀事件以来,pod X 没有发生新变化。因此,aggregator 可以维护一个待处理池,追踪所有尚未完全压缩准入历史的 PodProtector 对象,并在收到 list-watch 流任意 pod 事件时尝试对这些对象进行对账。即使这些事件来自于未被 PodProtector 选择器选中的 pod,更新了的全局快照时间也能触发更多准入历史压缩。
这问题在大规模集群中便解决了,在这些集群中,每秒可能会有数百或数千次由真正的 pod 生命周期或数据面事件自然触发 pod 更新。然而,对于每秒不到一 pod 事件的小型集群,更新事件频率过低,可能数分钟才自然触发一遍对帐,对用户造成可见的影响,如滚动或缩容操作明显减慢。为解决此问题,可能会想到几种方案:
为准入历史增设到期时间——这实际上违背了 Podseidon 的设计目的。在一些极端灾难事件中,watch 请求很可能会中断并触发 relist,从而在此期间引起非常高的 watch 时延(在大规模集群中 relist 可能需要长达数分钟),甚至无法重新建立新的一致性快照。增设到期时间在这种情况下实际上会使 Podseidon webhook 失去原来的作用。
如果一段时间内没有事件则进行 relist——但这样的时间周期该设多长?relist 过程本身也会导致长时间没有事件,可能会使情况更糟。而且 pod list 是個很重的操作,频繁的 relist 可能冲击 kube-apiserver 的性能。
在一个 dummy pod 上触发事件——这听起来可能不太优雅,但这是我们找到最切实可行的方案。通过循环更新一个会在 list-watch 流中发送的 dummy pod(例如切换 pod 注解),我们可以确保 watch 流的最小理论事件率,从而在正常情况下将无效准入历史条目的存续时间限制在这一时间下。
因此,只有第三种方案是可行的。podseidon-aggregator 中嵌入了一个循环组件来执行该方案,可以通过 CLI 选项启用。

根据直方图指标推算出某机房中不同大小的集群发送 watch 事件前后间的最大时间间隔
这个强一致性要求意味着,每个 list-watch 流的准入历史和聚合状态必须独立存储(我们称之为“cell”)。在 aggregator 需要使用多个 reflector(如多个命名空间、独立标签选择算符等)场景中,它们必须在不同的 aggregator 实例中执行,且 webhook 必须配置以识别每个准入请求所对应的 cell。
PodProtector 批量更新
由于每个删除请求都需要在托管 PodProtector 对象的集群("core 集群")中预留不可用配额,每次准入都会对 core 集群进行一个独立的 compare-and-swap 更新。这种设计在架构上并不合理,因为拆分多集群本来的目标就是将 apiserver 负载从 O(mn)(m 为部署数量,n 为每个部署的副本数)降低到 core 集群 O(m)。况且,对同一 PodProtector 对象进行多次并发更新很可能导致冲突退避,在最坏情况下会给 apiserver 带来 O(mn²)的负载压力。
通过 RetryBatch 机制,对同一对象的请求进行缓冲和批量处理,可缓解这个问题。当某个 PodProtector 的更新请求正在处理时,所有其他更新尝试都会被暂存在 RetryBatch 通道中等待下次请求时同时批量处理。在下次请求时,所有缓冲请求都会基于最新版本的 PodProtector 执行操作:在配额可用时扣除配额,并向 apiserver 提交更新后的 PodProtector 状态。这些请求在发生冲突时可带到再下一批次里重试,若配额不足或准入请求超时则会被直接拒绝。

此方案显著降低了 core 集群 apiserver 的请求量。在字节跳动生产环境中,使用 3 个 webhook 副本的配置,批量处理比率约为 1:2。理论上,请求速率不应超过正常多集群部署状态聚合器的状态更新速率乘以 webhook 实例数量。

某机房集群联邦中开启 Podseidon 保护的 Pod 的删除准入请求率
和实际 core 集群的 PodProtector 更新请求率(包括冲突)
各种多集群范式的适配
Podseidon 对集群管理系统保持中立性。它既可用于单集群部署,也可应用于单主多成员联邦架构,亦可支持去中心化集群网。
通用配置涉及两种集群类型:"core"与"worker"。托管 PodProtector 的集群称为"core"集群,运行受 PodProtector 保护 pod 的集群则称为"worker"集群。若某集群同时具备这两种资源,则可兼具两种类型;也可以同时存在多个核心或工作集群。

Various cluster management systems
各组件的部署拓扑如下:
Generator:每个 core 集群部署一组实例
Aggregator:每个 worker 集群部署一组实例,连接所有 core 集群
Webhook:全网部署一组实例供所有 worker 集群使用,连接所有 core 集群
也可为每个 worker 集群部署集群内 webhook,但这会降低 RetryBatch 效果,增加 core 集群控制面负载
受保护场景
凭借端到端设计特性,Podseidon 能在多种场景下防止 pod 被意外删除。虽然无法杜绝所有问题,但有效地强化了 Kubernetes 控制面中的单点故障环节:
Etcd 数据损坏
core 集群根负载对象(如 Deployments)丢失:若根对象未显式设置 deletionTimestamp,Podseidon generator 不会删除 PodProtector 对象。为确保该机制可靠运行,请尽量从根对象而非派生对象创建 PodProtector
core/worker 集群中间对象丢失(如 ReplicaSets):尽管这些对象可能被篡改,但只要 PodProtector 有效,它们就无法影响实际 pod
worker 集群 pod 丢失:参见下文"Kubelet 适配"章节
core 集群 PodProtector 丢失:当前版本未受保护;参见下文"潜在改进"章节
worker 集群 ValidatingWebhookConfiguration 丢失:无法保护,因这是 apiserver 与 etcd 间的直连链路(可另外添加 apiserver 内置准入插件加固 webhook 存在性检查)
显式 pod 删除(示例可参见文首):Podseidon 全面覆盖
系统集成
Kubelet 适配
尽管 Podseidon 能阻止 worker 集群中的显式 pod 删除,还有最后一个环节仍需解决:当 worker 集群发生 etcd 损坏或配置错误(如 kubelet 连接错误 apiserver,而该集群恰好有同名节点)时,pod 可能被意外移除。由于该问题存在于 kubelet 与 apiserver 的直接交互中,保护实际容器不被删除的唯一可靠方案是修改 kubelet 代码。具体而言,kubelet 被改造为:仅当明确查看到带有 deletionTimestamp 的 pod 时才会停止容器;若所有者 pod 凭空消失则保持容器运行。该方案虽然会影响强制删除等机制,但通过充分的监控和限制强制删除使用姿势,实践证明能有效减少人为误操作的风险。
类似项目
PodUnavailableBudget
OpenKruise 提供的类似组件 PodUnavailableBudget 采用相似原理拒绝超出可用预算的 pod 删除操作。Podseidon 扩展了多集群支持,提升了吞吐量和性能,并深入强化了容灾特性。
潜在改进
PodProtector informer 健壮性:当 core 集群的 PodProtector 被异常清除时,webhook 实例内存中仍存有副本。通过在 generator 删除前显式标记 PodProtector 失效并向 webhook 实例广播该事件,可在 etcd 损坏等场景下依然维持有效保护。
多样化保护条件:除 pod 就绪状态外,Podseidon 可整合 Scheduled 或 Initialized 等 pod 状态。虽然滚动控制器聚焦用户容器报告的就绪状态,但用户行为可能导致就绪状态突变,使监控复杂度提升。采用其他状态或 pod 阶段能为控制平面稳定性监控提供更可靠的 SLI 指标
使用 Podseidon
Podseidon 已在 GitHub 上开源。
评论