EIGRP的Stuck-In-Active(SIA)问题一直是网络运维中的经典难题。当一条路由进入Active状态后,路由器会向所有邻居发送查询报文,如果在指定时间内没有收到全部应答,这条路由就会卡在Active状态,最终导致邻居关系被重置,引发大面积路由震荡。与其等问题发生后再排查,不如提前用脚本对查询等待时间进行监控,设定合理的检测阈值,在路由走向SIA之前就发出预警。本文将用Ruby实现一个简单实用的SIA分裂检测工具,重点讲清楚阈值如何配置、如何调整。

一、理解SIA与检测阈值的关系
要设计检测阈值,先得明白EIGRP内部的处理机制。当主路由失效时,路由器会把该路由置为Active状态,并向邻居发送Query报文,同时启动一个定时器。默认情况下,这个定时器大约是3分钟(180秒左右)。如果定时器到期时仍有邻居没有回复Reply,路由器会认为该邻居"卡住"了,直接重置与其的邻接关系,这就是SIA的典型表现。
检测的核心思路其实很简单:我们不需要真正去解析EIGRP报文,只需要周期性采集设备上处于Active状态的路由信息(比如通过SNMP或定期执行show ip eigrp topology active命令并解析输出),记录每条活跃路由的等待时长,一旦某条路由的持续时间超过我们设定的阈值,就触发告警。阈值的意义在于它必须小于设备自身的SIA超时时间,否则告警就失去了提前量。
举个例子,如果设备默认180秒判定SIA,那么我们的检测阈值设在100到120秒之间比较合理,既留出了人工介入或自动处置的时间窗口,又不会因为阈值过低而产生大量误报。这个"提前量"的概念是整个检测方案的基础。
二、用Ruby实现阈值检测的核心逻辑
接下来写代码。整体结构分为三部分:状态采集器负责从数据源读取当前Active路由及其持续时间;阈值判断器负责比较和告警;配置模块负责管理阈值参数。先看核心的检测类实现:
class SiaDetector
attr_reader :threshold, :routes
def initialize(threshold: 120, check_interval: 15)
@threshold = threshold # 检测阈值,单位秒
@check_interval = check_interval # 采集间隔,单位秒
@routes = {} # 记录每条活跃路由的首次发现时间
@alerted = {} # 防止重复告警
end
# 输入当前采集到的活跃路由前缀列表
def update(active_prefixes, now = Time.now)
expired = []
active_prefixes.each do |prefix|
@routes[prefix] ||= now
elapsed = now - @routes[prefix]
if elapsed >= @threshold && !@alerted[prefix]
@alerted[prefix] = true
expired << { prefix: prefix, elapsed: elapsed.round }
end
end
# 已经离开Active状态的路由要清理,避免内存泄漏
gone = @routes.keys - active_prefixes
gone.each { |p| @routes.delete(p); @alerted.delete(p) }
expired
end
def run(collector)
loop do
active = collector.fetch_active_prefixes
alerts = update(active)
alerts.each do |a|
puts "[ALERT] 路由 #{a[:prefix]} 已持续Active #{a[:elapsed]}秒,接近SIA!"
end
sleep @check_interval
end
end
end这段代码有几个值得注意的细节。第一,@routes记录的是脚本第一次观察到该路由进入Active的时间,而不是设备真实的进入时间,所以实际等待时长可能被低估。为了弥补这个误差,可以把阈值再调低一些,或者改进采集器让它在轮询时直接解析命令输出中的剩余时间字段。第二,@alerted这个标记位很重要,没有它的话,同一件事由会每轮循环都告警一次,产生告警风暴。第三,清理逻辑不能省略,长期运行的脚本如果不及时删除已恢复的路由,内存会持续增长。
采集器部分以解析命令输出为例,用正则提取前缀即可:
require 'net/ssh'
class TopologyCollector
def initialize(host, user, password)
@host, @user, @password = host, user, password
end
def fetch_active_prefixes
output = nil
Net::SSH.start(@host, @user, password: @password) do |ssh|
output = ssh.exec!('show ip eigrp topology active')
end
# 典型输出行形如: "10.1.2.0/24, Serial0/0, ...
output.scan(/(\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}),/).flatten
rescue StandardError => e
warn "采集失败: #{e.message}"
[]
end
end正则表达式的模式要根据实际设备输出微调,不同版本的IOS输出格式略有差别,建议先手动执行一次命令确认格式再写正则。如果网络中设备支持SNMP,用SNMP轮询EIGRP相关MIB会更稳定,只是解析工作量稍大一些。
三、阈值的动态调整策略
固定阈值用起来简单,但不够灵活。网络规模不同、链路质量不同,合适的阈值也不一样。一个小型的三台路由器网络,查询传播很快,Active状态持续几十秒就很不正常,阈值可以收紧到60秒;而一个跨地域的大规模网络,查询要经过多层汇聚,100多秒才收到全部应答属于正常现象,阈值就要放宽。
一个实用的做法是引入基准学习:脚本启动后先观察一段时间,统计历史上Active状态的持续时间分布,把阈值设为正常持续时间的某个分位数加上安全余量。实现上可以用一个简单的数组保存最近的观测值:
class AdaptiveThreshold
WINDOW = 200 # 学习窗口大小
def initialize(base: 120, multiplier: 1.5)
@base = base
@multiplier = multiplier
@samples = []
end
# 每当一条路由离开Active状态时,记录它持续了多久
def record(duration)
@samples << duration
@samples.shift if @samples.size > WINDOW
end
def current
return @base if @samples.size < 30 # 样本不足时用基准值
sorted = @samples.sort
p90 = sorted[(sorted.size * 0.9).floor]
[(p90 * @multiplier).round, 150].min
end
end这里用P90分位数乘以1.5倍作为阈值,意味着正常情况下九成的Active持续时间都在阈值的三分之二以内,只有明显异常的长等待才会触发告警。同时用150秒做了上限封顶,确保阈值永远小于设备的SIA超时时间,保住告警的提前量。样本数不足30时退回基准值,避免学习初期阈值乱跳。
另外还有两个调整建议:一是采集间隔不要设得太密,15到30秒是比较均衡的选择,间隔太短会给设备带来不必要的命令执行压力;二是可以为不同前缀长度设置不同阈值,比如默认路由和汇聚前缀的查询影响面更大,阈值可以单独收紧。把配置外置到YAML文件中,运维人员就能在不改代码的情况下灵活调整:
sia_detector:
default_threshold: 120
check_interval: 15
special_rules:
- prefix: "0.0.0.0/0"
threshold: 60
- prefix_len: "16"
threshold: 90
adaptive:
enabled: true
multiplier: 1.5
max_threshold: 150最后提醒一点,检测只是第一步。告警触发后,脚本可以进一步联动采集show ip eigrp neighbors和show ip eigrp events的输出,把现场信息一并保存下来,为后续定位是链路问题还是邻居端的问题提供第一手资料。这个扩展在上述框架里加一个回调方法就能实现,整体代码量不会超过两百行,却能在SIA真正发生前争取到宝贵的处置时间。