作者:Brian Collins
原文:https://webrtc.ventures/2026/07/scaling-janus-webrtc-server-media-resource-broker/
Janus WebRTC 服务器是最易于使用的 WebRTC 媒体服务器之一,而且它还是开源的,这无疑是一大优势。它体积小、速度快且模块化:一个精简的 C 语言核心,配有针对您所需工作负载的插件,一个简洁的信令 API,以及一个能高效处理 SFU 风格会议的视频会议室插件。然而,当扩展到多个实例并将会议室分布在服务器池中时,就会面临新的挑战。
与某些媒体服务器不同,Janus 没有原生的多副本协调机制,它没有内置的 Janus 集群模式。这意味着它不会自行决定新会议室应部署在哪个实例上,也不会让两个实例共享同一个会议室。
这与其说是缺陷,不如说是设计上的限制:Janus 通过将集群级别的协调工作交给集成商,从而保持了轻量级。因此,任务在于构建一个轻量级的协调层,将一组独立的 Janus 实例转化为一个行为上像单一可扩展服务一样的工作单元。
这种模式有一个专有名称,即“媒体资源代理”(Media Resource Broker),本文将介绍如何构建该代理以及如何扩展其背后的实例池。
决定一切的核心事实:Janus 房间是实例本地的
在理解任何设计细节之前,你需要先掌握一个决定一切的技术事实。一个 Janus 房间仅存在于一个实例的内存中。这是选择性转发单元(SFU)工作原理的固有特性:SFU 接收每个发布者的流,并将其转发给每个订阅者,因此一个房间的所有媒体路径都会汇聚到托管该房间的实例上。实例之间不存在共享状态,也没有内置机制允许第二个实例托管在第一个实例上创建的房间。
videoroom 插件提供了一个 remote_publishers 功能,可以在实例之间转发 RTP,但这仅适用于超大型的单一房间,并非让房间跨实例池运行或应对实例宕机的通用方案。
这比“状态不共享”更深一层。当浏览器协商连接时,双方达成的会话描述会在其 ICE 候选项中嵌入该特定实例的地址。媒体路径被固定到该实例上。如果该实例消失,会议室也会随之消失,且无法无缝切换到其他实例,因为浏览器持有的资源分配与某个特定对等方绑定。
由此直接衍生出两个后果,它们也决定了本文后续内容的走向:
- 首先,必须由某个机制来决定将房间放置在哪个实例上,并记住这一决定,因为房间一旦放置就无法移动。
- 其次,不能像对待无状态服务那样将实例视为可互换的并随意轮换;替换一个实例意味着要放弃其上的房间,因此扩展和升级必须经过周密规划。
第一个结果是中间件。第二个是扩展性。
什么是媒体资源代理(MRB)?
抛开具体实现细节,MRB 其实是一个小型且权威的目录。它回答一个问题:“哪个实例拥有这个房间?”,并且当答案是“尚无”时,它负责选择一个实例。其他所有功能都是为了确保该目录的准确性。
具体来说,它由三个协同工作的组件构成:
- 有一个路由器,这是一个小型无状态服务,提供 HTTP API 供您的应用服务器调用。
- 有一个目录存储,即键值存储(我们使用 Redis),用于持久化房间与实例的映射关系,从而确保该答案在路由器重启后依然有效,并可在路由器副本之间共享。
- 还有 Janus 实例池本身,路由器必须能够单独访问这些实例,因为它需要在特定实例上创建房间,并在后续查询该实例的运行状况时与之通信。

代理服务器的三个组成部分:无状态路由器、存储房间到实例映射关系的目录存储,以及 Janus 池。实线表示对所选实例的管理 API 调用;虚线表示实例发现过程。
“单独寻址”的要求值得我们仔细考虑,因为这正是平台无关理念与具体平台相交汇之处:代理需要为每个 Janus 实例提供一个稳定的、唯一的标识符:一个名称或地址,它能确保在时间流转中始终指向同一个实例。
在裸机虚拟机上,这可能是一份静态地址列表。在 Kubernetes 上,最自然的选择是由一个无头 Service 作为前端,后端是 Janus Pod 的 StatefulSet,这会为每个 Pod 提供一个稳定的序号标识(janus-0、janus-1)以及一个稳定的 Pod 级 DNS 名称。
普通的 Service 会分配一个虚拟 IP 地址,并在 Pod 之间随机进行负载均衡,而当你需要与某个特定实例通信时,这种行为恰恰是错误的。
无头 Service 则会返回每个 Pod 的地址,并为每个 Pod 提供独立的 DNS 记录。原理在任何地方都是一样的;StatefulSet 加无头 Service 只是 Kubernetes 中的具体实现方式。
房间到实例的映射关系,以及应用服务器所看到的契约
从应用服务器的角度来看,整个消息代理仅涉及三项操作:查询房间是否已有归属;如果没有,则创建一个(将其绑定到某个实例上);当房间结束时将其销毁。
典型的连接操作会先进行查询,仅在未找到时才创建,因此第二个进入房间的人会与第一个进入者位于同一实例上。
在这简单的表面之下,目录中每个已绑定房间对应一条记录。该记录包含拥有该房间的实例、该实例的稳定地址、Janus 会话标识符以及时间戳。其最简结构如下:
room:abc123 -> {
instance: "janus-1",
addr: "janus-1.janus-pods...",
session: 1879234567,
last_seen: ...
}
将整个目录保存在一个键前缀下,可以方便备份,并且在对同一个存储运行多个 Janus 部署时,也方便进行命名空间管理。
每次查询时,“last_seen” 时间戳都会被更新,这使得后台清理程序能够让长期无人访问的房间过期,从而避免目录中积累“幽灵”房间。
代理服务器刻意不负责管理 Janus 的会话状态。该状态存储在实例的内存中,并随实例终止而消失。当实例重启时,针对该实例中某个房间的下一个管理调用会立即失败,路由器会在下次被查询时清除过期的条目,而应用服务器只需在下次创建时重新绑定即可。
这种设计带来的直接后果是:每次实例重启都会导致该实例管理的房间全部丢失。换言之,该设计旨在实现水平扩展,而非高可用性。房间仍会随其所属实例一同终止,客户端必须从头开始重新加入——在健康实例上建立新的 Janus 会话并进行新的媒体协商。本文后文提到的严谨扩展规范,正是为了确保这些重启既罕见又可控。
在 Janus 实例之间实现房间负载均衡,并确保目录的真实性
当路由器需要添加新房间时,它需要了解当前房间的状况。它会在内存中维护一个缓存,记录哪些房间处于健康状态以及每个房间的负载情况,并定期刷新该缓存。
每个发现周期都会解析池(在 Kubernetes 上,通过查找无头服务来获取活动 Pod 地址),通过管理 API 向每个实例询问其活动会话数,跳过任何标记为正在耗尽的实例,并更新缓存。每个实例的短超时时间可防止单个挂起的实例导致整个扫描过程停滞。
# discovery cycle — runs every interval (e.g. 10s)
for each instance in pool (live pod IPs behind the headless Service):
skip if instance is draining
cache[instance] = active session count # admin API call, short per-instance timeout
JavaScript 中的相同循环:
// runs every discovery interval (e.g. 10s)
async function refreshCache() {
// live pod IPs behind the headless Service
for (const instance of await resolvePool()) {
// skip anything being drained
if (instance.draining) continue;
// admin API, short per-instance timeout
cache[instance] = await countActiveSessions(instance);
}
}
在 Python 中:
# runs every discovery interval (e.g. 10s)
def refresh_cache():
# live pod IPs behind the headless Service
for instance in resolve_pool():
# skip anything being drained
if instance.draining:
continue
# admin API, short per-instance timeout
cache[instance] = count_active_sessions(instance)
分配房间时,只需选择负载最低的条目,并以实例的序号作为平局判定,以确保冷池按可预测的顺序填充。默认设置为负载最低的条目,这是合理的。如果您需要可预测的分配方式而非负载跟踪,则可以启用轮询分配,这在隔离测试期间非常有用。
两个关键细节决定了目录是否值得信赖,以及一个目录是否会慢慢欺骗你。
- 启动竞争:路由器需要等待一个完整的发现周期才能报告自身已准备就绪,这关闭了刚刚启动且缓存为空的路由器接受创建请求并因尚未发现任何实例而失败的窗口。
- 并发性。这是个比较隐蔽的问题。如果您为了提高可用性(或进行横向扩展)而运行两个路由器副本,它们可以同时向同一个房间发起创建请求。它们会争相获取目录键,但最终只有一个会胜出。而失败的那个路由器此时已经在它选择的实例上创建了一个会话,这个会话现在成了一个孤立会话,没有人会向它路由信令。
如果不加干预,每次竞争的创建操作都会泄露一个会话,这些泄露会扭曲负载计数,导致选择结果逐渐向失败的副本实例偏移。因此,失败的副本会检测到自己输掉了竞争,并在返回获胜副本的结果之前销毁其孤立实例。这只是一个很小的清理操作,但它却能防止基于负载的选择在并发写入的情况下悄无声息地出错。
// create-and-pin: session is created first, then the atomic claim
// putRoomNX: return result === 'OK';
// 'OK' = won; null = key already existed
const won = await putRoomNX(deps, room_id, pin);
if (!won) {
// loser: tear down the orphan session
await destroyVideoroom(deps, pod, room_id);
// return the winner's pin instead
return await getRoom(deps, room_id);
}
故障排除:Janus 管理 API 默认处于禁用状态
这就是那个会让人们花上好几个小时在错误的地方进行调试的问题,因为症状指向了所有地方,唯独没有指向真正的原因。
代理服务器通过 Janus 的管理 HTTP 接口创建房间并轮询负载。在默认的 HTTP 传输配置中,该接口默认处于禁用状态:admin_http 被设置为 false,因此除非您明确启用它,否则它将保持禁用。启用时请设置您自己的管理员密码,因为如果未设置,Janus 会回退到一个众所周知的默认值。
如果保持默认设置,整个控制路径会无声地失败。路由器无法创建房间,加载轮询无法读取任何数据,任何数据导出逻辑也无法获取活跃房间数量。这些症状均无法指明原因,因此当实际解决方案仅仅是一个布尔值时,你最终可能会怀疑存储、网络策略或路由器本身。症状与原因之间的这种脱节正是导致大家浪费整个下午的原因,因此当代理在每次创建操作时都返回错误时,这应是首要检查事项。
启用该界面后,又引出了一个小问题:管理员密码不应该以明文形式存储在配置文件中,而 Janus 的配置格式无法自行展开环境变量。因此,密码会在 Janus 启动前注入。
清理形状是一个在 Janus 之前运行的一次性渲染步骤:它从密钥中读取密码,生成一个填充了密钥的配置副本,并将 Janus 指向该副本。
它不需要任何特殊权限,填充的配置永远不会保存持久存储的密钥,主进程也完全不涉及任何模板逻辑。该步骤的运行方式取决于 Janus 的运行方式,但专用的单次渲染可以有效控制影响范围。
# janus.transport.http.jcfg — the Janus HTTP transport
general: {
http = true
port = 8088
# The trap: admin_http ships as false, so the admin API is off and
# the router gets 503s on every create until you flip it on.
# upstream default: false
admin_http = true
admin_port = 7088
# unset -> Janus uses a well-known default
admin_secret = "<set your own>"
}
Janus 的横向扩展:自由扩展,谨慎收缩
随着代理的部署,水平扩展被分为两个行为截然不同的部分,而这种不对称性正是关键所在:
横向扩展既简单又安全。添加一个实例后,下一次发现周期就会将其纳入;由于路由器仅将新房间固定到实例,并优先选择负载最轻的实例,因此新增的容量会立即开始接收新房间,而不会影响任何正在运行的任务。任何正在处理的任务都不会受到干扰。
而纵向缩减和升级时,实例本地的房间会迫使你采取行动。移除一个实例意味着终止其房间,因为这些房间无法迁移。由此产生的规范与平台无关:切勿在房间运行期间强行移除实例。
相反,应将要移除的实例标记为“正在清空”,这样代理就会停止在其上放置新的房间,等待现有房间结束(或截止日期过后),然后再将其移除。每次移除前都要先进行清空操作。
在 Kubernetes 中,这一原则体现在一个特殊且乍看之下令人惊讶的地方:更新策略。滚动更新会遍历整个 Pod 池,逐个替换 Pod,每次替换都会删除该 Pod 的 room。
因此,Janus StatefulSet 使用的是OnDelete策略。在 Kubernetes 文档中,OnDelete 指的是控制器不会自行更新 Pod;当你更改 Pod 模板时,规范会更新,但不会重启任何 Pod。新镜像会保持期望状态,而正在运行的 Pod 则保留其旧状态,直到你手动删除每个 Pod。
对于习惯于滚动更新的人来说,这种结果可能会让他们感到意外,因为你升级镜像后,升级报告显示成功,但实际上什么都没发生。没错。Pod 只有在你决定按照计划逐个卸载它们之后才会滚动更新,而自动同步的 GitOps 工具必须学习到这种待处理状态是人为设定的,而不是自然漂移。
与其手动排空,不如使用受控作业来自动执行循环:在路由器上标记一个正在排空的 pod,等待其活动房间数降至零或超时到期,然后将其删除,并等待替换的 pod 正常运行后再继续。
路由器标记是关键所在,因为它能止血,让舱体自然清空,而不是在房间仍然处于活动状态时被猛拉出来。

逐个实例进行排水循环。第一步中的路由标记起到止漏作用;之后的所有操作都在等待实例自行清空。
此外,还需要考虑不可控因素。集群可能会因为一些与你的作业无关的原因而关闭 Pod:例如节点退役、节点被驱逐、内存不足导致的 Pod 终止、自动扩缩器回收容量等等。
对于这些设备,每个 pod 都有自己的停止前钩子,在其关闭宽限期内执行相同的标记和等待操作,如果此时路由器无法访问,则回退到其本地会话计数。
无论如何,在系统将其终止之前,舱体都会尝试优雅地离开。请将其视为安全措施而非主要机制,并且不要让它诱使你放弃运行真正的排水系统。
使用 KEDA 实现 Janus 的自动扩缩容,以及缩容的防护措施
您可以让连接池自动调整大小。代理程序收集的会话数本身就是自然的扩容信号,在 Kubernetes 上,KEDA ScaledObject 可以读取该指标,并根据您从负载测试中选择的每个实例的会话目标来扩展连接池。
上一节中的不对称性直接延续了下来:横向扩展是安全的,但横向缩减可能会导致通话中断,因为直播房间无法移动。
因此,要对缩容操作进行两端的保护。让自动扩缩容器在回收实例之前等待一段持续的低负载期,并给该实例一个足够长的清空宽限期(记为 X),以便其停止前钩子能够优雅地清空实例。
需要注意的是:自动扩缩容会触发每个实例的 pre-stop hook,而不是按 pod 逐个执行的清空操作,因此,即使实例在 X 时间内没有清空,其存活的 room 仍然会被释放。请将 X 设置为您实际的最大调用时长,并在让自动扩缩容执行该调用之前,验证清空操作是否针对非空池。
关于打包的说明
如何打包这些内容由您决定,但值得了解其中的权衡取舍。将实例、路由器和存储打包到一个发布版本中非常方便:一次安装即可启动所有组件,且交叉引用已自动对齐,对于演示和开发集群而言,这是正确的选择。
但当两部分需要不同的发布节奏时,问题就会显现。例如路由器的修复采用快速迭代周期,而 Janus 镜像却很少更新。在单一捆绑发布中,每次仅涉及路由器的变更都会重新生成实例工作负载模板,即使实例本身无需任何更改,GitOps 工具仍可能将其识别为配置漂移。
如果这种“噪音”令您困扰,可以将路由器和实例拆分为独立的发布版本,仅共享一小部分共同配置。没有唯一的正确答案;请根据您的部署节奏来设计架构。
这意味着什么
媒体资源代理(Media Resource Broker)的代码量不大,专门负责一项明确的任务:管理“房间到实例”目录以及实例的选择,从而使一组独立的 Janus 实例能够像一个弹性服务一样协同工作。
房间在创建时会被固定在原位置,因此代理会记住其位置,自由扩展实例池,并在清空后才进行缩减。而默默控制整个系统的唯一 Janus 配置项是管理界面,该界面默认处于关闭状态,因此请务必先将其开启。
这就是协调层。我之后将发布一篇配套文章,接续本文的结尾,介绍如何使实例池可达:在 Kubernetes 上私有化运行这个多实例 Janus 配置,并通过 STUNner 提供媒体服务,同时不向任何实例分配公共地址。
本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/webrtc/69743.html