1. IaC 的三条路线
一句话总结: 声明式 HCL、编程语言 SDK、CDK 生成器是三条本质不同的路线,前者把配置当数据,后两者把配置当程序,选型的第一个问题不是「哪个更好」而是「你的团队更需要数据还是程序」。
基础设施即代码发展到今天,主流做法可以归到三条路线上。它们的差异不在语法糖,而在配置被求值的时机与方式:HCL 在 Terraform 内核里被解析成资源图,编程语言 SDK 在语言运行时里被求值成资源对象,CDK 生成器则介于两者之间,用语言求值后产出标准 HCL 或 JSON。
路线 A:声明式 HCL
写 .tf 文件 → terraform 解析 → 资源图 → provider 调用
路线 B:编程语言 SDK(Pulumi 模式)
写 Python/TS/Go → 语言运行时求值 → 资源对象注册 → provider 调用
路线 C:CDK 生成器(CDKTF 模式)
写 TypeScript → 语言运行时求值 → 生成 cdk.tf.json → terraform 执行
三条路线共享同一个底层假设:资源状态必须被持久化。差别在于状态由谁管理、抽象层写在哪里、以及审查时人看到的是什么。
1.1 三条路线的定位差异
一句话总结: HCL 把配置当数据审查,编程语言 IaC 把配置当程序执行,CDKTF 是二者的桥,用语言写、用 Terraform 跑。
| 维度 | 原生 HCL | CDKTF | Pulumi |
|---|---|---|---|
| 配置形态 | 声明式数据 | 语言求值后生成 JSON | 语言求值后注册资源 |
| 执行引擎 | Terraform 内核 | Terraform 内核 | Pulumi 引擎 |
| 状态存储 | Terraform state | Terraform state | Pulumi state |
| 抽象手段 | module 与 for_each | 类继承与函数 | 类继承与组件资源 |
| 审查对象 | diff 与 plan | 生成产物与 plan | preview 与程序代码 |
这里最容易被忽略的一点是:CDKTF 的产物仍然是 Terraform 配置,所以它可以和既有 HCL 工程共享 state 与 provider 生态;而 Pulumi 有自己的一套引擎与状态格式,互操作要靠桥接而不是共享。
1.2 同一个 S3 桶的两种写法
一句话总结: 用一个最小的 S3 桶例子能看出语法气质差异,HCL 是数据块加隐式引用,CDKTF 是类实例化加对象传参。
resource "aws_s3_bucket" "logs" {
bucket = "example-logs-prod"
}
resource "aws_s3_bucket_versioning" "logs" {
bucket = aws_s3_bucket.logs.id
versioning_configuration {
status = "Enabled"
}
}
import { S3Bucket, S3BucketVersioningA } from "@cdktf/provider-aws";
const bucket = new S3Bucket(this, "logs", { bucket: "example-logs-prod" });
new S3BucketVersioningA(this, "logs-versioning", {
bucket: bucket.id,
versioningConfiguration: { status: "Enabled" },
});
HCL 的依赖是隐式推导,aws_s3_bucket.logs.id 出现即建立边;CDKTF 的依赖来自对象传参,语言层面看起来更像普通程序。Pulumi 的等价写法在第三章给出。
2. CDKTF 的工作方式与生命周期
一句话总结: CDKTF 是一个「语言前端 + Terraform 后端」的编译器,生命周期是 synth 生成 JSON、Terraform 执行计划,状态仍落在标准 state 里。
CDKTF 的核心设计是不自己实现执行引擎。它提供一组语言绑定,把你的程序求值成 Terraform JSON 配置,然后调用 terraform 二进制去 plan 和 apply。这个决定带来一个关键性质:CDKTF 的能力边界等于 Terraform 的能力边界,provider 生态可以原样复用。
2.1 定义栈与生成配置
一句话总结: CDKTF 用
App与TerraformStack组织资源,cdktf synth把栈求值成cdktf.out下的cdk.tf.json。
import { App, TerraformStack, TerraformOutput } from "cdktf";
import { AwsProvider } from "@cdktf/provider-aws/lib/provider";
import { Instance } from "@cdktf/provider-aws/lib/instance";
class WebStack extends TerraformStack {
constructor(scope: App, id: string) {
super(scope, id);
new AwsProvider(this, "aws", { region: "ap-northeast-1" });
const web = new Instance(this, "web", {
ami: "ami-0c55b159cbfafe1f0",
instanceType: "t3.micro",
tags: { Name: "web", Env: "prod" },
});
new TerraformOutput(this, "public_ip", { value: web.publicIp });
}
}
const app = new App();
new WebStack(app, "web-prod");
app.synth();
注意最后一行 app.synth():这是 CDKTF 与普通程序的分界线。在这之前一切都是内存中的对象图,在这之后才落盘成 JSON。副作用必须发生在 synth 之前,否则你会得到一份生成时状态不一致的配置。
2.2 synth 与 deploy 的命令链路
一句话总结:
cdktf synth只生成配置,cdktf deploy才调用 Terraform,调试时应先看cdktf.out里的 JSON 而不是猜代码。
# 安装 provider 绑定并生成 provider 元数据
cdktf get
# 求值程序,产出 cdktf.out/stacks/web-prod/cdk.tf.json
cdktf synth
# 查看生成的配置:排查抽象层问题时最先看这个
jq '.resource' cdktf.out/stacks/web-prod/cdk.tf.json
# 计划与部署:内部调用 terraform plan / apply
cdktf plan web-prod
cdktf deploy web-prod
# 销毁与清理生成产物(生成物不入库)
cdktf destroy web-prod
rm -rf cdktf.out
生成的配置是标准 Terraform JSON 语法,可以直接交给 terraform 命令执行。这也是 CDKTF 排错的第一原则:当抽象层行为不符合预期时,去看生成产物,而不是去读 CDKTF 源码。
3. Pulumi 的资源模型与状态
一句话总结: Pulumi 有独立的引擎与状态格式,资源注册发生在程序执行过程中,因此抽象能力最强,但也最依赖语言运行时与自管状态后端。
Pulumi 与 CDKTF 的根本区别在于:Pulumi 自己实现执行引擎。程序在 pulumi up 时被真实执行,每次资源构造都会向引擎注册一个资源,引擎负责对比期望状态与实际状态。这意味着没有「生成产物」这一步,代码本身就是配置。
3.1 资源注册与隐式依赖
一句话总结: Pulumi 的资源依赖通过对象引用建立,
bucket.id被传入即形成边,输出值是惰性的Output类型。
import pulumi
import pulumi_aws as aws
bucket = aws.s3.Bucket(
"logs",
bucket="example-logs-prod",
tags={"Env": "prod", "ManagedBy": "pulumi"},
)
# 引用 bucket.id 即建立依赖边,无需显式 depends_on
policy = aws.s3.BucketPolicy(
"logs-policy",
bucket=bucket.id,
policy=bucket.arn.apply(
lambda arn: f'{{"Effect":"Allow","Action":"s3:GetObject","Resource":"{arn}/*"}}'
),
)
pulumi.export("bucket_name", bucket.id)
Output 类型是 Pulumi 的重要设计:资源属性在 preview 阶段是未知的,所以所有依赖它的值都必须是惰性求值的 Output,用 .apply() 变换。这比 HCL 的字符串插值更难写错,但也更难读——代码里到处是 lambda 是 Pulumi 工程常见的观感。
3.2 状态后端与 up 流程
一句话总结: Pulumi 状态默认存在 Pulumi Cloud,生产环境通常改用对象存储自管,
pulumi up的 preview 阶段等价于 Terraform plan。
# 登录并选择状态后端(自管:S3 或本地文件)
pulumi login s3://example-pulumi-state?region=ap-northeast-1
# 初始化项目栈并设置配置
pulumi stack init prod
pulumi config set aws:region ap-northeast-1
# 预览变更(等价于 terraform plan)
pulumi preview
# 应用变更并写入状态
pulumi up --yes
状态格式是 Pulumi 与 Terraform 之间最硬的墙。pulumi stack export 得到的 JSON 与 Terraform state 结构完全不同,无法直接互相导入。如果团队已有大量 Terraform 管理的资源,迁移到 Pulumi 只能靠 pulumi import 逐个采纳,而不是复用现有 state。
4. 与原生 HCL 的能力对比
一句话总结: 抽象能力与调试难度成正比:语言 IaC 抽象更强但更难审查,HCL 更啰嗦但 plan 更可预测,二者在生态成熟度上的差距正在收窄。
把三条路线放在同一张表里,可以看到它们的取舍并非随意的,而是同一个权衡的不同落点。
| 维度 | 原生 HCL | CDKTF | Pulumi |
|---|---|---|---|
| 抽象能力 | 弱,靠 module 与循环 | 强,类继承与组合 | 很强,组件资源与包 |
| 生态成熟度 | 最高,provider 全 | 高,复用 Terraform provider | 中,需桥接或官方 provider |
| 状态管理 | terraform state | terraform state | pulumi state 独立 |
| 审查体验 | 好,diff 即配置 | 中,需先 synth 再看 | 中,看代码与 preview |
| 团队门槛 | 低,运维可读 | 中,需 TS 工程能力 | 中高,需语言与引擎双懂 |
| 调试难度 | 低,plan 可解释 | 中,两层产物需对照 | 高,执行期错误难定位 |
4.1 抽象能力到底买到了什么
一句话总结: 语言 IaC 真正解决的是多环境多资源组合的重复问题,当资源超过百级且形态高度相似时,类与函数的复用收益才真正超过审查成本。
HCL 处理重复的手段是 for_each、count 和 module。它们足够表达「同一资源的多份实例」,但表达不了「一组有状态、有默认值、有校验逻辑的复合资源」。例如「一个带监控与告警的数据库」在 HCL 里是十几个资源加一堆变量,在编程语言 IaC 里是一个类。
class MonitoredDatabase {
constructor(scope: Construct, id: string, opts: DatabaseOptions) {
const db = new DbInstance(this, id, {
engine: "postgres",
instanceClass: opts.size,
backupRetentionPeriod: opts.backupDays ?? 7,
});
new CloudwatchMetricAlarm(this, `${id}-cpu`, {
comparisonOperator: "GreaterThanThreshold",
threshold: opts.cpuThreshold ?? 80,
alarmActions: [opts.alertTopicArn],
});
new TerraformOutput(this, `${id}-endpoint`, { value: db.endpoint });
}
}
代价是:审查者看到的是一段有控制流的代码,必须理解语言语义才能预测 plan。这就是抽象能力的真实成本。
5. 编程语言 IaC 的陷阱
一句话总结: 语言 IaC 把「配置错误」换成了「程序错误」,最危险的几类陷阱是隐式依赖、非确定性输出、状态膨胀和运行时依赖。
把配置写成程序,等于把程序的所有失败模式也引入了基础设施层。以下五类陷阱在实践中反复出现,且往往在资源规模变大之后才暴露。
5.1 隐式依赖与非确定性
一句话总结: 遍历 Map 或使用时间戳、随机数会让每次 synth 产出不同配置,导致 plan 永远不收敛,这是语言 IaC 最经典的自伤方式。
// 反例:Map 遍历顺序在不同语言运行时可能不同
const envs: Record<string, string> = { prod: "t3.large", dev: "t3.micro" };
for (const [env, size] of Object.entries(envs)) {
new Instance(this, `web-${env}`, { instanceType: size });
}
// 反例:时间戳进入资源属性 → 每次 synth 都产生 diff
new S3Bucket(this, "logs", { bucket: `logs-${Date.now()}` });
// 正例:稳定排序 + 显式版本常量,保证每次 synth 结果一致
new S3Bucket(this, "logs", { bucket: "logs-v1" });
5.2 状态膨胀与运行时依赖
一句话总结: 动态循环会把循环变量写进资源地址,改一个数组元素就导致大量资源重建;同时 CI 必须预装 Node 或 Python 运行时,流水线复杂度显著上升。
# 反例:用动态长度数组生成资源,插入一个元素导致后续全部重建
# 资源地址形如 aws_instance.web[0]、aws_instance.web[1]……
# 正例:用稳定键生成地址,插入元素不影响既有资源
# 资源地址形如 aws_instance.web["api"]、aws_instance.web["worker"]
# CI 中的额外要求:语言运行时必须在流水线里可用
node --version # CDKTF 需要 Node 18+
python3 --version # Pulumi 需要 Python 3.9+
cdktf get # 每次依赖变化都要重新生成 provider 绑定
陷阱清单
1. 隐式依赖:资源引用了计划外的外部数据源,plan 时才发现
2. 非确定性:Map 顺序、时间戳、随机后缀导致 plan 不收敛
3. 状态膨胀:动态循环地址不稳定,改动引发连锁重建
4. 运行时依赖:CI 必须装 Node/Python,镜像与缓存策略变复杂
5. 动态循环:循环次数依赖运行时数据,plan 结果无法静态预测
6. 迁移路径:互操作与分阶段替换
一句话总结: 从 HCL 迁到 CDKTF 可以共享 state 与 provider,风险可控;迁到 Pulumi 需要重新导入全部资源,必须按模块分阶段推进。
迁移的可行性完全取决于目标工具是否复用 Terraform 的执行引擎。CDKTF 复用,所以是渐进替换;Pulumi 不复用,所以是逐模块重导入。这条差异决定了迁移方案的设计。
6.1 CDKTF 与既有 HCL 共存
一句话总结: CDKTF 可以直接转换现有 HCL 并 import 既有资源,生成产物与手写 HCL 可共享同一 state 后端逐步替换。
# 把既有 HCL 目录转换为 CDKTF 代码骨架
cdktf convert --language typescript ./legacy
# 用 cdktf import 采纳已在 state 中的资源
cdktf import aws_instance.web i-0abc123def456
# cdktf.out/ 由 CDKTF 管理,legacy/ 由手写 HCL 管理
# 二者通过相同的 terraform state 后端共享记录
分阶段替换顺序(CDKTF)
阶段 1:新资源用 CDKTF 写,旧资源保持 HCL,共享同一 state 后端
阶段 2:按模块转换,每转一个模块跑一次 plan,确认无删除
阶段 3:转换完成后移除手写 HCL,统一走 cdktf deploy
阶段 4:把 cdktf.out 加入 .gitignore,只提交源码与 lock 文件
6.2 Pulumi 的逐模块重导入
一句话总结: Pulumi 无法复用 Terraform state,迁移必须用
pulumi import逐个采纳资源,且过程中要防止两个工具同时管理同一资源。
# 逐个导入既有资源
pulumi import aws:s3/bucket:Bucket logs example-logs-prod
# 导入后立即 preview,确认无意外变更
pulumi preview
# 从 Terraform 侧移除已迁走的资源
terraform state rm aws_s3_bucket.logs
迁移期间最危险的状态是双管:同一资源既在 Terraform state 里,又在 Pulumi stack 里。两侧都会尝试收敛,最终产生互相打架的 apply。纪律是:一个资源在同一时刻只能有一个管理者,导入 Pulumi 后立刻从 Terraform state 移除。
7. 选型决策框架
一句话总结: 选型应按团队规模、资源规模、合规审查强度和平台工程成熟度四个变量决策,多数团队的正确答案是「HCL 为主,语言 IaC 用于平台层」。
不存在普适的最优解,只有与团队当前状态匹配的解。下面这张决策表把四个关键变量与推荐路线对应起来。
| 团队规模 | 资源规模 | 合规审查 | 平台成熟度 | 推荐路线 |
|---|---|---|---|---|
| 1 至 5 人 | 百级以内 | 弱 | 起步 | 原生 HCL,先建规范 |
| 5 至 20 人 | 百至千级 | 中 | 成长 | HCL 为主,模块化优先 |
| 20 人以上 | 千级 | 强 | 成熟 | HCL 为底座,平台层用 CDKTF |
| 平台团队 | 千级以上 | 强 | 成熟 | CDKTF 封装内部平台抽象 |
| 全栈语言团队 | 百至千级 | 中 | 成熟 | Pulumi,接受独立状态 |
| 强审计行业 | 任意 | 极强 | 成熟 | 原生 HCL,审查路径最短 |
7.1 反模式清单
一句话总结: 最常见的三种错误是为了抽象而抽象、双引擎并行、跳过 plan 审查,它们都会让基础设施的变更风险失控。
反模式 1:资源不足百级就引入语言 IaC
→ 抽象成本高于复用收益,团队只学会语法没拿到收益
反模式 2:HCL 与 Pulumi 长期并行管理同一批资源
→ 双管状态下 plan 与 preview 结论互相矛盾,事故高发
反模式 3:CI 中跳过 plan 直接 apply
→ 语言 IaC 的执行期副作用无法静态审查,跳过 plan 等于放弃唯一护栏
反模式 4:用类继承层级表达环境差异
→ 继承链越深,plan 越难预测,环境差异应显式参数化
8. 总结
| 环节 | 要点 |
|---|---|
| 路线划分 | 声明式 HCL、编程语言 SDK、CDK 生成器三条路线,差异在求值时机 |
| CDKTF 本质 | 语言前端加 Terraform 后端,synth 产出 JSON,复用 provider 与 state |
| Pulumi 本质 | 独立引擎与状态格式,资源在执行期注册,抽象最强但迁移最重 |
| 能力取舍 | 抽象能力与调试难度成正比,审查路径长度决定合规友好度 |
| 主要陷阱 | 隐式依赖、非确定性、状态膨胀、运行时依赖、动态循环五类 |
| 迁移策略 | CDKTF 可共享 state 渐进替换,Pulumi 须逐模块 import 并避免双管 |
| 选型原则 | 按团队规模、资源规模、合规强度、平台成熟度四变量决策 |
| 通用底线 | 无论选哪条路线,plan 审查与单一管理者纪律都不可放弃 |
三条路线不是互斥的替代关系,而是同一问题在不同抽象层级上的答案。对多数团队而言,最稳妥的路径是以 HCL 为底座建立规范与模块资产,在平台层用 CDKTF 封装内部抽象,把语言 IaC 的收益留给真正需要复用的场景。至于何时该考虑换掉 Terraform 本身,下一篇文章会从许可证变更与 OpenTofu 分支的角度展开另一条完全不同的迁移路径。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。