2. Terraform模块化设计原则

模块化不是把代码拆成多个目录就算完成,而是要让每个模块在职责、接口和生命周期上都有清晰边界。Terraform 模块一旦被多个团队、多个环境复用,它的设计质量就会直接决定后续变更成本。本节围绕五条核心原则展开:高内聚低耦合、单一职责、可组合性、版本化、接口稳定性。它们不是彼此独立的教条,而是一组相互约束的设计取舍。

高内聚低耦合:把变化关在模块内部

高内聚指模块内部资源围绕同一个业务目标组织,例如“一个 RDS 实例及其参数组、安全组、备份策略”天然属于同一模块;低耦合指模块之间尽量通过明确的输入输出交互,而不是互相读取对方的内部资源。判断耦合是否过高,可以看一个信号:修改 A 模块时是否必须同时修改 B 模块的代码。如果必须,说明边界划错了。

在 Terraform 中,最容易造成耦合的做法是让模块直接引用外部资源地址,例如在子模块里写 aws_vpc.main.id 这种跨模块硬引用。正确做法是把 VPC ID 作为变量传入。下面是一个反例与正例的对比:

# 反例:子模块内部硬编码外部资源
resource "aws_subnet" "app" {
  vpc_id = aws_vpc.main.id   # 依赖调用方上下文,无法独立复用
}

# 正例:通过变量注入依赖
variable "vpc_id" {
  type        = string
  description = "所属 VPC 的 ID,由调用方传入"
}

resource "aws_subnet" "app" {
  vpc_id = var.vpc_id
}

正例中,子模块不再关心 VPC 从哪来,只关心“给我一个 VPC ID”。这样同一个子模块可以放进测试 VPC、生产 VPC,甚至另一个云账号的 VPC,耦合度显著降低。

单一职责:一个模块只回答一个问题

单一职责要求模块的变更原因只有一个。如果一个模块既负责创建网络、又负责创建数据库、还负责配置监控告警,那么网络策略调整、数据库版本升级、告警规则变更都会触发同一个模块的版本迭代,评审和回滚都会变得困难。实践中可以用一句话检验:能否用“这个模块负责……”一句话说清,且不需要用“以及”连接。

但单一职责不等于粒度越细越好。把每个资源都拆成独立模块,会导致调用方需要维护大量模块引用和变量传递,反而增加认知负担。合理的粒度通常对应一个可独立交付、可独立测试的能力单元,例如“标准应用子网”“标准 RDS 实例”“标准 S3 日志桶”。

可组合性:小模块拼出大平台

可组合性建立在低耦合和单一职责之上。平台团队提供基础能力模块,业务团队通过组合这些模块快速搭建环境。组合的关键是模块之间只通过变量和输出连接,不共享隐式状态。下图展示了一个典型的企业模块组合关系:网络模块输出子网 ID,计算模块和数据库模块分别消费该输出,监控模块再消费前两者的输出。

企业模块组合关系:输出驱动输入 网络模块 vpc / subnet / route 输出:subnet_ids 输出:vpc_id 计算模块 ec2 / asg / sg 输入:subnet_ids 输出:instance_ids 数据库模块 rds / parameter group 输入:subnet_ids 输出:db_endpoint 监控模块 alarm / dashboard 输入:instance_ids 输入:db_endpoint subnet_ids subnet_ids instance_ids db_endpoint 组合规则:模块之间只通过变量输入与输出连接,不直接引用对方内部资源地址 网络模块可独立替换为其他实现,只要保持 subnet_ids / vpc_id 输出契约不变

图中每个模块都只暴露必要的输出,调用方按需组合。如果某天网络模块从自建 VPC 换成共享 VPC,只要输出名称和类型不变,计算模块和数据库模块无需改动。这就是可组合性带来的替换能力。

版本化:让变更可追溯、可回滚

模块被复用后,任何修改都可能影响多个调用方。因此模块必须版本化,调用方通过版本约束锁定行为。Terraform Registry 和 Git 仓库都支持版本引用,推荐使用语义化版本:主版本号表示不兼容变更,次版本号表示向后兼容的功能新增,修订号表示向后兼容的缺陷修复。

module "app_subnet" {
  source  = "git::https://example.com/modules/network.git//subnet?ref=v2.3.1"
  vpc_id  = module.vpc.vpc_id
  cidr    = "10.0.1.0/24"
}

调用方应避免使用 ref=main 或 ref=latest,否则模块一改,所有环境同时漂移,故障难以定位。平台团队发布新版本后,业务团队按环境逐步升级,先测试、再预发、最后生产,形成可控的变更节奏。

接口稳定性:变量与输出是模块的契约

接口稳定性要求模块的输入变量和输出值在同一个主版本内保持兼容。具体做法包括:变量必须声明类型和描述;对可选变量提供合理默认值;不随意重命名或删除已有变量;新增变量时给出默认值,避免破坏现有调用。输出同理,删除输出或改变输出类型都属于破坏性变更,必须升主版本。

一个常见的稳定性问题是变量类型过于宽松。例如把 subnet_ids 声明为 list(any),调用方传入字符串列表或对象列表都能通过,但模块内部处理逻辑可能出错。更稳妥的做法是明确声明 list(string),并在变量校验中限制格式:

variable "subnet_ids" {
  type        = list(string)
  description = "子网 ID 列表,至少一个"

  validation {
    condition     = length(var.subnet_ids) > 0
    error_message = "subnet_ids 不能为空列表。"
  }
}

这样调用方在 terraform plan 阶段就能得到明确报错,而不是等到资源创建失败才发现问题。接口稳定性不是限制变更,而是让变更以可预期的方式发生。

五条原则的协同关系

这五条原则在实践中互相支撑:单一职责让模块边界清晰,高内聚低耦合让边界可维护,可组合性让模块能拼装成平台,版本化让拼装结果可追溯,接口稳定性让升级不破坏调用方。设计模块时,可以先问三个问题:这个模块的职责能否一句话说清?调用方是否只需要知道输入输出,而不需要了解内部实现?如果内部实现替换,调用方是否无需改动?三个问题都答“是”,模块设计基本合格。

平台团队可以建立模块评审清单:变量是否都有类型和描述、是否有默认值策略、输出是否最小化、是否声明了版本、是否有使用示例。评审通过后再发布到内部 Registry,供业务团队按版本引用。

需要强调的是,模块化设计没有唯一正确答案。同一个能力,在不同团队规模、不同合规要求下,边界可能不同。重要的是把上述原则作为判断依据,在具体场景中做出可解释的取舍,而不是机械套用。