Kamailio dispatcher模块:不只是负载均衡,更是SIP网源判断与分流利器
做VoIP接入这几年我越来越觉得Kamailio的dispatcher模块被严重低估了。很多人一提到dispatcher第一反应就是“负载均衡”把多台SIP服务器放进一个组里做轮询但它实际能干的活远不止这个。在“判断网源”这件事上也就是判断当前呼叫到底从哪条链路进来——运营商专线网关、公网SIP中继、企业内部IPPBX还是WebRTC接入网关dispatcher模块配合它那套“来源地址集合 分组编号 分发算法”的机制比随手写的if分支和正则判断要规整得多也更好维护。这篇文章就基于我一个实际重构过的接入平台聊一聊怎么用dispatcher做网源判断和分流以及线上踩过的三个实打实的坑。先交代一下我遇到的场景这个问题应该能覆盖不少做SIP接入的朋友。某呼叫中心平台有两条运营商中继、一条公网SIP线路外加公司内部的一台IPPBX。业务方要求每条线路的呼叫必须走不同的鉴权策略专线过来的主叫号码可以直接信任公网中继过来的要做白名单校验IPPBX的呼叫则要加上额外的信任标记方便后端计费区分。刚开始我是用一串if ($si ...)硬编码去匹配前一个月还行等网源节点增加到二十几个配置就变得非常难看每次增删线路都要改主路由逻辑出问题的时候一眼根本看不出哪个分支对应哪条线。1. 网源判断的真实业务场景与模块选型逻辑1.1 我遇到的“多网源混跑”问题先说具体痛点。平台上有三个网源组组A运营商专线网关固定两个源IP分别对应主备两个接入点。组B公网SIP中继源IP段相对固定但有多个对端节点还经常临时扩节点。组C内部IPPBX源IP只有一个信任级别最高不用做二次鉴权。你要做的“判断网源”本质上就是两个动作。第一个动作是识别来源进来的INVITE是从哪个组来的。第二个动作是按来源选路识别出来之后给它定一套处理策略决定是放行、拒绝对还是送到某个特定的出口网关注册出去。这两个动作拆开看不复杂但合在一起做工程化很多人就开始头疼了。用if ($si 1.2.3.4)硬写确实是最快的方式但它有三个毛病第一网源一多路由脚本会膨胀到没法读第二公网中继这类动态节点扩IP的时候你得改cfg再reload运气不好还会影响正在跑的呼叫第三判断和路由策略是耦合的想调整“某个IP从组A挪到组B”你得找两处代码来改。这个阶段还谈不上什么经验纯粹是被维护成本逼着换方案。1.2 为什么选dispatcher而不是permissions模块或纯if分支有些人会问Kamailio不是有permissions模块吗它里面的allow_source_address搭配address表也能做来源IP管理为什么不用它做网源判断这个问得挺到位。permissions模块确实能做来源控制address表甚至支持子网段比如192.168.1.0/24这是dispatcher在部分版本里做得不够好的地方。但permissions的定位是“访问控制”它的能力边界在allow/deny不在“按来源组做后续路由”。你判断完来源之后还得自己写if分支把那组地址映射到一组出口等于还是要写路由代码。而dispatcher天生就是“集合 组 算法”的结构它能把“来源匹配”和“组选路”用一个机制串起来。纯if分支的问题就更明显了。不仅是维护难可读性也很差。我后来把代码发给同事评审的时候用一个ds_is_from_list(1)加一个ds_select_dst(1, 5)就能说明白一条线路的完整逻辑比贴二十行if清晰得多。再说负载均衡这个“本职工作”。dispatcher模块本身是给SIP消息做分发用的它会维护一个目的地列表用轮询、哈希、权重等算法替你选一个目的地然后把RURI改掉。这个能力放在网源场景下其实非常好用一个网源组下面可能挂了多个出口节点我只要把组里的节点配好dispatcher自动决定当前呼叫该往哪个节点发这不就是网源维度的负载均衡吗。所以我的选型结论是用dispatcher做网源判断本质上是把“来源白名单”和“出口选路”合并成了同一套配置管理。这也是这篇文章标题想表达的核心。2. dispatcher模块的网源匹配机制ds_is_from_list与目的地址表2.1 dispatcher表到底怎么存网源信息dispatcher模块在Kamailio里既可以用纯文本的dispatcher.list也可以挂数据库。网源规模小的时候文本文件足够规模一大或者需要在线调整强烈建议入数据库。我的平台用的是MySQL表结构大概长这样CREATE TABLE dispatcher ( id INT unsigned NOT NULL AUTO_INCREMENT, setid INT unsigned DEFAULT 0, destination VARCHAR(64) DEFAULT , flags INT unsigned DEFAULT 0, priority INT unsigned DEFAULT 0, attrs VARCHAR(128) DEFAULT , description VARCHAR(64) DEFAULT , PRIMARY KEY (id) ) ENGINEInnoDB;核心字段就四个setid、destination、flags、priority。放到网源场景里setid就是“网源组编号”destination就是“网源节点的SIP地址”。举个我实际用的例子INSERT INTO dispatcher (setid, destination, flags, priority, attrs, description) VALUES (1, udp:192.168.10.10:5060, 0, 0, weight10 desccnc-master, 运营商专线A主), (1, udp:192.168.10.11:5060, 0, 1, weight5 desccnc-backup, 运营商专线A备), (2, udp:172.20.10.20:5060, 0, 0, descpublic-sip, 公网SIP中继), (3, udp:10.10.10.10:5060, 0, 0, descip-pbx, 内部IPPBX);注意destination的写法是协议:IP:端口这里我用的是udp:。sip:这种写法在一些老文档里出现过但在dispatcher模块的解析逻辑里它期望的是传输层协议名不是sip这种URI协议名。这个细节后面我会专门展开它是我踩过的第一个坑。priority是组内排序用的数字越小优先级越高。attrs在Kamailio 5.x之后是一个字符串可以存权重、描述信息甚至自定义键值对排障的时候非常有用。2.2 ds_is_from_list的匹配逻辑与注意事项判断网源最核心的函数就是ds_is_from_list。这个函数的作用是检查当前请求的源IP和源端口是否落在dispatcher列表里你可以给它传一个setid参数也可以不传。不传就是匹配任意组传了就是只在某个网源组里找。文档里的定义是Return true if the source address (IP and port) of the request is in the dispatcher list换句话说这个匹配不只是比较IP端口也要对得上。这句话看起来不起眼但实际影响非常大。我给你还原一个线上场景。某天有个公网SIP中继节点改了配置对端发INVITE的时候源端口从固定5060改成了随机端口结果我们这边的dispatcher判断立刻失效所有从这个节点来的呼叫都掉进了默认403分支。抓包一看源IP没变源端口变成了34871而dispatcher列表里存的是udp:172.20.10.20:5060自然匹配不上。怎么处理两种思路。第一种是约束接入方要求网源节点固定源端口5060。这个在运营商专线场景下基本可行因为中继网关一般都能配固定本地端口。第二种是不要指望dispatcher帮你匹配网段或随机端口而是在路由脚本里先用$si做粗筛命中后再用dispatcher做组内选路。比如regex($si, ^172\\.20\\.10\\.) { ds_select_dst(2, 5); }这就绕开了ds_is_from_list端口匹配的局限。所以我的经验总结是ds_is_from_list适合“源IP和源端口都稳定”的网源节点精确匹配如果网源侧端口不固定它就不是最优解不要把网源判断的赌注全押在这个函数上。3. 网源分流配置实战数据库、路由脚本与算法选择3.1 配置加载与验证手段讲完表结构说怎么加载。Kamailio配置文件里要做的几件事loadmodule dispatcher.so; modparam(dispatcher, db_url, mysql://kamailio:PASSWORDlocalhost/kamailio); modparam(dispatcher, ds_ping_method, OPTIONS); modparam(dispatcher, ds_ping_interval, 30);如果不用数据库也可以指定文本文件modparam(dispatcher, list_file, /etc/kamailio/dispatcher.list);在pcscf或者类接入场景里一般还会把ds_ping_method配成OPTIONS让dispatcher定时探测目的地是否活着。这个探测机制后面会讲它既能帮你自动感知网源节点下线也会带来一个非常隐蔽的坑。加载完之后不要急着写路由逻辑先用kamcmd验证数据有没有真正被读进去kamcmd dispatcher.list kamcmd dispatcher.dumpdispatcher.dump的输出会很明确每一行能看到setid、destination、当前状态是active还是inactive。这一步能排除一大半配置问题。3.2 主路由中的网源判断与分流逻辑我现在的路由脚本模式大致是这样。进入主路由后先处理初始INVITE然后按顺序判断网源组request_route { if (!is_method(INVITE) || has_totag()) { route(RELAY); exit; } if (ds_is_from_list(1)) { xlog(L_INFO, NETWORK_MATCH: set 1 (trunk), src $si:$sp\n); route(FROM_TRUNK); exit; } if (ds_is_from_list(2)) { xlog(L_INFO, NETWORK_MATCH: set 2 (public sip), src $si:$sp\n); route(FROM_PUBLIC); exit; } if (ds_is_from_list(3)) { xlog(L_INFO, NETWORK_MATCH: set 3 (ip pbx), src $si:$sp\n); route(FROM_IPPBX); exit; } xlog(L_ERR, NETWORK_MATCH: forbidden src $si:$sp\n); sl_send_reply(403, Source network not allowed); exit; }这里有个小细节我对非初始请求或者带tag的请求直接进了RELAY。原因很简单网源判断只需要在呼叫建立的第一个INVITE上做一次后面同会话的请求包括re-INVITE、BYE不需要重新判断。如果你每个包都判断网源不仅浪费CPU还可能因为个别请求的源IP来自转发路径上的不同节点误判导致呼叫被拆断。每组对应一个子路由拿运营商专线组举例route[FROM_TRUNK] { if (ds_select_dst(1, 5)) { route(RELAY); } else { sl_send_reply(503, Service Unavailable); } exit; }ds_select_dst(1, 5)的意思是在组1里选一个目的地算法用5号算法。选成功后函数会修改RURI和下一跳地址然后交给t_relay()转发。这一步做完网源判断和选路就算完整闭环了。3.3 分发算法怎么选轮询、哈希还是权重ds_select_dst第二个参数是算法号不同版本在细节上会有点差异但常见的几个值很稳定我这里列一个常用对照表算法值含义适用场景0轮询通用负载均衡节点能力一致时用1基于Call-ID哈希同一通话固定到同一节点2基于权重节点性能差异明显时用3随机测试场景生产不推荐4一致性哈希Call-ID节点频繁增删时最稳5基于源IP哈希按网源维度固定出口网源场景下我最常用的是5号也就是基于源IP的哈希。它的效果是同一个源IP进来的呼叫会被分配到同一个出口节点。这个特性在排查问题时特别舒服因为你可以预期某个网源的所有呼叫都走同一台后端不需要跨节点串日志。如果你某个网源组下面的出口节点吞吐能力不一样那就别用固定轮询在attrs里写权重配合2号算法modparam(dispatcher, ds_hash_size, 11); modparam(dispatcher, ds_hash_check_interval, 60);权重是个相对值不需要你精确计算每条链路的并发上限但建议根据实测呼叫量和CPU负载来折算别拍脑袋。比如主节点LICENSE多、CPU好就设weight10备节点配置低设weight5差不多就是2:1的比例。4. 线上实测中遇到的三个坑与完整排查链路4.1 坑一源端口随机导致ds_is_from_list漏判这个前面已经提到了但我要把完整排查链路写出来因为这类问题线上遇到的时候第一反应往往不是端口而是怀疑配置没加载。现象某天运营反馈公网SIP中继的呼叫全部失败错误是403。排查第一步查日志。我们每台Kamailio都有xlog输出日志里能看到NETWORK_MATCH: forbidden src 172.20.10.20:43218。这说明脚本确实跑到了最后的兜底403分支也就是说前面所有ds_is_from_list都没命中。排查第二步看dispatcher列表状态。kamcmd dispatcher.list查询组2destination正常显示udp:172.20.10.20:5060状态active。这时候最容易被带偏以为列表没问题转而去怀疑permissions或者网络策略。排查第三步抓包确认源端口。用sngrep在Kamailio接入网卡抓包发现INVITE源IP的确是172.20.10.20但源端口确实是43218。到这一步答案就很明显了。中继节点最近调了配置把本地SIP端口从5060改成了随机端口。解决方案我当时给了两条路对方把端口固定回5060或者我们把组2的destination从精确端口改成带通配的匹配方式。最终选择了前者毕竟对接方是公网中继固定端口对两边排障都友好。如果你遇到的网源是海量SIP终端端口肯定固定不了那就用$si正则先判断来源再用ds_select_dst做选路不要让ds_is_from_list硬扛。4.2 坑二dispatcher对网段匹配不友好第二个坑是关于网段的。很多刚上手的人会在destination里写udp:192.168.10.0/24:5060想在dispatcher里直接配一大段来源地址。这个想法我很能理解但实测下来在多数主流版本里dispatcher的destination字段不会老老实实按CIDR展开匹配。它的匹配逻辑更偏向于“精确地址匹配”也就是源IP要跟destination里的IP字段完全一致。你写一个/24进去它可能不报错但也不会按你想象的那样理解成“这个网段里所有地址都算数”结果就是网段里只有个别IP碰巧命中。我当时有一个内部IPPBX网段是10.10.20.0/24下面挂了二十几台话机我一开始想当然写了CIDR调试了半小时都没匹配上。后来改成两条路要么把二十几个话机IP全部逐条写进dispatcher表要么在路由脚本里先用正则做网段粗筛if (regex($si, ^10\\.10\\.20\\.)) { route(FROM_IPPBX); exit; }正则粗筛的好处是维护成本极低网段内新增话机不用改配置。但它的缺点是没法直观地在dispatcher表里看到这组网源运维同事会不习惯。所以我的建议是分场景网关节点用dispatcher精确匹配散终端网段用正则粗筛后再用dispatcher选路。如果项目里对“允许来源地址”的管理要求特别严格比如要可视化维护网段、要审计那我建议另起permissions模块的address表里面专门有subnet字段配合allow_source_address做来源准入dispatcher只负责后续的出口选路。不要让一个模块把所有脏活都干了。4.3 坑三OPTIONS活动探测把不响应探测的网源误判为不可用第三个坑比较隐蔽也最值得写。dispatcher模块默认会对destination发活动探测最常见的是每30秒发一个OPTIONS。探测的目的是确认对端SIP服务还活着探测失败后会把该destination标记为inactive后续调用ds_select_dst时会跳过它。问题在于有些网源节点压根不响应OPTIONS但并不意味着它们不能接INVITE。举个例子某个网源网关为了保证安全只放行INVITE和ACK对OPTIONS一律回403甚至直接丢弃。dispatcher探测了几次发现没收到200 OK就在内部把这条destination标记成了inactive。现象很迷惑ds_is_from_list明明应该匹配上但因为在某些版本的实现里对inactive的地址不会算作命中于是合法呼叫继续被403。我当时排查到kamcmd dispatcher.list时看到状态列是Iinactive才意识到是探测机制把网源“探死”了。解决方案有两个方向。方向一是让网源节点响应OPTIONS很多SIP网关都有“回复options”的开关打开就行。方向二是对纯做来源判断的destination关闭活动探测。我后来是把这组网源的flags配成了不参与探测的位或者直接调整模块参数只在真正需要负载均衡的组里保留探测能力。这里还要提醒一句如果你关掉了探测那么网源节点故障时dispatcher就不会自动摘除它上游呼叫仍然会被路由过去。这就要靠SIP层的超时重试来兜底。生产环境里我建议针对每个组单独权衡别图省事全局一刀切。5. 进阶玩法网源标记透传与故障切换策略5.1 把网源编号透传给下游应用判断出网源之后多数时候不只是Kamailio自己要用这个结论后端的应用服务器、计费系统、录音平台也要知道呼叫从哪条线来。与其让下游再去解析一遍源IP不如在SIP消息里直接带一个标记头比如X-Src-Network。在路由脚本里加一行就行route[FROM_TRUNK] { append_hf(X-Src-Network: trunk-group-1\r\n); ds_select_dst(1, 5); route(RELAY); exit; }下游的AS看到这个头就知道主叫号码该怎么处理。这个做法的额外好处是即使未来某个网源节点迁移IP下游逻辑不用变因为标记头是由Kamailio在入口统一打上的。如果不想污染SIP头也可以用AVP参数加$var在xlog里输出或者写入自定义的cdr表字段。但很多需求方还是要实时拿到结果所以SIP头透传是最通用、也最容易被下游开发接受的方案。这里有个合规小提醒任何自定义头都不要以P-开头以外随便造容易跟现有的P头语义冲突。X-Src-Network这种X头在内部网络里问题不大如果呼叫最终要出公网到第三方平台最好在中继前把自定义头剥掉免得对端拒绝。5.2 同网源跨节点容灾配合ds_next_dst做失败切换dispatcher组里可以配多个destination优先级字段控制主备。一个网源组可能对应两个出口SIP节点主节点挂了备节点要能顶上去。Kamailio里可以通过ds_next_dst()实现呼叫失败后的切换。我常用的一个简化逻辑是这样的route[TRUNK_FAILOVER] { if (!ds_select_dst(1, 5)) { sl_send_reply(503, No destination); exit; } if (t_relay()) { exit; } if (t_check_status(408|500|502|503|504)) { ds_next_dst(); if (t_relay()) { exit; } } sl_send_reply(503, Service Unavailable); exit; }这个逻辑的含义是第一次用算法5选主节点如果转发失败或者下游回了500/503这类临时错误就用ds_next_dst()切到组里下一个节点重试。注意这只是示意逻辑t_relay的返回值在不同版本里略有差异线上使用前一定要在小流量环境验证。有了这个机制网源判断和容灾就变成了一件事。你不需要在代码里手写“主线路IP是什么备线路IP是什么”这些信息都在dispatcher表里改表就是改策略重启都不用。最后再分享一个我一直在用的维护小技巧。dispatcher表里一定要在attrs写描述比如desccnc-gw、descpublic-sip而不是只留一个IP。排障时执行kamcmd dispatcher.dump一眼就能看出哪个IP对应哪条业务线路。而且数据库改完配置后直接kamcmd dispatcher.reload热加载就可以生效完全不用重启Kamailio这正是我后来坚决把网源配置从大段if分支迁到dispatcher表里的原因——运维操作从“改代码发布”变成了“改数据库一行记录”这个差别维护过的人自然懂。