三套环境的 S3 配置各抄了一份,改个参数要动三个目录。有一回漏改,plan 里 dev 的 bucket 前缀跟 prod 撞了。
💡 示例基于 Terraform 1.x + AWS provider 5.x,模块放在 ./modules/s3_bucket,本地路径引用,不推 registry 也能跑。
1. 模块结构与调用关系
1.1 三段文件结构
一个目录就是一个模块,目录里的 .tf 文件全部参与求值。约定拆成三个文件,把输入、加工、输出分开。
| variables.tf | 输入 | variable 块,声明调用方能传什么 |
| main.tf | 加工 | resource / data / locals,真正干活的资源 |
| outputs.tf | 输出 | output 块,声明调用方能读到什么 |
| versions.tf | 依赖约束 | terraform 块 + required_providers |
| README.md | 文档 | 参数表、调用示例,registry 会渲染 |
文件名本身没有语义,Terraform 只认后缀。拆三个文件是为了让人一眼找到入口和出口。
跑 terraform plan 的那个目录叫 root module,被它引用的目录叫 child module。child module 不能单独 apply,只能由 root 传参调用。
terraform-aws-s3-bucket/
├── variables.tf ……… 输入:name / versioning / force_destroy / tags
├── main.tf ………….. 加工:aws_s3_bucket + 版本控制 + 公共访问阻断
├── outputs.tf ……….. 输出:bucket_id / bucket_arn / bucket_domain_name
├── versions.tf ………. required_providers: aws ~> 5.0
└── README.md

1.2 调用方式与资源地址
调用方在自己的 main.tf 里写 module 块,source 指向模块目录,其余参数按变量名传。
module "s3_bucket" {
source = "./modules/s3_bucket"
name = "logs-archive"
versioning = true
force_destroy = false
tags = {
env = "dev"
}
}
模块内部的资源地址会加上模块前缀,aws_s3_bucket.this 变成 module.s3_bucket.aws_s3_bucket.this。调用方读输出写 module.s3_bucket.bucket_id,碰不到内部资源名。
调用方 main.tf ──传变量──▶ variables.tf ──┬─校验通过─▶ main.tf 资源 ─▶ outputs.tf ──输出──▶ 调用方
└─校验失败─▶ plan 报错
source 有四类写法,能写 version 的只有 registry:
| 本地路径 | ./modules/s3_bucket | 否 |
| Registry | hashicorp/consul/aws | 是,version = "~> 0.11" |
| Git | git::https://example.com/vpc.git?ref=v1.2.0 | 用 ?ref= 锁版本 |
| HTTP 归档 | https://example.com/vpc-module.zip | 否 |
本地路径适合开发期,稳定后推 Git 打 tag,用 ?ref= 固定版本号。
2. 输入:variables.tf
2.1 四个常用属性
| type | 约束传入值的类型 | 视为 any,写错类型到 plan 才炸 |
| default | 默认值,写了就变可选 | 不写即必填,调用方漏传直接报错 |
| description | 参数说明 | 文档靠人肉维护 |
| sensitive | 打码,plan 里显示 (sensitive value) | 明文进 plan 和日志 |
variable "name" {
type = string
description = "Bucket 名称前缀,全局唯一"
}
variable "versioning" {
type = bool
description = "是否开启版本控制"
default = false
}
variable "force_destroy" {
type = bool
description = "销毁时是否清空对象"
default = false
}
name 没有 default,调用方必须传。另外两个有默认值,不传就取默认。
2.2 复杂类型与可选字段
| 基础 | string number bool | 单个值 |
| 集合 | list(string) set(string) map(string) | 同类型多值 |
| 结构 | object({…}) | 一组固定字段 |
| 元组 | tuple([string, number]) | 位置固定、类型不同 |
对象里部分字段可以不填,用 optional(),Terraform 1.3 起支持:
variable "lifecycle_rule" {
type = object({
enabled = bool
days = optional(number, 30)
prefix = optional(string, "")
})
default = {
enabled = true
}
}
days 和 prefix 没传时取括号里的默认值。调用方只写必填项,模块内部按完整结构使用。
⚠️ list 用位置索引,中间插一个元素会让后面整体前移;set 成员无序,取值顺序不保证。要按名字引用就换成 map。
2.3 变量来源与优先级
同一个变量可能从多处赋值,Terraform 按固定顺序取,后者覆盖前者:
环境变量 TF_VAR_name
▼ 覆盖
terraform.tfvars
▼
terraform.tfvars.json
▼
*.auto.tfvars / *.auto.tfvars.json(按文件名排序)
▼
-var / -var-file(命令行,写在后面的赢)
export TF_VAR_name=from-env
terraform plan -var="name=from-cli"
结果取 from-cli。参数没生效时先查这条链,八成是某个 auto.tfvars 把人坑了。
3. 输出:outputs.tf
3.1 value 与 description
调用方只能读到 output 声明过的值,模块内部资源属性不声明就出不去。
output "bucket_id" {
description = "Bucket 名称"
value = aws_s3_bucket.this.id
}
output "bucket_arn" {
description = "Bucket ARN,用于策略授权"
value = aws_s3_bucket.this.arn
}
引用写 module.s3_bucket.bucket_arn。value 支持表达式,拼好的字符串也能出。
| value | 必填,输出内容 |
| description | 说明,registry 文档用 |
| sensitive | 打码,向上传播 |
| depends_on | 强制依赖,输出前等某资源就绪 |
| precondition | 输出前校验,不满足直接报错 |
3.2 敏感输出
密码、密钥这类值加 sensitive = true,plan 和 terraform output 都显示 (sensitive value)。
output "secret_key" {
description = "访问密钥"
value = aws_iam_access_key.this.secret
sensitive = true
}
取真实值用 terraform output -raw secret_key。sensitive 会向上传播,引用敏感输出的另一个 output 也要标敏感,否则 plan 报错。
4. 变量校验
4.1 validation 块
variable 块里加 validation,condition 返回 false 时 plan 直接失败,附上 error_message。
variable "name" {
type = string
description = "Bucket 名称前缀"
validation {
condition = can(regex("^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$", var.name))
error_message = "name 只允许小写字母、数字、连字符,长度 3-63。"
}
validation {
condition = !strcontains(var.name, "..")
error_message = "name 不能含连续两个点。"
}
}
can() 把可能报错的表达式包成布尔值,正则不匹配时返回 false,不会抛异常中断。一个变量可以挂多个 validation,全部通过才继续。

4.2 condition 的约束
| 必须返回 bool | 返回字符串或数字报 Invalid expression value |
| 只能用纯函数 | uuid() timestamp() bcrypt() 每次结果不同,禁用 |
| 引用范围 | 1.9 之前只能引用被校验的那个变量;1.9 起可引用其他输入变量 |
| 报错时机 | plan 阶段,早于任何云 API 调用 |
跨变量校验在 1.9 之前用 precondition 兜底:
variable "environment" {
type = string
}
variable "enable_versioning" {
type = bool
default = false
}
environment = "prod" 且没开版本控制时,希望在 plan 阶段拦住,把校验挂到资源上:
resource "aws_s3_bucket" "this" {
bucket = var.name
lifecycle {
precondition {
condition = var.environment != "prod" || var.enable_versioning
error_message = "生产环境的 bucket 必须开启版本控制。"
}
}
}
precondition 写在 lifecycle 里,condition 能读任意变量和资源属性,比 validation 宽松。
4.3 常用校验写法
| 非空字符串 | length(var.name) > 0 |
| 枚举值 | contains(["dev", "staging", "prod"], var.environment) |
| 数字区间 | var.port >= 1 && var.port <= 65535 |
| CIDR 格式 | can(cidrhost(var.vpc_cidr, 0)) |
| 前缀匹配 | startswith(var.name, "logs-") |
| 列表非空 | length(var.subnet_ids) > 0 |
| 互斥开关 | !(var.public && var.private) |
💡 error_message 写清期望值,别只写「参数非法」
plan 失败时调用方只能看到这一行提示,把允许范围和实际值都带上: validation { condition = contains([“dev”, “staging”, “prod”], var.environment) error_message = “environment 只能是 dev / staging / prod,当前值 ${var.environment}。” }
5. 抽取可复用模块
5.1 抽取前的状态
三套环境各有一份 main.tf,S3 相关段落几乎一致,只有前缀和版本控制开关不同。
envs/
├── dev/main.tf ……. aws_s3_bucket + versioning + 公共访问阻断
├── staging/main.tf … 同一段资源定义,复制一遍
└── prod/main.tf …… 同一段资源定义,再复制一遍
改一条公共规则要动三处,漏一处 plan 就出差异。把这些段落收进一个目录,环境目录只留 module 块。
5.2 目录规划
.
├── modules/
│ └── s3_bucket/
│ ├── variables.tf
│ ├── main.tf
│ ├── outputs.tf
│ └── versions.tf
└── envs/
├── dev/main.tf
├── staging/main.tf
└── prod/main.tf
modules/ 放可复用单元,envs/ 放各环境入口。模块目录只留通用逻辑,环境差异通过变量传进去。
模块粒度按「能不能独立描述一件事」切。S3 模块只管 bucket 本身,需要 VPC 就让调用方把 vpc_id 传进来,别在模块里顺手建 VPC。


5.3 迁移步骤
状态迁移是唯一容易出事的一步。资源地址多了模块前缀,不搬就是「旧的销毁 + 新的创建」:
terraform state mv 'aws_s3_bucket.this' 'module.s3_bucket.aws_s3_bucket.this'
terraform state mv 'aws_s3_bucket_versioning.this' 'module.s3_bucket.aws_s3_bucket_versioning.this'
terraform plan
No changes. Your infrastructure matches the configuration.
plan 里出现 destroy,说明有资源地址漏搬,反向执行一次 state mv 就能退回去。
⚠️ state mv 只改状态文件里的地址,不动真实资源。执行前先 terraform state list 对一遍清单,挪错了在真实环境里看不出来。
5.4 模块内的 provider 声明
模块里写 provider "aws" {} 在 0.13 之后被弃用,provider 配置由根模块决定,模块只声明依赖版本。
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
~> 5.0 允许 5.x 小版本升级,挡住 6.0 的不兼容变更。
6. 常见报错与排查
| Unsupported argument | 模块没声明该变量,调用方却传了 | 在 variables.tf 补 variable,或删掉传参 |
| Missing required argument | 变量没有 default,调用方漏传 | 传值,或给变量加 default |
| Invalid value for variable | validation 的 condition 返回 false | 看 error_message 里的允许范围 |
| Invalid expression value | condition 没返回 bool | 包一层 can(),或补上比较运算 |
| Reference to undeclared output | 读了模块没声明的输出 | 在 outputs.tf 补 output |
| Module version requirements have not been met | 本地路径上写了 version | 去掉 version,改用 ?ref= 锁 tag |
| 抽模块后 plan 要销毁重建 | 资源地址没搬 | terraform state mv 到新地址 |
| 输出显示 (sensitive value) | 该输出标了 sensitive | terraform output -raw xxx 取原值 |
动手前过一遍清单:
- 必填变量都写了 description,调用方看得懂
- 名字类变量配了 validation,非法值挡在 plan 之前
- condition 里没有 uuid() / timestamp() 这类非纯函数
- 输出覆盖了调用方要用的字段,敏感值标了 sensitive
- 抽完模块执行了 state mv,plan 为 No changes
- 模块内没有残留 provider 块
7. 参考
- Terraform 文档 · 模块结构:https://developer.hashicorp.com/terraform/language/modules/develop/structure
- Terraform 文档 · 输入变量:https://developer.hashicorp.com/terraform/language/values/variables
- Terraform 文档 · 输出值:https://developer.hashicorp.com/terraform/language/values/outputs
- Terraform 文档 · 自定义校验:https://developer.hashicorp.com/terraform/language/expressions/custom-validations
- Terraform 文档 · 模块源:https://developer.hashicorp.com/terraform/language/modules/sources
网硕互联帮助中心






评论前必须登录!
注册