概述

去年给一家做电商的客户搭 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/vpcmodules/rds5-153-8
组合模块组合多个基础模块modules/eks-platform15-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 planterraform 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 能跑但人类看不懂的模块,就是失败的设计。

参考资料与致谢

本文在撰写过程中参考了以下资料,感谢原作者的贡献:

  1. Terraform Best Practices — terraform-best-practices.com,提供了代码结构、命名规范和模块设计的社区实践参考
  2. Terraform v1.x Compatibility Promises — HashiCorp 官方文档,明确了 Terraform 1.x 系列的兼容性承诺,是模块版本管理策略的基础依据
  3. Architecture strategies for using infrastructure as code — Microsoft Azure Well-Architected Framework,提供了 IaC 成熟度模型和设计模式参考
  4. Terraform 模块化设计: 如何构建可复用、可组合的基础设施代码 — 简书技术博客,参考了模块化设计原则和代码复用率数据
  5. Terragrunt 常见问题解答 — CSDN 技术博客,参考了 Terragrunt 的 DRY 配置复用机制和多环境管理方案