跳到主要内容

模块化与项目组织

配置越写越长之后,Terraform 项目会遇到和 Ansible 一模一样的老问题:复用靠复制粘贴,改 bug 要同步 N 份,dev 和 prod 的差异散落在同一个文件里。Terraform 的答案是 module——如果你有 Ansible 背景,它之于 Terraform 就像 Role 之于 Ansible(Ansible 的整体设计见 Ansible 核心概念与架构)。这篇讲怎么写 module、怎么调用、传参的坑,以及多环境下的推荐项目布局。

环境说明

文中示例基于 Terraform 1.9+,macOS 或 Linux。模块示例继续用 local provider,不需要云账号就能跟着练;变量与输出的语法细节见 HCL 语言基础。

为什么要模块化​

单个 main.tf 超过三五百行,问题会集中爆发:

  • 网络、计算、DNS、监控全堆在一个文件里,改一处翻半天
  • dev 和 prod 两份配置复制粘贴,改 bug 要同步改 N 份,迟早漏一份
  • 新项目想复用「一套标准网络」这段逻辑,只能连文件带变量拷过去再手工改

Module 的解法是把「一组资源 + 输入变量 + 输出值」打包成一个目录,调用方一个 module 块传参引用。其实你已经写过 module 了:每个跑 terraform apply 的目录本身就是一个 module(叫 root module),现在要学的是写「被调用的子模块」。

写一个模块​

子模块就是一个普通目录,约定俗成的四件套:

modules/appconfig/
├── main.tf # 资源定义
├── variables.tf # 输入变量声明
├── outputs.tf # 输出值声明
└── README.md # 用法说明,必写

来一个能跑的小例子:封装 local_file,把键值对渲染成固定结构(带头部注释的 INI 风格)的配置文件。

# modules/appconfig/variables.tf
variable "name" {
description = "配置文件名(不含 .conf 后缀)"
type = string
}

variable "output_dir" {
description = "配置文件输出目录"
type = string
default = "./generated"
}

variable "settings" {
description = "要写入配置的键值对"
type = map(string)
default = {}
}
# modules/appconfig/main.tf
resource "local_file" "config" {
filename = "${var.output_dir}/${var.name}.conf"

content = join("\n", concat(
["# generated by terraform, do not edit"],
[for k, v in var.settings : "${k} = ${v}"]
))

file_permission = "0644"
}
# modules/appconfig/outputs.tf
output "file_path" {
description = "生成的配置文件路径"
value = local_file.config.filename
}

output "content_sha256" {
description = "配置内容的校验和,可用于触发其他资源重建"
value = local_file.config.content_sha256
}

设计上注意三点:

  • 输入靠 variable:description 必写,type 写明确,default 给合理值——调用方什么都不传也应该能跑起来。
  • 输出靠 output:把调用方可能需要的东西暴露出去(路径、校验和、ID)。模块内部的资源从外面是摸不到的,不 output 就等于不存在。
  • README 写用法:一段能复制就跑的 module 块示例,加一张变量说明表。写给三个月后的自己看。

调用模块​

root module(也就是你的环境目录)里用 module 块调用,source 指定来源,其余参数一一对应模块里的 variable:

module "app_conf" {
source = "./modules/appconfig"

name = "app"
output_dir = "./generated"
settings = {
listen = "8080"
workers = "4"
log = "info"
}
}

新增模块或改了 source 之后要重新 terraform init,Terraform 才会把模块装载、下载到 .terraform/modules/ 目录。引用模块的输出用 module.名字.输出名:

output "app_conf_path" {
value = module.app_conf.file_path
}

source 支持三种来源,按常用程度排:

# 1. 本地相对路径 —— 项目内的自研模块,最常用
module "app_conf" {
source = "./modules/appconfig"
}

# 2. git 仓库 —— 团队共享的模块仓库
# 双斜杠后是仓库内的子目录,?ref= 锁版本(tag / 分支 / commit)
module "common" {
source = "git::ssh://git@github.com/example/tf-modules.git//common?ref=v1.2.0"
}

# 3. Terraform Registry —— 社区公开模块
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.13.0"

name = "prod-vpc"
cidr = "10.0.0.0/16"
}
生产环境必须 pin 版本

registry 模块不写 version、git 模块不写 ?ref=,等于永远拉最新版——上游发一个不兼容更新,你的下次 apply 就无声无息地变了。生产把版本钉死,升级时主动改版本号并先在 dev 环境过一遍。第三方模块用之前也花十分钟读一遍源码,确认它到底会建什么。

模块传参的坑​

provider 怎么传给模块​

规则一句话:子模块默认继承 root module 的 provider 配置。单 region、单账号时什么都不用做。

多 region、多账号时就得上 providers 映射显式传了。root 里先给 provider 起别名:

provider "aws" {
alias = "tokyo"
region = "ap-northeast-1"
}

provider "aws" {
alias = "virginia"
region = "us-east-1"
}

module "edge" {
source = "./modules/edge"

providers = {
aws.main = aws.tokyo
aws.backup = aws.virginia
}
}

模块内部用 configuration_aliases 声明它期望收到哪些别名:

# modules/edge/versions.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
configuration_aliases = [aws.main, aws.backup]
}
}
}
不要在子模块里写 provider 块

子模块里自己写 provider "aws",会导致模块无法被干净地移除——把 module 块删掉之后,它的 provider 配置也跟着没了,destroy 直接失败。provider 一律在 root module 定义,通过 providers 映射传进去。

模块上的 count / for_each​

module 块和 resource 一样支持 count 和 for_each,一套配置批量生成多个实例:

module "app_conf" {
source = "./modules/appconfig"
for_each = var.apps

name = each.key
settings = each.value
}

引用输出时要带上 key:module.app_conf["web"].file_path。

事后补 for_each 会改地址

已经建好的模块实例,从「无 for_each」改成「有 for_each」之后,里面所有资源的 state 地址都会变——module.app_conf.xxx 变成 module.app_conf["web"].xxx,Terraform 会当成全删重建。提前用上一篇说的 moved 块把旧地址迁过去,再动手改。

多环境组织​

dev、staging、prod 三套环境怎么放,社区有两派做法,先对比再下结论:

维度workspaces目录隔离
配置代码一套,靠 terraform.workspace 区分环境每环境一份,共同引用共享 module
State 位置同一后端、不同 key每环境独立后端或 key,物理隔离
误操作风险忘切 workspace,plan 直接打到生产目录即边界,进错目录才可能打错
环境差异表达只能塞进变量和条件判断可以有完全不同的资源和模块组合
权限隔离同一个后端,粒度粗dev 与 prod 可拆不同账号、凭据、IAM
适合场景结构完全一致的临时副本、短期实验dev / staging / prod 长期环境

生产推荐目录隔离,理由都在表里:state 物理分开(捅破 dev 也碰不到 prod)、权限能拆、环境差异自由表达。代价是代码有重复——这部分重复用共享 module 消化,环境目录里只剩「组装 + 参数」。

推荐布局和 Ansible 按 inventories 分环境是一个思路:

terraform-project/
├── modules/ # 共享模块,所有环境复用
│ ├── appconfig/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── README.md
│ └── network/
│ └── ...
├── environments/
│ ├── dev/
│ │ ├── main.tf # 组装 module,dev 用小规格参数
│ │ ├── variables.tf # 本环境自己的变量声明
│ │ ├── outputs.tf # 本环境的输出
│ │ ├── backend.tf # dev 自己的 state key
│ │ ├── versions.tf # 锁定 terraform 和 provider 版本
│ │ └── terraform.tfvars # dev 的变量取值
│ ├── staging/
│ │ └── ... # 结构同 dev
│ └── prod/
│ ├── main.tf # 与 dev 相同的 module 组合,不同的参数
│ ├── backend.tf # prod 独立 key,IAM 收得更紧
│ └── ...
└── README.md # 项目说明:结构、怎么跑、注意事项

环境目录里的关键文件长这样:

# environments/dev/backend.tf
terraform {
backend "s3" {
bucket = "my-tfstate-bucket"
key = "dev/terraform.tfstate" # prod 目录里写 prod/terraform.tfstate
region = "ap-southeast-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}
# environments/dev/main.tf —— 注意相对路径要多退两级
module "app_conf" {
source = "../../modules/appconfig"

name = "app"
settings = var.app_settings
}

干活时进环境目录,init、plan、apply 全在里面跑:

cd environments/dev
terraform init
terraform plan
结构随需求生长

项目刚起步一个 main.tf 就够了,别第一天就搭出全套骨架。等第二套环境真的来了再拆目录——拆的过程正好逼你把公共部分提炼成 module。结构是长出来的,不是设计出来的。

模块设计原则​

几条实操中总结的原则:

1. 单一职责

一个 module 只干一件事:管网络的 module 不顺便建数据库。粒度细了才好复用、好组合,编排的事交给环境目录的 main.tf。

2. 默认值合理

variable 尽量带 default,目标是调用方零配置也能跑出合理结果。必须传的(比如名字)就别给默认值,让 Terraform 报错催调用方填。

3. 输出给够

把调用方可能用到的东西都 output:ID、ARN、路径、校验和。模块内部资源外面摸不到,少 output 一个,调用方就只能回头改你的模块。

4. README 写用法

一个能复制就跑的 module 块示例,加一张变量说明表。三个月后回来改的人(多半是你自己)会感谢这个决定。

5. 版本化

跨项目共享的模块放独立 git 仓库,发布打 tag(如 v1.2.0)。调用方用 ?ref= 锁版本,升级走「改版本号、dev 验证、上 prod」的流程。

小结​

  • module 就是「资源 + variable 输入 + output 输出」的打包目录,约定 main.tf、variables.tf、outputs.tf、README.md 四件套
  • source 三种来源:本地路径、git(?ref= 锁版本)、registry(version 锁版本);生产环境一律 pin 版本
  • provider 在 root module 定义,靠 providers 映射传进模块;模块上可以用 count 和 for_each
  • 多环境推荐目录隔离:environments 下 dev、staging、prod 各自独立 state,共享 modules/;workspaces 只留给结构完全一致的临时副本
  • 写模块的口诀:单一职责、默认值合理、输出给够、README 有示例、发布打 tag