Terraform 模块组合:VPC、安全组、EC2 的输出传递与依赖编排

上周把环境拆成三个模块之后,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. 参考

相关推荐
孔令飞1 小时前
【OneX】OneX AI 工具安装、配置及功能介绍
云原生·go·ai编程
时间售卖铺2 小时前
MySQL 安装部署
运维·数据库·mysql·云原生
FY@-@3 小时前
云计算基础(下):虚拟化原理与 DAS/NAS/SAN 存储
运维·服务器·云计算
做运维的阿瑞3 小时前
Terraform 敏感信息管理:sensitive 标记、外部密钥源与 state 加密
云原生·云计算·terraform
Z21-X4 小时前
云原生运维学习笔记:揭开虚拟化的硬核面纱,从底层架构到KVM实战
运维·笔记·学习·云原生
mengge.cloud4 小时前
1022小白入门教程(番外筑基)— nerdctl 容器基本功 + K8s 资源编写
linux·网络·云原生·容器·kubernetes·云计算·k8s
FY@-@4 小时前
云计算基础(上):从传统 IT 到 IaaS/PaaS/SaaS
云原生·云计算·paas
AKAMAI4 小时前
PHP 爆出两大高风险漏洞!Akamai 即时部署防护机制
人工智能·云计算
March.s4 小时前
Kubernetes 控制器深度指南:从 ReplicaSet 到 DaemonSet
云原生·容器·kubernetes