无中断升级:Slack如何构建更安全的Chef基础设施

本文详细介绍了Slack工程团队如何通过拆分Chef生产环境、构建Chef Summoner信号触发服务以及设计分阶段发布流程,在不对现有Cookbooks和角色造成破坏的前提下,显著提升其Chef部署的安全性与可靠性,从而避免配置变更影响整个计算集群。

推进我们的Chef基础设施:无中断的安全升级

去年,我写了一篇题为《推进我们的Chef基础设施》的博客文章,探讨了我们多年来Chef基础设施的演进。我们讨论了从单一的Chef堆栈转向多堆栈模型的转变,以及随之而来的挑战——从更新我们处理Cookbook上传的方式,到应对Chef搜索的限制。

如果你还没机会阅读那篇文章,我强烈建议你先看一下,以便充分理解本文的背景。

在Slack,保持服务的可靠性始终是重中之重。在我上一篇文章中,我谈到了使Chef和EC2供应更安全工作的第一阶段。完成那一步后,我们开始研究还能做些什么,使部署更加安全和可靠。

我们探索的一个想法是迁移到Chef Policyfiles。那将意味着替换角色和环境,并要求数十个团队更改他们的Cookbooks。从长远来看,这可能会让事情变得更安全,但在短期内,这将是一项巨大的努力,并且带来的风险大于所解决的问题。

因此,本文是关于我们选择的道路:以不破坏Cookbooks或角色的方式改进我们现有的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版本会影响整个集群。有了这个新的分桶方法,我们获得了独立更新每个环境的灵活性。现在,对单个环境的更改只影响特定AZ中的节点,显著降低了部署期间的风险和爆炸半径。

采用这种方法后,我们在整点(即X:00)将最新的Cookbook变更提升到沙箱环境。这些变更由Kubernetes cron作业管理,并在整点滚动到开发环境,最后在30分钟(即X:30)开始滚动到生产环境。

prod-1被视为我们的金丝雀生产环境。它每小时接收一次更新(假设有新的变更合并到Cookbooks中),以确保变更能定期在真实生产环境中得到验证——与我们的沙箱和开发环境一起。这使我们能够更早地、以更小、更安全的方式发现影响生产的问题。

对prod-2到prod-6的变更采用发布火车模型进行滚动部署。在将新版本部署到prod-2之前,我们确保之前的版本已成功通过所有生产环境直至prod-6。这种交错式的滚动部署最小化了爆炸半径,并让我们有机会在发布火车的早期发现回归问题。

如果我们等待一个版本通过所有环境后再更新prod-1——就像我们对prod-2及之后的环境所做的那样——我们最终将测试包含大量累积变更的制品。相比之下,频繁地用最新版本更新prod-1允许我们在问题引入时就能更早地发现。

下面是一个示例,展示了制品版本A在这个发布周期中如何流经每个环境:

1
(此处省略了原文中的多组时间线表格,内容为不同时间点各环境的版本状态变化,体现了滚动发布的详细过程。其核心展示了版本A、B、C...如何逐步从沙箱、开发环境,经由prod-1作为金丝雀,再依次滚动到prod-2至prod-6的过程。)

正如上面所示,变更现在需要更长的时间才能完全滚动到我们所有的生产节点。然而,这种延迟在不同可用区之间的部署提供了宝贵的时间窗口,使我们在有问题的变更完全传播之前,能够发现并解决问题。

通过引入拆分的生产环境(例如,prod-1到prod-6),我们已经能够避免新节点一上线就拉取错误配置的老问题。现在,每个节点都绑定到其AZ对应的环境,并且变更逐步推出,而不是一次性全部推出。这意味着如果某个AZ出现问题,我们可以在修复问题的同时,安全地在其他AZ进行扩展。

这是一个比以前更有弹性的设置——我们消除了单点故障,并内置了护栏,使整个系统更安全、更可预测。

我们触发Chef的方式有何变化?

现在我们有了多个生产Chef环境,每个环境都在不同的时间接收更新。例如,如果一个版本目前正处于滚动部署的中间阶段,那么下一个版本必须等待该版本在所有生产环境中完全部署完毕。另一方面,当没有更新进行时,新版本可以更快地滚动部署到所有环境。

由于这种可变性,通过固定的cron计划触发Chef不再实用——我们无法可靠地预测给定的Chef环境何时会接收新变更。因此,我们已不再依赖定时运行,而是构建了一个基于信号触发节点运行Chef的新服务。该服务确保Chef只在有实际更新可用时才运行,从而提高了安全性和效率。

Chef Summoner

如果你还记得我之前的博客文章,我们构建了一个名为Chef Librarian的服务,它监视新的Chef Cookbook制品并将其上传到我们所有的Chef堆栈。它还暴露了一个API端点,允许我们将特定版本的制品提升到给定的环境。

我们最近增强了Chef Librarian,每当它将一个制品版本提升到一个环境时,都会向一个S3桶发送一条消息。该S3桶的内容如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
agunasekara@z-ops-agunasekara-iad-dinosaur:~ >> s3-tree BUCKET_NAME
BUCKET_NAME
└── chef-run-triggers
    ├── basalt
    │   ├── ami-dev
    │   ├── ami-prod
    │   ├── dev
    │   ├── prod
    │   ├── prod-1
    │   ├── prod-2
    │   ├── prod-3
    │   ├── prod-4
    │   ├── prod-5
    │   ├── prod-6
    │   └── sandbox
    └── ironstone
        ├── ami-dev
        ├── ami-prod
        ├── dev
        ├── prod
        ├── prod-1
        ├── prod-2
        ├── prod-3
        ├── prod-4
        ├── prod-5
        ├── prod-6
        └── sandbox

chef-run-triggers键下,我们维护一个嵌套结构,其中每个键代表我们的一个Chef堆栈。在每个堆栈键内,还有针对每个环境名称的附加键。

这些环境键中的每一个都包含一个JSON对象,内容类似于下面的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
  "Splay": 15,
  "Timestamp": "2025-07-28T02:02:31.054989714Z",
  "ManifestRecord": {
    "version": "20250728.1753666491.0",
    "chef_shard": "basalt",
    "datetime": 1753666611,
    "latest_commit_hash": "XXXXXXXXXXXXXX",
    "manifest_content": {
      "base_version": "20250728.1753666491.0",
      "latest_commit_hash": "XXXXXXXXXXXXXX",
      "author": "Archie Gunasekara <agunasekara@slack-corp.com>",
      "cookbook_versions": {
        "apt": "7.5.23",
        ...
        "aws": "9.2.1"
      },
      "site_cookbook_versions": {
        "apache2": "20250728.1753666491.0",
        ...
        "squid": "20250728.1753666491.0"
      }
    },
    "s3_bucket": "BUCKET_NAME",
    "s3_key": "20250728.1753666491.0.tar.gz",
    "ttl": 1756085811,
    "upload_complete": true
  }
}

接下来,我们构建了一个名为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 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的信息——敬请期待!

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计