用TypeScript+Pulumi统一管理DigitalOcean与Kubernetes集群
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 Deploymentpulumi up执行后从裸金属到可调度Pod的完整栈5分23秒内就绪。这不是概念演示是每天支撑20次CI/CD流水线的真实生产链路。核心就三点用TypeScript写IaCInfrastructure 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 Dropletprod用4核16G同时要求所有节点自动打上envstaging或envprod标签并挂载对应环境的专用Volume。用Terraform实现你要维护两套几乎相同的digitalocean_droplet资源块靠count或for_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-16gbzzTS编译直接报错而不是等到pulumi up时才收到DigitalOcean API的400错误动态Provider绑定Pulumi会自动识别kubernetes资源依赖digitalocean.KubernetesCluster的kubeConfigRaw输出生成正确的执行顺序和认证上下文无需手动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场景威力巨大。比如我们用RecordEnv, ClusterConfig自动生成多环境配置比Terraform的tfvars文件管理干净十倍。2.3 DigitalOcean与Kubernetes的耦合点在哪里很多人以为“管理DigitalOcean上的K8s”就是先建Droplet再装kubeadm这是过时认知。DigitalOcean提供两种路径托管K8s服务DOKS调用digitalocean.KubernetesCluster创建完全托管的集群你只管节点池和网络Master节点、etcd、证书轮换全由DO负责。这是我们的首选因为SLA保障99.95%比自建集群省心KubernetesCluster资源直接输出kubeConfigRawPulumi能无缝注入到后续kubernetes.Provider节点池Node Pool支持自动伸缩、Spot实例、自定义镜像且API响应极快创建集群平均120秒。自建K8sDroplet kubeadm用digitalocean.Droplet创建虚拟机再通过pulumi-command远程执行kubeadm init。我们只在客户有特殊内核模块需求时用此方案因为需要自己维护证书、高可用、网络插件Calico/Flannelpulumi-command的SSH连接稳定性不如原生API曾因网络抖动导致kubeadm join超时中断。最终架构图文字描述Pulumi Program (TypeScript)→Pulumi Engine→DigitalOcean Provider创建VPC、DOKS集群、Node Pool、DB Kubernetes Provider部署ingress-nginx、cert-manager、metrics-server →DigitalOcean Cloud→Kubernetes Cluster→应用Pod3. 核心实现细节与关键配置解析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集群DOKSconst 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核4Gdev环境够用 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 Providerimport * 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是一个Outputstring类型Pulumi引擎会在执行时自动等待其解析完成再初始化Provider。这比Terraform里手动file(${path.module}/kubeconfig)安全得多——后者要求文件已存在而Pulumi是纯声明式。部署Ingress ControllerNginx// 使用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-ManagerHTTPS证书自动化// 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_TOKENyour_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 ServiceEXTERNAL-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 2m4.2 关键参数计算与选型依据Droplet规格选择为什么用s-2vcpu-4gb而不是s-1vcpu-2gbKubernetes 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.xDO版本策略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个节点池、多个数据库、多个服务网格SidecarK8s CIDR规划Kubernetes集群的Pod CIDR设为192.168.0.0/16Service 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/accountkubectl get nodes返回No resources foundkubeconfig未正确加载或过期重新pulumi stack output kubeconfig kubeconfig.yaml检查KUBECONFIG环境变量echo $KUBECONFIGkubectl config view --minify --flattenIngress Controller Pod状态为ImagePullBackOffDO的DOKS集群默认禁用Docker Hub拉取安全策略改用ghcr.io镜像源或配置imagePullSecretskubectl describe pod -n ingress-nginxpulumi preview报错Error: unable to determine current userNode.js版本过低18或Pulumi CLI未正确安装升级Node.js至18.18.2重装Pulumi CLInode --versionpulumi version部署后Load Balancer的EXTERNAL-IP一直为pendingDO Load Balancer创建失败常见于配额超限或VPC配置错误检查DO控制台的Load Balancer列表确认是否创建成功doctl compute load-balancer list5.2 我踩过的3个深坑及解决方案坑1pulumi 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坑2kubernetes.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是OutputstringPulumi自动解码 }); // ❌ 错误手动解码引入竞态 const decoded Buffer.from(cluster.kubeConfigs[0].raw, base64).toString(); // 编译报错raw不是string坑3多Stack并发pulumi up导致资源命名冲突现象dev和stagingStack同时执行pulumi up报错Resource myapp-dev-doks already exists。根因Pulumi默认用Stack名称作为资源前缀但DigitalOcean资源名在全局唯一。dev和stagingStack都试图创建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会自动拒绝不合规代码——把规范变成机器可执行的约束比写文档管用一百倍。