后端架构演进:服务拆分、环境选路与研发平台建设
生成链路最初由两个核心服务组成:Gateway 接收请求并处理项目接口,Runtime 运行 Agent,调用模型和工具,返回执行过程与结果。
随着业务增加,项目管理、调度和不同客户端的接入逐步拆开。服务之间的调用、环境选择、发布与排障,也需要相应调整。本文按这些问题的出现顺序,介绍各阶段的结构和实现方法。
1. 服务拆分
1.1. 初始结构
早期 Gateway 通过 Kitex RPC 调用 Runtime。Runtime 已经维护任务状态与事件,也采集运行、工具调用和错误指标。项目、素材、用户及其他外部依赖,围绕这两个核心服务接入。
当时服务地址和发布目标比较固定。个人 Gateway 对应个人 Runtime,共享开发环境使用另一组应用。CI 根据提交来源选择目标,构建镜像,再更新相应云应用。
服务较少时,这些对应关系容易维护。调试人员知道请求经过哪两个应用,流水线也只需要更新少量固定目标。
1.2. 项目与调度的拆分
Gateway 中的逻辑增加后,一次改动可能同时涉及客户端协议、项目状态和任务执行。我们把项目管理与调度抽出,让它们分别维护自己的状态。
拆分后的主要职责如下:
| 组件 | 负责的内容 |
|---|---|
| Gateway | 客户端协议、请求校验和必要的业务编排。 |
| 项目服务 | 项目、版本等业务对象及其状态。 |
| Dispatcher | 任务提交、排队与分发,并组织项目创建和首轮执行。 |
| Runtime | Agent 执行、模型与工具调用、执行状态和结果。 |
入口校验请求后,通过 RPC 调用相应服务。Dispatcher 组织项目创建和首轮执行,需要异步处理的事件与后续工作进入队列。项目对象、执行任务和客户端连接由此具有独立的生命周期。
这使不同入口能够复用同一份项目状态,调度可以单独处理积压,Runtime 也可以围绕执行能力演进。相应的代价是调用次数和版本组合增加,一次请求开始需要跨服务追踪。
图中按职责归纳阶段,各阶段都有其他依赖,迁移期间也存在新旧实现并行的情况。
2. 环境选路
这里的灰度主要指按环境标记定向验证,以及预发布环境选择。目标是让一次请求经过一组匹配的服务版本;按百分比放大正式流量属于另一种发布策略。
按照目标地址的确定方式,这部分可以分为四轮调整。
2.1. 固定地址映射
第一轮给开发人员分配固定应用。CI 将代码部署到这些应用,请求携带环境标记,Nginx 根据标记选择对应的上游地址或端口。Gateway 再根据配置连接 Runtime。
这种方式要维护三组关系:人员与应用、环境标记与地址、Gateway 与 Runtime。每增加一套环境,都要同步更新这些配置。
这个阶段 Nginx 选择的上游中包含 NLB 地址。后续基础设施迁移改变了 NLB 与 Nginx 的连接关系,早期选路方式需要按当时的拓扑理解。
2.2. 命名空间与服务发现
第二轮通过服务名寻找目标。调用方先在当前命名空间查询同名服务,明确不存在时,再根据规则查找共享环境。
实例地址变化后,由服务发现和 DNS 更新,调用方继续使用稳定服务名。允许共享回退的依赖,也不必在每个个人环境中部署一份。
限制在于,环境身份与进程所在命名空间绑定。若一个命名空间内需要同时运行多组实例,只根据命名空间已无法区分它们。
2.3. 服务名前缀
第三轮把环境标记加入服务名。同组个人实例使用相同前缀,调用方优先查询带前缀的目标,再按策略选择共享版本。Nginx 也可以根据环境标记推导服务名,减少逐个维护地址映射的工作。
CI 在部署时把环境身份写入实例配置;调度队列也逐步从命名空间维度转为环境身份维度。
但共享进程可以接收不同环境的请求。例如共享 Gateway 收到个人请求,如果后续服务发现只使用 Gateway 自己的启动配置,仍然会调用共享下游。因此,还需要将进程身份与本次请求选择的环境分开。
2.4. 请求上下文
第四轮把环境选择放入请求上下文。HTTP 入口识别环境,标准 RPC 客户端将其传给下游;发送消息时写入消息属性,消费时再恢复为上下文。
这时,共享 Gateway 可以接收个人请求,再调用对应的个人 Dispatcher 和 Runtime。是否存在个人入口,与后续任务应由哪组实例执行,可以分别决定。
服务发现缓存也需要包含环境维度。若缓存只按逻辑服务名保存,环境 A 查询到的地址就可能被环境 B 复用。
环境标记只用于选路,用户身份与操作权限仍然独立校验。共享 Gateway 的进程环境也不会因为一条个人请求而改变。
2.5. 回退条件
不同依赖采用不同选路策略:
- 优先个人:存在个人版本时使用个人版本,满足回退条件时使用共享版本;
- 必须个人:必须命中目标环境,缺失时返回错误;
- 仅共享:始终使用共享实例。
关键调度和执行链要求命中同一组实例。普通依赖是否可以共享,由对应策略确定。
允许回退时,也要区分“未部署”和“运行出错”。明确的 DNS 不存在可以表示目标未部署;DNS 超时、连接失败或应用错误则不能直接当作未部署。否则新版本出错后转用共享版本,测试结果就无法反映新版本的实际行为。
3. 分支环境的发布与回收
3.1. 构建范围与运行依赖
严格选路之后,目标环境必须包含必要的服务。
例如只修改 Gateway,CI 按代码差异可能只生成 Gateway 的构建与发布任务。但新环境中的 Dispatcher 和 Runtime 尚不存在,请求就无法继续执行。
我们因此为关键链路增加发布组。命中相关改动时,除代码依赖决定的构建范围外,还要补齐这组服务的运行依赖。
构建可以并行进行;部署按依赖顺序,先 Runtime,再 Dispatcher,最后入口。发布组保证按约定准备链路,不提供跨服务事务,也不意味着数据库和全部外部资源都被复制。
只发布改动服务适合已有完整、兼容依赖的环境。创建新环境时,还需要检查那些没有代码变化、但执行链必需的服务。
3.2. 环境生命周期
个人环境后来逐步改为按功能分支创建。分支打开时准备环境,更新代码时继续发布,合并、关闭或到期后回收。这样,同一个人可以同时维护多套改动,每套环境都有明确的代码来源和寿命。
临时环境使用内部 Service,并按策略复用共享依赖。共享入口的 NLB 绑定不能直接复制给个人实例,否则个人实例可能接收到共享流量。数据库和外部资源的隔离范围,也需要单独定义。
回收时还要处理资源名称复用的问题。
同名环境关闭后重新创建,已经是另一代资源。旧回收任务如果延迟执行,不能仅根据名称删除新环境。
因此,回收先关闭当前代次,阻止新发布,等待正在执行的操作退出,再按保存的资源 UID 等精确身份删除。新建环境使用新的代次和资源身份,迟到的旧任务据此失效。
选路负责确定请求去哪一组实例,生命周期管理负责确定这组实例何时存在,两者使用同一份环境身份关联。
4. 接入层与移动端
4.1. 按业务拆分 Gateway
项目和执行服务拆出以后,大 Gateway 中仍有不同领域的客户端接口。后续按业务逐步划分接入服务,通过 Nginx 路由让新旧入口并存。
外部调用方继续使用稳定入口,Nginx 按路径选择对应 Gateway。一个领域迁移完成后,再移除旧路由和旧实现。
迁移中的错误处理需要明确。新 Gateway 返回错误时,自动转到旧实现可能掩盖问题;写操作重试到另一上游,也需要考虑原请求是否已经执行。
图从业务 API 的 NLB 展开,各层分别负责:
NLB:将连接交给入口实例;Nginx:HTTP 路由、协议升级,以及通过子请求衔接鉴权;Gateway:对应业务的客户端协议与必要编排;WAP:面向移动端的创建、会话和流式交互协议;内部服务:通过 RPC 提供项目、版本、调度和执行能力。
域名前的代理与 TLS 终止配置,按具体入口确定,不在图中统一假设。
迁移到 Kubernetes 时,我们保留外部入口地址与监听,将后端接到由集群控制器维护的 Pod 集合。原托管平台管理的服务器组,需要重新确认管理归属,不能假设新控制器会直接接管。
4.2. HTTP、WebSocket 与 RPC 流
移动端需要更集中、稳定的创建和对话协议,因此增加了与普通 Gateway 并列的 WAP。它复用已有的项目、版本、文件、调度与执行能力。
一次创建请求通过 HTTP 返回项目对象。客户端随后通过 WebSocket 连接项目,WAP 校验访问权限,再桥接内部双向 RPC 流,将客户端命令和执行事件进行转换。
关闭页面结束的是观察连接。若用户要求停止生成,需要发送指向当前轮次的终止命令;已经存在的项目和任务不会因为连接关闭而自动删除。
按业务拆分 Gateway 和增加移动端入口,解决的是两种接口变化,但业务状态仍由原服务维护,避免不同入口各存一份项目数据。
5. 指标采集与查询
5.1. 统一采集方式
Runtime 和 Gateway 原来已有各自的指标。服务增加后,主要问题是同一个概念可能使用不同的标签、分母和聚合方式,面板之间难以比较。
公共组件逐步接管重复的采集逻辑:Kitex 中间件记录 RPC 调用量、错误、耗时和并发;Hertz 入口统一采集 HTTP 指标;队列与业务代码补充任务相关指标。
服务定期将指标发送到托管 Prometheus 的 PushGateway,再由 Prometheus 保存和查询,Grafana 展示并计算规则。
服务清单声明能力与部署目标,对应的监控模板生成面板和规则。平台根据实际目标确定查询范围。服务代码仍需产生正确样本,声明某项能力本身不会生成数据;采集未接入时,应当反映为缺少数据。
5.2. 成功与失败的统计范围
一次请求可能在入口成功接收后继续排队,也可能在多次工具调用后失败。因此,不同层的成功与失败要分别统计。
HTTP 成功表示入口接收或处理了这次请求,RPC 成功表示该次调用完成。最终任务失败率则按任务的终态统计,一次任务中的多次重试放在调用指标中。
用户断开页面、主动取消任务和执行器异常,也要保留不同结束原因。否则一次用户请求中的三次调用失败,会被误解成三个用户任务失败;入口持续返回 200,又可能掩盖后续任务异常。
5.3. 依赖资源指标
数据库、Redis 等依赖同时需要观察应用调用和资源运行情况。
客户端插件或 Hook 记录调用方、耗时与错误;云监控提供实例 CPU、内存、连接数和 I/O。先对齐资源身份与时间窗口,才能判断延迟来自调用方、连接池还是资源本身。
查询目标也需要准确。预发布和正式环境可能使用同一份生产配置,单一环境标签未必足以区分,要继续约束实际部署实例。共享 Gateway 接收个人请求时,进程环境与请求环境不同,也不能把全部基础指标都改写为请求环境。
5.4. 告警处理
Grafana 计算告警规则,平台维护事件状态、分级和通知节奏,既有消息服务负责发送。
调用错误、队列积压、最终任务失败和资源饱和分别设置条件。多个指标可能来自同一故障,通知时需要结合事件状态组织,而不是每层都无差别发送。
平台在这里主要维护服务、部署目标、实例与告警对象之间的关系,指标存储与查询仍然由 Prometheus 和 Grafana 完成。
6. 日志采集与查询
6.1. 两条采集通道
跨服务排查需要将日志汇聚到一起,并保留请求、任务与运行实例的关联。业务服务和 Nginx 使用不同的采集通道。
业务代码通过公共日志接口写入,公共库补充服务、运行环境和调用位置,保留上下文中的请求与任务字段,再由 SLS 后端异步批量发送。标准输出仍可保留,用于进程启动和现场排查。
Nginx 不直接调用 Go 日志库。它先写结构化访问日志和错误日志,由随入口镜像运行的采集进程读取文件,解析字段、脱敏并批量写入 SLS。
入口日志中的请求编号、上游、状态和耗时,说明请求经过了哪一跳;业务日志中的请求、任务和执行编号,用于继续查找后续处理。消息队列 SDK 等依赖库的日志,也需要接到公共通道,才能在同一时间窗口内排查。
6.2. 查询范围
日志集中后,需要进一步限定查哪一组实例。
预发布与正式环境共用生产配置时,只筛选 env=prod 会混入两组实例。平台根据目标声明确定完整实例范围,加入固定时间窗口,再查询并核对返回记录的归属。
翻页也使用同一窗口,避免第一页和第二页覆盖不同时间范围。日志字段还需要正确建立索引,否则记录已经写入,查询仍然可能没有结果。
一条日志从写入到被查出,会经过应用输出、采集与发送、SLS 接收、字段索引和查询条件。排查时逐段确认;发布验证则使用同一个请求编号,检查入口和业务日志能否被找到。
异步发送还存在进程退出、发送失败和缓冲积压等情况,所以任务最终结果仍以业务持久记录为准。日志集中也不自动等同于完整的分布式调用追踪。
7. 发布制品与执行记录
7.1. 从固定目标到发布操作
早期 CI 直接维护提交来源与应用目标的对应关系,完成构建、推镜像和更新应用。
服务拆分后,构建范围要展开共享代码影响,发布范围还要补齐运行依赖。页面、CLI 和自动化也开始发起发布,若各自拼接集群、应用和环境配置,就容易出现不同入口指向不同目标的情况。
后来,发布目标与执行过程收进研发平台。CI 提交可核验的构建结果,平台登记制品、解析目标、保存发布操作,再由持久 Worker 执行。各入口使用同一个操作编号查询进度。
发布记录需要保存以下关系:
| 关系 | 需要保存的内容 |
|---|---|
| 代码与制品 | 提交、真实构建任务、镜像 digest 和构建来源。 |
| 制品与目标 | 逻辑服务、目标环境、部署对象和配置身份。 |
| 目标与流量 | Service 选择器与实例标签;入口还涉及 NLB 监听及后端集合。 |
| 实例与观测 | 实际实例范围、日志字段、指标模板和查询条件。 |
| 操作与结果 | 发布进度、实际目标和制品,以及对应的功能验证结果。 |
镜像 digest 为构建产物提供确定身份。对于声明了预发布的服务,晋级正式环境时需要核对同一制品和兼容配置;重新构建后得到的新镜像,需要重新确认。没有预发布声明的服务,使用自身声明的发布流程。
配置也要能够识别。有的配置随镜像封装,有的由外部提供,操作记录需要说明本次使用哪份配置。
实例启动以后,还要确认 Service 选择器是否命中它,NLB 后端是否由正确控制器管理,个人实例是否误带共享入口绑定。否则进程正常运行,也可能接不到目标请求。
7.2. 超时与恢复
发布请求超时,并不能确定部署动作失败。调用方可能只丢失了响应,Worker 仍然在更新目标。
调用方先查询原操作,核对目标、制品和当前进度,再决定是否需要后续处理。若直接新建发布,两次操作可能同时修改同一个目标。
提交入口还需要保留原操作的查询凭证。例如在提交前生成请求编号,平台接受后保存它与操作编号的关联。首个响应丢失时,调用方也能据此找回同一次发布。
平台内部还需要互斥、租约和执行代次。Worker 接管时更新执行身份,迟到的旧结果不能覆盖新状态;外部部署动作则需要继续核对实际目标,不能仅靠数据库状态判断已经停止。
研发平台由此承担持久执行、恢复与排障的责任。它不在用户业务请求的转发路径上,但新的发布依赖它正常工作。
8. 整条链路的验证
上面的机制最终需要在同一个任务上验证。以一套分支环境为例,验证过程可以分为:
- 确认分支环境及必要依赖已经准备,目标版本与制品一致;
- 发起生成请求,检查入口识别的环境与实际后端;
- 沿 RPC 和消息检查 Dispatcher、Runtime 是否保持同一环境选择;
- 检查任务结束原因、产物与用户得到的版本;
- 用同一请求或任务编号查到入口和业务日志;
- 确认监控查询的是本次部署的实例,并验证目标功能。
流水线完成、Pod Ready、HTTP 成功、任务结束分别验证其中的一部分。保存它们对应的对象和结果,才能说明这次变更实际运行到了哪一步。