概述
去年给一家做电商的客户搭 IaC 体系,接手时他们的 Terraform 代码库大概长这样:三个环境(dev/staging/prod)各自一套配置,加起来 3000 多行,90% 是复制粘贴。改一个 VPC CIDR 要同步改三个文件,漏改一个就是环境漂移。最离谱的是有个安全组规则在 prod 里少了一条,三个月没人发现。
这种痛每个用过 Terraform 的团队都经历过。模块化是公认的解法——但很多人做完模块化后发现:代码确实短了,可维护性反而更差了。一个模块塞了 40 个变量、3 层嵌套,改一个参数要翻三个文件才能理解影响面。这就是"把模块写成黑盒"的典型症状。
这篇文章拆解我在多个企业客户项目中验证过的一套 Terraform 模块分层架构,以及 6 个在生产环境中真实踩过的反模式。不是泛泛的"最佳实践"罗列,而是每个决策点都附上"为什么这么做"和"什么场景下不推荐这么做"。
为什么需要模块化
先说清楚模块化解决什么问题。不是"代码复用"这么笼统的话,而是三个具体痛点:
痛点一:环境漂移。 三个环境的配置本应一致,但人工复制必然出错。在某出行项目中,我们统计过:复制粘贴的配置平均每个环境有 3-5 处细微差异(CIDR 偏移、可用区数量不一致、安全组规则遗漏),这些差异在平时不爆发,一出故障就是定位地狱。
痛点二:变更同步成本。 改一个 AMI ID 要在 3 套环境里各改一次。如果团队有 5 个人同时改不同模块,合并冲突频繁到让人怀疑人生。
痛点三:知识传递断裂。 新人来了看一个 800 行的 main.tf,完全不知道哪些资源是有关联的、改哪个会影响哪个。没有模块边界,就没有认知锚点。
模块化的本质不是"把代码拆短",而是建立明确的抽象边界——让每个模块成为一个可以独立理解、独立测试、独立变更的单元。
模块分层架构设计
三层模型
我推荐的三层架构如下:
| 层级 | 职责 | 示例 | 变量数量 | 资源数量 |
|---|---|---|---|---|
| 基础模块 | 封装单一云资源 | modules/vpc、modules/rds | 5-15 | 3-8 |
| 组合模块 | 组合多个基础模块 | modules/eks-platform | 15-30 | 调用 3-5 个基础模块 |
| 环境层 | 定义环境差异化配置 | envs/prod/ | 不限 | 调用 2-4 个组合模块 |
这三层不是什么新概念,但很多人在实际落地时会把层级搞混——最常见的是基础模块里塞了组合逻辑,或者环境层直接调用基础模块跳过了组合层。下面逐层拆解。
基础模块:单一职责的积木
基础模块的核心原则是单一职责:一个模块只管一类资源。modules/vpc 只管 VPC 和子网,modules/rds 只管数据库实例。听起来简单,但我见过太多反例:
# 反面教材:一个模块管了 VPC + RDS + 安全组 + IAM
module "everything" {
source = "./modules/everything"
vpc_cidr = "10.0.0.0/16"
rds_engine = "postgres"
rds_instance_class = "db.r5.large"
# ... 还有 38 个变量
}
这种模块改一个 RDS 参数可能影响 VPC 的重建计划,因为 Terraform 的依赖图把不相关的资源耦合在了一起。
正确的基础模块长这样:
# modules/vpc/main.tf
# 只管 VPC、子网、路由表、NAT 网关,不碰其他资源
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(
local.common_tags,
{ Name = "${var.name_prefix}-vpc" }
)
}
resource "aws_subnet" "private" {
for_each = toset(var.private_subnet_cidrs)
vpc_id = aws_vpc.this.id
cidr_block = each.value
availability_zone = var.availability_zones[tonumber(regex("[0-9]+$", each.value)) % length(var.availability_zones)]
tags = merge(
local.common_tags,
{ Name = "${var.name_prefix}-private-${each.key}", Tier = "private" }
)
}
resource "aws_route_table" "private" {
for_each = aws_subnet.private
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.this[each.key].id
}
tags = merge(local.common_tags, { Name = "${var.name_prefix}-rt-private-${each.key}" })
}
基础模块的变量控制在 5-15 个。超过 15 个就要警惕——大概率是职责膨胀了。
我的判断标准:如果模块的变量描述里出现"当 X 为 true 时启用 Y 资源"超过 3 处,说明这个模块在做太多事,该拆了。
组合模块:业务逻辑的组装层
组合模块把多个基础模块按业务逻辑组装起来。比如 modules/eks-platform 把 VPC + EKS 集群 + IAM 角色 + 安全组组合成一个完整的 K8s 平台。
# modules/eks-platform/main.tf
# 组合 VPC + EKS + 节点组,但不重新定义这些资源
module "vpc" {
source = "../vpc"
cidr_block = var.vpc_cidr
name_prefix = var.name_prefix
availability_zones = var.availability_zones
private_subnet_cidrs = var.private_subnet_cidrs
public_subnet_cidrs = var.public_subnet_cidrs
}
module "eks" {
source = "../eks"
cluster_name = "${var.name_prefix}-cluster"
kubernetes_version = var.kubernetes_version
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
node_groups = var.node_groups
}
# 输出给环境层使用
output "cluster_endpoint" {
value = module.eks.cluster_endpoint
}
output "vpc_id" {
value = module.vpc.vpc_id
}
组合模块的关键设计决策:只组装,不定义资源。组合模块里不应该出现 resource 块——所有资源都在基础模块里定义,组合模块只负责 module 调用和参数传递。
这个原则在实际项目中救过我。有一次客户想给 EKS 集群加一个额外的安全组规则,直接在 eks-platform 组合模块里加了个 resource "aws_security_group_rule"。结果 EKS 模块升级时,这个安全组规则被孤立了——Terraform state 里有这个资源,但没有模块认领它,最后变成了 state 漂移。
环境层:差异化的入口
环境层是最顶层,每个环境一个目录,只定义该环境特有的配置:
infrastructure/
├── modules/ # 基础模块
│ ├── vpc/
│ ├── eks/
│ ├── rds/
│ └── alb/
├── compositions/ # 组合模块
│ ├── eks-platform/
│ └── web-stack/
├── envs/ # 环境层
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ ├── staging/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ └── prod/
│ ├── main.tf
│ ├── variables.tf
│ └── terraform.tfvars
└── README.md
环境层的 main.tf 极其简洁:
# envs/prod/main.tf
terraform {
required_version = ">= 1.5.0"
backend "s3" {
bucket = "mycompany-tfstate-prod"
key = "eks-platform/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "tf-locks-prod"
encrypt = true
}
}
module "platform" {
source = "../../compositions/eks-platform"
name_prefix = "prod"
vpc_cidr = "10.10.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
kubernetes_version = "1.30"
node_groups = {
general = {
instance_types = ["m5.large", "m5.xlarge"]
min_size = 3
max_size = 10
desired_size = 5
}
spot = {
instance_types = ["m5.large"]
min_size = 0
max_size = 20
desired_size = 5
capacity_type = "spot"
}
}
}
注意看:环境层没有 resource 块,只有 module 调用和参数赋值。所有逻辑都在模块里,环境层只管"这个环境用什么参数"。
关于 Terraform State 的后端配置,这里只展示了基本结构。生产环境的状态文件管理有一整套灾难恢复方案,详见 相关文章:状态文件丢了怎么办:Terraform State 灾难恢复与生产级后端架构。
变量与输出设计
变量设计的三条铁律
铁律一:每个变量必须有 type 和 description。
# 正确
variable "node_groups" {
type = map(object({
instance_types = list(string)
min_size = number
max_size = number
desired_size = number
capacity_type = optional(string, "ON_DEMAND")
}))
description = "Map of node group configurations. Key is the group name."
}
# 错误:无 type,无 description
variable "node_groups" {
default = {}
}
这不是风格问题,是工程问题。没有 type 约束的变量接受任何输入,一个不小心传错类型,terraform plan 不报错但 apply 时炸——凌晨两点你不想处理这种事。
铁律二:提供安全默认值。
variable "enable_deletion_protection" {
type = bool
default = true # 生产安全默认值
description = "Whether to enable deletion protection for resources that support it."
}
默认值的设计原则是:默认值必须是生产安全的。enable_deletion_protection 默认 true,dev 环境显式设为 false。这样即使用户忘了配置,生产环境也不会因为缺少保护而被误删。
铁律三:变量数量超过 15 个就考虑拆分。
我在实际项目中定了个硬指标:基础模块变量 ≤ 15,组合模块变量 ≤ 30。超过就拆。这不是拍脑袋的数字——是在多个项目中观察到的认知负荷临界点。一个模块有 20+ 变量时,新人理解它的成本是 15 变量模块的 3 倍以上。
输出值规范
输出值是模块的"接口契约"。设计原则是:输出使用者需要的东西,不是模块内部实现的东西。
# 正确:输出使用者需要的标识符
output "vpc_id" {
value = aws_vpc.this.id
description = "The ID of the VPC."
}
output "private_subnet_ids" {
value = [for s in aws_subnet.private : s.id]
description = "List of private subnet IDs."
}
# 错误:输出内部实现细节
output "route_table_private_ids" {
value = [for rt in aws_route_table.private : rt.id]
}
路由表 ID 是 VPC 模块的内部实现——除非有明确的调用者需要它,否则不要暴露。每多输出一个值,就多一个耦合点:未来重构模块时改了路由表的实现,所有依赖这个输出的地方都要跟着改。
敏感数据处理
variable "db_password" {
type = string
sensitive = true
description = "Master password for the RDS instance."
}
output "db_endpoint" {
value = aws_db_instance.this.endpoint
sensitive = true # 如果 endpoint 包含敏感信息也标记
}
sensitive = true 的变量在 terraform plan 和 terraform apply 输出中会被标记为 (sensitive value) 而不显示明文。但要注意:这只是在 CLI 输出层面遮蔽,state 文件里仍然是明文存储。真正的敏感数据保护要配合 Vault 或 KMS——在 相关文章:IaC 不测试就上生产?Terraform 四层验证体系让凌晨告警少 80% 中有详细的安全验证方案。
模块版本管理
本地模块 vs Registry 模块
这个选择直接影响团队的迭代速度。
| 方面 | 本地模块 | Registry 模块 |
|---|---|---|
| 修改速度 | 快,改完即用 | 慢,需发版+更新引用 |
| 版本控制 | 依赖 Git 仓库 | 独立版本号 |
| 复用范围 | 单仓库内 | 跨仓库、跨团队 |
| 适合场景 | 快速迭代期 | 稳定共享期 |
我的推荐:在项目初期(前 3 个月)全部用本地模块,快速迭代、快速试错。等模块稳定后,把高频复用的模块抽到独立 Git 仓库发版,用 Registry 方式引用。
这个策略来自实际教训。某客户一开始就用 Registry 模块,结果前两个月改了 40 次模块,每次都要:改模块仓库 → 打 tag → 改引用方的 source 版本号 → 重新 terraform init。迭代速度慢了 3 倍。
版本锁定策略
# 推荐:使用语义化版本约束
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0" # 允许 5.x 的补丁版本更新
}
# 也可以更保守
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.2" # 锁死版本,只手动升级
}
# 不推荐:不锁版本
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
# 没有 version 字段,拉取最新版
}
~> 5.0 表示允许 5.x 的更新但不跨大版本。这个策略平衡了安全性和便利性:补丁版本的 bugfix 自动跟进,大版本升级需要人工 review。
关于 Terraform 基础入门的更多内容,可以参考 相关文章:Terraform 基础设施即代码入门。
多环境复用实践
环境差异化的正确姿势
多环境复用是模块化最直接的价值。核心原则是:模块逻辑不变,只变参数。
# envs/dev/terraform.tfvars
name_prefix = "dev"
vpc_cidr = "10.20.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b"]
node_groups = {
general = {
instance_types = ["t3.medium"]
min_size = 1
max_size = 3
desired_size = 1
}
}
# envs/prod/terraform.tfvars
name_prefix = "prod"
vpc_cidr = "10.10.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
node_groups = {
general = {
instance_types = ["m5.large", "m5.xlarge"]
min_size = 3
max_size = 10
desired_size = 5
}
spot = {
instance_types = ["m5.large"]
min_size = 0
max_size = 20
desired_size = 5
capacity_type = "spot"
}
}
dev 用 2 个可用区、t3.medium、最小 1 节点;prod 用 3 个可用区、m5.large、最小 3 节点加 Spot 池。逻辑完全一样,只有参数不同。
Terragrunt vs 原生 Terraform
环境多了以后,原生 Terraform 的痛点暴露:每个环境目录都要写一遍 terraform block(backend 配置)、provider block,代码重复。Terragrunt 解决这个问题:
# envs/prod/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "../../compositions/eks-platform"
}
inputs = {
name_prefix = "prod"
vpc_cidr = "10.10.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
kubernetes_version = "1.30"
}
# envs/root.hcl - 公共配置
remote_state {
backend = "s3"
config = {
bucket = "mycompany-tfstate"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "tf-locks"
encrypt = true
}
}
Terragrunt 的 find_in_parent_folders 自动找公共配置,path_relative_to_include() 让 state key 自动按环境名区分。backend 配置只写一次。
我的选择:5 个环境以下用原生 Terraform 足够,多了就上 Terragrunt。Terragrunt 不是银弹——它引入了额外的工具链和学习成本。在某新能源物流平台的运维平台建设中,我们只有 3 套环境,用原生 Terraform + tfvars 文件管理得很舒服,没必要再加一层 Terragrunt。
性能对比数据
这套分层架构在某客户的实际落地中产出了以下对比数据:
| 指标 | 模块化前 | 模块化后 | 变化 |
|---|---|---|---|
| 代码总行数 | 3200 行 | 850 行 | ↓73% |
| 环境部署时间 | 25 分钟 | 8 分钟 | ↓68% |
| 变更同步成本 | 改 3 个文件 | 改 1 个 tfvars | ↓67% |
| 配置错误导致故障 | 月均 2-3 次 | 季度 1 次 | ↓80% |
| 新人上手时间 | 2 周 | 3 天 | ↓78% |
代码行数下降 73% 不是因为删了功能,而是消除了复制粘贴。部署时间下降 68% 部分归功于模块化后 terraform plan 的依赖图更清晰,Terraform 能更好地并行处理不相关的资源。
6 个生产级反模式与修复
这些反模式不是从文章里抄的,每一个都来自实际踩坑。
反模式一:过度抽象
症状:一个基础模块试图覆盖所有场景,变量里塞满了 enable_xxx 开关。
# 反面教材
module "vpc" {
source = "./modules/vpc"
enable_vpc = true
enable_nat_gateway = true
enable_flow_log = true
enable_vpn_gateway = false
enable_dns = true
enable_ipv6 = false
# ... 还有 20 个 enable_xxx
}
为什么有问题:模块的 terraform plan 输出变成了一堆 if-else 分支的组合爆炸。你想理解"这个模块到底创建了什么资源",要追踪十几个开关的状态。
修复:拆成独立模块。NAT 网关、Flow Log、VPN Gateway 各自独立,按需引用。
# 修复后
module "vpc" {
source = "./modules/vpc"
# 只管 VPC 和子网
cidr_block = "10.0.0.0/16"
}
module "nat" {
source = "./modules/nat-gateway"
subnet_ids = module.vpc.public_subnet_ids
# 需要时才引用,不需要就不写这个 block
}
反模式二:变量爆炸
症状:组合模块暴露了所有底层模块的变量,导致变量数量失控。
# 反面教材:组合模块直接透传所有变量
module "vpc" {
source = "../vpc"
cidr_block = var.vpc_cidr_block
enable_dns_support = var.vpc_enable_dns_support
enable_dns_hostnames = var.vpc_enable_dns_hostnames
instance_tenancy = var.vpc_instance_tenancy
# 把 VPC 模块的 15 个变量全部透传
}
为什么有问题:组合模块变成了一个"管道",使用者要理解所有底层模块的变量。变量数量从 15 + 20 + 10 = 45 个,全堆在组合模块的接口上。
修复:组合模块做参数收束,只暴露业务相关的参数。
# 修复后:组合模块只暴露业务参数,内部做合理默认
module "vpc" {
source = "../vpc"
cidr_block = var.vpc_cidr
# 其他参数用基础模块的默认值,不透传
}
判断标准:如果组合模块的变量数量超过 30 个,检查是否在透传底层变量。超过 50 个,立即重构。
反模式三:循环依赖
症状:模块 A 依赖模块 B 的输出,模块 B 又依赖模块 A 的输出。
这在 VPC 和安全组之间特别常见:VPC 模块需要安全组 ID 来给默认路由加规则,安全组模块需要 VPC ID 作为归属。
修复:把共享资源提取到上层。
# 修复:环境层先创建 VPC,再分别传给安全组和路由
module "vpc" {
source = "../../modules/vpc"
cidr_block = var.vpc_cidr
}
module "security_group" {
source = "../../modules/security-group"
vpc_id = module.vpc.vpc_id
rules = var.security_group_rules
}
# VPC 模块内部不做安全组关联
反模式四:忽略模块版本兼容性
症状:本地模块直接用 source = "../modules/vpc",改了模块所有人都受影响,没有版本隔离。
为什么有问题:你改了 VPC 模块加了 force_destroy 参数,dev 环境测试通过,但 prod 环境的配置不兼容这个参数。由于没有版本隔离,prod 环境下一次 terraform init 就拉到了不兼容的版本。
修复:用 Git tag 做版本隔离。
# 修复:用 Git ref 锁定版本
module "vpc" {
source = "git::https://git.mycompany.com/infra/terraform-modules.git//vpc?ref=v1.2.0"
cidr_block = var.vpc_cidr
}
dev 环境用 ref=v1.3.0-rc1,prod 环境锁死 ref=v1.2.0。升级时先在 dev 验证,通过后更新 prod 的版本号。
反模式五:State 文件耦合
症状:多个模块共用同一个 state 文件,一个模块的 terraform destroy 影响其他模块的资源。
为什么有问题:state 文件是 Terraform 的"账本"。所有模块写在同一个 state 里,意味着所有模块共享一个锁——一个模块在 apply,其他模块全等着。而且 terraform destroy 一不小心删多了。
修复:每个环境层用独立的 state 文件。
# envs/prod/main.tf - 独立的 state key
terraform {
backend "s3" {
bucket = "mycompany-tfstate-prod"
key = "eks-platform/terraform.tfstate" # 这个 key 是独立的
region = "us-east-1"
}
}
不同模块用不同的 state key,互不干扰。State 管理的完整方案在 相关文章:Terraform State 灾难恢复与生产级后端架构 中有详细展开。
反模式六:不测试模块就上生产
症状:模块写完直接在 prod 环境跑 terraform apply,出了问题手动修 state。
为什么有问题:Terraform 模块也是一种代码——不测试就上生产等于裸奔。一个变量类型错误、一个依赖遗漏,都可能导致 apply 中途失败,留下半成品的资源。
修复:用 terraform test(Terraform 1.6+)或 terratest 做模块测试。
# tests/vpc_test.tftest.hcl
run "vpc_creation" {
command = plan
variables {
cidr_block = "10.0.0.0/16"
name_prefix = "test"
availability_zones = ["us-east-1a", "us-east-1b"]
}
assert {
condition = output.vpc_cidr_block == "10.0.0.0/16"
error_message = "VPC CIDR block does not match input"
}
assert {
condition = length(output.private_subnet_ids) == 2
error_message = "Should create 2 private subnets"
}
}
在 CI 里跑 terraform test,模块变更不通过测试不让合入主分支。完整的 IaC 测试体系设计参考 相关文章:IaC 不测试就上生产?Terraform 四层验证体系让凌晨告警少 80%。
生产环境注意事项
容量评估:模块化不改变资源数量,只改变管理方式。在落地前统计清楚每个环境的资源数量——超过 500 个资源时,单个 state 文件的 terraform plan 耗时会超过 5 分钟,需要考虑拆分 state。
回滚方案:模块变更后如果 apply 失败,Terraform 不会自动回滚(这不是它的设计目标)。回滚策略是:用版本控制回退模块代码 → terraform apply 让实际状态回到旧版本配置。不是 terraform state rollback——Terraform 没有这个命令。
团队协作:模块化后,模块的维护者需要明确。推荐每个基础模块指定一个 owner,变更需要 owner review。没有 owner 的模块会逐渐变成"无主代码"——谁都不敢改,最后变成技术债。
工具链选择:以下是我在不同规模项目中的工具链推荐:
| 项目规模 | 推荐工具链 | 理由 |
|---|---|---|
| 3 环境以下 | 原生 Terraform + tfvars | 工具链简单,够用 |
| 5-10 环境 | Terragrunt + 本地模块 | 减少配置重复,保留本地模块迭代速度 |
| 10+ 环境 | Terragrunt + Registry 模块 + Atlantis/TFC | 版本隔离 + PR 驱动的自动化 plan |
| 跨团队共享 | Terraform Cloud/Enterprise | 统一的模块 registry + RBAC + 审计日志 |
总结
模块化 IaC 的核心不是"拆代码",而是"建立抽象边界"。三层架构(基础模块 → 组合模块 → 环境层)提供了一个清晰的边界划分:基础模块管单一资源,组合模块管业务组装,环境层管差异化参数。
6 个反模式的共同根源都是边界模糊:过度抽象是基础模块的边界膨胀,变量爆炸是组合模块的接口失控,循环依赖是模块间的边界交叉,忽略版本是模块演进缺少隔离,state 耦合是运行时边界缺失,不测试是质量边界缺失。
从实战数据看,这套架构把代码量减少 73%、部署时间缩短 68%、配置错误故障降低 80%。但数字只是结果,真正的价值在于:每个模块成为一个可以独立理解、独立测试、独立变更的单元——新人 3 天就能上手,改一个参数不用翻三个文件。
如果只记住一句话:模块是给人读的,不是给 Terraform 跑的。一个 Terraform 能跑但人类看不懂的模块,就是失败的设计。
参考资料与致谢
本文在撰写过程中参考了以下资料,感谢原作者的贡献:
- Terraform Best Practices — terraform-best-practices.com,提供了代码结构、命名规范和模块设计的社区实践参考
- Terraform v1.x Compatibility Promises — HashiCorp 官方文档,明确了 Terraform 1.x 系列的兼容性承诺,是模块版本管理策略的基础依据
- Architecture strategies for using infrastructure as code — Microsoft Azure Well-Architected Framework,提供了 IaC 成熟度模型和设计模式参考
- Terraform 模块化设计: 如何构建可复用、可组合的基础设施代码 — 简书技术博客,参考了模块化设计原则和代码复用率数据
- Terragrunt 常见问题解答 — CSDN 技术博客,参考了 Terragrunt 的 DRY 配置复用机制和多环境管理方案