模块化与项目组织
配置越写越长之后,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"
}
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 "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」之后,里面所有资源的 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