IaC 工具选型:CDKTF、Pulumi 与原生 HCL 的取舍

对比 CDKTF、Pulumi 与原生 HCL 三种 IaC 路线:编程语言 IaC 的抽象能力与陷阱、状态与生态差异、渐进迁移路径与团队选型决策框架。

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 跑。

维度原生 HCLCDKTFPulumi
配置形态声明式数据语言求值后生成 JSON语言求值后注册资源
执行引擎Terraform 内核Terraform 内核Pulumi 引擎
状态存储Terraform stateTerraform statePulumi state
抽象手段module 与 for_each类继承与函数类继承与组件资源
审查对象diff 与 plan生成产物与 planpreview 与程序代码

这里最容易被忽略的一点是: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 更可预测,二者在生态成熟度上的差距正在收窄。

把三条路线放在同一张表里,可以看到它们的取舍并非随意的,而是同一个权衡的不同落点。

维度原生 HCLCDKTFPulumi
抽象能力弱,靠 module 与循环强,类继承与组合很强,组件资源与包
生态成熟度最高,provider 全高,复用 Terraform provider中,需桥接或官方 provider
状态管理terraform stateterraform statepulumi 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 分支的角度展开另一条完全不同的迁移路径。

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「terraform」更多文章

  1. Helm Provider 与应用发布:值注入与回滚
  2. 模块注册表与分发:版本、文档与测试
  3. DNS 与证书编排:托管区域与自动验证