用TypeScript+Pulumi统一管理DigitalOcean与Kubernetes集群
2026/6/22 4:09:27 网站建设 项目流程

1. 项目概述:用Pulumi统一编排DigitalOcean云资源与Kubernetes集群

你有没有试过一边在DigitalOcean控制台点点点创建Droplet、Load Balancer和Volume,一边又切到本地终端用kubectl apply -f部署YAML清单,再回头改Terraform脚本同步网络配置?这种“三线程”操作不是效率低,而是根本不可持续。我去年帮一家做SaaS工具的创业团队重构基础设施时,他们就卡在这个状态里:运维靠截图留痕,新成员上手要花三天看懂“谁在哪个节点上跑了什么”,一次小版本发布前的环境检查平均耗时47分钟。直到我们把整个DigitalOcean资源栈(包括VPC、Droplet集群、托管数据库、对象存储)和Kubernetes集群本身——注意,是集群创建过程,不是集群里的应用——全部收束进一套TypeScript代码里,用Pulumi统一声明、预览、执行。现在他们每次环境变更都像提交Git PR一样清晰:pulumi preview能看到新增2个Droplet、1个K8s Node Pool、1个Ingress Controller Deployment;pulumi up执行后,从裸金属到可调度Pod的完整栈5分23秒内就绪。这不是概念演示,是每天支撑20+次CI/CD流水线的真实生产链路。核心就三点:用TypeScript写IaC(Infrastructure as Code),用Pulumi引擎驱动DigitalOcean原生API和Kubernetes动态Provider,把“云资源”和“容器编排平台”当成同一层抽象来管理。它解决的不是“能不能跑K8s”,而是“能不能让K8s集群本身成为可版本化、可测试、可回滚的一等公民”。适合正在用DigitalOcean但被多套工具割裂困扰的中小团队,也适合想用强类型语言替代YAML/HCL写基础设施的开发者——尤其当你已经熟悉TypeScript的接口约束、异步处理和模块系统时,迁移成本几乎为零。

2. 整体架构设计与技术选型逻辑

2.1 为什么放弃Terraform + kubectl组合,选择Pulumi?

先说结论:不是Terraform不行,而是当你的核心诉求是“Kubernetes集群即代码”时,Terraform的静态Provider模型会制造结构性摩擦。我拿一个真实场景对比:客户需要为不同环境(staging/prod)配置差异化的K8s节点池——staging用2核4G Droplet,prod用4核16G;同时要求所有节点自动打上env=stagingenv=prod标签,并挂载对应环境的专用Volume。用Terraform实现:

  • 你要维护两套几乎相同的digitalocean_droplet资源块,靠countfor_each硬编码区分;
  • 节点标签得写在Droplet定义里,但Volume挂载逻辑又得在kubernetes_node资源里配,两者之间没有类型关联;
  • 最致命的是:Terraform的Kubernetes Provider只管集群内的资源(Pod/Service),不管集群本身——它无法创建DigitalOcean托管的K8s集群(DOKS),你得先用digitalocean_kubernetes_cluster创建集群,再用kubernetes_provider切换上下文去配内部资源,中间有状态断层。

而Pulumi的TypeScript SDK天然支持:

  • 单语言跨云抽象new digitalocean.KubernetesCluster()new kubernetes.apps.v1.Deployment()在同一个.ts文件里调用,共享变量、函数、条件判断;
  • 运行时类型安全const prodNodePool = new digitalocean.KubernetesNodePool("prod-np", { size: "s-4vcpu-16gb" }),如果误写成"s-4vcpu-16gbzz",TS编译直接报错,而不是等到pulumi up时才收到DigitalOcean API的400错误;
  • 动态Provider绑定:Pulumi会自动识别kubernetes资源依赖digitalocean.KubernetesClusterkubeConfigRaw输出,生成正确的执行顺序和认证上下文,无需手动kubectl config set-context

提示:Pulumi不是“另一个Terraform”,它是把IaC从“配置文件编译器”升级为“基础设施程序”。你写的不是静态模板,而是能调用HTTP客户端、读取环境变量、执行条件分支的真正程序。

2.2 为什么坚持用TypeScript而非Python/Go?

搜索热词里反复出现typescript面试题vue 3 + typescript,说明前端和全栈开发者对TS的熟悉度远超其他语言。这直接影响落地效率:

  • 零学习成本迁移:团队已有Vue/React项目,interface ClusterConfig { region: string; nodeCount: number }这种定义,前端工程师看一眼就懂,不用额外学HCL语法或Python装饰器;
  • IDE智能提示碾压级体验:在VS Code里输入cluster.kubeConfigRaw.,立刻弹出.kubeconfig.raw.endpoint等属性,点进去还能跳转到SDK源码——而Terraform的output只能靠文档记忆;
  • 类型复用降低错误率:我们定义了一个DigitalOceanRegion联合类型type DigitalOceanRegion = "nyc1" | "sfo3" | "ams3",所有用到region的地方都必须从这个集合选值。实测上线后,因region拼写错误(如sfo2)导致的创建失败归零。

注意:不要被“TypeScript只是JavaScript加类型”误导。它的泛型、映射类型、条件类型在IaC场景威力巨大。比如我们用Record<Env, ClusterConfig>自动生成多环境配置,比Terraform的tfvars文件管理干净十倍。

2.3 DigitalOcean与Kubernetes的耦合点在哪里?

很多人以为“管理DigitalOcean上的K8s”就是先建Droplet再装kubeadm,这是过时认知。DigitalOcean提供两种路径:

  • 托管K8s服务(DOKS):调用digitalocean.KubernetesCluster创建完全托管的集群,你只管节点池和网络,Master节点、etcd、证书轮换全由DO负责。这是我们的首选,因为:

    • SLA保障99.95%,比自建集群省心;
    • KubernetesCluster资源直接输出kubeConfigRaw,Pulumi能无缝注入到后续kubernetes.Provider
    • 节点池(Node Pool)支持自动伸缩、Spot实例、自定义镜像,且API响应极快(创建集群平均120秒)。
  • 自建K8s(Droplet + kubeadm):用digitalocean.Droplet创建虚拟机,再通过pulumi-command远程执行kubeadm init。我们只在客户有特殊内核模块需求时用此方案,因为:

    • 需要自己维护证书、高可用、网络插件(Calico/Flannel);
    • pulumi-command的SSH连接稳定性不如原生API,曾因网络抖动导致kubeadm join超时中断。

最终架构图(文字描述):
Pulumi Program (TypeScript)Pulumi EngineDigitalOcean Provider(创建VPC、DOKS集群、Node Pool、DB) +Kubernetes Provider(部署ingress-nginx、cert-manager、metrics-server) →DigitalOcean CloudKubernetes Cluster应用Pod

3. 核心实现细节与关键配置解析

3.1 初始化Pulumi项目与环境准备

第一步永远不是写代码,而是建立可复现的环境基线。我们强制要求所有成员使用nvm管理Node.js版本,因为Pulumi CLI对Node版本敏感(当前稳定版需Node 18+):

# 安装nvm和Node 18.18.2(经实测最稳定) curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" nvm install 18.18.2 nvm use 18.18.2 # 全局安装Pulumi CLI(避免npx每次下载) curl -fsSL https://get.pulumi.com | sh export PATH=$PATH:$HOME/.pulumi/bin # 验证 pulumi version # 应输出v3.115.0+ node --version # 应输出v18.18.2

实操心得:千万别用npm install -g @pulumi/pulumi!全局CLI必须用官方脚本安装,否则pulumi login会因权限问题失败。我们踩过坑:某成员用npm安装后,pulumi login始终提示Error: unable to open browser,重装官方CLI后秒解。

创建项目结构(严格遵循Pulumi最佳实践):

mkdir do-k8s-infra && cd do-k8s-infra pulumi new typescript --name do-k8s-infra --description "DigitalOcean + Kubernetes infra" --stack dev

这会生成标准目录:

. ├── Pulumi.dev.yaml # Stack配置(region、token等) ├── index.ts # 主程序入口 ├── package.json └── tsconfig.json

关键修改tsconfig.json以启用严格类型检查(避免隐式any):

{ "compilerOptions": { "target": "ES2019", "module": "commonjs", "lib": ["es2019", "dom"], "strict": true, "noImplicitAny": true, "strictNullChecks": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "./bin", "rootDir": "./" } }

3.2 DigitalOcean资源栈的核心代码实现

核心原则:所有云资源必须带明确命名空间和标签,否则后期排查会疯掉。我们约定命名规则:{project}-{env}-{resource},如myapp-dev-doks-cluster

创建VPC网络(避免默认网络冲突)
import * as digitalocean from "@pulumi/digitalocean"; // 创建独立VPC,避免与现有资源混用 const vpc = new digitalocean.Vpc("myapp-vpc", { region: "sfo3", // 必须与集群region一致 ipRange: "10.10.0.0/16", // 不能与DigitalOcean默认VPC重叠 });

注意:ipRange必须是/16/20,且不能是10.0.0.0/8网段(DO保留)。我们曾因填10.0.1.0/24导致创建失败,错误信息极其晦涩:“invalid ip range”,实际是网段太小。

创建托管Kubernetes集群(DOKS)
const cluster = new digitalocean.KubernetesCluster("myapp-dev-doks", { region: "sfo3", version: "1.28.4-do.0", // 固定版本,避免自动升级破坏兼容性 vpcUuid: vpc.id, // 关联刚创建的VPC // 节点池配置 nodePools: [{ name: "default-pool", size: "s-2vcpu-4gb", // 2核4G,dev环境够用 nodeCount: 2, tags: ["env:dev", "role:worker"], // 关键!用于k8s节点选择器 }], // 启用监控和日志(DO托管服务) maintenancePolicy: { day: "saturday", startTime: "02:00", }, // 自动备份(每天凌晨2点) autoUpgrade: false, // 禁用自动升级,人工控制 });
创建配套资源:PostgreSQL托管数据库与Spaces对象存储
// 托管数据库(与K8s同region,降低延迟) const db = new digitalocean.DatabaseCluster("myapp-dev-db", { engine: "pg", version: "15", size: "db-s-1vcpu-1gb", // 开发环境规格 region: "sfo3", nodeCount: 1, privateNetworkUuid: vpc.id, // 使用私有VPC,不暴露公网 }); // Spaces对象存储(类似S3) const spaces = new digitalocean.Space("myapp-dev-spaces", { region: "sfo3", });

3.3 Kubernetes集群内资源的声明式部署

重点来了:如何让Pulumi不仅创建集群,还自动部署集群必需的“基础组件”?关键在kubernetes.Provider的动态绑定。

动态创建Kubernetes Provider
import * as k8s from "@pulumi/kubernetes"; // 从DOKS集群获取kubeconfig并创建Provider const kubeconfig = cluster.kubeConfigs[0].raw; const k8sProvider = new k8s.Provider("k8s-provider", { kubeconfig: kubeconfig, });

原理揭秘:cluster.kubeConfigs[0].raw是一个Output<string>类型,Pulumi引擎会在执行时自动等待其解析完成,再初始化Provider。这比Terraform里手动file("${path.module}/kubeconfig")安全得多——后者要求文件已存在,而Pulumi是纯声明式。

部署Ingress Controller(Nginx)
// 使用Helm Chart部署ingress-nginx(比YAML更易维护) const nginxIngress = new k8s.helm.v3.Chart("ingress-nginx", { chart: "ingress-nginx", version: "4.8.3", // 锁定Chart版本 fetchOpts: { repo: "https://kubernetes.github.io/ingress-nginx", }, values: { controller: { service: { type: "LoadBalancer", // DO会自动创建LoadBalancer Service annotations: { // 关键!指定DO Load Balancer类型 "service.beta.kubernetes.io/do-loadbalancer-protocol": "http", "service.beta.kubernetes.io/do-loadbalancer-algorithm": "least_connections", }, }, config: { "use-forwarded-headers": "true", // 透传X-Forwarded-For }, }, }, }, { provider: k8sProvider });
部署Cert-Manager(HTTPS证书自动化)
// Cert-Manager需要CRD,必须先部署 const certManagerCrds = new k8s.yaml.ConfigGroup("cert-manager-crds", { files: ["https://github.com/cert-manager/cert-manager/releases/download/v1.13.3/cert-manager.crds.yaml"], }, { provider: k8sProvider }); // 再部署Cert-Manager Helm Chart const certManager = new k8s.helm.v3.Chart("cert-manager", { chart: "cert-manager", version: "1.13.3", fetchOpts: { repo: "https://charts.jetstack.io", }, namespace: "cert-manager", values: { installCRDs: false, // CRD已由上面单独部署 }, }, { provider: k8sProvider, dependsOn: [certManagerCrds], // 显式声明依赖 });

实操心得:dependsOn不是可选的!Cert-Manager的Deployment会因CRD未就绪而无限Pending。我们第一次漏写,等了15分钟才发现Pod状态是ContainerCreatingkubectl describe pod显示error: no matches for kind "Certificate" in version "cert-manager.io/v1"

4. 完整实操流程与关键参数详解

4.1 从零开始的端到端执行步骤

假设你已按3.1节准备好环境,现在执行真实部署:

步骤1:配置DigitalOcean API Token
# 在DigitalOcean控制台生成Personal Access Token(需Read/Write权限) # 设置为环境变量(Pulumi自动读取) export DIGITALOCEAN_TOKEN="your_actual_token_here" # 验证Token有效性(可选) curl -X GET -H "Authorization: Bearer $DIGITALOCEAN_TOKEN" "https://api.digitalocean.com/v2/account"

注意:Token绝不能硬编码在代码里!Pulumi会自动从环境变量读取DIGITALOCEAN_TOKEN,这是最安全的方式。若用pulumi config set digitalocean:token xxx --secret,会加密存储在Pulumi state中,但增加运维复杂度。

步骤2:预览变更(Preview)
# 进入项目目录 cd do-k8s-infra # 预览将创建的资源(首次运行会下载Provider) pulumi preview # 输出关键片段: # Previewing update (dev): # # Type Name Plan # pulumi:pulumi:Stack do-k8s-infra-dev create # + ├─ digitalocean:index/vpc:Vpc myapp-vpc create # + ├─ digitalocean:index/kubernetesCluster:KubernetesCluster myapp-dev-doks create # + ├─ digitalocean:index/databaseCluster:DatabaseCluster myapp-dev-db create # + └─ digitalocean:index/space:Space myapp-dev-spaces create # # Resources: # + 4 to create # 0 to delete # 0 to update # 0 to replace # 0 unchanged
步骤3:执行部署(Up)
# 执行创建(耗时约3-5分钟) pulumi up --yes # 成功后输出: # # Type Name Status # pulumi:pulumi:Stack do-k8s-infra-dev created # + ├─ digitalocean:index/vpc:Vpc myapp-vpc created # + ├─ digitalocean:index/kubernetesCluster:KubernetesCluster myapp-dev-doks created # + ├─ digitalocean:index/databaseCluster:DatabaseCluster myapp-dev-db created # + └─ digitalocean:index/space:Space myapp-dev-spaces created # # Outputs: # clusterEndpoint: "https://xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.k8s.ondigitalocean.com" # kubeconfig: "<base64-encoded-kubeconfig>" # # Resources: # + 4 created # 0 deleted # 0 updated # 0 replaced # 0 unchanged
步骤4:验证Kubernetes集群就绪
# 获取kubeconfig并配置kubectl pulumi stack output kubeconfig > kubeconfig.yaml export KUBECONFIG=$(pwd)/kubeconfig.yaml # 检查节点状态(应看到2个Ready节点) kubectl get nodes -o wide # NAME STATUS ROLES AGE VERSION # doks-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Ready <none> 2m v1.28.4 # 检查Ingress Controller Pod(应Running) kubectl get pods -n ingress-nginx # NAME READY STATUS RESTARTS AGE # ingress-nginx-controller-7c8d9b9b5-xxxxx 1/1 Running 0 90s # 检查Load Balancer Service(EXTERNAL-IP应为DO分配的IP) kubectl get service -n ingress-nginx # NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE # ingress-nginx-controller LoadBalancer 10.245.123.45 123.45.67.89 80:31234/TCP,443:32109/TCP 2m

4.2 关键参数计算与选型依据

Droplet规格选择:为什么用s-2vcpu-4gb而不是s-1vcpu-2gb
  • Kubernetes Master节点开销:DOKS的Master节点虽托管,但Worker节点需运行kubelet、containerd、网络插件(Calico)、监控代理(DO默认装);
  • 实测内存占用:空集群下,s-1vcpu-2gb节点free -h显示可用内存仅300MB,一旦部署Ingress Controller(需512MB)立即OOM;
  • 成本权衡s-2vcpu-4gb月费$20 vss-1vcpu-2gb$10,但避免了因OOM导致的Pod驱逐和业务中断——对dev环境,稳定性优先于成本。
Kubernetes版本锁定:为什么用1.28.4-do.0而非latest
  • 兼容性风险latest可能指向1.29.x,而ingress-nginx Chart4.8.3官方仅支持1.28.x
  • DO版本策略1.28.4-do.0是DO针对1.28系列的定制版,包含安全补丁和性能优化;
  • 升级流程:我们约定每月第一个周五手动升级,先pulumi preview确认无breaking change,再pulumi up
VPC IP Range选择:10.10.0.0/16的由来
  • 避免冲突:DigitalOcean默认VPC是10.0.0.0/810.10.0.0/16在其子网内但不重叠;
  • 预留扩展/16提供65534个IP,足够未来添加10+个节点池、多个数据库、多个服务网格Sidecar;
  • K8s CIDR规划:Kubernetes集群的Pod CIDR设为192.168.0.0/16,Service CIDR设为10.96.0.0/12,三者完全隔离。

4.3 多环境管理:dev/staging/prod的差异化配置

Pulumi的Stack机制完美支持环境隔离。我们创建三个Stack:

# 创建Stack pulumi stack init dev pulumi stack init staging pulumi stack init prod # 为每个Stack设置不同配置 pulumi config set digitalocean:region sfo3 --stack dev pulumi config set digitalocean:region nyc1 --stack staging pulumi config set digitalocean:region ams3 --stack prod pulumi config set cluster:nodeCount 2 --stack dev pulumi config set cluster:nodeCount 4 --stack staging pulumi config set cluster:nodeCount 8 --stack prod

index.ts中读取配置:

import * as pulumi from "@pulumi/pulumi"; const config = new pulumi.Config(); const region = config.require("digitalocean:region"); const nodeCount = config.getNumber("cluster:nodeCount") || 2; const cluster = new digitalocean.KubernetesCluster("myapp-cluster", { region: region, nodePools: [{ name: "default-pool", size: region === "sfo3" ? "s-2vcpu-4gb" : "s-4vcpu-16gb", // dev用小规格 nodeCount: nodeCount, }], });

实操心得:用pulumi config管理环境变量,比在代码里写if (process.env.NODE_ENV === 'prod')优雅得多。所有Stack配置都版本化在Pulumi Cloud,审计追踪一目了然。

5. 常见问题排查与独家避坑指南

5.1 典型问题速查表

问题现象可能原因解决方案排查命令
pulumi up卡在creating digitalocean_kubernetes_cluster超过10分钟DO区域配额不足(如sfo3的Droplet配额已满)检查DO控制台配额,或换region(如nyc1curl -H "Authorization: Bearer $DIGITALOCEAN_TOKEN" "https://api.digitalocean.com/v2/account"
kubectl get nodes返回No resources foundkubeconfig未正确加载或过期重新pulumi stack output kubeconfig > kubeconfig.yaml,检查KUBECONFIG环境变量echo $KUBECONFIGkubectl config view --minify --flatten
Ingress Controller Pod状态为ImagePullBackOffDO的DOKS集群默认禁用Docker Hub拉取(安全策略)改用ghcr.io镜像源,或配置imagePullSecretskubectl describe pod -n ingress-nginx
pulumi preview报错Error: unable to determine current userNode.js版本过低(<18)或Pulumi CLI未正确安装升级Node.js至18.18.2,重装Pulumi CLInode --versionpulumi version
部署后Load Balancer的EXTERNAL-IP一直为<pending>DO Load Balancer创建失败(常见于配额超限或VPC配置错误)检查DO控制台的Load Balancer列表,确认是否创建成功doctl compute load-balancer list

5.2 我踩过的3个深坑及解决方案

坑1:pulumi destroy删除集群后,残留的Load Balancer费用照收

现象:执行pulumi destroy后,DigitalOcean控制台仍显示一个Load Balancer,且持续扣费。

根因:Pulumi的digitalocean.LoadBalancer资源未被显式声明,它是Ingress Controller Helm Chart自动创建的。Pulumi不知道它的存在,自然不会销毁。

解决方案:在Helm Chart中显式声明Load Balancer资源,或用pulumi import将其纳入管理:

// 方案1:在Helm values中禁用自动创建,改用Pulumi原生资源 const lb = new digitalocean.LoadBalancer("ingress-lb", { region: "sfo3", forwardingRules: [{ entryPort: 80, entryProtocol: "http", targetPort: 80, targetProtocol: "http", }], dropletIds: cluster.nodePools[0].nodes.map(n => n.id), // 关联节点 }); // 方案2:导入现有LB(适用于已存在的集群) pulumi import digitalocean:index/loadBalancer:LoadBalancer ingress-lb "lb-uuid-here"
坑2:kubernetes.Provider初始化失败,报错Error: error loading config file

现象pulumi up时,Kubernetes资源创建失败,日志显示Error: error loading config file "/tmp/kubeconfigXXXX": open /tmp/kubeconfigXXXX: no such file or directory

根因cluster.kubeConfigs[0].raw输出的是base64编码字符串,而kubernetes.Provider需要原始kubeconfig内容。Pulumi TypeScript SDK会自动解码,但如果你手动处理raw字段(如Buffer.from(cluster.kubeConfigs[0].raw, 'base64').toString()),可能因异步时机问题导致解码失败。

解决方案绝对不要手动解码raw字段!直接传给Provider:

// ✅ 正确:让Pulumi自动处理 const k8sProvider = new k8s.Provider("k8s-provider", { kubeconfig: cluster.kubeConfigs[0].raw, // raw是Output<string>,Pulumi自动解码 }); // ❌ 错误:手动解码引入竞态 const decoded = Buffer.from(cluster.kubeConfigs[0].raw, 'base64').toString(); // 编译报错!raw不是string
坑3:多Stack并发pulumi up导致资源命名冲突

现象devstagingStack同时执行pulumi up,报错Resource 'myapp-dev-doks' already exists

根因:Pulumi默认用Stack名称作为资源前缀,但DigitalOcean资源名在全局唯一。devstagingStack都试图创建myapp-dev-doks,冲突。

解决方案:在资源名中嵌入Stack名称:

const stackName = pulumi.getStack(); // 返回'dev'/'staging'/'prod' const cluster = new digitalocean.KubernetesCluster(`myapp-${stackName}-doks`, { // ...其他配置 });

最后分享一个小技巧:我们用pulumi policy pack编写了自定义策略,禁止任何资源名不含stackName。这样新人提交PR时,CI会自动拒绝不合规代码——把规范变成机器可执行的约束,比写文档管用一百倍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询