上周把环境拆成三个模块之后,plan 直接报了 Error: creating EC2 Instance: InvalidSubnetID.NotFound。subnet_id 是从 dev 的 tfvars 里手抄过去的,prod 的 VPC 里没有这个子网。
💡 示例基于 Terraform 1.5 + AWS provider 5.x,三个模块都放在本地 modules/ 下,source 用相对路径。
1. 模块划分与依赖关系
1.1 三层模块划分
按「谁依赖谁」切成三层,上游只暴露输出,下游只在参数里引用。
| 模块 | 职责 | 关键输入 | 输出 |
|---|---|---|---|
network |
VPC、公有子网、IGW、路由表 | vpc_cidr、az_count |
vpc_id、subnet_ids |
security_group |
安全组与入站规则 | vpc_id、ingress_rules |
sg_id |
compute |
EC2 实例 | subnet_ids、sg_ids、instance_type |
instance_ids、private_ips |
network 不知道安全组的存在,compute 也不碰 VPC 的 CIDR。跨层需要的东西只能从输出里拿。
切分依据是变更频率。VPC 改一次要动整片子网,安全组规则一周改好几遍,EC2 随发布重建。三样东西混在一个模块里,调一条规则就得跟着重算子网和路由。
1.2 目录结构
latex
.
├── modules/
│ ├── network/
│ ├── security_group/
│ └── compute/
└── envs/
├── dev/main.tf
└── prod/main.tf
envs/*/main.tf 是唯一的 root module,terraform plan 只在这里跑。模块目录里不放 provider 配置。
1.3 三段调用
plain
module "network" {
source = "../../modules/network"
env = var.env
vpc_cidr = var.vpc_cidr
az_count = 2
}
module "sg_app" {
source = "../../modules/security_group"
vpc_id = module.network.vpc_id
sg_name = "app-${var.env}"
}
module "app" {
source = "../../modules/compute"
instance_count = var.instance_count
subnet_ids = values(module.network.subnet_ids)
sg_ids = [module.sg_app.sg_id]
instance_type = var.instance_type
}
三段里没有一处顺序声明。module.app 的参数写上 module.sg_app.sg_id 那一刻,依赖就已经成立。
图1 三层模块的依赖链与输出暴露
2. 模块输出的声明与消费
2.1 输出声明
outputs.tf 决定调用方能读到什么,没声明的属性一律拿不到。
plain
output "vpc_id" {
description = "VPC ID,安全组和子网都挂在它下面"
value = aws_vpc.this.id
}
output "subnet_ids" {
description = "可用区到子网 ID 的映射"
value = { for k, s in aws_subnet.public : k => s.id }
}
子网用 map 输出,下游要指定可用区时按 key 取,省掉按索引猜的麻烦。
输出的字段挑调用方真正要用的。写成 value = aws_vpc.this 也能跑,但下游会粘上一堆用不到的属性,provider 升版时跟着报错。
2.2 消费写法
| 上游输出 | 下游写法 | 拿到什么 |
|---|---|---|
string |
module.network.vpc_id |
单个 ID |
map(string) |
module.network.subnet_ids["az-a"] |
指定可用区的子网 |
map(string) |
values(module.network.subnet_ids) |
无序 list |
list(object) |
module.app[*].instance_ids |
展平的 ID 列表 |
values() 按 key 排序,跨环境顺序一致,可用区的对应关系会丢。要保对应关系就把 map 直接传下去,在 compute 里用 for_each 展开。
2.3 类型转换的位置
转换统一放在 root,模块内部只认一种类型,下游的 variables.tf 就不用兼容 map 和 list 两套输入。让 compute 自己兼容两种入参,for 表达式里就得写条件分支,模块的可读性掉一档。
图2 一次输出从 map 到 list 的三段传递
3. 模块间的依赖传递
3.1 隐式依赖
参数里引用上游输出,Terraform 在 graph 里加一条边,apply 时先建上游。
bash
terraform graph | dot -Tsvg > graph.svg
依赖只从表达式里的引用推导。ID 拼进字符串模板、塞进 local 再传,只要链路里还留着 module.x.y,边就还在;写成 tfvars 里的字面量,边就断了。
plan 输出里的资源地址带模块前缀,创建顺序也能对出来:
latex
# module.network.aws_vpc.this will be created
# module.sg_app.aws_security_group.this will be created
# module.app.aws_instance.this[0] will be created
3.2 显式 depends_on
依赖没体现在参数里时才用它,典型场景是 IAM 实例配置要等策略生效。
plain
module "app" {
source = "../../modules/compute"
subnet_ids = values(module.network.subnet_ids)
sg_ids = [module.sg_app.sg_id]
depends_on = [module.iam_profile]
}
⚠️ depends_on 作用在整个模块上,模块内所有资源一起等,plan 里也看不出多了哪条边。参数引用能表达依赖的场合不加它。
| 场景 | 写法 | 等待粒度 |
|---|---|---|
| 下游读上游输出 | 参数写 module.x.y |
属性级 |
| 下游不读输出但必须先建 | depends_on = [module.x] |
整个模块 |
| 单个资源内部校验 | lifecycle.precondition |
单个资源 |
3.3 循环依赖
network 里想给默认安全组加一条规则,规则源指向 module.sg_app.sg_id,而 sg_app 又要 vpc_id:
latex
Cycle: module.network.output.vpc_id, module.sg_app.output.sg_id
报错里列出的地址就是环上的节点,拿这两个地址反查各自的 main.tf,能定位到互相引用的那两行。
图3 双向引用报 Cycle,把规则挪到 root 后恢复单向
修法是把规则从模块里拿出来,在 root 里用 aws_security_group_rule 挂到两个模块的输出上,模块之间只剩单向依赖。
plain
resource "aws_security_group_rule" "app_from_bastion" {
type = "ingress"
security_group_id = module.sg_app.sg_id
source_security_group_id = module.sg_bastion.sg_id
from_port = 8080
to_port = 8080
protocol = "tcp"
}
4. 多环境组合
4.1 输入结构
环境差异收进一个 map(object),root 只写一份模块调用。
plain
variable "envs" {
type = map(object({
vpc_cidr = string
instance_type = string
}))
default = {
dev = { vpc_cidr = "10.1.0.0/16", instance_type = "t3.micro" }
prod = { vpc_cidr = "10.2.0.0/16", instance_type = "t3.small" }
}
}
4.2 for_each 展开
plain
module "network" {
source = "../../modules/network"
for_each = var.envs
env = each.key
vpc_cidr = each.value.vpc_cidr
}
module "app" {
source = "../../modules/compute"
for_each = var.envs
instance_count = 2
subnet_ids = values(module.network[each.key].subnet_ids)
sg_ids = [module.sg_app[each.key].sg_id]
instance_type = each.value.instance_type
}
两个模块共用 var.envs 做 key,实例按环境一一对应。
key 集合必须一致。module.network 里有 dev、prod,module.app 只传了 dev,引用 module.network["prod"] 会报 Invalid index。
latex
var.envs ──for_each──┬─ "dev" ──▶ module.network["dev"] ──▶ module.app["dev"]
└─ "prod" ──▶ module.network["prod"] ──▶ module.app["prod"]
4.3 带 key 的资源地址
地址变成 module.app["prod"].aws_instance.this[0],state list 和 state mv 都要带 key。
bash
OLD='module.app["prod"].aws_instance.this[0]'
NEW='module.app["prod"].aws_instance.web[0]'
terraform state mv "$OLD" "$NEW"
⚠️ for_each 的 key 必须 plan 阶段确定。拿 module.x.some_id 这种 apply 后才有值的属性当 key,报 Invalid for_each argument。
5. 输出向上汇聚
5.1 root 汇总输出
plain
output "private_ips" {
description = "各环境实例私网 IP"
value = { for k, m in module.app : k => m.private_ips }
}
汇总输出给别的 root 用。跨目录读状态时,对方只看得到 root 的 output,模块内部那层输出在那边不存在。
💡 另一个 root 读这些输出的写法
同账号同区域用 terraform_remote_state,只取 state 里的 outputs: data "terraform_remote_state" "prod" { backend = "s3" config = { bucket = "tfstate-prod" key = "network/terraform.tfstate" region = "ap-east-1" } } output "vpc_id" { value = data.terraform_remote_state.prod.outputs.vpc_id } state 里的 outputs 是明文,敏感值别往这里放。
5.2 敏感值传播
plain
output "db_password" {
description = "RDS 主密码"
value = module.rds.password
sensitive = true
}
上游标了 sensitive,root 再暴露一次仍要标,否则 plan 报 Output refers to sensitive values。同一个值经过 root 输出、再被另一个模块变量接收,链条上每一段都带着标记。
6. 常见报错与排查
| 报错 / 现象 | 原因 | 处理 |
|---|---|---|
Unsupported attribute |
输出名拼错,或模块没声明该输出 | 对着 outputs.tf 逐个核名字 |
Reference to undeclared output |
引用 module.x.y 但 y 不存在 |
在模块 outputs.tf 补 output |
Cycle: module.a..., module.b... |
两个模块互相引用 | 把公共资源挪到 root |
InvalidSubnetID.NotFound |
subnet 来自另一个 VPC,或写死了 ID | 改成引用 module.network.subnet_ids |
Invalid for_each argument |
key 依赖 apply 后才知道的值 | key 换成 var.envs 这类静态 map |
Invalid value for variable |
上游给 map,下游声明 list | root 里用 values() 转换 |
Output refers to sensitive values |
上游敏感输出没继续标 | 逐层加 sensitive = true |
Missing required argument |
下游新增必填变量,root 没传 | 补参数或给默认值 |
排查顺序是先看 plan 里资源地址的模块前缀,再看 graph 里有没有断边。
bash
terraform graph | grep -E 'module\.(network|sg_app|app)'
terraform plan -target=module.sg_app
7. 组合检查清单
- 每个模块的输出只暴露调用方真正要用的字段
- 跨模块传的值都走
module.x.y,没有手抄的 ID - map 与 list 的转换统一放在 root
-
depends_on只用在参数表达不了的场合 - 多环境用
for_each+map(object),key 来自静态变量 - 敏感值在 root 输出里继续标
sensitive
8. 关联文章
-
编写第一个 Terraform Module:结构、输入输出与变量校验:
[https://blog.csdn.net/2401_82619496/article/details/168637977?spm=1001.2014.3001.5502](https://blog.csdn.net/2401_82619496/article/details/168637977?spm=1001.2014.3001.5502)
9. 参考
- Terraform 文档 · 模块组合:https://developer.hashicorp.com/terraform/language/modules/develop/composition
- Terraform 文档 · 输出值:https://developer.hashicorp.com/terraform/language/values/outputs
- Terraform 文档 · depends_on:https://developer.hashicorp.com/terraform/language/meta-arguments/depends_on
- Terraform 文档 · for_each:https://developer.hashicorp.com/terraform/language/meta-arguments/for_each
- Terraform 文档 · 敏感数据:https://developer.hashicorp.com/terraform/language/manage-sensitive-data