已关闭
[提案] v1 调度面架构演进:意图 MySQL 化 · 调度面多活无状态 · Agent 上报制 · 控制面合并 #4
rd-tan创建于  24 天前关闭于  7 天前
rd-tan成员
24 天前 创建

[提案] v1 调度面架构演进:意图 MySQL 化 · 调度面多活无状态 · Agent 上报制 · 控制面合并

状态:已提交(openFuyao/SandboxSched Issue #4,评审中)
标签建议:proposal / enhancement
影响范围:internal/sandboxsched-controller、internal/sandboxsched-scheduler、cmd/sandboxsched-{controller,scheduler}、charts/sandboxsched、internal/sandboxwatcher(一处适配)


一、背景与动机

v1 现架构存在四个结构性限制(均附代码锚点):

1. 意图经 CR 异步落盘,存在"接受但未持久化"窗口
沙箱创建走 FastPath:先 agent create 成功、Sandbox CR 异步补写(sandbox_controller.go:594-614)。崩窗期意图仅存进程内存;controller/scheduler 重启恢复全靠 CR 全量重建(RebuildRouter controller.go:142-174、restoreSSG ssg_controller.go:354)。

2. controller 单实例 + 内存权威态
--leader-elect=false(main.go:49),chart replicaCount=1;内存 Router 三张 map 是权威状态(round-robin 决策、sandboxInfos),实例故障即调度面不可用;重启恢复依赖全量 CR List 回放。

3. scheduler 是静态分片而非多活
StatefulSet×3 按 SSG label 静态绑定(API 层过滤,ssg_watcher.go:61-63),实例故障需重新分片才能恢复容量;InMemoryRegistry(registry.go:28-52)承载全量 reservation/slot/心跳,进程内权威态。

4. 观测面轮询
scheduler 2s Loop 对全部 agent 并发 GetStatus 刷新心跳(60s 过期,loop.go:124-163、registry.go:706),与 Create 请求争抢同一 agent HTTP server(高水位 50 退避,loop.go:53-54 注释明示)——agent 侧被动挨扫,观测开销随沙箱规模线性增长。

二、目标架构

sandboxsched 调度面(多活 Deployment ×N,合并替代 controller + scheduler 两组件)
├─ 入向:gRPC SDK(SandboxProviderService 契约不变,:9091)+ 内部 HTTP(原 :9090 面)
├─ 意图:Sandbox → MySQL manifest(双腿并发同步落盘,CAS 状态机)
│        —— agent 派发与意图落盘双腿并发:双腿成功才返回 201,补偿语义消灭
│           "接受但未落盘"窗口
├─ 观测:agent 5s 上报心跳 → 任意实例接收 → Redis(2s 全量轮询退役,按需拉取兜底)
├─ 容量:Redis 原子预约账 + MySQL CAS 持久门(多实例并发创建不超卖)
├─ 周期任务:过期 GC/孤儿清扫协调/SSG 调和 → K8s Lease 选主(仅周期任务选主,请求面真多活)
└─ 保留不动:SG/SSG CR(容量/模板声明)、watcher DS、agent 执行路径、/api/v1/schedagent 协议

三、四点改动(现状锚点 → 设计方案)

1. 意图存储:CR → MySQL(同步双腿落盘)

  • 现状:CR 唯一读写者 = scheduler 侧 SandboxController(CR worker 有序队列/restore/list);controller 侧三处 CR 索引与重建;gRPC 面四个 CR 读点(List/Get/SetLabel/convert,sandbox_provider.go:325-424);finalizer 门控删除
  • 方案:CR 读写面整体替换为 MySQL 存储层;manifest 表以单事务写入完整意图 JSON(结构见数据模型);派发采用双腿并发 + 补偿设计——agent 派发与意图落盘同时进行,双腿成功才对外成功,任一腿失败触发补偿(agent 回滚删除或意图行转孤儿由清扫任务收敛);finalizer 机制随 CR 退役(删除走账本状态机推进);SandboxWatcher informer 退役(事件源三路替代:FastPath 直调 / leader 周期扫描 / 心跳与 gRPC RPC 转移);提供 SandboxSpec→意图 JSON 的映射层(逐字段转换,含 resources 量纲换算与 expireTime→TTL)

2. scheduler 多活无状态(+Redis)

  • 现状:InMemoryRegistry 全内存权威;SSG label 静态分片;实例故障需重新分片
  • 方案:registry 消解——心跳/视图/沙箱投影 → Redis(任意实例 LB 可收可读);容量预约 = Redis SET 原子账(SADD+SCARD 校验)+ MySQL CAS 持久门,调和对账任务修复 drift;Deployment ×N 替代 StatefulSet 分片;请求面天然多活(MySQL CAS 并发安全),仅周期任务选主

3. Agent 上报制

  • 现状:agent 纯被动 HTTP server(不轮询);2s Loop 全量 GetStatus 拉取是观测瓶颈与争抢源
  • 方案:agent 侧新增周期上报组件(5s+jitter+指数退避,断连自愈)POST → 调度面 Service(LB 到任意实例)→ 心跳接收端点 → Redis;env SCHEDULER_REPORT_URL 由 SSG controller constructPod 注入(SSG CR schema 不动);按需 GetStatus 兜底保留

4. 控制面合并 + 选主

  • 现状:controller/scheduler 两进程经 5 个 HTTP RPC 协作(SchedulerClient);Router.Resolve 内存 round-robin;gRPC 入向面独立部署
  • 方案:两进程合并为单组件——5 RPC 内联为进程内调用;Router.Resolve 内联进调度流水线(Redis 视图+SSG informer cache);gRPC server 迁入;周期任务统一收敛到选主 Runner 执行——K8s Lease 协调,同一周期任务全局单实例运行;请求面多活不选主(MySQL CAS 已并发安全)

四、数据模型

MySQL(新增两张核心表,DDL 随实施给出)

表 列 承载
sandbox_manifest uid(PK) / ssg_key / agent_pod / node / spec / created_at / updated_at 意图权威:spec = 意图载荷(v1 SandboxSpec 映射,自描述 kind 标记;序列化格式随实现演进——JSON/PB 均可,列名不编码格式);agent_pod = 落位 agent pod(派发寻址的身份标识,地址经 pod watch 解析)
ssg_index uid(PK) / ssg_key / agent_pod / state / attempt / generation / last_phase / last_seen / expires_at CAS 状态机;last_phase 承载 v1 用户可见 phase

快照台账不在本次范围:v1 的 Snapshot RPC 经 templatemanager(E2B Template Manager)编排,快照目录在 E2B 侧——维持现状("其他保持不动"),不引入 sandbox_snapshots/snapshot_artifacts 表;如未来需要一方快照台账,作为独立增强另行提案。

state 词汇映射(CAS 状态机为准,v1 phase 为 last_phase 投影):

v1 phase ssg_index.state
创建中 仅 Redis 预约账(MySQL 行在派发成功时建)
Running bound@A
agent pod 消失 host_lost
恢复中 restoring@B
Paused paused
删除中 terminating → 行删
孤儿 orphaned

Redis(新增,key 设计经操作清单逐条推演——心跳/预约/Resolve/清扫/对账五操作全覆盖,统一 prefix 隔离)

Key 类型 支撑操作 说明
{prefix}:ssg:{ssgKey}:agents ZSET member=agentID, score=lastHeartbeatUnix 心跳 ZADD / Resolve ZRANGE / 清扫任务 ZREMRANGEBYSCORE(leader 周期) 成员+活性合一:score 即心跳时刻,清扫一条命令批量清过期,Resolve 一次取全 SSG 存活列表
{prefix}:agent:{agentID} HASH(TTL 90s 随心跳续) 心跳写 / Resolve 读 agent 自述视图:上报地址(pod IP:5758)、runtime 形态、capacity-limit echo、版本——不含沙箱向量与 allocated(后者=容量账 SCARD)
{prefix}:cap:{ssgKey}:{agentID} SET 成员=uid create Lua(SADD+SCARD 容量校验)/补偿(SREM)/删除(SREM)/调和对账(diff manifest) 预约+在位统一账(uid 集合可枚举对账);删除流程或对账任务收敛

设计取舍说明(相对初稿的操作推演修订):

  • 活性机制合一:ZSET score 替代独立 seen 键——心跳一条 ZADD 完成成员登记+活性刷新,清扫一条 ZREMRANGEBYSCORE 批量清过期(无 SET/seen 失配窗口)
  • 删除沙箱 phase 投影(ssg:{ssgKey}:sandboxes:{agentID}):phase 权威在 MySQL,低频读直查即可——对账面从双向 diff 缩为单向(容量账 vs manifest);若实测 Resolve 热路径需要投影,登记为演进位
  • allocated 字段删除:容量账 SCARD 即 allocated,避免双写漂移

权威边界:

  • MySQL = 意图与分配状态机唯一权威(CAS 并发安全)
  • Redis = 易变观测 + 原子计数(可重建,调和任务 diff MySQL 修复 drift)
  • K8s CR = SG/SSG 声明;K8s Lease = 周期任务选主
  • 路由真相 = MySQL manifest.agent_pod(Redis 只放观测投影)

Agent↔Scheduler 连接拓扑:agent env 注入调度面 Service DNS(非实例地址)——推送 LB 到任意健康实例,写共享 Redis,实例故障自动摘除转移;下行派发由任意实例查 MySQL manifest.agent_pod 直连 agent pod(pod 网络全可达)。两方向均无实例绑定。

五、兼容性承诺

  • gRPC SDK(SandboxProviderService)契约与响应形状零变化——SDK 用户无感
  • agent 侧 /api/v1/schedagent 协议、沙箱执行路径(runtime/manager)零修改(仅新增上报 env 注入)
  • SG/SSG CR 定义与语义零变化(容量/模板继续走 CR)
  • watcher DS 保留,仅孤儿判定的"CR 缺失"检查改查 MySQL(正确性依赖的唯一必要适配)
  • 面向全新部署形态,不做存量 Sandbox CR 迁移(已上线版本不在范围内)

六、部署改动(charts/sandboxsched)

# 改动 现状 → 目标
1 组件拓扑 controller-deployment(×1)删除;scheduler-statefulset(×3 静态分片)→ scheduler-deployment 多活 ×N(副本可配;SCHEDULER_NAME Downward API 退役)
2 Service 合并 controller-service(gRPC 9091)+ scheduler-service(9090)→ 单一调度面 Service(gRPC+HTTP+心跳端口一次暴露)
3 依赖启用 MySQL/Redis 部署新增独立开关(与现有门控解耦);DSN/连接串进 ConfigMap/Secret
4 RBAC 合并 controller-rbac+scheduler-rbac 合并:SG/SSG 全权限保留;Sandbox CR 权限移除;新增 coordination.k8s.io/leases(选主);watcher-rbac 不动
5 配置注入 合并组件 env:MYSQL_DSN/REDIS_ADDR/选主配置;agent pod 模板新增 SCHEDULER_REPORT_URL env(constructPod 注入,SSG CR schema 不动)
6 退役清理 SCHEDULER_NAME Downward API、controller-configmap 内容并入、探针重写(readiness=gRPC+HTTP+MySQL/Redis 连通性)
7 测试部署面 test/e2e/topology 引用 controller/scheduler 组件拓扑的部署用例同步更新

七、分阶段实施

阶段 内容 量级
P0 基线分支 + 部署骨架(上表 1-3、5-6) S
P1 意图账本:MySQL 存储层 + SandboxSpec 映射层 + CR 读写面替换 + 双腿并发派发与补偿 + finalizer 退役 L
P2 多活+Redis:视图层移植 + Registry 消解 + Redis 原子容量账 + CAS 持久门 L
P3 上报制:心跳接收端点 + agent 上报接线 + Loop 轮询退役 M
P4 合并+选主:5 RPC 内联 + Resolve 内联 + gRPC 迁入 + 周期任务 Lease 门控 + SG/SSG reconciler 迁入 M
P5 watcher MySQL 检查适配 + chart 收尾 + docs S
P6 集成验证(kind 真跑,见验证标准) M

八、验证标准

  • 全程 TDD;全量单测 + -race 绿;行为零变化(SDK 响应/SG·SSG CR 语义/agent 协议逐字回归)
  • kind 真跑取证:全生命周期(create/delete/pause/resume/snapshot)/ 多实例 LB(双实例并发创建容量不超卖实证)/ kill 实例(心跳与请求自动转移)/ kill leader(周期任务漂移接续)/ gRPC SDK 兼容回归
  • codecheck 门禁绿

九、风险与对策

风险 对策
reservation 原子性迁移引入超卖 Redis SET 原子账 + MySQL CAS 双门 + 调和对账任务 diff 修复 + 多实例并发真跑实证
意图落盘与 agent 派发的双臂失败窗口 双腿补偿语义(孤儿态→清扫任务收敛)
心跳丢失误判(Redis 抖动) TTL 宽裕(60s×N)+ 按需拉取兜底 + agent 推送指数退避自愈
合并后进程过大 gRPC/HTTP/流水线/周期任务按包清晰分层;选主 Runner 隔离周期任务
likedislike
Rrd-tan成员
24 天前 添加了label:proposalenhancement
openFuyao-botopenFuyao-bot成员
24 天前 添加了label:sig/sig-agent-sandbox
openFuyao-bot
openFuyao-bot成员
24 天前 评论:

欢迎来到 openFuyao 社区

Hey @rd-tan , 感谢你对社区的贡献.

机器人使用手册

有关指令的使用,可以点击 此处 查看详情。开发人员可以在每个PR或Issue下方评论特定指令来触发机器人任务。您可以自助配置此仓库的 PR 合并规则,更多详情,请参阅此处。

联系指引

有疑问可以联系 SIG: sig-agent-sandbox ,
维护者是: @Gao-Pan, @fisher789, @ruanhanqing, @wwthw, @zbbbc, @zjw6 ,
审核者是: @Gao-Pan, @fisher789, @ruanhanqing, @wwthw, @zbbbc, @zjw6 .

likedislike
Rrd-tan成员
24 天前 修改了issue 的描述
Rrd-tan成员
24 天前 修改了issue 的描述
Rrd-tan成员
24 天前 修改了issue 的描述
pervasive成员
24 天前 评论:

@rd-tan
根据我对这个提案的理解,目标架构整体流程可能是:沙箱创建请求 -> scheduler服务 -> 从redis查询agent,执行filter,筛选符合条件的所有agent -> 执行score,选择一个最优agent -> 更新redis中agent空闲槽位信息 --> 调用agent pod接口创建沙箱,同时写sandboxSpec到mysql,以及写running状态的status到mysql -> agent返回response,失败则回滚mysql,成功则写sandboxStatus到redis-> 返回201给客户端,不确定我理解的对不对?

我的一些疑问和建议:
1、每个沙箱创建请求需要执行filter和score两阶段,随着调度策略越来越复杂,耗时也会越来越长,filter和score涉及查询redis和更新redis,加锁的范围是什么?如果加锁范围很大,并发能力会很弱。如果加锁范围很小,怎么解决并发冲突的问题?
2、之前保存在Sandbox CR中的沙箱信息,可以分为三类:一是沙箱创建请求下发的静态spec信息(image、resource、sg等),二是调度器完成调度后分配的信息(agent_pod,node),三是沙箱运行时完成沙箱创建后的信息(endpoint、phase等)。改为mysql存储后,这三类信息分别存在什么地方?
3、manifest表应该只负责承载沙箱静态spec信息,agent_pod和node字段是调度完成后才有的信息,为什么也放到这个表里?
4、ssg_index表的定位是什么?每个字段的作用是什么?state和last_phase字段有什么区别?agent_pod为什么在manifest和index表里都有?
5、数据库表建议统一以t_xx格式命名
6、沙箱ID字段命名建议uid -> sid
7、manifest表spec字段建议待后续spec字段稳定后需要拆分为基础字段和扩展字段
8、sg拆分为多个ssg,之前是为了scheduler多实例分片的目的,存在一个问题:一个沙箱属于sg1,随机分配给ssg1后,ssg1的agent pod已经没有空闲槽位了,但是同样属于sg1的ssg2还有空闲槽位,导致沙箱分配失败,实际上sg1还有空闲槽位。目标架构下,应该去掉ssg,直接sg -> agent pod。后续可以通过独立pr重构。

likedislike
rd-tan成员
23 天前 评论:

感谢 @ruanhanqing 的细致评审——逐条回应。其中 #5/#6/#7 直接采纳,#8 认同方向并登记后续独立提案,#1/#3/#4 给出设计与实证。

0. 流程确认:您理解的顺序有一处关键修正

您描述的"更新 redis 空闲槽位 → 调用 agent 创建"方向正确,但更精确的表述是——"检查容量"与"更新容量"不是两步,是同一步原子操作:

filter(纯读:容量/端口/资源筛除)→ score 排序(纯内存)
→ Reserve(★ Redis Lua 原子:SCARD 校验 <capacity 则 SADD,同脚本内完成)
→ 占坑成功才调 agent 创建(leg A)∥ 写 MySQL manifest(leg B)
→ agent 调用失败 → Release(SREM 退坑)+ 补偿

Redis 单线程执行 Lua 脚本,任何副本的 Reserve 天然串行化——不存在"先检查后更新"的竞态窗口。

另一点澄清:运行态不由 scheduler 写 Redis——agent 侧 5s 周期主动上报(含沙箱向量与自述视图),观测通道是上报制。

1. filter/score 的锁与并发冲突:全程无应用层锁,原子性由 Redis 保证

  • filter/score 为纯读(ZRANGE/HGETALL/SCARD),不加锁、不构成冲突点;
  • 唯一并发冲突点=容量账原子性,由 Reserve 的 Lua 脚本解决:校验与写入同脚本,Redis 单线程串行化——无需分布式锁、无大锁小锁之争;
  • 占坑失败自动级联重试(两级,无原地死磕、无后台队列):
层级 触发 动作 兜底
候选级 单 agent 占坑失败(坑被他人原子占走) 按分数序落次优候选继续 Reserve(同候选不重试——失败即被占,原地重试无意义) 全部耗尽→503
SSG 级 503 该 SSG 进排除表,ResolveExcluding 换同 SG 下一个 SSG 重发创建(仅 503 触发,有界重试) 全部耗尽→返回调用方(同步语义)
时间维 最终失败 scaleUp 扩容/沙箱释放后容量自然恢复,下一次创建请求自然成功 对账任务修漂移
  • 集群实证:双实例 10 并发创建(容量 5)→ 恰好 5 成 5 拒,四方对账(Redis/账本/agent/containerd)uid 集合完全一致;
  • 耗时量级:filter/score 为 O(SSG 内 agent 数) 内存读与打分,无网络扇出,策略复杂化只增加打分函数内计算,不增加锁竞争面。

2. 三类信息的存储归属

类 内容 存储(写入时机)
① 静态 spec image/resource/sg/命令等 sandbox_manifest.spec(create 同步落盘)
② 调度分配 agent_pod/node sandbox_manifest.agent_pod/node(Reserve 后 leg B 同事务)+ ssg_index.agent_pod(状态机工作副本)
③ 运行时信息 endpoints/phase manifest 观测列(endpoints/portmap,create/resume 完成时刻窄写)+ ssg_index.last_phase(投影)

Redis 只承载三类易变观测(agent 存活性 ZSET/自述视图 HASH/容量计数 SET)——MySQL=意图与状态机权威,Redis=可重建投影(对账任务双向 diff 修复 drift)。

3. manifest 为何含 agent_pod/node(设计论证,请求保留)

关键在 manifest 行的诞生时机:该行创建于容量 Reserve 之后、双腿派发的 MySQL 提交时刻——此刻调度决策已定,agent_pod/node 是行诞生瞬间的初始事实,而非"后补的调度结果"。manifest 的语义是"该沙箱落位在哪、意图是什么"的完整事实快照:一次 INSERT 成形。

坚持单行全量的收益在读写路径:

读方 单行全量 拆表后
SDK GetStatus(热路径) WHERE sid=? 一次拿 spec+落位+endpoints JOIN/双查
恢复链(从账本意图重建) 一次拿 spec+endpoints+portmap+agent_pod 同上
过期 GC 扫描 expires_at+spec 单表 JOIN
容量对账 diff uid↔行 单查询 JOIN
fork-join 补偿(删除本次痕迹) 单行 DELETE 两表原子删除
  • 写路径:agent_pod 与 spec 同事务写入(意图与落位原子绑定);
  • 双表冗余是分工而非无意重复:manifest.agent_pod=落位事实权威(读投影,一次成形);ssg_index.agent_pod=状态机 CAS 事务内工作副本(转移时同读同写,拆开会把状态机事务变成跨表事务);
  • 诚实承认这是非范式化取舍(事实宽表模式,读多写少场景):文档化补强承诺——DDL 落地时逐列注释标注"事实维/过程维"归属与更新时机。

4. ssg_index 的定位与字段语义

  • 定位:CAS 状态机表——全部生命周期转移走 UPDATE ... WHERE state=from,多实例并发创建/删除/暂停正确性的核心机制;
  • state vs last_phase:state=内部转移词汇(bound/host_lost/restoring/paused/terminating/orphaned——含精细中间态,调度/恢复/清扫的决策依据);last_phase=用户可见投影(Pending/Running/Paused...——SDK 稳定展示词汇)。解耦意图:内部词汇允许演进,外部词汇保持稳定;
  • 其余字段:attempt(派发重试计数)/generation(代次)/last_seen(活性审计)/expires_at(TTL GC 驱动);
  • agent_pod 双表:同 #3 的"事实 vs 状态机工作副本"分工。

5/6/7. 直接采纳

  • #5 表名 t_ 前缀:采纳(t_sandbox_manifest/t_ssg_index——尚未建表,零成本);
  • #6 uid→sid:采纳(sandbox id 语义更准;列名与 Redis key 说明同步);
  • #7 spec 拆分:采纳方向、登记演进位——当前处于字段增长期(扩展参数/hostport/超分仍在加入),单列 JSON+自描述 kind 标记是过渡稳态;字段稳定后拆分基础字段(列化)+扩展字段(JSON),届时一并给出迁移方案。

8. 去掉 SSG:认同方向,登记后续独立提案

确认您的判断:SSG 的历史目的是 scheduler 多实例静态分片,多活无状态架构下分片失去存在理由。跨 SSG 容量不均(ssg1 满而 ssg2 有空闲)在现实现中已有缓解——#1 的 SSG 级排除式换路重试(一个 SSG 503 后自动尝试同 SG 下一个)——但"SG 直接 → agent pod"是更彻底的消解(容量聚合、消除随机分配失败路径)。

本次范围裁定 SG/SSG CR 保持不变(控制单次提案影响面),去 SSG 重构登记为后续独立提案(含:SSG CR 退役评估、SG→agent pod 容量模型、存量 agent pod 归属迁移)。

修订计划

  • #5/#6 采纳 → 数据模型章节修订(t_ 前缀+sid);#7 → 演进位登记;#8 → 后续独立提案登记——修订稿更新后在本 Issue 同步。
likedislike
Rrd-tan成员
20 天前 关联了pull request:feat(v1evo): v1 调度面架构演进 — 意图 MySQL 化 · 多活无状态 create · Agent 上报制 · 控制面合并
Rrd-tan成员
7 天前 将 rd-tan 设为负责人
Rrd-tan成员
7 天前 issue状态由 开启 改变为 待测试
Rrd-tan成员
7 天前 issue状态由 待测试 改变为 已完成
Rrd-tan成员
7 天前 关闭了 issue