[译] Kubernetes 1.23 到 1.24:一次让 Reddit 宕机超 5 小时的升级踩坑

可爱的异常朋友们
还记得这个 500 错误页面吗?似乎是好久以前的事了。那时候的它既可爱又有趣。而现在,我们那只可怜的 Snoo 正被如山的点赞压得喘不过气来。不幸的是,如果你在美国时间 3 月 14 日下午尝试浏览 Reddit,你可能已经在这次 314 分钟的宕机事故 中看到了那只倒霉的 Snoo(而且还是在 Pi Day!)。或许你只看到了空空如也的主页,或者一个报错信息。不管怎样,Reddit 确实挂了。但这并不是你的网络问题,而是我们的锅。
今天我们将聊聊这次 Pi Day 宕机事件,但在此之前,我想先肯定我们团队的付出。在过去的几年里,我们一直致力于提高系统的可用性。事实上,我们的 CTO 写过一篇很棒的博文,详细介绍了我们这几年来的改进。按照 Reddit 的传统艺能,我就直接把那张图偷来贴在这儿了。

Reddit 的每日可用时间与当前 SLO 目标
正如你所见,我们在提升 Reddit 可用性方面取得了显著进展。虽然我们一直在强调改进并努力降低变更风险,但我们尚未在所有领域达到预期目标,因此我们深知某些变更仍然存在不合理的风险。Kubernetes 版本和组件升级对我们来说,依然是一个容易“搬起石头砸自己脚”(footgun)的巨大隐患。事实上,这也正是导致我们 3.14 宕机的主要诱因。
太长不看版
- 升级必要与风险:升级工作(尤其是 Kubernetes 集群升级)存在一定风险,但又是必须推进的任务。我们将通过充分的测试与验证尽可能降低风险,尽管如此,后续仍有大量工作等待完成;
- 隐蔽的升级深坑:在我们操作的特定集群上,从 Kubernetes 1.23 升级到 1.24 的过程中,我们踩到了一个前所未见且极其隐蔽的坑。我们花了几个小时才最终决定回滚(而回滚本身也是一个高风险操作);
- 备份恢复的痛点:备份恢复令人讨厌。我们现有的流程有大量问题需要改进。但幸运的是,这次它奏效了;
- 后知后觉的根因:直到我们恢复完成数小时后,才发现了那个极其隐蔽的根本原因;
- 还没有全军覆没:并非所有服务都中断了。我们的现代服务 API 层都保持弹性运行,但这影响了依赖关系中最关键的遗留节点,因此影响范围仍然包括大多数用户流,我们的现代化进程中仍需更多工作;
- 将危机化为动力:我们决心利用这次宕机事件,彻底改变我们长期以来的一些主要架构和流程决策,确保未来的集群升级安全无恙。
故障的开始
这事说起来有点讽刺意味。我们团队刚完成了一次之前不太成功的 Kubernetes 升级经验总结,那次问题并不严重,而且根因也已经完全解决了。所以我们又开始对同一个集群进行另一次升级了。
今年我们一直在大力进行内部整顿,试图让系统处于更加易于维护的状态。管理 k8s 集群在很多方面都很痛苦。Reddit 自 2009 年起就在云上运行,并且较早地采用了 k8s。在此过程中我们积累了一堆使用 kubeadm 工具构建的定制化集群。其中一些集群甚至大到无法通过各种云托管服务来支持。这段历史导致了升级节奏的不一致以及集群间配置的分裂。用 DevOps 的话说,我们养了一群需要精心照料的宠物,而不是管理一群标准化的牛。
译者注:其中 “宠物与牛” 对应 DevOps 中的 “Pets vs Cattle”。我没有想出更好的简化翻译方式,可以参考此文章进行理解。
计算团队(Compute Team)负责管理与运行工作负载相关的基础设施,并花费了大量时间来定义和完善我们的升级流程来改善现状。升级会先在专用的集群集上测试,然后按照优先级从低到高的顺序发布到生产环境。这次升级是我们团队本季度的重点任务之一,而作为公司最重要的集群之一:运行着我们技术栈中“遗留部分”(社区亲切地称为 Old Reddit)的集群,已准备好升级到下一个版本。负责此项工作的工程师在 UTC 时间 19:00 刚过就启动了升级,起初的 2 分钟一切正常。然后混乱降临。

Reddit 边缘流量,按 RPS 统计
网站瞬间陷入瘫痪。我们立即创建了事故工单,并召集所有人手排查问题。在 T+3 分钟内已全员就位。我们发现的第一件事是:受影响的集群完全丢失了所有指标(上图显示的是我们 CDN 边缘的统计数据,这些数据特意与主数据分开展示)。我们完全摸不着头脑。唯一明显的线索是 DNS 解析失败了。我们无法解析 Consul(用于跨环境动态 DNS 的服务)中的记录,也无法解析集群内的 DNS 条目。但奇怪的是,公共 DNS 记录的解析却一切正常。我们顺着这条线索查了一会却一无所获。这是我们在之前的任何升级或测试中从未见过的新问题。
面对部署失败,立即回滚是首选方案,我们第一时间也考虑过这点。但亲爱的 Redditor 们,k8s 并没有官方支持的降级流程。因为在升级过程中,k8s 会自动执行许多架构、数据迁移操作,所以没有回头路。因此,降级意味着必须从备份中恢复并重新加载状态!
我们向来谨慎,备份当然是升级流程中的标准动作。然而这个备份和恢复程序是几年前写的。尽管它在我们的小型测试集群中经过了反复、全面的测试,但它并没有完全跟上生产环境的变化,而且我们从未在生产集群上使用过它,更别说在这个集群上了。这意味着我们不确定执行恢复操作需要多久,但初步估计至少是数小时的停机时间。因此我们决定继续调查并尝试找到解决方案。
故障的表现形式
大约过去了 30 分钟,我们仍然没有找到明确的线索。更多人加入了电话会议。来自不同值班轮换的六位工程师正在一线排查,而其他几十人则在观察并提供建议。又过了 30 分钟,我们有了一些眉目,但仍无定论。是时候启动应急预案了,我们分出了一部分计算团队成员去准备从备份中恢复系统的流程。
在此期间,我们几个人仔细查看了日志。尝试重启组件,心想或许有些组件陷入了无限循环或连接池泄漏。同时也注意到以下几点:
- Pod 的启动和停止极慢;
- 容器镜像拉取极慢(在多千兆带宽下,拉取 <100MB 的镜像需要数分钟);
- 控制平面日志大量刷新,但没有明显的错误信息。
在某个时刻,我们发现容器网络接口(CNI) Calico 工作异常。它的 Pod 状态不健康。Calico 在我们环境中有三个关键组件:
- calico-kube-controllers:负责根据集群状态采取行动,将 IP 池分配给节点供 Pod 使用;
- calico-typha:一个聚合、缓存代理,位于 Calico 其他组件和控制平面之间,以减轻 K8s API 的负载;
- calico-node:网络的核心。在每个节点上运行的代理,负责为 Pod 动态配置网络接口。
我们首先发现 calico-kube-controllers Pod 卡在了 ContainerCreating状态。作为集群控制平面升级的一部分,我们也需要将容器运行时升级到适配的版本。该环境使用 CRI-O 作为容器运行时。最近在某个主机上升级 CRI-O 时发现了一个低危 bug:一个或多个容器退出后,会随机且低概率地卡在重新启动过程中。快速的解决方法是删除该 Pod,它会被重新创建,然后就可以继续操作了。但这次的情况并非如此,这不是问题所在。
接下来我们决定重启 calico-typha 服务。情况开始有趣起来:在删除所有 Pod 后,等待重启却并未触发新 Pod 创建。等待数分钟后,依旧没有新 Pod 生成。为了打破僵局,我们对控制平面组件进行了滚动重启,但情况依然没有变化。最后尝试了最经典的重启大法,关闭整个控制平面再重新打开,不过这一尝试也未能奏效。
此时有人发现 API 服务器日志中出现了大量写入操作超时。但并非写入本身的超时,而是集群上准入控制器调用超时。Reddit 使用了多个不同的准入控制器 Webhook。在这个集群上,唯一监视所有资源的准入控制器是 Open Policy Agent(OPA)。由于 OPA 本身已经宕机,我们趁此机会删除了它的 Webhook 配置。超时立即消失了,但集群依然没有恢复。
回滚流程
此时宕机已经持续了两个多小时,我们已经想不出什么好办法了。是时候做出艰难的决定了:从备份中恢复。考虑到大部分正在运行的工作节点都会因为恢复而失效,我们开始终止这些节点,这样在控制平面恢复后就不用再进行漫长的协调工作了。不幸的是,由于这是我们最大的集群,这个过程也相当耗时,所有 API 调用大约需要 20 分钟才能完成。
完成上述步骤后,我们着手进行恢复工作,这是所有参与者之前从未执行过的流程,更不用说在我们最容易出现故障的单点故障上进行恢复了。简而言之,恢复流程如下:
随后我们开始了恢复工作,这是所有参与者之前从未执行过的流程,一个从未有人在我们“最喜欢的单点故障”核心集群上执行过的流程:
译者注:这里最后一句是原文作者的一个黑色幽默。
- 缩容至单节点:终止其他控制平面,只保留一个;
- 组件版本回滚:将仅存节点上的 K8s 组件回滚到旧版本;
- 单点数据恢复:执行 etcd 数据恢复操作;
- 重建高可用:启动新的控制平面节点,并将它们加入集群以同步数据,重建高可用。
立刻,我们遇到了问题。这个程序是针对已过时的 Kubernetes 版本编写的,而且早于我们切换到 CRI-O 的时间点,意味着所有指令都是基于 Docker 的。命令语法变了,参数失效了,我们不得不现场重写程序。
我们立即发现了一些问题。该流程是基于一个现已停止维护的 k8s 版本编写的,而且早于我们切换到 CRI-O 的时间,这意味着所有指令都是针对 Docker 编写的。导致了一些令人困惑的变量,例如命令语法已更改、参数不再有效,我们不得不重写该流程用来适应这些变化。但正如你即将看到的,在某个时刻对我们造成了不利影响。
在我们环境中并非把所有控制节点一视同仁。会给它们编号,第一个节点通常被视为“特殊性”的,将它作为操作基准,实际它们功能是一样的。但我们没有设置主机名来反映它们在控制节点中的身份,而是保留 AWS 默认主机名(如 ip-10-1-0-42.ec2.internal)。恢复流程要求我们应该终止除第特殊节点外的所有控制节点,然后将备份恢复到该节点,然后用新节点替换其他节点。
第一个节点的恢复完成了。随着集群自动缩放器(Cluster Autoscaler)启动,其他节点开始陆续上线。这是一个好兆头,表明网络恢复了。但当时我们还没做好充分准备,因为:这是一个大型集群,仅靠单节点控制平面很可能在高负载下崩溃。所以我们希望在真正开始扩容前,先让另外两个控制节点恢复上线。因此关闭了自动缩放器以争取时间将系统恢复到已知状态。然而当我们启动它俩时,却遇到了新的难题:AWS 提示我们控制节点急需的那款服务器型号库存不足,这让我们进退两难。因为当时自动化部署命令(Terraform apply)已经运行了,如果强行中断,很可能把后台的资源状态文件(State)搞乱,引发一连串未知的连锁反应。在那个节骨眼上,我们要尽量避免让局势雪上加霜。好在最终 AWS 腾出了资源,服务器启动成功,我们随即开始尝试将它们加入集群。
译者注:最后的问题是通过 IaC 工具 Terraform 在 AWS 上一键部署控制节点,但配置中让控制节点使用的云服务器没货了导致一直卡在部署中。
下一个问题出现了:新节点无法加入。每次都会卡住,没有错误提示,原因是无法连接到第一个节点上的 etcd。几位工程师再次研究连接失败的原因,其余同事则计划如何从冷启动开始,缓慢而优雅地恢复工作负载。只用了几分钟就找到了问题所在:我们的恢复流程对操作顺序和恢复目标都有严格的规定,但备份流程却没有限制。我们的备份流程可以在任何控制平面节点上执行,但恢复必须在同一节点上执行。然而恢复时没有这么做。这意味着,由于主机名不匹配,正常工作的节点提供的 TLS 证书对其他任何设备都无效,无法与其通信。由于缺乏文档,我们费了一番周折才生成了新的有效证书。新节点成功加入,我们又拥有了一个正常运行的高可用性控制平面。
与此同时,主要响应团队开始恢复流量。这是我们很长一段时间以来遇到的最长宕机时间,因此我们采取了极其保守的策略,最初只恢复了大约 1% 的流量。Reddit 依赖大量的缓存才能勉强维持运行。如果立即恢复所有流量,下游服务可能因准备不足,在突发的大流量冲击下出现惊群效应(Thundering Herd Problem)问题。
在服务中断的情况下,这种情况往往会更加严重,因为闲置的服务通常会缩容来节省资源。我们有一些工具可以帮助解决这个问题,将在另一篇博文中介绍,但关键在于我们不想一下子就把所有资源都用光。我们从 1% 开始,逐步小幅恢复:5%、10%、20%、35%、55%、80%、100%,网站基本恢复了正常。一些特别敏感的旧服务通过手动重启的方式确保它们在流量恢复后不会出现故障。
成功了!故障已经恢复。
但我们仍然不知道它最初为什么发生。
通过日志锁定故障
我们随即展开了进一步调查。开始搜寻所有能想到的线索,试图缩小故障发生的时间范围,希望能从指标崩溃前的最后时刻找到一些线索。然而并没有。不过这次一个历史决策帮了忙:我们的日志代理(Logging Agent)没有受到影响。指标是 k8s 原生的,但日志级别非常低。因此我们保留了这些日志。
我们首先尝试找出故障发生的确切时刻。控制平面的 API 日志在 UTC 时间 19:04:49 突然激增,日志量一瞬间增加了 5 倍。但其中唯一的线索是我们之前已经发现的:调用 OPA 超时。接下来我们检查了故障发生确切时刻的 OPA 日志。在控制平面 API 开始大量输出日志前约 5 秒,OPA 日志完全停止了。陷入了死胡同。真的是这样吗?
Calico 在某个时间点开始出现故障。我们查看了该时间段的日志,发现了下一个线索。

为了保持沟通的一致性,所有 Reddit 指标和事件活动均以 UTC 时间为准。由于我们的日志系统过于精确,此处的日志时间戳采用的是美国中部时间。
混乱爆发的前两秒,集群中所有 calico-node 守护进程开始丢弃通往第一个控制平面节点的路由。这是预期行为,因为该控制节点在升级过程中会离线。但出乎意料的是,所有节点的路由都开始断开。就在这时,我恍然大悟。
Calico 的默认工作方式是,集群每个节点都与其他所有节点直接建立对等连接,形成一个网状网络。这在小型集群中非常有效,因为它能显著降低管理的复杂性。但在大型集群中,这种方式会变得非常繁重;这些连接(各节点都要将路由传播到其他节点)的扩展性很差。这时就需要用到路由反射器(Route Reflectors):它的理念是指定少量节点与所有节点建立对等连接,而其余节点只与这些反射器建立对等连接。这样可以显著减少连接数,并降低 CPU 和网络开销。从理论上讲,路由反射器非常理想,并且可以扩展到更多的节点数量(建议在节点数 >100 时使用,而我们的节点数起码要在后面加个零)。然而 Calico 对路由反射器的配置比较晦涩难懂,难以追踪。这正是我们问题的根源所在。
路由反射器是几年前由前任团队设置的。随着时间的推移、人员流动和团队发展,所有了解其存在的人员都已离职或跳槽。只有我们规模最大、历史最悠久的集群还在使用它们。因此,没人意识到它可能存在问题。此外,Calico 的配置方式无法通过代码轻松管理。路由反射器配置的一部分需要下载 Calico 特有的数据(这些数据只能通过其 CLI 界面管理,而不是标准的 Kubernetes API),然后手动编辑并上传。要实现这一点,就需要编写自定义工具。不幸的是,我们当时并没有这样做。因此路由反射器的配置没有被提交到任何地方,导致我们没有任何记录,工程师也无法追踪问题。一位工程师碰巧记得我们曾使用过这个功能,并在这次事后分析过程中进行了研究,发现这才是真正影响我们并影响我们的方式。
故障总结
它究竟是如何崩溃的?这真是最出乎意料的事情之一。在研究过程中,我们发现路由反射器的配置方式是将控制平面节点设置为反射器,并让所有其他节点都使用这些反射器。这相当直接,而且在自动扩缩容集群中,控制平面节点是唯一始终可用的节点,这样做也合乎逻辑。然而,这种配置方式存在一个隐蔽的缺陷。请看下面的代码,看看你能不能发现它。提示一下:我们当时升级到的版本是 k8s 1.24。

一个令人毛骨悚然的 Kubernetes 对象在 YAML 中的内容
路由反射器的 nodeSelector 和 peerSelector 指向标签 node-role.kubernetes.io/master。在 k8s 1.20 版本中,将其术语从 master 更改为 control-plane。在 1.24 版本中,他们移除了对 master 的引用。这就是导致我们服务中断的原因:k8s 节点标签。
但这并非全部原因。实际上真正的根因更加系统性,也是我们多年来一直在努力解决的问题之一:不一致。
Reddit 的几乎每个 k8s 集群都以某种方式进行了定制。无论是仅在该集群上运行的独特组件、独特的工作负载、仅在单个可用区运行的开发集群,还是其他各种因素,都存在差异。这是自然增长的必然结果,也导致了我们难以追踪的频繁宕机。计算团队的一项重要任务就是简化这些定制化n欸容,使我们的环境更加同质化。
在过去两年里,我们投入了大量精力来打破原有模式,推动构建以可持续性为核心的基础设施。越来越多的组件正在实现标准化,并在不同环境间共享,而非到处采用定制配置。我们拥有更多可以进行可靠测试的预生产集群,而不是贸然投入生产环境。我们正在开发用于管理整个集群生命周期的工具,力求使所有集群尽可能保持一致,并可根据需要重新创建或复制。我们正朝着仅在绝对必要时才使用定制组件的方向发展,并努力在合理的情况下将这些定制组件打造为新的标准。尤为重要的是,我们在尽可能地对所有组件进行编码,以确保应用的一致性,并清晰地记录我们为实现当前目标所做的选择。对于无法编码的内容,我们也在详细记录,并评估如何用更好的替代方案来取代这些特殊情况。这是一条漫长而艰难的道路,但这是我们自己选择的路,这样才能为我们的工程师和用户提供更好的体验。
尾声
感谢你读到这里,在此我们也想衷心感谢社区每位成员一直以来的关注和支持。没有你们,就不会有今天的 Reddit。正是有了大家,我们才能始终充满热情地持续建设这个平台——尽管过程中难免经历起伏(值得高兴的是,随着我们对网站可靠性的持续投入,宕机的情况正变得越来越少!)。

浙公网安备 33010602011771号