4. 环境抽象与变量分层策略

当 Terraform 从单环境走向多环境时,最先失控的往往不是资源本身,而是“差异”。开发、测试、预发、生产四套环境,90% 的资源配置是相同的,剩下 10% 的差异——实例规格、副本数、CIDR、域名、标签、开关——如果散落在各处的 main.tf 里,就会迅速演变成配置漂移:有人改了生产没同步测试,有人复制了一份 dev 目录改出 staging,半年后没人说得清哪套才是“标准”。环境抽象与变量分层要解决的,正是把“共性”沉淀为可复用模块,把“差异”收敛到少数几个受控入口,并让这些入口本身具备隔离与审计能力。

一、先分清“环境差异”的三种性质

不是所有差异都该用同一种方式管理。实践中可以把差异分成三类,分别对应不同的承载机制:

  • 结构性差异:某环境多一个只读副本、多一套灾备 VPC。这类差异改变的是资源拓扑,不适合用变量开关硬塞,应通过模块组合或独立的环境根目录表达。
  • 参数性差异:实例类型、磁盘大小、副本数量、保留天数。这类差异是同一结构的取值不同,最适合用变量分层。
  • 敏感性差异:数据库密码、API Key、证书私钥。这类差异不能进版本库,必须走密钥管理或环境变量注入。

把这三类混在一起,是配置漂移的根源。下面的图展示了从“共性模块”到“环境差异”再到“敏感注入”的分层收敛关系,以及每一层应该由谁来承载。

第 1 层 · 共性模块(版本库,所有环境共享) modules/network · modules/compute · modules/database · modules/observability 只声明变量接口,不写死任何环境取值;通过 module 版本号锁定,禁止环境目录直接改模块内部 输出:vpc_id、subnet_ids、instance_ids、db_endpoint 等标准接口 传入变量 第 2 层 · 环境参数层(tfvars / Terragrunt,按环境隔离) dev.tfvars instance_type = "t3.small" replicas = 1 · cidr = 10.10.0.0/16 staging.tfvars instance_type = "t3.medium" replicas = 2 · cidr = 10.20.0.0/16 preprod.tfvars instance_type = "m5.large" replicas = 3 · cidr = 10.30.0.0/16 prod.tfvars instance_type = "m5.xlarge" replicas = 5 · cidr = 10.40.0.0/16 敏感值注入 第 3 层 · 敏感注入层(不进版本库,运行时注入) 环境变量 TF_VAR_db_password TF_VAR_api_key CI/CD 运行时注入,不落盘 密钥管理服务 Vault / AWS Secrets Manager data "vault_generic_secret" 按环境路径隔离读取 加密 tfvars(可选) sops + age / KMS prod.secrets.enc.tfvars 密文入库,解密后使用

二、tfvars 分层:从“一个文件”到“三层叠加”

最朴素的做法是每个环境一个 terraform.tfvars,但很快会遇到重复:四个环境里 region、project、owner 完全一样,却要抄四遍。更稳妥的是三层叠加:

envs/
├── common.tfvars          # 所有环境共享:region、project、tags
├── dev/
│   ├── env.tfvars         # dev 专属:cidr、instance_type、replicas
│   └── backend.hcl        # dev 的 state 后端配置
├── staging/
│   ├── env.tfvars
│   └── backend.hcl
└── prod/
    ├── env.tfvars
    └── backend.hcl

执行时按顺序传入,后面的覆盖前面的:

terraform apply \
  -var-file=../common.tfvars \
  -var-file=env.tfvars \
  -var="image_tag=${GIT_SHA}"

这样 common.tfvars 是唯一的“标准基线”,任何环境想偏离基线,必须在自己的 env.tfvars 里显式写出,评审时一眼可见。反过来,如果某个参数在四个环境里都相同,就应该上移到 common.tfvars,而不是留在各环境里——这是抑制漂移最有效的习惯。

三、Terragrunt:把“重复的根目录”也抽象掉

tfvars 分层解决了变量差异,但每个环境仍然要维护一份 main.tf、provider.tf、backend.tf,这些文件内容几乎一样。Terragrunt 的价值在于用 terragrunt.hcl 把“模块引用 + 输入变量 + 后端配置”声明化,并通过 include 继承根配置:

# envs/terragrunt.hcl(根配置)
remote_state {
  backend = "s3"
  config = {
    bucket = "acme-tfstate-${local.env}"
    key    = "${path_relative_to_include()}/terraform.tfstate"
    region = "cn-north-1"
  }
}

# envs/prod/app/terragrunt.hcl
include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "../../../modules//app"
}

inputs = {
  instance_type = "m5.xlarge"
  replicas      = 5
  cidr_block    = "10.40.0.0/16"
}

关键收益是:state 路径由目录结构自动推导,天然实现环境隔离;inputs 就是该环境的全部差异,集中在一个文件里;根配置统一了后端、provider 版本、标签策略,改一处全环境生效。代价是引入了一个额外工具层,团队需要理解 include 与 dependency 的解析顺序。

四、安全隔离:三条不能破的线

分层方案再优雅,如果隔离没做好,一次误操作就能把生产打穿。以下三条是底线:

  1. state 必须物理隔离。每个环境独立的 backend key 或独立 bucket,配合独立的 IAM 角色。绝不允许 dev 的 CI 凭证有权限读写 prod 的 state。
  2. 敏感变量不进版本库。密码、密钥只通过 TF_VAR_* 环境变量或密钥管理服务注入。如果必须落盘,用 sops 加密后提交密文,解密密钥由 CI 的 KMS 权限控制。
  3. 生产变更需要显式确认。在 CI 中为 prod 目录设置人工审批门禁,plan 输出必须包含资源变更摘要,apply 前二次确认。dev 可以自动 apply,prod 不行。
常见误区:用 workspace 做环境隔离。Terraform workspace 共享同一份后端配置和同一套变量文件,只是 state 前缀不同,很容易出现“在 dev workspace 里 apply 了 prod 变量”的事故。workspace 适合临时并行实验,不适合作为正式的多环境隔离机制。

五、一个可落地的分层检查清单

在把方案推给团队之前,用下面这份清单自查,能挡掉大部分后续漂移:

检查项合格标准不合格信号
共性参数位置只出现在 common.tfvars 或模块默认值同一参数在 3 个以上环境文件里重复
环境差异可见性每个环境的差异集中在一个 env.tfvars差异散落在 main.tf、脚本、CI 变量中
state 隔离每环境独立 backend key + 独立 IAM共用 bucket 且无前缀隔离
敏感值版本库中无明文密钥tfvars 里出现 password = "..."
模块版本module 引用带版本号或 commit SHA直接引用本地路径且无版本约束
漂移检测定期对 prod 执行 plan 并告警非空 diff只在 apply 时才跑 plan

回到本质:环境抽象不是把变量拆得越细越好,而是让“什么是标准、什么是例外”变得一目了然。共性沉到模块和 common 层,差异收敛到每个环境的一个文件,敏感值走独立通道,state 按环境硬隔离。做到这四点,配置漂移就从“靠人自觉”变成了“结构上难以发生”。