Pulumi 深度实战:当基础设施即代码终于学会「说人话」——从 HCL 方言困境、资源图到自动化 API 与策略即代码的生产级完全指南(2026)
如果你写过两年以上的 Terraform,大概率经历过这样的时刻:想给团队封装一个「标准Web服务」抽象,结果只能靠
module+ 一堆count/for_each+locals拼出一套「伪函数」;想做一点点条件逻辑,HCL 的try()和三元表达式把可读性啃得千疮百孔;想给基础设施写单元测试,发现根本没有趁手的工具;想把一个环境开通能力嵌进后端服务,让产品经理自己点按钮拉起一套沙箱,HCL 直接劝退。这不是你的问题,是 DSL(领域特定语言)的天花板。而 Pulumi 给出的答案是最朴素也最激进的那一个:别再发明方言了,直接用你已经会、IDE 已经懂、编译器已经帮你查过类型的通用编程语言去写基础设施。
本文从工程视角彻底讲透 Pulumi:它到底解决了什么痛点、引擎在背后干了什么、如何用 Go / TypeScript / Python 写出可复用、可测试、可嵌入应用的生产级基础设施代码,以及如何把它落到 CI/CD、策略红线与大规模团队的真实拓扑里。
一、背景:IaC 的三次范式跃迁
1.1 从「脚本地狱」到「声明式」
基础设施即代码(Infrastructure as Code,IaC)的第一次跃迁,是把手工敲 aws cli / kubectl 的脚本,换成声明式配置。你描述「期望状态」,工具负责把现实对齐到期望状态。这一步的价值怎么强调都不过分:可版本化、可 Review、可回滚。
但声明式本身不承诺「好写」。Bash/Ansible 那种过程式脚本的问题是「顺序即逻辑、副作用满天飞」;而第一代声明式工具(CloudFormation、Terraform 早期)的问题是:配置语言太弱,复杂的真实世界逻辑(条件、循环、抽象、复用)被强行塞进一个本不擅长表达它们的格式里。
1.2 Terraform 许可证地震与 OpenTofu 的诞生
2023 年 8 月,HashiCorp 把 Terraform 从 Mozilla Public License(MPL)改为 Business Source License(BSL 1.1),意味着它不再是 OSI 认可的开源协议,商业竞争场景受到限制。社区的反应非常迅速:在 Linux 基金会牵头下,OpenTofu 作为 Terraform 的社区分支于 2023 年 11 月 GA,承诺永久开源(MPL)。
这件事的深远影响不是「多了一个 Terraform 分叉」,而是让大量团队第一次认真问了一个问题:我的 IaC 工具,是不是把我锁死在了一个供应商的节奏和许可证哲学里?
Pulumi 从第一天起就是 Apache 2.0 协议,状态后端虽然有一个商业化的 Pulumi Cloud,但你完全可以用自托管后端、甚至 Git-backed 状态,不受制于任何人的商业决策。在「许可证风险」这个维度上,Pulumi 天然站在开源一边。
1.3 HCL 在大型组织里开始吃力
HCL(HashiCorp Configuration Language)设计得很克制:它想让人人都看得懂。但克制是有代价的。当基础设施规模膨胀到「几十个微服务、跨多个账号、需要统一的安全基线、需要为不同团队提供差异化抽象」时,HCL 会暴露三个典型痛点:
- 抽象能力贫弱。你只能用
module做一层薄封装,无法用「类」「函数」「泛型」「接口」去表达领域的层级结构。结果是大量 copy-paste 的module调用,参数透传一长串。 - 没有真正的类型系统。
variable的type约束很弱,跨模块传递复杂结构时,编译器帮不了你,错误要等到plan阶段甚至apply阶段才暴露。 - 测试与复用困难。你想给一段基础设施逻辑写单元测试?HCL 没有原生的测试运行时。你想把它当成一个库发布给别的团队
npm install?做不到——HCL 模块不是「包」。
Pulumi 的核心主张正是:用通用语言(TypeScript、Python、Go、.NET、Java)写基础设施,上述问题全部回归到语言本身已经解决的领域。类型检查、抽象、测试、包管理、IDE 自动补全,全部原地复用。
二、核心概念:把「声明式」重新发明一遍
很多人第一次看 Pulumi 会困惑:它还是声明式吗?还是变回过程式脚本了?答案是:它用过程式语言的写法,表达声明式的语义。理解下面几个概念是关键。
2.1 Project / Stack / Resource / ComponentResource
- Project:一个 Pulumi 项目,由
Pulumi.yaml描述name和runtime(如nodejs、python、go、dotnet)。它对应「一段基础设施程序」。 - Stack:同一个 Project 的一个部署实例,相当于一套环境(dev / staging / production)。每个 Stack 有独立的 state 和独立的配置。
- Resource:任何被 Pulumi 管理的基础设施对象(一个 S3 Bucket、一条 DNS 记录、一个 K8s Deployment)。
- ComponentResource:这是 Pulumi 区别于 Terraform module 的杀手锏。它是一个「逻辑容器」,把一组 Resource 聚合成一个更高层的概念(比如
StaticSite、KubernetesService)。它是对真实云资源的「函数封装」——你可以给它传参、做内部逻辑、返回输出,完全就是写一个类。
// Go:一个 ComponentResource 就是一个普通的 struct + 方法
type StaticSite struct {
pulumi.ResourceState // 嵌入以继承 ComponentResource 行为
URL pulumi.StringOutput
}
func NewStaticSite(ctx *pulumi.Context, name string, args *StaticSiteArgs, opts ...pulumi.ResourceOption) (*StaticSite, error) {
component := &StaticSite{}
err := ctx.RegisterComponentResource("acme:infra:StaticSite", name, component, opts...)
if err != nil {
return nil, err
}
// 内部创建真实资源,并把 parent 指向这个 component
bucket, err := s3.NewBucketV2(ctx, name+"-bucket", &s3.BucketV2Args{
Bucket: pulumi.String(args.BucketName),
}, pulumi.Parent(component))
if err != nil {
return nil, err
}
component.URL = bucket.Bucket
return component, nil
}
注意 pulumi.Parent(component) 这一行——它让所有子资源在状态里都挂在 StaticSite 之下,销毁、依赖、权限都跟着走。这是真正的「组合」,而不是 HCL module 那种「文本展开」。
2.2 Output<T> 与 Input<T>:部署时才知道的值
这是 Pulumi 最容易劝退新人的概念,也是它最优雅的设计之一。
云资源之间高度依赖「对方创建后才知道的值」:你创建了一个 VPC,它的 vpcId 在 apply 之前并不存在;你拿这个 vpcId 去建子网,再拿子网的 subnetId 去建路由表。在 Terraform 里,这种依赖由图自动解析;在 Pulumi 里,这种「未来才有的值」被建模成 Output<T>。
// TypeScript:Output<T> 不是普通字符串,是一个「承诺」
const bucket = new aws.s3.BucketV2("assets", { bucket: "my-app-assets" });
// 错误写法:bucket.bucket 是 Output<string>,不能直接字符串拼接
// const url = "https://" + bucket.bucket + ".s3.amazonaws.com"; // ❌ 类型错误
// 正确写法:用 .apply 在「值就绪后」做转换
const url = bucket.bucket.apply((name) => `https://${name}.s3.amazonaws.com`);
Output<T> 是只读的「计算结果」;Input<T> 是「输入」,它既能接受普通值(如 "us-east-1"),也能接受 Output<T>。几乎所有资源的参数类型都是 Input<T>,所以「普通值」和「未来值」可以无缝混用——引擎会在运行时自动建立依赖边。
2.3 状态与后端:Cloud、自托管与 Git-backed
Terraform 的 state 是本地 .tfstate 或远端 S3 + DynamoDB 锁。Pulumi 的状态后端抽象成了一层可插拔的「Backend」:
- Pulumi Cloud(saas):托管状态、历史、Web Console、RBAC、漂移检测 UI。
- 自托管(Self-hosted):企业版可以把 Pulumi Service 跑在自己的集群里,满足数据不出域的合规要求。
- 本地 / 文件 / Git:
pulumi login --local用本地文件;也可以用对象存储或 Git 仓库做状态后端。
对绝大多数中小团队,我的建议是:先用 Pulumi Cloud 的免费层把状态托管起来,把「状态锁、加密、历史」这些脏活交给它;等规模上来、合规要求明确,再切自托管或 Git-backed,迁移成本极低,因为代码一行都不用改。
2.4 Provider 与 Native Provider
Provider 是 Pulumi 与云厂商 API 之间的适配器。Pulumi 有两代 Provider:
- Bridged Provider:用 Terraform 的 Provider 通过一层 bridge 转译过来(如经典的
@pulumi/aws底层曾大量依赖 Terraform AWS Provider)。好处是覆盖广、跟得紧。 - Native Provider:直接用目标语言从云厂商 OpenAPI / SDK 生成(如
@pulumi/aws-native用 AWS Cloud Control API)。好处是更轻、更贴合云厂商原生模型、发布更快。
实际选型上,通用资源用 bridged(覆盖全),追求原生一致性或新特性用 native。两者可以混用,Pulumi 不限制。
2.5 与 Terraform 心智模型对照
| 维度 | Terraform (HCL) | Pulumi (通用语言) |
|---|---|---|
| 抽象单元 | module(文本展开) | ComponentResource(真正的类/函数) |
| 类型安全 | 弱,plan 期才发现 | 编译期/类型检查期即可发现 |
| 复用分发 | Git submodule / registry | npm / pip / go mod 等原生包管理 |
| 单元测试 | 无原生运行时 | 有(mock provider + pulumi test) |
| 嵌入应用 | 基本不可行 | Automation API 原生支持 |
| 状态后端 | S3+DynamoDB 等 | 可插拔(Cloud/自托管/Git/本地) |
| 许可证 | BSL(Terraform)/ MPL(OpenTofu) | Apache 2.0 |
三、架构分析:引擎到底在做什么
理解 Pulumi 不是「脚本按顺序执行」,是写好它的前提。它的运行时是一个多进程协作模型。
3.1 三进程模型:语言宿主 + 引擎 + Provider
┌──────────────────┐ gRPC ┌──────────────────┐ gRPC/HTTP ┌─────────────┐
│ Language Host │ ──────────► │ Pulumi Engine │ ────────────► │ Providers │ ──► 云厂商 API
│ (你的 Go/TS/Py) │ ◄────────── │ (核心协调器) │ ◄──────────── │ (aws/k8s..) │
└──────────────────┘ 事件流 └──────────────────┘ 资源状态 └─────────────┘
│
▼
状态后端 (State)
- Language Host:运行你的程序。你的代码里每调用一次
new aws.s3.BucketV2(...),语言宿主并不直接调 AWS,而是通过 gRPC 向引擎「注册」一个资源请求。 - Pulumi Engine:核心协调器。它收集所有资源注册请求,构建依赖图,按拓扑顺序调用 Provider 去「实际创建/更新/删除」,并把结果写回状态。
- Provider:真正与云厂商 API 对话的插件。
关键点:你的程序跑完(register 完所有资源)后,真正的变更才由引擎按图执行。你在 main 函数里写的「顺序」只决定依赖图的「边」,不决定执行的「时刻」。这就是为什么 Pulumi 是声明式的——你描述的是「有哪些资源、它们之间什么依赖」,引擎决定「怎么落地」。
3.2 资源图(DAG)的构建与并行化
引擎收到的注册事件带有隐式依赖:
- A 的构造参数引用了 B 的
Output→ A 依赖 B; - 显式
dependsOn/parent→ 额外边。
引擎据此构建一个有向无环图(DAG),然后拓扑排序 + 并行执行:没有依赖关系的资源会被同时下发。这就是为什么一个 200 个资源的 stack,up 时可以几秒到几十秒完成,而不是 200 次串行 API 调用。
# 控制并行度(默认会根据依赖图尽量并行)
pulumi up --parallel 20
3.3 期望状态协调(Reconciliation)与 Diff 算法
每次 up,引擎都会:
- 从状态后端读取「上一次的实际状态」;
- 用当前程序算出「期望状态」;
- 对每一个资源做 diff:哪些属性变了?能否原地更新?必须替换(recreate)?
Pulumi 的 diff 是基于 schema 的精细 diff——它能区分「可更新字段」和「不可变字段(变更即重建)」。例如改 S3 Bucket 的 bucket 名称(不可变),引擎会标记为 replace;改 tags,则标记为 update。这个 diff 结果会在 preview 里清晰展示,包括 +-~ 符号和具体的字段级变化。
3.4 Secret 的加密链路
Pulumi 的 Secret 不是「明文存 state,靠权限控访问」,而是默认加密写入状态。链路是:
- 你用
pulumi config set --secret DB_PASSWORD xxx写入的密钥,会被一个 encryption key 加密后再进 state; - 这个 key 可以来自 Pulumi Cloud 托管的 KMS,也可以是你自己的 KMS / age / passphrase;
- 读取时只有拥有 key 的上下文才能解密。
# 写密钥(自动加密进 state)
pulumi config set --secret aws:accessKey AKIA......
# 在代码里直接当普通 Input 用,引擎负责解密后传给 Provider
更进一步,Pulumi ESC(Environments, Secrets, and Configuration) 把「环境 + 密钥 + 配置」本身也变成了代码,可以在多个 stack / 多个项目间组合复用,而不是在每个 stack 里重复 config set。
# ESC 环境定义(myorg/prod-env)
values:
environmentVariables:
AWS_REGION: us-east-1
aws:
region: ${environmentVariables.AWS_REGION}
secrets:
DB_PASSWORD:
fn::secret: ENC[AES256_GCM,data:xxxx,iv:xxxx,tag:xxxx,type:str]
然后在 Pulumi.yaml 里引用:environment: myorg/prod-env。一次定义,处处可用,密钥永不落本地明文。
四、代码实战
光讲概念没意思,直接上三种语言的真实可运行代码。
4.1 Go:定义一个生产级静态站点栈(S3 + CloudFront + OAC)
下面这段用 Go 定义了一个「关闭公开 ACL、通过 CloudFront + OAC 代理访问」的静态站点,并随手把安全基线(公开访问阻断)做了进去。
package main
import (
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"github.com/pulumi/pulumi-aws/sdk/v6/go/aws/s3"
"github.com/pulumi/pulumi-aws/sdk/v6/go/aws/cloudfront"
)
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
// 1) 存储桶
bucket, err := s3.NewBucketV2(ctx, "site-bucket", &s3.BucketV2Args{
Bucket: pulumi.String("my-app-static-site-2026"),
Tags: pulumi.StringMap{"Owner": pulumi.String("platform-team")},
})
if err != nil {
return err
}
// 2) 彻底关闭公开访问(安全基线)
_, err = s3.NewBucketPublicAccessBlock(ctx, "site-pab", &s3.BucketPublicAccessBlockArgs{
Bucket: bucket.ID(),
BlockPublicAcls: pulumi.Bool(true),
IgnorePublicAcls: pulumi.Bool(true),
BlockPublicPolicy: pulumi.Bool(true),
RestrictPublicBuckets: pulumi.Bool(true),
})
if err != nil {
return err
}
// 3) OAC:让 CloudFront 用 sigv4 签名访问私有桶
oac, err := cloudfront.NewOriginAccessControl(ctx, "site-oac", &cloudfront.OriginAccessControlArgs{
OriginAccessControlConfig: cloudfront.OriginAccessControlConfigArgs{
Name: pulumi.String("site-oac"),
OriginAccessControlOriginType: pulumi.String("s3"),
SigningBehavior: pulumi.String("always"),
SigningProtocol: pulumi.String("sigv4"),
},
})
if err != nil {
return err
}
// 4) 分配(CDN)
dist, err := cloudfront.NewDistribution(ctx, "site-cdn", &cloudfront.DistributionArgs{
Enabled: pulumi.Bool(true),
DefaultRootObject: pulumi.String("index.html"),
Origins: cloudfront.DistributionOriginArray{
&cloudfront.DistributionOriginArgs{
DomainName: bucket.BucketRegionalDomainName,
OriginId: pulumi.String("s3-origin"),
OriginAccessControlId: oac.ID(),
},
},
DefaultCacheBehaviors: cloudfront.DistributionDefaultCacheBehaviorArray{
&cloudfront.DistributionDefaultCacheBehaviorArgs{
TargetOriginId: pulumi.String("s3-origin"),
ViewerProtocolPolicy: pulumi.String("redirect-to-https"),
AllowedMethods: pulumi.StringArray{pulumi.String("GET"), pulumi.String("HEAD")},
CachedMethods: pulumi.StringArray{pulumi.String("GET"), pulumi.String("HEAD")},
Compress: pulumi.Bool(true),
},
},
})
if err != nil {
return err
}
ctx.Export("cdnDomain", dist.DomainName)
ctx.Export("bucketName", bucket.Bucket)
return nil
})
}
注意:这里没有任何「手动编排顺序」的代码。你只是声明了 4 个资源,引擎自己算出了「桶 → 公开阻断 → OAC → 分配」的依赖与并行关系。
4.2 TypeScript:用 ComponentResource 做可复用抽象
如果全公司都要建静态站点,你会希望把它封装成一个「类」,传个名字就能用。这正是 ComponentResource 的主场。
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
export interface StaticSiteArgs {
bucketName: string;
indexDocument?: string;
}
export class StaticSite extends pulumi.ComponentResource {
public readonly url: pulumi.Output<string>;
constructor(name: string, args: StaticSiteArgs, opts?: pulumi.ComponentResourceOptions) {
super("acme:infra:StaticSite", name, {}, opts);
const bucket = new aws.s3.BucketV2(`${name}-bucket`, {
bucket: args.bucketName,
tags: { Owner: "platform-team" },
}, { parent: this });
const oac = new aws.cloudfront.OriginAccessControl(`${name}-oac`, {
name: `${name}-oac`,
originAccessControlOriginType: "s3",
signingBehavior: "always",
signingProtocol: "sigv4",
}, { parent: this });
const dist = new aws.cloudfront.Distribution(`${name}-cdn`, {
enabled: true,
defaultRootObject: args.indexDocument ?? "index.html",
origins: [{
domainName: bucket.bucketRegionalDomainName,
originId: "s3-origin",
originAccessControlId: oac.id,
}],
defaultCacheBehaviors: [{
targetOriginId: "s3-origin",
viewerProtocolPolicy: "redirect-to-https",
allowedMethods: ["GET", "HEAD"],
cachedMethods: ["GET", "HEAD"],
compress: true,
}],
}, { parent: this });
this.url = dist.domainName;
this.registerOutputs({ url: this.url });
}
}
// 使用方:一行搞定一个站点
const docs = new StaticSite("docs", { bucketName: "acme-docs-site" });
export const docsUrl = docs.url;
这就是和 HCL module 的本质区别:调用方拿到的 docs.url 是一个有类型、可传到别处继续组合的输出,而不是一段需要在 output.tf 里手动 value = module.docs.cdn_domain 拼接的弱类型字符串。
4.3 Python + Automation API:把 Pulumi 嵌进你的应用
这是 Pulumi 最被低估的能力。Automation API 让你用代码直接驱动「preview / up / destroy」,而不必靠 shell 调用 CLI。典型场景:一个内部平台,产品经理在后台点「开通测试环境」,后端服务用 Automation API 拉起一套隔离的云资源,返回访问地址。
import pulumi
from pulumi import automation as auto
import pulumi_aws as aws
def build_program(environment: str):
def program():
bucket = aws.s3.BucketV2("env-bucket",
bucket=f"myapp-{environment}",
tags={"Owner": "platform-team"})
pulumi.export("bucket_name", bucket.bucket)
return program
def provision_environment(environment: str):
stack = auto.create_or_select_stack(
stack_name=environment,
project_name="self-service",
program=build_program(environment),
)
stack.set_config("aws:region", auto.ConfigValue(value="us-east-1"))
# 直接 up,结果结构化返回
up = stack.up(on_output=lambda line: print(line)) # 实时日志
return {
"bucket": up.outputs["bucket_name"].value,
"summary": up.summary.resource_changes, # 这次变更了多少资源
}
if __name__ == "__main__":
print(provision_environment("staging"))
这段代码可以原样跑在一个 FastAPI / Flask 服务里。基础设施不再是「运维的脚本」,而是「产品功能的一部分」。这种「基础设施即产品能力」的范式,正是平台工程(Platform Engineering)的核心。
4.4 CrossGuard:策略即代码守住红线
光有抽象还不够,大团队必须有人守红线:S3 不能公开、安全组不能 0.0.0.0/0 全开、所有资源必须带 Owner 标签。Pulumi 用 CrossGuard 把「策略」写成代码,在 up 前自动校验,违规直接阻断。
// policy/index.ts —— 用 @pulumi/policy 写策略
import { validateStack } from "@pulumi/policy";
import * as aws from "@pulumi/aws";
validateStack("require-owner-tag", (args, reportViolation) => {
for (const r of args.resources) {
if (r.type === "aws:s3/bucketV2:BucketV2") {
const tags = (r.props["tags"] as Record<string, string>) || {};
if (!tags["Owner"]) {
reportViolation("所有 S3 Bucket 必须带 Owner 标签", r.urn);
}
}
// 顺便拦掉公开的 S3 ACL
if (r.type === "aws:s3/bucketAclV2:BucketAclV2") {
const acl = r.props["acl"];
if (acl === "public-read" || acl === "public-read-write") {
reportViolation("禁止公开 ACL", r.urn);
}
}
}
});
部署前 pulumi up 会自动加载策略包,任何违规都会以 error 级别阻断发布——比「靠 Review 人肉看」可靠得多。
4.5 测试:给基础设施写单元测试
Pulumi 支持 pulumi test,用 mock provider 在不真正碰云的情况下验证你的资源逻辑。下面是用 Go 写的单元测试骨架:
func TestStaticSite_CreatesBucket(t *testing.T) {
// 用 WithMocks 注册假的 Provider,断言资源被按预期注册
err := pulumi.RunErr(func(ctx *pulumi.Context) error {
site, err := NewStaticSite(ctx, "test", &StaticSiteArgs{BucketName: "x"})
if err != nil {
return err
}
// 断言输出非空(真实测试里可进一步断言资源的 props)
if site.URL == nil {
t.Fatal("expected URL output to be set")
}
return nil
}, pulumi.WithMocks("proj", "stack", mocks)) // mocks 实现 resource/read 的假行为
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
虽然 mock 写出来有点样板代码,但它换来的是:基础设施逻辑的回归测试可以进 CI,PR 里改了一行 ComponentResource,立刻有测试告诉你有没有改坏别的资源。这是 HCL 时代几乎不可能低成本的做到的事。
五、性能优化与生产实践
5.1 并行度、refresh 成本与 --target 手术刀
- 并行度:
pulumi up --parallel N控制同时下发的资源数。依赖链末端的独立资源会被并行打出去。默认已经尽量并行,超大 stack 可显式调大。 - refresh 是贵操作:
pulumi up默认会先refresh(拉取云上真实状态做 diff),资源多时会很慢。CI 里可以用pulumi up --skip-refresh跳过,只信本地 state;但定期(如每天一次)跑一次带 refresh 的preview来发现漂移。 - --target 手术刀:只想改一个资源及其依赖?
pulumi up --target <urn> --target-dependents,只动相关子树,避免全量 diff 的漫长等待。
# 只更新某个特定资源及其依赖者
pulumi up --target 'urn:pulumi:prod::app::aws:s3/bucketV2:BucketV2::site-bucket' --target-dependents
5.2 漂移检测与 protect
云上资源被「手改」是常态(有人直接在控制台点了下)。Pulumi 通过 pulumi refresh 把真实状态拉回与 state 对齐,再用 pulumi preview --refresh 看「代码期望」和「现实」差在哪。
对于「绝对不能误删」的资源(生产数据库、核心桶),用 protect: true:
new aws.s3.BucketV2("prod-db-backup", {
bucket: "prod-db-backup-2026",
}, { protect: true }); // 任何 up/destroy 都不会动它,除非先手动去掉 protect
protect 会在误删前强制拦截,是生产环境的保命符。
5.3 从 Terraform 迁移:import 与 tf2pulumi
已有 Terraform 资产不必推倒重来:
# 把已有资源导入 Pulumi 管理(不重建,只接管)
pulumi import aws:s3:BucketV2 legacy-bucket my-legacy-bucket-name
# 把 .tf 代码批量转成 Pulumi(TypeScript/Python/Go)
tf2pulumi --target-language typescript
tf2pulumi 不是 100% 无损,复杂 module / 动态表达式需要人工校对,但能覆盖 70%~80% 的搬运工作,剩下的靠 import + 手工对齐 state。
5.4 大型项目的项目拓扑
两种主流组织方式:
- Poly-repo(多项目):网络层一个 project、数据层一个 project、每个服务一个 project。优点:权限/状态边界清晰、爆炸半径小;缺点:跨项目引用要靠 stack reference。
- Mono-repo(单项目多 stack):一个 Pulumi 项目里用多个 stack 区分环境/服务。优点:复用 ComponentResource 极方便;缺点:单 state 文件可能很大,误操作影响面宽。
我的经验法则:基础设施「地基」(账号、网络、IAM、共享存储)用独立 poly-repo + stack reference 对外暴露输出;业务服务各自 poly-repo 引用地基输出。既隔离爆炸半径,又不重复造轮子。
// 在业务项目里引用地基项目的输出
const infra = pulumi.StackReference("acme/foundation/production")
val vpcId = infra.GetStringOutput("vpcId")
5.5 CI/CD 与状态锁定
Pulumi 后端天然提供状态锁(同一 stack 同一时刻只允许一个 up),CI 里直接:
pulumi login --cloud-url <后端>
pulumi stack select production
pulumi preview --stack production # PR 阶段只预览,贴 diff 到 PR 评论
pulumi up --stack production --yes # 合并后自动 apply
配合 PR 预览 + 审批,基础设施的发布流程和标准应用代码完全一致。
六、总结展望:通用语言是 IaC 的终局吗
我的判断是:在「复杂、多团队、需要抽象与测试」的场景里,通用语言会是终局;在「简单、一次性、只想快速拉起」的场景里,DSL 仍有价值。两者不是你死我活,而是分层共存——这也是为什么 Pulumi 与 OpenTofu / Terraform 会长期并存:前者吃「工程复杂度」,后者吃「上手速度」。
几个值得关注的趋势:
- 平台工程把 IaC 推向「产品化」。Automation API 让基础设施成为后端服务的能力,开发者「自助开通、自助销毁」,平台团队退居「能力供给方」。这会是未来三年企业落地的主战场。
- ESC 与环境即代码。密钥、配置、环境不再是散落在各 stack 的
config set,而是可组合、可继承、可审计的代码资产。 - 策略即代码的常态化。CrossGuard 这类能力会从「高级团队的可选项」变成「合规的必选项」,和 CI 门禁深度绑定。
- AI 辅助生成。用自然语言描述「我要一个带 WAF 的 HTTPS 站点」,模型直接产出 Pulumi 代码——而通用语言因为「有编译器、有类型、能跑测试」,比 HCL 更适合做 AI 生成的「可校验产物」。
回到开头的那个问题:HCL 没做错什么,它只是把 IaC 带到了一个高度,然后触到了 DSL 的天花板。Pulumi 做的,是把天花板捅破——让写基础设施这件事,重新变成「写代码」这件你本就擅长的事。当你能用 for 循环、class 封装、pytest/go test 去管理云,你会发现:原来基础设施也可以有 DRY、有抽象、有回归测试,也可以优雅。
如果你正在被 Terraform 的 module 地狱折磨,或者被「想给基础设施写个测试」这件事劝退,不妨用 pulumi new aws-go 起一个项目,把本文的静态站点例子跑一遍。十分钟后你会回来谢我。
本文所有示例均可在 Pulumi 3.x 系列最新版中运行,Provider 以 @pulumi/aws v6 系列为准。实际部署前请结合自身云账号权限与合规要求调整。