ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

erupt-cloud-server 深入解析:Erupt 分布式部署控制平面的注册、路由与代理机制

erupt-cloud-server 深入解析:Erupt 分布式部署控制平面的注册、路由与代理机制
  • 后端
  • 低代码
  • AI 应用
  • 人工智能
  • AI Agent
  • 认证鉴权
  • RAG

【免费下载链接】erupt

🚀 通用数据管理框架,VORM 对象视图模型,注解驱动低代码开发

项目地址:https://gitcode.com/erupts/erupt
点击查看免费下载

erupt-cloud-server 是 Erupt 分布式部署架构中的控制平面(Control Plane):它统一注册管理一组erupt-cloud-node工作节点、向节点下发配置、并把 API 与 UI 请求代理到正确的节点,同时提供一个用于节点健康监控与管理的管理界面。读完本文,你将掌握该模块的完整配置项(erupt.cloud-server.*)、节点注册/心跳/摘除的生命周期、基于 Redis 的节点状态存储、请求代理与故障转移的实现细节,以及如何与节点端erupt.cloud-node配置配对完成一次真实的多节点部署。

模块定位:erupt-cloud 三件套

Erupt 的分布式能力位于 erupt-cloud 模块下,由三个子模块构成。erupt-cloud-server 的定位可以概括为:

Control plane for Erupt distributed deployments. Manages a fleet oferupt-cloud-nodeworkers: registers nodes, distributes configuration, and proxies API and UI requests to the appropriate node. Provides an admin UI for node health monitoring and management. Built on top oferupt-adminwith Ant Design UI.

结合 pom.xml 的依赖声明可以印证这句话:模块依赖 erupt-cloud-common(server 与 node 共用的 DTO、常量与工具类),以provided作用域引用erupt-upms(用户与权限)和erupt-security(API 防护)——这正是 erupt-admin 打包的两大能力;同时依赖erupt-tpl与erupt-tpl-ui.ant-design,说明管理界面基于模板引擎 + Ant Design 组件渲染,并引入freemarker用于节点信息/节点日志等行内模板页。

三个子模块的分工:

子模块角色说明
erupt-cloud-common共享库通用 DTO(NodeInfo)、REST 路径常量(CloudRestApiConst)、请求头常量(CloudCommonConst),不直接用于业务工程
erupt-cloud-node工作节点启动后向 server 注册,接收被代理的请求并在本地执行;使用 Spring AOP 透明拦截请求
erupt-cloud-server(本文)控制平面节点注册、配置下发、API/UI 代理、节点健康监控管理界面

server 端的自动装配入口是 EruptCloudServerAutoConfiguration:它通过@ComponentScan+@EruptScan+@EntityScan扫描自身包下的组件与实体,并以EruptModule身份向 Erupt 注册模块信息(erupt-cloud-server/ "Erupt Cloud Microservice control center"),同时声明了节点管理的整套菜单(见后文"管理界面"一节)。

服务端配置项:erupt.cloud-server.*

所有服务端可调参数集中在 EruptCloudServerProp,通过@ConfigurationProperties("erupt.cloud-server")绑定。完整参数与默认值如下:

配置项类型默认值含义
erupt.cloud-server.nodeExpireTimeInteger600000(10 分钟,毫秒)节点在 Redis 中的缓存存活时长,即注册信息 TTL
erupt.cloud-server.nodeSurviveCheckTimeInteger120000(120 秒,毫秒)节点存活巡检任务的执行周期
erupt.cloud-server.cloudNameSpaceStringerupt-cloud:节点注册信息在 Redis 中的 Key 命名空间
erupt.cloud-server.validateAccessTokenbooleantrue节点注册/配置拉取时是否校验 Access Token
erupt.cloud-server.nodeRequestTimeoutInteger30000(30 秒,毫秒)server 向节点转发请求的读超时,作用于共享的RestClient

配置示例(放入 server 端工程的application.yml):

erupt: cloud-server: nodeExpireTime: 600000 # Redis 中节点注册信息的 TTL(毫秒) nodeSurviveCheckTime: 120000 # 存活巡检周期(毫秒) cloudNameSpace: "erupt-cloud:" validateAccessToken: true # 是否校验节点的 Access Token nodeRequestTimeout: 30000 # 转发到节点的读超时(毫秒)

各参数的底层作用位置:

  • nodeExpireTime在 NodeManager.putNode 中作为RedisTemplate.opsForValue().set(...)的 TTL 写入;
  • nodeSurviveCheckTime是 NodeWorker 定时任务的调度间隔;
  • nodeRequestTimeout在 EruptCloudServerAutoConfiguration#nodeRestClient 中构建 server → node 的共享RestClient时作为读超时生效;
  • cloudNameSpace与固定前缀node:拼接出每个节点的缓存 Key(见下节);
  • validateAccessToken在 EruptNodeMicroservice.findNodeByAppName 中决定是否比对 Token,注册与配置拉取路径都会走到这里。

节点注册与心跳:一个实例如何进入集群

节点状态的核心数据结构是 MetaNode,它继承自 common 模块的 NodeInfo,在共享字段(instanceId唯一实例 ID、version版本、nodeName节点名、accessToken访问令牌、nodeAddress上报地址数组、eruptModules本节点承载的模块列表、erupts本节点承载的 erupt 列表、resources可选模块贡献的命名资源)之外,扩展了:

  • registerTime:本次服务注册时间;
  • locations(Set<String>):该节点当前所有可用实例地址的集合——一个nodeName可以对应多个实例,这是多实例负载均衡与故障转移的基础。

注册接口

节点端 EruptNodeTask 启动后以守护线程方式进入注册循环:每heartbeatTime(节点端默认 15 秒)构造一次NodeInfo并POST到 server 的注册接口。server 侧的落点链路如下:

  1. 入口 EruptMicroserviceController.registerNode,路径为CloudRestApiConst.REGISTER_NODE,即POST /erupt-cloud-api/register-node;
  2. 先经 EruptNodeMicroservice.findNodeByAppName 用nodeName在数据库CloudNode表查出节点档案,若validateAccessToken=true则比对accessToken,不匹配直接抛错——也就是说节点必须先在管理界面里建好档案(拿到 Token)才允许注册;
  3. 检查CloudNode.status,被禁用的节点会被拒绝注册(返回 500 并提示 "prohibiting the registration");
  4. 若主应用类上标注了@EruptCloudServer(见"扩展点"一节),回调其Proxy.registerNode(metaNode, request);
  5. 对metaNode.getNodeAddress()中每一个地址执行健康探测(CloudServerUtil.nodeHealth):GET{location}/node-health,要求 2xx 且响应体与nodeName一致,任一地址不通则整体注册失败;
  6. 最后调用 EruptNodeMicroservice.registerNode:与 Redis 中已存在的locations合并、追加本次上报的地址、刷新registerTime并putNode写入 Redis。

节点端上报的地址默认由本机回环 IPv4 地址 +server.port+context-path自动拼装(协议前缀由erupt.cloud-node.schema控制,默认http);在多层代理等复杂网络环境下可通过erupt.cloud-node.hostAddress显式指定对外可达地址,这一设计直接体现在 EruptNodeTask.run 的分支逻辑中。

由于心跳会周期性重放注册流程并刷新 Redis TTL,nodeExpireTime(默认 10 分钟)相当于"心跳丢失后节点被自然淘汰"的兜底窗口。

优雅下线

与注册对称的是摘除接口POST /erupt-cloud-api/remove-instance-node(EruptMicroserviceController.removeInstanceNode),参数为nodeName、accessToken与可选的locations(逗号分隔)。节点端在 EruptNodeTask.destroy 中按"两阶段"完成下线:

  1. 精确摘除:把本实例注册的地址列表上报给 server,EruptNodeMicroservice.removeInstance 调用nodeManager.removeNodeInstance逐个删除locations中的条目,使 server 立即停止向该实例路由,不必等待存活巡检;若未上报地址则退化为触发一次NodeWorker.run()全量健康巡检;
  2. 排空(drain):节点端等待已受理的请求处理完毕,等待上限为erupt.cloud-node.drainTimeout(默认 30 秒),期间通过 EruptNodeInterceptor.inFlight() 的在途计数轮询;超时仍有在途请求会打印告警。

基于 Redis 的节点状态存储

NodeManager 是节点状态的读写中枢,其设计要点:

  • Key 结构:{cloudNameSpace}node:{nodeName},即默认erupt-cloud:node:order-service;
  • 值序列化:复用应用注入的RedisTemplate连接工厂,但单独构建一个以 Gson JSON 序列化MetaNode的模板(setRedisTemplate)。注释中说明这样做的收益:给NodeInfo增加字段不会使旧缓存失效;遇到旧二进制格式(JDK 序列化)的历史条目时解析失败按"不存在"处理,等下次心跳重写即可;
  • 写语义:putNode时若locations为空集则直接删除该节点缓存(putNode),保证"无实例"的节点不会残留在路由表里;
  • 实例级摘除:removeNodeInstance只移除指定实例地址(null 安全,重复摘除无副作用);evictInstance是其"尽力而为"版本,吞掉异常——请求路径上发现死实例时先摘掉,后续由存活巡检兜底对齐;
  • 全量视图:findAllNodes以数据库CloudNode表登记过的节点名反查 Redis(multiGet),因此"管理界面可见、但当前离线"的节点会表现为无缓存,不会出现在全量列表里。

多实例选择:轮询 + 故障转移候选序

pickLocations实现了节点内多实例的选择策略(NodeManager.pickLocations):

  • 无实例时抛出EruptWebApiRuntimeException('{nodeName}' has no available instance);
  • 单实例直接返回;
  • 多实例时用一个共享的AtomicInteger游标做轮询(round-robin):返回的列表中第一个元素是本次主选,其余元素按轮转顺序作为故障转移候选,所有实例都保留在候选序列中。

对应的单元测试 NodeManagerTest 锁定了三组行为:单实例恒等返回;三实例时每次调用"头部轮转、成员不丢、整圈回到原点";空集合抛异常。

健康巡检与死实例摘除

NodeWorker 在@PostConstruct中启动单线程定时调度,按nodeSurviveCheckTime(默认 120 秒)执行一轮巡检:遍历findAllNodes(),对每个实例地址调用 CloudServerUtil.retryableNodeHealth(nodeName, location, 2, 200)——最多重试 2 次、每次间隔 200 毫秒;仍有存活实例变化的节点会回写putNode刷新缓存(同时续期 TTL)。

健康探测本身使用一个独立的 1 秒超时客户端(源码注释:"Health probes must answer fast; a slow instance counts as down",健康探测必须快速应答,慢实例按宕机计),探测目标正是节点端 NodeController 暴露的GET /node-health——该接口直接返回eruptNodeProp.getNodeName(),server 侧用"响应体 == nodeName"来确认探到的确实是目标节点而非挂在同地址的其他服务。

故障转移的安全性边界

转发请求时并非"失败就重试"。EruptCloudServerInterceptor.httpProxy 按pickLocations的顺序逐个尝试,只有当异常被 CloudServerUtil.isConnectFailure 判定为"连接从未到达节点应用"时才向下一实例故障转移,并顺手evictInstance摘除死实例。isConnectFailure沿 cause 链解包,仅识别ConnectException、UnknownHostException、NoRouteToHostException、HttpConnectTimeoutException四类;读超时(SocketTimeoutException)或应用级 500 都不算——因为请求可能已被节点处理,重试会重复写入。

CloudServerUtilTest 把这条安全边界固化成了断言:三类连接异常及其嵌套包装形式判定为可转移,而SocketTimeoutException("read timed out")与业务异常判定为不可转移。这是理解"为什么某些故障会重试、某些不会"的直接证据。

请求代理:浏览器流量如何到达正确的节点

erupt-cloud-server 的核心职责之一是"proxies API and UI requests to the appropriate node"。实现落在 EruptCloudServerInterceptor,它拦截/erupt-api/**(EruptRestPath.ERUPT_API),只在目标 handler 标注了@EruptRouter时介入。

路由键:nodeName.eruptName

代理的路由依据是@EruptRouter(verifyType = ERUPT)接口上携带的 erupt 名。请求头erupt(或 URL 参数_erupt)取值若包含点号,按最后一个点切分(preHandle):

order-service.xyz.erupt.sample.model.Customer ───────────────────────────────────────── nodeName: order-service eruptName: xyz.erupt.sample.model.Customer

代理前的鉴权在 server 侧完成:token 与在线会话(SessionKey.TOKEN_OLINE)校验失败返回 401,父级菜单权限getEruptMenuByValue(authErupt)校验失败返回 403;随后把用户、erupt、token 写入MetaContext/NodeContext并进入proxyAndRespond。

两个值得注意的旁路:

  • @EruptRouter.cloudProxy()默认为true;对"档案数据在 server 本地"的接口(如记录评论,节点既没有该模块也不认识 server 的用户),把cloudProxy置为false即可让 server 自己应答,节点名仅作为寻址依据。该语义完整写在 EruptRouter.cloudProxy 的注释中;
  • 节点承载的模板页走/erupt-api/tpl/路径,此时没有 erupt 名可路由,改由显式的_node查询参数指定节点,路径原样透传(preHandle 中的 tpl 分支),并按 tpl 路径做菜单权限校验。

转发细节

httpProxy 的关键处理:

  • 请求头:整体拷贝后剔除逐跳/成帧头(Host、Origin、Content-Length、Connection、Transfer-Encoding、Expect、Upgrade、Accept-Encoding);其中Origin被有意剔除——节点端 EruptNodeInterceptor.preHandle 会拒绝携带Origin的请求(即浏览器直连节点,节点 API 只允许 server 转发),而剔除Accept-Encoding使节点以 identity 编码返回,方便透传;
  • 注入的交互头:Access-Token(节点档案中的 Token)、token(当前用户会话)、erupt(节点本地的 erupt 简单名)、user(Base64 编码的MetaUserJSON);钻取来源头DRILL_SOURCE_ERUPT会剥掉nodeName.前缀还原为节点本地名;
  • 请求体:普通 body 一次性读入字节数组(这样故障转移到下一个实例时可以重放,而不必重读一次性的 servlet 输入流);multipart/form-data则被逐 part 重编码,由 Spring 重新生成 boundary;
  • 响应回写(proxyAndRespond):透传Content-Type与Content-Disposition;非 200 状态缓冲小体积 body、记录错误日志并原样回传;/erupt-api/build/{erupt}的构建响应需要缓冲反序列化为EruptBuildModel,由 eruptBuildProcess 把主模型、drill 链接、operation/tab/combine 子模型的 erupt 名统一补回nodeName.前缀,使前端在"节点聚合视图"里看到的仍是全限定名;其余响应(数据、Excel、文件、大体积 JSON)一律StreamUtils.copy流式透传,不做全量缓冲。

节点端收到转发请求后,由 EruptCloutNodeInterceptor 复核erupt头在本节点真实存在(不存在返回 404),再由 EruptNodeInterceptor 校验Access-Token并从token/user/erupt头重建MetaContext——于是节点本地完整地重跑自己的校验、权限与 DataProxy 管线。

进程内路由:CloudNodeRouter

浏览器请求走的是上面的 HTTP 拦截器短路;但 AI/MCP 工具、工作流等进程内调用者走的是 Erupt 数据管线而非原始 HTTP。CloudNodeRouter 在@PostConstruct中注册为EruptRemoteRouter,以nodeName.eruptName形式的点分名识别远程 erupt(isRemote),并提供:

  • resolveErupt/resolveEruptView:解析远程 erupt 的模型/视图(视图按需懒加载 schema,注释明确"Only the schema is fetched (lazily); data is never cached",只取结构、从不缓存数据);
  • remoteEruptNames:汇总所有节点的 erupt 全限定名列表;
  • tableQuery、findById、insert、update、delete:把 Erupt 标准数据 API 转发到节点(路径模板中的/{erupt}占位符替换为节点本地简单名)。

其exchange方法与拦截器共用同一套请求头方案、pickLocations轮询与"仅连接失败才转移"的策略,并在转移时同样evictInstance,保证进程内路径与浏览器路径的一致性。

管理界面:节点档案、配置下发与权限

节点档案模型

管理数据由两个实体承载,均通过注解驱动生成低代码界面:

CloudNode(表e_cloud_node,菜单名 "Node Config"):

字段说明
nodeName节点名,唯一索引,注册寻址的主键
name友好名称
cloudNodeGroup所属节点组(REFERENCE_TREE引用,形成组 → 节点树)
status启用/禁用,禁用后节点注册被拒绝
eruptNum/eruptModuleNum/instanceNum/version瞬态字段,列表查询时由 CloudNodeProcess.afterFetch 从 Redis 的MetaNode实时填充(模型数、模块数、实例数、版本),即"节点健康监控"的数据来源;节点离线时显示-,取数异常时版本列渲染红色错误提示
duty值守人,标签来源于用户列表(fetchTags)
config节点配置,JSON 代码编辑器,注释明确"Config can be read by child nodes"(子节点可读取)
accessToken访问令牌;新建节点时若留空,beforeAdd 自动生成 16 位大写随机码;列表展示时脱敏为"前 3 位 + ****** + 后 3 位",完整值需通过 "View Token" 行操作查看
remark描述(HTML 渲染)

行操作(定义在 CloudNode 的 @Erupt 上):

  • View Token:微前端模板页/tpl/node-info.ftl(MICRO_FRONTEND嵌入),受cloud_access_token_permission权限控制;
  • Node Log:抽屉式日志页/tpl/erupt-log.html(底部抽屉、高度 80vh),仅在节点版本可用(item.version !== '-')时显示,受erupt_cloud_node_log权限控制;日志经 EruptNodeController.eruptNodeLog 代理到节点端/erupt-api/tool/log接口取回;
  • 实例移除:管理端也可主动摘除某实例,入口为 EruptNodeController.removeInstance(GET /erupt-cloud-api/remove-instance/{nodeName}?instance=...,受cloud_node_manager_permission控制)。

CloudNodeGroup(表e_cloud_node_group,菜单名 "Group Config",树形菜单且默认隐藏):code(唯一)、name、config(组级 JSON 配置)、remark。

菜单整体结构由 EruptCloudServerAutoConfiguration.initMenus 声明:根菜单$NodeManager("Micronode Management")下挂组树(隐藏)与节点列表,按钮权限对应 CloudServerConst 中的三个权限码cloud_access_token_permission、cloud_node_manager_permission、erupt_cloud_node_log。

配置下发

server 上维护的两级 JSON 配置通过 EruptServerApi 的两个只读接口下发,节点端 ServerRemoteService 拉取:

接口鉴权返回
GET /erupt-cloud-api/node-config/{nodeName}请求头Access-Token与档案一致CloudNode.config
GET /erupt-cloud-api/node-group-config/{nodeName}同上节点所属CloudNodeGroup.config

同文件还暴露两个协作接口:GET /erupt-cloud-api/erupt-power?eruptName=&nodeName=供节点侧查询"某用户在nodeName.eruptName上的增删改查/导入/导出/详情权限位"(eruptPower);GET /erupt-cloud-api/userinfo/{nodeName}返回当前 server 侧登录用户的简要信息,供节点端重建用户上下文(注意其verifyType = LOGIN,即只验登录)。节点侧 ServerRemoteService.getMenuCodePermission 也会回到 server 校验菜单码权限——权限的最终裁定权始终在 server。

扩展点:@EruptCloudServer

CloudServerUtil.findEruptCloudServerAnnotation 会在注册/摘除两个时机检查主应用类上是否标注了 @EruptCloudServer:该注解的value指定一个实现了EruptCloudServer.Proxy的 Bean,Proxy 提供registerNode(metaNode, request)与removeNode(nodeName, request)两个默认空实现钩子,供部署方在节点加入/离开时执行自己的联动逻辑(如同步到其他注册中心)。

此外,distribute 包中的 DistributeAbstract(init/distributePut/distributeRemove)抽象出了节点状态变更的分布式分发骨架,是从"单 server + Redis"向多 server 部署演进的预留扩展面;从源码结构看,当前默认实现即直接使用 Redis 缓存,DistributeAbstract作为扩展抽象存在。

实战配套:节点端erupt.cloud-node.*配置

要让一个 Spring Boot 应用成为可被本模块管理的节点,需要在节点端配置 EruptNodeProp(前缀erupt.cloud-node):

配置项默认值说明
erupt.cloud-node.enableRegistertrue是否启用节点注册
erupt.cloud-node.nodeName无节点名,必须与 server 档案中的nodeName一致(建议填 Java 工程名)
erupt.cloud-node.accessToken无在 server 管理界面生成/查看的访问令牌
erupt.cloud-node.serverAddresses无server 地址数组,支持多 server;多地址时getBalanceAddress轮询取用
erupt.cloud-node.schemahttp自动注册地址的协议前缀
erupt.cloud-node.hostAddress无显式指定本实例对外可达地址(多层代理场景必配)
erupt.cloud-node.heartbeatTime15000心跳(注册刷新)周期,毫秒;连接失败时按半周期重试
erupt.cloud-node.drainTimeout30000优雅下线时等待在途请求排空的超时,毫秒

配置示例:

erupt: cloud-node: nodeName: order-service accessToken: A1B2C3D4E5F6G7H8 # 在 server 管理界面"View Token"获取 serverAddresses: - http://cloud-server:8080 # hostAddress: # 多层代理时显式指定 # - http://order-1.internal:8081

节点启动日志会打印节点名、server 地址与版本横幅,注册失败按heartbeatTime/2间隔重试并计数,成功恢复时输出绿色 "Connection success" 提示(EruptNodeTask.run)。

小结

erupt-cloud-server 用一个相当克制的技术组合完成了控制平面的全部职责:数据库(CloudNode/CloudNodeGroup档案)承载节点身份与配置,Redis 承载带 TTL 的运行时实例视图,单线程定时巡检负责最终一致性,拦截器 + 共享RestClient负责请求转发与安全的故障转移。它的几个设计取舍——心跳刷新 TTL 而非依赖主动注销、"仅连接级失败才转移"的保守重试、build 响应重写 erupt 前缀而数据响应流式透传、节点 API 拒绝浏览器直连——都直接体现在 NodeManager、EruptCloudServerInterceptor 与其配套测试(NodeManagerTest、CloudServerUtilTest)中,值得在多实例、多节点的管理系统代理层设计中参考。

  • 后端
  • 低代码
  • AI 应用
  • 人工智能
  • AI Agent
  • 认证鉴权
  • RAG

【免费下载链接】erupt

🚀 通用数据管理框架,VORM 对象视图模型,注解驱动低代码开发

项目地址:https://gitcode.com/erupts/erupt
点击查看免费下载

相关推荐

上一篇:IronClaw 中 Google Sheets `delete_sheet` 工具能力深度解析:从 Capability ID 到 batchUpdate 的完整调用链
下一篇:FerretDB v1.17.0 技术解析:无后端构建、$natural 排序与 listCollections 增强

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进