Terragrunt 与大规模编排:DRY、依赖图与 run-all

用 Terragrunt 解决 Terraform 的 DRY 缺口:include 继承、dependency 依赖图、多环境多账号目录布局、run-all 并发控制与 CI 集成实践。

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 打通的方案与统一网络模块设计。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「terraform」更多文章

  1. Helm Provider 与应用发布:值注入与回滚
  2. 模块注册表与分发:版本、文档与测试
  3. DNS 与证书编排:托管区域与自动验证