Slack的Chef基础设施演进:在稳定中构建安全

本文详细介绍了Slack工程团队如何通过拆分Chef环境、构建信号触发机制(Chef Summoner)以及实施分阶段发布流程,来提升其EC2基础设施配置部署的安全性与可靠性,并降低变更的爆炸半径。

推进我们的Chef基础设施:在稳定中构建安全

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

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

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

因此,本文讲述我们选择的道路:改进我们现有的EC2框架,这种方式不会干扰Cookbook或角色,同时仍能为我们的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作业管理,并在整点开始逐步推广到开发环境,最后从每个小时的30分开始逐步推广到生产环境。

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

对prod-2到prod-6的更改使用发布列车模型进行推广。在将新版本部署到prod-2之前,我们要确保前一个版本已成功通过了所有生产环境(直到prod-6)。这种交错推广方式最小化了爆炸半径,并让我们有机会在发布列车的早期阶段捕获回归问题。

如果我们像处理prod-2及以后环境那样,等待一个版本通过所有环境再更新prod-1,我们将最终测试包含大量累积变更的制品。相比之下,频繁用最新版本更新prod-1使我们能够在问题被引入时更接近的时间点发现它们。

我们触发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 设计