//
编者按:本次RTSCon2022我们邀请到了烟台小樱桃网络科技有限公司CTO、FreeSWITCH中文社区创始人杜金芳,为大家详细分享了双机、三机、弹性伸缩通信集群的搭建经验,包括一对一通话、呼叫中心、音视频会议、日志监控等场景,包含了FreeSWITCH、Kamailio、WebRTC、MCU、SFU、Docker、K8S、ETCD、NATS、Loki等相关技术。
文/杜金芳
编译/LiveVideoStack
大家好,我本次分享的主题是FreeSWITCH高可用部署与云原生集群部署,主要讲一下从高可用到弹性伸缩的一些技术应用。
具体包括以下相关内容:双机、三机、可弹性伸缩的通信集群搭建经验,包括一对一通话、呼叫中心、音视频会议、日志监控等场景,涉及FreeSWITCH、Kamailio、WebRTC、MCU、SFU、Docker、K8S、ETCD、NATS、Loki等相关技术。
本文主要介绍一下我们使用到的一些技术,希望对大家有所帮助。上面提到的一些技术其实不算是新技术。通信技术已经发展了几十年,早在二三十年前人们就在研究高可用相关技术。但由于新时代的发展,最近人们开始关注云原生等相关技术,相应的基础设施也发生了一些变化。通信与互联网的联系越来越紧密,产生了更多新的玩法。
01 单点故障
其实一切源于“单点故障”的问题,我们也将从这一点出发来介绍它。
A和B是两个通信实体,两个手机(人)通过一个服务器进行通信,当然这个服务器可以是FreeSWITCH,也可以是其他任何服务器。假设这个服务器因为通信链路或者网络连接断开,A和B就无法完成通信,这就是单点故障的由来。
为了解决这个单点故障,需要另外一台服务器通过绕行路由或者其他方法来克服单点故障问题。
02双机高可用
一般来说克服这种单点故障的方式就是双机HA(High Availability),即主系统和备系统的高可用。
双机HA的主要原理就是:有一台主机,一台备机,如果主机出现问题断网了,备机可以接替主机继续工作,主备不断切换。主机和备机有同一个IP地址,对于A和B来说,可能能够感知到,也可能感知不到主备的切换,因为A和B在通讯时看到的只是IP地址。任何一台服务器切换到主机上后,就占用了该IP地址对外服务,我们把这个IP地址叫做虚拟IP,也叫业务IP或者浮动IP。每台服务器本身在底层都有一个IP,但是对外提供服务的IP(也就是A、B看到的IP)其实是一个虚拟IP。
这样当服务器切换的时候,A、B 还是在和原来的 IP 通话,可能会感觉到短暂的网络卡顿,之后又恢复正常,但却无法感知到服务器是否切换,这就是主备高可用的原理。
为了实现主、备服务器的高可用,需要有数据同步机制,因为有些数据需要在主服务器和备服务器之间同步。
当然,数据同步的机制有很多,比如日志,消息队列等等。在FreeSWITCH中,数据同步主要是通过数据库来实现的。主服务器会实时把A和B之间的通话数据(A和B之间可能有上万次通话)写入数据库,备份服务器可以查询数据库中的数据。一旦发生主备切换,备份服务器从数据库中获取数据,重新建立通话场景,这样A和B就可以继续通话了。
这样数据库就变成单点了,为了解决这个问题,数据库也需要主从高可用。
FreeSWITCH中主从切换的原理:首先主机包含一个Param,参数为:
如果我们开启这个参数,它就会实时的把调用数据写入数据库。当然这个会有一定的开销,因为需要实时的写入数据库。比如说每秒有1000个、10000个调用,这个开销就会非常大,所以这种双机切换对系统吞吐量是会有一定的影响的。但在一些必要的场景下,我们往往需要牺牲一些性能来更好地实现高可用性。
当备机切换时,备机执行 sofia restore 命令从数据库获取数据重构通话场景,向 A、B 发送 reINVITE。前面我们说 A、B 无法感知,但其实是可以感知到的,因为 A、B 都收到了重连邀请,可以继续通话。一般这个通话过程在 1-3 秒内解决,A、B 只是感觉到短暂的卡顿,不需要挂断再拨打。
我们先排除数据库的影响(默认数据库是主备高可用)再来看FreeSWITCH的主备高可用。
为了准确感知主服务器和备份服务器之间的切换,需要一个叫心跳(心跳线)的东西。一般以前的心跳线都是串口线,因为心跳只是传输几个字节的信息,不需要太多的带宽。但现在有些虚拟机里,没有物理串口,只能用网线来实现。通过网线,有持续不断的心跳,备份机就可以通过它感知主机的状态,一旦主机当机或者断线,备份机就会接管 IP。
当然这种情况也可能造成误判,考虑到心跳线断线本身带来的影响,我们可以通过两条心跳线或者双网卡来避免这种误判。总之我们需要更多的机制来保护系统,避免出现两台服务器同时绑定同一个 IP,同时向服务器写入数据,造成服务器混乱的情况。
当然这种情况会存在一些问题,把两台机器当成一台机器使用,可能会造成资源浪费。还有一种方法就是负载均衡,将 A、B 之间 50% 的流量分别放在两台主机上,两台主机可以同时达到满负荷。但是这种情况也存在一定的问题,假设每台主机可以处理 1000 个呼叫,两台主机总共可以处理 2000 个呼叫,当其中一台主机出现问题,另一台主机满负荷时,实际系统吞吐量只能达到 1000,就会出现拥塞问题。
所以在主备负载分担的情况下,我们会保证两台FreeSWITCH主机各自的流量不超过其设计容量的50%,这样还是比较安全的。当然这个计算我们还是存在50%的浪费。我们也可以采取通信降级策略,当一台主机发生故障时,根据实际业务需要,只使用另一台主机,保证部分呼叫连接的正常使用。
但是负载均衡对 A 和 B 是有一定的要求的,上面我们提到了,主从模式下,A 和 B 只能看到一台服务器(实际上是两台服务器),就是一个 IP 地址。但是做负载均衡的话,A 和 B 可以看到两台机器,这就需要一定的逻辑(在 A 和 B 上完成),比如把 50% 的流量分发到一台主机上,剩下 50% 分发到另一台主机上。另外有时候两台主机的性能不一样,可能一台是 64 核,一台是 32 核,那么就需要根据主机性能来分配流量,比如一台 60%,一台 40%。这样对 A 的要求就会更高,需要 A 能够感知主机来分配负载。
在实际部署中我们一般采用这样的结构(如图所示),FreeSWITCH作为媒体服务器,前面放一个代理服务器,一般用Kamailio或者openSIPS做代理。Kamailio只代理SIP,也就是说它负责处理通讯的建立和分发。一个Kamailio后端可以容纳很多个FreeSWITCH,因为FreeSWITCH需要传递媒体,做录制、质检、分析等媒体处理,所以FreeSWITCH的处理能力没有Kamailio强。这样前面放一个Kamailio,后端可以放很多个FreeSWITCH进行通讯。
当然,Kamailio需要主备高可用,而Kamailio和FreeSWITCH之间又使用了Load Balance,所以使用HA+负载分担的方式就完成了一个比较大的通信集群。另外由于A、B两边的业务逻辑可能不一样,比如一边是中继,一边是运营商系统电话,那么我们可以放两台不同的Kamailio,这样管理起来会更方便。当然我们也可以使用一台Kamailio,将A、B放在一边,但是这样的话,脚本和逻辑判断会复杂一些。因为需要判断呼叫是从A过来还是B过来,还是从FreeSWITCH过来,所以需要判断呼叫的指向,逻辑会相对复杂一些。
还有一种情况就是异地容灾。什么是异地容灾?比如我们可能在北京和上海有两个机房,都用的是FreeSWITCH,主备高可用。这样平时我们主要通过北京机房进行通信,如果出现问题,可以通过绕行的方式通过上海机房进行通信。
但是异地容灾也需要一些数据同步,这就对 A 提出了一定的要求,因为 A 面对的是北京和上海两个机房。所以高可用是无止境的,只要有需求,架构改变,就需要做相应的考虑。但是本质是一样的,其实就是 HA 和负载均衡两个逻辑。当然具体来说,A 可能依赖 DNS 轮询,也可能直接把北京或者上海的地址写入设备,根据情况执行自己的策略进行切换等等。
然后我们再看B端,A和B通话的时候,电话打进来之后可能会执行一些IVR的应用,这些应用也需要主备高可用。比如有人打电话进来的时候,Kamailio负责信令,FreeSWITCH负责媒体,但是具体的逻辑是应用程序负责的,需要告诉FreeSWITCH什么时候处理媒体,什么时候录音,什么时候播放等等,所以应用端也需要主备高可用。
当然我们认为这种IVR一般是无状态的,挂断电话后又通过同一个IVR拨打新的电话,所以一般采用负载分担的方式处理多个IVR的业务。
但是有些服务是有状态的,比如呼叫中心常用的ACD,ACD需要检查坐席和队列的状态,有多少客户在等待,有多少坐席在服务,哪个坐席在和客户沟通,哪个坐席空闲,它需要跟踪这些状态。一般来说,对于这种有状态的服务,应该采用主备高可用方式。当然双机HA也有可能两台机器同时出现问题,这时候我们就扩展到三台机器。
03Raft
三台机器的场景就比较麻烦了,所以我们引入了一个协议叫Raft,还有一个协议叫PaxOS,但是还是Raft协议比较常用。
Raft 其实是一个共识协议,主要作用就是日志。首先它采用的是分布式系统,而分布式系统主要解决的是容错问题。那么怎么解决呢?就是同步日志。比如说一台机器上的日志高可用集群软件,我想把这些日志副本同步到其他服务器上。当然我们说的日志也可能是数据,数据库数据或者调用数据或者状态数据等等。一般来说 Raft 是奇数,因为它遵循少数服从多数的原则,通过投票来进行选举。
Raft 包含三个节点,Leader 是一个主服务器,大家会选举一个 Leader,Leader 会决定什么时候修改数据,然后它会把数据同步给 Followers,所有数据都会在 Leader 上修改,然后同步给 Followers。正常情况下,集群中有 Leader 和 Followers,服务器之间可以同步数据。但是还有一种情况,就是作为 Leader 的主服务器挂了,其他所有的服务器都会变成 Candidate,有机会被选举为新的 Leader。通过这个机制,可以保证有一个服务器可以保存数据。
但是它虽然能存数据,却不能对外提供服务。Raft 集群规定一台主机负责写入数据,另外两台负责备份。只有集群中的主节点和备份节点大多数存活,比如三个节点中有一个挂了,才能继续对外提供服务,但是如果两个挂了,就不能再对外提供服务了。
那么,这是为什么呢?如最右图所示,如果原来的主服务器与其他服务器断线了,它还能正常提供服务,另外两台服务器会根据当前的情况重新选举一台为主服务器。这时候整个集群就会同时出现两台主服务器,造成冲突。所以,我们要遵循少数服从多数的原则,整个集群中只有大多数节点都活着,才能对外提供服务。
当然,在所有的 ACD 上实现 Raft 是比较困难的。目前有一个应用叫 ETCD,我们可以直接把服务接入 ETCD,ETCD 会告诉我们谁是主,谁是备。但是这样又带来一个问题,本来三台机器就够了,但是我们又要多装三台 ETCD,这样会带来更大的开销和浪费,资源也会增加一倍。
但是当我们的集群比较大的时候,比如说除了ACD之外,我们还有其他的服务比如BCD,CDE等等,如果各种微服务数量比较多的话,它们可以共用一个ETCD,相比之下开销就不会那么大。
简单总结一下:
双机可以提高可靠性,但是资源的投入与回报不成正比;
为了节省服务器而将不同的服务放在同一台物理服务器或虚拟机上可能会适得其反;
集群可以提高可靠性,但是只有集群足够大,才能有效利用资源;
双机所需服务器数量为偶数,至少2台;
分布式系统(集群)所需的服务器数量为奇数,至少为3台。
一般来说一台FreeSWITCH服务器就够了,如果要双机设备,就需要两台服务器,如果要数据库,就需要四台服务器。还可能用Nginx来代理HTTP,还可能用Kamailio来代理SIP。当然我们主要用NATS,就是一个消息队列。然后使用Etcd来选主,还可能用Redis来做缓存,还可能用到日志、监控等各种服务器。还可能还有rtpengine,存储,业务系统……
总之,如果要搭建一个可靠的系统,至少需要十几台服务器,而它能对外提供的服务能力,也不过是一台服务器提供的服务而已。所以如果集群规模比较小的话,是没有意义的。投入是天文数字,但整体收益其实很小。如果要把集群规模做得足够大,类似云服务,那么投入多少台服务器都无所谓,因为成本比较小。当然这些最终还是需要根据业务本身去权衡。
04XSwitch 练习
接下来我们来介绍一下XSwitch的一些具体做法。
XSwitch 是XSwitch集群,一般来说最低配置是两台主备高可用的机器,FreeSWITCH和PostgreSQL放在一起。
对于预算有一定限制的客户,我们建议他们把数据库单独拿出来放在单独的服务器上,一共 4 台服务器。我们一般会把 Nginx 和 FreeSWITCH 放在一起,也可能把 Kamailio 放在一起。
如果预算充足的话,也可以全部分开,以便以后可以放更多的FreeSWITCH。
然后是远程位置,负载共享。
因为WebRTC只有媒体,所以直接上FreeSWITCH,而信令可以通过Nginx或者Kamailio来实现,因为信令是基于WebSocket的,这就是WebRTC的高可用性。当然前面也说了,还有rtpengine也可以充当代理,把FreeSWITCH隐藏在后台,这个就是比较复杂的应用了。
XSwitch 是怎么实现多租户的?其实我们有很多种方式。一种是 Per tenant per FreeSWITCH。每个租户分配一个 FreeSWITCH,每个 FreeSWITCH 上都有一个 Docker。它们使用同一个数据库。我们使用 PostgreSQL,可以很自然地划分成 Schema,每个 Schema 之间是相互隔离的。这样就可以给每个租户分配一个 Schema。
也就是每个租户一个域名,一个Docker,一个Schema,数据库也一样。前面放一个SBC,用Kamailio做信令代理。当然我们目前是单机部署SBC,以后也可以做HA。
其实具体代码我们写了一个映射表,因为我们的集群比较小,还没有放置数据库,所以我们直接通过域名找到对应的IP地址进行分配,我们使用Kamailio+Lua。
我们在应用端使用的是NATS。NATS是一个消息队列,所以它具备消息队列的一些基本特性,比如Pub/Sub可以进行推送,还有Queue Groups,可以通过队列进行订阅,这样可以做到负载均衡。生产者生成消息,消费者可以以负载均衡的方式消费这些消息。
然后我们用它做集群应用:有电话打进来,分发到Kamailio,分发到不同的FreeSWITCH上,通过NATS分配给不同的Controller,这个Controller就是应用端,应用端会控制通话的逻辑。
当一个呼叫进入到FreeSWITCH时,NATS会把它分配给一个Controller,此时Controller会和一个FreeSWITCH建立一个虚拟的对应关系,在呼叫生存期内,它可以控制这个呼叫的呼叫行为和呼叫流程。
当然这个Controller也是可以增加的,FreeSWITCH也是可以增加的。NATS也是跟Kamailio连接的,Kamailio也是可以感知NATS的。这个时候我们如果进行扩容,弹性伸缩,FreeSWITCH不够用了,我们再增加几个,这个时候FreeSWITCH就会发消息给NATS,NATS再把这个消息发给Kamailio。Kamailio就会感知到我现在有6个FreeSWITCH了,它就会重新计算它的路由表。我们通过调度模块,重新加载调度模块的数据,然后它就会把新的呼叫分发到新的FreeSWITCH上高可用集群软件,这样就完成了一个扩容,这就是弹性伸缩。
“扩展”弹性伸缩相对容易,只需添加机器即可。“扩展”则比较困难,有时需要等到所有流量都清除后才能继续。
当然还有一种“缩水”大家可能都会想到,比如说其中一台机器死机了,我重启了一下,其实重启之后就不是同一台机器了。我们这里用的是FreeSWITCH的UUID,重启之后UUID就会变。虽然IP地址可能变也可能不变,但是因为是新机器,所以我们就认为它变了。
所以在这个集群里,即使重启之后,也不再是同一台机器了。我们在哲学里学过“人不能两次踏入同一条河流”就是这个意思。如果要做集群,最好做成无状态的,这样才能分布式,大规模复用。
所以用到的机制主要是Docker和K8S。当然把FreeSWITCH放到K8S里也不容易,我们先把它放到Docker里,完成容器化,再放到K8S里。因为K8S是一个网络,好处就是你不知道它跑在哪台物理机上,你想开就开,想关就关。但是FreeSWITCH、SIP,特别是RTP,它们端口很多,比较麻烦。
那么,我们怎么做呢?我们用 Kamailio 作为 Ingress,负责信令。Kamailio 还是双机的,然后分发到后端的 FreeSWITCH。如果 FreeSWITCH 不够用,我们就扩容,否则就缩容。
但是具体我们用了一个叫VIP的东西,这是我们自己写的一个协议。因为现在的K8S主要是针对HTTP进行优化的,对于SIP的应用来说会比较麻烦。所以我们自己写了一个应用,每个物理机或者虚拟机上面都有一个VIP服务。在启动FreeSWITCH的时候,每个机器上只启动一个FreeSWITCH,它告诉VIP开放一对端口,然后VIP通过iptables把这些端口开放,就能正常分发了。万一这台机器死了,端口空了也没关系,因为FreeSWITCH也死了,不会再有服务给它发了。当机器重启的时候,端口还是使用这些端口段,所以就没有问题了。这样的话RTP就直接走FreeSWITCH了,前端还是通过Kamailio来分发SIP。
这种应用就是每个Node上只跑一个FreeSWITCH,每个Node上也跑一个VIP,当然VIP叫DaemonSet,每台机器上只启动一个VIP服务,集群里也是这个服务。这样我们就可以动态的开放SIP、RTP端口,这样就可以实现弹性伸缩了。以上就是我们做的一些应用。
当然,如果一个 Node 有 64 核或者 128 核的话,那能不能跑多个 FreeSWITCH 呢?可以的,其实需要通过端口段来区分,可以做成两个 Pod,一个占用 1-2 万,一个占用 3-5 万。这样既保证了两个 FreeSWITCH 同时启动的时候不会互相影响,管理也会复杂一些。
以下是在 Kamailio 中使用 NATS 的一些基本代码:
05会议
另一种是会议。
我们正常的负载均衡分配是尽量均匀的分配到不同的FreeSWITCH上,这是最好的分配策略。但是对于会议来说,这是做不到的,因为所有打往同一个会议号的电话都需要分配到同一个FreeSWITCH上。这里我们在Kamailio中使用了“2”策略,即“hash over to URI”。
当然在实际使用中,会议规模比较大,一台FreeSWITCH无法满足需求,我们需要放到多台FreeSWITCH上,这时候我们使用“7”策略,“对PV的字符串内容进行哈希处理”,我们可以自己创建一个字符串,只要不同的终端计算出来就是一个组,通过分组,只要计算出来的字符串相同,就会被分配到同一个FreeSWITCH上。
视频会议有几种模式:Mesh是无状态的,MCU就是一切都通过中间的屏幕整合,SFU是通过它进行分发的,不整合屏幕。
我们还会将会议级联,通过级联多台FreeSWITCH来实现更大规模的会议。
级联还会造成一种叫“互相看”的问题,这是一种类似无限循环的效果,如上图所示。
那么,我们该怎么做呢?在我们的会议中,我们首先讨论如何连接两个 FreeSWITCH 会议。
很简单,只要在第一个FreeSWITCH中输入会议3000(会议号码),然后呼叫另外一个FreeSWITCH,同样呼叫3000,另外一个FreeSWITCH接到呼叫后,直接加入与会议3000的会议,此时两个会议就接通了。
串起来之后,我们就可以设置两个画布了,第一个是“video_initial_canvas”,表示我把我的图片放在哪个画布上;第二个是“video_initial_watching_canvas”,表示我观看哪个画布。
通过这种方式,我们也实现了MCU和SFU的互通,现在我们已经开放了Agora、TRTC、MediaSoup等应用。
06 日志
我最后想谈的事情是日志记录。
日志记录很简单,有一些现成的服务:
Homer 是用于 SIP 日志的,它的实现原理就是在 FreeSWITCH 或者 Kamailio 里面插入一个 Agent,把收到的消息转发给它,然后画出 SIP 图。Loki 是用来存储日志的,我们会把所有的日志都发给它。另外还有 Zabix、Grafana、Promuthus。
这里的关键点是,每天有几万个通话在进行,我们需要知道哪些通话和哪些通话有关联。所以我们需要一个 uuid。FreeSWITCH 中每个通话都有一个 uuid,这个 UUID 必须和 call-id 关联起来。通过 call-id 可以找到对应的 uuid,通过 uuid 可以找到另一条腿的 uuid。
以上是来电,拨出电话时会用到此参数:outbound-use-uuid-as-callid。
如果FreeSwitch向外部打电话,则sip中的呼叫ID和内部UUID是一致的,因此您可以找到它们的对应关系,即日志和SIP之间的对应关系。
这样,当A进来时,可以通过A的呼叫ID找到UUID,并且可以通过B的UUID找到相应的呼叫ID。
07 结论
最后,让我们简要介绍通信簇,我们需要使用各种开源软件,包括双光机和三频,包括一对一的呼叫,呼叫中心,音频和视频会议,日志监视和其他软件,无论使用什么软件,他们都使用了基本原理。