跳转至

EP14:节点池管理:把资源边界放进调度器

上集回顾 EP13《P2P 直连:比 CDN 更快的最后一公里》
下集预告 EP15《横向扩容和低延迟的关系》

0. 本集目标

看完这一集,观众应该能做到三件事:

  1. 说清楚"节点池"到底解决什么问题——它是在角色、区域等维度之外增加资源隔离边界;app 可以独占池,也可以共享池,不能把绑定一律解释成物理独占。
  2. 讲清楚一次调度请求怎么走:先按池得到可用候选,再按剩余容量择优;只有主池没有可用候选时,才讨论是否回退。
  3. 区分通用模型和 PPCDN 案例:通用系统可以按租户、用户或 app 建池;PPCDN 当前产品把多个 app 默认归入所属用户的池,也允许显式 app 绑定。

1. 开场钩子(逐字稿要点)

节点池不是简单的机器标签,而是调度器的候选集边界。需要先问清楚边界属于谁:租户池通常由同一租户的多个 app 共享,用户池也可能承载该用户的全部 app,只有专门分配给一个 app 的池才叫 app 独占。PPCDN 是一个实现案例:app 可以显式绑定池;未显式绑定时通常解析到所属用户的池。下面既讲通用调度原则,也标清这个案例的产品取舍。


2. 问题:为什么"角色 + 区域"两维还不够用

现状很朴素:Origin/Edge 节点只有两个筛选维度——角色(Origin 还是 Edge)和区域(region/city)。同一个角色的所有在线节点,被当成同一个大资源池来排序和挑选,不区分这批机器归属于谁、给谁用。

在下面这些场景里,这个模型就不够用了:

  • 大客户/高优先级 app 需要独占一批节点,或同一租户的多个 app 需要共享一批保留资源,避免被其它租户挤占容量。
  • 不同硬件规格/编码能力的节点要分开调度——比如支持更高分辨率编码的节点,只服务特定套餐。
  • 新版本或刚上线的节点需要先放进"灰度池"验证,不能立刻进入生产调度候选。
  • 运维希望按团队或合同边界拆分节点的管理权限和可见范围。

这些场景的共同点是:需要在一个"角色 + 区域"的结构上,再加一维节点池,作为资源的隔离边界。这一维不是把所有节点分成三六九等的"标签",而是一条谁能被看见的硬约束。


3. 机制设计

3.1 池是调度的第一层过滤

一次 Origin/Edge 调度请求,处理顺序是这样的:

  1. 解析请求所属的池:可以来自 app 显式绑定,也可以来自租户/用户默认池或共享池。
  2. 在该池中计算可用候选:角色匹配、在线、健康、未排空,并满足调度准入条件;仅仅“池里有节点”不等于有候选。
  3. 在可用候选中按剩余容量最大(free 最多,workerId 升序做平局规则)排序,取最优。

关键点是:池过滤发生在最前面,池内的排序再怎么变,都改不了"别的池的节点压根不在候选里"这个事实。这就是隔离的落点。

3.2 数据模型:节点声明归属,app 绑定池

两个实体,各司其职:

NodePool(poolId, name)                                     // 池的身份
AppPoolBinding(appId, poolId, allowFallbackToDefault)      // app 绑到哪个池、满了能不能兜底
  • 节点带一个 PoolId 字段,注册时写入——但写入的是控制面按节点凭证记录解析出的值,不是节点自己上报的(详见 §3.3);没有对应凭证或凭证里 PoolId 为空,统一归一化为内置的 default 共享池——存量节点零改造兼容,升级前是什么行为,升级后还是什么行为。
  • app 绑定与用户池不是同一个概念:显式 app 绑定决定该 app 的池;没有显式记录时,PPCDN 才按产品规则解析到所属用户的池。前者可做 app 级隔离,后者是多个 app 共享的用户级边界。
  • 节点池归属和 region 一样,属于"部署时静态确定"的属性,不是每次调度都变的动态数据。

对应实现:ppcenter/internal/models/node_pool.go、ppcenter/internal/store/node_pool_store.go、ppcenter/internal/store/app_pool_binding_store.go。

3.3 池归属由谁决定:从"节点自报"到"控制面权威分配"

这里有一段值得讲的演进,而不是一个一步到位的静态设计。

早期设计确实是"节点自报":ppmmx 在部署配置里有一个静态项 mmxNodePoolId,注册时通过当时的 WorkerIndication 消息的 poolId 字段(proto 字段号 11)带给 ppcenter,和 region、容量一样由节点自己决定、自己上报。

但现在已经不是这样了。2026-09-09 ppcenter 引入"控制台创建节点 + nodeSecret/licenseCode 注册鉴权"机制之后,注册协议改成了 NodeRegister(见 ppcenter/pb3/mmx.proto,字段只有 msgType/nodeSecret/nodeType/diskSerial/version/region/webrtcBaseUrl/publishUrl/abrNegotiationAddr/licenseCode 十个,没有 poolId 字段)。池归属变成了控制面权威分配,节点对此没有任何发言权:

  • 节点创建时(超管在控制台建节点,或自建节点首次用 licenseCode 完成注册)就在 mmx_nodes 表里生成一条凭证记录,PoolID 默认是该节点所属用户的 pool-<userId>(store/mmx_node_store.go 的 NodePoolIDForUser(userID))。
  • 节点每次用 nodeSecret/licenseCode 注册,ppcenter 都会先查出这条凭证记录,再服务端组装出内部使用的 WorkerIndication 结构,把 cred.PoolID 填进去(ppcenter/main.go 的 SetNodeRegisterHandler 回调:if cred.PoolID != "" { ind.PoolId = &cred.PoolID }),传给 NodeManager.RegisterIndication。
  • 节点本地不做任何池相关决策这句话依然成立,但原因已经从"控制面调度算法更适合做这件事"变成了更硬的约束——节点现在连"自报池归属"这个能力本身都没有了。

这不是随手改的,是一次安全收紧:一旦允许自建节点用 licenseCode 注册(可信程度低于托管节点),就不能再让节点自己上报"我属于哪个池"——否则一个自建节点理论上可以在注册时谎称自己属于别人的池,绕开"一个用户一个池"这条隔离边界。把池归属收回到控制面权威记录(节点连不上、改不了),才能保证隔离承诺不被节点端绕过。

对应实现:ppcenter/main.go(SetNodeRegisterHandler,cred.PoolID 组装进 ind.PoolId)、ppcenter/internal/store/mmx_node_store.go(NodePoolIDForUser、节点创建时写入 PoolID)、ppcenter/internal/manager/node_manager.go(applyIndication 写入在线节点)、ppcenter/pb3/mmx.proto(NodeRegister 消息)。ppmmx 侧历史上确实有过 mmxNodePoolId 配置项,但对应的 WorkerIndication.poolId 字段在当前注册协议里已经不存在——这个配置项即便还留在 ppmmx 代码里,现在也不会被 ppcenter 采信。

3.4 池内排序:只看剩余容量(顺手把 region 排序请下场)

池内怎么排序,本身也经历过一次修正。早期的版本是"city 优先级 + 健康度 + 剩余容量"三层排序;2026-09-20 之后,Origin/Edge 的池内排序收敛为只看剩余容量最大,region/city 不再参与。

这不是随手删的,而是职责重新划分的结果:"把节点分组"这件事已经由池来干了。如果池已经表达了你想要的那批机器,再叠一层区域优先级,只会让"池内按容量择优"的语义变得含糊——到底该优先照顾区域,还是优先照顾空闲度?删掉区域排序之后,池内规则干净到只剩一句话:谁最闲,谁上。RegionRoute 这套配置和后台界面保留了下来,但不再影响 Origin/Edge 选边。

3.5 回退语义:fail-closed,不静默突破隔离

这是整套机制里最需要讲清楚的一条,也是最容易踩坑的地方。

假设某个 app 绑定了池,但池内没有可用候选,怎么办?“没有可用候选”包括无节点、节点离线或不健康、处于排空状态,以及没有节点满足容量等准入条件;不能只用“节点数量为零”或口语化的“池满”判断。两种做法语义完全不同:

  • 静默回退到 default 共享池:调度"永远不失败",但隔离承诺被悄悄破坏了——业务方以为自己在用独占资源,实际已经跑到公共池上,成本核算和隔离预期全部错位。
  • 显式配置 + 显式告警:回退是一个需要主动打开的开关(allowFallbackToDefault),并且默认关闭。

这套系统选了后者,而且默认值是 fail-closed:

  • 有显式绑定的 app,allowFallbackToDefault 默认 false。
  • 主池无可用候选且不允许回退 → 调度直接失败,并产生 pool_exhausted 告警。
  • 主池无可用候选且允许回退 → 重新在 default 池计算可用候选;有候选才回退成功,否则仍然调度失败。成功回退产生一条可区分的 pool_fallback 记录,而不是悄无声息地过去。
  • 没有绑定记录的 app → 这是"通用机制"层面过滤器本身的默认语义(等价于绑定 default,行为和升级前一模一样)。但 PPCDN 产品层另有一条更具体的规则覆盖它——见 §5,没有显式绑定时实际解析到的是该 app 所属用户的池,不是字面意义上的 default。2026-09-13 的一次生产事故,就是这两层语义没对齐导致的。

这里的设计哲学很简单:隔离是一条承诺,静默回退就等于承诺失效。所以它必须由业务方显式打开,而且无论哪种结果都要留下痕迹——要么失败被告警,要么回退被记录,绝不能"看起来一切正常"。

3.6 unassigned 与灰度池:两个"看不见"的用法

池机制里还藏着两个很实用的边界用法,靠的是同一个原理——只要一个池没有任何 app 解析到它,池里的节点就不会被任何调度选中:

  • unassigned(移出调度):这是一个专属的哨兵池 ID。任何 app 都不会解析到它(app 要么解析到自己的 pool-<userId>,要么解析到 default),所以一个被置为 unassigned 的节点,等于被干净地"拔出了调度",但进程还活着、连接还在,可以随时再搬回来。这就是超管后台"移除节点"按钮背后的动作。
  • 灰度池:把新节点注册到一个暂时没有任何 app 绑定的池里,它就自然地"隐身"在生产调度之外——不参与任何候选,但心跳、容量上报、健康检查都照常走。等验证通过,再把它搬进真正的生产池。不需要为灰度单独发明一套部署流程。

3.7 四处调用点,改造面很小

池过滤被加在了四类调度入口上:SelectOriginForPublish(WHIP 推流选 Origin)、SelectOriginForStream(按流选 Origin)、SelectOriginForSRTPublish(SRT 推流选 Origin)、FindEdgesForStream/SelectEdgeForStream(选 Edge)。这几处调用点在改造前就已经持有 appId,所以只是多传一个参数、在候选集上套一层池过滤,没有引入新的全局状态或新的服务间依赖。


4. 谁在管、怎么管

池的配置和节点归属是超级管理员(superadmin)的能力,走的是和其他后台配置一致的模式:

  • 管理接口:实际落地的接口比最初设计精简得多——GET /admin/node-pools(只读,列出现有池)和 PUT /admin/mmx-nodes/{id}/pool(把一个节点搬进/搬出某个池)。最初设计里 PUT/DELETE /admin/node-pools 和 GET/PUT/DELETE /admin/app-pool-bindings 这两组"建池/绑定"接口在 v1.0.19(2026-09-21)被直接删除——原因见 §5,是产品把"任意建池"简化成"一人一池"之后的直接后果;AdminHandler 现在甚至没有保留对应的 bindingStore 字段。保留下来的接口全部 superadmin-only,走相同的鉴权中间件和审计日志。
  • 搬节点即时生效:给节点换池时,控制面一边把新池写进持久化的节点记录(mmx_nodes.pool_id,撑过下一次节点重连注册),一边把变更镜像到当前在线的内存节点对象上,所以下一次调度请求立刻就能用上新归属,不用等节点重连。
  • 后台界面:超管后台有一个 node-pools 页,按池聚合展示节点,支持"把节点加入某个池",以及从池里移除(即置为 unassigned)。default 池恒定显示,unassigned 置底。
  • 告警:pool_exhausted 带 role + poolId + appId 的去重维度,避免同一个绑定反复刷屏;pool_fallback 因为是"平台共享池场景下的正常路径",只记日志、不告警——否则每个还没配自己节点的 app 每次调度都会触发一次通知。代码注释(ppcenter/internal/manager/alarm_notifier.go)说得很直接:"OnPoolFallback is now the normal scheduling path...Deliberately log-only - it must not page anyone"。

还有一个细节值得一提:展示类路径用的是"安静版"过滤。像 Active Streams 列表、Edge 预览这类每次轮询都会跑一遍的路径,如果直接复用带告警的 filterByPool,同一个绑定会被轮询反复触发告警。所以这些路径走 poolFilterQuiet——过滤逻辑一样,但不发射 pool_exhausted/pool_fallback 告警。读路径和调度路径共享语义、不共享副作用。

对应实现:ppcenter/internal/apis/admin_mmx_node.go、internal/apis/admin.go、internal/apis/route.go、internal/manager/alarm_notifier.go、web-admin-react/src/pages/NodePoolsPage.tsx。


5. 一次真实的产品简化:从"任意池"到"一人一池"

通用调度模型可以支持为不同角色定义任意池、候选池优先级以及 active/draining/disabled 状态,也可以选择一节点一池或多池标签。这些是可选的产品能力,不是节点池机制天然要求。

真正落地到产品时,这套灵活性被大幅收敛了:

  • 池是固定的:一个用户一个池,ID 形如 pool-<userId>(NodePoolIDForUser)。不提供"让用户自由建池、命名、排序"的入口。
  • app 的绑定可以自动推导:如果某个 app 没有显式绑定记录,就自动解析到它所属用户的 pool-<userId>;这个用户自己还没部署节点时,allowFallbackToDefault 设为 true,先兜底用共享池,而不是直接把请求拦死。
  • 超管的动作从"建池 + 绑 app"简化成"搬节点":后台不再做节点池的创建/更新,也不再做"把应用绑定到节点池",只保留"把节点加入池 / 从池中移除"这一个最小动作。

这套"自动推导"规则不是未雨绸缪的设计超前量,是一次真实生产事故逼出来的修正。2026-09-13 晚间,/v1/publish/requests 对所有 app 返回 503 no_healthy_origin——超管后台看三台节点的 Status/Capacity/心跳全部正常,调度却一个节点都选不中。根因是:三台节点实际都注册在 pool-1(即 pool-<userId>),而当时的过滤逻辑里,没有显式 pool 绑定的 app 会被拿去匹配 pool=default 的节点——两边对不上,所以所有没有显式绑定的 app(包括官方 Demo 账号)一个节点都匹配不到,跟节点健康、mmx 版本、心跳完全无关,纯粹是 app→pool 这一步从没配置过。确认"一个用户一个池"是既定设计之后,正确的修法不是把节点挪回 default(那样违背这条设计),而是让没有显式绑定的 app 退回到"自己所属用户的池"——也就是上面第二条里描述的行为。这条修复当天就验证通过并重新部署,细节见部署记录 2026-09-13(晚间)条目。

为什么这样收敛?因为"命名池 + 绑定优先级列表"对当前产品用户过重。PPCDN 把常见诉求简化成“这个用户的 app 优先使用该用户的节点”,再用显式 app 绑定处理少数 app 级需求。用户池并不意味着其中每个 app 各自独占机器;是否独占取决于池里实际绑定了多少主体以及回退策略。

诚实的代价也要讲:设计里那些更灵活的形态(任意命名池、池优先级列表、节点 M:N、draining/disabled 状态)目前并没有全部开放给产品,它们留在了设计里,作为将来真需要时再启用的空间。"设计能力"和"产品暴露的能力"是两回事,这一集正好是一个例子。


6. 诚实的边界

  • 它不依赖双活。节点池和 ppcenter 双活改造出自同一份设计文档(前者的双活部分),但两条轨道相互独立:双活方案经评审后决定不投入,节点池则独立推进、已经上线在跑。EP02/EP03 提到的"按 app 隔离节点池",就是这条已经落地的能力。
  • 它目前只覆盖 Origin/Edge 两个角色。节点池过滤加在 SelectOriginFor*/FindEdgesForStream/SelectEdgeForStream 这组函数上,Recorder 的调度完全不走这条路径(Recorder 曾经被认为会并入 Origin、不需要单独的池,但后来产品把它拆成了独立角色,没有同步补上池隔离);2026-10-06 新增的 Forward(跨区域桥接)和 Standalone(自建单机)两种角色,从设计上就不参与 Origin/Edge 调度,也同样不在这套隔离机制的管辖范围内——它们的节点记录里确实有 PoolId 字段(注册时和其它角色一样上报/归一化),但这个值不会被任何候选过滤逻辑读取,纯粹是随节点静态落盘,目前没有实际作用。
  • 它是"调度层面的隔离",不是天然的 app 独占或物理隔离。池约束的是控制面把请求分给谁;租户池/用户池内部仍可能由多个 app 共享节点,网络和进程边界也不会自动隔离。
  • 灵活性有留白。一个节点是否允许同时属于多个池、节点池维度的容量告警阈值是否要单独配置、回退的默认策略在产品上怎么定,这些在设计里明确标为"待评审",当前实现取的是简单而保守的取值。
  • 灰度池缺一个显式用例。逻辑上"未被任何 app 绑定的池不参与调度"是成立的,但目前主要靠隔离相关的单测覆盖,灰度池本身还没有一个专门的测试用例。

7. 框图

7.1 调度:先按池过滤,再在池内按剩余容量择优

调度请求(role=origin/edge, appId)
        │
        ▼
解析 app 的候选池
(显式绑定优先;否则 → 用户的 pool-<userId>;都没有 → default)
        │
        ▼
按池过滤:只保留属于候选池的在线节点
(不属于该池的节点,从这里开始就不在候选里)
        │
        ▼
池内排序:剩余容量最大优先,workerId 升序平局
(region / city 自 2026-09-20 起不参与)
        │
        ▼
返回最优节点;主池无可用候选 → 进入 §7.2 的回退判定

7.2 回退语义:fail-closed,拒绝静默穿越隔离边界

app 解析到的主池内没有可用候选
        │
        ▼
allowFallbackToDefault = ?
        │
   ┌────┴─────┐
  false       true
   │           │
   ▼           ▼
调度失败      在 default 共享池重新计算可用候选
+ pool_exhausted 告警       │
                           ├─ 有候选:回退成功 + pool_fallback 记录
                           └─ 无候选:调度失败

7.3 节点池的归属与管理动作

   ppmmx Origin / Edge 节点
   (注册时只带 nodeSecret/licenseCode + nodeType + region 等,
     NodeRegister 消息里没有 poolId 字段——节点自己说了不算)
              │ 用 nodeSecret / licenseCode 注册
              ▼
        ppcenter 控制面
   ┌───────────────────────────────────┐
   │ 查 mmx_nodes 凭证记录拿 cred.PoolID  │
   │ (创建节点时默认写入 pool-<userId>)  │
   │ 服务端组装进内部 WorkerIndication     │
   │ 节点归属池:pool-<userId> /          │
   │             default / unassigned     │
   │ app 绑定池:AppPoolBinding            │
   │ 调度:池过滤 → 池内容量择优            │
   └───────────────────────────────────┘
              ▲
              │ 超管搬节点:PUT /admin/mmx-nodes/{id}/pool
              │  • 写入 mmx_nodes.pool_id(撑过重连注册)
              │  • 镜像到在线节点(下一次调度立即生效)
              │  • pool-<userId> = 归属某用户
              │  • unassigned    = 移出调度(节点仍在跑)

8. 结尾与下集预告(逐字稿要点)

这一集把节点池拆到了调度代码这一层:节点声明归属,请求按 app、用户或租户规则解析到池,调度只在可用候选中择优;主池无可用候选时,是否回退必须显式定义。PPCDN 的“用户池 + 可选 app 显式绑定”只是一个案例,不能把用户池共享资源绝对化成每个 app 独占。

下一集,我们把镜头拉远一点:横向扩容和低延迟之间到底是什么关系——加机器这件事看起来简单,其实也有代价和取舍。