1. 为什么需要 Terragrunt:Terraform 的 DRY 缺口
一句话总结: Terraform 只解决了「资源描述」,没解决「配置复用」与「跨栈编排」,Terragrunt 正是为补这两个洞而生。
当基础设施从「一个 VPC + 几台机器」长到「几十个模块、三套环境、多个云账号」时,纯 Terraform 的三个缺口会同时暴露:每个模块目录都要重复写一遍 backend 与 provider;环境之间的差异靠复制粘贴目录来体现;跨模块的先后顺序只能靠人工记忆和 shell 脚本串联。
# 每个目录都要重复一遍,只有 key 不同
terraform {
backend "s3" {
bucket = "acme-tfstate"
key = "prod/networking/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "acme-tf-lock"
encrypt = true
}
}
# 一旦有人改了 bucket 名字却漏改某个目录,就会静默写进旧桶
# 手工编排:顺序写死、失败无回滚、并发全无
cd live/prod/networking && terraform apply -auto-approve
cd ../compute && terraform apply -auto-approve
cd ../app && terraform apply -auto-approve
1.1 三类重复与三类代价
一句话总结: backend/provider 重复带来状态错写风险,环境复制粘贴带来漂移,无编排带来顺序事故,三者叠加就是规模化后的维护成本。
最朴素的「多环境」做法是 cp -r prod staging 再手工改几十处变量,半年后两套代码已无法对比;顺序写死的 shell 脚本则没有失败回滚,也没法并发。
| 缺口 | 表现 | 代价 |
|---|---|---|
| 配置重复 | backend/provider 逐目录抄写 | 状态错写、静默覆盖 |
| 环境复制 | 目录拷贝后手工改值 | 环境漂移、无法对比 |
| 无编排 | 脚本 cd 顺序执行 | 顺序事故、无法并发 |
1.2 Terragrunt 的定位
一句话总结: Terragrunt 不替换 Terraform,而是在
terraform命令外再包一层,负责生成配置、解析依赖、按拓扑序调度。
它的设计目标恰好对应上面三个缺口:配置继承、目录约定、依赖图驱动的批量执行。理解这一点,后面所有机制都能归位——include 治重复,dependency 治顺序,run-all 治批量。
2. terragrunt.hcl 基础与 include 继承
一句话总结: Terragrunt 的核心机制是「根配置 + include 继承 + generate 生成」,把重复的 backend/provider 收敛到一份 root.hcl。
2.1 root.hcl 模式
一句话总结: 仓库顶层放一份 root.hcl,用 path_relative_to_include 推导状态 key,让每个模块的状态路径由目录结构自动决定。
# root.hcl —— 放在仓库根,所有子模块继承它
locals {
# path_relative_to_include() 返回当前模块相对根配置的路径
rel_path = path_relative_to_include()
account_id = read_terragrunt_config(find_in_parent_folders("account.hcl")).locals.account_id
region = read_terragrunt_config(find_in_parent_folders("region.hcl")).locals.region
}
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "acme-tfstate-${local.account_id}"
key = "${local.rel_path}/terraform.tfstate"
region = local.region
dynamodb_table = "acme-tf-lock"
encrypt = true
}
}
2.2 子模块的 include 与 generate
一句话总结: 子模块只声明「继承谁、传什么参数」,provider 与 backend 全部由根配置生成,
expose = true让父层 locals 可被引用。
# live/prod/us-east-1/networking/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
expose = true
}
terraform {
source = "git::ssh://git@github.com/acme/tf-modules.git//networking?ref=v2.4.0"
}
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "${include.root.locals.region}"
default_tags {
tags = { ManagedBy = "terragrunt", Env = "prod" }
}
}
EOF
}
inputs = {
vpc_cidr = "10.20.0.0/16"
}
expose = true 让父配置的 locals 可以在子模块里通过 include.root.locals.xxx 引用,这是跨层传参最常用的方式。需要覆盖时在子模块里定义同名 locals 即可,子层优先级更高。
3. 依赖图与 dependency 块
一句话总结:
dependency声明「我要用谁的输出」,Terragrunt 据此构建有向无环图,并在执行前自动解析上游输出。
3.1 dependency 块的基本用法
一句话总结: dependency 比 terraform_remote_state 更安全,会校验状态与输出名,并能被 run-all 纳入拓扑排序。
# live/prod/us-east-1/compute/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
dependency "networking" {
config_path = "../networking"
# 上游尚未 apply 时用于 plan 的占位值,只在 plan 阶段生效
mock_outputs = {
vpc_id = "vpc-00000000000000000"
private_subnet_ids = ["subnet-00000000000000001", "subnet-00000000000000002"]
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
dependency "database" {
config_path = "../database"
}
inputs = {
vpc_id = dependency.networking.outputs.vpc_id
subnet_ids = dependency.networking.outputs.private_subnet_ids
db_endpoint = dependency.database.outputs.endpoint
}
3.2 dependencies 与 mock_outputs 的边界
一句话总结: 只要顺序不要数据时用 dependencies,取数据时用 dependency,而 mock_outputs 的字段名必须与上游 output 逐字一致。
# 只约束顺序:先跑 iam,再跑本模块,但不引用其输出
dependencies = ["../iam", "../kms"]
# 坑:mock_outputs 字段名与上游真实输出不一致时
# 现象是 plan 全绿、apply 才报 attribute not found
mock_outputs 的字段必须和上游模块 output 的真实名称逐字一致。建议在根配置里统一开启 mock_outputs_merge_strategy_with_state = "shallow",让部分输出可以从真实状态补齐,减少首次 apply 前的摩擦。
4. 多环境多账号目录布局
一句话总结: 用
live/<account>/<region>/<stack>的三段式目录,让账号与区域成为路径的一部分,配置差异通过层级*.hcl自动收敛。
4.1 目录骨架
一句话总结: 每一层放一个 account.hcl 或 region.hcl 提供该层公共变量,子模块用 find_in_parent_folders 逐层向上取值。
live/
├── root.hcl # 全局:backend、provider、通用 tags
├── prod/
│ ├── account.hcl # locals { account_id = "111122223333" }
│ ├── us-east-1/
│ │ ├── region.hcl # locals { region = "us-east-1" }
│ │ ├── networking/terragrunt.hcl
│ │ ├── database/terragrunt.hcl
│ │ └── compute/terragrunt.hcl
│ └── eu-west-1/
│ ├── region.hcl
│ └── networking/terragrunt.hcl
└── staging/
├── account.hcl
└── us-east-1/
└── networking/terragrunt.hcl
# prod/account.hcl —— 账号层的公共变量
locals {
account_id = "111122223333"
account_name = "prod"
assume_role_arn = "arn:aws:iam::111122223333:role/TerraformDeploy"
}
4.2 账号维度与 IAM 角色假设
一句话总结: 在根配置声明 iam_role,run-all 遍历到哪个账号目录就自动 AssumeRole 到对应角色,避免用错凭据改错环境。
# root.hcl 片段:按目录自动切换部署角色
locals {
account_vars = read_terragrunt_config(find_in_parent_folders("account.hcl"))
}
iam_role = local.account_vars.locals.assume_role_arn
这样 run-all apply 遍历到哪个账号的目录,就会自动切到对应角色,避免了「用 prod 凭据误改 staging」这类事故。若团队使用 SSO,也可改为 iam_assume_role_duration 配合 AWS_PROFILE 环境变量。
5. run-all 与并发控制
一句话总结:
run-all读取所有terragrunt.hcl构建依赖图,按拓扑序并发执行,用--terragrunt-parallelism控制并发度。
5.1 基本用法与并发度
一句话总结: run-all 默认串行,生产环境通常把并发调到 4~8,并按 include-dir 与 exclude-dir 缩小目标范围。
# 预览整个环境所有模块的变更
terragrunt run-all plan
# 按依赖序执行,并发 8
terragrunt run-all apply --terragrunt-parallelism 8
# 只对某个模块及其下游生效
terragrunt run-all plan --terragrunt-include-dir "networking"
# 排除某个目录
terragrunt run-all apply --terragrunt-exclude-dir "sandbox"
5.2 失败中断与依赖顺序
一句话总结: 默认「一个模块失败即中止」,但已启动的并发任务不会立刻停,CI 里必须据此判断哪些模块已落地。
# 忽略依赖错误继续跑(谨慎使用,只适合清理类操作)
terragrunt run-all destroy --terragrunt-ignore-dependency-errors
# 只跑依赖链上的模块,跳过无关目录
terragrunt run-all plan --terragrunt-strict-include --terragrunt-include-dir "compute"
并发度不是越大越好:同一账号下的模块若都要创建 IAM 角色,过高的并发会撞上云厂商的 API 限流。经验值是每个账号 4~6 并发,跨账号可以整体放大到 12 以上。
6. 与 CI/CD 集成
一句话总结: CI 里的 Terragrunt 关键是「按变更目录算出目标集、plan 归档成产物、apply 严格复用 plan」,而不是无脑 run-all。
6.1 按变更目录选择目标
一句话总结: PR 阶段用 git diff 找出改动的 terragrunt.hcl 目录,只对受影响模块执行 plan,比全量 run-all 快且安静。
# 计算本次 PR 改动的 Terragrunt 模块目录
CHANGED=$(git diff --name-only origin/main...HEAD \
| grep 'terragrunt.hcl$' \
| xargs -n1 dirname \
| sort -u)
# 对每个变更目录生成 plan
for dir in $CHANGED; do
echo "==> planning $dir"
(cd "$dir" && terragrunt plan -out=tfplan -no-color \
| tee "plan-$(echo "$dir" | tr '/' '_').txt")
done
6.2 plan 归档与审批
一句话总结: apply 必须复用与 plan 完全相同的产物与代码版本,并用 CI 的 environment 机制挂人工审批,而不是在脚本里交互式确认。
# GitHub Actions 片段:plan 与 apply 分两个 job,产物跨 job 传递
name: terragrunt
on:
pull_request:
push:
branches: [main]
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: terragrunt run-all plan -out=tfplan --terragrunt-parallelism 6
- uses: actions/upload-artifact@v4
with:
name: tfplan
path: "**/tfplan"
apply:
needs: plan
if: github.ref == 'refs/heads/main'
environment: production
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: tfplan
- run: terragrunt run-all apply --terragrunt-parallelism 4
状态锁由 backend(如 DynamoDB)提供,Terragrunt 只是继承 Terraform 的加锁行为。另外记得在 CI 中固定 Terragrunt 与 Terraform 的版本(.terragrunt-version + tfenv),否则本地能跑、CI 报错这类问题极难定位。
7. 迁移到 Terragrunt 与常见坑
一句话总结: 迁移要「一次包一个模块、先 plan 后 apply」,坑集中在路径解析、变量优先级与循环依赖三处。
7.1 渐进包裹既有模块
一句话总结: 保留原模块目录,在外层加 terragrunt.hcl,source 先指本地路径,plan 为空即证明迁移无损,再切换远端源。
# 迁移第一步:source 指向本地已有模块,状态沿用原 key
terraform {
source = "../../../modules//networking"
}
# 确认 plan 为空(即状态与代码一致)后,再逐步把 source 换成远端 tag
# terragrunt plan 输出为空 = 迁移无损,这是最关键的验证信号
7.2 路径函数、循环依赖与变量优先级
一句话总结: 三个路径函数语义各不相同,循环依赖用 graph-dependencies 排查,变量覆盖顺序是「子层覆盖父层、inputs 覆盖 locals」。
# get_terragrunt_dir() -> 当前 terragrunt.hcl 所在目录
# get_parent_terragrunt_dir() -> include 的根配置所在目录
# path_relative_to_include() -> 当前目录相对根配置的路径(做 state key 最合适)
# 坑:include 未加 expose = true 就引用 include.root.locals 会直接报错
# 坑:用 path_relative_to_include("root") 传参后语义变为「相对名为 root 的 include」
# 打印依赖图(DOT 格式),可用 graphviz 渲染
terragrunt graph-dependencies | dot -Tsvg > deps.svg
# 报错信息形如:Cycle: live/prod/a -> live/prod/b -> live/prod/a
# 修复思路:把双向引用拆成单向,公共部分下沉到更底层的模块
# 或用 dependencies 只声明顺序、不取输出,断开数据回边
循环依赖是 run-all 最常见的失败原因。除此之外,变量优先级也是高频坑:inputs、locals、include 三者的覆盖顺序是「子层覆盖父层、inputs 覆盖 locals」,搞反了就会出现「明明改了值却没用上」的情况。
8. 总结
Terragrunt 把 Terraform 从「单目录工具」抬升为「多栈编排器」,核心是继承、依赖图与批量执行三件事:
| 环节 | 要点 |
|---|---|
| 动机 | 补 Terraform 的 DRY 与编排缺口 |
| 继承 | root.hcl + include + generate 收敛重复 |
| 依赖 | dependency 取输出,dependencies 只定顺序 |
| 布局 | live/账号/区域/模块 三段式目录 |
| 执行 | run-all 按拓扑序并发,parallelism 按账号调 |
| CI | 按变更目录定目标,plan 归档后 apply |
| 迁移 | 逐模块包裹,plan 为空即无损 |
| 坑 | 路径函数语义、mock 字段名、循环依赖 |
一句话收尾:Terragrunt 的价值不在于多写一层配置,而在于把「几十个状态、多个账号、先后依赖」这些隐性知识显式地编码进目录与依赖图,让大规模 IaC 从靠人记忆变成靠机器调度。下一篇「多云网络与互联」将把视角从编排拉回网络层,讲解跨云 VPC/VNet 打通的方案与统一网络模块设计。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。