研发平台是怎么长出来的:环境、发布与可观测性的架构取舍
先讲一个我们补过的具体问题。
一次生成请求,要经过入口服务、任务调度和 Agent 执行。如果这次只改了入口,流水线按代码变更生成任务,就可能只把入口部署到个人环境。入口的流水线是绿的,后面的个人调度和执行实例却没有跟着补齐。
服务已经发上去了,但这次联调需要的环境还不完整。
我们后来给这三个服务加了发布组:命中这条链的改动时,把需要的依赖一起带上。三个服务可以并行构建;部署则先执行服务,再调度服务,最后入口。请求进来之前,先把接住它的后两段准备好。
图中使用通用角色名。构建并行、部署按依赖顺序推进;发布组本身不提供跨服务事务。
这个问题表面上是少部署了两个服务,往下看,其实是两种模型对不上:流水线理解的是“哪些代码变了”,开发需要的是“哪些服务一起存在,才能完成一次联调”。
后面做发布、监控和告警,也反复碰到类似的问题。一个脚本能执行,一条查询能返回,一条消息能发出去,都不算难。难的是入口越来越多以后,谁来决定目标,谁保存执行状态,谁有权更新资源,出了问题又该相信哪份记录。
所以这篇会从这些具体问题讲起,把后面的架构选择一起拆开。先看请求怎么从 NLB、Nginx 走到 Gateway、WAP 和业务 RPC,再看我们怎样为这条链准备环境、执行发布、观察运行结果。
1. 从 NLB 到业务 RPC,一条请求实际怎么走
前面把“入口服务”画成一个框,其实省略了好几层。展开以后,连接先到 NLB,再到 Nginx;Nginx 根据请求路径转给对应的 Gateway 或 WAP;这些接入服务解析客户端请求,再通过 Kitex RPC 调用内部业务服务。
图中从 NLB 开始展开业务 API 链路。域名前可能还有 CDN 或同源代理,各入口的 TLS 终止位置也可能不同,这里不把它们画成统一配置。鉴权服务是 Nginx 发起的子请求分支。
NLB 先解决连接应该交给哪些入口实例。 它维护监听和后端集合,不判断这是创建项目还是获取用户信息。迁到 Kubernetes 时,我们延续已有的入口地址和监听,再把后端接到 Service 对应的 Pod 集合。Pod 换了,由控制器维护后端成员,不要求调用方跟着改地址。
Nginx 再处理 HTTP 这一层:按路径选择上游,代理普通请求和 WebSocket,处理跨域预检,并为需要鉴权的业务请求发起认证子请求。认证服务检查凭证、给出身份结果,Nginx 再覆盖写入下游的身份信息。这样客户端自己带来的“用户是谁”,就不会直接变成业务网关的判断依据。
这里要分清两件事。认证回答当前请求是谁发的;这个人能不能访问某个项目,还要在业务入口或对应业务服务里检查。例如 WAP 建立项目连接前,会读取项目归属并核对当前用户。不能因为已经登录,就跳过对象权限。
我们也没有把项目规则写进 Nginx。请求该转去哪、连接该怎样代理,可以留在入口配置里;选什么模板、允许什么操作、怎样组织客户端返回值,放在 Gateway 或 WAP;项目状态和任务执行继续交给拥有这些能力的业务服务。否则每加一种产品交互,都要同时修改入口配置和业务实现,边界很快又混回去了。
Gateway 和 WAP 是面向不同客户端的两种接入。 常规 Gateway 要承接编辑器里更完整的项目、素材和场景操作;WAP 面向移动端,把创建项目和对话过程收窄成更少的输入与事件。它们各自对外提供 HTTP / WebSocket 接口,再调用共享的内部能力。
比如移动端创建项目,只需要提交用户输入,服务端确定移动端模板等选项;项目、版本和文件等稳定模型继续复用下游定义。我们让客户端协议可以分别演进,同时把项目状态留在原来的业务服务里,避免为了一个移动端入口再维护一份项目和任务状态。
到了 RPC 这一跳,调用方使用生成的客户端和约定好的数据结构。标准客户端统一带上服务发现、环境上下文、调用方信息、超时和调用指标。普通 RPC 的元信息通过 TTHeader 等传输机制交给下游,长连接的 RPC 客户端也要显式保留这些上下文。换成内部调用以后,不能把“这是谁发起的、要去哪套环境”弄丢。
外部接口采用“调用一个方法”的命名方式,也不代表浏览器直接连着内部 Kitex 服务。对外的连接协议和对内的服务协议之间,仍然有 Gateway / WAP 这一层适配。
拿一次移动端生成来说,过程可以拆成下面两段。
前置 NLB、Nginx 和鉴权在上一张图中展开,这里聚焦 WAP 与内部业务的交接;模板查询等辅助调用省略。
第一段是创建。WAP 校验输入、选择模板,再调用调度服务创建项目并启动首轮。客户端收到项目对象时,生成工作已经交给后面的执行链,并不需要把整个生成过程塞进这一条 HTTP 响应里。
第二段是连接项目。客户端建立 WebSocket,Nginx 处理协议升级和长连接转发,WAP 校验项目访问权限,再接上内部 Kitex 双向流。客户端命令被转换成下游请求;下游事件则按移动端协议裁剪、组合后返回。比如内部有多种消息和版本事件,客户端需要的是自己能理解的进度和一轮完成结果。
这时,连接的生命周期和生成任务的生命周期要分开。 用户关掉页面,断开的是观察这次执行的连接;要停止当前生成,需要发送带有当前轮次身份的明确终止命令。重连则重新取得状态和可恢复的结果,不能把“重新建立连接”理解成“再创建一次任务”。
这也是入口配置需要理解协议的原因。WebSocket 要正确转发升级信息,持续事件不能被普通响应缓冲挡住,连接空闲超时、单次 RPC 超时和任务执行期限也有不同含义。把所有超时一起调大,只会让故障更晚暴露。
分层以后,排障顺序也更明确。请求没进入 Nginx,先看连接与入口后端;Nginx 没有匹配到路由,先看转发规则;网关已经收到请求,再看身份、对象权限以及下游 RPC。浏览器拿到一个 502,不能直接推断生成服务挂了;入口的上游连接状态、网关的调用错误和任务最终状态,需要一层层对上。
我觉得这几层值得保留,是因为它们处理的变化不同:实例变化交给负载均衡与服务发现,URL 和连接协议交给 Nginx,客户端交互交给 Gateway / WAP,业务状态留在内部服务。每层多一次转发都会增加耗时和故障点,所以也要能说清楚它到底承担了什么。
2. 先把业务请求和发布指令分开
早期直接在 CI 里执行发布脚本,很顺手:构建完镜像,连接运行环境,更新服务,等它起来。一条流水线就能做完。
问题是,后来除了 CI,还出现了页面、CLI 和自动化调用。假如每个入口都可以直接改集群,发布规则就会散在这些入口里。某个入口检查过环境,另一个入口可能没检查;页面显示一个状态,CI 又按自己的理解给出另一个结论。
我们逐步把这部分收进正式运行的研发平台。它管理服务目标、制品、发布记录以及资源操作,可以把它理解成控制面。用户真正使用产品时,请求仍然经过业务入口、调度和执行服务,不绕到这个管理平台里。
图中展开发布相关组件。业务环境是被管理的目标,平台自身的运行角色决定谁能领取正式操作。
这里有两个很容易混起来的“环境”。一个是这次发布要去个人环境、共享测试、预发布还是正式环境;另一个是这台平台实例有没有资格执行正式控制面的任务。
我们没有让它们共用一个开关。平台自己的开发实例可以用来调试页面和接口,但不能因为请求里带了“正式环境”,就开始领取正式发布任务。CI 和告警回调也接到正式控制面,避免同一件事被几套平台实例分别处理。
入口的身份同样分开校验。人从页面或 CLI 发起操作,要验证人的身份与权限;CI 登记制品,要核对实际构建任务和来源;告警回调用自己的机器身份。业务请求里的环境选择负责路由,不能顺便成为平台的操作授权。
把发布执行权收拢以后,平台就要为结果负责。 CI 只交出明确的制品和发布请求,平台失败不能再偷偷回到旧脚本改集群。否则平时看起来统一了,真正出问题时仍然有两个执行者。
这也带来了集中式控制面的代价:平台故障会阻断新的管理操作,平台数据库和 Worker 本身需要备份、监控和恢复手段。把它放到业务请求链之外,是为了让管理操作的可用性和正在运行的业务请求少一层直接耦合。
3. 平台拆成三个服务以后,为什么又收了回来
平台本身也走过一次拆分:Web、网关和管理服务各自运行。后来又把它们收敛到一个 TypeScript 模块和同一份镜像里。
我觉得这段比“用了什么框架”更值得讲。网关转一次请求,并不意味着那里就一定需要一个独立服务。前后几层经常一起改、一起发时,跨进程边界会增加接口同步和部署配合,未必能提供对应的隔离价值。
收敛以后,页面和 HTTP 入口直接调用本地用例,平台内部少了一段网关到管理服务的网络调用。但有一个边界我们保留了:接请求的进程和执行持久任务的 Worker 分开运行。 它们使用同一份镜像、随同一个部署发布,各自设置资源限制。网页关掉、HTTP 请求超时,都不该决定发布任务的寿命。
图中只展开 HTTP 与运维 Worker 两个主要角色,以及与本文相关的模块;分析任务等其他执行角色未展开。
这里也有取舍。共同发布省掉了一部分版本配合,但 HTTP 和 Worker 的扩容仍受同一部署影响,故障隔离也没有做到完全独立。如果以后两边的负载、发布频率差别足够大,再把运行单元拆开有实际理由;现在不为了形式再补一层内部 RPC。
进程可以收敛,数据归属不能跟着变成“谁都能写”。我们在同一个平台里,仍然把这些记录分给不同模块:
| 模块 | 自己负责写入的记录 | 其他模块怎样使用 |
|---|---|---|
| 服务目录 | 服务注册、归属和构建绑定 | 通过契约读取服务信息 |
| 发布 | 制品来源、目标、发布单和执行事件 | 提交发布或查询进度 |
| 个人环境 | 环境代次、创建来源和回收状态 | 申请、关闭或查询环境 |
| 资源管理 | 受管资源关系、同步记录和观察结果 | 读取资源身份与观察状态 |
| 告警 | 事件生命周期、收件人和投递记录 | 查询事件及通知结果 |
共用 PostgreSQL,不代表可以跨模块随手改表。比如环境回收需要停止发布,要经过发布模块约定的操作;不能直接把发布表里的状态改成“完成”,让页面看起来干净。确实需要几个模块一起落库的步骤,则通过明确的事务接口放在同一个数据库事务里完成。
内部依赖也按这个原则组织。HTTP 入口负责认证、解析输入和输出;应用用例安排步骤;领域规则判断这次状态变化是否允许;最外面的适配器才访问数据库、Kubernetes、日志和指标系统。用例通过接口调用这些能力,启动时明确组装,不把云 SDK 和数据库操作塞进每条状态判断里。
这样做的实际收益是可以单独验证规则。比如旧执行者还能不能写回、查询失败能不能覆盖旧资源状态,不需要先搭一套集群才能判断。跨模块私有实现引用、领域规则依赖 SDK 等情况,也交给架构检查拦住。单体里的边界,需要靠这些约束维持。
4. 个人环境:只部署改过的服务,为什么不够了
早期的个人环境,做法很直接:请求带上环境选择,优先找这个环境里的服务,没有个人实例的普通依赖就使用共享实例。只部署自己改过的部分,准备成本比较低。
随着生成链路变长,这种默认回退需要收紧。
入口处理完请求,后面还有任务调度、Agent 执行、模板和沙箱。调度与执行如果回到了共享版本,这次测试就掺进了另一套运行逻辑;即使恰好跑通,也很难确认验证的是哪种组合。
我们对关键调度和执行服务改成了必须命中同一环境。找不到对应实例就明确失败,不静默回到共享服务。普通依赖则继续按策略共享,没有把所有数据库和基础资源复制一遍。
这实际上是两次路由选择。测试入口先按环境标签寻找个人 Gateway 或 WAP;没有对应入口实例时,允许按策略使用共享入口,但环境标签继续往下传。进入 RPC 后,服务发现再按同一份上下文选择下游实例。因此,请求经过共享 Gateway,也可以进入个人调度和执行链。
这里的“找不到”还要和“暂时查不出来”分开。入口明确收到 DNS 不存在的结果,才按规则回退;DNS 超时等临时错误会让请求失败,不能悄悄切到另一套环境。关键调度和执行服务则保持必须命中同一环境的约束。这些细节决定了一次联调究竟验证了哪套服务组合。
入口接到的环境选择还要经过 RPC、异步消息以及后续依赖选择,不能只停在浏览器到入口的第一跳。
但路由变严格以后,部署范围也必须跟着改。否则入口已经就绪,调度却找不到个人实例,联调仍然走不下去。前面那个发布组,就是用来补这个缺口的。
这里有两个不同的依赖关系。构建只关心代码和编译输入,能并行就并行;部署还关心运行时谁要调用谁,所以按“执行、调度、入口”推进。把它们混成一张代码依赖表,就会漏掉那些代码没改、运行时却必须存在的服务。
我们保留了共享依赖的低成本,也为关键链路补上了成组部署。代价是一次入口改动可能多出两个部署任务,但它换来的是一条可完整验证的个人链路。
这里的个人环境,首先隔离的是关键代码版本和执行路径。请求经过入口、RPC 和消息队列时都要延续同一份环境上下文;用到共享数据库时,数据隔离仍然要靠业务自己的约束。不能因为请求命中了个人实例,就把它当成一套完全独立的数据环境。
5. 流水线:同一台 Runner,也可能反复从冷缓存开始
环境补齐以后,接下来就是等。等准备源码,等编译,等镜像,等实例起来。
我们最先做的是缩小任务范围:根据改动动态生成子流水线,把共享代码会影响的服务展开,其余模块不生成作业。前面的运行时发布组,则补上单靠代码依赖无法表达的联调关系。
缓存里还有一个很容易漏掉的细节。同一台 Runner 有多个并发槽,如果每个槽都使用不同的 BuildKit builder,它们就各自维护一份本地缓存。上一个任务刚编过的依赖,下一个任务换个槽,可能又要准备一遍。
所以 builder 的身份后来固定到 Runner,而不是并发槽或者某个 job。Go 的依赖缓存和编译缓存跟着这份持久状态复用;换到另一台 Runner 时,再用镜像仓库里的缓存兜底。本地缓存挂载和远程镜像层缓存作用不同,不能以为上传了镜像缓存,就把所有编译中间结果也带走了。
另一个改动更简单:结果汇总任务只需要读取上游产物,却也走了一遍源码检出。它没有编译代码,也不需要业务源码,这段准备直接去掉就行。
图里画的是任务关系,不是耗时比例。构建缓存是否命中,还取决于 Runner、输入变化和缓存状态。
这几件事对应的优化手段完全不同:任务太多,就缩小生成范围;缓存分散,就修正缓存身份;根本用不到输入,就删掉准备步骤。只盯最后那条编译命令,很容易把前面这些时间漏掉。
我们也没有把所有发布都改成同一种启动方式。个人环境可以接受的短暂停顿,正式环境未必能接受。省掉等待之前,还是要看它原来承担了什么作用。
6. 监控:HTTP 返回了 200,生成任务可能才刚进队列
早期服务监控先覆盖 HTTP 和 RPC:请求量、错误率、耗时、并发数。公共中间件负责采集,Prometheus 保存指标,Grafana 使用共同的看板。
后来把监控接进研发平台时,我们没有再做一份自己的时序数据库。Prometheus 保存指标,SLS 保存原始日志,云监控提供基础资源状态。平台主要保存服务、环境、部署目标和资源身份,再拿这些关系去限定查询。
比如打开一个数据库页面,平台先确定是哪台实例、哪个环境,再分别取调用指标和云端实例指标。这样能把两边的数据放在同一个排查入口里,又不用自己重复承担指标采集、原始日志存储和保留周期的维护。
这套东西能回答接口有没有接住请求。到了异步生成,它还回答不了用户最后有没有拿到结果。
请求返回以后,任务可能还在队列里。执行时,一次模型或工具调用失败了,重试以后又成功了。把这一次调用失败算成整个生成失败,会把任务失败率算高;只看入口的 200,又会把后面真正失败的任务漏掉。
所以我们往下补了几层数据:队列是否在积压,执行有没有推进,模型和工具各自花了多久,以及任务最终是成功、失败还是取消。最终任务失败率用最终任务结果计算;调用错误率保留在调用层。 两个数字都看,但不混成一个成功率。
Agent 的缓存指标也一样。一次输入很短、一次输入很长,把它们的缓存百分比直接平均,得不到整体节省了多少输入 Token。我们使用输入 Token 加权:缓存输入量的总和,除以输入量的总和。没有输入样本,就保留没有样本的状态。
数据库监控还有另一段演进。
只有 CPU、内存、连接数,能看到实例承受的压力,但不知道是谁在用。我们在标准数据库客户端里装 GORM 采集插件,在 Redis 客户端里加 hook,在其他依赖的标准客户端上做同类封装。每次调用记录调用服务、资源、操作类别、结果和耗时。
这样数据库页面就可以从真实调用指标里列出:哪些服务在读,哪些在写,谁的错误率上升,谁的 P99 变大。调用关系随实际样本出现,不需要再手工维护一张“这个库关联了哪些服务”的表。
但只用数据库名做关联也有坑。两台实例可能有同名库,不能因为名字一样,就把所有调用量都归到其中一台。因此后来补了实例身份;旧样本身份不完整、又无法唯一对应时,页面显示归因歧义,不替它猜一个实例。
Redis 的阻塞读则是另一个很小、但很实际的例子。XREAD、XREADGROUP 可能本来就在等待新消息,这段时间不能直接解释成 Redis 执行慢。我们把阻塞读取单独分类,避免套用普通命令的延迟判断。
这些工作都不会让看板突然多出什么炫酷效果,但它们会决定人看到一个红色数字以后,究竟会不会查错方向。
代价是,上游指标系统查询失败,平台就只能明确展示查询失败;数据关联不完整,就只能展示歧义。我们宁愿把这个缺口露出来,也不拿数据库里上一次成功查询的结果冒充当前状态。
7. 日志:选了正式环境,怎样确保不会查到预发布
我们使用 SLS 保存日志,后来在研发平台里增加了按服务、环境和实例查询的入口。做这个入口时,最麻烦的部分并不是搜索框。
多个环境的日志可能落在同一个日志库里。历史环境字段还有不同写法,预发布和正式环境又可能使用同一类生产配置。只筛一个 env=prod,不一定能把两者分开。
平台因此先读取选中服务的目标声明,确认集群、命名空间和 Deployment,再生成受约束的 SLS 查询。环境字段用于缩小范围,完整实例名用于确认归属;源端过滤以后,返回的每条日志还要再核对一次。缺少归属字段、无法确认属于当前目标的历史记录,不混进结果。
另一个容易被忽略的地方是翻页。用户选“最近 15 分钟”,翻到下一页时,如果又按当前时间重新计算窗口,前后两页就不是同一批数据了。我们把起止时间固定在第一次查询里,游标绑定这段时间和筛选条件,只有显式刷新才重新计算窗口。
查询历史日志也不要求那个 Pod 现在还活着。实例被滚动替换以后,旧实例的日志仍然是排查发布前后差异的重要依据。
页面最后保留几种不同结果:查询完成但没有匹配;还在扫描、只有部分结果;没有接入日志;查询失败。它们不能都叫“暂无日志”。否则一个正在扫描的查询,就可能被理解成“这个请求根本没有发生”。
8. 告警:85 起新告警,为什么会变成 460 条通知
一次 24 小时的审查里,我们看到 85 起新告警,其中 84 起被标成 P1,最终产生了 460 条送达通知。
第一反应很容易是发送重试出了问题。但那次检查到的发送账本里,没有发现同一逻辑发送被重复提交,尝试次数都是一次。通知多,需要继续看事件怎么分类、一次事件发给多少人,以及触发和恢复各发了什么。
缓存成本、核心流程失败、预期的业务限制,都挤在相近的紧急等级里。把这些事件同时发给多人,发送链路完全正常,人也照样会被淹没。
我们后来按事件含义拆开默认策略。正式环境的持续服务失败、最终任务失败保留即时通知;性能退化、容量风险、缓存成本进入 P2,按小时汇总;额度不足等预期限制进入 P3,只保留事件。测试、预发布和环境未知的信号,也不再直接套用正式环境的打断方式。
这背后也划了三段职责。Grafana 负责评估规则、产生触发和恢复信号;平台负责事件分级、通知节奏,以及这起事件应该通知哪些人;实际发送继续调用已有的消息服务,平台保存投递记录。判断异常、决定是否打断人、把消息送到,是三件分别可能失败的事。
收件人也跟着事件保存下来。事件持续期间,服务负责人可能变化,但一条恢复通知应该能接回之前那次触发通知。不能临时再查一遍最新负责人,就让开始和结束发给完全不同的人。
缓存命中率就是一个具体调整。那次采用的规则看 30 分钟的 Token 加权命中率,低于 75% 并持续 15 分钟才触发,归到成本与效率类的小时汇总。它依然值得处理,但通常不需要和用户任务持续失败用同样的节奏通知。
错误告警则不能只看百分比。请求很少时,一两次失败就能把比例拉得很高;一味提高最小样本量,又会漏掉低流量接口的持续失败。所以普通错误规则结合请求量、错误量和持续时间,并另外保留低流量严重失败的条件。
恢复通知也补了等待。那次审查里,很多事件在触发后两分钟内恢复,这不能直接证明它们都是误报,但足以提醒我们别把一次短暂抖动立刻拆成两轮打断。P1 恢复默认等待五分钟,收件人和通知方式沿用触发时记录的策略。
这次调整还有个不太漂亮、但值得写出来的结果:把预发布与正式环境拆开以后,规则数量增加,部分新增规则被共享 Grafana 的配额拒绝了。平台保存了策略,不代表告警引擎已经开始评估。我们必须把“保存成功”“引擎已应用”和“消息已送达”分开核对。
上面的数字属于那次审查窗口。我不会据此写一个长期降噪百分比,因为后面还需要同口径的通知样本和漏报复盘。
9. 发布:重试一个 job,不应该再产生一次新发布
发布最初由 CI 脚本直接操作运行环境,后来运行底座从托管应用逐步迁到 Kubernetes,再把发布执行权交给统一平台。
为什么只换一层页面还不够?因为入口变多以后,CI、页面和其他工具都可能更新同一个目标。每个入口各自维护重试、回退和状态判断,就会出现两套人都以为自己负责发布的逻辑。
我们把 CI 留在构建和提交请求这一侧:产出镜像,登记不可变摘要及构建来源,再提交平台发布。平台负责解析目标、检查配置、执行更新和记录结果。移交以后,平台报错就处理平台的失败,不再偷偷回到旧脚本直接改集群。
版本也不能只记源码提交。同一个提交,换一套构建输入,仍可能得到另一份镜像。对于要求经过预发布的服务,正式发布要使用同一份不可变制品和兼容配置的验证依据,不能拿“同一个提交重新构建了一次”代替。没有声明预发布阶段的服务,按自己的环境流程执行。
再看一个容易出错的情况:CI 已经提交发布,平台正在滚动更新,HTTP 响应却丢了。job 失败以后点重试,如果把新的 job ID 当成一次全新发布,就可能重复执行。
我们的处理是把操作身份稳定在同一次流水线、提交、模块、目标和动作上。重试时先查原操作;原来已经受理,就沿着原来的发布单继续等。平台先保存操作记录,再交给持久 Worker 执行,网页关掉或者客户端超时,不会因此丢掉这次操作。
持久 Worker 也不能只做成一段不停轮询的代码。多个 Worker 同时运行,或者旧进程卡住以后被新进程接管,都要考虑。
我们先在数据库事务里保存发布意图,提交以后再允许 Worker 去操作外部资源。领取任务时锁住目标和发布记录,检查原租约是否仍然有效,再递增执行代次。其他 Worker 跳过已经被锁住的目标,或者发现租约有效后继续找下一项。
假设 Worker A 拿到第 7 代执行权,调用集群时卡住了。租约过期,Worker B 接管同一张发布单,拿到第 8 代。此时 A 突然恢复,不能让它把迟到的结果盖回去。
第 7、8 代是说明并发顺序的示例编号,不是实际发布记录。
因此,写入进度时还要核对当前操作、目标、执行代次、Worker 身份和有效租约。只要其中一项不匹配,旧执行者就失去写回资格。这里的租约决定什么时候可以接管,执行代次则用来识别“回来得太晚的人”,两者作用不同。
但这还没把外部操作变成“绝对只执行一次”。租约到期不会撤回一条已经发给集群的网络请求。所以更新和删除资源时,还要检查资源 UID、版本和操作标记;已经发出、又没有拿到确定结果的动作,要读回实际资源,沿着原操作恢复判断,不能换一个编号再做一次。
我们用 PostgreSQL 同时保存发布意图、领取资格和进度,让这些更新能在本地事务里约束。引入一个消息队列也不会自动解决外部操作和数据库之间的一致性,因此这里先把原有记录的状态变化做完整。相应的代价是 Worker 领取会占用数据库锁,轮询和并发规模需要控制。
进程退出时则先停止领取,给已经开始的步骤留出收尾时间。来不及完成的操作保留可恢复状态,不为了让关机过程看起来成功,就提前写成执行成功。重试规则也留在具体任务里:发布、资源同步和通知投递的失败含义不同,不能用一个统一的“异常就再跑一次”覆盖它们。
查询也不能只问“这个服务有没有成功记录”。还要核对是不是这次操作、这个目标、这份制品,避免把另一次成功发布当作本次结果。
回退同样只引用已记录的历史成功发布,不靠猜镜像标签。切回旧版本以后,还要重新验证业务路径;平台完成了版本切换,只回答了切换有没有执行完。
10. 回收环境:同名的新实例,不能被旧任务删掉
个人环境能自动创建以后,关闭合并请求时自然也希望自动回收。但“收到关闭事件,然后按名字删除资源”是不够的。
考虑这个顺序:环境开始回收,删除请求的响应没有回来;用户又重新打开同一个改动,准备创建新环境;旧清理任务随后重试。只认名字,就有误删新实例的风险。
我们给环境记录了代次和创建操作的身份。关闭时先阻止继续发布,等待已有执行租约结束,再冻结目标。删除使用账本里记录的精确资源 UID 和版本条件;只有旧代确认删除后,才允许新一代复用同样的资源名。名字可以相同,资源身份必须是新的。
云资源同步里也有类似的问题。供应商接口查询失败,只说明这次没取到状态,不能把上次还在的资源全标成不存在。完整查询成功以后才更新观察结果;失败则保留上次记录,并标明本次查询失败。
“从平台取消管理”和“销毁云上的实例”也分开处理。前者改变平台的管理关系,后者会真实删除资源,两种动作不能共用一个含糊的删除入口。
这些边界让自动化代码多了不少状态判断,但省掉它们的代价,会落在真正创建、删除的那一刻。
11. 最后沉淀下来的,是不用每次重做的接入步骤
做完前面的东西,新服务再接入时,就不应该从复制几份旧配置开始。
标准服务模板带上基本运行与监控接入,标准客户端负责采集数据库、缓存和外部依赖调用。服务声明自己用了哪些能力,再对应到需要的监控模板和发布目标。实际跑起来以后,还要检查有没有样本、规则有没有应用,不能把生成了一份配置当成接入结束。
页面之外,我们也提供 CLI。流水线通过它提交发布,人用它查状态,Agent 也可以调用同样的入口。它们引用同一个目标和同一张操作记录,不各自猜一套资源名,也不把一个超时统一包装成失败。
这对 Agent 参与运维尤其实际。它遇到发布结果未知,下一步应该拿原操作编号去查询;查询没有监控样本,就应该保留数据缺口。把这些输入输出做好,比单纯让它多执行几个命令更有用。
回头看,我们对架构的判断也在变。早期更关注功能怎么接上:能部署、能查询、能通知。后来关注的是同一个动作被多个人、多种工具、多个进程同时碰到时,系统还能不能说清楚它的状态。
有些地方因此收敛了:发布执行统一进入控制面,平台内部减少跨进程转发,入口共用同一套用例。有些边界反而划得更细:请求进程和持久 Worker 分开,数据按模块确定写入者,租约与执行代次分别校验,告警触发与消息送达分别记录。
我现在更愿意拿具体问题检验这些设计:同一次发布重试会不会多执行一次,旧 Worker 回来能不能覆盖新状态,个人环境是不是测到了自己的执行链,查不到数据时页面会不会误导人。能把这些问题答清楚,架构图上的那些线才有意义。