ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Terraform多环境配置管理:用YAML替代tfvars的完整实践指南

Terraform多环境配置管理:用YAML替代tfvars的完整实践指南 如果你还在用tfvars文件给开发、生产两套环境做参数切换你会慢慢发现一个很现实的问题*.tfvars写得越来越多结构越来越散新同事根本分不清哪个变量是哪个环境的terraform plan的-var-file参数也越堆越长。我前两个月把整个 infra 的配置源从 tfvars 迁到了本地 YAML 文件配合 Terraform 内置的yamldecode函数做环境加载虽然中间踩了不少坑但整体收益非常明显。这篇文章把我从目录设计、YAML 数据契约、读取链路到 state 隔离、误删教训的完整思路写出来给正在纠结“多环境配置怎么组织”的人一个可以直接抄的参考。先说清楚我用这套方案解决的核心问题开发环境和生产环境的资源规格、网络段、数据库配置差异很大但 Terraform 代码本身应该是同一套。如果main.tf里全是var.environment prod ? t3.large : t3.micro这种三元表达式维护成本很快就失控了。把配置的“真相”从分散的 tfvars 收拢到一份结构化的 YAML 后环境差异变得一目了然代码里只做读取和映射不再堆条件判断。这套做法不挑云厂商AWS、阿里云、腾讯云都可以套用核心思路是一致的。1. 为什么我要把配置从 tfvars 迁到本地 YAML1.1 tfvars 文件管理两个环境时最痛的事情先回顾一下传统做法。大多数人管理多环境时会写dev.tfvars和prod.tfvars然后执行terraform plan -var-filedev.tfvars terraform apply -var-fileprod.tfvars听起来挺美好但用一段时间之后就会出现几个问题。第一tfvars 里的变量名是拍平的instance_type、vpc_cidr_block、db_password全都堆在一个命名空间里变量一多就分不清是哪个模块用的。第二tfvars 不支持结构你想表达“compute 下面有一组配置database 下面有一组配置”这种层级关系得靠变量名前缀硬凑compute_instance_type、database_instance_class丑且容易写错。最麻烦的是第三个问题tfvars 文件本身没有“层级覆盖”的概念。当你需要为 prod 增加一个 dev 里不存在的字段比如multi_az时variable定义、tfvars 文件、模块入参三处都要同步改漏一处就是plan报错或者变量被静默忽略。团队里只要有三个人同时改配置冲突几乎无法避免。1.2 YAML 配置文件给团队协作带来的变化后来我尝试把配置写进 YAML发现它天然解决了上面几个痛点。YAML 支持嵌套结构能直接映射到 Terraform 的 object 类型支持注释可以在关键字段旁边写清楚“为什么 dev 用这个规格”文件定位也很明确一个环境一个文件新同事看目录结构就知道 dev 和 prod 分别是什么配置。更重要的是YAML 在团队协作里比 tfvars 友好得多。开发、运维、甚至不写代码的同事都能看懂 YAML 的结构评审配置变更时不需要理解 Terraform 语法。我们团队的 SRE 之前提的需求都是“把 prod 的机器数从 3 改成 5”以往我要自己在 tfvars 里找对应的变量名现在他直接改config/prod.yaml里的desired_size我自己只 review 变更内容就行。2. 目录规划与 YAML 的数据契约先定义清楚再写代码2.1 一套能支撑多环境的目录结构配置从 tfvars 迁到 YAML 前第一步不是写代码而是把目录结构和 YAML 的数据契约定下来。否则每个人写的 YAML 字段名都不一样后面解析起来会非常痛苦。我目前用的目录结构大致是这样infra/ ├── config/ │ ├── dev.yaml │ ├── prod.yaml │ └── common.yaml ├── modules/ │ ├── network/ │ ├── compute/ │ └── database/ ├── envs/ │ ├── dev/ │ │ ├── backend.tf │ │ ├── main.tf │ │ └── variables.tf │ └── prod/ │ ├── backend.tf │ ├── main.tf │ └── variables.tf └── scripts/ └── apply.shconfig 目录放 YAML 配置文件envs 目录下每个环境一份 Terraform 入口代码modules 目录放复用的模块。在envs/dev/main.tf里通过相对路径../../config/dev.yaml读取配置。如果你只用单目录加 workspace 管理多环境也可以把 config 放在项目根目录然后通过var.environment拼出文件名这个后面会说。这套结构的好处是每个环境有独立的入口目录terraform plan时不会因为误切 workspace 而看错配置config 目录从 Terraform 代码中分离出来即使将来不用 Terraform 了配置数据仍然可以平移到其他 IaC 工具。2.2 YAML 数据契约字段、类型与默认值约定数据契约的重点是约定 YAML 的字段命名、层级和类型让 Terraform 解析时不用反复判断“这个字段到底有没有”。以下是我实际在用的 dev.yamlenvironment: dev region: ap-southeast-1 project_name: demo compute: instance_type: t3.micro desired_size: 1 min_size: 1 max_size: 1 network: vpc_cidr: 10.0.0.0/16 subnet_cidrs: public: - 10.0.1.0/24 - 10.0.2.0/24 database: engine: postgres engine_version: 15.3 instance_class: db.t3.micro allocated_storage: 20 multi_az: false password_env: DB_PASSWORD对应的 prod.yamlenvironment: prod region: ap-southeast-1 project_name: demo compute: instance_type: t3.large desired_size: 3 min_size: 3 max_size: 6 network: vpc_cidr: 10.20.0.0/16 subnet_cidrs: public: - 10.20.1.0/24 - 10.20.2.0/24 database: engine: postgres engine_version: 15.3 instance_class: db.m5.large allocated_storage: 100 multi_az: true password_env: DB_PASSWORD我在团队里定了几条硬性约定所有布尔值统一用true/false不允许写yes/no避免 YAML 1.1 的布尔歧义。数字类型不要加引号除非它真的需要是字符串比如engine_version: 15.3这种版本号加了引号是为了防止被解析成数字类型后丢失格式。每个环境文件必须是完整配置不依赖读取方做默认值兜底。宁可 dev 和 prod 里重复写 90% 相同的字段也不要依赖代码里的 default。这样 plan 的结果永远可预测。最后一条尤其重要。因为yamldecode解析出来的 map 里如果缺字段Terraform 会在lookup()或对象类型转换时报错而如果你用try()包了一层报错就变成了静默兜底这恰恰是环境配置最怕的事情——你根本不知道 prod 这次 plan 用的是哪个默认值。3. Terraform 读取 YAML 的完整链路yamldecode、merge 与 for_each 的组合3.1 先用 locals 把 YAML 读成 HCL 数据YAML 本身不是 Terraform 能直接识别的东西所以第一步是用内置函数yamldecode把它转成 HCL 的 map。我习惯在入口文件的 locals 块里统一处理locals { config yamldecode(file( ${path.module}/../../config/${var.environment}.yaml )) }这样local.config.compute.instance_type就能直接拿到 YAML 里compute段下的instance_type字段了。path.module指向当前执行目录也就是envs/dev然后回退两级到config目录下取对应环境的 YAML。var.environment在terraform plan -var environmentdev时传入。如果你希望配置里有公共默认值、各环境只覆盖差异可以加一层 mergelocals { common yamldecode(file(${path.module}/../../config/common.yaml)) env yamldecode(file(${path.module}/../../config/${var.environment}.yaml)) config merge(local.common, local.env) }但要特别小心Terraform 的merge默认只做浅合并。如果common.yaml里有compute.instance_type而dev.yaml里只写了compute.desired_size合并后compute下的instance_type会直接丢失因为dev.yaml里的compute整个覆盖了common.yaml里的compute。我试过自己写递归 deep merge也能用第三方函数库但最终还是放弃了“公共配置 环境覆盖”模式改成每个环境一个完整 YAML。原因很简单省掉的重复字段没多少但配置的可预测性下降了一大截。3.2 用 for_each 把配置映射为真实资源拿到local.config之后下一步就是把配置映射成真实资源。最直接的写法是把 YAML 字段作为模块入参module network { source ../../modules/network vpc_cidr local.config.network.vpc_cidr subnet_cidrs local.config.network.subnet_cidrs } module compute { source ../../modules/compute instance_type local.config.compute.instance_type desired_size local.config.compute.desired_size min_size local.config.compute.min_size max_size local.config.compute.max_size vpc_id module.network.vpc_id }YAML 里如果有一个资源列表用for_each遍历更合适。比如你在 YAML 里定义多个安全组规则security_groups: web: ingress_ports: [80, 443] cidr_blocks: [0.0.0.0/0] internal: ingress_ports: [3306] cidr_blocks: [10.0.0.0/8]对应 HCLresource aws_security_group this { for_each local.config.security_groups name ${local.config.project_name}-${each.key} vpc_id module.network.vpc_id dynamic ingress { for_each each.value.ingress_ports content { from_port ingress.value to_port ingress.value protocol tcp cidr_blocks each.value.cidr_blocks } } }这里for_each配合dynamic块能有效减少重复代码而且 YAML 里新增一组规则后Terraform 自动生成新资源不需要改 HCL。但注意for_each的 key 必须是字符串或能转为字符串的值如果 YAML 里某个列表项没有合适的唯一标识得用索引拼接for_each { for idx, rule in local.config.rules : idx rule }3.3 yamldecode 的类型边界字符串、布尔与数字yamldecode解析出来的类型不一定符合 Terraform 资源的预期这是很多人第一次跑时报错的根源。YAML 里的裸字true会被识别为布尔类型true加引号则会被识别为字符串15.3会被识别为浮点15.3加引号则是字符串。Terraform 在把值传给资源参数时如果类型不匹配大部分情况会做隐式转换但有些不匹配会直接报错例如把一个布尔值传给需要 string 的参数。我做版本号字段时吃过亏YAML 里写engine_version: 15.3yamldecode解析成 number然后 Terraform 创建数据库实例时把版本号转成了15.3字符串看似没问题但如果你要的是15.3-p1这种带后缀的版本号就必须加引号。所以凡是版本号、CIDR、实例类型这类看似数字但语义上属于标识符的值一律在 YAML 里用引号包裹养成习惯后能少踩很多类型坑。4. 开发与生产环境的差异收敛一份 YAML 的灵活边界4.1 环境差异控制在什么粒度合适很多人拿到 YAML 方案后第一反应就是搞一个完善的继承机制比如 dev 继承 commonprod 覆盖 dev。但我的建议适得其反环境差异真正影响配置的维度其实很有限无非是资源规格、副本数、网络段、数据库开关。这些差异直接写进各环境的 YAML 是最清晰的不需要引入继承。举一个具体的边界判断方法如果某个配置字段在 dev 和 prod 的值一样那就直接写在两份 YAML 里不用抽公共层如果某个值会随环境变化先问自己“这个变化是预期的吗”如果是预期内的就在每个环境文件里显式写出来。这样做的好处是你看任何一个环境的 YAML 都能独立理解整套配置不需要再查另一个文件。4.2 用 common.yaml 环境覆盖文件的模式即使我前面批评了 Teraform 浅合并的坑我还是会在项目里放一份common.yaml。但它的定位不是被 Terraform 读取而是作为“配置基线”给人和代码评审工具参考。common.yaml里写的是项目通用的元信息比如项目名、维护人、告警联系邮箱等这些不直接参与资源创建。如果你确实希望走common.yaml 环境覆盖的模式必须先解决深合并的问题。一个有效但不优雅的方案是把 YAML 拆成多个小块按需读取# common.yaml project_name: demo region: ap-southeast-1# dev.yaml project_name: demo region: ap-southeast-1 compute: __use_common: true instance_type: t3.micro然后 HCL 里针对每个模块单独做 map 合并比如先读 common 的 compute 段再用 env 的 compute 段做 merge。这样确实能规避全局浅合并的问题但代价是代码变得繁琐。我的结论很直接如果团队小于 10 人直接复制粘贴如果团队大到需要节流公共配置说明基础设施复杂度已经超过 YAML 能承载的边界应该考虑 Terragrunt 或 CDK 这类工具了。4.3 防呆设计环境名与路径的强绑定比“配置继承”更重要的是防呆设计。我最怕的不是配置写得重复而是某个人在 dev 环境目录里执行terraform destroy时YAML 读的却是 prod 的配置。为了杜绝这种情况我在入口代码里加了一个校验locals { expected_env basename(path.module) actual_env var.environment } resource terraform_data environment_guard { provisioner local-exec { command test ${local.expected_env} ${local.actual_env} || echo environment mismatch! exit 1 } }basename(path.module)在envs/dev目录下执行时返回dev如果传进来的var.environment不是devplan 阶段就会报错退出。你也许觉得这个步骤多余但越是给团队使用的 Terraform 仓库越要相信“人会犯错”这个前提在代码层面提前拦截。类似的防呆逻辑还有prod 环境的 plan/apply 必须手动确认dev 环境允许 CI 自动执行。这些可以通过 CI 里的分支保护实现也可以在variables.tf里把 prod 相关的变量加validation约束比如实例类型必须匹配某个正则避免有人把 prod 写成t3.micro。5. 密钥与敏感字段YAML 不背这个锅5.1 最基础也最实用的做法环境变量注入YAML 文件的便利性也带来了最致命的诱惑把数据库密码、API 密钥直接写进去。千万不要这么做。尤其当你的 config 目录纳入了版本库后即使后来删掉git 历史里仍然会留下明文密码的痕迹。我采用的方案是在 YAML 里只放敏感字段的“环境变量名”而不是值本身。回到上面的password_env例子database: password_env: DB_PASSWORD在 HCL 里这样读取module database { source ../../modules/database engine local.config.database.engine engine_version local.config.database.engine_version instance_class local.config.database.instance_class allocated_storage local.config.database.allocated_storage multi_az local.config.database.multi_az password nonsensitive(file(${path.module}/../../secrets/${var.environment}/db_password)) }如果你的环境变量方式更直接也可以这样password var.db_password然后在运行 terraform 之前用export TF_VAR_db_passwordxxx注入。这两种方式都行核心原则是敏感信息只存在于进程环境变量、密钥管理服务或本地未跟踪的 secret 文件中绝不出现在 YAML 和 git 里。5.2 敏感字段的引用约定为了避免团队成员各自发挥我在 README 里明确了敏感字段的处理规则所有敏感值在 YAML 中用xxx_env字段命名值为环境变量名。禁止在 YAML 注释里写测试账号的密码。禁止把.terraform目录、*.tfstate、secrets/加入 git 索引。本地开发环境如果需要临时密码统一走scripts/load_secrets.sh脚本从密码管理器拉取。如果你对加密有更高要求可以试试 sops它能对 YAML 文件里的指定 key 做加密提交到 git 的是密文terra-form 执行时通过 sops 解密后加载。不过考虑到多数团队的实际情况环境变量注入已经是性价比最高的安全方案了先做到这一步再谈别的。6. state 隔离与多人协作别让两个环境互相踩踏6.1 后端选择与 workspace 的适用边界用本地 YAML 解决配置源问题后下一个绕不开的坎就是 state 管理。如果 dev 和 prod 共用同一个 state 文件那么两个环境的资源会被 Terraform 认为是同一个资源集合的一部分最直接的后果就是在 dev 目录执行terraform destroy会把 prod 的资源也一起销毁因为 state 里记录了 prod 的资源地址。我见过几个团队在这个问题上翻车共同特征都是图省事用了默认的本地 state或者用 workspace 但没搞清楚 workspace 之间的隔离逻辑。实际上 workspace 是同一个 state 下的不同命名空间资源地址是aws_instance.this[workspace_root]这种形式误操作的风险不小。相比之下我更推荐每个环境独立的 state 文件通过 backend 的key区分terraform { backend s3 { bucket my-infra-tfstates key envs/dev/infra.tfstate region ap-southeast-1 dynamodb_table terraform-lock encrypt true } }但注意backend 块里不能使用变量所以 dev 和 prod 的backend.tf需要分别维护或者 init 时通过-backend-config动态传参。我选择了后者在envs/dev目录执行terraform init \ -backend-configbucketmy-infra-tfstates \ -backend-configkeyenvs/dev/infra.tfstate \ -backend-configdynamodb_tableterraform-lock \ -backend-configencrypttrue配合dynamodb_table做状态锁多人同时 apply 时避免并发写 state。每套环境有自己的 stateplan和apply天然隔离误操作成本大大降低。6.2 state 中不要放密钥等敏感信息即使敏感值不是写在 YAML 里如果你的数据库密码是最终会落入 state 文件的state 文件本身就会成为敏感信息的载体。S3 后端一定要开启加密bucket 的访问策略也要收紧只允许运维和 CI 角色访问。我每次执行完terraform apply后会顺手检查一遍 state 文件里是否出现可疑的明文aws s3 cp s3://my-infra-tfstates/envs/dev/infra.tfstate - | jq .resources[] | select(.typerandom_password)一般不会直接搜到因为很多资源把敏感值放到了 sensitive 字段里但养成这个习惯后你能及时发现是否有模块设计不合理、把敏感字段暴露到了 state 的普通属性中。7. 我踩过的几个坑YAML 缩进、布尔类型与误删环境7.1 缩进和布尔类型引发的低效排错第一类踩坑记录最没技术含量但也最常见YAML 缩进错误。我们的 config 文件统一用两个空格缩进但有人会用 tabyamldecode直接报错。这个还容易发现。更隐蔽的是不统一的对齐方式比如subnet_cidrs下的子项有的缩进两个空格、有的缩进四个空格解析出来的 map 结构跟你预期完全不同Terraform 拿到的 CIDR 列表根本不是 YAML 里看着的样子。第二类坑是布尔类型。YAML 1.1 规范里yes、no、on、off都会被解析成布尔值而 Terraform 的某些资源参数对这样的隐式转换支持得不好。我们曾经在 YAML 里写multi_az: no结果数据库模块收到的值是false吗不是no解析成布尔 false但有人误以为no这个字符串会被读取排查了好久才发现是 YAML 类型转换问题。后来我在 pre-commit hook 里加了一个简单的脚本用 Python 的yaml.safe_load加载所有 config 文件再递归检查布尔字段是否是true或false字符串之一从源头上杜绝这个坑。7.2 误删环境的教训为什么我彻底放弃“单 state 管多环境”最后一个教训也是让我下决心重构目录结构的关键事件。之前我用单目录加for_each遍历local.configs的方式管理 dev 和 prod 两个环境一个 state 文件包含两套资源。某次需要临时清理 dev 环境腾出测试资源同事执行了terraform destroy。他本意是只想删 dev但terraform destroy根本不管你的环境变量是什么它的语义是“销毁 state 里所有由这个配置管理的资源”。结果 prod 的实例、数据库、网络全部被删了虽然通过备份恢复了一部分但那次事故的 impact 足够写进我个人的“最贵教训”清单了。重构之后每个环境一个目录、一个 state、一个独立的 destroy 权限。如果想销毁 dev只能进入envs/dev目录执行而 prod 目录的权限在 CI 里额外加了审批。这个方案虽然让目录多了一点重复代码但换来的是“物理隔离”级别的安全感。如果你正在一个 state 里管理多个环境我强烈建议尽早拆开。7.3 celery 之外的日常操作节奏建议最后补充一个实用性建议不管你的 YAML 结构设计得多好每次改完配置后都别急着 apply。我自己的操作节奏是terraform fmt -recursive terraform validate terraform plan -var environmentdevplan的输出一定要人工扫一眼重点看有没有“意外删除”或“替换资源”的操作。因为 YAML 方案让配置变更变得太容易了手一抖多删一个字段plan 里就可能是destroy一台数据库。加一道 review 关卡比事后找备份恢复省心太多。这套方案我已经跑了两个多月目前团队里新同事接手环境配置时基本不用问我就能通过 YAML 文件判断环境差异。如果你也有多环境管理混乱的问题不妨从把 tfvars 换成 YAML 开始先把配置的组织方式理顺再逐步优化 state 和后端。踩坑不可怕关键是每一个坑都能帮你把流程往前推一步。
返回列表