🔥 只需一次 terraform destroy - 瞬间让 15 个客户系统下线 🔥
“周五下午毁灭者”再次出手了。
在这篇分为两部分的文章中,我们将从管理视角深入剖析 Infrastructure-as-Code 面临的最大结构性基础设施问题之一以及其被低估的风险。 因为我们致力于帮助企业系统性地降低 Blast Radius 风险。
因为最好的爆炸,就是根本没有发生的那一次。
Terraform 灾难的解剖
场景: 周五下午毁灭者
星期五 16:30。一名开发人员想快速清理一个测试环境,于是执行了 terraform destroy。他不知道的是:待删除的 VPC 被三个其他 Remote State 引用,而这三个 State 正在管理多家客户的生产工作负载。
⚠️ 结果:
- 15 个客户系统同时故障
- 数据库连接中断
- 负载均衡器丢失其 targets
- 监控系统报告全面故障
- 整个周末都被毁了
这个场景并非人为杜撰 - 它在现实中发生的频率远超你的想象。其原因通常是以下因素的组合:
- 单体 State: 一个 State 中资源过多,导致依赖关系变得难以掌控
- 责任不清: 没有人确切知道谁对哪些资源负责
- 缺少 Guardrail: 缺乏防止误删的技术保护
Infrastructure-as-Code 承诺提供可控性、可复现性和效率。然而,如果 Terraform State 结构设计不当,一个看似无害的 terraform destroy 或 terraform apply 就可能酿成大祸。所谓的 Blast Radius - 意外影响的波及半径 - 会迅速超出预期。
继上一篇关于利用 Goldilocks 原则优化 State 大小的文章之后,我们现在将重点讨论 Terraform 扩展过程中最关键的方面之一:Blast Radius 的管理。因为问题不在于 是否 会出错,而在于出错时 有多少 资源会受到影响。
什么是 Terraform 中的 Blast Radius?
“Blast Radius” 一词最初源自爆破工程,用来描述爆炸造成破坏的半径。在 Terraform 领域,它指的是在变更、错误或故障发生时,可能受到影响的基础设施范围。
各个资源之间的连接体现在它们彼此的依赖关系上。Terraform 会在内部动态生成一个 Dependency Graph,但这个图并不总能让工程师完整且直观地察觉。
有些依赖关系是显而易见的。例如,一个虚拟服务器必须部署在某个网络中。通常,工程师会在与服务器对应的 Compute 资源中对该网络进行显式连接,因此这种依赖不仅在逻辑上存在,也在 Terraform 模块代码中被记录下来。
然而,针对那些仅在 Terraform 内部计算过程中才产生的间接和动态依赖关系,又该如何处理?例如,一个 Web 服务的 IP 地址并不是直接分配给虚拟服务器,而是归属于一个 IP 地址资源;该资源又连接到一条 VNIC 资源;这条 VNIC 可能 - 也可能不会 - 被分配给一个或多个虚拟服务器、负载均衡器,或者防火墙。那么,当你调整子网的网络掩码,而该 IP 地址正好来自该子网的地址池时,要立即判断会带来什么影响就变得更困难了。
IT 基础设施在经历了历史积累和多次变更之后,往往比设计阶段设想的更加复杂。此时,你就依赖于每位工程师和流水线自动化都能真正阅读 terraform plan 生成并在 Shell 窗口快速滚动而过的执行计划,并完全理解并校验其正确性 - 但由于缺乏全局视角、时间和动力,几乎没有运维人员会这样做,更别提平均水平的 CI/CD 流水线了。
Blast-Radius 可视化:
- 小型 State = 较少的直接与间接依赖 = 较小的 Blast-Radius:错误仅影响少量资源
- 大型 State = 更多的直接与间接依赖 = 更大的 Blast-Radius:错误可能带来广泛且难以预测的影响。
在实践中,不受控的 Blast-Radius 往往表现为多种形式:
- 意外删除: 一次 terraform destroy 不仅移除目标资源,还会删除其他系统中的依赖组件。也就是说,你在基础设施的一角操作,却在另一处意想不到的地方发生“爆炸”。这主要发生在庞大的单体 State 中。
- 级联故障: 对某个核心资源的改动导致看似独立的服务或资源出现故障。这不仅可能因依赖关系引起,也可能因资源被错误地组织成不合适的数据类型(即代码错误)导致。例如,我曾遇到一位客户,他们将数百条 DNS-Record 作为各自独立的资源,使用 count 而非 for_each 存储在列表中。后来在一次 terraform apply 中,删除了列表的首条记录……随后几百条 DNS 记录也被一并删除,因为索引变化导致 Terraform 先删除再重建这些资源。事情之所以变得严重,是因为公有云供应商严格的 API 速率限制,使得新建 DNS 记录时只能以每批最多 5 条的速度缓慢创建,无法一次性完成,结果导致客户几乎所有无关服务“潜水”超过一小时。试想在变更管理和公关说明会上如何解释这种事故?
- 跨 State 依赖: Remote State 引用在 apply 或 destroy 时引发意外副作用,意味着某个 Terraform 实例使用的数据来自其他 State 文件而非自身。“哎呀,那张网里居然还有另一位客户的生产 Bare Metal …”
- 租户交叉影响: 为某个客户做出的更改无意间影响了其他租户。想想在共享环境中动态管理的防火墙规则吧。
- 彻底混乱: 当上述多个场景同时发生时,问题会更加棘手。若运气好,还会有另一个数据中心提供冗余。
典型的 Blast-Radius 场景
1. Remote State 灾难
# State A: 网络基础
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
output "vpc_id" {
value = aws_vpc.main.id
}
# State B: Application(引用 State A)
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "terraform-states"
key = "network/terraform.tfstate"
}
}
resource "aws_instance" "app" {
[...]
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0] [...]
}
⚠️ 问题: 如果 State A 中的 VPC 被销毁,State B 的引用将失效,导致状态不一致,并需要手动执行 terraform import 或 terraform state rm 进行修复。在许多企业中,这还会引发组织层面的额外问题,因为网络与服务器实例通常隶属于不同的职能团队,发生事故时未必能确保及时且建设性的沟通。
2. 多租户混乱
当多个租户(客户)在同一个 State 中共享资源时,尤其容易出现高风险场景:
示例: Shared Infrastructure State
├── shared-infrastructure/ │ ├── customer-a-resources.tf │ ├── customer-b-resources.tf │ ├── customer-c-resources.tf │ └── shared-services.tf
针对 Customer A 的一次变更可能意外波及 Customer B 与 C。更糟糕的是,一次 terraform destroy 可能同时影响所有客户。
因此,务必让各租户彻底隔离。是的,这在初期可能成本更高,但一次停机的代价更昂贵。
3. 依赖地狱
资源之间的复杂依赖链会导致难以预测的级联效应:
依赖链:
链条起点的任何变更都可能一路影响到末端,产生意料之外的副作用,主要源于代码中不可见、由 Terraform 计算出的依赖关系。
理论上,这应由各个 Terraform Provider 承担责任。依赖关系意味着 Provider 不应仅把输入值原封不动、毫无关联性地转交给云供应商 API,还应实现最基本的逻辑与校验。相信我:在这方面,许多 Provider 做得非常非常糟糕。
Blast Radius 最小化:策略与最佳实践
或许我已经让您眉头紧锁。现在,让我们讨论如何避免这些问题。
方法 1:按 Blast-Radius 进行 State 分段
最有效的 Blast-Radius 控制手段是经过深思熟虑的 State 分段。设计自动化架构时,我们遵循以下原则:
按影响级别隔离:
- Critical States: 高 Blast-Radius 的核心基础设施(网络、IAM、DNS)
- Service States: 中等 Blast-Radius 的应用专用资源
- Ephemeral States: 最小 Blast-Radius 的临时资源
# AWS 中基于 Blast-Radius 优化的结构示例
├── foundation/ # 关键基础设施 │ ├── network-core/ # VPC、Transit Gateway(高 Blast-Radius) │ ├── security-baseline/ # IAM、KMS Key(高 Blast-Radius) │ └── dns-zones/ # Route53 Zone(中等 Blast-Radius) │ ├── platform/ # 平台服务 │ ├── kubernetes-cluster/ # EKS/OKE 集群(中等 Blast-Radius) │ ├── databases/ # RDS、DocumentDB(中等 Blast-Radius) │ └── monitoring/ # CloudWatch、Grafana(低 Blast-Radius) │ └── applications/ # 应用层 ├── frontend-dev/ # 开发环境(低 Blast-Radius) ├── frontend-prod/ # 生产环境(中等 Blast-Radius) └── batch-jobs/ # 批处理(低 Blast-Radius)
重要的是尽可能同时考虑 Rate of Change。也就是说,应避免高频变更的 State 被较少变更的 State 引用。如果某资源可能每隔几周或几个月就会变化一次,那么它最好不要承担关键角色,而应被拆分到独立的 Ephemeral State。
高变更率与关键基础设施相互排斥 - 但具体界限由您自行决定,并应尽早做出并记录。
关于 Rate of Change 及其控制方法,我们将在本系列的后续文章中单独讨论。
方法 2: 使用 Remote State 进行依赖反转
与其创建直接依赖,我们通过 Data Source 与 Lifecycle Preconditions 来“拆弹”。让我用一个示例来说明。
问题: 直接依赖(反模式)
# Anti-Pattern: Direkte Abhängigkeit resource "aws_instance" "app" { subnet_id = aws_subnet.main.id }
让我们仔细看看:
- EC2 实例直接引用同一 State中的 Subnet 资源。这意味着二者被绑定在同一个 terraform.tfstate 文件内。
- 对该 Subnet 执行 terraform destroy 会同时删除两项资源。如果 terraform apply 过程中需要暂时删除并重建 Subnet,同样会销毁并重新部署 aws_instance。
- 因此,Subnet 的任何变更都可能意外影响 EC2 实例。
危险之处在于:
- Blast Radius 高: 网络层的改动可能摧毁应用,最糟糕时还会破坏 EC2 实例本地存储的数据。
- 生命周期耦合: Subnet 与 EC2 必须始终一起管理。网络改动必然触发应用层的变更申请。
- 职责冲突: Network 团队与 Application 团队在最坏情况下会彼此掣肘。
- 回滚困难: 发生故障时,无法简单从备份恢复 EC2,因为备份中的网络配置已不一定与当前环境匹配。
部分解决方案: 依赖反转(最佳实践)
对于变更(即“突变”)我们依旧难以完全控制。但通过依赖反转,我们可以让 EC2 实例与网络解耦,从而避免意外的 destroy。
# App State(消费 Network Outputs) data "terraform_remote_state" "network" { backend = "s3" config = { bucket = "terraform-states" key = "foundation/network/terraform.tfstate" } }
现在有什么不同?
- VPC 与 Subnet 位于独立 State(foundation/network/)。
- 应用不再直接引用 Subnet,而是通过Remote State Data Source 进行访问。
- 因此,EC2 实例与 Subnet 之间已不再存在直接的资源级依赖。
应用不再直接依赖 Subnet 资源,而是依赖网络 State 的抽象输出。这种依赖仍然存在,但不再紧耦合。
Remote State 依赖并非彻底的 Blast Radius 防护,而更像是一种“断路器”,争取到响应时间;最终仍需解决潜在依赖。
这只是第一步。如果远程 State 中的网络资源发生变化,EC2 实例仍会受到影响。因此,我们还需进一步实现验证逻辑,并在必要时紧急制动。
验证与防御式编程
在 locals.tf 中我们定义:
locals { vpc_id = try(data.terraform_remote_state.network.outputs.vpc_id, null) subnet_ids = try(data.terraform_remote_state.network.outputs.subnet_ids, []) }
这里发生了什么?
这实现了一种防御式行为: 函数 try() 会在 Remote State 不存在时阻止报错。若出现这种情况,vpc_id 将被赋值为 null,而 subnet_ids 则获得一个空列表(一个 VPC 可以包含多个子网,因此使用列表)。
接下来有几种可行的方案。
选项 1: Detect-Only Pattern
现在我们为 EC2 实例声明一个 Lifecycle:
resource "aws_instance" "app" {
lifecycle { precondition { condition = can(data.terraform_remote_state.network.outputs.vpc_id) error_message = "WARNING: Network state not available - using fallback configuration" } }
subnet_id = try( data.terraform_remote_state.network.outputs.private_subnet_ids[0], "subnet-fallback-12345" # 回退到已知、稳定的 Subnet ID ) }
该示例经过大幅简化,仅用于说明基本原理。在实际环境中当然可以进一步改进和细化,但流程大体相同:
- 如果 Remote State 中已没有 VPC(此时 local.vpc_id 为 null),terraform plan 会以带警告的错误信息终止。
- 如果 VPC 仍存在,但 Subnet ID 缺失,则会回退到另一条 Subnet ID。此 ID 可以硬编码(不优雅)或来自其他来源 - 为简洁起见,此处演示前者。
在此场景下,这意味着服务器将迁移到其他子网,即先执行一次 destroy 然后重新部署,对该资源类型并不理想。因此,可以考虑同样为 Subnet ID 定义 Lifecycle Precondition,在 plan-阶段强制中断。或者,作为替代方案,直接禁止 Terraform 拆除 EC2 实例,我们将在下一节进行说明。
选项 2: Prevent Destroy Pattern
这样可以禁止 Terraform 删除资源,而是改为以错误消息终止:
resource "aws_instance" "app" { lifecycle { prevent_destroy = true # 防止意外删除 precondition { condition = can(data.terraform_remote_state.network.outputs.vpc_id) error_message = "WARNING: Network state not available - using fallback configuration" } } subnet_id = try( data.terraform_remote_state.network.outputs.private_subnet_ids[0], "subnet-fallback-12345" # 回退到已知且稳定的 Subnet ID ) }
此处只是新增了 prevent_destroy = true 这一行,其余内容与上一示例完全相同。
此外,还有第三种可以与该方案结合的做法。
选项 3: 显式确认模式
在此方案中,可将一个布尔变量设为 true,以批准自动拆除并重建操作。
警告: 这并不能阻止因网络变更而导致 EC2 实例被拆除!此 for_each 循环中的表达式只是一种自我保护机制——通过 var.confirm_network_dependency_removal 明确表示您已意识到相关风险。仅当稳定的 CI/CD 流水线比服务器实例的持续存在更为重要(例如在开发环境或严格执行零可变性的场景)时,才建议采用此方法。
variable "confirm_network_dependency_removal" { type = bool default = false } data "terraform_remote_state" "network" { backend = "s3" config = { bucket = "terraform-states" key = "foundation/network/terraform.tfstate" region = "eu-central-1" } } locals { vpc_ok = can(data.terraform_remote_state.network.outputs.vpc_id) subnet_ok = can(data.terraform_remote_state.network.outputs.private_subnet_ids[0]) deploy_app = ( local.vpc_ok || var.confirm_network_dependency_removal ) && local.subnet_ok app_instances = local.deploy_app ? { "main" = true } : {} } resource "aws_instance" "app" { for_each = local.app_instances ami = "ami-12345678" instance_type = "t3.micro" subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0] lifecycle { prevent_destroy = true precondition { condition = local.vpc_ok error_message = "VPC missing – deployment denied" } precondition { condition = local.subnet_ok error_message = "Subnet missing – deployment denied" } } tags = { Name = "BlastRadiusProtected" } }
方法 3: 使用 Lifecycle 规则设置 Blast-Radius 防护栏
我在方法 2 的说明里已经提到过:Terraform 提供了多种机制,可用于防止意外删除资源。
选项 1: 针对关键资源使用 prevent_destroy
之前我们已经介绍过这一点,但让我们再回顾一次:
# 防止关键资源被销毁 resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" lifecycle { prevent_destroy = true }
tags = { Name = "Production-VPC" Environment = "production" BlastRadius = "high" }
}
请注意这里的做法:我们把 VPC 声明为关键资源并禁止删除,从而保护依赖资源(如子网和 EC2 实例)。这只有在 VPC 由我们自身管理、而非来源于 Remote State 时才可行。
选项 2: 针对有状态资源使用 create_before_destroy
当 Provider 规定某些资源参数无法在线更新时,就必须先销毁再重建。由于基础设施中许多资源(如 IP 地址)只能存在一份,Terraform 会先删除旧资源再创建新资源。
不过,您可以要求先创建新资源,再删除旧资源:
resource "aws_db_instance" "main" { lifecycle { create_before_destroy = true ignore_changes = [ password, # 忽略密码变更以避免不必要的替换 ] }
[...]
}
但这样做就需要您自行确保不会发生冲突。旧资源与新资源必须能够并存;以 IP 地址为例,由于同一网络中 IP 地址必须唯一,您需要先创建不带 IP 的新资源,再把 IP 从旧资源迁移到新资源。这会增加复杂度,只能按需使用。
选项 3: 使用 Preconditions 做条件删除
第三个选项是再次借助 Lifecycle Precondition,通过标志位控制资源是否允许删除;若不允许,则在 terraform plan 阶段直接报错并终止:
variable "confirmed_destroy" { type = bool default = false description = "Explicitely allows destruction of resources if set to `true`." } resource "aws_instance" "app" { lifecycle { precondition { condition = var.environment != "production" || var.confirmed_destroy == true error_message = "Production resources require explicit confirmation for destruction." } }
[...]
}
方法 4: 采用 Policy-as-Code 的专业方案(Terraform Enterprise)
前述方案都有一个共同点:多少带点“手工补丁”性质,需要妥协。若追求企业级、可强制执行且可审计的专业方案,就要考虑 Terraform Enterprise。Terraform Enterprise 通过 Sentinel 提供深入的 Policy-as-Code 功能:
# Sentinel Policy: Blast Radius Kontrolle import "tfplan/v2" as tfplan # 阻止删除具有高 Blast Radius 的资源 high_blast_radius_resources = [ "aws_vpc", "aws_route53_zone", "aws_iam_role" ] main = rule { all tfplan.resource_changes as _, resource { resource.type not in high_blast_radius_resources or resource.change.actions not contains "delete" } }
在此示例中,首先将 AWS VPC、IAM 角色和 Route 53 DNS Zone 定义为具有高 Blast Radius 的关键基础设施。
随后,任何删除此类资源的尝试都会被规则拦截;一旦触发,Terraform 会在 plan 阶段后阻止 terraform apply 的继续执行。
阶段性结论
在使用 Terraform 时,应及早关注 Blast Radius,否则风险巨大。借助免费版 Terraform,通过模块实现上的技巧和“小妙招”可以稍微降低风险,但无法做到万无一失。
如果您需要能够在全公司范围内强制执行并审计的专业级解决方案,请考虑搭配 Sentinel 模块的 Terraform Enterprise。如果贵公司的风险管理能够量化宕机的风险与成本,那么 Terraform Enterprise 许可证就像一份保险,其成本和收益可以直接对比评估。
展望
我们已详细讨论了降低过大 Blast Radius 风险的各种做法。
但如果事故真的发生了怎么办?有哪些方法可以减轻损失,并让基础设施恢复到可运营状态?
这些内容将在本系列下一篇文章中继续探讨。