推进我们的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版本会影响整个服务器群。有了这种新的分桶方法,我们获得了独立更新每个环境的灵活性。现在,对单个环境的更改仅影响特定AZ中的节点,从而显著降低了部署期间的风险和爆炸半径。
采用这种方法,我们在整点将最新的cookbook更改提升到沙盒环境。这些更改然后由一个Kubernetes cron作业管理,并在整点开始滚动到开发环境,最后从整点过30分开始滚动到生产环境。
prod-1被视为我们的金丝雀生产环境。它每小时接收更新(假设有新的更改合并到cookbooks中),以确保更改在真实的生产环境中定期得到验证——与我们的沙盒和开发环境一起。这使我们能够更早地、以更小、更安全的增量发现影响生产的问题。
对prod-2到prod-6的更改使用发布列车模型进行滚动更新。在新版本部署到prod-2之前,我们确保前一个版本已成功通过所有生产环境直到prod-6。这种交错部署最大限度地减少了爆炸半径,并让我们有机会在发布列车的早期发现回归问题。
如果我们像处理prod-2及后续环境那样,等待一个版本通过所有环境后再更新prod-1,最终我们将测试包含大量累积更改的制品。相反,频繁地用最新版本更新prod-1使我们能够在问题被引入时更接近地检测到问题。
以下是一个示例,展示了制品版本A在此发布周期中如何通过每个环境:
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
|
Hour X:00
Sandbox ---> Version A (Latest)
Dev ---> Version A (Latest)
Prod-1 (no change - currently at version Z)
Prod-2 (no change - currently at version Z)
Prod-3 (no change - currently at version Z)
Prod-4 (no change - currently at version Z)
Prod-5 (no change - currently at version Z)
Prod-6 (no change - currently at version Z)
Hour X:30
(Now that Prod-2 to Prod-6 are all on the same version, we’ll begin updating them again)
Sandbox (no change - currently at version A)
Dev (no change - currently at version A)
Prod-1 ---> Version A (dev version)
Prod-2 ---> Version A (prod-1 version)
Prod-3 (no change - currently at version Z)
Prod-4 (no change - currently at version Z)
Prod-5 (no change - currently at version Z)
Prod-6 (no change - currently at version Z)
Someone committed a change, resulting in the creation of a new artifact called B.
Hour (X + 1):00
Sandbox ---> Version B (Latest)
Dev ---> Version B (Latest)
Prod-1 (no change - currently at version A)
Prod-2 (no change - currently at version A)
Prod-3 (no change - currently at version Z)
Prod-4 (no change - currently at version Z)
Prod-5 (no change - currently at version Z)
Prod-6 (no change - currently at version Z)
Hour (X + 1):30
Sandbox (no change - currently at version B)
Dev (no change - currently at version B)
Prod-1 ---> Version B (dev version)
Prod-2 (no change - currently at version A)
Prod-3 ---> Version A (prod-2 version)
Prod-4 (no change - currently at version Z)
Prod-5 (no change - currently at version Z)
Prod-6 (no change - currently at version Z)
Someone committed a change, resulting in the creation of a new artifact called C.
Hour (X + 2):00
Sandbox ---> Version C (Latest)
Dev ---> Version C (Latest)
Prod-1 (no change - currently at version B)
Prod-2 (no change - currently at version A)
Prod-3 (no change - currently at version A)
Prod-4 (no change - currently at version Z)
Prod-5 (no change - currently at version Z)
Prod-6 (no change - currently at version Z)
Hour (X + 2):30
Sandbox (no change - currently at version C)
Dev (no change - currently at version C)
Prod-1 ---> Version C (dev version)
Prod-2 (no change - currently at version A)
Prod-3 (no change - currently at version A)
Prod-4 ---> Version A (prod-3 version)
Prod-5 (no change - currently at version Z)
Prod-6 (no change - currently at version Z)
Someone committed a change, resulting in the creation of a new artifact called D.
Hour (X + 3):00
Sandbox ---> Version D (Latest)
Dev ---> Version D (Latest)
Prod-1 (no change - currently at version C)
Prod-2 (no change - currently at version A)
Prod-3 (no change - currently at version A)
Prod-4 (no change - currently at version A)
Prod-5 (no change - currently at version Z)
Prod-6 (no change - currently at version Z)
Hour (X + 3):30
Sandbox (no change - currently at version D)
Dev (no change - currently at version D)
Prod-1 ---> Version D (dev version)
Prod-2 (no change - currently at version A)
Prod-3 (no change - currently at version A)
Prod-4 (no change - currently at version A)
Prod-5 ---> Version A (prod-4 version)
Prod-6 (no change - currently at version Z)
Someone committed a change, resulting in the creation of a new artifact called E.
Hour (X + 4):00
Sandbox ---> Version E (Latest)
Dev ---> Version E (Latest)
Prod-1 (no change - currently at version D)
Prod-2 (no change - currently at version A)
Prod-3 (no change - currently at version A)
Prod-4 (no change - currently at version A)
Prod-5 (no change - currently at version A)
Prod-6 (no change - currently at version Z)
Hour (X + 4):30
Sandbox (no change - currently at version E)
Dev (no change - currently at version E)
Prod-1 ---> Version E (dev version)
Prod-2 (no change - currently at version A)
Prod-3 (no change - currently at version A)
Prod-4 (no change - currently at version A)
Prod-5 (no change - currently at version A)
Prod-6 ---> Version A (prod-5 version)
Someone committed a change, resulting in the creation of a new artifact called F.
Hour (X + 5):00
Sandbox ---> Version F (Latest)
Dev ---> Version F (Latest)
Prod-1 (no change - currently at version E)
Prod-2 (no change - currently at version A)
Prod-3 (no change - currently at version A)
Prod-4 (no change - currently at version A)
Prod-5 (no change - currently at version A)
Prod-6 (no change - currently at version A)
Now that Prod-2 to Prod-6 are all on the same version, we’ll begin updating them again and this cycle will continue till the end of time.
Hour (X + 5):30
Sandbox (no change - currently at version F)
Dev (no change - currently at version F)
Prod-1 ---> Version F (dev version)
Prod-2 ---> Version F (prod-1 version)
Prod-3 (no change - currently at version A)
Prod-4 (no change - currently at version A)
Prod-5 (no change - currently at version A)
Prod-6 (no change - currently at version A)
|
如上所示,更改现在需要更长的时间才能在我们所有的生产节点上完全推出。然而,这种延迟为不同可用区之间的部署提供了宝贵的时间窗口,使我们能够在有问题的更改完全传播之前发现并解决任何问题。
通过引入拆分生产环境(例如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运行。
整体流程如下所示:
1
2
3
4
|
[Chef Librarian 提升制品版本] --> [向 S3 发送信号]
|
v
[Chef Summoner 在节点上轮询 S3] --> [检测新信号] --> [安排 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的信息——敬请关注!