搞了几年WebRTC,一直绕不开自建会议服务这个话题。前阵子被现有业务里的XMPP账号体系绑定住,没法直接换一套全家桶,只能硬着头皮去啃Jitsi那一套。结果发现,Jitsi Videobridge表面上只是个媒体转发服务,内部真正决定会议能不能开起来的,是一套叫Colibri的信令协议。

这玩意儿在网上的中文资料少得可怜,我几乎是把源码翻了个底朝天,配合抓包才把整个数据流摸清楚。如果你也在折腾自建WebRTC会议、SFU选型,或者想理解Jitsi和SRS、Janus、mediasoup这类项目的本质区别,这篇就来把Colibri从头到尾拆一遍,包括它是怎么创建会议、分配通道、协商媒体,以及我踩过的那些坑。

1. 项目概述:Colibri在WebRTC会议中到底扮演什么角色

先把结论放前面:Colibri不是媒体协议,媒体走的是SRTP/UDP,Colibri管的是“服务器内部媒体路由怎么编排”这件事。它是一套基于XMPP IQ的消息集合,也可以跑在WebSocket或HTTP JSON上,专门用来控制Jitsi Videobridge这个SFU。

1.1 从Jingle到Colibri:协议演进背后的逻辑

Jitsi的前身是SIP Communicator,早年走的是Jingle协议,也就是基于XMPP的端到端会话协商。Jingle的设计思路是对等连接——两个端点之间直接协商媒体,服务器只做信令中转,不掺和媒体流。

但这个模型一旦放进多人会议就崩了。假设会议室里有20个人,直接P2P意味着每个人要维护19条ICE连接,上行带宽和CPU都扛不住。于是Jitsi转向SFU架构,服务器做成一个媒体路由器,每个人只跟服务器建一条连接,再由服务器把各路流转发给需要的人。

此时问题来了:多人流表的维护、每个参与者的媒体通道编号、哪些人需要接收哪些人的流,这些状态放在谁那儿?Jingle管的是“两端点之间”,放在客户端之间就乱套了。所以Jitsi团队在Jingle基础上扩展了一套服务端信令,就是Colibri——Conference with Lightweight BRIdging,蜂鸟这名字也暗示了它追求轻量。

1.2 为什么传统信令不适用于SFU场景

我们对比一下传统SIP会议和WebRTC SFU的区别。传统MCU把所有媒体流混流成一路输出,信令只需要管一个混流通道就行,复杂度集中在音频处理上。SFU不混流,转发的是原始RTP包,于是服务器得知道:

  • 每个参与者希望发送几种分辨率或帧率的流
  • 每种流被哪些人订阅
  • 上行用了什么编码参数、下行要不要转码
  • 服务器如何把“会议”和“物理连接”解耦

这几个问题再叠加多租户、多节点,就不是普通Jingle能解决的。Colibri的核心思路是把“会议”抽象成一棵媒体树,树的节点是channel,channel之间由endpoint标识关联。信令面只同步这棵树的创建、更新、销毁,媒体面保持RTP直通,所以转发效率高,协议本身也不重。

2. 核心概念拆解:会议、通道、端点、编码协商

如果你第一次看Colibri的抓包,第一反应肯定是:这XML怎么这么多层。别慌,核心就三个对象:conference、channel、endpoint。把这三者关系理清,整个协议就懂了一半。

2.1 三种标识:conference、channel、endpoint的关系

先说conference,也就是一场虚拟会议。每一个conference有全局唯一的ID,通常是UUID字符串。Jitsi Videobridge内部用Conferences ConcurrentHashMap管理,key就是这个ID。客户端搜索会议,本质就是拿着这个ID去找JVB要通道。

channel是媒体通道,它绑定在某个conference下,一个channel对应一条RTP流。可以这么理解:channel是会议这棵树上的叶子,收到媒体包就按路由表复制转发给其他channel。每个channel也有自己的ID,格式是十六进制字符串。

endpoint是逻辑参与者,一个用户可能同时有音频流和视频流,那就是两个channel,但它们都属于同一个endpoint。endpoint没有独立的媒体能力,它只是把多个channel聚合成一个“人”。

这么一拆就清晰了:conference包含多个endpoint,endpoint包含多个channel,channel是实际承载媒体包的单元。

2.2 channel创建与媒体协商的完整流程

一次典型的Colibri会话长这样。客户端进门,先通过XMPP或HTTP发起一个conference IQ查询,带上会议名或ID。JVB收到后创建conference对象,返回conference的响应IQ,里面包含一个channel元素。此时这个channel还没有任何媒体参数,只是占了一个席位。

接着客户端发起Jingle session-initiate,携带ICE候选和DTLS指纹,目标地址是JVB返回的那个channel。JVB把这条Jingle会话和Colibri channel绑定,从候选列表里选出可用地址,再响应一个session-accept。到这里,DTLS握手通过,客户端开始往这个channel推RTP流。

中途如果想加视频流,客户端再发一个source-add或channel-add之类操作,JVB在同一个endpoint下再分配一个channel,指定payload type和SSRC。如果涉及simulcast,一个endpoint下就有多个channel,对应不同空间分辨率,转发时由JVB按订阅需求挑选。

2.3 Colibri消息的XML结构剖析

我摘一段实际抓包里的核心XML结构(省略了无关属性):

<iq to='jvb.example.com' type='set' id='colibri-1'>
  <conference xmlns='http://jitsi.org/protocol/colibri'
              id='conference-uuid'
              name='room-name'>
    <channel xmlns='http://jitsi.org/protocol/colibri'
             endpoint='endpoint-uuid'
             expire='60'
             initiator='true'>
      <payload-type id='111' name='opus' clockrate='48000' channels='2'/>
      <rtp-hdrext id='3' uri='urn:ietf:params:rtp-hdrext:sdes:mid'/>
    </channel>
  </conference>
</iq>

关键属性里,expire是channel的存活时间,单位秒。JVB会在到期前检查是否有媒体活动,没有就自动回收。initiator表示这个channel是否由会话发起方创建。

响应IQ大致是这样:

<iq type='result' to='client@example.com' id='colibri-1'>
  <conference xmlns='http://jitsi.org/protocol/colibri'
              id='conference-uuid'
              name='room-name'>
    <channel xmlns='http://jitsi.org/protocol/colibri'
             id='channel-1'
             endpoint='endpoint-uuid'
             expire='60'
             initiator='true'>
      <transport xmlns='urn:xmpp:jingle:transports:ice-udp:1'>
        <candidate component='1'
                   id='cand-1'
                   ip='203.0.113.10'
                   port='10010'
                   protocol='udp'
                   type='host'/>
        <fingerprint hash='sha-256'
                     xmlns='urn:xmpp:jingle:apps:dtls:0'>
          AA:BB:CC:...
        </fingerprint>
      </transport>
      <payload-type id='111' name='opus' clockrate='48000' channels='2'/>
    </channel>
  </conference>
</iq>

注意这里的transport不是给客户端用来P2P的,而是告诉客户端,媒体要推到203.0.113.10:10010这个地址。ICE在这里的作用是NAT穿透和路径选择,候选由JVB生成后,客户端再用标准ICE流程去连通性检测。

3. 实操过程:从零部署一套Colibri服务并打通会议

光讲概念容易飘,我们直接来一遍落地。这里以Jitsi Videobridge 2.x + 自研信令客户端为例,不讲Jitsi Meet全家桶,而是聚焦如何直接跟Colibri协议层交互。

3.1 环境准备与服务端配置

部署JVB需要Java 11+,我实测用OpenJDK 11在Ubuntu 22.04上跑得很稳。JVB本身不带XMPP服务器,如果你只想验证Colibri,可以直接用它的REST接口,或者用内嵌的WebSocket端点,不需要额外装Prosody。

下载jitsi-videobridge的最新release包,解压后修改config/jvb.conf,核心配置项如下:

videobridge {
  http {
    enabled = true
    port = 8080
  }
  websockets {
    enabled = true
    port = 4443
  }
  media {
    port-range {
      min = 10000
      max = 20000
    }
  }
}

媒体端口范围一定要规划好。默认10000起步,如果服务跑在容器里,得把这端口的UDP段完整映射出来。我曾在Docker里只映射了10000一个端口,结果第二个客户端进会就失败,因为JVB给不同channel分配了10001、10002等不同端口。

启动后可以用HTTP接口验证健康状态:

curl -s http://127.0.0.1:8080/about/version

能返回版本号就说明服务起来了。

3.2 用REST接口创建会议和通道

JVB的REST接口是Colibri的JSON封装,路径为/colibri/conferences,支持Basic Auth。先发POST创建会议:

curl -u jvb:secret \
  -H "Content-Type: application/json" \
  -d '{"id":"my-conference-001"}' \
  http://127.0.0.1:8080/colibri/conferences

响应会返回conference的完整结构,其中就包含channel数组。此时你已经拿到一个channel ID和它的transport候选。如果你的客户端只做WebRTC推拉流,拿到这个信息后就可以直接跟JVB做DTLS-SRTP握手,不需要再走XMPP。

REST接口适合调试和自动化测试,生产环境建议走WebSocket或XMPP,因为REST没有推送能力,服务器状态变更(比如其他参与者加入)没法主动通知客户端。

3.3 通过WebSocket交互的实战细节

JVB的WebSocket端点在ws:// :4443/ws,协议是RFC 6455。客户端连接后发送Colibri JSON消息,结构跟XML一样,只是换成了键值对:

{
  "colibri": {
    "conference": {
      "id": "my-conference-001",
      "channel": [
        {
          "endpoint": "endpoint-1",
          "initiator": true,
          "payload-type": [
            {"id": 111, "name": "opus", "clockrate": 48000}
          ]
        }
      ]
    }
  }
}

JVB会响应一个包含channel ID和transport候选的消息。之后所有后续操作,比如添加source、订阅切换、踢人,全部通过同样的消息结构发送。这里有一个容易踩的坑:WebSocket模式下的用户认证,JVB默认不做鉴权,只认连接来源IP,局域网内测没事,上公网必须前面套一层认证服务。

4. 常见问题与排查技巧实录

协议和流程都跑通之后,真正花时间的是排查那些莫名其妙的问题。我把实际踩过的坑按排查逻辑整理成速查表,遇到同类问题直接对照。

4.1 会议创建失败与网络不可达

创建conference后,客户端一直连不上channel的候选地址。先确认三件事:JVB的媒体端口段是否全部可达、UDP是否被防火墙丢包、服务端是否返回了公网IP。

用nc测试UDP端口可达性不太直观,直接抓包最快:

sudo tcpdump -i any udp portrange 10000-20000 -nn

客户端推流时能看到目的IP和端口。如果客户端发了一堆STUN binding请求但JVB没回应,大概率是候选IP选错了。JVB默认从网卡探测IP,在NAT后面会出现私有地址发给了客户端的情况,此时需要在jvb.conf里显式设置:

videobridge {
  ice {
    public-address {
      address = "203.0.113.10"
    }
  }
}

4.2 DTLS握手超时与指纹不匹配

DTLS永远卡在connecting,典型的日志里看到 fingerprint does not match 。Colibri在channel创建时返回的fingerprint,必须和后续DTLS握手用的证书指纹完全一致,包含冒号大小写都要对上。

如果自己写客户端,生成SDP时别图省事直接用demo证书的指纹。同一个conference里如果出现两个channel的指纹不一致,JVB会在DTLS阶段直接丢弃包,表现就是客户端看对方黑屏但自己上行正常。

4.3 Octo跨节点时媒体流断裂

多节点部署时,JVB之间用Octo协议互连,Octo的信令是Colibri的扩展,叫colibri2。它跟普通Colibri最大的区别是transport类型变成了octo,而且channel的属性里会带上region或bridge-id。

排查Octo问题有一个笨办法:在两台JVB上分别抓UDP包。Octo流量用的是独立端口(默认也是10000段但独立配置),且流量特征是一端是SRTP、另一端是带抖动缓冲的RTP封装。如果A节点收到了客户端RTP但B节点没收到转发,优先查Octo的带宽限制配置:

videobridge {
  octo {
    enabled = true
    bandwidth {
      max = 0
    }
  }
}

max设为0表示不限制。我有一次只配了enabled却忘了配带宽,默认值直接导致只有前几秒有流,后面全被掐了。

4.4 expire过期导致通道突然消失

channel和conference都有expire属性,范围从30到86400秒。JVB会周期性地把过期且无媒体活动的channel清理掉。如果你的客户端不推流只订阅(比如纯观看端),RTP接收不会刷新channel的存活时间,必须实现心跳。

有两种心跳方式:一种是定时重新发送channel-update消息,另一种是在colibri IQ里带上 <maintenance/> 标签。个人建议直接定时更新,逻辑简单,对JVB压力也小。注意心跳周期要小于expire值的一半,否则JVB重启或GC长暂停后容易误判失活。

常见问题速查表

现象 可能原因 优先排查方向
channel创建成功但媒体不通 候选IP错误或端口段未放行 抓包看STUN响应、检查ice.public-address
DTLS始终握手失败 指纹不匹配或客户端用了自签证书 比对channel创建返回的fingerprint与SDP指纹
推流正常但收不到他人流 订阅关系未建立或subscribed-endpoint缺失 检查colibri消息里是否声明了subscription
会议人数超过10人后卡顿 JVB单端口模式未开启 配置media.single-port-mode=true消除端口碎片
服务器重启后客户端全部掉线 没有做conference重建 客户端需监听服务端关闭码并重新创建channel

5. 一点个人心得

Colibri这套协议设计得很收敛,它不像SIP那样用一堆RFC堆出完整生态,而更像一个为实现目标不断打补丁的精巧装置。最初看它名不见经传,真上手之后才意识到,它把“会议是树、参与是藤”这个模型贯彻得极为彻底——channel挂在endpoint上,endpoint挂在conference上,任何一层都能独立增删,天然适合多节点扩展。

如果你正在做自建会议系统,我的建议是别一上来就抄Jitsi Meet的部署脚本,先把Colibri的协议层单独拉出来玩一遍,用REST接口建个会议,再拿一个小客户端推拉流,理解完channel生命周期之后,再去叠Jitsi Meet那些复杂的认证和弹幕逻辑会清晰很多。实际跑协议时也务必先抓包,看着报文一步步走远比找文档高效。

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐