三套环境的 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.md1.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:
| source 写法 | 示例 | 能否写 version |
|---|---|---|
| 本地路径 | ./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 复杂类型与可选字段
| 类型 | 写法 | 适合 |
|---|---|---|
| 基础 | stringnumberbool | 单个值 |
| 集合 | 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(命令行,写在后面的赢)exportTF_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 常用校验写法
| 需求 | condition |
|---|---|
| 非空字符串 | 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.tfmodules/放可复用单元,envs/放各环境入口。模块目录只留通用逻辑,环境差异通过变量传进去。
模块粒度按「能不能独立描述一件事」切。S3 模块只管 bucket 本身,需要 VPC 就让调用方把vpc_id传进来,别在模块里顺手建 VPC。
5.3 迁移步骤
- 建
modules/s3_bucket/,把三个文件搬进去,硬编码改成variable声明 - 环境目录改写成
module块,参数走变量 - 搬状态地址,避免销毁重建
terraform plan确认输出No changes
状态迁移是唯一容易出事的一步。资源地址多了模块前缀,不搬就是「旧的销毁 + 新的创建」:
terraform statemv'aws_s3_bucket.this''module.s3_bucket.aws_s3_bucket.this'terraform statemv'aws_s3_bucket_versioning.this''module.s3_bucket.aws_s3_bucket_versioning.this'terraform planNo 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