概述

去年年底接手一个项目:某出行平台的云基础设施代码库,Terraform HCL 写了 3000 多行,分了 40 多个模块,循环引用 3 处,for_each 嵌套两层 dynamic block 到没人看得懂。新来的运维改了一行 tagsterraform plan 输出 847 行变更,其中 600 行是"无实际影响但状态会更新"。他不敢 apply,我也看不懂那 847 行。

这不是个例。我在多个项目里见过同样的剧本:HCL 在 500 行以内很好用,超过 1000 行就开始失控,2000 行以上就是"只有写的人敢碰"的炸弹。原因不复杂——HCL 是领域专用语言(DSL),设计目标是"声明式配置",不是"工程化代码"。它没有函数(只有 module),没有异常处理,没有类型推断,测试要靠第三方工具(如 Terratest)从外部驱动。

Pulumi 换了个思路:用你已经在用的编程语言(Go、Python、TypeScript 等)写基础设施。听起来只是"换种语法",但它改变的是整个工程实践——你可以写单元测试、用 IDE 跳转定义、用包管理器复用代码、甚至把 IaC 嵌入应用里通过 API 驱动部署。

这篇文章不讲"Pulumi 比 Terraform 好"这种废话。我讲的是:在什么场景下你应该考虑迁移,迁移时做哪些架构决策,以及我踩过的 5 个坑。所有结论来自我在某出行平台和某电商平台的基础设施改造实践,数据脱敏但结构和逻辑保留。

为什么 HCL 写到 3000 行会"烂掉"

先说清楚问题出在哪,不然迁移决策就是拍脑袋。

HCL 的设计哲学与工程化冲突

HCL 的核心是声明式——你告诉 Terraform"期望状态是什么样",引擎负责把现实对齐到期望。这个模型在 500 行以内很优雅:一段 VPC 配置、几个安全组、一个 RDS 实例,读起来像配置文件,改起来也直观。

但工程规模上去后,HCL 的设计约束变成短板:

场景HCL 的做法编程语言的做法
根据环境创建不同资源count = var.env == "prod" ? 1 : 0if env == "prod" { createProd() }
批量创建子网for_each = toset(local.subnets)subnets.map(s => createSubnet(s))
复用一组资源写 module,传变量写函数/类,直接调用
错误处理没有,apply 失败了看日志try/catch,可恢复
单元测试Terratest 从外部跑,慢go test/pytest,秒级
类型检查没有,plan 时才知道对不对编译器/IDE 实时报错

不是说 HCL 做不到这些,而是它的设计哲学就是"不需要这些"。Terraform 团队认为 IaC 应该简单到"看一眼就知道建了什么资源"。这个理念在小规模下成立,大规模下就是"用螺丝刀拧螺丝,拧到 1000 颗时手断了"。

HCL 复杂度增长曲线:从优雅到灾难

我观察过一个 HCL 代码库从 200 行增长到 3200 行的全过程,复杂度增长不是线性的,而是阶梯式的跳变:

200-500 行:蜜月期。 配置清晰,resourcemodule 各司其职。terraform plan 输出几十行,能看懂。团队效率高,改配置像改 YAML。

500-1000 行:开始痒了。 出现 for_eachdynamic block。locals 里开始塞复杂逻辑,比如根据 var.env 计算 CIDR 段。terraform plan 输出 200+ 行,需要仔细看才能分辨哪些是真正的变更。

1000-2000 行:痒得难受。 模块嵌套 3 层以上。变量定义 40+ 个,其中 10+ 个是 type = any(因为类型系统表达不了)。有人开始写 .tfvars 文件管理环境差异,文件数量爆炸。terraform plan 输出 500+ 行,没人有耐心看完。

2000+ 行:灾难。 原始作者离职。新来的人不敢改,因为改一行可能触发几百行 diff。terraform state 里有几百个资源,terraform plan 要跑 3-5 分钟。有人偷偷用 terraform import 手动管理,导致状态文件和实际资源不一致。

真实案例:847 行 plan 输出的噩梦

在某出行平台,我们有一段 HCL 管理安全组规则。36 个安全组,每个 8-15 条规则,总共约 400 条规则。有一次新来的运维加了 1 条 SSH 放通规则,terraform plan 输出 847 行变更。

原因?HCL 的 for_each 在安全组规则变更时,Terraform 会重新计算整个 for_each 集合的 diff。由于规则是 dynamic block 生成的,内部嵌套了 source_prefix_list_idscompact 处理,Terraform 把所有规则的 tags 变更也标记成了"需要更新"。实际上只有 1 条规则变了,但 plan 输出告诉你 400 条都"可能变了"。

300 行 TypeScript 代码比 300 行 HCL 更难一眼看懂——这是我后面要讲的坑之一。但 3000 行 HCL 比 3000 行 TypeScript 更难维护,因为 TypeScript 至少能重构,HCL 连提取函数都做不到。

Pulumi 到底解决了什么问题

Pulumi 的核心创新不是"换种语法写 IaC",而是把基础设施定义从"配置文件"变成了"程序"。这意味着:

  1. 编程语言的全部能力可用:循环、条件、函数、类、异常处理、包管理
  2. 标准测试框架直接用:Go 的 testing、Python 的 pytest、TypeScript 的 Jest
  3. IDE 全力支持:自动补全、跳转定义、类型检查、重构
  4. Automation API:可以把 IaC 嵌入应用,通过代码驱动部署(Terraform 没有等价物)
  5. 密钥默认加密:状态文件中标记为 secret 的值自动加密,Terraform 状态文件中的敏感值是明文

执行模型对比:Terraform vs Pulumi

Pulumi 的执行模型和 Terraform 有本质区别。理解这个区别是做迁移决策的基础。

Terraform 执行流程

1. terraform init       → 下载 provider 插件,初始化后端
2. terraform plan       → 读取 HCL → 构建 DAG → 对比 State → 输出变更计划
3. terraform apply      → 执行 DAG → 创建/更新/删除资源 → 写入 State

Terraform 直接解释 HCL,没有运行时。HCL 是纯声明式的,同一份配置每次 plan 结果一致。

Pulumi 执行流程

1. pulumi preview       → 启动语言运行时 → 执行程序代码 → 构建资源声明树 → 对比 State → 输出变更计划
2. pulumi up            → 执行变更 → 创建/更新/删除资源 → 写入 State

Pulumi 启动一个语言运行时(如 Node.js 或 Python),执行你的代码,构建一棵资源声明树,然后把树提交给 Pulumi 引擎。引擎对比当前 State 和期望状态,计算差异并执行变更。

用大白话说:Terraform 是"先写配置,引擎读配置";Pulumi 是"先跑代码,代码生成配置,引擎执行配置"。这个区别直接决定了它们在项目中的角色定位——Terraform 更像"配置管理系统",Pulumi 更像"基础设施编程平台"。

三个组件协作完成 Pulumi 部署:

  1. 语言主机(Language Host):启动你选择的编程语言运行时,执行你的 Pulumi 程序
  2. 部署引擎(Deployment Engine):接收程序输出的资源声明树,对比 State,计算 diff,执行变更
  3. 资源提供者(Resource Providers):实际调用云 API 创建/更新/删除资源

这个架构意味着你的 Pulumi 程序是"真正的程序"——它可以做任何程序能做的事,包括调用外部 API、读数据库、执行条件逻辑。这是一个巨大的能力提升,但也带来了新的风险(后面讲 Plan 非确定性坑时会详细展开)。

5 个生产级架构决策

决策1:语言选型——Go 还是 TypeScript 还是 Python

Pulumi 支持 Python、TypeScript、Go、.NET、Java 和 YAML。选哪个不是看"哪个更酷",而是看你的团队主力和基础设施复杂度。

我的推荐:

语言适用场景优势劣势
Go运维团队主力 Go,需要编译期类型安全类型系统强,编译期抓错,go test 原生集成,和 K8s 生态一致语法稍重,快速原型不如 Python
TypeScript全栈团队,前后端都用 TSnpm 资源丰富,async/await 处理异步资源好,IDE 支持最佳Node.js 运行时依赖,CI 环境需装 Node
Python运维团队习惯 Python,快速迭代写得快,运维圈接受度高,pytest 成熟动态类型,大规模项目重构风险高

在出行平台,我选了 Go。原因很简单:团队主力是 Go(参考我之前写的 相关文章:别把 Terraform 模块写成黑盒),而且 Pulumi 的 Go SDK 类型推断比 Python 更严格,编译期就能抓到资源属性拼写错误。后来证明这个选择是对的——有一次同事把 cidrBlock 写成 cidrblock,Go 编译器直接报错,而如果用 Python 或 HCL,要等 pulumi preview 时才能发现。

但如果你是全栈团队或前端背景的 DevOps,TypeScript 可能更合适。 Pulumi 官方的很多文档和示例优先用 TypeScript,社区资源也最丰富。

// Go 示例:用 Pulumi 创建 VPC + 子网,条件和循环是原生语法
package main

import (
    "github.com/pulumi/pulumi-aws/sdk/v6/go/aws/ec2"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

func main() {
    pulumi.Run(func(ctx *pulumi.Context) error {
        availabilityZones := []string{"ap-northeast-1a", "ap-northeast-1c", "ap-northeast-1d"}

        // 创建 VPC
        vpc, err := ec2.NewVpc(ctx, "main-vpc", &ec2.VpcArgs{
            CidrBlock:        pulumi.String("10.0.0.0/16"),
            EnableDnsHostnames: pulumi.Bool(true),
            EnableDnsSupport:  pulumi.Bool(true),
        })
        if err != nil {
            return err
        }

        // 用循环创建子网——这就是编程语言的优势
        for i, az := range availabilityZones {
            _, err := ec2.NewSubnet(ctx, pulumi.Sprintf("subnet-%s", az), &ec2.SubnetArgs{
                VpcId:          vpc.ID(),
                CidrBlock:      pulumi.Sprintf("10.0.%d.0/24", i),
                AvailabilityZone: pulumi.String(az),
            })
            if err != nil {
                return err
            }
        }

        return nil
    })
}

等价的 HCL 需要用 for_each + dynamic block 实现,逻辑复杂度差不多,但 Go 版本你可以直接 go test 验证逻辑,HCL 版本只能靠 terraform plan 人肉检查。

决策1.5:测试策略——IaC 终于能写单元测试了

这是我迁移后最惊喜的发现。用 Terraform 时,IaC 测试只有两个选择:Terratest(Go 写测试调 terraform CLI)或 OPA(策略测试)。Terratest 要跑真实的 terraform apply + terraform destroy,一个测试用例 3-5 分钟,CI 跑一遍半小时。

Pulumi 可以用语言原生的测试框架。Go 用 testing 包,不需要启动真实云资源就能验证逻辑:

// IaC 单元测试:验证 CIDR 计算逻辑,不需要真实云资源
package main

import (
    "testing"
    "fmt"
)

// 测试 CIDR 段分配逻辑
func TestCidrAllocation(t *testing.T) {
    tests := []struct {
        name     string
        index    int
        expected string
    }{
        {"第一个子网", 0, "10.0.0.0/24"},
        {"第二个子网", 1, "10.0.1.0/24"},
        {"第三个子网", 2, "10.0.2.0/24"},
        {"第十六个子网", 15, "10.0.15.0/24"},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := fmt.Sprintf("10.0.%d.0/24", tt.index)
            if result != tt.expected {
                t.Errorf("期望 %s,得到 %s", tt.expected, result)
            }
        })
    }
}

// 测试环境差异化配置逻辑
func TestEnvironmentConfig(t *testing.T) {
    prodConfig := getEnvConfig("prod")
    stagingConfig := getEnvConfig("staging")
    
    // 生产环境必须有 3 个 NAT Gateway
    if prodConfig.natGatewayCount != 3 {
        t.Errorf("生产环境 NAT Gateway 数量应为 3,得到 %d", prodConfig.natGatewayCount)
    }
    
    // 非生产环境只配 1 个 NAT Gateway
    if stagingConfig.natGatewayCount != 1 {
        t.Errorf("非生产环境 NAT Gateway 数量应为 1,得到 %d", stagingConfig.natGatewayCount)
    }
}

type envConfig struct {
    natGatewayCount int
    minSize         int
    maxSize         int
}

func getEnvConfig(env string) envConfig {
    if env == "prod" {
        return envConfig{natGatewayCount: 3, minSize: 3, maxSize: 10}
    }
    return envConfig{natGatewayCount: 1, minSize: 1, maxSize: 3}
}

这些测试秒级完成,不需要云资源。逻辑变更时测试自动跑,CI 里几秒钟就能发现问题。参考我之前写的 相关文章:IaC 不测试就上生产?,Pulumi 的测试能力远超 Terraform 的 Terratest。

但要注意:逻辑测试和资源测试是两回事。逻辑测试验证你的计算函数,资源测试(用 pulumi preview)验证资源配置是否正确。两者都需要,不能只用一个。

决策2:状态管理后端——Pulumi Cloud 还是自建

Terraform 的状态管理是出了名的痛点(参考 相关文章:状态文件丢了怎么办)。Pulumi 默认用 Pulumi Cloud 管理状态,也支持自建后端(S3、Azure Blob、GCS 等)。

我的决策矩阵:

维度Pulumi Cloud自建后端(S3 + DynamoDB)
状态加密默认加密(传输 + 静态)需手动配置 SSE-KMS
状态锁自动需配合 DynamoDB
历史版本自带版本化需开 S3 Versioning
访问控制RBAC,按 stack 粒度IAM 策略,粒度粗
漂移检测商业版支持定时检测+自动修复需自己写定时任务跑 pulumi refresh
成本免费版够小团队用,商业版按人收费S3 + DynamoDB 存储费,极低
审计自带审计日志需开 CloudTrail

我推荐:30 人以下团队用 Pulumi Cloud 免费版。 理由很简单——状态管理是 IaC 最容易出事的地方,Pulumi Cloud 默认加密、自动锁、版本历史、RBAC,这些功能自己搭一遍至少两周,还不算运维成本。存储费省不了多少钱,但出一次状态损坏事故的代价远超这点费用。

但如果你有合规要求(数据不能出境内),或者团队超过免费版限制,就用自建后端。我出行平台用的是自建 S3 + DynamoDB,因为合规要求基础设施状态不能存在第三方 SaaS 上。

自建后端的关键配置(Pulumi.yaml):

# Pulumi.yaml - 自建 S3 后端配置
name: my-project
runtime: go
backend:
    url: s3://my-pulumi-state-bucket?region=ap-northeast-1&profile=pulumi

配置要点:

  • S3 bucket 必须开 Versioning(历史版本恢复靠它)
  • DynamoDB 表用做状态锁,表名通过 ?awssdk=v2 或环境变量指定
  • SSE-KMS 加密是强制选项,别用 SSE-S3(KMS 密钥可轮换,S3 managed key 不能)
  • bucket policy 限制 IP 范围和 IAM 角色,别让所有人都能读状态文件

决策3:迁移策略——渐进式还是一刀切

Pulumi 官方提供了 4 种迁移路径:

  1. Pulumi HCL 兼容模式:在 Pulumi.yaml 中设置 runtime: hcl,直接跑现有 .tf 文件
  2. pulumi convert --from terraform:自动把 HCL 转成目标语言
  3. pulumi import:导入已存在的云资源,生成对应代码
  4. Terraform State 引用:Pulumi 程序引用 Terraform state 的输出

我的推荐:渐进式迁移,按资源生命周期切分,不要一刀切。

具体步骤:

第一阶段(第1-2周):新资源用 Pulumi 写
  → 老资源保持 Terraform 管理
  → Pulumi 通过 Terraform State Reference 读取老资源的输出(如 VPC ID)
  → 目标:验证 Pulumi 工具链跑通,CI/CD 集成完成

第二阶段(第3-4周):按资源类型逐批迁移
  → 先迁移无状态资源(安全组、IAM 策略)
  → 再迁移有状态资源(RDS、S3)
  → 最后迁移核心网络(VPC、子网、路由表)
  → 目标:60% 资源切到 Pulumi

第三阶段(第5-6周):全量切换
  → 删除 Terraform 配置
  → Pulumi 全量接管
  → 回归测试 + 维护窗口切换
  → 目标:Terraform 配置归档,Pulumi 全量管理

关键踩坑提醒pulumi convert --from terraform 的自动转换质量大约 70-80%。简单的资源(S3、安全组)转换没问题,复杂的(嵌套 dynamic block、for_each + count 混用)会丢失逻辑结构,转出来是一堆扁平资源声明,原来的循环变成了重复代码。我的做法是:自动转换生成骨架,然后手动重构逻辑部分。

在某出行平台,我们花了 6 周完成 3000 行 HCL → Go Pulumi 的迁移。不是一口气转的,而是每周末转一批,周一到周五在新环境验证。最后一周做全量切换,用了 2 小时的维护窗口。

迁移过程中用到的关键命令:

# 1. 将 Terraform HCL 转换为 Pulumi Go 代码
pulumi convert --from terraform --language go --out ./converted

# 2. 导入已有云资源到 Pulumi 管理(不经过 Terraform state)
pulumi import aws:ec2/vpc:Vpc my-vpc vpc-0abc123def456 --provider aws

# 3. 从 Terraform state 批量导入资源
pulumi import --from hcl --tf-state ./terraform.tfstate

# 4. Pulumi 引用 Terraform state 的输出(渐进式迁移用)
# 在 Pulumi 程序中读取 Terraform state:
#   terraformStateRef := pulumi.NewStackReference("terraform-state")
#   vpcId := terraformStateRef.GetOutput("vpc_id")

决策4:CI/CD 集成与 Automation API

这是 Pulumi 最让我兴奋的特性,也是 Terraform 完全没有等价物的能力。

Terraform 在 CI/CD 里只能通过 CLI 调用:terraform planterraform apply。你要构建自助化平台、按 PR 创建临时环境、动态编排多 stack 部署,得自己在外面包一层调度逻辑。

Pulumi 的 Automation API 是一个 SDK,可以在程序里直接驱动 previewupdestroy,不用 shell out 到 CLI。这意味着你可以:

  • 在 Go 服务里嵌入 IaC 逻辑,用户点击"创建环境"按钮直接触发部署
  • 按 PR 分支动态创建临时环境,PR 合并后自动销毁
  • 编排跨云部署,每一步作为更大工作流的一部分

我实测的用例:在某电商平台,我们用 Go + Pulumi Automation API 搭了一个自助环境管理平台。开发者提交 PR 后,平台自动创建一个隔离的测试环境(VPC + 子网 + EKS + RDS),PR 合并后自动销毁。之前用 Terraform 实现同样的功能,需要在 Jenkins 里拼 shell 脚本调 terraform CLI,错误处理靠 set -e,状态管理靠 terraform workspace,体验很差。

Pulumi 官方案例也印证了这个方向:Starburst 用 Java + Automation API 管理多区域 K8s 部署,部署时间从 2 周降到 3 小时(112 倍提速)。Wiz 用 Automation API 管理 100 万+ 云资源和数千个 K8s 集群,每天处理数十万次基础设施更新。BMW 的软件工厂用 Python + Pulumi 管理 20000+ 云资源。

// Automation API 示例:在 Go 服务里驱动 Pulumi 部署
import (
    "github.com/pulumi/pulumi/sdk/v3/go/auto"
)

func createEnvironment(branchName string) error {
    ctx := context.Background()
    
    // 选择 stack
    stack, err := auto.UpsertStack(ctx, "preview-"+branchName, 
        auto.WorkDir("./infra"),
    )
    if err != nil {
        return err
    }
    
    // 设置配置
    stack.SetConfig(ctx, "aws:region", auto.ConfigValue{Value: "ap-northeast-1"})
    
    // 执行部署
    result, err := stack.Up(ctx)
    if err != nil {
        return err
    }
    
    // 获取输出
    vpcId := result.Outputs["vpcId"].Value
    fmt.Printf("环境创建完成,VPC ID: %v\n", vpcId)
    return nil
}

但 Automation API 有个坑:如果 Pulumi 程序里调用了外部 API(如读取 Consul 服务注册表、调用 CMDB 接口),每次 pulumi preview 的输出可能不同,因为程序的执行结果取决于外部状态。这是 Terraform HCL 不会有的问题——HCL 是纯声明式的,同一份配置每次 plan 结果一致。我后面会详细讲这个坑。

GitHub Actions CI 流水线配置

常规 CI/CD 集成(不走 Automation API)也很简单。Pulumi 官方提供了 GitHub Action:

# .github/workflows/pulumi-preview.yml
name: Pulumi Preview
on:
  pull_request:
    paths:
      - 'infra/**'

jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      
      - name: Install Pulumi CLI
        uses: pulumi/actions-install@v1
      
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1
      
      - name: Pulumi Preview
        uses: pulumi/actions@v5
        with:
          command: preview
          stack-name: staging
          work-dir: ./infra
        env:
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}

在出行平台,我们把 PR preview 的结果通过 Pulumi 的 webhook 通知到飞书群,开发者提交 PR 后 3 分钟内就能看到基础设施变更计划。之前用 Terraform 时,terraform plan 需要手动触发,开发者经常忘了跑就直接合并 PR。

决策5:策略即代码与安全门禁

Terraform 的策略框架是 Sentinel,但它是专有商业产品,只在 HCP Terraform / Enterprise 中可用。开源替代方案是 OPA(Open Policy Agent),但需要额外集成。

Pulumi Policies 是开源的(Apache 2.0),可以用 Python、TypeScript 或 OPA Rego 写策略规则,在 pulumi preview 时自动执行。

我的推荐:如果你需要 IaC 策略门禁,Pulumi 比 Terraform 开箱即用得多。 但如果你已经有一套 OPA + Terraform 的方案且运行良好,没必要为了换策略引擎而迁移。

// Pulumi 策略示例:禁止创建公开的 S3 bucket
import * as pulumi from "@pulumi/pulumi";
import { PolicyPack } from "@pulumi/policy";

new PolicyPack("security-policies", {
    policies: [
        {
            name: "no-public-s3-bucket",
            description: "S3 bucket 不允许公开访问",
            enforcementLevel: "mandatory",
            validateResource: (args, reportViolation) => {
                if (args.isType("aws:s3/bucket:Bucket")) {
                    const acl = args.props.acl;
                    if (acl === "public-read" || acl === "public-read-write") {
                        reportViolation("S3 bucket 不允许设置为公开访问");
                    }
                }
            },
        },
        {
            name: "require-tags",
            description: "所有资源必须有 Environment 和 Owner 标签",
            enforcementLevel: "mandatory",
            validateResource: (args, reportViolation) => {
                const tags = args.props.tags || {};
                if (!tags.Environment || !tags.Owner) {
                    reportViolation(
                        `资源 ${args.name} 缺少 Environment 或 Owner 标签`
                    );
                }
            },
        },
        {
            name: "no-iam-wildcard",
            description: "IAM 策略不允许使用 * 通配符",
            enforcementLevel: "mandatory",
            validateResource: (args, reportViolation) => {
                if (args.isType("aws:iam/policy:Policy")) {
                    const policyDoc = args.props.policy;
                    if (policyDoc && policyDoc.includes('"Action": "*"')) {
                        reportViolation("IAM 策略不允许使用 Action: * 通配符");
                    }
                }
            },
        },
    ],
});

在出行平台,我们用 Pulumi Policies 替换了之前 Sentinel + HCP Terraform 的方案。迁移后策略规则从 HCL + Sentinel 语法变成了 TypeScript,可读性和可维护性都上了一个台阶。最重要的是,策略规则可以和 IaC 代码在同一个仓库里管理,CI 流水线里 pulumi preview --policy-pack ./policies 一行命令搞定。

不过有个细节要注意:Pulumi Policies 的 mandatory 级别会阻断 pulumi upadvisory 只告警不阻断。生产环境建议用 mandatory,开发环境可以用 advisory 让开发者先看到问题但不阻塞。

迁移踩坑实录

坑1:Plan 非确定性——程序逻辑导致 preview 结果不稳定

这是我在迁移过程中踩的第一个大坑。

HCL 是声明式的——同一份配置,terraform plan 的输出每次都一样。但 Pulumi 程序是真正的程序,它执行时可以读取环境变量、调用外部 API、查数据库。如果程序里有这些操作,两次 pulumi preview 的输出可能不同。

真实场景:我在 Pulumi 程序里写了一段逻辑,根据 Consul 里的服务注册列表动态创建安全组规则。第一次 pulumi preview 时 Consul 返回 20 个服务,preview 显示要创建 20 条规则。过了一分钟,某个服务注销了,Consul 返回 19 个,pulumi preview 显示要删除 1 条规则。

这在 Terraform 里不会发生,因为 HCL 不执行运行时逻辑——所有值在配置阶段就确定了。

解决方案:把外部数据源的结果缓存到 Pulumi Stack Config 里,程序只从 Config 读取,不在运行时调 API。需要更新数据时手动 pulumi config set 更新,然后 pulumi up。虽然多了一步手动操作,但保证了 plan 的确定性。

// 错误做法:运行时调用外部 API,导致 plan 不确定
func main() {
    pulumi.Run(func(ctx *pulumi.Context) error {
        // 不要这样做!每次 preview 都会调 Consul,结果可能不同
        services := consul.GetServices() // 运行时调用
        
        for _, svc := range services {
            // 动态创建安全组规则
        }
        return nil
    })
}

// 正确做法:从 Stack Config 读取,保证确定性
func main() {
    pulumi.Run(func(ctx *pulumi.Context) error {
        cfg := pulumi.NewConfig(ctx, "")
        servicesStr := cfg.Require("services") // 从配置读取
        
        var services []string
        json.Unmarshal([]byte(servicesStr), &services)
        
        for _, svc := range services {
            // 创建安全组规则
        }
        return nil
    })
}

更进一步,我们写了一个外部 cron job,每 5 分钟从 Consul 拉取服务列表,调用 pulumi config set services '[...]' 更新配置。这样 plan 的确定性保证了,数据也能定时刷新。

这个坑的本质是:Pulumi 把 IaC 从"配置"变成了"程序",程序的不确定性是编程语言固有的。你获得了编程灵活性,就要承担确定性管理的责任。Terraform 用 HCL 的限制换来了确定性,但也失去了灵活性。这是一个工程权衡,没有对错。

坑2:代码审查难度翻转——300 行 TS 比 300 行 HCL 更难看懂

前面提到过"3000 行 HCL 比 3000 行 TS 更难维护",但反过来说:300 行 HCL 比 300 行 TS 更容易看懂。

HCL 的声明式特性让它在小规模下"自文档化"——你看到 resource "aws_instance" "web" 就知道创建了什么。TypeScript 代码需要追踪函数调用、类型定义、import 路径才能理解一段代码创建了什么资源。

真实场景:在出行平台迁移初期,团队习惯了看 HCL 的 plan 输出。切到 Pulumi 后,pulumi preview 的输出和 Terraform 的 plan 类似(都显示资源变更),但代码审查时看的是 TypeScript/Go 源码,不是 plan 输出。团队花了 2-3 周才适应"审查代码而不是审查 plan"的工作流。

解决方案:在 PR 模板里强制要求贴 pulumi preview --diff 的输出,审查者先看 diff(和 terraform plan 类似),再看代码变更。这样既利用了 Pulumi 的编程能力,又保留了 plan-based 审查习惯。

另外,Pulumi 的 Component Resources(组件资源)可以帮助组织代码。把一组相关资源封装成一个组件,组件内部的资源在 plan 输出里呈现为父子关系,读起来更清晰。

数据对比:迁移初期 PR 平均审查时间从 15 分钟(HCL plan)增加到 25 分钟(Go 源码+preview diff)。4 周后回落到 18 分钟,因为团队适应了新工作流。2 个月后稳定在 12 分钟,因为 Pulumi 的类型系统让低级错误(属性拼写、类型不匹配)在编译期就消除了,review 时不用再检查这些。

坑3:CI/CD 运行时依赖——Node.js / Python 环境的复杂度

Terraform 的运行时依赖只有 terraform 二进制文件。Pulumi 的运行时依赖包括:Pulumi CLI + 目标语言运行时(Node.js / Python / Go)+ 包管理器(npm / pip / go mod)+ 资源 provider 插件。

在 CI/CD 里,这意味着 Docker 镜像要装更多东西。如果用 Go 还好——编译后的二进制不需要运行时。但 TypeScript 需要装 Node.js,Python 需要装解释器。

实测对比

工具Docker 镜像大小CI 初始化时间依赖安装
Terraform~200MB(terraform + AWS CLI)~10s
Pulumi + Go~350MB(pulumi + go + AWS CLI)~15sgo mod download
Pulumi + TypeScript~500MB(pulumi + node + npm + AWS CLI)~30snpm install

Pulumi 官方提供了 Docker 镜像,但体积偏大。我的做法是基于 alpine 自己构建精简镜像,把 provider 插件预装进去避免每次 CI 运行时下载。

# 精简的 Pulumi Go CI 镜像
FROM golang:1.22-alpine AS builder
RUN apk add --no-cache git

FROM alpine:3.19
RUN apk add --no-cache curl bash python3 py3-pip aws-cli
# 安装 Pulumi CLI
RUN curl -fsSL https://get.pulumi.com | sh -s -- --version 3.163.0
# 预装常用 provider 插件,避免 CI 运行时下载
RUN pulumi plugin install resource aws v6.42.0
RUN pulumi plugin install resource kubernetes v4.18.0

还有一个细节:Pulumi 的 provider 插件下载速度在某些区域很慢(下载源在海外)。在出行平台的 CI 环境中,首次 pulumi up 下载 aws provider 要 3 分钟。预装到镜像后变成 0 秒。

坑4:Secrets 处理差异——状态文件加密方式不同

Terraform 状态文件里的 sensitive 变量是明文存储的。你 terraform apply 后看 .tfstate 文件,sensitive = true 的变量值照样能看到。官方推荐的方案是配合 HashiCorp Vault 使用,但那是另一个产品。

Pulumi 默认对标记为 secret 的值做加密——传输和静态都加密,每个 stack 有独立的加密密钥。支持的 KMS 包括 AWS KMS、Azure Key Vault、Google Cloud KMS 和 HashiCorp Vault。

踩坑场景:迁移时把 Terraform 的 sensitive 变量直接搬到 Pulumi,但忘了用 pulumi config set-secret 标记。结果这些值在 Pulumi 状态文件里也是明文的。Pulumi 不会自动猜哪些是敏感的——你必须显式声明。

解决方案:写了一个迁移脚本,扫描 Terraform 的 variable 定义中 sensitive = true 的变量,在 Pulumi 中用 pulumi config set-secret 对应设置。

#!/bin/bash
# 迁移 Terraform sensitive 变量到 Pulumi secret
# 用法: ./migrate-secrets.sh <stack-name>

STACK=$1
TF_DIR="./terraform"

# 提取 Terraform 中标记为 sensitive 的变量
grep -B2 'sensitive.*=.*true' "$TF_DIR"/*.tf | grep 'variable' | \
    awk -F'"' '{print $2}' | while read var_name; do
    echo "迁移 sensitive 变量: $var_name"
    # 从 .tfvars 或环境变量中读取值
    value=$(terraform -chdir="$TF_DIR" output -raw "$var_name" 2>/dev/null)
    if [ -n "$value" ]; then
        pulumi config set-secret "$var_name" "$value" --stack "$STACK"
        echo "  → 已设置为 Pulumi secret"
    else
        echo "  → 跳过(值未找到)"
    fi
done

echo "敏感变量迁移完成"

坑5:模块迁移的陷阱——Terraform Module 到 Pulumi Component

Terraform 的 module 和 Pulumi 的 Component Resource 概念类似但不完全等价。Terraform module 是静态的 HCL 源码组织单元,module 实例在 state 里是扁平的。Pulumi Component 是运行时对象,有明确的父子关系,在 plan 输出和 state 里都呈现层级结构。

pulumi convert --from terraform 转换 module 时,大部分情况下会把 module 转成一个组件。但如果 module 里用了 countfor_each,转换结果可能不符合预期——count 变成了数组循环,for_each 变成了 map 遍历,但 module 的输入/输出变量传递逻辑可能丢失。

我的做法:不依赖自动转换,手动把核心 module 重写为 Pulumi Component。虽然工作量大,但能确保组件的接口设计和类型签名清晰。一个写得好的 Pulumi Component 应该像标准库函数一样——输入参数类型明确,输出结果可组合。

// Pulumi Component 示例:封装一个标准 VPC 组件
type VpcComponent struct {
    pulumi.ResourceState
    VpcId     pulumi.IDOutput
    SubnetIds pulumi.IDArrayOutput
}

func NewVpcComponent(ctx *pulumi.Context, name string, 
    cidr string, azs []string, opts ...pulumi.ResourceOption) (*VpcComponent, error) {
    var comp VpcComponent
    err := ctx.RegisterComponentResource("custom:Vpc", name, &comp, opts...)
    if err != nil {
        return nil, err
    }
    
    vpc, err := ec2.NewVpc(ctx, name+"-vpc", &ec2.VpcArgs{
        CidrBlock: pulumi.String(cidr),
    }, pulumi.Parent(&comp))
    if err != nil {
        return nil, err
    }
    
    var subnetIds []pulumi.StringInput
    for i, az := range azs {
        subnet, err := ec2.NewSubnet(ctx, 
            pulumi.Sprintf("%s-subnet-%d", name, i), &ec2.SubnetArgs{
                VpcId:          vpc.ID(),
                CidrBlock:      pulumi.Sprintf("10.0.%d.0/24", i),
                AvailabilityZone: pulumi.String(az),
            }, pulumi.Parent(&comp))
        if err != nil {
            return nil, err
        }
        subnetIds = append(subnetIds, subnet.ID())
    }
    
    comp.VpcId = vpc.ID()
    comp.SubnetIds = pulumi.ToIDArrayOutput(subnetIds)
    return &comp, nil
}

实测数据:在出行平台的迁移中,40 个 Terraform module 的转换结果如下:

  • 12 个简单 module(S3、IAM):自动转换可用,微调后直接使用
  • 15 个中等复杂度 module(带 for_each):自动转换结构正确但逻辑丢失,需手动重构
  • 13 个复杂 module(嵌套 dynamic + count 混用):自动转换不可用,全部手动重写

总工作量约 120 人时,6 周 2 人交替投入。

漂移检测与自动修复

Pulumi 商业版支持定时漂移检测(Drift Detection)和自动修复(Auto-Remediation)。Terraform 也有类似功能(HCP Terraform),但需要商业版。

漂移是什么?简单说就是:你的 IaC 代码说"应该有 3 台服务器",但实际上有人通过控制台手动加了一台,或者某个脚本删了一台。代码和现实不一致了。

漂移检测流程:
  pulumi refresh     → 读取云上实际状态,更新 State 文件
  pulumi preview     → 对比更新后的 State 和代码期望,输出差异
  如果有差异 → 触发告警 / 自动修复

自动修复流程:
  pulumi refresh     → 读取实际状态
  pulumi up --refresh → 对齐实际状态到代码期望
  → 资源被"纠正"回代码定义的状态

配置方式(Pulumi Cloud 控制台):

  1. 导航到 Stack > Settings > Schedules
  2. 选择 Drift 类型
  3. 设置 cron 表达式(如 0 2 * * * 每天凌晨 2 点检测)
  4. 可选:开启 auto-remediation,检测到漂移后自动执行 pulumi up --refresh
  5. 配置 webhook 通知到 Slack/飞书

在出行平台,我们设置了每天凌晨 2 点漂移检测,检测到漂移后告警到飞书群但不自动修复(生产环境不敢自动修复,怕误操作)。告警里附带 diff 摘要,值班 SRE 早上上班后人工确认是否需要修复。

自建后端的漂移检测(不用 Pulumi Cloud):

#!/bin/bash
# 自建漂移检测脚本,放到 crontab 每天 2:00 执行
STACK="production"
WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/xxx"

# 刷新状态
pulumi refresh --stack $STACK --yes 2>&1 > /tmp/drift-refresh.log

# 检查是否有漂移
DIFF=$(pulumi preview --stack $STACK --diff 2>&1)

if echo "$DIFF" | grep -q "changes"; then
    # 有漂移,发送告警
    MESSAGE="⚠️ 检测到基础设施漂移\nStack: $STACK\n\nDiff 摘要:\n$(echo "$DIFF" | head -50)"
    
    curl -X POST "$WEBHOOK_URL" \
        -H "Content-Type: application/json" \
        -d "{\"msg_type\":\"text\",\"content\":{\"text\":\"$MESSAGE\"}}"
fi

架构权衡分析:Pulumi vs Terraform vs OpenTofu

迁移决策不能只看 Pulumi 的优势,得看三个选项的完整权衡。

维度TerraformOpenTofuPulumi
语言HCL(DSL)HCL(DSL)Go/Python/TS/Java/.NET/YAML/HCL
许可证BSL 1.1(非开源)MPL 2.0(开源)Apache 2.0(开源)
状态加密明文(需 Vault)明文(同 Terraform)默认加密
嵌入式 SDKAutomation API
策略即代码Sentinel(商业)OPA(外挂)Pulumi Policies(开源)
AI 代码生成HCL token 少但不可部署率高同 Terraform可部署率高(4/5 vs 0/5)
社区规模最大成长中(Terraform 分叉)中等但增长快
provider 数量最多(Terraform Registry)同 Terraform300+(可桥接 Terraform provider)
学习曲线HCL 简单同 Terraform需要会编程语言

AI 代码生成:一个被忽视的差异

2026 年 AI 辅助编程成为标配,IaC 工具的 AI 友好性开始影响选型。Pulumi 官方做了一组对比测试:用 Claude 3.5 Opus 和 Codex 分别对等量的 HCL 和 Pulumi TypeScript 代码做重构。

结果很有意思:

  • HCL 的 token 效率更高(输出 token 少 21-33%)
  • 但 Codex + Terraform 重构后的代码 0/5 可部署(5 次全部无法通过 plan
  • Codex + Pulumi 重构后的代码 4/5 可部署
  • Opus + Pulumi 5/5 可部署,零修复

HCL 在 token 层面更省,但 LLM 对 HCL 的架构正确性把握不如通用编程语言。原因可能是:训练语料中 Python/TypeScript/Go 的量远超 HCL,AI 对通用语言的"语法正确 + 架构意图"两方面都更熟悉。

这不意味着你应该因为 AI 友好性就迁移。但如果你的团队已经在用 AI 编程工具,Pulumi 的 AI 辅助体验确实更好。

什么时候选 Terraform

  • 团队没有编程语言背景,HCL 更容易上手
  • 已有大量 HCL 代码且运行稳定,没有迁移动力
  • 依赖 HCP Terraform 的 Sentinel 策略、run tasks 等商业特性
  • provider 覆盖优势——某些小众 provider 只在 Terraform Registry 有

什么时候选 OpenTofu

  • 你担心 Terraform 的 BSL 许可证(2023 年 HashiCorp 从 MPL 换成了 BSL)
  • 团队熟悉 HCL 且不想学编程语言
  • 想要开源 IaC 工具但 Terraform 不再开源

什么时候选 Pulumi

  • 团队有 Go/Python/TypeScript 背景,HCL 限制了表达能力
  • 需要嵌入式 SDK(Automation API)构建自助平台或临时环境
  • IaC 代码超过 1000 行,HCL 模块管理开始失控
  • 需要状态文件默认加密(合规要求)
  • 想用标准测试框架对 IaC 做单元测试

我的综合建议

如果你从零开始且团队有编程背景,直接上 Pulumi。HCL 的"简单"在小规模时是优势,大规模时是负债。与其等到 3000 行 HCL 烂掉再迁移,不如一开始就用编程语言。

如果你已经有大量 Terraform 代码,不要急着迁移。先用 Pulumi HCL 兼容模式跑现有配置,同时用 Pulumi 写新资源。等团队适应了 Pulumi 的工具链,再渐进式迁移老代码。

如果你只是对 BSL 许可证不满,不想学编程语言,直接用 OpenTofu。它是 Terraform 的开源分叉,API 兼容,迁移成本几乎为零。

总结

从 Terraform 迁移到 Pulumi,不是"换个工具"这么简单。它改变了 IaC 的工程范式——从"写配置文件"变成"写程序"。这个改变带来的是测试、类型安全、代码复用、嵌入式部署的全部能力,代价是运行时复杂度增加和确定性管理的新责任。

5 个核心决策的要点回顾:

  1. 语言选型:看团队主力,Go 适合运维团队,TypeScript 适合全栈团队,Python 适合快速迭代
  2. 状态后端:小团队用 Pulumi Cloud 免费版,大团队或有合规要求自建 S3 + DynamoDB
  3. 迁移策略:渐进式迁移,按资源生命周期切分,不要一刀切
  4. CI/CD 集成:Automation API 是 Pulumi 的杀手级特性,适合构建自助平台
  5. 策略门禁:Pulumi Policies 开源且开箱即用,比 Sentinel 的商业依赖更灵活

5 个踩坑的核心教训:

  1. Plan 非确定性:不要在 Pulumi 程序里调外部 API,用 Stack Config 缓存数据源
  2. 代码审查翻转:小规模下 HCL 更易读,大规模下编程语言可重构才是优势
  3. CI/CD 运行时依赖:Pulumi 比 Terraform 多一层语言运行时,需要精简 Docker 镜像
  4. Secrets 处理:Pulumi 默认加密但需要显式标记 secret,迁移时别漏
  5. 模块迁移pulumi convert 自动转换质量约 70-80%,核心模块建议手动重写

最后一个观点:IaC 工具的选择不是技术问题,是组织问题。你的团队会什么语言、有多少人、代码规模多大、有没有自助平台需求——这些才是决定性因素。工具只是工具,选错了能换,组织决策错了才是真正的技术债。

参考资料与致谢

本文在撰写过程中参考了以下资料,感谢原作者的贡献:

  1. Pulumi vs. Terraform — Pulumi 官方文档,提供了 Pulumi 和 Terraform 的详细功能对比、真实案例数据和迁移路径说明
  2. How Pulumi IaC Works — Pulumi 官方文档,介绍了语言主机、部署引擎和资源提供者的架构模型
  3. Token Efficiency vs Cognitive Efficiency: Choosing IaC for AI Agents — Pulumi Blog,提供了 AI 代码生成在 HCL 和 Pulumi 之间的可部署性对比数据(4/5 vs 0/5)
  4. Top 10 IaC Tools for DevOps in 2026 — DEV Community,提供了 2026 年 IaC 工具的全景对比,包括 Terraform、Pulumi、OpenTofu 的定位分析
  5. Drift detection and remediation — Pulumi 官方文档,介绍了定时漂移检测和自动修复的配置方法
  6. Pulumi 深度实战 — 程序员茄子,提供了 Pulumi 引擎工作原理和 Go/TypeScript/Python 实现的工程视角分析
  7. Terraform与Pulumi在DigitalOcean场景下的分工实践 — CSDN,从实际交付生命周期角度分析了 Terraform 和 Pulumi 的分工策略
  8. 基础设施即代码:Terraform、Pulumi 与 GitOps — 土法炼钢,提供了 Pulumi 执行模型与 Terraform 的本质差异分析,包括 Plan 不确定性和代码审查难度对比
  9. Troubleshooting Pulumi in CI/CD — Pulumi 官方文档,提供了 CI/CD 中 Pulumi 管道失败的常见类别和排查方法