Module 模块复用
这一课你将能做到
- 说清楚模块解决什么问题,识别根模块、子模块和注册表模块
- 自己写一个带变量和输出的本地模块,并在根模块中调用
- 正确使用官方 AWS VPC 模块,知道模块版本、源码来源和重构时的 state 迁移
为什么要学
当你的 .tf 文件超过几百行,VPC、EC2、RDS 的配置全挤在一起,改一处要翻半天。多个项目都要一套「标准网络」,复制粘贴很快失控——和代码里复制工具类一样。模块就是 Terraform 的「函数/类」:把一组资源打包,定义输入(variables)和输出(outputs),对外只暴露接口。团队会沉淀自己的模块库,AWS 官方和社区模块(如 terraform-aws-modules)是日常开发的重要基石,必须会用、会写、会选。
人话:模块像宜家的成品柜——你不用从木头开始打,告诉它「要几层、什么颜色」(输入变量),它给你柜子并告诉你抽屉尺寸(输出)。整个基础设施就是这些柜子组合起来的。
先建立图像
模块的三种来源和层级:
根模块(root module):你执行命令的目录
└─ 子模块:module 块调用
├─ 本地路径:./modules/network
├─ Git 地址:git::https://github.com/org/tf-modules//network
└─ 注册表: terraform-aws-modules/vpc/aws(含版本号)
调用形式:
module "network" {
source = "./modules/network" # 必填
# 其余都是传给子模块的变量
cidr = "10.0.0.0/16"
}
# 引用子模块输出:module.network.vpc_id
跟着做
步骤 1:写一个本地安全组模块
新建目录结构:
modules-demo/
├── main.tf
└── modules/
└── sg/
├── main.tf
├── variables.tf
└── outputs.tf
modules/sg/variables.tf:
variable "name" {
type = string
}
variable "vpc_id" {
type = string
}
variable "ingress_ports" {
type = list(number)
}
modules/sg/main.tf:
resource "aws_security_group" "app" {
name = var.name
description = "由模块创建:${var.name}"
vpc_id = var.vpc_id
dynamic "ingress" {
for_each = var.ingress_ports
content {
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
dynamic 块按端口列表动态生成多个 ingress 规则,这是模块里处理「数量不固定」的标准手法。
modules/sg/outputs.tf:
output "sg_id" {
value = aws_security_group.app.id
}
步骤 2:在根模块调用
main.tf:
terraform {
required_version = ">= 1.7.0"
}
provider "aws" {
region = "ap-northeast-1"
}
module "app_sg" {
source = "./modules/sg"
name = "springboot-sg"
vpc_id = "vpc-0123456789abcdef0"
ingress_ports = [22, 8080]
}
output "security_group_id" {
value = module.app_sg.sg_id
}
terraform init
terraform plan
init 会发现并注册本地模块;plan 中资源地址带模块前缀,形如 module.app_sg.aws_security_group.app。
步骤 3:使用官方 VPC 注册表模块
把 main.tf 里的手工网络换成官方模块(真实项目推荐做法):
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.8.1"
name = "springboot-vpc"
cidr = "10.0.0.0/16"
azs = ["ap-northeast-1a", "ap-northeast-1c"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets = ["10.0.11.0/24", "10.0.12.0/24"]
enable_nat_gateway = true
single_nat_gateway = true # 演示省钱:一个 NAT;生产建议每 AZ 一个
tags = {
Project = "springboot-demo"
Env = "dev"
}
}
module "app_sg" {
source = "./modules/sg"
name = "springboot-sg"
vpc_id = module.vpc.vpc_id
ingress_ports = [22, 8080]
}
output "vpc_id" {
value = module.vpc.vpc_id
}
注册表模块首次使用必须重新 terraform init 以下载。模块页面(registry.terraform.io)会列出全部输入变量和输出,通常几十个,只覆盖需要改的,其余走默认值。
步骤 4:模块也能传 provider
一个根模块里让子模块用别的 region/provider:
module "app_sg" {
source = "./modules/sg"
providers = {
aws = aws.singapore
}
# ...其余变量
}
模块内默认所有资源走传入的 provider。跨账号部署常用此模式。
步骤 5:把资源重构进模块时迁移 state
假设原来 SG 是根模块直接写的 aws_security_group.old,现在要搬进 module.app_sg。直接改代码 plan 会显示「删旧建新」。正确姿势:
terraform state mv aws_security_group.old module.app_sg.aws_security_group.app
先改好模块代码并 init,再 mv,最后 plan 确认无差异。模块重命名同理:terraform state mv module.old module.new。
步骤 6:模块的组合建议
infra/
├── main.tf # 只做「组装」,几乎不写资源
├── modules/
│ ├── network/ # VPC、子网、路由
│ ├── compute/ # EC2/ASG + SG
│ └── database/ # RDS + 参数组 + 子网组
└── envs/{dev,prod}.tfvars
根模块像 main 方法:声明 provider、调用三个模块、用模块输出把它们「接线」(如 compute 模块接收 network 模块的子网 ID)。
讲清楚要点
- source 是唯一必填参数:本地模块改完代码即时生效;注册表/Git 模块 init 后缓存到
.terraform/modules,升级版本要改引用并重新 init。
- 模块接口 = variables + outputs:模块内资源对外不可见,想被外部拿到必须 output;外部只能通过变量影响内部。
- 注册表模块必须钉版本:
version = "5.8.1" 或约束表达式,不写版本会报错;升级走版本号 + plan 验证。
- 模块不是越多越好:只复用一两次的配置不必抽模块,过度分层会让跳转和调试成本变高,三复用再抽象是常见经验线。
- 优先官方认证模块:terraform-aws-modules 维护活跃、边界考虑周全;小众第三方模块要看源码、issues 和更新频率。
你可能会懵的地方
-
模块和 .tf 拆文件有什么区别?
同一目录下拆成多个 .tf 文件只是物理分文件,资源地址没有前缀,互相直接可见;模块是独立命名空间,有明确的输入输出边界和独立 state 地址。
-
改了子模块代码要重新 init 吗?
本地路径模块不用,代码即时生效;注册表/Git 源模块改不了(是缓存副本),要换版本或源地址才需 init。
-
module has not been initialized 怎么处理?
新增了 module 块还没 init。执行 terraform init(或 terraform get)即可。
-
模块输出拿不到,报不存在?
检查子模块 outputs.tf 是否定义了该输出,以及引用拼写;output 的值若依赖有 count 条件为 0 的资源,会报错,需要在模块内处理可选输出。
-
能在模块之间直接互相引用吗?
只能通过根模块中转:A 模块 output,根模块把值传给 B 模块的变量。模块之间不允许直接引用,保持组装关系清晰。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 注册表模块不写 version | init 报错/不可重复 | 钉版本号 |
| 模块内部写死环境名、CIDR | 无法复用 | 全部通过 variable 暴露 |
| 重构进模块不做 state mv | 资源被销毁重建 | 改代码 + init + state mv |
| 在子模块里声明 provider 配置 | 与根配置耦合、难复用 | 模块只声明 required_providers,配置由根传入 |
| 直接修改 .terraform/modules 里的缓存代码 | 下次 init 被覆盖 | 维护源仓库或 fork 后改 source |
卡点急救
Unsupported argument(模块不认识某参数):子模块 variables.tf 没声明该变量,或版本不对。核对模块版本的输入变量列表。
- 模块下载失败(Git/registry):检查源地址写法(双斜杠
// 分隔目录)、凭证和网络;公司 Git 可用 .netrc 或 SSH key。
- plan 显示整组模块资源重建:多半改了模块 source、version 或 for_each 的 key,先
terraform state list 查地址,用 state mv 对齐。
面试常问
-
为什么要使用 Terraform 模块?
封装一组高内聚资源,提供统一接口和输出,实现复用、标准化命名与安全基线;降低根模块复杂度,支持团队沉淀模块资产和版本化演进。
-
调用模块时 source 有哪些形式?
本地路径(./modules/x);注册表地址(命名空间/模块/云 + version);Git URL(git::https/ssh,可带 ref 分支标签);还支持 GCS、S3 等归档源。
-
把已有资源重构进模块,怎么避免重建?
先完成模块代码和 init,用 terraform state mv 旧地址 module.名.内部地址 迁移映射,plan 确认无差异后提交。
自测三问
- 模块的输入和输出分别用什么块定义?外部为什么不能直接引用模块里的资源?
- 使用
terraform-aws-modules/vpc/aws 需要做对哪几件事?
- 模块内资源想使用根模块的非默认 provider,怎么接线?
小结一句话
模块是带明确输入输出边界的资源包:本地模块快速封装、注册表模块钉版本使用,重构移动资源记得 state mv 保命。
延伸