ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Kamailio动态路由实战:数据库驱动配置、负载均衡与灰度发布

Kamailio动态路由实战:数据库驱动配置、负载均衡与灰度发布 做SIP通信的老哥应该都有这种体会网关一多、线路一杂最怕的就是改路由配置。传统做法是把路由规则硬编码在Kamailio配置文件里某条线路要摘掉或者加一台新网关就得改完配置reload还得挑业务低峰期操作稍不留神就是事故现场。这个项目要解决的问题就是把这些静态规则全部干掉用配合数据库的脚本实现动态路由同时把负载均衡和灰度发布也一并做进去。做完之后你会得到一个这样的效果某条中继线路质量下滑直接在数据库里把权重调低或者禁掉业务自动绕开新网关接入后想先放5%的流量观察也不再需要动Kamailio配置了。整个过程都是运行时生效不用重启服务。这个方案适合谁参考手里管着一套甚至多套Kamailio节点、日常要面对网关增删和版本迭代的运维和开发或者正准备从单体静态架构往可编排方向过渡的团队。内容偏工程实践我会把路由表结构怎么设计、脚本怎么写、灰度逻辑怎么实现以及上线时踩过的坑全部摊开讲。1. 核心思路拆解路由逻辑和业务解耦到底怎么解先聊设计思路。静态路由最大的问题在于路由决策依赖的条件一变就要动配置文件。配置文件的修改意味着reloadreload期间的新呼叫可能丢失即便Kamailio的reload机制已经很成熟但频繁变更配置本身就违背了SIP网关层应该保持稳定这个原则。所以我搞这个项目时给自己定了几条硬性标准路由规则必须存在外部存储中用的MySQL配置脚本保持干净只做逻辑不做数据。路由决策必须实时响应存量数据的变化不能靠reload。负载均衡策略要支持加权这样网关容量不一样时也能按比例分流量。灰度发布要能以极小的粒度控制——精确到某个被叫号段、某个主叫前缀都行。这几条定完之后整个框架就出来了Kamailio一侧只保留多套路由脚本模块动态路由、负载均衡、灰度判定所有规则数据全部落库。调用链路上每一个呼叫进来之后先查库拿到当前生效的路由策略然后按其规则执行转发。听起来很简单但落地时有好几个细节必须想清楚。1.1 为什么选Kamailio而不是OpenSIPS或FreeSWITCH选型这事得说清楚。做动态路由和负载均衡OpenSIPS也完全能做而且它自带的dispatcher模块更成熟。但我的场景里有个特别的约束灰度发布要支持非常灵活的规则匹配不止是简单的流量百分比还要能按主叫、被叫、时间甚至自定义头域来切割。这一点用Kamailio的脚本配合SQL查询实现起来更自由因为Kamailio的路由脚本本质上是内嵌路由语言所有的SIP头域、AVP、伪变量都能灵活参与条件判断。相比FreeSWITCH它更偏向媒体处理和B2BUA场景作为前置SIP代理做路由分发并不合适。而Kamailio无状态代理模式转发效率高作为入口网关配合后端的业务服务器或中继网关天生就是干这个活的。如果你要处理的是几千路并发的信令转发用Kamailio做路由层、FreeSWITCH或其他媒体服务做媒体层这是目前社区里很成熟的组合。所以最终敲定Kamailio最重要的原因是脚本语言灵活、转发性能强、社区生态里关于路由分发的实践资料也多。1.2 动态路由的整体调用链路设计画一下这个项目的整体调用链路用文字描述不画图SIP INVITE - Kamailio入口路由 - 鉴权/安全检查 - 查询路由规则表 - 命中灰度规则 - 是: 走灰度网关组 - 按权重选网关 - 转发 - 否: 走稳定网关组 - 按权重选网关 - 转发链路本身不复杂但注意每一步都放了一个钩子方便后续加逻辑。比如查询路由规则前先查本地缓存缓存没有命中才回源到MySQL这样可以避免每路呼叫都穿透数据库。灰度判定独立成一个路由块确保后续灰度策略要加维度时不会动到主流程代码。2. 路由表设计数据模型是一切上层逻辑的基础动态路由的根基在数据库表设计。我踩过不少这方面的坑比如把所有规则堆在一张表里导致某条规则字段太多、可读性差后面维护想哭。这次的设计吸取了教训总共拆成三张核心表外加一张版本号表。2.1 路由规则表和网关表先看路由规则表的结构CREATE TABLE route_rules ( id int(11) NOT NULL AUTO_INCREMENT, rule_name varchar(64) NOT NULL COMMENT 规则名称便于人读, caller_pattern varchar(32) DEFAULT NULL COMMENT 主叫号码前缀匹配NULL表示全部, callee_pattern varchar(32) NOT NULL COMMENT 被叫号码前缀匹配, time_range varchar(32) DEFAULT NULL COMMENT 生效时间范围例如 09:00-18:00, priority int(11) NOT NULL DEFAULT 0 COMMENT 规则优先级数字小的先匹配, weight int(11) NOT NULL DEFAULT 100 COMMENT 流量权重用于负载均衡按比例分发, gateway_group varchar(32) NOT NULL COMMENT 目标网关组名, enabled tinyint(1) NOT NULL DEFAULT 1 COMMENT 是否启用, remark varchar(255) DEFAULT NULL, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_callee (callee_pattern), KEY idx_enabled (enabled) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT动态路由规则表;网关表CREATE TABLE gateway_info ( id int(11) NOT NULL AUTO_INCREMENT, gateway_name varchar(64) NOT NULL, gateway_ip varchar(64) NOT NULL COMMENT 网关IP或域名, port int(11) NOT NULL DEFAULT 5060, weight int(11) NOT NULL DEFAULT 1 COMMENT 网关权重越大分到的流量越多, max_call int(11) NOT NULL DEFAULT 100 COMMENT 最大并发呼叫数0为不限制, current_call int(11) NOT NULL DEFAULT 0 COMMENT 当前并发呼叫数, enabled tinyint(1) NOT NULL DEFAULT 1, last_check_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT网关信息表;这两张表的设计有几个关键决策规则表和网关表分开用gateway_group字段关联。这样一组网关比如华东中继组可以同时被多条规则引用修改网关参数IP、权重时不需要改规则。时间范围字段支持业务上白天走A线路晚上走B线路这种典型需求。刚开始我觉得这需求偏门结果线上很快就有人提了。优先级字段配合enabled实现规则的热替换。想修改某条规则时先把新规则插入处理完再把旧规则置为enabled0永远不要直接修改生产环境的规则。2.2 网关健康状态表和版本控制表负载均衡要做得可靠光有静态权重不行网关本身可能出故障。所以我又加了一张网关状态表由脚本周期性检测网关的存活状态并且把检测结果写回数据库CREATE TABLE gateway_status ( gateway_id int(11) NOT NULL, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 1存活0异常, latency_ms int(11) NOT NULL DEFAULT 0 COMMENT 最近一次探测延迟, last_success_time datetime DEFAULT NULL COMMENT 最近一次成功探测时间, last_fail_time datetime DEFAULT NULL COMMENT 最近一次失败时间, fail_count int(11) NOT NULL DEFAULT 0 COMMENT 连续失败次数, PRIMARY KEY (gateway_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里要注意Kamailio自身的dispatcher模块其实自带了OPTIONS探测功能。我在项目中直接用dispatcher的探测能力但是通过脚本去手动管理网关的启用状态——也就是不让dispatcher自己决定摘除网关而是根据探测结果决定是否在脚本层面路由到该网关。这样做的原因是dispatcher的自动摘除机制在某些场景下反应过激一次瞬间的网络抖动就能把网关摘掉恢复又要等待窗口期反而不如自己控制来得稳。版本控制表是动态路由的金钥匙CREATE TABLE route_version ( table_name varchar(64) NOT NULL, version bigint(20) NOT NULL DEFAULT 0, PRIMARY KEY (table_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;每张路由相关的表都在这里维护一个版本号。Kamailio脚本定期比如每5秒查一次版本号如果变了就重新加载相应表的数据到本地内存哈希表。这个设计搞定了规则变更与Kamailio脚本之间的同步不需要每条呼叫都穿透查询MySQL性能和安全得到兼顾。后文会详细说实现。3. 负载均衡核心逻辑从数学原理到Kamailio脚本落地负载均衡的策略选择是我在这个项目里花时间比较多的地方。Kamailio内置的dispatcher模块支持轮询、哈希、随机等多种算法但我的需求不只是把请求发到某个网关而是要支持不同网关按容量分配不同比例的流量同时灰度发布的网关组要能以很小的比例导入流量。前者要求加权后者要求精度可控。3.1 加权轮询的数学原理与参数计算加权轮询算法不复杂现成的代码到处都有。但关键问题是Kamailio脚本里没有现成的加权轮询容器必须自己维护状态。我用的方案其实是一个平滑加权轮询算法Smooth Weighted Round-Robin这个算法在Nginx里也用到了好处是避免短时间窗口内流量集中打到一个高权重节点上。算法的伪代码是这样的初始化时每个节点有 weight固定权重和 currentWeight动态调整值初始值都为 0。 每次请求进入 1. currentWeight[i] weight[i]所有节点都加上自己的固定权重 2. 选择 currentWeight 最大的节点 3. 被选中的节点 currentWeight[i] - totalWeight所有节点权重之和举个例子假设有三个网关权重分别是 3、2、1总权重是6。按这个算法跑10轮选中的顺序会是A, A, B, A, B, C, A, B, A, B。看起来是不是分布很均匀A被选中5次、B 3次、C 2次比例接近3:2:1而且不会出现连续大量请求全部打到A的情况。Kamailio脚本里怎么实现这个状态维护用htable模块。在$rc路由上下文里维护一个哈希表为每个网关组保存对应的currentWeight# 加载 htable 模块 loadmodule htable.so modparam(htable, htable, gw_statesize8;autoexpire3600;) # 平滑加权轮询选择网关 route[SMOOTH_WRR_SELECT] { $var(total_weight) 0; $var(best_gw) ; $var(best_weight) -1; # 遍历当前网关组内的所有网关计算 currentWeight # 这里通过 avp 列表传入候选网关 foreach ($avp(gw_list)) { $var(gw_id) $avp(gw_list); # 从 htable 拿当前权重初始为0 $var(cur) $sht(gw_state$var(gw_id)); if ($var(cur) $null) { $var(cur) 0; } # 加上固定权重 $var(fixed) $sht(gw_fixed_weight$var(gw_id)); $var(new_cur) $var(cur) $var(fixed); $sht(gw_state$var(gw_id)) $var(new_cur); # 累加总权重 $var(total_weight) $var(total_weight) $var(fixed); # 选出最大 currentWeight if ($var(new_cur) $var(best_weight)) { $var(best_weight) $var(new_cur); $var(best_gw) $var(gw_id); } } # 给选中的网关减去总权重 if ($var(best_gw) ! ) { $var(best_cur) $sht(gw_state$var(best_gw)); $sht(gw_state$var(best_gw)) $var(best_cur) - $var(total_weight); # 设置路由目标为选中的网关 $ru sip: $var(best_gw) : $var(best_port) ;transport $var(best_transport); $du $ru; route(RELAY); exit; } # 没有可用网关 send_reply(503, Service Unavailable); exit; }这段代码里有几个细节是反复调过的状态存储必须用htable而不是AVP或XAVP。AVP是每个事务私有的不能跨请求共享状态htable是全局的所有进程共享。但要注意Kamailio是多进程模型不同进程之间访问htable会有少量性能开销不过相对于数据库查询来说这个开销完全可以忽略。网关组切换状态时currentWeight的连续性。比如规则从组A切换到组Bhtable里的状态还残留着这没问题因为gw_state表的key是网关ID它会自然衰减。但为了性能可以给htable设置autoexpire让不活跃的条目自动过期避免状态表无限增长。不支持权重为0的节点。权重为0意味着不参与分发那就不应该出现在候选网关列表里。脚本里要先把enabled0或weight0的网关过滤掉防止除零错误。3.2 网关健康检查与摘除恢复机制光有均衡算法还不够网关故障时的容错才是真正考验系统的地方。我的实现里健康检查走了两条线第一条线Kamailio dispatcher模块的OPTIONS探测loadmodule dispatcher.so modparam(dispatcher, db_url, mysql://kamailio:passwordlocalhost/kamailio) modparam(dispatcher, flags, 1) modparam(dispatcher, xavp_dst, dst) modparam(dispatcher, xavp_ctx, ctx) modparam(dispatcher, probe_mode, 1) modparam(dispatcher, socket, udp:0.0.0.0:5060)注意probe_mode参数设置为1表示自动探测。启用了dispatcher的探测功能后它会周期性地对每个SIP网关发送OPTIONS请求并维护一个动态的可用状态。脚本可以主动查询这个状态if (ds_is_in_list($avp(gw_id), 1)) { # 网关在可用状态 } else { # 网关不可用跳过 }这一层主要做快速失败检测秒级得出网关是否活的。第二条线基于连续失败次数的平滑退出dispatcher的探测是网络层存活检测业务层的问题例如网关能通但媒体转发异常它看不见。所以我在脚本里加了一个计数器每次转发失败收到408、500、503等响应就把该网关的连续失败次数fail_count加1写入gateway_status表。当fail_count超过阈值比如10次脚本主动把网关从候选列表里摘除。恢复机制也很简单脚本每30秒对摘除的网关做一次测试呼叫或者OPTIONS探测连续3次成功就把网关重新加回来。这个双轨机制实际用下来很可靠。网络抖动时dispatcher先扛住业务故障时失败计数兜底不会出现某条中继出事但路由器还在往上面灌流量的情况。4. 灰度发布实现精准控制的那5%流量怎么做到灰度发布是这个项目里最有意思、也是踩坑最多的部分。核心目标一句话让指定比例的呼叫流量走向新网关同时保证其他流量完全不受影响。4.1 三种灰度策略比例灰度、条件灰度与时间灰度我在系统里实现了三种灰度模式覆盖了绝大多数上线场景。比例灰度按号码哈希百分比抽样。这是最常用的比如新接入一条线路先放5%的流量。用于计算哈希的输入很关键我用了$ciCall-ID字段——同一通电话的后续请求比如re-INVITE会走同一条路避免同一通电话的不同信令被发到不同网关。计算方式是# 根据 Call-ID 做哈希取模100得到0-99的整数 $var(hash) 0; # 用 Kamailio 内置的哈希函数计算 if (zlib_crc32($ci) ! NULL) { $var(hash) zlib_crc32($ci) % 100; } # 判断是否落在灰度区间例如灰度比例是5%则 hash 5 走灰度 if ($var(hash) $var(gray_percent)) { route(GRAY_GATEWAY); } else { route(STABLE_GATEWAY); }这里有个容易踩的坑不要直接对$rU被叫号码做哈希。因为同一个主叫发起多路呼叫时被叫不同会导致哈希结果离散灰度判断不连贯。用Call-ID是最稳的因为一个通话的Call-ID是固定的。条件灰度按主叫、被叫前缀或者自定义头域匹配。典型场景是让某个测试客户的号码先走新网关。这个实现简单规则表里已经有caller_pattern和callee_pattern字段照着匹配就行# 检查是否命中条件灰度规则 sql_xquery(cb, SELECT gateway_group FROM gray_rules WHERE caller_pattern$fU OR callee_pattern$rU LIMIT 1, rb); if ($dbrbrows 0) { $var(gray_group) $dbrbgateway_group[0]; route(TO_GRAY_GROUP); exit; }时间灰度按时间段切割。前面路由规则表里有个time_range字段就是为这个准备的。比如凌晨2点到4点全量走新网关观察稳定后再放大比例。这个场景适合分批放量但前提是新网关的业务逻辑能扛住凌晨的压力。4.2 灰度发布状态机从5%到100%的完整流程灰度的过程管理很重要不能拍脑袋直接改数据库权重。我设计了一个简单的状态机管理灰度流程阶段灰度比例观察周期验证项灰度启动1%2小时呼叫成功率、时延、媒体质量小流量验证5%24小时稳定性、告警半量验证50%48小时对比新旧网关指标全量发布100%持续确认后摘除旧网关这个流程跑下来最关键的一点是每个阶段都必须有明确的退出条件。一旦连续多个监控指标超标比如呼叫失败率超过2%、平均时延上升超过100ms要能一键回滚——把灰度网关组的权重归零流量切回稳定网关组。回滚的动作不需要改任何配置脚本只需要在数据库里把灰度规则的enabled改成0就完成了这是动态路由方案比静态配置方案好用的最大体现。实现上灰度状态机完全靠route_rules表里的weight和enabled字段驱动灰度启动插入一条灰度规则weight1同时把稳定网关组的group里对应新网关的权重调低。灰度提升更新灰度规则的weight值5、20、50、100脚本通过版本号感知变化并重载。回滚把灰度规则的enabled0强制所有流量走稳定组。4.3 灰度发布中对记录和监控的特殊处理灰度发布阶段最容易被忽略的是日志和监控。如果新网关出了问题没有全链路日志光靠SIP的响应码排查会非常痛苦。我做了一套灰度专属的日志方案# 灰度流量打标记后续日志全部携带 if ($var(hash) $var(gray_percent)) { xlog(L_INFO, [GRAY] Call-ID: $ci, from: $fU, to: $rU, route to gray gateway\n); append_hf(X-Gray-Group: $var(gray_group)\r\n); # 记录详细日志 sql_xquery(cb, INSERT INTO call_log (call_id, caller, callee, route_type, gateway) VALUES ($ci, $fU, $rU, gray, $var(best_gw)), rb); }所有灰度流量都会带上一个X-Gray-Group头域后端业务服务器看到这个头就知道这通电话走了灰度链路可以有针对性地做联调验证。同时日志会记录到专门的call_log表后续对比新旧网关的指标时直接从这个表里拉数据就能分析。监控指标方面需要看几项呼叫建立成功率SIP 200 OK数量/INVITE数量、平均呼叫时长、呼叫失败的原因分布特别是网络类原因如483/408/486、时延百分比P50/P95。我是用Prometheus的kamailio_exporter配合自定义脚本从call_log表统计数据再灌到Grafana做看板这样灰度到每个阶段看得出数据是好是坏。这块想强调一点灰度不只是把流量拨一点过去更关键的是能否以一个非常细的粒度精准分配流量以及出了问题能否快速恢复。这两个坎跨过去灰度才有实际价值。5. 脚本实现细节从数据库查询到Kamailio代码的无缝衔接前面讲了原理和设计现在到纯实操环节。这一节我把项目里核心的Kamailio配置脚本拆解出来直接给能用的版本。5.1 Kamailio环境准备和模块清单基础环境要求Kamailio 5.4 我用的是5.6MySQL 5.7 / MariaDB 10.3需要安装的模块db_mysql、sqlops、htable、dispatcher、sl、tm、rr、pv、xlog、rtimerDebian系安装apt-get install kamailio kamailio-mysql-modules kamailio-extra-modules kamailio-json-modules启动前确保MySQL有对应的库和表。Kamailio主配置文件里开头要把承载动态路由的核心模块加载好# ----------- 全局配置 ----------- debug3 log_stderrorno log_facilityLOG_LOCAL0 forkyes children8 # ----------- 模块路径 ----------- mpath/usr/lib/x86_64-linux-gnu/kamailio/modules/ # ----------- 加载模块 ----------- loadmodule tm.so loadmodule sl.so loadmodule rr.so loadmodule pv.so loadmodule maxfwd.so loadmodule textops.so loadmodule xlog.so loadmodule siputils.so loadmodule db_mysql.so loadmodule sqlops.so loadmodule htable.so loadmodule dispatcher.so loadmodule rtimer.so # ----------- 模块参数 ----------- modparam(db_mysql, db_url, mysql://kamailio:passwordlocalhost/kamailio) modparam(sqlops, sqlcon, cbmysql://kamailio:passwordlocalhost/kamailio) modparam(htable, htable, gw_statesize8;autoexpire3600;) modparam(htable, htable, gw_fixed_weightsize8;autoexpire3600;) modparam(dispatcher, db_url, mysql://kamailio:passwordlocalhost/kamailio) modparam(dispatcher, flags, 1) modparam(dispatcher, xavp_dst, dst) modparam(dispatcher, xavp_ctx, ctx) modparam(rtimer, timer, routeRT_CHECK_DB;间隔10s;)这里解释几个关键点sqlops的sqlcon是我们自定义的数据库连接名cb后面sql_xquery(cb, ...)都用这个。htable建了两张表gw_state保存平滑加权轮询的当前状态gw_fixed_weight保存网关的静态权重。为什么分开因为gw_state的条目是动态变化的每次请求都会改而gw_fixed_weight是相对静态的只在规则变更时写一次。分开两个哈希表可以避免动态写入影响静态读取的并发性能。rtimer模块每10秒触发一次RT_CHECK_DB路由做版本号检查和规则热加载。5.2 数据库版本号检查与规则热加载这是整套动态路由最精妙的部分。Kamailio的路由脚本本身是无状态的但它可以通过rtimer定时器周期性地执行一些操作。我利用这个机制让脚本每10秒检测一次数据库中的route_version表如果版本号变了就重新加载路由规则到本地。route[RT_CHECK_DB] { # 查当前数据库版本号 sql_xquery(cb, SELECT version FROM route_version WHERE table_nameroute_rules, rb); if ($dbrbrows 0) { $var(db_ver) $dbrbversion[0]; # 和本地缓存的版本号比较 if ($sht(route_cacheversion) ! $var(db_ver)) { xlog(L_INFO, Route rules version changed from $sht(route_cacheversion) to $var(db_ver), reloading...\n); # 重新加载路由规则到 htables sql_xquery(cb, SELECT id, caller_pattern, callee_pattern, gateway_group, weight, priority, enabled FROM route_rules WHERE enabled1, rb); if ($dbrbrows 0) { $sht(route_cacheversion) $var(db_ver); # 清空旧的规则缓存 htable_clear(route_rules_cache); $var(i) 0; while ($var(i) $dbrbrows) { $var(rule_id) $dbrbid[$var(i)]; $var(rule_callee) $dbrbcallee_pattern[$var(i)]; $var(rule_group) $dbrbgateway_group[$var(i)]; $var(rule_weight) $dbrbweight[$var(i)]; # 写入哈希表 $sht(route_rules_cache$var(rule_callee)) $var(rule_group) , $var(rule_weight); $var(i) $var(i) 1; } xlog(L_INFO, Route rules reloaded, total $dbrbrows rules\n); } } } }这段脚本干的事情很简单定时去问数据库版本有没有变变了就重新拉全量规则、清空本地缓存、重新写入。实际运行中10秒的检测间隔已经足够因为配置变更本身不是高频操作。如果你要更快的响应可以把定时器调到5秒但注意别太频繁否则数据库查询压力会变大。5.3 呼叫入口路由脚本灰度判定与网关选择的完整流程每次呼叫进来路由脚本按下面的流程走。我把核心逻辑放在route[MAIN]里封装成几个子路由块保证可读性和可维护性。route[MAIN] { # 初始化前置检查鉴权、非法请求拦截等 route(AUTH_CHECK); # 判断是否命中灰度规则 route(GRAY_CHECK); # 未命中灰度走正式路由 route(DYNAMIC_ROUTE); exit; } # 灰度规则检查 route[GRAY_CHECK] { # 判断是不是灰度流量条件包括 Call-ID 哈希、主叫或被叫前缀 # 先从本地哈希表检查缓存规则避免穿透数据库 $var(hash) zlib_crc32($ci) % 100; $var(gray_percent) 5; # 默认灰度比例5%也可以从配置表读取 if ($var(hash) $var(gray_percent)) { # 走灰度网关组 xlog(L_INFO, [GRAY] Call $ci from $fU to $rU, hash$var(hash), route to gray\n); route(GRAY_GATEWAY_ROUTE); exit; } # 检查是否有条件灰度的规则比如特定主叫号码 sql_xquery(cb, SELECT gateway_group FROM gray_conditions WHERE caller_pattern$fU OR callee_pattern$rU LIMIT 1, rb); if ($dbrbrows 0) { $var(gray_group) $dbrbgateway_group[0]; route(SELECT_GATEWAY); exit; } } # 动态正式路由 route[DYNAMIC_ROUTE] { # 从本地缓存里查询被叫号码对应的网关组 $var(callee_prefix) $rU; # 从哈希表中获取网关组 $avp(gw_list) $null; $avp(rule_weight) $null; # 查询当前被叫号码对应的网关组先查全匹配再逐渐放宽前缀长度 $var(found) 0; $var(prefix_len) strlen($var(callee_prefix)); while ($var(prefix_len) 0) { $var(prefix) substr($var(callee_prefix), 0, $var(prefix_len)); $var(rule) $sht(route_rules_cache$var(prefix)); if ($var(rule) ! $null) { $var(group) $(var(rule){s.select,0,;}); $var(weight) $(var(rule){s.select,1,;}); # 获取该组的网关列表 sql_xquery(cb, SELECT g.id, g.gateway_ip, g.port, g.weight FROM gateway_info g JOIN gateway_group_mapping gm ON g.idgm.gateway_id WHERE gm.group_name$var(group) AND g.enabled1 ORDER BY g.id, rb); if ($dbrbrows 0) { $var(i) 0; while ($var(i) $dbrbrows) { $var(gw_id) $dbrbid[$var(i)]; $var(gw_ip) $dbrbgateway_ip[$var(i)]; $var(gw_port) $dbrbport[$var(i)]; $var(gw_weight) $dbrbweight[$var(i)]; # 存储到AVP列表 $avp(gw_list[$var(i)]) $var(gw_id); $avp(gw_ip[$var(i)]) $var(gw_ip) : $var(gw_port); $avp(gw_weight[$var(i)]) $var(gw_weight); $var(i) $var(i) 1; } $var(found) 1; break; } } $var(prefix_len) $var(prefix_len) - 1; } if (!$var(found)) { xlog(L_ERR, No route rule found for callee $rU\n); send_reply(404, Not Found); exit; } # 调用负载均衡选择网关 route(SELECT_GATEWAY); exit; } # 从候选网关列表中选择具体网关 route[SELECT_GATEWAY] { # 使用平滑加权轮询选择网关 # 维护每个网关的 current_weight 在 htable $var(total_weight) 0; $var(best_gw_index) -1; $var(best_gw_id) ; $var(best_weight) -1; # 遍历候选网关 $var(i) 0; while ($avp(gw_list[$var(i)]) ! $null) { $var(gw_id) $avp(gw_list[$var(i)]); $var(gw_weight) $avp(gw_weight[$var(i)]); # 如果网关权重为0或已禁用跳过 if ($var(gw_weight) 0) { $var(i) $var(i) 1; continue; } # 从 htable 取当前权重 $var(cur) $sht(gw_state$var(gw_id)); if ($var(cur) $null) { $var(cur) 0; } # 加上固定权重 $var(new_cur) $var(cur) $var(gw_weight); $sht(gw_state$var(gw_id)) $var(new_cur); $var(total_weight) $var(total_weight) $var(gw_weight); if ($var(new_cur) $var(best_weight)) { $var(best_weight) $var(new_cur); $var(best_gw_index) $var(i); $var(best_gw_id) $var(gw_id); } $var(i) $var(i) 1; } if ($var(best_gw_index) 0 || $var(best_gw_id) ) { # 没有可用网关 send_reply(503, Service Unavailable); exit; } # 给选中的网关减去总权重 $var(best_cur) $sht(gw_state$var(best_gw_id)); $sht(gw_state$var(best_gw_id)) $var(best_cur) - $var(total_weight); # 设置目标为选中的网关 $var(target) $avp(gw_ip[$var(best_gw_index)]); $du sip: $var(target); xlog(L_INFO, Route call $ci from $fU to $rU via gateway $var(best_gw_id) at $var(target), current weight$var(best_cur), new weight$var(best_cur)-$var(total_weight)\n); # 转发 route(RELAY); exit; } route[RELAY] { if (!t_relay()) { send_reply(500, Internal Server Error); } exit; }这段脚本已经把核心逻辑都覆盖了。实际部署时注意几点AVP列表的清理Kamailio的AVP在同一个事务内是共享的如果处理一个请求后没有清空$avp(gw_list)下一个请求进来时旧数据还会残留导致候选网关列表混乱。所以每次路由前要先执行$avp(gw_list) $null;类似操作确保清洁状态。前缀匹配从长到短从完整的被叫号码开始匹配如果没命中规则就往前截取一位直到找到匹配的前缀。这个优化避免了对每条规则做正则匹配性能更好。$du和$ru的差异$du是发送目的地址next hop$ru是Request-URI。做SIP代理时一般不改$ru直接设置$du让包发往指定网关就行后端网关会根据自己的路由表做进一步分发。5.4 与dispatcher模块的结合使用技巧虽然我的脚本自己实现了加权轮询和健康检查但dispatcher模块仍然很有用。我实际用它的方式是用dispatcher维护SIP网关的存活状态自动发送OPTIONS探测包。脚本在选网关前通过ds_is_in_list检查网关是否可用。如果某个网关在dispatcher里已经标记为不可用脚本直接把它的权重设为0不参与分发。这样一来等于是让dispatcher干笨重但高频率的探测工作让自己的脚本做灵活的决策逻辑两者相互配合。关键配置如下# dispatcher列表从数据库加载 modparam(dispatcher, list_file, /etc/kamailio/dispatcher.list) modparam(dispatcher, db_url, mysql://kamailio:passwordlocalhost/kamailio) modparam(dispatcher, flags, 1) # 1: 开启自动探测 modparam(dispatcher, probe_mode, 1) modparam(dispatcher, xavp_dst, dst) modparam(dispatcher, xavp_ctx, ctx) # 在路由脚本里检查网关状态 route[CHECK_GW_STATUS] { if (!ds_is_in_list($var(gw_id), 1)) { xlog(L_INFO, Gateway $var(gw_id) is unavailable, skipping\n); # 对应网关的权重置为0 $sht(gw_fixed_weight$var(gw_id)) 0; return -1; } # 可用的话恢复权重 $sht(gw_fixed_weight$var(gw_id)) $var(original_weight); return 1; }这里有个细节需要特别提醒ds_is_in_list检查的是dispatcher维护的动态状态它的更新频率通常由ds_ping_interval决定我设的是10秒。所以从网关真正挂掉到脚本感知最长有10秒的延迟。如果对时延要求高可以把探测间隔调小到3秒但同时OPTIONS探测的频率也会上升对网关的压力要自己把握。6. 常见问题与排查心得把能踩的坑都先替你们踩一遍这个项目从开发到上线前后迭代了三个版本中间碰到了不少奇奇怪怪的问题。这里整理成清单希望能帮对口的朋友少走一些弯路。6.1 数据库查询性能和连接池问题现象刚开始上线时每路呼叫都实时查询MySQL呼叫量一上来数据库连接直接被打满大量SIP请求超时。原因分析Kamailio的sqlops模块在每次查询时都会从连接池拿连接如果连接数不够就会阻塞等待。默认的sqlops连接池大小是1等于所有子进程共用一个DB连接并发一高必然排队。解决办法首先把sqlops连接池调大我最终设在10modparam(sqlops, sqlcon, cbmysql://kamailio:passwordlocalhost/kamailio?max_db_connections10max_db_queries2000)其次也是最关键的把高频查询迁移到本地缓存。最终线上稳定版本的方案是规则数据全部缓存进htable只有版本号检查每10秒一次和日志写入异步队列才访问数据库。这个改动让数据库的QPS从每路呼叫1次直降到几乎为0再也不担心连接瓶颈了。6.2 htable状态数据在后端重启后丢失现象Kamailio进程一重启htable里的gw_state全部清空平滑加权轮询的状态丢失。这个其实不算致命因为状态丢失后只是重新从零开始累积不影响可用性。但注意一个边界情况如果某个网关刚被摘除重启后它的权重也会被重置回原始值会重新参与流量分发。解决办法把网关的禁用状态也持久化到数据库。脚本每次加载候选网关列表时先查gateway_info表里的enabled字段已经置为0的网关直接从源头上过滤掉。htable状态丢失无所谓数据库里的业务状态不能丢。6.3 灰度规则误命中导致大面积路由错误现象灰度规则里写的caller_pattern匹配条件太宽把正常用户的流量也切到了灰度网关导致某天下午部分正常用户反馈通话异常。原因条件灰度匹配时用前缀匹配很容易误伤。比如写了个caller_pattern138那所有138开头的手机号都会命中而这本来只是想给某个测试号段用的。解决措施在灰度条件表里增加一个match_type字段区分精确匹配、前缀匹配、正则匹配、CIDR匹配同时对条件的变更增加审计日志防止误操作。另外灰度规则的enabled默认值设置为0关闭状态只有显式打开才会生效。6.4 灰度流量监控数据滞后现象灰度发布后想观察实时指标结果Grafana看板要延迟好几分钟才能看到数据等发现异常时流量可能都已经放大到50%了。解决措施把call_log表的写入从同步改为异步。具体做法是脚本里先用htable攒数据通过rtimer每5秒批量把日志写入MySQL这样既不影响呼叫性能又能把监控数据的时延控制在10秒以内。另外把关键指标呼叫成功率、时延、失败分布用Prometheus直接统计不要每次都查MySQL监控响应速度能快到秒级。6.5 常见问题速查表现象可能原因解决方法呼叫全失败日志里没有路由记录数据库连接失败sqlops查询异常检查MySQL连接确认sqlcon配置正确部分呼叫超时htable查询过慢或脚本处理时间长检查htable size是否足够考虑增大或优化脚本逻辑网关权重修改后未生效版本号未更新脚本还在用旧缓存更新route_version表版本号灰度流量比例不对哈希算法非均匀分布检查zlib_crc32在不同Call-ID下的分布情况网关不可用但还在被选dispatcher探测间隔太长或未开启调小ds_ping_interval确认flags和probe_mode配置日志量太大拖慢性能每路呼叫都写xlog和call_log关闭调试日志日志批量异步写入7. 上线流程与效果复盘最后聊聊这套方案上线时的一些经验以及最终的效果。7.1 上线三步走影子模式、导流模式、放量模式我强烈建议不要一上来就直接切生产。上线分三步第一步影子模式Shadow Mode。这个阶段脚本正常工作但只做路由决策和日志记录实际流量还是走原来的静态配置。这样能验证路由逻辑是否正确、数据库读取是否正常、日志是否完整同时不影响线上业务。影子模式实现特别简单在脚本最后不要调用t_relay()而是直接send_reply(486, Shadow mode, not actually forwarding)同时在日志里记录将要发往的网关。第二步导流模式。影子模式跑1-2天确认无异常后把一小部分真实流量比如1%切到新路由脚本上。观察这1%的流量是否和之前静态配置下的表现一致。第三步放量模式。逐步把流量放大到10%、50%、100%。每一步之间至少观察24小时。至此动态路由正式接替静态配置。7.2 踩过的坑影子模式发现路由规则有漏洞影子模式帮我们抓到了一个非常重要的bug某条被叫前缀本来应该匹配到国内中继组但因为数据库里的规则写成了callee_pattern01只匹配两位前缀导致所有01开头的号码全部被路由到国际测试组。如果直接在生产环境下放量这会是一个严重的事故。影子模式让问题在无感知的情况下暴露出来这也是我强烈建议上线前必须跑一遍影子模式的原因。7.3 效果复盘和性能数据这套系统上线后运维体验和之前完全不是一个档次网关割接之前要协调凌晨割接窗口改配置再reload耗时2小时。现在直接在数据库里改权重或者enabled5秒内自动生效全程无需重启。灰度发布新网关上线走标准流程1%-5%-50%-100%全程可观测、可回滚。灰度周期从原来的一两天搞完延长到一周稳步推进但事故率从之前的每次上线提心吊胆降到基本为零。容量扩展业务高峰期前给新网关加大权重几秒内流量自动分流相当于呼叫层面的弹性伸缩。具体性能数据在8核16G的VM上单Kamailio实例稳定支撑并发呼叫500路以上这个数字受限于业务逻辑的复杂度纯无状态转发的话可以更高脚本路由决策平均耗时约0.5ms其中平滑加权轮询选路计算约0.1ms加上数据库版本号检查的开销后整体性能依然充裕。8. 实测后的体会和建议我做完这个项目后最大的感触是Kamailio脚本的能力边界远远超出大部分人的使用场景。大多数人拿它当固定SIP代理用配几条静态规则完事但这其实是把一匹能跑障碍赛的马当成驴子骑了。如果你也想在现有的Kamailio环境里上动态路由我提几个务实建议先做监控再改路由。没有全链路日志和指标监控动态路由就是个盲人骑瞎马。第一件事不是写脚本而是把呼叫日志、网关状态、路由决策记录全部打通。我用的call_log表就是最终的兜底排查依据每一次呼叫走了哪条路、为什么走这条路全都有据可查。规则的缓存层必须做好。动态路由不等于每次呼叫都要查数据库那是静态路由都扛不住的做法。最理想的架构是数据库存全量规则Kamailio本地哈希表存热规则版本号驱动增量更新。三层架构缺一不可缺了任何一层都会在流量上来时暴露问题。灰度发布从工具变成流程。技术实现只是第一步真正难的是把灰度从一次性的上线操作变成可持续复用的上线流程。我这里用状态机管理灰度阶段配上每个阶段明确的验证项和退出条件后面任何一个新网关上线都可以套用这套流程这比单纯写几个脚本有意义得多。最后分享一个小细节。在调试平滑加权轮询脚本时发现初始状态下所有网关的current_weight都是0第一路呼叫进来后所有网关都加上各自权重必然权重最大的网关被选中——也就是说系统上线的第一通电话一定会打到权重最高的那个网关上。这个特性在特定场景下可能是好事比如新网关初始权重设很高第一通灰度电话就打到新网关上方便验证但也有可能不是你想看到的。理解算法下的每个状态变化才能更好地控制生产环境的行为。这大概就是我这个项目最想说的东西动态路由和灰度发布没有多高深核心是数据驱动的决策机制配合上合理的缓存和可靠的健康检查最终让路由配置从改文件变成改数据库从重启服务生效变成秒级自动切换。这套模式跑顺之后无论是业务扩容、网关割接还是新版本上线都能变得从容很多。
返回列表