3. 模块化设计原则

在 Terraform 中,模块不是简单地把资源文件挪到一个目录里,而是对基础设施能力的一次边界划分。划分得好,模块像标准件一样可复用、可升级、可组合;划分得差,模块会变成“参数搬运工”,调用方需要了解内部实现,改一处牵动全局。本节围绕五条原则展开:高内聚低耦合、单一职责、接口稳定、版本化、可组合性,并说明它们如何落到 Terraform 的目录结构、变量、输出、Provider 与版本约束上。

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

高内聚指模块内部资源围绕同一目标协作,低耦合指模块之间尽量只通过输入变量和输出值交互。以“创建一套带日志与加密的 S3 存储桶”为例,桶、桶策略、生命周期规则、服务端加密、访问日志桶应放在同一模块内,因为它们随同一业务需求变化;而“谁可以访问这个桶”的 IAM 角色绑定,更适合由调用方或独立 IAM 模块处理,避免存储模块承担权限职责。

落地时可用一条判断标准:如果修改模块内部某个资源属性,调用方是否需要同步修改?若需要,说明耦合偏高。例如模块内部把桶名写死为 app-logs-prod,调用方就无法在测试环境复用;应改为变量 bucket_name,由调用方传入。低耦合还要求模块不直接读取调用方的本地文件、不隐式依赖调用方已创建的资源,除非通过变量显式传入。

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

单一职责不是“一个模块只能有一个资源”,而是“一个模块只对一类变化负责”。网络模块负责 VPC、子网、路由表、安全组;计算模块负责实例、启动模板、自动伸缩组;数据库模块负责实例、参数组、备份策略。若把 VPC 与 RDS 放进同一模块,网络策略变化和数据库版本升级会互相干扰,测试与复用都会变难。

实际划分时,可以按“生命周期”和“变更频率”辅助判断:VPC 很少重建,RDS 可能因版本升级而重建,二者生命周期不同,应拆开。另一个常见误区是把“环境差异”塞进同一模块的大量 if 分支,导致模块内部出现 count = var.env == "prod" ? 1 : 0 这类复杂条件。更好的做法是抽出公共模块,再用环境目录传入不同参数。

三、接口稳定:变量与输出是模块的合同

模块接口包括输入变量、输出值和必要的 Provider 要求。接口稳定意味着:新增功能尽量以可选变量加入,不随意重命名或删除已有变量;输出值一旦被下游引用,就不要轻易改变名称和类型。变量应设置清晰的 type、description,对关键参数设置 default 或 validation,让错误在 terraform plan 阶段暴露,而不是等到 apply 中途失败。

variable "bucket_name" {
  type        = string
  description = "S3 存储桶名称,需全局唯一"
  validation {
    condition     = can(regex("^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$", var.bucket_name))
    error_message = "桶名只能包含小写字母、数字和连字符,长度 3-63。"
  }
}

variable "enable_versioning" {
  type        = bool
  description = "是否开启对象版本控制"
  default     = true
}

output "bucket_arn" {
  value       = aws_s3_bucket.this.arn
  description = "存储桶 ARN,供 IAM 策略引用"
}

输出应只暴露调用方真正需要的值,例如 bucket_arn、bucket_id,而不是把内部所有资源属性都输出。输出过多会扩大接口面积,增加后续维护成本。

四、版本化:让升级可控、可回退

当模块被多个环境或多个团队引用时,必须版本化。Terraform 模块来源支持 Git 标签、Registry 版本和本地路径。生产环境应引用固定版本,例如:

module "s3_logs" {
  source  = "git::https://example.com/infra/modules/s3.git?ref=v1.4.0"
  bucket_name = "app-logs-prod"
}

版本号建议遵循语义化版本:修复 bug 升 patch,新增向后兼容的可选变量升 minor,删除变量、改变输出类型或默认行为升 major。升级时先在测试环境把 ref 改为新版本,执行 terraform plan,重点观察是否出现资源重建、输出变化或变量缺失;确认后再合并到生产。若模块内部资源地址发生变化,应使用 moved 块或 terraform state mv 避免误删重建。

五、可组合性:小模块拼出完整平台

可组合性要求模块之间通过输出与输入自然拼接,而不是互相硬编码。例如网络模块输出 vpc_id 和 private_subnet_ids,计算模块接收这些值创建实例,数据库模块再接收安全组 ID 和子网 ID。组合关系应像下面这样清晰:

模块组合:输出 → 输入 的显式连接 网络模块 network VPC / 子网 / 路由表 输出:vpc_id private_subnet_ids 计算模块 compute 实例 / 启动模板 / ASG 输入:subnet_ids 输出:instance_sg_id 数据库模块 database RDS / 参数组 / 备份 输入:subnet_ids security_group_id 根模块 root 组合调用 + 传参 版本锁定 ref=v1.4.0 实线:输出传递;虚线:根模块向各子模块传入变量与版本约束

可组合性还要求模块不要互相调用。若网络模块内部直接调用数据库模块,就会形成隐式依赖,调用方无法单独替换数据库实现。正确做法是根模块负责编排,子模块只暴露能力。对于必须共享的标签、命名前缀、Provider 配置,可通过变量传入,而不是让子模块自行读取全局变量。

六、把原则落到目录与评审清单

一个可落地的模块目录通常包含:main.tf 放资源,variables.tf 放输入,outputs.tf 放输出,versions.tf 锁定 Terraform 与 Provider 版本,README.md 说明用途与示例,examples/ 提供可运行调用样例。评审模块时,可以逐条检查:模块是否只负责一类变化;变量是否有类型和描述;输出是否最小必要;是否锁定版本;是否通过变量而非硬编码连接其他模块;是否提供示例调用。

判断模块边界的一个实用方法:假设明天要把数据库从 RDS 换成 Aurora,需要改几个模块?如果只改数据库模块和根模块的调用参数,说明边界合理;如果网络、计算、监控模块都要跟着改,说明耦合过高。

最后要强调,五条原则不是彼此独立的。高内聚低耦合决定边界,单一职责决定粒度,接口稳定决定协作成本,版本化决定升级安全,可组合性决定平台能否像积木一样扩展。把它们同时落到变量、输出、目录和版本约束上,Terraform 模块才真正从“代码复用”升级为“平台能力复用”。