推进我们的Chef基础设施:无中断的安全保障
去年,我写了一篇名为《推进我们的Chef基础设施》的博文,探讨了我们Chef基础设施多年来的演进。我们谈到了从单一Chef堆栈到多堆栈模型的转变,以及随之而来的挑战——从更新我们处理cookbook上传的方式,到应对Chef搜索的限制。
如果你还没有机会阅读那篇文章,我强烈建议你先读一读,以便充分理解本文的背景。
在Slack,保持服务可靠始终是重中之重。在上一篇文章中,我讨论了使Chef和EC2配置更安全的第一阶段工作。完成那些之后,我们开始研究还能做些什么来使部署更安全、更可靠。
我们探讨过的一个想法是迁移到Chef Policyfiles。那将意味着替换角色和环境,并要求数十个团队更改他们的cookbook。从长远来看,这可能会让事情更安全,但在短期内,这将是一项巨大的工作,并且带来的风险可能超过其解决的问题。
因此,本文是关于我们选择的路径:以不中断cookbook或角色的方式改进我们现有的EC2框架,同时为我们的Chef部署提供更多安全保障。
拆分Chef环境
以前,每个实例都有一个cron作业,按设定的时间表每隔几小时触发一次Chef运行。这些计划的运行主要是为了合规目的——确保我们的机器群保持在一致且已定义的配置状态。为了降低风险,这些cron作业的时间在不同可用区之间错开,帮助我们避免同时在所有节点上运行Chef。这种策略给了我们一个缓冲:如果引入了错误的变更,它最初只会影响一部分节点,让我们有机会在问题扩散之前发现并修复它。
然而,这种方法有一个关键限制。在共享单一生产环境的情况下,即使Chef没有在所有地方同时运行,任何新配置的节点也会立即从该共享环境中获取最新(可能有问题)的变更。这成为一个重大的可靠性风险,尤其是在大规模扩展事件期间,数十或数百个节点可能以损坏的配置启动。
为了解决这个问题,我们将单一的生产Chef环境拆分成多个桶:prod-1、prod-2、……、prod-6。服务团队仍然可以以“prod”身份启动实例,但在幕后,我们根据其可用区将每个实例映射到这些更具体的环境之一。这样,新节点不再从单一的全局真理源拉取配置——它们被均匀地分布在隔离的环境中,这增加了一层额外的安全性和韧性。
在Slack,我们的基础AMI包含一个名为Poptart Bootstrap的工具,该工具在AMI构建过程中被预装。此工具在实例启动时通过cloud-init运行,负责诸如创建节点的Chef对象、设置任何必需的DNS条目以及向Slack发布成功或失败消息等任务(根据拥有该节点的团队,可以自定义通道)。
为了支持环境拆分,我们扩展了Poptart Bootstrap,使其包含检查节点AZ ID并将其分配到编号的生产Chef环境之一的逻辑。这一变更使我们能够停止将所有生产节点指向单个共享的Chef环境,而是将它们分散到多个环境中。
以前,更改单一prod环境中的cookbook版本会影响整个机器群。通过这种新的分桶方法,我们获得了独立更新每个环境的灵活性。现在,对单个环境的更改只影响特定可用区中的节点,显著降低了部署期间的风险和爆炸半径。
在这种方法下,我们在整点将最新的cookbook变更提升到沙箱环境。这些变更随后由Kubernetes cron作业管理,并在整点滚动到开发环境,最后在半小时时开始滚动到生产环境。
prod-1被视为我们的金丝雀生产环境。它每小时接收一次更新(假设有新的变更合并到cookbooks中),以确保变更在真实的生产环境中定期得到验证——与我们的沙箱和开发环境一起。这使我们能够更早、以更小、更安全的方式发现影响生产的问题。
对prod-2到prod-6的变更使用发布火车模型进行滚动。在将新版本部署到prod-2之前,我们确保前一个版本已成功通过所有生产环境直到prod-6。这种交错式滚动最小化了爆炸半径,并让我们有机会在发布火车的更早阶段发现回归问题。
如果我们像处理prod-2及以后环境那样,等待一个版本通过所有环境后再更新prod-1,我们最终将测试具有大量累积变更的构件。相比之下,频繁地使用最新版本更新prod-1使我们能够在问题被引入时更早地发现它们。
下面是一个示例,展示了构件版本A在这个发布周期中如何通过每个环境:
|
|
(注意:文章后续有大量类似表格,详细描述版本B、C、D等随时间推移在不同环境中的传播过程,此处为保持摘要简洁,未完全展开。其核心模式是:沙箱和开发环境在整点接收最新版本;生产-1在半小时时接收开发环境的版本;生产-2及以后环境在半小时时,依次接收前一个生产环境的版本,形成接力式的“发布火车”,确保版本在全部生产环境中逐步推进。)
如你所见,变更现在需要更长的时间才能在所有生产节点上完全铺开。然而,这种延迟提供了在不同可用区之间部署的宝贵时间,使我们能够在有问题的变更完全传播之前发现并解决它们。
通过引入拆分生产环境(例如,prod-1到prod-6),我们已经能够避免新节点一上线就拉取错误配置的老问题。现在,每个节点都绑定到其可用区的环境,变更逐步滚动,而不是一次性全面铺开。这意味着如果一个可用区出现问题,我们可以在修复问题的同时在其他可用区安全地扩展。
这是一个比之前更具韧性的设置——我们消除了单点故障,并内置了护栏,使整个系统更安全、更可预测。
我们触发Chef的方式发生了什么变化?
现在我们有了多个生产Chef环境,每个环境在不同时间接收更新。例如,如果一个版本目前正处于滚动过程中,下一个版本必须等待该滚动在所有生产环境中完全完成。另一方面,当没有正在进行的更新时,新版本可以更快地滚动到所有环境。
由于这种可变性,通过固定的cron计划来触发Chef不再实用——我们无法可靠地预测给定的Chef环境何时会接收到新的变更。因此,我们不再依赖计划运行,而是构建了一个基于信号触发节点上Chef运行的新服务。该服务确保Chef仅在确实有可用更新时才运行,从而提高了安全性和效率。
Chef Summoner
如果你还记得我之前的博文,我们构建了一个名为Chef Librarian的服务,它监视新的Chef cookbook构件并将它们上传到我们所有的Chef堆栈。它还公开了一个API端点,允许我们将特定版本的构件提升到给定的环境。
我们最近增强了Chef Librarian,使其在将某个构件的版本提升到某个环境时,向一个S3存储桶发送一条消息。该S3存储桶的内容如下所示:
|
|
在chef-run-triggers键下,我们维护一个嵌套结构,其中每个键代表我们的一个Chef堆栈。在每个堆栈键内,还有代表每个环境名称的附加键。
这些环境键中的每一个都包含一个JSON对象,其内容类似于下面的示例:
|
|
接下来,我们构建了一个名为Chef Summoner的服务。该服务在Slack的每个节点上运行,负责检查对应于节点Chef堆栈和环境的S3键。如果Chef Librarian发出的信号中存在新版本,该服务会读取配置的splay值并相应地调度一次Chef运行。
splay用于错开Chef运行时间,使得给定环境和堆栈中的所有节点不会同时尝试运行Chef。这有助于避免负载峰值和资源争用。我们还可以根据需要自定义splay——例如,当我们使用来自Librarian的自定义信号触发Chef运行并希望更刻意地分散运行时。
然而,如果没有合并任何变更,也没有构建新的Chef构件,Librarian就没有什么可提升的,也不会发出新的信号。尽管如此,我们仍然需要确保Chef至少每12小时运行一次,以保持合规性并确保节点保持在其预期的配置状态。因此,Chef Summoner也会至少每12小时运行一次Chef,即使没有收到新的信号。
Summoner服务在本地跟踪自己的状态——包括上次运行时间和使用的构件版本等信息——以便它可以比较任何新信号与最近的运行,并确定是否需要新的Chef运行。
整体流程如下所示: (图示说明:Chef Librarian提升构件 -> 写入S3信号 -> Chef Summoner读取信号并调度运行 -> 执行Chef运行)
现在Chef Summoner是我们依赖的触发Chef运行的主要机制,它成为了基础设施的关键部分。在节点被配置之后,后续的Chef运行负责保持Chef Summoner本身更新到最新的变更。
但如果我们不小心推出了一个损坏的Chef Summoner版本,它可能会完全停止触发Chef运行——使得我们无法使用正常的部署流程推出修复版本。
为了缓解这种情况,我们在每个节点上都内建了一个后备cron作业。这个cron作业检查Chef Summoner存储的本地状态(例如,上次运行时间和构件版本),并确保Chef至少每12小时运行了一次。如果cron作业检测到Chef Summoner未能在该时间范围内运行Chef,它将直接触发一次Chef运行。这为我们提供了一个恢复路径,以推出一个正常工作的Chef Summoner版本。
除了这个安全网,我们还有工具允许我们在需要时在整个机器群或节点子集上触发临时的Chef运行。
下一步是什么?
我们EC2生态系统的所有这些近期变更使得滚动基础设施变更变得显著更安全。团队不再需要担心错误的更新影响整个机器群。虽然我们已经取得了很大进展,但这个平台仍然不完美。
一个主要限制是我们仍然无法轻松支持服务级部署。理论上,我们可以为每个服务创建一组专用的Chef环境,并单独提升构件——但在Slack运营着数百个服务的情况下,这在规模上很快就会变得难以管理。
考虑到这一点,我们决定将我们的遗留EC2平台标记为功能完成,并将其转入维护模式。取而代之的是,我们正在构建一个全新的EC2生态系统,名为Shipyard,专门为那些尚不能迁移到我们基于容器的平台Bedrock的团队设计。
Shipyard不仅仅是我们旧系统的一次迭代——它是关于EC2基础服务应如何工作的彻底重新构想。它引入了诸如服务级部署、指标驱动的滚动以及出现问题时完全自动化的回滚等概念。
我们目前正在构建Shipyard,并计划在本季度进行软启动,目标是让前两个团队进行测试和反馈。我期待在下一篇博文中分享更多关于Shipyard的信息——敬请关注!