当声明式防火墙同步修剪规则并切断访问时
一次防火墙同步操作修剪了仅手动存在的规则,并切断了实时流量。本文是这次事件的事后分析报告、根本原因,以及如何确保修剪操作的安全性。
一个将防火墙规则与代码中事实来源进行协调的声明式同步循环,它完全按照指令行事,而这正是问题所在。一天下午,它删除了一组线上规则,切断了已正常运行数月的流量。没有人更改过代码。它删除的那些规则是在控制台中手动添加的,位于声明式来源之外,而同步过程将任何不在来源中的内容都当作待修剪的垃圾处理。本文是那次事件的一篇事后复盘,特意写得比较通用,并探讨了为防止自动化修剪成为一种武器而做的设计变更。
发生了什么
环境中有一个协调器。版本控制中的一个声明式源描述了每个网络边界所需的防火墙和安全组规则。一个自动化程序会按计划读取该源,将其与基础设施上的实时规则集进行比较,并使实时规则集与之匹配。源中的新规则会被创建。实时存在但源中没有的规则会被删除。这种“为匹配而删除”的行为是声明式协调的全部意义所在,也正是本次服务中断的根本原因。
几周前,有人为了解决一个紧急需求,直接在基础设施控制台中添加了几条规则:一个特定的出站路径和某个服务所依赖的几个端口。这些规则从未被写回到声明式源中,它们只存在于实时基础设施上。有一段时间,这个问题没有暴露出来,因为自从手动编辑后,同步程序没有针对该边界运行过,或者运行时所用的模式不会进行删减。
然后,一次常规同步运行了。它在实时端发现了一些未出现在声明式源中的规则。根据其约定,这些规则是需要纠正的偏差,因此它删除了它们。出站路径消失了。经过其中一个被删减端口的健康检查开始失败。依赖于手动添加规则的流量中断了。自动化程序报告成功,因为从它的角度来看,实时状态现在与源完全匹配。关键的警报并非来自同步程序,而是来自那些再也无法访问其所需资源的服务。
简要时间线
这个过程很短,并且在许多团队中反复出现,因此有必要清楚地说明。
- 以声明方式定义规则集并自动协调。到目前为止是正确的。
- 出现一个紧急需求。有人在控制台中手动添加规则以立即修复问题。声明式源未更新,因此第二个事实来源悄然出现。
- 时间流逝。手动添加的规则持续生效。在无人关注的情况下,实时状态与声明式源之间的差距不断扩大。
- 一次启用了清理 (prune) 功能的例行同步运行。它将手动添加的规则视为不受管理的漂移并将其删除。
- 访问中断。故障出现在下游,而不是在引发故障的工具中,因此事故发生后的最初几分钟都被花在了错误的地方进行排查。
一旦原因明确,恢复过程本身是很快的。丢失的规则被重新添加,这一次是添加到声明式源中,然后访问恢复了。令人不安的部分不是修复过程本身,而是意识到几周以来,自动化系统距离造成这个结果只差一次计划运行,并且这种设计使得这个结果是必然的,而非偶然。
根本原因:一套规则集,两个事实来源
直接原因并非同步过程中的 bug。同步功能按设计正常工作。原因是两个地方都可以定义现实情况,而其中只有一个被允许保留下来。
声明式的来源声称是唯一的事实来源。控制台允许人类直接编写规则,这使得控制台在实践中成为了第二个事实来源。只要两者同时被使用,系统中就存在两个相互冲突的权威,而协调器(reconciler)被构建来解决所有分歧,其方式是以来源为准,删除来源中不包含的任何内容。手动添加的规则并非系统会保留的增量,而是系统设计要抹除的差异。
三个具体的决策将这一结构性缺陷变成了服务中断。
首先,自动化通过删减(pruning)来强制将来源作为唯一事实,但同时又保留了手动编辑的路径。你不能鱼与熊掌兼得。如果来源是权威的,并且删减功能是开启的,那么手动编辑就不是捷径,而是引信未知的定时炸弹。
其次,同步功能在没有差异预览和审批的情况下立即应用了变更。它从未向人类展示过它即将删除的规则列表。一次模拟运行(dry run),如果能打印出“即将删除 3 条规则,包括一条出站路径”,就能在读完这一行字的时间内阻止这次事故。
第三,也是最重要的一点,该设计将删除和添加视为同一种变更。添加一条来源中包含但基础设施中缺失的规则,风险很低。最坏的情况是,本就阻塞的流量会多阻塞片刻。删除规则则是危险的操作方向,因为它可能切断正在被活跃使用的访问,而其爆炸半径只有在事后才能知晓。对这两种操作给予同样自动、无防护的处理,意味着安全的操作和破坏性的操作在同等的信任级别下运行。
根本原因与对策
这些修复措施与原因一一对应,每一项措施都独立地缩小了故障范围,因此某一项措施的缺失不会重新导致服务中断。
| 根本原因 | 导致的问题 | 对策 |
|---|---|---|
| 存在两个事实来源(代码和控制台) | 同步任务将手动创建的规则视为偏差 | 单一事实来源:禁用手动控制台编辑,所有变更都通过声明式来源进行,检测到偏差时会发出警报,而不是静默创建 |
| 在没有预览或批准的情况下应用了修剪(prune)操作 | 静默删除线上规则 | 修剪(prune)操作总是会生成一个空运行(dry-run)差异,并且在删除任何内容之前需要明确的人工批准 |
| 将删除操作与添加操作同等对待 | 一个破坏性操作以安全操作的信任级别运行 | 非对称处理:添加操作自动应用,删除操作则需要经过审查 |
| 对管理访问权限没有保护 | 同步任务可能会删除它本不应触碰的规则 | 使用保护列表,将管理规则和健康检查规则完全排除在协调(reconciliation)之外 |
| 变更被零散地应用,没有回滚点 | 导致状态不完整且恢复缓慢 | 通过变更前快照实现原子化应用,以便一步回滚 |
| 批量删除时没有信号 | 服务中断在下游浮现,延迟了数分钟 | 当同步任务将要删除的规则数量超过一个小的阈值时,会触发警报 |
预防:使修剪成为受保护的操作
指导原则是,一个声明式系统的安全性取决于它如何处理删除操作。创建是宽容的,删除则不然。重新设计后,移除操作必须通过一道关卡才能执行,而将大多数安全的变更保留为自动执行。
现在,修剪操作分两个阶段运行。第一个阶段计算源状态和线上状态之间的差异,并按方向分组打印出来:一边是新增项,另一边是移除项。它会自动应用新增项,因为新增操作不会切断现有的访问权限。它不会应用任何移除项。相反,它会停下来,并呈现待移除的项以供审批,同时提供足够的上下文来判断每一项。人员会阅读该列表并批准或不批准。只有在明确批准后,移除阶段才会运行。
这种不对称性是此修复方案的核心。在稳定状态下,大多数同步只包含新增项或根本没有变更,这些同步无需人工干预即可自动运行。少数想要删除某些内容的同步,才是需要人工介入的少数同步。关卡的成本只落在可能导致服务中断的操作上,而这正是你希望增加阻力的地方。
阈值警报为此提供了支持。如果一个提议的修剪操作要一次性移除超过几条规则,这会被视为输入有问题的信号,而不是一次大规模的常规变更。一个突然要删除二十条规则的同步,更有可能是读取了损坏或空的源,而不是反映了移除二十条规则的真实意图。该警报在差异比对被批准之前就会触发。
预防:单一事实来源和保护列表
修剪门控限制了漂移造成的损害。消除漂移是另一半工作。如果只有一个地方可以定义规则,那么协调器就永远不会将合法的实时规则视为垃圾,因为每条合法规则都存在于它所协调的源中。
这意味着关闭控制台路径。通过控制台直接编辑防火墙和安全组规则的功能对人类禁用了,因此声明式源不仅在意图上,而且在事实上成为了单一事实来源。紧急变更仍然会发生,但它们是以对源进行小而快的变更的形式发生的,这些变更会流经相同的流水线,这只比通过控制台编辑稍慢一点,并且让两个权威源保持一致而非冲突。一个独立的漂移检测器会按计划运行,并在实时状态和源有任何不一致时发出警报,因此,在修剪操作可能对其采取行动之前很久,差异就会以警告的形式被捕获。
保护列表堵上了最后一个漏洞。有些规则绝不能被修剪,无论源怎么说,因为它们是保持操作员可访问的规则。管理访问以及健康检查所经过的路径是明显的例子。它们被标记为受保护,并被完全排除在协调之外。即使是一个完全为空或已损坏的源也无法删除它们,这意味着那种因错误源导致所有人被锁定在修复问题所需的基础设施之外的故障模式被排除了。保护列表在设计上就很短,每个条目都通过回答一个问题来证明其存在的必要性:如果这条规则消失了,我们还能进入系统来修复损坏吗?
下面是一个带有这些保证的协调器配置的形态。确切的模式并不重要,重要的是其属性。
reconcile:
# Additions apply on their own. Removals never do.
auto_apply:
additions: true
removals: false
# Removals produce a diff and wait for a human.
prune:
require_approval: true
dry_run_first: true
# A prune larger than this is treated as a bad input, not a big change.
alert_threshold: 5
# Rules the reconciler is forbidden to delete, whatever the source says.
protect:
- name: management-access
match: { port: 22, direction: inbound }
- name: health-check
match: { port: 8080, direction: inbound }
# Snapshot before any change so a bad apply rolls back in one step.
snapshot:
before_apply: true
retain: 10
预防:原子性、快照与可观测性
最后一层假设错误的变更终究会通过,因为这迟早会发生,并提出问题:你能以多快的速度撤销它。在执行任何应用操作之前,协调器会为当前的实时规则集创建一个快照。如果一次应用操作导致了损坏状态,恢复方法是还原快照,而不是在压力之下根据记忆重建规则。在平台允许的情况下,变更会以原子方式应用,因此一次运行不会留下一半规则已更新而另一半未更新的状态,这本身就是一种故障,比一次彻底的失败更难排查。
可观测性形成了闭环。事实证明,最有用的单一信号反而是最粗略的一个:每个边界的活动规则数量,当其急剧下降时发出警报。在一次同步中,规则数量从 40 条下降到 34 条,这是一个值得立即探究的问题,而且这并不需要理解任何单条规则的作用。将其与漂移检测器配对——该检测器会监视实时状态与源配置之间的不一致——两者结合起来,便可以在人类注意到流量故障之前,很好地捕获慢速问题(漂移累积)和快速问题(同步操作批量删除)。
总的教训:声明式不等于安全
关于声明式自动化的一个令人欣慰的说法是,它能消除人为错误。编写所需的状态,让机器来执行,从而避免手动操作的失误。这个说法只对了一半。声明式协调确实能消除一类漂移,并且对于添加操作而言,它确实比手动编辑更安全。但它没有做到的是让删除操作变得安全,而且它悄悄地增加了存在多个事实来源(source of truth)的风险。
一旦有两个地方可以定义现实,一个通过删除来解决所有冲突的执行者就不是一个安全机制。它是一种以机器速度,根据错误的事实来源采取行动的自动化方式。这里的失败之处不在于使用了自动化。而在于,在一个允许存在第二个非正式事实来源的系统中,一个破坏性操作被赋予了与安全操作同等的信任。
由此得出的规则很简单。只保留唯一一个事实来源,并检测与之产生的漂移,而不是任由漂移形成。让自动化自由地应用安全方向的操作。在危险方向的操作上设置一个人工关卡,因为删除是切断访问权限的操作,再怎么声明式的整洁也改变不了这一点。一个在修剪(prune)操作前没有人工审核的流水线,并不会比一个谨慎的操作员更可靠。它是一个谨慎操作员最糟糕的下午,被设定为按时自动运行。