Pulumi:用通用语言把资源变更变成可审查的状态机
一次看似普通的网关改名,在预览中却出现“创建新网关、删除旧网关”;值班同学担心中断,把删除动作从流水线里临时注释掉。几周后,云控制台里同时存在新旧两套资源,账单上涨,告警仍指向旧实例,而 Pulumi 的 stack 只认识其中一套。另一次事故更隐蔽:开发者把数据库密码标成配置 secret,便以为状态文件里再无敏感信息;导出的 checkpoint 虽看不到密码明文,却仍包含资源 ID、私网地址、租户路径和依赖关系,随后被当作普通构建产物长期保留。
这两类事故说明,Pulumi 不是“用 TypeScript 调云 API”的脚本框架。程序每次运行会重新声明期望资源,Pulumi engine 先把声明与 stack checkpoint 比较,形成依赖图和操作计划,再由 provider 完成创建、读取、更新和删除。普通 preview 与 up 不会在每次运行前自动读取每一个真实对象;只有显式 refresh、preview --refresh、up --refresh 或具体 provider 操作触发的读取,才会把相应远端事实带回判断过程。通用语言提供抽象、循环、测试和包生态,也把语言运行时、依赖安装、第三方包执行和非确定性代码一起带进了变更控制面。真正需要治理的是一条状态机:代码、配置、凭据、provider、真实资源和 checkpoint 必须能持续收敛。
六个对象决定一次更新会发生什么
Project 是一个 Pulumi 程序的工程边界,根目录中的 Pulumi.yaml 至少声明项目名和语言运行时。Stack 是该程序的一份独立、可配置实例,例如同一代码可以有 dev、staging 和 prod 三个 stack;每个 stack 有独立配置、secret provider 和状态。stack 不是 Git 分支的别名:分支可以消失,stack 背后却可能仍有计费资源和长期状态。
Resource 是 engine 跟踪的生命周期对象。它有逻辑名、类型、URN、输入、输出、父子关系、依赖和 provider;云端对象通常另有 provider 返回的物理 ID。修改逻辑名会改变资源身份,若没有 aliases,engine 可能把它解释为“创建一个新对象,再删除旧对象”。ComponentResource 用来封装一组子资源,CustomResource 则通过 provider 对外部系统执行 CRUD。
Provider 是资源 SDK 与外部 API 之间的执行插件。Pulumi 的 provider 说明把它拆为语言侧 SDK 和实际通信的 provider 可执行程序。默认 provider 从 stack 配置、环境变量或约定文件取配置,方便但容易误用当前终端的账号和区域;显式 provider 本身也是资源,可以把账号、区域或集群连接明确传给目标资源。多账号、多区域和生产 stack 应优先使用显式 provider,并考虑禁用默认 provider,避免漏写 provider 时落到操作者本机的默认身份。
Input<T> 表示资源参数可以接收普通值、Promise 或其他资源的 Output<T>;Output<T> 同时携带最终值、依赖关系、是否已知和 secret 标记。预览阶段,云端分配的 ID 往往未知,因此不能把 Output 当同步字符串读取。把一个资源的 Output 直接传给另一个资源的 Input,engine 就能推导隐式依赖;只有依赖不能从数据流表达时,才补 dependsOn。在 apply 回调里创建资源或执行副作用会让依赖与预览变得难以推理,应把 apply 用于纯值转换。
Checkpoint 是 stack 在 backend 中保存的权威记录,包含资源身份、依赖、输入输出、pending 操作和 secret 密文等元数据。它让下一次运行知道“哪个声明对应哪个真实对象”。丢失 checkpoint 不等于云资源消失,而是管理关系丢失;盲目重建 stack 可能重复创建,手工编辑 checkpoint 则可能破坏依赖和恢复能力。
preview 不是调用外部 API 后的静态 diff,而是执行语言程序、解析包与插件、读取配置、注册资源并让 provider 计算差异。程序依赖当前时间、随机数、未锁定包或外部 HTTP 返回值时,同一提交可能得到不同计划。因此 IaC 仓库同样需要 lockfile、受信包源、运行时基线和可重复构建。
在本机建立不碰云账号的完整闭环
先确认 Node.js、npm 和 Pulumi CLI 都能从当前终端找到。Pulumi 的安装页给出了 Homebrew、安装脚本、Windows Package Manager、Chocolatey、MSI 和手工二进制入口;团队模板应锁定已验证的 CLI 版本区间,而不是把网页显示的“最新版本”写死。安装后打开新终端执行:
pulumi version
node --version
npm --version下面的实验使用本地 filesystem backend 和 @pulumi/command provider,只在项目目录创建 runtime/manifest.json。它仍会下载 npm 包和 provider 插件,企业代理、私有 npm 镜像、CA 和出站白名单必须能覆盖这些入口。创建一个空目录,并放入以下文件:
pulumi-local-lab/
├── Pulumi.yaml
├── package.json
├── index.js
└── scripts/
├── write-manifest.mjs
└── remove-manifest.mjsPulumi.yaml 把程序交给 Node.js language host:
name: pulumi-local-lab
runtime:
name: nodejs
description: Local state and lifecycle evidence without cloud credentialspackage.json 同时固定 Pulumi SDK 和 command provider 的依赖范围。团队仓库在首次安装后还要提交 package-lock.json,CI 使用 npm ci,并由依赖更新流程审查版本变化:
{
"name": "pulumi-local-lab",
"private": true,
"main": "index.js",
"scripts": {
"check": "node --check index.js"
},
"dependencies": {
"@pulumi/command": "^1",
"@pulumi/pulumi": "^3"
}
}scripts/write-manifest.mjs 只接受受控环境变量,创建目录后原子替换目标文件,避免进程中断留下半个 JSON:
import { mkdir, rename, writeFile } from "node:fs/promises";
const message = process.env.DEMO_MESSAGE ?? "missing";
await mkdir("runtime", { recursive: true });
await writeFile(
"runtime/manifest.json.tmp",
JSON.stringify({ managedBy: "pulumi", message }, null, 2) + "\n",
"utf8",
);
await rename("runtime/manifest.json.tmp", "runtime/manifest.json");
console.log("manifest-ready");scripts/remove-manifest.mjs 把销毁动作做成幂等删除:
import { rm } from "node:fs/promises";
await rm("runtime/manifest.json", { force: true });
console.log("manifest-removed");index.js 把普通配置变成资源触发器,把 secret 作为受保护的 Output 导出,并用资源选项表达身份和删除策略:
const pulumi = require("@pulumi/pulumi");
const command = require("@pulumi/command");
const config = new pulumi.Config();
const message = config.get("message") ?? "hello";
const apiToken = config.requireSecret("apiToken");
const protect = config.getBoolean("protect") ?? false;
const retain = config.getBoolean("retain") ?? false;
const manifest = new command.local.Command(
"manifest",
{
create: "node scripts/write-manifest.mjs",
update: "node scripts/write-manifest.mjs",
delete: "node scripts/remove-manifest.mjs",
environment: { DEMO_MESSAGE: message },
triggers: [message],
logging: command.local.Logging.Stdout,
},
{
aliases: [{ name: "legacy-manifest" }],
protect,
retainOnDelete: retain,
},
);
const verify = new command.local.Command(
"verify",
{
create: "node -e \"const f=require('fs');const x=JSON.parse(f.readFileSync('runtime/manifest.json'));if(x.managedBy!=='pulumi')process.exit(12);console.log(x.message)\"",
triggers: [message, manifest.stdout],
},
{ dependsOn: [manifest] },
);
exports.manifestPath = "runtime/manifest.json";
exports.verifiedMessage = verify.stdout;
exports.tokenFingerprint = apiToken.apply((value) =>
require("node:crypto").createHash("sha256").update(value).digest("hex").slice(0, 12),
);哈希输出仍继承 secret 标记,这是 Output 的传递性,而不是因为短哈希必然安全。aliases 告诉 engine,旧逻辑名 legacy-manifest 与当前 manifest 是同一身份的演进;它不是任意资源合并工具。protect 阻止 Pulumi 删除受保护资源,retainOnDelete 则允许 Pulumi 从状态移除资源但不调用 provider 的删除动作,两者结果完全不同。
安装依赖后选择本地 backend。首次建立项目可用 npm install 生成并审查 package-lock.json,后续克隆和 CI 改用 npm ci。pulumi install 会按当前项目安装语言依赖以及 Pulumi.yaml 声明的本地 package;资源插件也会在首次 preview 或 up 时按程序依赖自动进入插件缓存。显式 backend 目录比用户主目录里的隐式本地状态更便于观察和清理:
npm install
npm run check
pulumi login "file://$PWD/.state"
pulumi whoami --verbose这里让 shell 把 $PWD 展开成绝对路径;PowerShell 对应写法是 pulumi login "file://$((Get-Location).Path.Replace('\','/'))/.state"。本例只使用发布到 npm 的 SDK,语言依赖以 lockfile 为准;项目若在 Pulumi.yaml 中声明本地或参数化 package,再运行 pulumi install 安装并生成对应 SDK。CI 应使用稳定的远端 backend,不能让不同 checkout 各自生成一份同名 stack 后误以为共享状态。初始化 stack 时使用 passphrase secret provider;示例口令仅存在当前 shell,不得提交:
export PULUMI_CONFIG_PASSPHRASE="local-lab-only"
pulumi stack init dev --secrets-provider passphrase
pulumi config set message hello
pulumi config set apiToken demo-token-not-for-any-service --secret
pulumi config set protect false
pulumi config set retain false
pulumi configPowerShell 用 $env:PULUMI_CONFIG_PASSPHRASE = "local-lab-only" 设置进程环境变量。预期 pulumi config 对 apiToken 显示 [secret]。Pulumi.dev.yaml 可以提交普通 stack 配置和密文,但 passphrase、KMS 权限或 Pulumi Cloud 访问身份必须通过受控凭据入口提供;密文文件与解密能力落在同一仓库或同一低权限共享盘,等于没有形成有效分离。
正向实验:从 preview 到无变化收敛
先只预览,不加 --yes:
pulumi preview --diff第一次预期看到两个 command:local:Command 创建,摘要类似 2 to create,但 runtime/manifest.json 此时不应存在。若预览阶段已经创建文件,说明程序求值或 transform 中藏有副作用;预览就不再安全。确认计划后执行:
pulumi up --yes
node -e "console.log(require('./runtime/manifest.json'))"
pulumi stack output
pulumi preview --expect-no-changes
pulumi plugin ls预期文件包含 managedBy: pulumi 和 message: hello,verifiedMessage 输出 hello,tokenFingerprint 显示为 secret;无变化预览应以零退出,插件清单应出现与 SDK 依赖匹配的 command resource plugin。这里的“成功”包含四层证据:语言依赖可重复安装、provider 插件可解析、目标文件达到期望、再次预览稳定。只看到 pulumi up 退出码为零,还不能排除脚本写错位置、验证资源读取旧文件或 runner 复用了未登记的插件缓存。
插件缓存是可执行供应链,不是普通下载缓存。Pulumi 通常会从 SDK 依赖推导并自动安装插件;需要离线镜像或严格校验时,可用 pulumi plugin install resource <name> <exact-version> --exact --checksum <sha256> 从受控来源预装,再保存 pulumi plugin ls、制品来源和校验结果。手工省略版本会尝试查找最新插件,不能作为可重复构建入口;缓存命中也不证明制品来源仍可信。清理缓存前先确认其他 stack 和 runner 是否依赖该版本,不要把共享 $PULUMI_HOME 当作单个项目的私有目录。
把消息改成 hello-v2,观察 Input 改动如何驱动资源更新:
pulumi config set message hello-v2
pulumi preview --diff
pulumi up --yes
node -e "console.log(require('./runtime/manifest.json').message)"
pulumi preview --expect-no-changes预览应显示 manifest 更新;message 也进入 verify.triggers,因此验证资源会重新运行。triggers 是 command provider 的输入;provider 依据输入 diff 决定更新,不是 Pulumi 监视了文件内容。真实云 provider 同理:engine 依赖 provider 的 Check、Diff、Read 和 CRUD 实现解释变化。
反向实验:故意制造漂移和受保护删除
先绕过 Pulumi 手工修改文件,模拟控制台改资源:
node -e "const fs=require('fs');const p='runtime/manifest.json';const x=require('./'+p);x.message='manual-drift';fs.writeFileSync(p,JSON.stringify(x,null,2)+'\n')"
pulumi preview --diff这里很可能仍显示无变化,因为 command provider 的 Read 并不会把任意文件内容反查成受管输入。这是重要的失败证据:preview 能否发现漂移取决于 provider 是否实现真实状态读取以及哪些字段进入状态模型,不能把所有 IaC 工具的预览宣传成全能扫描器。删除文件后执行 pulumi refresh --preview-only,也不应假定它会恢复文件或必然把 command 资源判为删除。
refresh 的语义是读取外部系统并把观测到的变化采纳进 stack 状态,CLI 文档明确提醒:若程序代码没有相应修改,后续更新仍可能再次把资源推回代码声明。先预览 refresh,再决定是否写入 checkpoint:
pulumi refresh --preview-only
pulumi refresh --yes
pulumi preview --diff对支持完整 Read 的云资源,处置顺序应是“保存 refresh 预览 → 判断控制台变化是否合法 → 合法则同步代码并 refresh,非法则由 up 恢复期望值”。直接 refresh --yes 会让 checkpoint 接受现实,不等于修复现实。
再开启删除保护:
pulumi config set protect true
pulumi up --yes
pulumi destroy --yes销毁应失败并指出受保护资源,目标文件仍在。这个反例证明 protect 是 engine 层删除门,不是云端防删锁;拥有状态编辑权限的人可以取消保护,云控制台也可能绕过 Pulumi 删除真实对象。解除保护并完成清理:
pulumi config set protect false
pulumi up --yes
pulumi destroy --yes
pulumi stack rm dev --yes
pulumi logout销毁后检查 runtime/manifest.json 不存在,再删除实验目录中的 .state、runtime 和本地 passphrase 环境变量。若把 retain 设为 true 后再销毁,checkpoint 会放弃管理但文件会保留;重新接管需要 import 或恢复状态,不能把 retainOnDelete 当作“稍后自动清理”。
Import、refresh 与 state import 是三件事
当真实资源已经存在,pulumi import <type> <name> <id> 会让 provider 按物理 ID 读取对象、把它纳入 stack,并输出对应代码。CLI 导入默认给资源设置删除保护;只有明确传入 --protect=false 才会留下未保护资源,因此接管记录要保存最终保护状态,不能假定团队模板已经覆盖它。导入指南也支持在资源选项中临时设置 import,成功后应移除该选项。导入前必须先确认账号、区域、provider 和物理 ID;错误环境的同名资源不会因为逻辑名“看起来对”就自动纠正。
pulumi import <package:index/type:Resource> existing <provider-resource-id>
pulumi preview --diff导入后的第一次预览若提出大面积更新或替换,通常说明代码参数、默认值、provider 版本或实际资源不一致。不要立即 up;先把 import 读取到的属性与代码逐项对齐,明确哪些字段由 Pulumi 管理。refresh 更新的是已受管资源的观测状态,不能代替首次接管。
pulumi stack import 接受的是 stack deployment 导出,用于恢复或迁移状态;它与“导入一个云资源”不是同一操作。backend 地址和访问凭据不在 deployment 中跟着迁移,操作者必须先冻结源 stack 更新并从源 backend 导出,再登录目标 backend 后导入。Pulumi 会在迁移时转换 backend 所需的状态表示,stack 仍沿用原 secret provider;因此目标环境还必须拥有对应 passphrase、KMS 或 Vault 解密能力。不能只复制对象存储中的一个 checkpoint JSON 就宣称迁移完成。导入前保存不可变备份和哈希,导入后核对 stack 名称、secret provider 与资源数,执行 preview --expect-no-changes,再做受控的小变更和恢复演练验证目标 backend 的读、写、锁与历史路径。
资源选项是生命周期契约,不是安全贴纸
隐式依赖来自 Output → Input 数据流,dependsOn 只补充没有数据值可传的顺序约束,例如策略附件必须等待主体建立。滥用 dependsOn 会把原本可并行的图串行化,也可能掩盖真正缺失的输入关系。
protect: true 阻止 update 中的删除和替换;若某字段修改会强制替换,保护同样会让更新失败。保护可以通过先把代码改为 protect: false 并执行一次 up,或通过受控的 pulumi state unprotect 解除。组件的保护默认传播到子资源,生产数据库、密钥和共享网络可以借此建立整棵资源树的保护,但局部例外必须显式评审。
retainOnDelete: true 让 Pulumi 在删除或替换 custom resource 时不调用 provider 的 Delete,随后把旧对象移出 state;它不是“继续管理但暂不删除”。该选项对 component resource 本身没有可保留的外部对象,但会传播给组件的子 custom resources。它适用于向其他控制面转交资源,代价是旧对象不再受当前 stack 治理,团队必须登记新 owner、物理 ID、账单归属和后续删除入口。若未来仍要由 Pulumi 删除,只能先重新 import,再把 retainOnDelete 改回 false 后执行下一次更新。
aliases 保存重命名、改 parent、改 type 或组件重构前的身份映射,避免无意替换。每次重构先看 preview 是否为 same/更新,而不是 create/delete;别把别名永久堆成无法解释的历史垃圾。跨项目、跨 stack 或复杂拆分合并仍需专门迁移设计。
transforms 能在资源注册时统一修改输入和选项,例如给组件子资源补标签或 protect。Transforms 文档说明新 API 可以作用于 custom 和 component resource,并将逐步替代旧 transformations。一个栈级保护例子如下:
pulumi.runtime.registerResourceTransform((args) => {
if (args.type === "aws:rds/instance:Instance") {
return {
props: args.props,
opts: pulumi.mergeOptions(args.opts, { protect: true }),
};
}
return undefined;
});Transform 是程序代码,可能覆盖调用方选项并改变大量资源;它必须有单元测试、类型过滤、预览证据和升级评审。不要在 transform 中访问不稳定网络接口,也不要用它静默注入高权限 provider。Policy as Code 适合表达可审计的组织规则,transform 适合做确定性默认值;两者不能互相冒充。
Backend 决定团队能否安全并发和恢复
Pulumi 支持托管的 Pulumi Cloud backend,也支持本地文件、S3、Azure Blob、Google Cloud Storage、S3 兼容对象存储和 PostgreSQL 等 DIY backend。State 与 backend 文档指出,backend 是 CLI 协调更新并读写 stack state 的 API 与存储端点。Pulumi Cloud 提供托管的事务 checkpoint、并发锁、更新历史和团队控制;DIY backend 提供数据位置与基础设施控制权,但备份、加密、访问控制、审计、锁恢复和灾难恢复由团队承担。
| 形态 | 适合的使用方式 | 必须承担的代价 |
|---|---|---|
| 本地 filesystem | 个人实验、离线原型、一次性培训 | 无天然团队共享;设备丢失、路径漂移和误删直接威胁状态 |
| Pulumi Cloud | 需要协作、历史、锁、审查与托管能力的团队 | 账号、组织权限、服务可用性、数据驻留和订阅成本需要评估 |
| 对象存储 DIY | 已有成熟云存储、KMS、版本化和审计平台的团队 | 自行保证凭据、锁、版本恢复、生命周期策略和跨区域容灾 |
| PostgreSQL DIY | 希望复用既有 PostgreSQL 存储、访问控制与备份体系 | 数据库高可用、连接凭据、容量、升级和恢复演练成为 IaC 依赖 |
| 自托管 Pulumi Cloud | 有严格网络或数据边界且能运营平台的组织 | 控制面本身的部署、升级、数据库、对象存储、监控和支持成本 |
DIY backend 不是“把 JSON 放进 bucket”这么简单。当前 DIY backend 默认提供 state locking 和 checkpoint history,其 .pulumi 目录会包含 stack、locks 和 history;团队不需要另写一套并发锁,却仍要运营锁存储的可用性、权限和异常恢复。对象存储要开启版本化、服务端加密、TLS、最小权限、访问日志和经过验证的恢复流程。生命周期规则不能把当前 checkpoint、锁和恢复历史一起按天数粗暴清理。锁文件残留时先确认是否仍有活跃更新和 provider 操作,再按 backend 文档恢复;直接删除锁可能让两个更新同时写状态。对象存储协议的 checkpoint 保证也弱于 Pulumi Cloud 的事务 API,恢复演练必须覆盖写入中断与历史版本回退,而不只是下载当前文件。
checkpoint 即使加密了 secret,仍包含大量非 secret 元数据。资源名称、ARN、IP、数据库端点、标签、租户 ID、依赖图和 provider 输出可能是敏感资产清单。pulumi stack export --show-secrets 会解密 secret,禁止在普通 CI 日志或 artifact 中运行;不带该参数的导出也应按敏感备份处理。backup owner、恢复权限和销毁权限应分离,恢复演练必须证明导入后 preview 收敛,而不是只证明文件能下载。
Secret provider 保护密文,不替代凭据生命周期
pulumi config set key value --secret 会把配置值加密,并让 secret 标记沿 Output 传播。Secrets 文档列出 Pulumi Cloud 默认密钥、passphrase、AWS KMS、Azure Key Vault、Google Cloud KMS 和 Vault Transit 等 secret provider;已有 stack 可以用 pulumi stack change-secrets-provider 重加密配置与 state 中的 secret。
选择 secret provider 时要同时验证加密与可恢复性。passphrase 适合个人和隔离实验,但共享口令轮换、离职回收和无人值守 CI 很难治理;云 KMS 或 Vault 能把解密授权交给短期身份和审计系统,却增加网络、密钥可用性与跨账号权限依赖。更换 provider 后执行无变化预览,并在隔离恢复环境证明新密钥可解密、旧密钥按计划撤销。
Secret 标记不是数据防泄漏的万能开关:程序可以在 apply 中主动 console.log 明文,provider 错误可能回显请求,外部脚本也可能把环境变量写入文件。CI 要禁止 --show-secrets,对日志和 artifact 做敏感信息扫描,provider 配置中的 token、私钥和连接串必须按文档标为 secret。对于 @pulumi/command,若 stdout 可能含密钥,应关闭 logging,并用 additionalSecretOutputs 标记输出,而不是依赖操作者记得不看日志。
从个人 stack 走向多环境团队流水线
单人模式可以使用一个本地 dev stack,但仍要提交项目文件、依赖 lockfile 和非敏感配置,定期导出受控状态备份。团队模式至少把 stack owner、backend、provider 身份、批准人和成本中心写入台账;dev、staging、prod 不共享云账号管理员凭据,也不让每个开发者在本机直接更新生产 stack。
多环境不要靠 if (stack === "prod") 堆出两个完全不同系统。共享组件表达相同拓扑,stack 配置承载容量、区域和环境差异;真正不同的架构应拆成显式组件或项目。跨 stack 引用会形成发布顺序和可用性依赖,必须记录 producer 输出契约、版本和故障降级,不能把 StackReference 当远程全局变量。
拉取请求阶段运行 pulumi preview,保存机器可读计划或托管更新链接,由 code owner 与资源 owner 审查新增、替换、删除、权限和成本影响。合并后由受保护环境执行 up,生产更新串行排队,不要取消一个正在写资源和 checkpoint 的 job。一次批准只能绑定提交 SHA、stack、provider 身份和 preview 结果;代码或配置变化后必须重新预览。
GitHub Actions 可使用 Pulumi 维护的 actions。Pulumi 的GitHub Actions 指南建议用 OIDC 将 workflow 身份交换成短期 Pulumi Cloud token,并可再通过 ESC 或云厂商原生 OIDC 获取短期云凭据,减少长期 PULUMI_ACCESS_TOKEN 和云密钥:
name: pulumi-preview
on:
pull_request:
paths: ["infra/**"]
permissions:
id-token: write
contents: read
pull-requests: write
concurrency:
group: pulumi-preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
preview:
runs-on: ubuntu-latest
environment: infra-preview
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: infra/package.json
cache: npm
cache-dependency-path: infra/package-lock.json
- run: npm ci
working-directory: infra
- uses: pulumi/auth-actions@v1
with:
organization: example-org
requested-token-type: urn:pulumi:token-type:access_token:organization
- uses: pulumi/actions@v7
with:
command: preview
stack-name: example-org/platform/dev
work-dir: infra
comment-on-pr: true
github-token: ${{ secrets.GITHUB_TOKEN }}
suppress-outputs: trueOIDC 信任策略必须约束 issuer、audience、repository、ref 或 environment 等 claims;仅仅“启用 OIDC”而接受任意仓库签发的 token,会把长期密钥风险换成宽泛联合身份风险。Pulumi Cloud 身份与云 provider 身份是两条认证链,前者允许访问 stack/backend,后者允许修改真实资源,二者都要最小权限、短生命周期和独立审计。
DIY backend 的 CI 不需要 Pulumi Cloud token,但需要 backend 读写锁权限和云 provider 权限。不要因为凭据来自同一个云角色就把两类权限混成管理员策略;能读取 checkpoint 的任务未必应该能创建 IAM,能预览资源的拉取请求也不应拥有生产删除权限。
Automation API 把引擎嵌入平台,也放大了责任
Automation API 用强类型 SDK 驱动 Pulumi CLI 和 engine,支持 local program 与 inline program,并提供 preview、up、refresh、destroy、配置和输出等生命周期方法。Automation API 指南明确指出它底层仍需要 Pulumi CLI;部分 SDK 可以安装与 SDK 匹配的 CLI,再传给 workspace。
下面的 TypeScript 骨架驱动已有 infra 目录,不把部署逻辑复制到平台代码。LocalWorkspace 会使用工作目录中的 Pulumi.yaml、stack 配置和当前 backend;它不是无状态函数:
import { LocalWorkspace } from "@pulumi/pulumi/automation";
const stack = await LocalWorkspace.createOrSelectStack({
stackName: process.env.PULUMI_STACK ?? "dev",
workDir: "infra",
});
const preview = await stack.preview({ onOutput: console.info });
if ((preview.changeSummary.delete ?? 0) > 0) {
throw new Error("delete requires an approved change ticket");
}
const result = await stack.up({ onOutput: console.info });
console.log(result.summary.result);Automation API 仍驱动 Pulumi CLI;TypeScript、Python、Go 和 .NET SDK 可以安装与 SDK 匹配的 CLI 并把得到的 command 传给 workspace,Java 入口则需要预装 CLI。平台镜像应固定 SDK、CLI、语言 runtime 和插件来源,启动时验证版本,而不是依赖宿主机 PATH 上碰巧存在的二进制。
这只是执行入口,不是完整审批系统。平台还要做调用者认证、stack 级授权、请求幂等、同 stack 并发互斥、超时与取消、日志脱敏、计划绑定、人工批准、provider 凭据注入和中断恢复。backend 的锁会让竞争者得到 concurrent update 错误,但不会替平台排队、判定前一请求是否成功,也不会自动把 HTTP 重试与原请求关联。Automation API 进程重试前必须按请求 ID 查询更新历史和真实 stack 状态;若前一次仍运行则等待,若结果未知则先 refresh/preview 和检查 provider 侧资源,不能立即再发 up。把 Automation API 暴露为 HTTP 接口时,不能让调用者自由传 stack 名、backend URL、程序路径或任意环境变量。
漂移、并发和中断要靠证据收敛
漂移有三类:真实资源被控制台或其他工具修改;代码与 stack 配置变化但尚未更新;provider 升级改变默认值或 diff 语义。定时 pulumi refresh --preview-only 可以发现 provider 能读到的第一类变化,拉取请求 preview 覆盖第二类,依赖与 provider 升级预览覆盖第三类。任何检测都不能替代 provider 的读取能力,command 反向实验正是这个边界的证据。
并发控制要以“同一 backend 中的同一 stack”为互斥键,而不是仓库或分支名。Pulumi Cloud 和 DIY backend 都有锁机制,但流水线仍应排队生产更新,避免频繁争锁和人工取消。发现冲突时先确认另一个 update 的运行 ID、发起者和 provider 侧动作;只有证明更新已终止,才处理残留锁。中断后的第一步是查看 update 历史、pending 操作和真实资源,再选择 refresh、import、继续更新或恢复 checkpoint。
常见故障可以从第一证据快速分层:
| 现象 | 第一证据 | 可能停在哪一层 | 修复后的证明 |
|---|---|---|---|
pulumi 找不到 | pulumi version、PATH | CLI 安装 | 新终端能输出版本 |
| npm 包或插件下载失败 | npm 日志、pulumi plugin ls、代理与 CA | 语言依赖或 provider 供应链 | npm ci 与 preview 在干净环境通过 |
| 预览出现未知值 | 资源 Output 与依赖图 | provider 尚未分配输出 | update 后输出已知,再次 preview 收敛 |
up 命中错误账号/区域 | pulumi stack、显式 provider、云身份查询 | 默认 provider 配置 | 目标资源身份和审计主体匹配 |
| update conflict | backend 锁、更新历史、CI 并发组 | 同 stack 并发 | 仅一条更新运行,后续 preview 收敛 |
| destroy 被拒绝 | 资源 protect、云端删除保护、IAM deny | engine、provider 或云策略 | 未降低无关保护且清理演练通过 |
| secret 解密失败 | secrets provider、KMS/Vault 审计、passphrase 注入 | 解密身份或密钥 | 隔离 runner 能读取配置且日志无明文 |
| refresh 后仍有 diff | 程序输入、provider 读回值、默认值变化 | 代码与现实仍不一致 | 合法来源确定后无变化预览 |
选型要同时计算编程能力与控制面成本
Pulumi 适合希望用 TypeScript、Python、Go、.NET、Java 或 YAML 表达基础设施,并复用类型系统、组件、测试与包管理能力的团队。它在复杂抽象、平台 API、跨资源数据流和 Automation API 场景中有优势。代价是每次变更要运行语言运行时,供应链包含 Pulumi CLI、SDK、provider 插件和语言包,代码也可能引入循环、异步、网络调用和非确定性。
Terraform/OpenTofu 的声明语言、模块和生态更适合希望限制程序表达能力、采用广泛 HCL 工作流的团队;Ansible 更偏向主机配置与过程编排,不以同样的云资源 checkpoint 模型工作。选择不应停在“团队更熟哪种语言”,还要比较 provider 覆盖与质量、backend 数据边界、策略与审查、状态迁移、托管费用、私有包、离线能力、招聘与值班经验。
成本包含四部分:被管理资源费用、Pulumi Cloud 或自托管 backend 成本、CI 运行与插件缓存成本、治理和恢复的人力成本。Preview 可以显示资源操作,却不天然给出完整账单;需要将 stack 标签、项目、环境、owner 和成本中心写进组件默认值,并用云账单验证。临时 review stack 必须有 TTL、关闭事件销毁、失败补偿任务和孤儿资源巡检。
供应链治理至少锁定语言运行时、Pulumi CLI、SDK、provider 包和 lockfile,限制 npm/PyPI/NuGet/Maven 等来源,验证下载校验和或签名能力,并审查 provider 升级产生的计划。缓存只能加速下载,不能成为唯一插件来源;离线恢复要证明从受控制品库可以重建相同运行环境。
团队治理与退出从第一份 stack 开始
每个 stack 都应有 owner、代码仓库、backend、secret provider、云身份、审批规则、成本中心、恢复目标和退出日期或长期理由。高风险资源默认 protect,但保护解除、destroy、state edit、stack export/import 和 secrets provider 变更必须有 break-glass 流程与审计。日常操作者不持有 backend 管理员和云账号管理员双重权限。
退出 Pulumi 时先冻结并发更新,保存代码、依赖锁、插件清单、stack 配置和加密 checkpoint 备份;再决定是把资源导入另一套 IaC,还是保留真实资源并解除 Pulumi 管理。对每个资源记录新控制面、owner 和物理 ID,使用 retainOnDelete 或定向 state 操作前先在副本演练。最后才撤销 Pulumi Cloud token、OIDC trust、backend 权限、KMS grant 和 CI secret,并验证旧流水线无法再更新资源。
一个可持续的 Pulumi 交付链应持续满足这些不变量:相同提交、配置和锁定依赖产生稳定 preview;生产更新只能由短期受控身份发起;删除与替换有独立审批和云端补偿保护;checkpoint 可备份、可恢复但不能被普通构建读取;控制台漂移能被分类而非盲目接受;临时 stack 能按 owner 和 TTL 清理;迁移到其他工具时能保留真实资源并重建管理关系。做到这些,通用语言才真正提高基础设施交付效率,而不是把脚本的自由度搬进生产控制面。
