terraform plan -var-file=prod.tfvars跑完,staging 里一切正常的资源在 prod 被整体重建,根因只是environment在资源块里被写成了字符串常量。本文沿着「传进来 / 算出来 / 交出去」三条边界,把变量、locals、outputs 和支撑它们的类型与函数讲透,读完你能让同一份代码安全地跑 dev、staging、prod。
一、场景铺垫
我接手过一个三环境仓库,main.tf里并排躺着三份九成相同的资源块,改一个标签要同步改三处,漏一处就是线上配置漂移。把硬编码全部外提之后,文件长度砍掉一半,环境差异收敛成了两个 tfvars 文件。
二、踩坑现象
我前后踩了三个坑,症状各不相同,根因却是同一个:值的来源没有被定义清楚。
| 坑点 | 现象 | 根因 |
|---|---|---|
资源块里写死environment = "prod" | 切环境必须改代码,改完还容易漏 | 变化点没被暴露成接口 |
设了TF_VAR_environment=dev却不生效 | 最终仍是 prod | 命令行-var/-var-file优先级高于环境变量 |
以为sensitive = true就不落盘 | state 文件里明文可见 | sensitive只抑制 CLI 回显,不改变存储 |
三个坑指向同一件事:Terraform 里每个值都应该有明确的归属。那么,值的归属由什么决定?
三、底层原理
3.1 三条边界
Terraform 的参数化只做三件事:把外部输入接进来,把内部推导算出来,把执行结果交出去。对应到配置里,就是variable、locals、output三个块。
外部输入 Terraform 配置内部 交付目标 terraform.tfvars -var / -var-file ──传入──> [ variable ] ──引用──> [ locals ] ──引用──> [ resource ] TF_VAR_xxx │ └──输出──> [ output ] ──> CLI / CI / 父模块三者的引用前缀互不重叠:变量用var.,局部值用local.,输出在子模块中被父模块引用时写成module.<模块名>.<输出名>。前缀不重叠,才可能在阅读时一眼判断一个值从哪里来、能不能被外部改。
划分边界的目的只有一个:让每个值在全仓库只有一个定义处。定义处唯一,修改点才唯一,环境之间的差异才可枚举。
3.2 数据类型
类型不是装饰,它决定校验发生在哪一层。声明list(string)会直接拒绝传入字符串,声明number会拒绝"3"。
| 类型 | 写法 | 接受的输入 | 典型用途 |
|---|---|---|---|
| 字符串 | string | "demo" | 应用名、环境名 |
| 数字 | number | 3、1.5 | 副本数、超时秒数 |
| 布尔 | bool | true、false | 功能开关 |
| 字符串列表 | list(string) | ["auth", "metrics"] | 特性开关集合 |
| 字符串映射 | map(string) | { CostCenter = "core" } | 资源标签 |
| 对象 | object({...}) | 结构化键值对 | 复杂配置块 |
| 任意 | any | 不做校验 | 仅限原型验证 |
类型收窄到刚好够用即可。写any等于放弃类型校验,错误会推迟到apply阶段才暴露,排查成本远高于多写一行类型声明。
3.3 表达式与函数
变量默认值和 locals 里放的不是常量,而是表达式(expression)。真正让参数化变灵活的是函数。
| 函数 | 作用 | 示例 |
|---|---|---|
merge | 合并多个映射,后者覆盖前者 | merge({A = 1}, {B = 2}) |
jsonencode | 对象转 JSON 字符串 | jsonencode(local.app_config) |
regex | 正则匹配并返回结果 | regex("^[a-z][a-z0-9-]{1,20}$", var.app_name) |
contains | 判断列表是否含某元素 | contains(["dev", "prod"], var.environment) |
can | 表达式求值成功返回 true | can(regex("^[a-z]+$", var.app_name)) |
floor | 向下取整 | floor(var.replicas) |
format | 字符串格式化 | format("%s-%s", var.app_name, var.environment) |
can()是校验规则里最关键的一块拼图:regex匹配失败会直接抛错中断求值,只有包一层can(),才能把「是否匹配」转换成可判断的布尔值。
注意:sensitive = true只让值不出现在 CLI 回显里,它依然以明文写入 state,state 本身必须按敏感数据保护。
3.4 值的引用来源
除var.与local.之外,配置里还有几个固定引用前缀,混用时容易写错:
| 前缀 | 含义 |
|---|---|
var. | 引用输入变量 |
local. | 引用局部值 |
path.module | 当前模块所在路径 |
terraform.workspace | 当前工作区名称 |
data.<类型>.<名称> | 引用只读数据源 |
四、解决方案
4.1 变量:只暴露变化点
你只需要把真正随环境、随部署变化的量做成变量,其余一律不暴露。
variable "app_name" { description = "应用名" type = string default = "demo" validation { condition = can(regex("^[a-z][a-z0-9-]{1,20}$", var.app_name)) error_message = "app_name 必须小写字母开头,可含数字和连字符,长度 2-21。" } } variable "environment" { description = "环境名" type = string validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "environment 必须是 dev、staging 或 prod。" } } variable "replicas" { description = "副本数" type = number default = 1 validation { condition = var.replicas >= 1 && var.replicas <= 10 && floor(var.replicas) == var.replicas error_message = "replicas 必须是 1 到 10 的整数。" } } variable "owner" { description = "归属团队" type = string default = "platform" } variable "enabled_features" { description = "启用特性" type = list(string) default = [] } variable "extra_tags" { description = "额外标签" type = map(string) default = {} }variable块可用的参数:
| 参数 | 作用 |
|---|---|
type | 声明数据类型,参与校验 |
default | 默认值,写了即非必填 |
description | 说明,会出现在terraform-docs输出与交互提示中 |
sensitive | 抑制 CLI 回显 |
validation | 自定义校验规则 |
nullable | 是否允许传入 null |
变量只承载真正随环境变化的量。变量的数量本身就是设计信号:正常的业务模块通常不超过十个,一旦超出,说明其中一部分本该属于 locals。固定不变的结构、资源类型、内部命名规则,都不该出现在变量清单里。
4.2 locals:收敛重复表达式
同一个merge、format或jsonencode写三遍,就该把它收进 locals。
locals { name_prefix = "${var.app_name}-${var.environment}" common_tags = merge( { Project = var.app_name Env = var.environment Owner = var.owner }, var.extra_tags ) app_config = { app_name = var.app_name environment = var.environment replicas = var.replicas features = var.enabled_features tags = local.common_tags } app_config_json = jsonencode(local.app_config) }这里收敛了三类推导:统一命名规则、公共标签与额外标签的合并、对象到 JSON 的序列化。三者都是纯计算,没有外部输入,正好落在 locals 的职责范围内。
locals不能被外部赋值,这一点是有意为之:它只承载可从其他值推导出来的中间结果。一旦允许外部注入,推导链就断了,同一个值会同时存在「算出来的」和「传进来的」两个来源,排查时会非常难受。
4.3 outputs:把结果交出去
output有三个消费方:命令行与 CI、父模块、remote state。
output "config_path" { value = local_file.app_config.filename } output "name_prefix" { value = local.name_prefix } output "summary" { value = { environment = var.environment replicas = var.replicas features = var.enabled_features } } output "config_content" { value = local_file.app_config.content sensitive = true }只输出外部真正需要的值。把整个内部对象原样抛出去,等于把内部实现变成公开接口,后续任何一次重构都会牵动下游。
4.4 赋值优先级
同名变量被多个来源赋值时,后加载的覆盖先加载的。
| 顺序 | 来源 | 说明 |
|---|---|---|
| 1 | 默认值 | 最低 |
| 2 | TF_VAR_xxx | 环境变量 |
| 3 | terraform.tfvars | 自动加载 |
| 4 | terraform.tfvars.json | 自动加载 |
| 5 | *.auto.tfvars | 按文件名排序自动加载 |
| 6 | -var/-var-file | 命令行最高,后出现覆盖先出现 |
4.5 tfvars 组织
按环境拆文件,是让优先级规则为我所用的最省事做法。
# terraform.tfvars —— 本地开发默认值 app_name = "demo" environment = "dev" replicas = 1 owner = "platform" enabled_features = ["auth", "metrics"] extra_tags = { CostCenter = "R&D" }# prod.tfvars —— 生产覆盖 app_name = "demo" environment = "prod" replicas = 3 owner = "platform" enabled_features = ["auth", "metrics", "audit"] extra_tags = { CostCenter = "core" Compliance = "true" }注意:敏感值不要写进 tfvars,这类文件通常随仓库提交,等同明文入库;密钥应交给密钥管理系统,或通过<font style="color:#080;">TF_VAR_</font>环境变量注入。
五、实操验证
用localprovider 生成一个 JSON 文件,不需要云账号就能把整条链路跑通。目录结构:
. ├── main.tf ├── variables.tf ├── locals.tf ├── outputs.tf ├── terraform.tfvars └── prod.tfvarsmain.tf只声明 provider 和资源,不出现任何环境相关的字面量:
terraform { required_version = ">= 1.5.0" required_providers { local = { source = "hashicorp/local" version = "~> 2.5" } } } resource "local_file" "app_config" { filename = "${path.module}/${local.name_prefix}.json" content = local.app_config_json }variables.tf、locals.tf、outputs.tf分别放前面三段代码。初始化并校验:
terraform init terraform validateInitializing the backend... Initializing provider plugins... - Finding hashicorp/local versions matching "~> 2.5"... - Installing hashicorp/local v2.5.2... - Installed hashicorp/local v2.5.2 (signed by HashiCorp) Terraform has been successfully initialized! Success! The configuration is valid.切换到 prod 只需要换一个文件:
terraform plan -var-file="prod.tfvars"Terraform will perform the following actions: # local_file.app_config will be created + resource "local_file" "app_config" { + content = "{\"app_name\":\"demo\",\"environment\":\"prod\",...}" + filename = "./demo-prod.json" + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy.terraform apply -var-file="prod.tfvars"-auto-approve terraform outputApply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: name_prefix = "demo-prod" summary = { "environment" = "prod" "features" = ["auth", "metrics", "audit"] "replicas" = 3 }对比一下硬编码版本,差异就很直观了:
resource "local_file" "app_config" { filename = "./demo-prod.json" content = jsonencode({ app_name = "demo" environment = "prod" replicas = 3 tags = { Project = "demo" Env = "prod" Owner = "platform" CostCenter = "core" } }) }这份写法在 dev 与 prod 之间无法复用,标签规则和命名规则各写一遍。校验规则的价值则在plan阶段就体现出来:
terraform plan-var="environment=test"Error: Invalid value for variable on variables.tf line 10, in variable "environment": 10: variable "environment" { var.environment is "test" environment 必须是 dev、staging 或 prod。 This was checked by the validation rule at variables.tf:14,3-13.再验证一次优先级,三个来源同时给environment赋值:
TF_VAR_environment=dev terraform plan-var="environment=staging"-var-file="prod.tfvars"最终生效的是prod:-var-file出现在-var之后,后加载者胜出。命令行参数之间按出现顺序覆盖,与环境变量的高低无关。这也是我在第 2 节里踩到第二个坑的直接原因。
六、总结延伸
三个块各自解决一个问题,边界不能混:
| 块 | 职责 | 引用前缀 | 能否被外部赋值 |
|---|---|---|---|
variable | 暴露变化点 | var. | 能 |
locals | 收敛重复表达式 | local. | 不能 |
output | 交付执行结果 | module.<模块名>.<输出名> | 不能 |
减少硬编码的关键从来不是「把所有值都变成变量」,而是分清值的归属。外部必须传的做变量,内部可推导的放 locals,外部需要知道的做 output。
几条我在实际项目里固定下来的避坑建议:
- 本地开发用
terraform.tfvars,多环境用dev.tfvars、prod.tfvars。 - CI/CD 统一走
-var-file或TF_VAR_,不要依赖仓库里的默认值。 - 敏感变量不进 tfvars,交给密钥管理系统。
- 每个变量都加
validation,让错误停在plan之前。 - 需要暴露给外部的输出才加
sensitive,而不是为了「安全」而加。
术语速查表
| 术语 | 英文 | 一句话解释 |
|---|---|---|
| 输入变量 | input variable | 外部传入配置的接口,可带默认值与校验 |
| 局部值 | locals | 模块内部派生值,不能被外部赋值 |
| 输出值 | output | 把结果交给 CLI、CI、父模块或 remote state |
| 变量文件 | tfvars | 批量赋值文件,按固定顺序自动加载 |
| 表达式 | expression | 生成值的语句,可调用内置函数 |
| 数据源 | data source | 只读引用已有资源或外部信息 |
| 状态文件 | state file | 记录资源映射的文件,须按敏感数据保护 |
| 根模块 | root module | 直接执行terraform apply的模块 |
| 子模块 | child module | 被module块引用的模块 |
参考链接
- Terraform 输入变量:https://developer.hashicorp.com/terraform/language/values/variables
- Terraform 局部值:https://developer.hashicorp.com/terraform/language/values/locals
- Terraform 输出值:https://developer.hashicorp.com/terraform/language/values/outputs
- Terraform 表达式:https://developer.hashicorp.com/terraform/language/expressions
- Terraform 内置函数:https://developer.hashicorp.com/terraform/language/functions