ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VPP FIB表项解析:从show ip fib到转发排障与快照对比

VPP FIB表项解析:从show ip fib到转发排障与快照对比

VPP 的 FIB 表项信息,是我在日常转发面排查里翻得最多的一类输出。刚接触这套东西的时候,我也觉得它不过是"把路由表换个姿势打印一遍"——直到有一次,一条明明已经加进去的 /24 路由,报文死活不走它,最后是在show ip fib的输出里发现表项指向的根本不是我以为的那个邻接对象。从那以后我就明白了:VPP FIB 表项信息不是路由表,而是一张描述"报文该被交给谁处理"的对象引用图。这篇文章就把这张图拆开讲,从表项的分层结构、路由源与择优逻辑,到关键字段怎么读、表项不生效该怎么一层一层往下刨,再到批量抓取表项做变更对比的脚本写法。不管你是刚开始在实验环境里敲ip route add,还是已经在现网设备上盯着 FIB 排查丢包,下面这些内容都能直接拿去用。

1. 从一条 show ip fib 的输出说起:表项里到底存了几层东西

1.1 一次典型误判:路由加了,报文却没走对路径

先说个我早期遇到的场景,印象特别深。测试环境里两台设备直连,各自起了收发流量,我在其中一台上用命令行加了一条目的前缀 10.10.10.0/24 的静态路由,出接口和下一跳都写得清清楚楚,然后ping目的地址不通。当时的第一反应是"路由没生效",于是反复删了加、加了删,甚至怀疑是不是前缀长度写错了。折腾了快二十分钟,最后把show ip fib 10.10.10.0/24的完整输出打出来,才看到表项确实在,但末端那个转发对象指向的接口索引和我预期的物理口对不上——因为我在命令行里写的接口名,被解析到了一个同名的子接口或者被误创建的 tunnel 上。

这件事给我的教训是:VPP 的 FIB 表项存在,和"报文按你想象的方式转发",是两件独立的事。表项只保证"前缀可以被匹配到",至于匹配到之后交给哪个对象、那个对象是否真的能把包发出去,是下一层的事。所以看 FIB 输出,一定不能只看前缀那一行有没有出现,得顺着往下看它引用的对象。

另一个常见的误判是拿 VPP 的 FIB 和传统路由表做类比。传统路由表里一行就是"目的前缀 + 下一跳 + 出接口",是扁平的。VPP 的 FIB 是分层的:前缀指向一个负载均衡对象,负载均衡对象里挂着若干条路径,每条路径再指向一个邻接对象,邻接对象里才记录着真正的出接口、MAC 头和 MTU 信息。这四层里任何一层出问题,转发都会异常,而前缀那一行依然会老老实实地显示在那里。

1.2 表项的四个层次:entry、load-balance、path、adjacency

把输出拆开看会更清楚。一条典型的show ip fib结果大致长这样:

vpp# show ip fib 10.10.10.0/24 ipv4-VRF:0, fib_index:0, flow hash:[src dst sport dport proto ] epoch:0 flags:none 10.10.10.0/24 fib:0 index:26 locks:2 src:API [@0]: dpo-load-balance: [proto:ip4 index:28 buckets:1 uRPF:30 to:[0:0]] [0] [@4]: ipv4 via 192.168.10.1 GigabitEthernet0/8/0: mtu:1500 next:6 flags:[]

这里每一行代表一个层次。第一行是表头,告诉你这是哪个 VRF 的 FIB、fib_index 是多少、当前的 epoch 值和 flow hash 配置——flow hash 那一串决定了多路径场景下按哪些字段做哈希,这个后面还会细说。第二行是FIB 表项本体,包含前缀、所属 FIB 索引、表项自身的索引号,以及锁计数 locks。第三行是路由源,标明这条表项是谁加进来的,src:API表示通过二进制接口下发的,src:CLI则是命令行,还有src:STATIC、src:INTERFACE等等。

第四行是负载均衡对象,也就是 DPO(Data Path Object,数据面对象)层,index:28是它自己的索引,buckets:1表示只有一条路径即单路径场景,如果是 ECMP 这里会是桶的数量。最后一行是路径与邻接,[@4]是邻接对象的索引,后面跟着下一跳地址、出接口名、MTU 和 next 指针。

这四层的拆法不是 VPP 为了显得复杂,而是它整个数据面设计的必然结果。

1.3 为什么一定要把 entry 和 path 拆成两层

很多人会问:直接像传统路由表那样一行存完不行吗?答案藏在一个很实际的优化里。设想有 5000 条前缀,它们全都指向同一个下一跳 192.168.10.1 和同一个出接口。如果是扁平结构,这 5000 行里每一行都要重复存一遍下一跳、出接口、MAC 头、MTU。而拆成两层之后,这 5000 个表项可以共享同一个负载均衡对象,甚至共享同一条路径和同一个邻接对象——这就是输出里locks字段存在的意义,那个数字是引用计数,表示有多少个上层对象在引用它。

提示:看到locks数字很大不要紧张,那是正常的共享复用,不是内存泄漏。只有当你想删某条路由却删不掉时,才需要顺着锁计数去查是谁还在引用。

分层的第二个好处是变更代价小。当下一跳的 MAC 地址变了,比如对端换了网卡,只需要更新最底层的邻接对象,上层几千个表项指向的对象索引完全不用动。我在一个中等规模的测试环境里验证过:约 2 万条前缀共享 40 个邻接对象,修改其中一个邻接的 MAC,FIB 表项的索引和锁计数一个都没变。这对控制面的稳定性非常关键,因为它意味着一次邻接刷新不会触发大规模的表项重建。

第三个好处是路径的独立性。多路径场景下,多条路径可以有不同的权重、不同的优先级,甚至分别处在"可达"和"不可达"两种状态,这些信息挂在路径层,而不是表项层。表项只关心"我有一组路径",具体用哪条、怎么分配,是负载均衡对象的事。

2. 表项从哪来:路由源、优先级与 FIB 的择优逻辑

2.1 常见路由源与它们的默认优先级

FIB 表项不是凭空出现的,每条表项都带着来源标记。搞清楚来源,才能在排查时判断"这条表项是配置下发的,还是接口状态推导出来的"。常见的来源类型和处理方式整理如下:

路由源标记典型来源删除方式排查时的注意点
src:CLI命令行手工添加命令行删除重启后是否恢复取决于有没有做持久化配置
src:API外部控制面通过二进制接口下发由下发的控制面删除控制面异常退出后表项可能残留
src:STATIC静态配置载入重新载入配置与 CLI 来源容易混淆,注意区分
src:INTERFACE接口地址自动生成的直连表项接口地址删除时自动清理接口 down 后不一定立即消失
src:DHCP地址分配流程产生的表项由分配流程清理租约变化时会有一段时间的旧表项

这张表里最容易被忽略的是src:INTERFACE。很多人加了接口地址之后看到多出几条前缀,以为是别人下发的,其实那是接口地址天然产生的直连表项。它们的存在是必要的,因为下一跳的可达性解析依赖这些表项,删掉会导致所有经由该接口的间接路由失效。我在某次清理"多余表项"的时候就干过这种蠢事,把直连的 /24 删了,结果一整片间接路由全部解析失败,链路瞬间断掉。

至于优先级,VPP 内部对同一个前缀、同一个 FIB 索引,允许存在多条来自不同源的表项,最终生效的那条由源的优先级决定。这套机制和主流路由协议的管理距离思路一致,但 VPP 的实现更简单直接:谁优先级高谁赢,平级时后到的覆盖先到的,同时把被覆盖的那条记在"备份"位置,等赢家被删除时自动恢复。

2.2 同一前缀多个源并存时的实测表现

我在测试环境里做过一个对照实验,把同一个前缀 172.16.5.0/24 依次用三种方式加进去,然后观察表项的变化。第一步只用命令行加,输出里src:CLI独占。第二步用控制面接口再下发一条相同前缀但不同下一跳的路由,此时输出里能看到两条源的记录,实际生效的是优先级更高的那条,另一条在输出中以备份形式存在,锁计数各自独立统计。

第三步把生效的那条删掉,这时候不需要重新下发,备份的那条会自动提升为生效状态。整个过程里表项索引可能保持不变,但负载均衡对象和路径对象会重建,所以如果你是在做流量不间断测试,会看到极短暂的收敛窗口。

注意:这个过程不要用"删了再加"的方式来验证,因为删除动作本身会触发一次表项释放,再添加又是一次重建,你观察到的现象和自然的优先级切换不是一回事。

理解这个机制的实际价值在于排查"改了配置但表项没变"的问题。很多时候不是配置没生效,而是你的配置被更高优先级的源压住了,看起来毫无变化。这时候把输出的源标记部分看仔细,比反复重启服务有效得多。

2.3 递归路由在表项里的形态

递归路由是新手最容易看懵的地方。所谓递归,就是你给的下一跳本身不是一个直连可达的地址,需要再查一次路由才能知道怎么到达。比如默认路由指向 192.168.10.1,而这个地址所在网段是通过另一个接口学的。

VPP 处理递归的方式是先把下一跳解析成直连可达的地址,再让原表项指向解析后的结果。所以在输出里,你会额外看到一条针对下一跳地址的 /32 表项,它才是真正挂着邻接对象的那一层。原表项则通过一个指向它的引用间接转发。

这个设计带来的一个实用推论是:排查递归路由问题时,要同时看两条表项。原前缀的表项显示的下一跳可能是个"中间地址",如果那个中间地址的 /32 表项不存在或者状态异常,原表项的转发就是断的,但前缀那一行依然在。我在现网见过好几次"路由在但 ping 不通",最后都是中间 /32 表项因为邻接解析失败被撤掉了,而控制面还没感知到这个变化。

递归解析还有一个细节值得说:解析是有深度限制的。如果 A 指向 B、B 指向 C、C 又指回 A,形成环路,解析会在达到上限后失败,表项会进入一个"未解析"状态。这种状态下前缀表项通常在输出里会显示为特殊的标记或者干脆不显示转发路径。造环这种事故在配置合并、批量下发脚本出 bug 的时候确实会发生,我建议任何批量下发之前都先在离线环境跑一遍解析验证。

3. 读懂表项里的那些字段:flag、path-list 与 hash 桶

3.1 标志位逐个拆解

FIB 表项输出里的 flags 字段信息密度很高,但文档里往往一笔带过。根据我实际接触过的几类标记,整理成下面这张表,方便遇到时快速对照:

标志位含义出现场景
local该前缀属于本机地址接口地址生成时自动出现
drop命中后直接丢弃黑洞路由、null 接口路由
punt上送控制面处理需要本机协议栈介入的流量
icmp-echo本机地址的 ICMP 响应设备自身地址
no-uRPF不参与反向路径检查非对称路由环境
drop-exempt豁免丢弃策略特定策略场景

这些标记是可以组合的,一条表项上同时出现local和icmp-echo是很常见的,因为它既是本机地址又要响应 ping。排查时如果看到一条本该正常转发的表项带了drop,那基本可以确定是某处配置把它设成了黑洞,往后查配置来源就行,不用再纠结转发面的问题。

我个人的经验是,把 flags 当作一个"优先级很高的线索"而不是"最后才看的细节"。因为它明确告诉你这条表项在数据面会被怎样处理,比你去猜路径状态高效得多。

3.2 path-list 索引与对象共享关系

输出里的index:数字是对象自身的索引,[@n]是它引用的下层对象的索引。这两个数字在排查共享关系时特别有用:如果你在多条不同前缀的输出里看到同一个[@n],说明它们共享同一个下层对象,那么修改这个下层对象会影响所有引用它的前缀。

这个技巧在定位"改了一条路由影响了一大片"的问题上非常有效。有一次测试环境里出现批量不通,我随手抓了三条不通的前缀做对比,发现它们的负载均衡对象索引完全不同,但路径层引用的邻接对象索引是同一个。问题立刻锁定在那个邻接对象上——接口状态发生了变化,邻接被标记为不可用,所有依赖它的路径同时失效。

反向的用法同样有价值:如果一批前缀应该共享邻接对象,但输出里显示各自的邻接索引五花八门,那说明路径没有正确复用,可能是控制面每次下发都新建了邻接,长期运行会累积大量冗余对象。这种情况在自研控制面里不算罕见,属于性能隐患而不是功能故障。

3.3 多路径场景下 hash 桶的分配

单路径时buckets:1,看起来毫无信息量。但一旦配了等价多路径,这个数字就变成了核心参数。假设有两条等价的下一跳,桶数通常是 2;三条就可能是 3 或者被规整成 4,具体取决于实现和配置。每个桶按顺序对应一条路径,报文进来后按表头里配置的 flow hash 字段算出哈希值,再对桶数取模决定走哪条。

这里有个必须说清楚的坑:桶数不等于路径数。当路径权重不均等的时候,比如权重 2 比 1,实现上往往会展开成 3 个桶,其中两个指向高权重路径。所以在输出里看到桶数比路径条数多,不要以为是哪里配错了。

另一个相关的坑是会话粘性问题。默认的 flow hash 字段组合决定了同一会话的报文哈希值相同,但如果只用了源地址做哈希,同一源的大量会话会全部压到一条路径上,多路径等于白配。我一般建议至少包含源地址、目的地址、协议号和源目的端口这几项,代价是哈希计算稍微多几个周期,收益是负载分布会均匀得多。

提示:改 flow hash 配置属于全局动作,会影响到所有 FIB 表的多路径分发,而且生效瞬间会让已有会话重新分布,可能引起短暂的乱序。要动就在业务低峰动。

4. 表项不生效的完整排查链路

4.1 第一步:确认表项真的进了 FIB,而不是停在配置层

排查任何"路由不生效"的问题,我最强调的第一步是别急着怀疑转发面,先确认表项存在。用show ip fib直接查指定前缀,如果有输出,说明配置层到 FIB 的链路是通的,问题在更下游;如果完全没输出,那问题在配置下发环节,转发面根本没参与。

这个顺序的价值在于它把问题一刀切成两半。很多人上来就去看接口状态、看 ARP 表,浪费大量时间,最后发现是表所在的 FIB 索引不对——比如配置写到 table 10 里去了,而报文查的是默认 table 0。VPP 支持多 FIB 表之后,这种错配非常常见,因为命令行里如果不显式指定表,默认走的就是 0 号表。

确认表项存在之后,再看它的fib_index是否和你预期的表一致。这一步只要一眼,能省掉很多弯路。我在给团队做分享时经常强调:排查的顺序应该是"表项有没有 → 表项在哪个表 → 表项的路径指向哪 → 路径的邻接是否正常 → 邻接的出接口是否 up",严格按照从上层到下层走,不要跳步。

vpp# show ip fib table 10 10.10.10.0/24 vpp# show ip fib table 10 10.10.10.0/24 detail

detail选项会展开更多信息,包括备份表项和更细的对象状态,排查时建议直接带上。

4.2 第二步:路径解析与邻接状态

表项在、表也对了,下一步看路径。这里的判据是:路径条数和你的预期是否一致,路径指向的邻接索引是否存在。如果路径条数是 0 或者只有一条但你配了两条,说明有一路没被接受,常见原因是对应的下一跳不可达、出接口不存在,或者被策略过滤了。

接着看邻接对象。邻接的关键状态是它有没有解析到硬件地址。在以太网环境里,下一跳的 MAC 地址没学到,邻接就是"不完整"状态,报文发不出去。这时候要转去看邻居表:

vpp# show ip neighbor vpp# show ip neighbor table 10

如果邻居表里对应条目缺失或者状态不是可达,问题就定位在地址解析这一层了。常见原因包括:对端没配地址、链路层不通、VLAN 封装不匹配、接口刚起来还没完成学习。这些都属于"表项看着正常但报文出不去"的典型。

这里有个容易忽略的点:邻居缓存是有老化时间的。如果邻居条目因为老化被清掉了,而后续的解析又没成功,表项会一直存在但转发中断。我在一个测试环境里遇到过邻居老化和解析风暴叠加的问题,表现为每隔几分钟断一次,纯粹看静态配置完全看不出问题。所以要养成看邻居条目剩余时间的习惯。

4.3 第三步:接口索引漂移导致的"表项假存在"

这是我认为最隐蔽的一类。VPP 里的接口是有索引的,删除重建同名接口之后,索引通常会变。如果某个邻接对象仍然指向旧索引,而新接口占用了这个索引(索引被复用的情况),那么邻接对象看起来"指向了一个存在的接口",但它可能指向的是另一个接口,或者指向一个已经被重新配置过的接口。

这类问题的表现极度诡异:表项全在、邻居条目正常、接口状态 up,就是不通。排查方法是对比预期接口的实际索引和邻接对象里记录的索引是否一致。脚本化环境下特别容易出现这个问题,因为批量删除重建接口是常规操作。

vpp# show interface # 看接口索引 vpp# show adjacency # 看邻接对象里的接口索引与标志 vpp# show ip fib <prefix> detail

我的做法是在任何涉及接口删除重建的自动化流程里,都加一步"重建后刷新依赖该接口的邻接"。虽然会带来一点额外开销,但能彻底避免这类幽灵问题。

4.4 第四步:MTU 与分片相关的隐性丢包

最后这一层不属于表项本身,但排查时必须走一遍,因为它的表现太像"路由没生效"了。表项正常、邻接正常、接口 up,但大包全丢、小包正常,很可能是出接口 MTU 导致的。IPv4 场景下如果禁用了分片或者设置了不分片位,超 MTU 的报文会被静默丢弃,ICMP 差错消息在某些环境下还会被策略过滤,于是现象就是"能 ping 通但传不了数据"。

排查手段是先用不同大小的包做对比测试,确认是不是尺寸相关;然后看路径上各段的 MTU 和隧道封装带来的额外开销。一个隧道封装加 60 到 80 字节很常见,如果底层接口是标准的 1500,隧道内实际可用就只剩一千四百多,业务侧配了 1500 的包就会出问题。这类问题在加入封装之后立刻出现,如果时间线对得上,优先级可以提到最高。

5. 批量化运维:把 FIB 表项抓出来做变更对比

5.1 通过接口抓取全量表项的正确姿势

手工一条条看表项只适合定位单点问题。真正做变更验证或者配置巡检,必须靠接口把整张表抓出来。VPP 的表项导出走的是 dump 接口,特点是流式返回、分多条消息,条数可能成千上万,客户端必须循环接收直到收到结束标记,不能指望一次调用拿全。

import collections from vpp_papi import VPPApiClient vpp = VPPApiClient(apidir="/usr/share/vpp/api", apifiles=["ip.api"]) vpp.connect("fib-snapshot") def dump_fib(table_id=0): rows = [] # 每收到一条 details 回调就收集,直到 dump 结束 for item in vpp.api.ip_fib_dump(table_id=table_id): rows.append(item) return rows if __name__ == "__main__": entries = dump_fib(0) print("collected:", len(entries)) vpp.disconnect()

这段代码的关键在于客户端要能正确识别 dump 的结束。如果自己在套接字层做实现,最容易踩的坑是"只收第一批就以为完事了",尤其在表项数量超过单批上限时,你会静默拿到一个残缺的视图。我在做第一次变更对比的时候就吃过这个亏,快照缺了后面几千条,结果 diff 出来的变更列表完全失真。

注意:dump 过程中不要同时做大批量的表项写操作,否则会拿到一个"半新半旧"的混合视图,用来做对比毫无意义。要对比就在两个稳定时刻分别抓。

5.2 把抓取结果整理成可比对的快照

拿到原始对象之后,直接对比对象列表是不可行的,因为里面包含大量内部索引、计数器之类的易变字段。我一般的做法是只保留业务语义相关的字段,把每一条表项规约成一行文本,然后排序输出。

def normalize(item): prefix = f"{item.prefix.address}/{item.prefix.len}" paths = sorted( f"{p.next_hop}/{p.sw_if_index}" for p in getattr(item, "path", []) ) flags = ",".join(sorted(getattr(item, "flags", []))) return f"{item.table_id}\t{prefix}\t{flags}\t{';'.join(paths)}" def snapshot(items): return sorted({normalize(i) for i in items})

这样得到的是一个纯文本的、行有序的集合,可以直接用标准 diff 工具比较。我特别推荐用集合去重再排序,因为某些实现会在 dump 里重复返回同一条表项的不同副本,不去重的话 diff 结果会出现大量假的"新增"。

对于生产环境,我还会额外记录两类信息:一是抓取时刻的 epoch 值,二是总条数。前者能让你事后判断两次快照之间是否发生过表项变化,后者是个低成本的一致性检查——如果两次快照条数差异很大而变更记录里没有对应操作,那多半是抓取本身出了问题,而不是配置真的动了。

5.3 变更前后的对比与结果解读

有了规约后的快照,对比就变成了三行命令的事。但结果怎么读,值得说几句。diff 里出现的新增行,可能是真的新增路由,也可能是某条路由的路径变了导致整行文本发生变化——后一种情况在对比里表现为"一删一增",容易让人误判为两条独立变更。

解决办法是在规约时把前缀作为主键单独提取,先按主键比对集合差异,再对交集部分逐条比对路径和标志位差异。这样输出会清晰很多:前缀集合的变化是一类,属性变化是另一类,混在一起看很容易看漏关键变更。

我在实际使用中还加了一条规则:任何涉及默认路由或者大面积前缀的变更,都在对比结果里高亮提示。因为这类变更的影响面最大,即使只有一行,也值得单独确认一遍。这个简单的检查帮我拦下过好几次批量下发脚本写错掩码的事故。

6. 几个容易被误判的细节

6.1 输出是快照还是实时状态

这个问题问的人不多,但影响很大。命令行打印出来的内容,是执行命令那一刻的状态快照,不是你持续观察的视图。如果表项在你敲命令的过程中发生了变更,你看到的是变更前或者变更后的一个瞬间,甚至可能是跨多个对象的非原子拼合。所以在排查"时好时坏"的问题时,单次快照几乎没用,得靠重复采样或者订阅事件。

VPP 提供了针对表项变化的事件订阅能力,可以在表项新增、删除、更新时收到通知。做收敛性验证的时候,订阅事件比轮询快照准确得多,也能拿到变化的先后顺序。不过订阅本身有开销,生产环境建议只在排查窗口内开启,别长期挂着。

6.2 删除表项的延迟与引用计数

很多人以为删了配置表项就立刻消失,实际上它受引用计数控制。一条表项被多个上层对象引用时,删除动作只是释放其中一个引用,表项本身要等锁计数归零才会真正释放。这个设计是必要的,否则共享的对象会被提前回收导致野指针。

排查时如果发现"删了还在",先看锁计数和来源列表,多半是还有别的地方引用着。这种情况在控制面异常退出后特别常见——控制面没来得及释放自己持有的引用,表项就成了泄漏。长期累积会占用内存,最终影响表容量。

提示:做容量规划时,别只按前缀条数估算,要把路径对象、邻接对象的数量一起算进去。共享度低的环境,实际对象数可能是前缀数的好几倍。

6.3 大表规模下的查找与内存表现

最后说点规模相关的东西。FIB 查找本身是基于多级索引结构的,前缀长度不同走的查找层级不同,性能不会随表项数线性下降,这一点在实测里表现明显。但内存占用基本是随对象总数线性增长的,所以真正需要关注的是"对象总数"而不是"前缀数"。

我做过一组对照:同样 2 万条前缀,一组共享 50 个邻接对象,另一组每条前缀独享邻接。前者内存占用明显低,且表项添加的总耗时也短,因为共享对象的创建只发生一次。这组数据给我们的启示是:控制面在设计时应该尽量把相同下一跳的路由聚合成共享路径,而不是每条都新建,这个优化在表规模上去之后收益非常可观。

另外一个容易忽视的点是删除时的收敛时间。表项越多,一次批量删除引起的对象释放开销越大,如果控制面在这个窗口内持续下发,可能造成处理延迟堆积。我的做法是给大批量变更加上分批和间隔,虽然看起来慢了,但整体稳定性反而更好。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进