从 CloudFormation 迁移到 Terraform

把 CloudFormation 栈迁移到 Terraform 的完整方法:资源类型映射与语义差异、import 声明式导入与 plan 状态对齐、Export 与 DependsOn 等栈依赖转换、分批灰度切换、回滚预案、残留标签清理与双跑期对账。

1. 迁移的动因与评估

一句话总结: 从 CloudFormation 迁到 Terraform 通常不是技术优劣之争,而是「多云、生态、可测试性」三类诉求驱动,动手前必须先做存量盘点。

1.1 常见动因

动因说明
多云统一已有多套 IaC 体系,想收敛到一套语言与流程
生态覆盖某些资源/第三方系统只有 Terraform Provider
可测试性想要 Terratest、policy-as-code、模块化复用
团队技能团队更熟悉 HCL,维护 CFN 模板成本高
状态可见性想要显式的 state 与 plan 评审

1.2 反向理由

也要诚实评估不该迁的情况:只跑 AWS 且模板稳定,CloudFormation 的原生集成(如 StackSets、Service Catalog、Change Sets、Drift Detection)开箱即用;重度的 CFN 宏/自定义资源(AWS::CloudFormation::CustomResource + Lambda 后端)迁移成本极高;StackSets 做多账号铺开的复杂度在 Terraform 里要用 Terragrunt 或工作区等价实现。

1.3 盘点清单

动手前对每个栈采集:

# 列出账号下所有栈
aws cloudformation list-stacks --stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE \
  --query 'StackSummaries[].{Name:StackName,Status:StackStatus}' --output table

# 导出某个栈的资源清单、模板、参数与输出
aws cloudformation describe-stack-resources --stack-name my-stack \
  --query 'StackResources[].{Logical:LogicalResourceId,Type:ResourceType,Physical:PhysicalResourceId}'
aws cloudformation get-template --stack-name my-stack --query 'TemplateBody' > my-stack.template.json
aws cloudformation describe-stacks --stack-name my-stack --query 'Stacks[0].{Params:Parameters,Outputs:Outputs}'

把这三样(资源清单、模板、参数输出)落到一个表格里,作为迁移的基准台账。没有台账就开始写 HCL,必然漏资源。

2. 资源映射与语义差异

一句话总结: CFN 的资源类型与 Terraform 资源并非一一对应,且有若干语义差异(删除策略、命名、属性嵌套),必须逐类核对。

2.1 类型映射

CloudFormationTerraform备注
AWS::EC2::VPCaws_vpc基本一一对应
AWS::EC2::Subnetaws_subnet注意 MapPublicIpOnLaunch
AWS::EC2::SecurityGroupaws_security_group + aws_vpc_security_group_ingress_ruleCFN 用内联规则,Terraform 推荐独立规则资源
AWS::S3::Bucketaws_s3_bucket + 若干子资源Terraform 把策略/加密/版本控制拆成独立资源
AWS::RDS::DBInstanceaws_db_instance注意 ManageMasterUserPassword 语义
AWS::IAM::Roleaws_iam_role + aws_iam_role_policy_attachment托管策略需拆成独立资源
AWS::Lambda::Functionaws_lambda_function注意 Code 打包方式差异
AWS::CloudFormation::Stack(嵌套栈)module嵌套栈直接映射为模块

最需要注意的是一对多的资源:Terraform 把很多 CFN 里的内联属性拆成独立资源(S3 的加密、版本控制、生命周期;IAM Role 的多个策略;安全组的规则)。这意味着导入时不能只导入一个资源,而要把拆出来的子资源逐个导入。

2.2 语义差异清单

  • 删除策略:CFN 的 DeletionPolicy: Retain 对应 Terraform 的 lifecycle { prevent_destroy = true },但语义不完全等价(后者是拦截 destroy,前者是允许删除但保留物理资源)。Terraform 侧还有资源级的 deletion_protection。
  • 命名:CFN 支持 AWS::StackName 参与命名,Terraform 里用 var 或 name_prefix 复刻。
  • 物理 ID vs 逻辑 ID:CFN 的 Ref 返回物理 ID,Terraform 的引用返回资源对象,取值方式不同。
  • 更新替换:CFN 的 UpdateReplacePolicy 与 Terraform 的 create_before_destroy 语义要对齐。
  • 参数 vs 变量:CFN 的 Parameters 有 AllowedValues/ConstraintDescription,Terraform 用 validation 块等价实现。
variable "env" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.env)
    error_message = "env 必须是 dev / staging / prod 之一"
  }
}

2.3 用工具做初稿转换

手工翻译模板容易出错,可以用转换工具生成初稿,再人工修正:

pip install former2                          # 通过 API 扫描生成 Terraform 代码
cf2tf my-stack.template.json -o generated/   # 直接把 CFN 模板转成 HCL(社区工具)

工具产物只能当草稿:命名不一致、拆分的子资源缺失、条件逻辑丢失都是常见问题。人工核对是必须的。

3. 导入与状态对齐

一句话总结: 迁移的核心动作是「把既有资源的 ID 写进 Terraform state」,用 import 块 + plan 反复对齐,直到 plan 干净。

3.1 为什么要导入而不是重建

云资源往往有数据(数据库、S3 桶、EIP),重建意味着停机与数据迁移。导入让 Terraform 接管既有资源,不触发重建。

3.2 import 块(推荐)

Terraform 1.5+ 支持声明式导入,可评审、可批量:

# imports.tf
import {
  to = aws_vpc.main
  id = "vpc-0a1b2c3d4e5f6g7h8"
}

import {
  to = aws_subnet.private[0]
  id = "subnet-0123456789abcdef0"
}

import {
  to = aws_s3_bucket.assets
  id = "acme-assets-prod"
}
terraform plan -generate-config-out=generated.tf   # 自动生成配置骨架
terraform plan                                     # 审查将要导入的资源
terraform apply                                    # 执行导入

-generate-config-out 会为导入目标生成 HCL 骨架,把已有属性填进去。生成的代码需要整理(重命名、抽模块、补变量),但它省掉了「逐个属性从控制台抄」的工作。

3.3 导入后的 plan 对齐循环

导入完成后 plan 通常不干净,因为生成的配置与真实资源有差异。对齐流程:

1. terraform plan
2. 看差异类型:
   ├─ 属性缺失/不同 → 修改 HCL 匹配现状(若现状是对的)
   └─ 现状确实要改   → 保留 HCL,让 plan 去改(谨慎)
3. 反复 1-2,直到 plan 只剩「期望的变更」

对「无法在 HCL 表达」的差异(如云厂商自动加的标签、默认值),用 lifecycle { ignore_changes } 收敛:

resource "aws_instance" "legacy" {
  # ...
  lifecycle {
    ignore_changes = [
      tags["aws:cloudformation:stack-name"],   # CFN 残留标签
      user_data,
    ]
  }
}

3.4 大量资源时的批量导入

几十上百个资源手写 import 块不现实,用脚本生成:

#!/bin/bash
# 从 CFN 资源清单生成 import 块(简化示意)
aws cloudformation describe-stack-resources --stack-name my-stack \
  --query 'StackResources[].[ResourceType,PhysicalResourceId,LogicalResourceId]' \
  --output text | while read -r type physical logical; do
    printf 'import {\n  to = %s.%s\n  id = "%s"\n}\n' \
      "$(map_type "$type")" "$(snake_case "$logical")" "$physical"
  done > imports_generated.tf

生成的块要人工核对映射关系,尤其是「一对多」的资源——CFN 里一个 AWS::S3::Bucket 可能要生成 3~4 条 import 块。

一句话: 导入不是一次性动作,而是「导入 → plan → 修配置 → 再 plan」的循环,直到 plan 只剩预期变更。

4. 栈依赖转换

一句话总结: CFN 的跨栈依赖靠 Export/ImportValue 与 DependsOn,Terraform 里对应「远程 state 数据源」与「资源引用」,转换时要显式化这些隐式关系。

4.1 Export/ImportValue → 远程 state 数据源

CFN 常见的跨栈引用:

# 网络栈导出
Outputs:
  VpcId: {Value: !Ref Vpc, Export: {Name: prod-VpcId}}
# 应用栈引用
Parameters:
  VpcId: {Type: String}
Resources:
  Instance:
    Type: AWS::EC2::Instance
    Properties: {SubnetId: !ImportValue prod-VpcId}

Terraform 的等价写法是远程 state 数据源:

# 网络栈的输出
output "vpc_id" { value = aws_vpc.main.id }

# 应用栈读取
data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "acme-tfstate"
    key    = "prod/network/terraform.tfstate"
    region = "ap-northeast-1"
  }
}

resource "aws_instance" "app" {
  subnet_id = data.terraform_remote_state.network.outputs.subnet_ids[0]
}

注意 terraform_remote_state 依赖对方 state 的 output 结构,是弱契约。更稳健的做法是把共享值写进 SSM Parameter Store,用 aws_ssm_parameter 数据源读取,把契约从「state 内部结构」变成「有名字的参数」。

4.2 DependsOn → 隐式引用

CFN 的 DependsOn 表达「顺序依赖但无数据传递」。Terraform 里若两个资源之间无引用,可以用 depends_on 显式声明:

resource "aws_iam_role_policy_attachment" "app" {
  # ...
  depends_on = [aws_iam_role.app]
}

但优先用引用而非 depends_on:引用能自动推导依赖,depends_on 是显式兜底,用多了会让依赖图难读。

4.3 嵌套栈 → 模块

# CFN 嵌套栈
Resources:
  NetworkStack:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: https://s3.../network.yaml
      Parameters:
        Env: prod
# Terraform 模块
module "network" {
  source = "../../modules/network"
  env    = "prod"
}

嵌套栈的 Parameters 直接映射为模块的 variables,Outputs 映射为模块的 outputs。这是迁移里最平滑的部分。

4.4 依赖图的整体校验

迁移完成后,用 terraform graph 检查依赖图是否合理:

terraform graph | dot -Tsvg > graph.svg

重点看:有没有意外的循环依赖;原本 CFN 里串行的栈,在 Terraform 里是否变成了一个大的扁平图(可能需要拆 state)。

5. 灰度切换与回滚

一句话总结: 迁移不能「一刀切」,要按栈的重要性分批切、每个栈保留回滚路径,且回滚必须在切换前演练过。

5.1 分批策略

第一批:无状态、可重建(S3 桶策略、IAM、安全组)
第二批:有状态但可导入(RDS、ElastiCache)
第三批:核心链路(VPC、LB、EKS)
第四批:嵌套栈与 StackSets 铺开的栈

每一批完成后观察一段时间再进下一批。

5.2 单栈切换流程

1. 冻结 CloudFormation 侧变更(禁用 Pipeline / 加保护)
2. 生成 HCL 初稿(转换工具 + 人工)
3. 导入全部资源,plan 对齐到「无差异」
4. 在只读模式下双跑验证(见第 6 节)
5. 切换所有权:Terraform 接手,CFN 栈保留但不再 apply
6. 观察期结束后,决定「保留 CFN 栈作为快照」还是「删除栈定义」

第 5 步是关键:不要立刻删除 CFN 栈。直接 delete-stack 会把资源一起删掉(除非全部 DeletionPolicy: Retain)。正确做法是保留栈,仅停止对它做变更;确认稳定后再单独处理栈元数据。

5.3 回滚预案

回滚的本质是「把所有权还给 CloudFormation」。这要求:

  • 切换后不要用 Terraform 改资源的关键属性(否则 CFN 栈会漂移)。
  • 保留切换时的 CFN 模板版本与参数。
  • 演练回滚:停用 Terraform、用 update-stack 重新让 CFN 接管。如果此时资源已被 Terraform 改动,CFN 会尝试「纠正」它们,可能触发替换。
# 回滚:用原模板与参数重新让 CFN 接管
aws cloudformation update-stack --stack-name my-stack \
  --template-body file://my-stack.template.json \
  --parameters file://params.json

一句话: 回滚能否成功,取决于切换后 Terraform 有没有改过资源;因此切换后的第一段时间应「只读」,把变更留到观察期之后再放开。

5.4 处理 CFN 残留标签

CFN 会在资源上打 aws:cloudformation:stack-name 等标签,Terraform 导入后会认为「多了标签」。要么在 HCL 里保留这些标签,要么用 ignore_changes 忽略。不要盲目让 Terraform 删掉它们,否则 CFN 侧若还要回滚会出问题。

6. 双跑期的对账

一句话总结: 双跑期指「CFN 栈仍存在、Terraform 已接管」的过渡阶段,核心工作是用定期 plan 与资源属性对账,确认两边没有互相打架。

6.1 对账方法

# Terraform 侧:确认无漂移
terraform plan -detailed-exitcode     # 0 = 无差异

# CFN 侧:确认无漂移(若栈仍受管)
aws cloudformation detect-stack-drift --stack-name my-stack
aws cloudformation describe-stack-resource-drifts --stack-name my-stack \
  --stack-resource-drift-status-filters MODIFIED DELETED

两边都报告「无漂移」,说明所有权交接干净。任一侧报告漂移,都要查清是谁改的。

6.2 避免双写

双跑期最大的风险是两个系统都在管同一资源。规则:

  • CFN 侧:禁用所有自动化(CodePipeline、StackSets、定时任务),只保留人工紧急操作。
  • Terraform 侧:正常的 PR 流程,但变更窗口避开 CFN 的任何人工操作。
  • 用一个共享的变更日历,明确「这段时间谁负责」。

6.3 观察期结束后的收尾

  • 把 CFN 栈标记为 Retain 或直接保留不再触碰。
  • 更新文档与 runbook,把「怎么改这个资源」指向 Terraform。
  • 删除 CFN 侧的 CI 流水线与 IAM 角色,避免误触发。

7. 实战:一个小栈的完整迁移

一句话总结: 用一个「VPC + 子网 + S3 桶」的小栈走完整流程,能暴露所有会在生产栈上遇到的问题。

7.1 原始 CFN 模板

Resources:
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsSupport: true
      Tags: [{Key: Name, Value: prod-vpc}]
  Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: acme-assets-prod
      VersioningConfiguration: {Status: Enabled}

7.2 对应 HCL

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  tags = { Name = "prod-vpc" }
}

resource "aws_s3_bucket" "assets" {
  bucket = "acme-assets-prod"
}

# CFN 的内联 VersioningConfiguration 在 Terraform 里是独立资源
resource "aws_s3_bucket_versioning" "assets" {
  bucket = aws_s3_bucket.assets.id
  versioning_configuration { status = "Enabled" }
}

注意这里就是「一对多」:CFN 的 1 个资源 → Terraform 的 2 个资源,因此需要 2 条 import 块。

7.3 导入与对齐

terraform plan -generate-config-out=generated.tf
# 人工整理 generated.tf,改名、抽变量
terraform plan      # 检查差异
terraform apply     # 导入
terraform plan      # 应显示 No changes

7.4 排错

现象原因处理
Resource already managed同一 ID 导入两次检查 import 块是否重复
plan 显示要重建不可变属性不一致修 HCL 匹配现状,或接受重建(有数据则禁止)
导入后 plan 反复出现差异默认值/服务端填充用 ignore_changes 收敛
Error: reading... not found物理 ID 写错或跨区域核对 region 与 ID

7.5 相关能力

一句话收尾: 从 CloudFormation 迁移到 Terraform 的难点不在「翻译模板」,而在「把既有资源的身份(物理 ID)与期望状态(HCL)精确对齐,并在切换期保证只有一个系统在写」。凡是把导入当成一次性动作、把回滚留到出事才想的迁移,都会在核心栈上翻车。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「terraform」更多文章

  1. 引导与状态后端自举
  2. 数据平台基础设施即代码
  3. Terraform 与 Ansible 协同