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。

依赖

依赖

API 安全组
内联规则引用 Worker SG ID

Worker 安全组
内联规则引用 API SG ID

**重构后:先创建两个安全组,再创建引用它们的独立规则。**图中合并展示四条规则,实际每条规则都引用两个 SG。

引用 API SG ID

引用 Worker SG ID

使用 VPC ID

使用 VPC ID

绑定关系:四个独立规则
API 入站 / Worker 出站
Worker 入站 / API 出站

资源身份:API 安全组

资源身份:Worker 安全组

已有 VPC

“资源身份”是安全组本身及其 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):

# 在 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 如何迁移#

  1. 保存 state 和 AWS describe-security-group-rules 清单,记录每条 sgr-* ID、方向、端口、对端及原 Terraform 地址;冻结这批规则的其他写入者。
  2. 保留现有 SG 地址/name/description/VPC,不能把示例的 name_prefix 覆盖生产属性。移除其 inline ingress/egress 配置,声明独立规则并以原 sgr-* 导入。
  3. 不写 ingress = [] / egress = [],那是清空规则;不让 inline、旧 aws_security_group_rule 和新资源同时管理同一规则。旧独立资源需先做明确的所有权转移。
  4. 审查同一迁移计划:只允许导入/状态归属变化,无 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 不保证首次规划成功。

流水线验收#

  1. 从空环境跑 foundation,确认 API/节点、IAM/RBAC、私网连通;发布带环境/commit/契约版本的配置制品。
  2. platform 创建 controller/CRD 并验证 Established、Webhook Ready,随后 workloads 完成真实订单链路。
  3. 测试 controller/集群升级和反向下线:先应用/CR、再平台,确认 LB/PV/finalizer 已处理,最后才删除集群与网络。
  4. 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;按 官方状态拆分流程执行:

  1. 同时冻结源/目标流水线,确认无其他操作;独立保存两边 state 版本、配置 commit、资源 ID 和依赖映射,限制备份读取权限。
  2. 先写好目标 resource/import,核对与源相同的账户、region、名字和关键属性;准备恢复 PR,不要让两个 state 同时 apply 同一对象。
  3. 源配置移除原 resource,改为下方 removed;同步修复源中引用。计划必须只表示停止管理,不能销毁云资源,然后 apply。
  4. 目标立即导入原 ID;计划不得创建替代对象或修改未批准的属性,审阅后 apply。此期间资源仍运行,但处于临时未管理窗口。
  5. 查目标 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 资源变更;真实迁移和业务连续性需在相同配置的预生产环境验收。

搜索文档

输入关键词,搜索所有文档。

↑ ↓ 选择 · Enter 打开 · Esc 关闭