Terraform 模块化实战:把一个 800 行的 main.tf 拆清楚
上周接手了一个项目。打开 infrastructure/ 目录,只有一个 main.tf,832 行。VPC、子网、NAT 网关、EKS 集群、RDS 实例、安全组、IAM 角色,全塞在一个文件里。改一个变量的默认值,往下翻八百行确认有没有别的地方被覆盖。每个环境(dev / staging / prod)各有一份这样的文件,三份之间靠复制粘贴同步,diff 出来几千行。
这篇文章用一个具体例子,从头走一遍拆解过程。
你要拆的是什么
假设你有一个三层的 AWS 应用:
- 网络层:VPC、公有/私有子网、NAT 网关、Internet Gateway
- 计算层:EKS 集群,跑在私有子网里
- 存储层:RDS PostgreSQL 实例
目前三个环境各有一个 main.tf,内容高度重复,只是 CIDR、实例规格、副本数不同。
目标:抽出一个 modules/ 目录,每个环境只保留几十行调用代码,通过 terraform.tfvars 区分环境。
第一步:拆出 VPC 模块
从最大的开始。VPC 相关的资源通常占整个文件的三分之一。先梳理清楚这个模块需要什么输入、输出什么。
输入(variables.tf):环境的网络规划不同——dev 可能只需要两个 AZ、不需要 NAT 网关(省钱),prod 需要三个 AZ、完整的 NAT 网关。
# modules/vpc/variables.tf
variable "vpc_cidr" {
description = "VPC 的 CIDR 块"
type = string
}
variable "azs" {
description = "可用区列表"
type = list(string)
}
variable "private_subnets" {
description = "私有子网 CIDR 列表,每个 AZ 一个"
type = list(string)
}
variable "public_subnets" {
description = "公有子网 CIDR 列表,每个 AZ 一个"
type = list(string)
}
variable "enable_nat_gateway" {
description = "是否创建 NAT 网关。dev 环境可设为 false 以节省成本"
type = bool
default = true
}
注意 enable_nat_gateway 的默认值设为 true。这是最小惊讶原则:不做特殊配置时走安全的路(有 NAT),明确知道自己在省钱的 dev 环境再主动关掉。
输出(outputs.tf):只暴露其他模块真正需要的东西。
# modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.this.id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
一个常见错误是输出太多东西——路由表 ID、NAT 网关的弹性 IP、Internet Gateway ID 全暴露出去。问自己:有哪个模块会在意 VPC 的 NAT 网关 EIP 是什么?如果没有,就不输出。输出越少,模块的调用方越清晰,内部修改的影响范围也越小。
VPC 模块的 main.tf 这里不贴完整版,核心逻辑和原来单文件时一样,只是把硬编码的 CIDR、AZ 列表换成 var.* 引用。关键变化是:原来 cidr_block = "10.0.0.0/16" 变成 cidr_block = var.vpc_cidr。
第二步:拆出 RDS 模块
数据库模块的输入比 VPC 更"业务化"——它不关心网络拓扑,只关心数据库本身的配置。
# modules/rds/variables.tf
variable "db_name" {
description = "数据库名称"
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]*$", var.db_name))
error_message = "数据库名称必须以小写字母开头,仅含小写字母、数字和连字符。"
}
}
variable "engine_version" {
description = "PostgreSQL 引擎版本"
type = string
default = "15.4"
}
variable "allocated_storage" {
description = "分配的存储容量(GB)"
type = number
default = 100
}
variable "instance_class" {
description = "实例规格"
type = string
default = "db.t4g.medium"
}
variable "vpc_id" {
description = "RDS 实例所在的 VPC ID"
type = string
}
variable "subnet_ids" {
description = "RDS 实例所在的子网 ID 列表(至少两个 AZ)"
type = list(string)
}
这里有两点值得展开。
第一,validation 块值得写。 没有它,有人把 db_name 写成 "My-DB",Terraform 会在 apply 时被 AWS API 拒绝,报错信息是 AWS 原生的,排查起来需要从 Terraform 日志翻到 AWS 错误码。花 30 秒写个 validation,出问题时 Terraform 在 plan 阶段就直接告诉你"数据库名称必须以小写字母开头",省掉的是团队每个人未来各自踩坑的时间。
第二,vpc_id 和 subnet_ids 是从外部注入的。 RDS 模块不应该自己去 data.aws_vpc 查 VPC——这会让模块和环境的耦合变深,换个 AWS 账号或 region 就坏了。让调用方传进来,模块只关心数据库本身。
第三步:在环境目录里把它们串起来
模块写好后,dev 环境的调用长这样:
# environments/dev/main.tf
module "vpc" {
source = "../../modules/vpc"
vpc_cidr = "10.0.0.0/16"
azs = ["ap-southeast-1a", "ap-southeast-1b"]
private_subnets = ["10.0.10.0/24", "10.0.20.0/24"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
enable_nat_gateway = false
}
module "rds" {
source = "../../modules/rds"
db_name = "app-dev"
engine_version = "15.4"
allocated_storage = 20
instance_class = "db.t4g.small"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
}
dev 的配置明显比 prod 低:存储 20G 而不是 100G,db.t4g.small 而不是 db.t4g.medium,NAT 网关关掉。但这些差异完全体现在模块调用参数里,不需要修改模块内部逻辑。
环境变量放到 terraform.tfvars:
# environments/dev/terraform.tfvars
environment = "dev"
region = "ap-southeast-1"
prod 的 terraform.tfvars 只是同样的变量赋不同的值。从 dev 切到 prod,改一行 tfvars 就够了,不再是"复制整个目录然后全局查找替换"。
第四步:状态隔离
多环境的 Terraform state 必须物理隔离——dev 的 apply 永远不能碰到 prod 的状态文件。
# environments/dev/terraform.tf
terraform {
backend "s3" {
bucket = "company-terraform-state"
key = "environments/dev/terraform.tfstate"
region = "ap-southeast-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
prod 的配置只改 key 里的 dev 为 prod。同一个 S3 bucket,不同路径,物理隔离。加上 DynamoDB 做状态锁——多人协作时,一个人正在 apply,另一个人的 apply 会直接报错而不是同时写状态导致损坏。
第五步:模块版本化(什么时候需要)
如果模块只在当前项目里用——这是大多数情况——上面的 source = "../../modules/vpc"(本地相对路径)就够了。简单、直接、改了立刻生效。
如果你有多个项目共享同一套基础设施模块(比如公司级的基础设施团队维护一套标准模块,多个业务团队引用),才需要版本化:
module "vpc" {
source = "git::https://github.com/company/tf-modules.git//vpc?ref=v1.2.0"
# ...
}
不要过早版本化。本地路径引用在 80% 的场景下是最好的选择——零运维开销,CI 里也不会有"忘记打 tag 导致引用不到新版本"的问题。等到真正有第二个项目要复用同一个模块时,再提取到独立仓库加 tag。
收尾:改之前 vs 改之后
| 维度 | 改之前 | 改之后 |
|---|---|---|
| 单文件行数 | 832 行 main.tf | 环境目录每个约 40 行 |
| 新增环境 | 复制整个目录,全局查找替换 | 新建目录,写一份 tfvars |
| 改一个模块 | 三个环境各改一遍 | 改 modules/ 下一次,三个环境继承 |
| 新人上手 | 从 832 行里找入口 | 先看 environments/dev/main.tf,40 行读完知道整体结构 |
拆模块不是炫技。目的是让"改一个东西"的成本局限在一个文件里,让"加一个环境"不需要复制粘贴,让接手的人能从 40 行开始而不是从 800 行开始。
本文关键词:Terraform、模块化、基础设施即代码、IaC、AWS