Terraform 循环依赖:企业排查与无重建重构
验证:未记录
- 适用版本
- 未记录
- 验证范围
- 尚未登记验证记录。
本文缩写(英文全称与中文含义)
| 缩写 | 英文全称/名称 | 中文含义 |
|---|---|---|
| ACM | AWS Certificate Manager | AWS 证书管理服务 |
| ALB | Application Load Balancer | 应用负载均衡器 |
| API | Application Programming Interface | 应用程序编程接口 |
| AWS | Amazon Web Services | 亚马逊云服务 |
| CA | Certificate Authority | 证书颁发机构 |
| CI | Continuous Integration | 持续集成 |
| CLI | Command-Line Interface | 命令行接口 |
| CR | Custom Resource | Kubernetes 自定义资源对象 |
| CRD | Custom Resource Definition | Kubernetes 自定义资源定义 |
| DNS | Domain Name System | 域名系统 |
| EKS | Elastic Kubernetes Service | AWS 托管 Kubernetes 服务 |
| ENI | Elastic Network Interface | AWS 弹性网络接口 |
| GitOps | GitOps | 以 Git 保存期望状态、由自动化持续协调实际状态的运维方式 |
| GKE | Google Kubernetes Engine | Google 托管 Kubernetes 服务 |
| HCP | HashiCorp Cloud Platform | HashiCorp 云平台 |
| IaC | Infrastructure as Code | 基础设施即代码 |
| IAM | Identity and Access Management | 身份与访问管理 |
| ID | Identifier | 标识符 |
| KMS | Key Management Service | 密钥管理服务 |
| LB | Load Balancer | 负载均衡器 |
| PR | Pull Request | 代码合并请求 |
| PV | Persistent Volume | 持久卷 |
| RBAC | Role-Based Access Control | 基于角色的访问控制 |
| SA | Service Account | 服务账号,供程序或工作负载使用的身份 |
| SG | Security Group | 安全组 |
| SSM | AWS Systems Manager | AWS 系统管理服务;SSM 是沿用的简称 |
| TCP | Transmission Control Protocol | 传输控制协议 |
| TLS | Transport Layer Security | 传输层安全协议 |
| VPC | Virtual Private Cloud | 虚拟私有云网络 |
适用:Error: Cycle、模块互相取输出、EKS/GKE 与 Kubernetes provider 互相依赖,以及拆分 state 后流水线互相等待。目标是消除依赖环并保留资源身份和业务可用性,不是让一次 apply 勉强通过。
1. 先分清故障#
| 表现 | 判断与处理 |
|---|---|
Error: Cycle |
图中存在 A 依赖 B、B 又依赖 A;拆开“资源身份”和“绑定关系” |
Invalid for_each argument |
key 在 plan 时未知;改用配置中已知的稳定 key,不一定有环 |
connection refused / Unauthorized |
Kubernetes API、网络或认证不可用;不等同于循环依赖 |
known after apply |
普通未知值并不异常;关注是否导致不必要替换/无法规划 |
| 两个 state 都等对方输出 | 跨部署环;Terraform 单个 plan 看不到,需要重设计契约 |
| 只在替换/销毁时报 Cycle | 检查 destroy 节点、旧实例、create_before_destroy 和 provider 生命周期 |
图解:拆开资源身份与绑定关系#
**箭头 A → B 表示 A 依赖 B,即 B 必须先准备好。**以下对应第 3 节的安全组案例。
重构前:规则内联在安全组中,两个安全组互相等待对方的 ID。
**重构后:先创建两个安全组,再创建引用它们的独立规则。**图中合并展示四条规则,实际每条规则都引用两个 SG。
“资源身份”是安全组本身及其 ID;“绑定关系”是允许哪个安全组访问另一个安全组的哪一个端口。安全组不再引用规则,图中没有返回路径;创建顺序为 已有 VPC → 两个 SG → 四条规则,与依赖箭头方向相反。
Terraform 按引用、provider 配置和显式依赖建图;文件名、资源书写先后不决定执行顺序。depends_on 增加边,不能删除已有环,模块级依赖还可能使全部 data source 延后读取。依赖图、depends_on
2. 企业排查步骤#
先暂停该环境的并发流水线,确认账户、region/project、backend key、workspace 和操作者。保存当前 commit、lockfile、模块版本及 state 版本;不要一边重构一边升级 provider。
terraform version
terraform workspace show
terraform providers
terraform validate
terraform plan -var-file=envs/staging.tfvars -out=staging.tfplan
# plan 失败时可能没有 tfplan;从配置绘图,不依赖失败的计划文件。
terraform graph -type=plan -draw-cycles > dependency.dot
# 本机有 Graphviz 才执行,不上传企业依赖图到公共网站。
dot -Tsvg dependency.dot -o dependency.svg部分图构建错误连 graph 都无法完成,此时用报错地址定位引用。A -> B 在此依赖图中表示 A 依赖 B,不能按业务请求方向读图。graph 参数
rg -n 'depends_on|provider|module\.|output|create_before_destroy|replace_triggered_by' . --glob '*.tf' --glob '!.terraform/**'把错误链压缩成最短环,逐边记下“哪个属性引用了谁、为什么必须依赖”。优先找:互相引用的内联配置、过大的 module output/depends_on、provider 从其管理对象获取配置、data source 回读本次创建的资源。仅在需要时启用 TF_LOG,并按敏感日志管理,避免凭据泄漏。
3. Hands-on A:双向服务安全组#
企业场景:orders API 调 worker 的内部 TLS 接口,worker 也回调 API。业务流量双向不代表资源创建必须双向依赖。不要为了造环给数据库额外开放无用回调端口。
可下载配置,分别放入两个独立空目录(不连接生产 backend):
- 错误版本 main.tf:两个 SG 的 inline ingress 互相引用,预期校验报 Cycle。
- 修复版本 main.tf:先创建 SG,再用四个独立 ingress/egress rule 建立 TCP/8443 双向通路。
- 无云资源 mock 测试:放入 fixed 目录,验证方向与端口约束。
# 在 broken 目录执行,validate 出现 Cycle 是本案例的预期失败。
terraform init -backend=false
terraform validate错误结构相当于 api_sg -> worker_sg -> api_sg;修复后 SG 只依赖 VPC,四个规则分别依赖两个 SG,没有返回边。核心结构如下,完整参数见下载文件:
resource "aws_vpc_security_group_ingress_rule" "api_from_worker" {
security_group_id = aws_security_group.api.id
referenced_security_group_id = aws_security_group.worker.id
ip_protocol = "tcp"
from_port = 8443
to_port = 8443
}# 在 fixed 目录执行。mock_provider 需要 Terraform >=1.7,不调用 AWS。
terraform init -backend=false
terraform fmt -check
terraform validate
terraform test隔离预生产实测时准备已有 VPC、两组真实服务实例及短期 AWS 身份;staging.tfvars 提供 region/account_id/vpc_id。先审完整 plan,再 apply 保存的计划。示例只建立两个服务的授权通路,数据库、DNS、镜像/云 API 出口按真实需要另设计;不授予默认全网出口。独立规则与冲突限制
验收双向真实 TLS 请求/回调成功,其他端口和无关 SG 被拒绝,VPC Flow Logs 与应用日志吻合;SG 是有状态的,响应流量本身不需要一条反向入站规则,本例反向规则用于另一次主动连接。mock 测试不证明云网络可用。
把输出的 SG ID 通过原工作负载 IaC 绑定到对应服务,先在预生产验证;检查实例/ENI 上其他 SG 的叠加允许规则,避免宽泛规则使负向测试失真。首次 init 后提交已审核的 .terraform.lock.hcl,CI 使用同一锁文件,不在修复环时顺便更新依赖。
已有生产 SG 如何迁移#
- 保存 state 和 AWS
describe-security-group-rules清单,记录每条sgr-*ID、方向、端口、对端及原 Terraform 地址;冻结这批规则的其他写入者。 - 保留现有 SG 地址/name/description/VPC,不能把示例的 name_prefix 覆盖生产属性。移除其 inline ingress/egress 配置,声明独立规则并以原
sgr-*导入。 - 不写
ingress = []/egress = [],那是清空规则;不让 inline、旧aws_security_group_rule和新资源同时管理同一规则。旧独立资源需先做明确的所有权转移。 - 审查同一迁移计划:只允许导入/状态归属变化,无 SG/规则删除重建、无权限扩大。不同 provider/历史 state 行为以实际计划为准,有 delete/replace 就停。
import {
to = aws_vpc_security_group_ingress_rule.api_from_worker
id = "sgr-REPLACE_EXISTING_RULE_ID"
}其余已存在规则逐条导入;没有的规则才按业务授权新增。apply 后检查 SG/规则 ID 不变、业务流量正常,再跑一次完整 plan,应无变更。SG 省略 inline 参数与显式空集合的区别见 AWS SG 文档。
4. Hands-on B:EKS/GKE 与集群内组件解耦#
典型环:cluster module 中还创建 Kubernetes 对象 → kubernetes provider 需要 cluster endpoint/CA → cluster module 又显式依赖 add-ons。即使暂时没有图环,首次 plan、token 过期、CRD discovery、销毁时集群先消失也可能失败。
拆为独立 root/state,由流水线按顺序执行,不是将同一个 root 的文件换目录名:
| 层 | 管理对象 | 对外契约 |
|---|---|---|
| foundation | VPC/网络、EKS/GKE、节点、访问身份 | 集群名称、位置、账户/项目、契约版本 |
| platform | CRD/controller、StorageClass、基础策略 | API/控制器已就绪的验收结果 |
| workloads | 应用 CR、路由、业务资源,或交给 GitOps | 业务版本与健康结果 |
foundation 不回读 platform 的对象。认证建立在云/IAM 身份上,不能依赖自己将要创建的 Kubernetes Secret/SA。private API 必须让 CI runner 可达;depends_on 无法建立网络或保证 API readiness。Kubernetes provider 分层建议
EKS platform root 的 provider 模板#
由 foundation 成功 apply 后发布已审核的 cluster_name/region/account_id 非敏感契约;platform 使用独立锁定 backend。以下保存 providers.tf,并将业务资源放在同一 platform root:
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 6.0" }
kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.0" }
}
}
variable "cluster_name" { type = string }
variable "region" { type = string }
variable "account_id" { type = string }
provider "aws" {
region = var.region
allowed_account_ids = [var.account_id]
}
data "aws_eks_cluster" "existing" {
name = var.cluster_name
}
provider "kubernetes" {
host = data.aws_eks_cluster.existing.endpoint
cluster_ca_certificate = base64decode(data.aws_eks_cluster.existing.certificate_authority[0].data)
exec {
api_version = "client.authentication.k8s.io/v1beta1"
command = "aws"
args = ["eks", "get-token", "--cluster-name", var.cluster_name, "--region", var.region]
}
}runner 须有 AWS CLI、短期 IAM 身份和 EKS access/RBAC;AWS provider assume_role 不会自动传给外部 aws eks get-token,两者必须指向同一被授权身份。计划审批后 apply 仍应能取得新 token,不把短期 token 固化在 tfvars/制品中。
GKE 用同样三层:foundation 发布 project/location/name,platform 用兼容的 GKE auth plugin/短期身份访问现有集群;认证与私网路由单独验收。CRD 和其 Custom Resource 可能需不同 plan/apply,因为 kubernetes_manifest 在 plan 时需要 API schema;仅加 depends_on 不保证首次规划成功。
流水线验收#
- 从空环境跑 foundation,确认 API/节点、IAM/RBAC、私网连通;发布带环境/commit/契约版本的配置制品。
- platform 创建 controller/CRD 并验证 Established、Webhook Ready,随后 workloads 完成真实订单链路。
- 测试 controller/集群升级和反向下线:先应用/CR、再平台,确认 LB/PV/finalizer 已处理,最后才删除集群与网络。
- runner 身份错误时必须拒绝;集群未就绪时流水线停止且不产生默认 localhost 请求。已存在环境拆分时按下一节迁移 state,不能直接重复 apply。
跨 state 读取只读契约,不互读整个业务状态。terraform_remote_state 消费者通常需要读取整个 state 快照,可能接触未作为 output 暴露的秘密;优先用权限独立的 SSM/配置制品/HCP outputs。状态锁只保护单个 state,不会自动协调多个层的并发发布。远程状态边界
5. 企业重构:同 state 与跨 state 分开处理#
同一个 state 内改模块/地址用 moved,保留迁移历史供尚未升级的环境使用:
moved {
from = aws_security_group.api
to = module.network.aws_security_group.api
}目标必须存在且类型/配置兼容;moved 不能消除资源图中的环,也不能阻止属性变化引起替换。
不同 backend/state 的所有权转移:普通 moved 不能跨 state。Terraform >=1.7 可使用源端 removed(不销毁)和目标端 import;按 官方状态拆分流程执行:
- 同时冻结源/目标流水线,确认无其他操作;独立保存两边 state 版本、配置 commit、资源 ID 和依赖映射,限制备份读取权限。
- 先写好目标 resource/import,核对与源相同的账户、region、名字和关键属性;准备恢复 PR,不要让两个 state 同时 apply 同一对象。
- 源配置移除原 resource,改为下方 removed;同步修复源中引用。计划必须只表示停止管理,不能销毁云资源,然后 apply。
- 目标立即导入原 ID;计划不得创建替代对象或修改未批准的属性,审阅后 apply。此期间资源仍运行,但处于临时未管理窗口。
- 查目标 state ID/业务状态,源和目标各跑一次完整 plan。目标导入失败时冻结双方,修复导入,或在确认目标未持有对象后恢复源配置并重新 import;不盲推旧 state 覆盖后续变更。
# 源 root:原 resource 已移除。旧 Terraform 不支持 removed 时先单独规划工具升级。
removed {
from = aws_security_group.api
lifecycle {
destroy = false
}
}# 目标 root:已定义完整 aws_security_group.api,关键属性保持原值。
import {
to = aws_security_group.api
id = "sg-REPLACE_EXISTING_ID"
}跨 state 迁移是一项协调变更,不是分布式事务。不能用 terraform init -migrate-state 只搬一个资源:它是 backend 迁移操作。state rm 也不是修复环的工具,遗留配置会再次计划创建资源。
6. 其他常见企业环#
| 场景 | 拆解方案 |
|---|---|
| IAM Role ↔ Policy/服务 | Role 的最小 trust、权限 policy、attachment 分开;将可后置的权限绑定移到下游,不用 Principal="*" 逃避依赖 |
| KMS Key ↔ 加密桶/Role | 保留管理员恢复路径;Key/身份先建立,使用 Key 的资源随后建立,精细授权再绑定;不能清空 key policy 或扩大解密范围 |
| ACM ↔ DNS ↔ ALB | Certificate request → DNS validation record → validation 完成 → TLS listener;证书模块不应依赖整个 ALB 模块输出 |
| 应用身份 ↔ 数据库 | 先建立 SA/Role 与数据库身份,再由下游绑定权限/发布连接信息;不要让“创建身份”依赖“使用身份的应用” |
| bootstrap/backend | 先通过独立 bootstrap 管理 state 桶/锁/IAM,再初始化业务 backend;业务 state 不负责销毁自身存放位置 |
| create_before_destroy + 固定名称 | 审核云平台唯一性与 destroy 依赖;采用允许并存的新名称/蓝绿迁移,不随意关闭生命周期保护 |
模块在输入输出层互相引用不一定形成真正资源环,必须分析具体资源图;优先把双向“绑定资源”移到第三个 integration module,再决定是否需要拆 state。模块 outputs 只暴露明确 ID/契约,不回传整个复杂 module 对象。
7. 禁止当成长期方案的绕路#
-target、反复 apply、count=0、try/coalesce、time_sleep、local-exec、-parallelism=1 都不能消除真实依赖环。target 仅用于有记录的特定恢复操作,结束后必须完整 plan;delay 只处理已无环时的外部就绪问题。ignore_changes 隐藏漂移,prevent_destroy 保护删除,二者都不改写依赖方向。
最终验收:从空环境可部署、存量升级无意外替换、完整第二次 plan 无变更;业务及负向权限测试通过;隔离环境下线顺序可行;state 唯一所有者、单向契约、CI 编排与恢复记录齐全。不要只以 terraform validate 成功作为企业交付完成。
核对日期:2026-09-23。本地以 Terraform 1.15.8 / AWS provider 6.54.0 复现错误版 Cycle,修复版通过 fmt、validate 和 1 项 mock 测试。未执行 AWS 资源变更;真实迁移和业务连续性需在相同配置的预生产环境验收。