03-信令归信令,媒体归媒体:Kamailio、FreeSWITCH与RTPEngine的分工
信令归信令,媒体归媒体:Kamailio、FreeSWITCH 与 RTPEngine 的分工
新同事入项第一天,我一般都先讲明白三件事:谁管信令、谁管媒体、为什么这么分。想不通这三个问题,后面所有的排障都是玄学。这篇就把这套媒体层组网掰开讲一遍。
一开始不是这样的
最初的方案是 FreeSWITCH 一肩挑:分机注册到它,中继从它出,WebRTC 也从它进。跑起来之后发现四个结构性问题:
- FS 暴露 internal:5060、external:5080 等多个 profile 入口,信令和媒体耦合在一个进程里,任何配置改动都是全局风险;
- 配置下发靠"写 XML + 共享卷 + ESL reload"——改一条中继要写 trunk-*.xml、等容器同步、再
sofia rescan,链路长且脆弱; - 没有 SIP 层负载均衡和故障切换,FS 一挂全挂;
- 多租户的 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 进程里没有一个字节音频流,这是控制面可以随便重启发版的根本前提。
一次浏览器外呼的信令之旅
以坐席点拨号为例,把信令流走一遍:
- 后端经 ESL 下发
originate,呼叫串是sofia/gateway/kamailio/<客户号码> &bridge(sofia/gateway/kamailio/<坐席分机>)——注意两腿都是"经 Kamailio 的网关",FS 自己不再直连任何人; - FS 把 INVITE 递给 Kamailio,Kamailio 查 dispatcher(运营商中继组),转发给运营商,同时触发 RTPEngine 建立媒体;
- 客户接听后,FS 发起坐席腿 INVITE,还是给 Kamailio;
- Kamailio 查 usrloc(注册位置表)找到坐席分机的注册 contact——坐席可能用浏览器 WSS 注册,也可能用实体话机 UDP 注册,同号多设备按标准 fork 行为并行振铃——对 FS 来说完全透明,它只认"Kamailio 网关后面有个分机号";
- 两腿都通了,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 篇 。

浙公网安备 33010602011771号