03-信令归信令,媒体归媒体:Kamailio、FreeSWITCH与RTPEngine的分工

信令归信令,媒体归媒体:Kamailio、FreeSWITCH 与 RTPEngine 的分工

新同事入项第一天,我一般都先讲明白三件事:谁管信令、谁管媒体、为什么这么分。想不通这三个问题,后面所有的排障都是玄学。这篇就把这套媒体层组网掰开讲一遍。

一开始不是这样的

最初的方案是 FreeSWITCH 一肩挑:分机注册到它,中继从它出,WebRTC 也从它进。跑起来之后发现四个结构性问题:

  1. FS 暴露 internal:5060、external:5080 等多个 profile 入口,信令和媒体耦合在一个进程里,任何配置改动都是全局风险;
  2. 配置下发靠"写 XML + 共享卷 + ESL reload"——改一条中继要写 trunk-*.xml、等容器同步、再 sofia rescan,链路长且脆弱;
  3. 没有 SIP 层负载均衡和故障切换,FS 一挂全挂;
  4. 多租户的 DID 路由硬编码在 dialplan 里,每接一个新租户都要改配置文件发版。

中间还认真评估过 LiveKit SFU 方案(浏览器进房间、livekit/sip 网关对接 PSTN),最终放弃了:为了接电话引入 LiveKit Server + 网关 + JWT/webhook 一整套东西,而"被叫号=房间名"的模型跟坐席固定分机、转接、监听这些呼叫中心的基本操作全都别扭。ADR-003 里这条决策的原话是"激进替换"——直接换掉,不留中间态。

现在的分工

最终组网三个角色,各管一段:

Kamailio 管 SIP 信令。 分机注册(软电话 WSS、硬话机 UDP)、运营商中继、DID 路由、鉴权、dispatcher 负载均衡,全部收口在它这里。它只碰信令,不碰一秒钟音频。官方文档对它的定位很准确:Kamailio 是信令代理,不是 FreeSWITCH 的替代品——FreeSWITCH 退居媒体面之后,两者关系从"互相替代"变成"各干各的"。

FreeSWITCH 管媒体和呼叫控制。 IVR 放音、录音、DTMF、桥接两腿通话、会议,这些都是它的老本行。后端通过 ESL(8021 端口 event socket)控制它,而且定了一条铁律(ADR-002):esl-gateway 是 FS 唯一控制入口,业务代码禁止直连 ESL。所有 FS 事件经 esl-gateway 聚合后发 RabbitMQ,单向异步——每通电话 20~40 条事件,先削峰再消费,数据库永远不当第一现场。

RTPEngine 管媒体桥。 浏览器那腿是 DTLS-SRTP/ICE,运营商那腿是普通 RTP,两边格式对不上,RTPEngine 负责翻译加 NAT 穿越。坐席到客户的完整媒体路径是:坐席浏览器(SRTP)↔ RTPEngine ↔ FreeSWITCH ↔ 运营商(RTP)。顺带一提,这条路径决定了控制面(cc-backend)从头到尾不接触任何 RTP——Spring Boot 进程里没有一个字节音频流,这是控制面可以随便重启发版的根本前提。

一次浏览器外呼的信令之旅

以坐席点拨号为例,把信令流走一遍:

  1. 后端经 ESL 下发 originate,呼叫串是 sofia/gateway/kamailio/<客户号码> &bridge(sofia/gateway/kamailio/<坐席分机>)——注意两腿都是"经 Kamailio 的网关",FS 自己不再直连任何人;
  2. FS 把 INVITE 递给 Kamailio,Kamailio 查 dispatcher(运营商中继组),转发给运营商,同时触发 RTPEngine 建立媒体;
  3. 客户接听后,FS 发起坐席腿 INVITE,还是给 Kamailio;
  4. Kamailio 查 usrloc(注册位置表)找到坐席分机的注册 contact——坐席可能用浏览器 WSS 注册,也可能用实体话机 UDP 注册,同号多设备按标准 fork 行为并行振铃——对 FS 来说完全透明,它只认"Kamailio 网关后面有个分机号";
  5. 两腿都通了,FS 内部 bridge,开录音,通话建立。

这里有个设计细节值得单独说:软电话和硬话机被统一成了同一种东西。两者都是注册到 Kamailio 的 SIP UA,AOR 都是分机号,桥接目标永远是 sofia/gateway/kamailio/<分机号>。坐席用什么终端接电话,对呼叫引擎来说是不可见的。

12 个变量的契约

多租户系统里,一通电话从 FS 诞生的那一刻起就必须"知道自己属于谁"。我们的做法是定义了 12 个 cc_* channel 变量:cc_tenant_id、cc_task_id、cc_roster_item_id、cc_seat_id、cc_campaign、cc_trunk_name 等,originate 时注入,出局时 FS dialplan 统一 export sip_h_X-cc-* 转成 SIP 头,入局再反着读回来。ESL 事件流里 tenant_id 缺失会直接拒收——宁可不处理,也不能把 A 租户的通话记到 B 租户头上。

端口与部署形态

生产是两个 compose 栈、一张 docker 网络:

组件 端口 暴露范围
Kamailio SIP 5060 UDP/TCP 公网(注册与中继)
Kamailio TLS/WSS 5061 TCP 公网(浏览器软电话信令)
Kamailio 入局 9375 UDP/TCP 公网(注册型运营商中继呼入)
RTPEngine 媒体 35000-65000 UDP 公网
FreeSWITCH ESL 8021 TCP 仅内网(限 backend 可达)
FreeSWITCH SIP 5060 仅 Kamailio 可达
RTPEngine ng 控制 22222 仅内网

应用栈(backend/postgres/redis/rabbitmq/rustfs)和媒体栈(freeswitch/kamailio/rtpengine)通过 external 网络 cc-net(172.19.0.0/16)互通,跨栈共享两个卷:cc_fs_recordings 录音、cc_fs_sounds 放音——backend 写放音、FS 读放音,FS 写录音、backend 读录音上传对象存储。Kamailio 容器固定 IP 172.19.0.5,cfg 里的 listen/advertise 硬依赖这个地址,这在后来迁移服务器时又坑了一次,那是后话。

几个安全细节:ESL 密码禁止默认的 ClueCon,必须是 16 位以上随机串;分机 SIP 密码用 AES-GCM 加密落库,主密钥丢了就等于所有分机凭据报废,所以有专门的备份纪律;WSS 用独立域名加受信证书,浏览器才肯把麦克风交出来。

内存态与数据库:一个持续的张力

Kamailio 体系里有个绕不开的特性:鉴权表和注册表是内存态的。

分机的 digest 鉴权摘要(ha1)放在 Kamailio htable 里,它不读数据库。谁负责把数据库里的分机密码推过去?后端。于是产生了一条隐形契约:所有改分机密码的代码路径都必须调 syncExtension 推送 ha1,后端启动时 resyncAll 全量兜底。听起来稳,实际上 2026 年 10 月就栽过一次:新建分机走了一条没推送的代码路径,坐席注册 401 死循环一小时 1900 多次,最后定位到"创建分机"和"改分机密码"是两个入口,只堵了后者。这事的完整复盘在排障篇里。

同样,uac 中继代注册状态存在 PG 的 uacreg 表、dispatcher 探测目标也由后端从业务库推送——Kamailio 本身无状态可丢,重启后靠后端 60 秒一次的 resyncIfNeeded 恢复。这个"内存态性能好、但要小心所有写入口"的设计,是这个体系给我们上过的最贵的一课。

取舍的账

这套组网的代价要认账:浏览器媒体比直连 FS 多一跳 RTPEngine(局域网内 <5ms,可接受);多一个组件要运维,Kamailio cfg 的学习曲线陡得像悬崖(subst 语法写错一个字符容器就崩溃循环,亲身经历);排障要同时看三个组件的日志。

换来的是:FS 可以池化横向扩(dispatcher 自动探测 failover);租户 DID 路由从"改配置发版"变成"改一行数据";信令面无状态、重启不拆通话;浏览器的 WebRTC 复杂度被 Kamailio + RTPEngine 挡在业务代码之外。

下一篇讲软电话——这套组网上线后第一批生产事故,几乎全部埋在信令与媒体的接缝里。


本文是「云呼叫中心技术内幕」系列第 3 篇 。

posted @ 2026-10-25 20:51  cliffdev  阅读(3)  评论(0)    收藏  举报