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 类型映射
| CloudFormation | Terraform | 备注 |
|---|---|---|
AWS::EC2::VPC | aws_vpc | 基本一一对应 |
AWS::EC2::Subnet | aws_subnet | 注意 MapPublicIpOnLaunch |
AWS::EC2::SecurityGroup | aws_security_group + aws_vpc_security_group_ingress_rule | CFN 用内联规则,Terraform 推荐独立规则资源 |
AWS::S3::Bucket | aws_s3_bucket + 若干子资源 | Terraform 把策略/加密/版本控制拆成独立资源 |
AWS::RDS::DBInstance | aws_db_instance | 注意 ManageMasterUserPassword 语义 |
AWS::IAM::Role | aws_iam_role + aws_iam_role_policy_attachment | 托管策略需拆成独立资源 |
AWS::Lambda::Function | aws_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 相关能力
- 大批量导入与既有资源纳管,参见 导入与既有资源 。
- 迁移后的目录重构与 state 拆分,参见 重构与迁移 。
- 需要移动资源地址时(
terraform state mv、moved块),参见 状态高级操作 。 - 目标环境的组织方式(账号、目录、模块),参见 AWS 基础设施 。
一句话收尾: 从 CloudFormation 迁移到 Terraform 的难点不在「翻译模板」,而在「把既有资源的身份(物理 ID)与期望状态(HCL)精确对齐,并在切换期保证只有一个系统在写」。凡是把导入当成一次性动作、把回滚留到出事才想的迁移,都会在核心栈上翻车。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。