Appearance
ModelDock 的设计起点:预算、交付与流量是三笔不同的账
搜索团队想把一个模型上线。第一次请求超时后,同事又点了一次提交。你要能回答三件事:是否重复占了预算,模型是否真的启动,调用者能否拿到第一个响应块。它们需要三种不同证据。先把服务启动起来,再加预留与网关,最后把这条请求完整跑一遍。
先用 ./scripts/start.sh 跑单机网页,提交 team-a 的两个 1 槽模型,再提交第三个。QuotaBlocked 是平台自己的判断;此时没有 Pod、更没有 Scheduler 事件。进阶实验改用 SQL 账本和真正的控制器。同一份请求经历三个不同的裁决:能不能花租户预算,哪个节点能容纳 Pod,节点实际交付哪个设备。任意一层成功都不能替代下一层验证。
flowchart LR; CLI-->SQLQuota; SQLQuota-->Reservation; Reservation-->Outbox; Outbox-->ModelService; ModelService-->Operator; Operator-->Deployment; Deployment-->Pod; Pod-->Scheduler; Scheduler-->kubelet; kubelet-->DevicePlugin; Gateway-->EndpointSlice; EndpointSlice-->Pod
单机控制台和 Go API 使用 JSON store;集群实验的入口是 admission CLI、Gin API 与 Kubernetes API,二者没有共享数据库。端点路由和设备分配独立于 SQL。生产设计可让 Vue 控制台经过鉴权 API 调用同一准入服务,用 MySQL 做持久事实,Redis 做短期限流而不做最终配额真相。Prometheus 采集端点级负载,Grafana 展示,KEDA 读取指标。进阶控制台位于 console/,使用 Vue 3 + TypeScript 访问集群推理网关与诊断 API;旧 JSON 演示页面仍可独立运行。教材直接引用两个项目的源码。
建模:每种记录由谁更新?
| 持久事实 | 写者 | 不能据此推出 |
|---|---|---|
| reservation(id,tenant,cost,priority,state,node) | 准入服务 | reserved 不等于 Pod 已获设备 |
| tenant_quota 与 gpu_pool 的 used/cap | 同一 MySQL 事务 | logical_pool 不是 scheduler 选中的节点 |
| outbox.delivered、binding.uid/ever_ready | 投递与对账 worker | 成功交付 CR 不代表还有可用端点 |
| ModelService.spec / status | 客户端写期望,Operator 写观察 | Ready 不代表网关已授权该租户 |
| EndpointSlice、路由快照 | K8s 发布、网关定期读 | 旧 IP 不保证现在还有同一后端 |
erDiagram
TENANT_QUOTA ||--o{ RESERVATION : owns
GPU_POOL ||--o{ RESERVATION : budgets
RESERVATION ||--o| OUTBOX : dispatches
RESERVATION ||--o| BINDING : tracks上图是业务关联,并非每条线都有数据库外键。申请状态 waiting → reserved → released,过期等待者进 expired;Pod 的 Pending/Running 和 API 请求的 202 是另两套状态。Go 适合实现事务、HTTP 取消和 K8s 客户端;vLLM 处理模型计算,不决定租户预算;Redis 适合限流而不做最终配额账本。需要额外 Operator 的原因是对象重建和漂移纠正必须持续发生,而非只响应第一次 HTTP 提交。
完整源码:modeldock/admission/api.go · 第 39–55 行
go
r.POST("/requests", func(c *gin.Context) {
var b struct {
ID string
Cost int
Priority int
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 8192)
if c.ShouldBindJSON(&b) != nil {
c.Status(400)
return
}
if e := submit(db, b.ID, c.GetString("tenant"), b.Cost, b.Priority); e != nil {
c.JSON(409, gin.H{"error": "invalid or conflicting request"})
return
}
c.JSON(202, gin.H{"id": b.ID})
})从协议开始写,而非从表开始画
POST /requests 带 Authorization: Bearer 和 JSON {"id":"demo-a","cost":1,"priority":50},服务端从鉴权得到 tenant;202 返回 id。GET /requests/:id 同时以 id、tenant 查询;同一租户重提相同 payload 可重读,改变 cost 是冲突。推理接口 POST /v1/chat/completions 是另一条请求链,有独立模型权限。项目的交付顺序是搭环境 → CRD 与 Operator → 一次原子预留 → 流式响应 → 扩缩容 → 端到端验收。
最后串起一次用户请求:每个环节承诺了什么?
| 实现顺序 | 先写哪个最小行为 | 增加的验收,而不是功能清单 |
|---|---|---|
| 1. 受理 | submit 插入 waiting,主键用请求 ID,不以名称代替租户鉴权 | 同 ID、同参数重试返回同一记录;改变 cost 被拒;另一个租户不能查询 |
| 2. 准入 | admit 一次事务选择等待项、更新两份用量、写 outbox | 20 个并发申请不会承诺第 3 个名额;成功行数、已用量、发件箱行数一致 |
| 3. 投递 | deliver 通过确定名字创建 ModelService,绑定 UID 后才标记送达 | 处理 Create 成功、数据库确认尚未成功这一窗口,不重复扣费 |
| 4. 启动 | Reconcile 创建带所有者的 Deployment/Service,status 对照真实副本 | 重复执行不重写对象;删去受管子资源可以重新创建,不能接管同名异主对象 |
| 5. 流量 | 网关从 ready EndpointSlice 选路并转发 SSE | 既验证正常流 DONE,也验证拒绝其他租户和取消传播 |
| 6. 回收 | 进入删除、等待对象和 Pod 消失,然后条件更新账本 | 连续调用两次 release 只归还一次;每种失败能用 ID 和 UID 对账 |
上表是运行时请求经过的顺序。开发时先让声明收敛,再加入计费;为每个阶段先写右列的失败断言。不要先把所有后台 worker 和监控服务都启动,否则失败不易定位。