做VOIP核心网运维的人迟早会碰到这种尴尬局面网关扩容了、运营商要求割接、某个线路质量突然下降但Kamailio还在按一张静态表转发改一次配置就要reload一次白天还不敢动生怕把线上通话搞断。我最早折腾这套系统的时候也是一样SIP服务器本身没问题瓶颈全在路由策略上——写死的前置路由、简单的failover、没有权重的负载。后来我花了一段时间把Kamailio的路由脚本彻底改造成动态路由把负载均衡和灰度发布都塞进了脚本里效果立竿见影。这篇文章就把这套方案完整拆开讲适合正在用Kamailio做SIP代理、软交换又对路由灵活性不满意的同学参考。1.1 静态路由的痛点和动态路由的价值很多团队的Kamailio配置其实是这样的一个dispatch模块或rtjson里写一堆网关地址按顺序轮询挂了就顺延。这在网关数量少、业务单一的时候完全够用。但一旦网关超过三四个或者不同客户要分到不同线路问题就来了你得维护一堆if-else或者靠dispatcher模块的setid硬编码分组。这种方案最大的问题不是难写而是难改。每次割接、加网关、调比例都要动配置重载模块运气不好还会影响在线的注册和通话。动态路由的核心思路是把“路由决策”从静态配置里解放出来变成一段按请求实时计算的逻辑。这段逻辑可以根据主叫、被叫、地区码、时间、网关健康状态、甚至自定义的头域来决定最终把请求转给谁。配合脚本里的哈希计算和权重算法就能做到同一用户始终走同一网关会话保持、不同网关按比例分流、新网关上线时只切一小部分流量先观察——这些就是负载均衡和灰度发布的基础。1.2 脚本化方案的整体设计思路我用的是Kamailio原生cfg脚本而不是KEMI框架Lua/Python/JS原因很简单cfg脚本直接在核心层执行路径最短、性能最好出了问题也好排查。整个方案分三层数据层用htable哈希表存放网关组、权重、灰度比例运行时可以动态修改不需要重启Kamailio。决策层路由脚本按固定顺序执行——先做基础检查再根据主被叫信息命中网关组最后用哈希或权重算法选网关。执行层通过route[RELAY]或t_relay()把请求转发出去同时在日志里记录关键决策变量。这个分层的好处是每一层的职责很清晰改数据不动逻辑改逻辑不动数据。比如灰度发布时我只需要在htable里改一个灰度比例参数脚本每次请求会自动读到新值下一条电话就走了新策略真正做到秒级生效。2. 动态路由脚本的核心从请求进来开始说2.1 路由脚本里的执行模型Kamailio的cfg脚本本质是一套事件驱动的路由块。每个SIP请求进来之后会进入request_route这个主路由块从上往下执行。你可以把它理解成一段“接电话后的处理流程”先判断这通电话是不是本地的、要不要鉴权、是不是转发请求然后才轮到路由决策。实际我的路由块大致长这样request_route { # 基本SIP检查 if (!mf_process_maxfwd_header(10)) { sl_send_reply(483, Too Many Hops); exit; } # 只处理需要转发的呼叫请求 if (is_method(INVITE)) { route(ROUTE_DYNAMIC_SELECT); route(ROUTE_RELAY); exit; } # 其他方法如ACK、BYE、OPTIONS按默认方式处理 route(ROUTE_DEFAULT); }这里有个细节容易被新手忽略INVITE请求进入路由选择逻辑时脚本里$rU被叫号码和$fU主叫号码已经在PV伪变量里准备好了直接拿来参与计算就行。千万别在路由选择前又去改$rU否则后面的哈希计算全部失真。2.2 关键逻辑一如何标记和选择网关组网关组我用的是一个简单的整数字段gateway_group_id归入htable: gw_group。每一条网关记录包含组ID、网关地址、权重和健康状态。脚本里通过$sht(gw_group$var(group_key))来读取网关组的属性其中$var(group_key)由请求参数动态拼出来比如根据被叫的前缀或者主叫的客户ID来定位到某个网关组。为什么要用htable而不是数据库因为数据库查询在每次INVITE进来时都做一次延迟和压力都不划算。htable是内存结构读取是纳秒级。如果网关配置变了只需要通过Kamailio的RPC命令或HTTP接口去动态更新那张哈希表不需要reload配置文件这也是整个方案能够“动态”的根本原因。2.3 关键逻辑二动态路由的条件判断所谓动态不只是读表还要在脚本里做实时判断。我总结了一下实际业务里用到频率最高的几个判断维度按被叫前缀选网关组比如010开头的走北京出口线路0755开头的走深圳出口线路。按主叫客户ID选网关组大客户有专线小客户走公共线路池。按时间窗口选网关组夜间线路费率低的时段把流量切到备用运营商。按主被叫的哈希结果选网关这个用于组内负载均衡保证同一主被叫对每次都命中同一个网关后面细讲。举个例子前缀匹配的代码很简单但要注意通配顺序。有一次我排查问题发现某个号码段永远走不到第二组网关查了半天发现是前面的前缀规则把它吃掉了。所以我的习惯是所有前缀都先做长度判断再精确匹配体积小的规则排前面避免被后面的规则覆盖route[ROUTE_PREFIX_MATCH] { $var(called_prefix) $(rU{s.substr,0,4}); if ($var(called_prefix) 0101) { $var(gw_group) 10; } else if ($var(called_prefix) 0755) { $var(gw_group) 20; } else { $var(gw_group) 30; # 默认组 } }这些判断本身不难难的是把所有组合情况捋清楚并且保证日志里能把每一步的决策原因记录下来。我在每个分支里都会加xlog(L_INFO, prefix matched, group$var(gw_group)\\n)一旦线上路由异常查看日志能秒级定位。3. 负载均衡的三种常用实现与取舍3.1 最简单方案轮询要不要做Kamailio自带的dispatcher模块天然支持轮询和failover如果只是想让多个网关轮流接电话直接用ds_select_dst就够了。但它的短板很明显没有基于请求特征的会话保持同一对主被叫的多次呼叫可能会落在不同网关上这在某些业务场景下会出问题比如注册服务器要求同一用户始终从同一IP地址注册并通话。所以我在脚本里做负载均衡时通常不会用纯粹的顺序轮询而是要加一层“按用户维度取模”的逻辑。最简单的实现是拿被叫号码或主叫号码去掉末尾后缀后转成整数再mod网关数。这样同一号码会稳定地落到同一个网关上天然实现会话保持。代码大概是这样$var(hash_key) $(rU{s.int}); $var(gateway_index) $var(hash_key) % $var(gw_count); $var(selected_gw) $sht(gw_pool$var(gateway_index));这种方案的优点是极简、好理解缺点是不够灵活——网关数量变化时同一个号码的取模结果会全部漂移导致大量会话切换网关。因此它只适合网关数量长期固定的场景。3.2 带会话保持的一致性哈希方案当网关数量经常变化或者想尽量减少哈希漂移时就要上一致性哈希。原理不复杂把0到2^32-1的哈希环分成若干段每个网关节点在环上分布几个虚拟节点号码算出来的哈希值落到哪个节点的区间就选哪个网关。这样增删网关时受影响的只有附近区域的号码其他号码全部保持原路由。我在Kamailio脚本里的做法是引入crypto模块的MD5哈希函数把号码字符串哈希成一个32位十六进制数再转成整数映射到哈希环上。虚拟节点我在配置文件里静态生成比如每个网关生成40个虚拟节点均匀铺在环上$var(hash_value) $(crypto::md5($rU)); $var(hash_int) $(var(hash_value){s.int}); $var(ring_pos) $var(hash_int) % 4096;然后在路由选择时按ring_pos去htable: gw_ring里查对应网关。这个表我在初始化时按顺序写入节点ID从小到大排列查询时用二分查找或者直接减枝遍历实际性能测试下来单个INVITE的额外耗时在微秒级别完全不是瓶颈。3.3 权重分配的数学计算权重均衡的需求特别常见两个网关一台性能强一台性能弱希望按3:1的比例分流。这个用纯模运算做不了因为模运算只关心数量不关心比例。我的做法是“权重区间法”定义网关总权重比如网关A权重3、网关B权重1总权重为4然后生成一个0到3的随机整数或基于哈希计算的整数落到0-2区间选A落到3选B。重点是随机数或哈希源要稳定。我直接用被叫号码的哈希值避免每次呼叫随机抖动导致同号码命中不同网关又保留权重比例$var(total_weight) 4; $var(weight_rand) $var(hash_int) % $var(total_weight); if ($var(weight_rand) 3) { $var(selected_gw) gw_A; } else { $var(selected_gw) gw_B; }实际业务里我把权重值存到htable中改权重时直接更新表脚本每次读表里的值完全不用动代码。这个设计上线之后调带宽比例成了运维最轻松的事。4. 灰度发布脚本里如何优雅切流量4.1 灰度发布的需求分析和设计网关割接或者SIP中继服务商切换时最怕的就是一把梭全部流量直接切到新网关万一新网关有兼容性问题整个业务瞬间瘫痪。正确的做法是按比例灰度先放5%的流量到新网关观察日志、通话质量指标、成功率确认稳定后再逐步放大到10%、30%、50%、100%。Kamailio脚本实现灰度发布本质上是给负载均衡加一个“灰度开关”。我把它设计成三个参数gray_ratio灰度比例0到100的整数动态读取。gray_gw灰度网关地址。normal_gw正常网关地址。当请求进入路由选择时先算一个基于号码的哈希值如果哈希值落在灰度区间就转发到灰度网关否则走正常网关。关键在于“hash_mod_100”用这个值来模拟百分比区间。4.2 按号码段灰度 动态配置比例灰度的一种变体是按号码段灰度。比如只让某个测试客户或某个特定前缀的号码走新网关。这种需求在割接时特别常见因为它能精准控制风险只影响指定号码段。我的做法是在htable: gray_policy里维护一个前缀列表脚本每进来一个请求先看被叫号码是否命中前缀命中则进入灰度路由分支route[ROUTE_GRAY_CHECK] { $var(gray_prefix) $sht(gray_policy$var(group_key)); if ($var(gray_prefix) ! $null $(rU{s.starts_with,$var(gray_prefix)}) ) { $var(gray_hit) 1; } else { $var(gray_hit) 0; } }重点来了所有灰度策略参数都存在htable里运营同学通过管理接口修改表项不需要碰Kamailio配置也不需要重启。这意味着灰度发布不再是“凌晨两点的割接操作”而是随时可以在白天做的常规操作改完之后立即生效。4.3 灰度发布与负载均衡的联动实际上灰度发布不是独立于负载均衡的另一个功能它更像是负载均衡策略的前置过滤器。我的脚本执行顺序是先判灰度再判组最后负载均衡。灰度命中强制转发到灰度网关不参与正常负载。灰度未命中正常进入网关组再做哈希模运算选网关。这种联动设计的好处是灰度流量可以被完整观测。我在灰度分支里会打印更详细的日志包括主叫、被叫、灰度比例、目标网关、哈希值方便对比新网关和旧网关的业务表现。比如灰度完成后通过对比同号码段在新旧网关上的呼叫成功率差异可以快速决定是否要继续放大灰度比例。如果新网关出现异常也不需要回滚配置文件直接把灰度比例改成0或者把灰度网关地址改回原网关下一条呼叫立即恢复。5. 实操验证和问题排查5.1 环境准备我的测试环境是两台Kamailio 5.6实例一台模拟核心侧一台模拟接入侧另配了两个SIP网关其实是用rtpproxy和b2bua模拟的SIP服务端。Kamailio的安装直接用的是官方仓库的预编译包CentOS 7上两条命令搞定。关于htable模块需要在kamailio.cfg里显式加载loadmodule htable.so modparam(htable, htable, gw_groupsize8;autoexpire0;) modparam(htable, htable, gray_policysize8;autoexpire0;)这里autoexpire0很重要如果我设置成默认的几百秒表项会被自动清掉路由策略就失效了。我当时第一次配的时候就踩了这个坑排查了很久才发现是表项过期了。5.2 验证步骤和日志输出环境准备好之后我习惯用sipsak或者sipp打测试电话验证三类场景场景一同一被叫号码连拨10次检查是否都落到同一个网关。场景二改变灰度比例从0调到20观察每10通电话是否有约2通打到灰度网关。场景三手动把正常网关地址改为不存在的IP验证failover是否触发。验证时打开Kamailio的xlog日志我特意在每个决策节点都打印了关键变量。比如路由选择后会有类似下面的日志xlog(L_INFO, route selected caller$fU callee$rU group$var(gw_group) hash$var(hash_int) gw$var(selected_gw) gray$var(gray_hit)\\n);这个日志行在排障时就是神器我只要把同一通电话的所有日志拉出来就能完整还原路由决策过程。有一次线上反馈某些号码接通率变低我就是靠这些日志发现这些号码刚好命中了一个故障网关立刻在htable里把该网关权重改为0问题在5秒内解决。5.3 常见问题速查表现象可能原因解决思路所有请求都走同一个网关哈希取模分母为0或网关数读取失败检查htable中网关数是否初始化打印$var(gw_count)确认灰度比例不生效修改了DB但未同步到htable用kamcmd htable.sht_get确认内存表里的实际值会话保持失效被叫号码被改写过或取模源不一致确认路由前没有改$rU哈希只用原始$rU新增网关后大量号码漂移使用了简单取模而非一致性哈希换用一致性哈希方案或接受漂移的运维窗口日志太多刷屏严重xlog打了INFO级别生产环境把xlog级别调成L_NOTICE只保留关键节点Kamailio启动失败提示htable不存在模块加载顺序问题在loadmodule里先加载htable.so再加载依赖它的路由模块5.4 踩坑记录最后分享几个我实际踩过的坑。第一个是xlog里不要在变量两边加额外花括号。有一次我写了xlog(caller$fU)运行时总是报错查了半天才知道$fU在xlog里不能直接识别必须写成换行的转义形式或者用$(fU)。第二个坑是htable的RPC更新命令在Kamailio 5.5之后有变化老命令kamcmd htable.set变成了sht_set写自动化脚本前先确认版本对应关系。还有一个是动态路由里做DNS解析的问题。如果网关地址是域名而不是IPKamailio在t_relay的时候会做DNS查询我遇到过DNS故障导致路由不稳定的情况。解决方案是在初始化时把域名解析成IP写入htable后续脚本直接用IP转发虽然损失了DNS动态更新的能力但换来的是稳定性和路由的确定性。这个方案上线之后我的日常工作轻松了不少。以前每次割接都要提前准备变更窗口现在直接在系统里调参数看着监控曲线逐步变化心态完全不同。如果你现在正在被静态路由的维护问题困扰我建议先从网关组htable改造开始跑通后再叠加权重和灰度每走一步都能感受到收益。