自动重启无响应的 Worker,且不引起重启风暴
我们的监控系统在 2 分钟内检测到一个挂起的 GPU 工作进程,然后观察到它持续宕机了 6 个小时。安全地缩小检测和修复之间的差距。
一个 GPU 集群中的两个工作节点在同一个晚上挂起了,前后相隔六分钟。监控堆栈在两分钟内就注意到了这一情况,并发布了一条详细的警报:服务器名称、诊断结论,以及导致其崩溃的任务。之后六个小时里什么也没发生,直到有人醒来并输入了 docker restart。
警报是完美的。但服务中断的时间依然很长。这篇文章要讨论的就是这两个事实之间的差距:为什么检测系统常常“有眼无手”,以及如何才能将重启操作接入检测路径,而不产生更糟糕的问题——即导致健康机器被杀死的“重启风暴”。
检测和修复各自独立发展
没有人会故意设计这样的鸿沟。它是逐渐累积形成的。
我们的系统有两个独立的安全机制,它们是为应对不同事件而相隔数月构建的。第一个是挂起检测器:如果一个工作进程报告状态为 processing 但其心跳停止更新超过 120 秒,就宣布它无响应并通过频道告警。第二个是自动重启器:如果一个工作进程处于明确的 error 状态达 30 分钟,就收集并发布其日志,然后通过 SSH 重启容器,同时还有一个每日熔断器将每台服务器的重启次数限制在三次以内。
每个机制都按设计工作。陷阱在于它们的结合之处:重启器所依赖的触发条件与检测器的不同。重启需要 state == "error"。我们实际遇到的挂起是堆损坏,它在作业中途冻结了进程,因此状态字段永远保持为 processing,而心跳则停止了。检测器触发了。重启器的条件从未成立。更糟糕的是,重启器有一条“礼让”规则:如果挂起检测器已确认发生挂起,它就退到一旁,让检测器来处理。但它让出的那个组件却无能为力。
当你的修复机制所依赖的触发条件与检测机制的不同时,任何只有其中一个机制能识别的故障模式,都会变成一个没有后续处理的警报。把这对机制作为一个单元来审计:对于检测器可能产生的每一种判定结果,都要指明由哪个组件来对其采取行动。任何判定结果的答案是“最终由人来处理”的,都是你选择留下的一个鸿沟,而这至少应该是一个有文档记录的选择。
默认情况下,重启并非安全操作
这个修复方案听起来微不足道:从挂起路径调用重启函数。之所以需要进行设计评审而不是打个单行补丁,是因为自动重启就像一把上了膛的枪。糟糕的重启策略其失败模式不是一台工作节点宕机,而是整个集群范围的风暴,自动化杀死机器的速度比它们启动的速度还快。
使其能够发布的保障措施,按其运行顺序列出:
行动前持久化确认。 确认的挂起状态必须持续一段固定的延迟(我们选择了 3 分钟)后,重启操作才会触发。检测过程本已需要连续两次不良观测;该延迟增加了对 GC 暂停、瞬时负载峰值和监控毛刺的容忍度。计时器通过 SETNX 存在 Redis 中,而不是在进程内存里,原因如下文所述。
每个服务器一个执行锁。 两个后端实例运行着相同的监控循环。两者大致会在同一时间得出相同的结论。一个带有短 TTL 的 SETNX 锁确保了只有一个实例会执行重启操作;失败的那个实例则直接跳过。警报去重和操作序列化是不同的关注点,所以我们分别使用不同的键:去重键用于抑制每次事件的重复通知,而锁则用于抑制对同一台服务器的重复命令。
在最后一刻重新验证。 在做出决定和执行 SSH 命令之间,有日志收集和警报发送等操作,延迟可能长达一分钟。在此期间,工作节点可能已经自行恢复。在执行前立即重新读取状态;如果心跳是新的,就取消操作并进行清理。因为一台机器一分钟前生过病就重启它,这正是自动化绝不能造成的危害。
共享的熔断器。 所有重启路径都会为每台服务器每天增加一个计数器,超过三次后,自动化将停止操作,只发送警报。关键在于,计数器在命令实际执行前才增加,而不是在处理流程开始时。早期版本会计算每一次尝试,因此,一次在日志收集阶段失败的运行仍然会消耗预算,而一个嘈杂的检测器可能会在没有发生任何一次重启的情况下耗尽配额。
当补救措施失败时升级处理。 一次未能解决挂起问题的重启不应是故事的结局。我们记录重启时间;如果同一台服务器在十分钟后仍然挂起,系统会发出一次性的“重启未能恢复”的呼叫并附上提及信息。如果没有这个机制,系统会发送“已触发重启”然后就沉寂了,读者会以为问题已经处理。那种行动后就沉寂的状态正是最初那次六小时中断发生的原因,而自动化可以完美地复现它。
你刚刚执行的重启会看似挂起
我们在审查中发现的最微妙的故障,其发生概率大约是抛硬币的概率,是自我造成的:双重重启。
执行 docker restart 后,容器会花费一分钟或更长时间来启动和加载模型。在此期间,旧的状态记录仍然存在于 Redis 中,显示为 processing 状态并带有一个过期的心跳,因为即将终止的进程没能更新它。对于恢复循环来说,这与一次新的挂起无法区分。它会重新激活其计时器,等待延迟结束,然后在启动过程中再次重启该容器。每一轮都会消耗熔断器的预算,因此一次真正的挂起就可能因为反复终止一个正在启动的容器而耗尽每日限额,然后在真正需要自动化时,它却放弃了。
解决方法是使用一个冷却键:在重启后,在一个固定的时间窗口内(我们使用 10 分钟)抑制重新激活,并且只要冷却键存在,就清除持久化计时器。通用规则是:任何暂时使系统看起来出现故障的自动化操作,都必须留下一个标记,告诉检测器“这是我干的,不是新的事件”。
在同一次审查中发现的另外两个相关陷阱也值得一提:
**在共享存储中而不是进程内存中测量事件持续时间。**我们的第一个草案是从一个内存中的结构体读取挂起开始时间的。该字段在每个重新确认周期都会被刷新,因此测得的持续时间永远徘徊在零附近,重启阈值也永远无法达到。可行的设计是使用 SETNX 将开始时间锚定在 Redis 中:第一个观察者获胜,每个实例都看到相同的时钟,多实例部署也不会互相重置对方的计时器。
**从数据而不是从本地结论中推导触发条件。**如果有两个监控实例,每个实例对“已确认”都有自己的看法。如果实例 A 激活了共享计时器,而实例 B(其观察结果落后一次)仍然认为服务器是健康的,那么 B 就会清除 A 的计时器,两者将无限期地冲突下去。直接从共享的状态记录中推导谓词,可以使每个实例计算出相同的答案。
某些故障不应触发重启
将一种判定结果与修复措施挂钩,并不意味着要将所有判定结果都挂钩。我们特意没有处理其中两种。
状态记录缺失意味着代理不可达:服务器可能正在重启、出现网络分区或被有意关机,而且我们目前就有一台机器出于成本考虑而处于关闭状态。通过 SSH 重启一台处于未知电源状态的机器,其结果介于无用和有害之间,因此该判定结果仅用于告警。并且,一个进度标记停滞但其心跳保持健康的作业也不会被重启,因为长时间运行的作业本身就可能呈现这种状态,而因为一个缓慢的任务就终止一个活跃的工作进程,是用一个误报换来了真实的损害。
由此得出的边界规则是:仅针对某些故障模式自动化修复措施,在这些模式下,操作是近乎幂等的,爆炸半径仅限于一个已经死亡的工作进程,并且错误猜测的代价很低。其他所有情况都应快速且响亮地升级给人工处理。
常见问题
为什么不直接使用容器健康检查并让编排器重启? 如果你在使用 Kubernetes 并配置了适当的 liveness probes,请首先这样做。这种模式适用于“挂起”仅在应用层面可见(例如 Redis 中的心跳、作业进度)而进程仍在接受 TCP 连接的情况,这会通过大多数端口级别的健康检查。我们的僵尸进程在六个小时内都响应连接。
3 分钟的延迟是否太慢? 把真实的时间线加起来:过时阈值、两个确认周期、延迟,然后是重启和模型加载。我们的方案从故障到恢复大约需要十分钟,而有人工介入则需要六个小时。将延迟缩短到低于服务的启动时间几乎没有好处,反而会增加误报的成本。
为什么所有重启路径共享一个熔断器,而不是每个路径各用一个? 熔断器保护的是物理机器,而机器并不关心是哪个代码路径重启了它。独立的预算会使最坏情况下的重启次数成倍增加。如果将它们分开,请为总和设置上限。
如果重启命令本身失败了怎么办? 将其视为一个一等的结果,而不仅仅是一行日志。释放去重键,以便下一个周期可以重试,并立即呼叫人工,因为一台甚至无法远程重启的机器已经超出了自动化应该决策的范围。