大家好,我是李桥平,来自学霸君上海互动产品研发中心,本次分享的主题是Janus网关的集成与优化。Janus网关是WebRTC的媒体服务器,它可以接收来自WebRTC客户端的音视频数据,根据业务需要对媒体数据进行处理,再转发到其他WebRTC客户端上, 以此完成音视频互动。
本次分享的主要内容是如何把Janus网关集成到我们公司内部的自研RTC系统中,并对其做了一些优化,在集成之后就可以通过浏览器和客户端进行实时互动了。
背景介绍主要从三个方面来进行切入,分别是:业务场景、自研RTC体系以及为何要做集成。
1.1 业务场景
我们所做的业务是一个多人在线实时互动的教育场景,基本需求是老师和学生之间能够进行音视频实时互动。除了音视频之外,还需要有一些其他的辅助教学内容,也需要进行实时的交互,比如老师和学生的手写笔迹、PPT课件、控制的状态(课件翻页)等。为了满足这些功能,从技术上分解来看,首先需要支持多对多的音视频连麦,其次是课件、手写笔迹的实时同步。
1.2 自研RTC体系
为了实现这些功能,在袁荣喜老哥的带领下, 我们开发了自己的RTC系统。自研RTC系统主要包含服务端和客户端两大块,它们都是通过自研实现的(语音处理借助了WebRTC的APM模块)。客户端和服务器之间使用UDP协议来进行媒体通信, 数据包采用的是私有格式, 在此基础之上完成传输的控制, 比如数据包排序重组, FEC, 丢包重传, 主动Get以及拥塞控制等. 整个体系以客户端的形式提供给用户,支持windows、Android/ target=_blank class=infotextkey>安卓、mac、IOS这几个主流平台,在使用之前需要下载客户端。
1.3 为何要做集成
我们主要是从用户接入的易用性来考虑的. 首先是我们的客户端需要用户自己去下载,安装成本是比较高,然后才是注册账号、登录这些步骤。而WebRTC可以在浏览器上运行, 而大部分用户对于浏览器是非常熟悉的. 其次, WebRTC的功能通过JS API进行调用,天然跨平台, 不需要过多的考虑设备兼容性这些问题, 它们都封装在WebRTC内部了。
通过集成,用户可以通过浏览器来接入我们的产品,对于没有使用过我们产品的用户来说, 它提供了一种更加便捷的方式。
上图是完成集成后的一个效果,左图是浏览器,登录的是学生端。右图的窗口是我们PC上的客户端,登录的是老师。老师和学生可以进行实时的视频互动,同时还可以通过PPT课件和手写笔迹来辅助课堂教学。
WebRTC与Janus网关部分包含三个小节:首先是P2P传输通道的建立,介绍WebRTC的媒体传输是如何建立起来的,其次是介绍WebRTC网关以及Janus网关。
2.1 P2P传输通道的建立
P2P是指通信的内容可以不经过服务器, 直接发送给对方,省去了中间服务器的开销。WebRTC的P2P传输底层采用的是UDP协议,从传输特性上说,它是无连接、不可靠的协议。当然,WebRTC在进行传输时会有比如包确认、包重传等措施来弥补这些问题。
图中下方是两台需要进行音视频互动的电脑,电脑中的五色圆圈图案是WebRTC的logo,表示这个电脑上运行的WebRTC的客户端,这种客户端最常见的就是浏览器了。实际上只要实现WebRTC的模块功能,它们都可以进行音视频的会话,比如WebRTC网关就实现了WebRTC模块的功能,这里认为这两台电脑上运行了支持WebRTC的浏览器就可以了。这两个浏览器要进行音视频互动至少需要两方面的信息:一是双方采用怎样的音视频编解码以及相应的编解码参数,比如采样率、分辨率、帧率等参数。二是使用UDP发送数据需要知道对方UDP的地址信息,主要包括IP地址和端口。要交换获取这两方面的信息的话, 需要借助到一个位于外网的服务器,我们称之为信令服务器。
接下来我们来分析一下连接建立的过程. 首先,左边浏览器发起一个SDP offer的请求,在SDP中携带了它支持的音视频编解码和ICE参数。这里引入了两个概念:SDP和ICE。SDP(Session Description Protocol)是会话描述协议,这里只需知道它封装了协商的参数就可以了。ICE(Interactive Connectivity Establishment)是互动连接建立,它负责UDP下媒体会话的建立. 在ICE参数里包含了UDP的地址信息(访问外网的NAT地址需要借助STUN服务, 为了简单起见, 可以先不考虑)以及建立ICE连接所需要的用户名跟密码。
右边的浏览器在接收到SDP offer工作请求以后,会根据自己所支持的编码器情况进行匹配和筛选,然后生成SDP answer作为响应,通过信令服务器中转返回给左边的浏览器,这样双方就完成了SDP的协商和交换。
在交换了SDP之后, 双方通信需要的信息都完备了. 随后这两个浏览器会分别初始化好各自的音视频设备,比如麦克风、摄像头设备。然后根据协商好的编解码, 初始化编解码器. 于此同时, 它们会向对方发送ICE建立请求的消息,该消息会带上双方协商好的ICE参数,主要是携带用户名和密码的信息(后面的单端口改造借助了这里的用户名字段)。在完成ICE的请求交换后进行握手认证,这样就建立起了ICE的连接,双方随后以P2P的方式通过ICE连接发送编码后的媒体数据。
直接将媒体数据发送给对方的这种形式被称之为P2P直连,这种方式看似很好,因为它中间不需要经过服务器,但在一些情况下会有问题。
首先,一般的设备都没有公网IP地址,在访问外网时需要经过路由器,路由器上的NAT转换会分配相应的外网地址,再进行设备到外网的访问工作. 这时路由器上的NAT策略直接影响到ICE连接是否能够建立起来。整个过程涉及到UDP穿透问题,比如在对称型、限制型锥形NAT上,穿透是很难完成的。
其次,在P2P直连的方式下,中间链路我们无法控制,因此传输质量难以保证。假设图中这两台电脑,一个位于电信,一个位于网通,即使它们能够完成UDP的穿透,它们之间的传输延迟大概率也是很高的。
最后,因为数据不经过服务器,行为监管和媒体录制都难以实现,, 尤其对教育行业来讲,行为监管这块是一个必不可少的需求。
2.2 WebRTC网关架构
这是WebRTC网关的架构图。通常情况下我们将WebRTC网关部署到外网,这两个浏览器分别通过NAT连接到网关,并通过网关来转发相应的媒体数据。网关上的WebRTC logo表示在网关上实现了WebRTC模块的功能. 因此它可以和浏览器上的WebRTC模块进行通信。浏览器和WebRTC网关之间的红色箭头表示信令消息的交互,绿色箭头表示媒体消息。
下面来看看关于上个小节中的几个问题在WebRTC网关上是如何解决的。
首先穿透问题,因为WebRTC网关是部署到外网的,浏览器通过内网去访问外网. 只要能够正常上网,访问外网是没有问题的,因此不会有穿透失败的问题, 同时也可以省去STUN服务.
其次是联通到电信的情况,可以把WebRTC网关部署到BGP的多线机房, 电信和联通到BGP的延迟可以做到很低,通过一个中转,整体的中转质量反而比P2P直连质量更好。
最后是监管和录制,因为媒体数据会经过WebRTC网关,可以方便地在网关上进行录制,同时也可以在网关上针对媒体内容进行相应的数据分析,实现对其监管的功能。
在讨论WebRTC网关时,一般会根据网关对媒体消息的处理方式划分为两类:SFU和MCU。
SFU在收到媒体数据以后,不会对媒体数据本身进行处理,只做一些基本处理(SSRC, timestamp等转换)和转发。左图是SFU的示意图,不同颜色所表示的媒体数据在进入SFU之后,它是以原来的形态发送到其他浏览器上的。
右图是MCU的示意图,媒体数据在进入MCU以后,MCU会对媒体内容进行深度处理,比如把多路的声音合并成一路或者把多路的头像合并成一个大头像,再根据需要做转码,并转发到其他浏览器上。合流的一个好处是可以节省相应的带宽,同时可以在发送媒体数据的时候, 根据浏览器所支持的编解码情况进行转码,因此它的适应性会比较好。
2.3 Janus网关
Janus网关是SFU. 它是用C语言来实现的。其次, 在Janus上,业务模块以插件的形式实现,部署是以SO动态库的形式进行部署的,所以它的主程序和插件开发是一个分离的方式。最后,Janus Demo非常简单直观,很容易上手。
接下来这部分介绍Janus网关的软件架构。从层级上分析,Janus网关主要分为三层,从上至下分别是插件层、核心层和传输层。
插件层主要是决定SFU的转发逻辑,比如决定转发给房间里面的所有人,还是只转发给其中的一部分人,是转发音频或者视频,还是音视频同时转发。一个完整的插件方案,除了Janus网关服务器上的插件实现之外,还包括浏览器上的JS SDK。JS SDK处理的逻辑主要包括进出房间、订阅相应的媒体流等. 除此之外, 调用WebRTC的API获取麦克风和摄像头的数据,还有播放音频和视频数据,都是通过JS SDK来完成的。
核心层主要负责SDP的协商以及ICE连接的建立,UDP媒体数据的接收和转发也在核心层里完成。而插件和JS SDK的通信使用的是TCP协议, 它是通过传输层来完成的.
传输层主要负责在JS SDK和网关之间传输控制数据, 插件自定义消息等。传输层支持多种常见的传输协议,比如HTTP、WebSoket等。
第三部分是Janus与自研RTC的集成,主要包含三个小节,分别是系统架构、音视频互通、集成效果。
3.1 系统架构
这张图片是高度简化后的结果,像自研RTC集群里的媒体调度、负载均衡、线性扩展等内容都没有在这里表达出来,主要是希望能突出与集成相关的内容。图中大致包含三个部分:自研RTC系统、Janus网关以及中间绿色箭头代表的媒体通道。
我们按上图从左至右, 来看一下通信流程。
首先是用户A通过任意一个平台的客户端连接到自研RTC集群,通过中间的媒体通道,间接地和连接到网关上的浏览器用户B进行音视频互动。在Janus网关和浏览器用户B之间主要传输RTP格式的音视频数据和自定义格式的笔迹数据。其中的音视频数据走的是P2P的传输通道,笔迹数据走的是WebSocket通道。整个集成核心的部分是位于Janus网关和自研RTC集群中间的绿色箭头所代表的音视频转换,更具体的来说, 就是自定义封装格式和RTP封装格式的转换。
前面介绍P2P媒体传输通道时提到RTP最终是通过UDP的传输协议发送出去的。
为了避免IP分片, 发送的UDP包不能太大, 具体一点是不能超过路径上MTU的限制,一般来说,以太网上的MTU的最大限制是1500个字节。实际过程中需要除去IP协议头和UDP协议头开销,剩下大概也就1400多个字节, 因此RTP包不能超过这个限制, 这个限制会影响到RTP的封包过程。
3.2 音视频互通
在我们的系统中音频采用Opus编码,视频采用H.264编码,WebRTC(主要是Chrome浏览器)也支持这两种编码,因此不需要在网关上进行转码了。
图中展示的是音频数据的转换, 包含了音频数据从采集到封装成RTP的过程。从上往下, 首先是声卡采集到PCM数据,一般是按10毫秒或者20毫秒这种固定长度进行组织. 经过Opus编码器, 根据PCM数据的内容特征, 编码成长度不一样的编码数据. 编码后的音视频数据一般是几十到几百个字节左右。这样的数据量可以直接在单个RTP包中进行携带,因此声音的RTP封装非常简单,只需要在数据的前面追加上RTP头部就行。
RTP头部中主要的两个字段是sequence number和timestamp, 即序列号和时间戳。因为UDP传输是一个不可靠的协议,在传输的过程中可能会发生丢包或者乱序到达。序列号可以帮助接收端正确地组织接收到的数据, 根据序号的缺失情况可以知道哪些数据包丢失,根据丢失包的序号可以要求发送端进行重传,从而保证传输质量。时间戳主要是辅助播放端进行声音的同步播放。
整个过程倒过来看,就是如何从浏览器发过来的RTP数据中提取编码数据的过程。在提取出编码数据以后就可以封装成自研RTC格式,通过自研RTC集群再转发到客户端上,并在客户端上进行播放。
接下来是视频的转换。
H.264视频转换在RFC6184文档里有详细的规定和说明。相对于音频来说,视频转换要复杂一些,这是因为图像数据编码后,它的数据帧往往比较大,会超过RTP包的大小限制。
该图是视频数据转换成RTP包的示意图。还是从上往下看,首先摄像头采集原始的视频图像,一般是YUV格式的,经过H.264编码后生成H.264的数据帧。数据帧本身是有内部结构的,它包含一个起始码,后面跟着NAL单元,由多个这样的结构组成编码后的数据帧,在转换的过程中,第一步是要把起始码去掉,再提取出单个的NAL单元数据。然后根据NAL单元数据能否封装到单个RTP包中,分别封装成三种不同的封装格式。
图中左边是单个NAL单元的封装, 在NAL单元比较小的情况下使用. 中间是单元片段的封装, 在单个NAL单元大小超过RTP包限制的情况下,采用该封装格式。
右边是多个NAL单元聚集到一个RTP包的封装过程,这里主要针对NAL单元很小,RTP包可以同时携带多个NAL单元的情况,封装到一个包里,可以减少发包的数量。同样,封包过程需要正确的填充RTP头部的时间戳和序列号。
整个图从下往上看,就是从RTP数据流中提取出来H.264编码数据的过程,完成提取后再封装成自研RTC系统的格式,发送到客户端上进行数据的还原,再经过H.264解码器的解码,得到原始的视频数据并在界面上渲染出来.
3.3 集成效果
这个测试主要是想知道中间转换部分的开销, 因此这里不考虑客户两端到服务器的弱网情况. 首先是稳定WiFi,到服务器RTT是30毫秒,视频分辨率是320×240,帧率是20帧。整个过程下来音视频流畅,媒体延迟小于100毫秒。
测试方法借助了一个在线秒表的时间跳动的画面,虚拟摄像头采集在线秒表的动画,通过PC端进行编码,然后上传到自研RTC服务器, 转换成RTP格式, 通过RUDP通道传输到Janus网关, 再通过网关发送到浏览器上还原出视频画面。对比PC端和Web端看到的视频画面,就可以得出他们观看的时间差。
图中可以看出PC客户端的画面时间和Web的画面时间相差大概几十个毫秒。由于PC端有一些相应的处理(如美颜),而且存在渲染的时间消耗, 实际的差值会比这个大一些, 整体的时间延迟估计是100毫秒左右,效果还是不错的。
这部分我会从现象入手,介绍集成过程中所做的一些优化,这里主要介绍CPU优化和端口优化。
首先在CPU方面,在测试时我们发现,在同一个房间里进入12个人,八个人开麦进行音视频互动的话,Janus近程的CPU大概占到30%多。如果是一个四核的CPU, 算打到300%的话,也只能支撑120多个人,这样的话承受能力会非常有限。因此需要对CPU进行优化。
其次是端口,Janus在服务部署的过程中需要开放大量的端口. 这是因为Janus对于每一路上传和每一路观看都需要为它分配一个外网端口。分配过多的端口不管从安全管理上还是运维部署上都会带来不便。在我们实验室实际开发过程中就遇到过,当同时开3、4个视频时,整个视频的数据下发不来, Web上看到的画面是黑的. 经过找相应的IT人员一起定位分析后,发现是办公室交换机出口对UDP访问端口做了限制导致的,因为每一路视频上传下载都需要分配端口, 在交换机策略看来, 多个内网的机器访问了同一个外网IP(janus网关的IP)的大量不同端口, 被判定是异常状态了。因此,不管从安全性、运维部署,还是服务质量上来讲,最好是用少量的端口来完成同样的事情。
4.1 CPU优化
这部分介绍针对上述两个问题的分析和相应的解决方法。CPU问题的原因主要有3个:一是SFU的转发关系复杂度为M*N,其中M是上麦人数,N是房间内的总人数。假设房间里有100个人,其中10个人上麦,那么转发的复杂度就是10×100,因为对每一路上传的视频都需要转给其他99个人,一路上传加上99路转发就是100处理量,10个人就是1000。
二是对于每一路上传和转发,Janus都分配一个对应的UDP端口和socket描述符,该分配行为是Janus所使用的网络库Libnice决定的。
三是Libnice的内部采用poll做事件处理,在描述符量很大时,它的效率很低。因为poll在调用时, 需要把所有描述符以数组的形式传递到内核, 内核需要对每个描述符进行查询处理,并且还要注册相应的事件监听。如果当前这次调用没有收集到任何事件的话, 它会进行等待, 在等待过程中, 它会把当前线程注册到所有描述符的通知等待队列里,然后被动等待相应事件的唤醒。在事件到达唤醒后, 返回的过程中又需要把当前线程移出所有描述符的等待队列,这其中涉及到大量锁操作。以上三个原因叠加起来,就造成了高CPU的情况。
CPU优化的对策主要是从两方面入手: 减少端口的使用, 以及把glib内的poll调用改为epoll。
在使用上,端口的问题的使用可以采用以下一些办法来缓解:
一是通过ice_enforce_list限定ICE收集candidate的网卡。默认情况下,Janus会对所有的网卡都做端口收集。我们在开发的过程中所部署的机子上正好有两个网卡,测试时发现,它所收集的端口数量比单网卡下多了一倍,在开启这个的配置后,数据数量立马减半,CPU也降低了很多。
二是确保Janus服务配置中, ice_tcp=false。这是在使用TCP穿透时所需要收集的端口,在实际应用中很少用到,所以将其设置为“false”禁止掉就可以。
其次,把glib内的poll调用改为epoll. 可以采用两种方式 :一是修改glib代码,把事件处理的poll调用替换成epoll. 这种方式需要把glib代码拉下来修改并测试,整个工作需要比较长的时间;二是采用github上第三方的扩展实现 。
4.2 端口优化
对于端口优化,我们采用了端口复用方案. 实现端口复用的情况下, 可以做到减少端口使用, 同时降低CPU使用率。具体的方法如下, 首先在Janus上接管ICE的处理,通过SDP中的ICE用户名参数来识别发送端身份。在上文提到的P2P连接建立的过程中,首先要经历ICE认证的过程,在认证消息里包含了用户名信息,而用户名信息是通过SDK的的ICE参数来传递给对方的,因此可以在用户名中添加业务标识的内容,然后在ICE握手的过程中识别出对方的身份,然后将身份和发送的IP地址关联,这样只要对方发送消息我们就可以知道是谁发送的,从而实现端口复用。
在实现单端口方案的过程中, 采用epoll来实现描述符事件管理,去掉对libnice和glib的依赖。最终可以通过单一(或少量)的端口对外提供网关的服务,同时降低CPU的消耗。
在方案实施后, 同样的场景下, CPU占用从30%降到了10%左右, 仍然有点高, 不过已经好很多了。
相比前面的几种方案,这种方案会复杂很多,首先需要实现ICE逻辑并在Janus Core中把libnice替换成自实现方案,同时还需要实现相关的辅助结构,如ICE定时器等, 总体来看有一定的工程复杂度,但从效果上来说是值得一做的。