1. 从一次“内存凭空消失”的排查说起
如果你在运维岗位上待过一段时间,大概率遇到过这种场景:一台服务器的内存监控曲线缓慢爬升,free 命令看到的可用内存一天比一天少,但翻遍 top 和 ps 的常驻内存排行,前几名都是些不起眼的进程,加起来也解释不了那消失的几个 G。更让人头疼的是,重启服务没用,清理缓存也只是暂时缓解,过不了多久内存又被吃掉。这种“隐形内存占用”往往比一个进程直接 OOM 更折磨人,因为它不报错、不崩溃,只是安静地把系统资源一点点蚕食掉。
这次要聊的就是一个非常典型的案例:zebra 进程导致的内存占用异常。zebra 是很多网络设备或 Linux 服务器上都会跑的一个路由管理守护进程,属于 Quagga 或 FRR 这类路由套件的一部分。它平时看起来人畜无害,CPU 占用低、内存占用也不高,但恰恰是这种“看起来没问题”的进程,最容易在长时间运行后暴露出内存泄漏问题。本文会从排查思路、定位手段、根因分析到修复验证,完整还原一次真实的排查链路,适合有一定 Linux 运维基础、正在被内存问题困扰的工程师参考。哪怕你之前没接触过 zebra,这套方法论也能迁移到其他守护进程的内存排查上。
我先把结论放在前面:zebra 的内存泄漏往往不是它自己“主动”吃内存,而是某些配置或交互模式下触发的数据结构只增不减,比如路由表项、邻居表、或者某些事件回调注册后没有正确释放。这类问题在短时间压测里根本看不出来,只有跑上几天甚至几周才会显现。所以排查的核心不是“看谁占用高”,而是“看谁在持续增长”。
2. 为什么常规内存排查手段会漏掉 zebra
2.1 top 和 ps 的视角盲区
大多数人排查内存问题的第一反应是敲top,按M排序看谁占用高。但 zebra 这类进程有个特点:它的 RSS(常驻内存)可能只有几十 MB,在 top 里排到几十名开外,你根本不会注意到它。可问题在于,它可能同时在内核态或者通过共享内存、mmap 映射占用了大量内存,而这些在 top 的默认视图里是不体现的。
举个例子,zebra 在处理大量路由条目时,会通过 netlink 和内核交互,内核里的路由缓存、邻居表这些结构占用的内存是算在 slab 里的,不会计入某个进程的 RSS。你用ps aux看 zebra 只占 30MB,但slabtop一看,kmalloc-256、fib6_nodes这些 slab 缓存涨了好几百 MB。这就是典型的“进程看起来小,系统内存却没了”。
2.2 free 命令的误导性
free -h输出的buff/cache经常被当成“可回收内存”,很多人看到 available 还够就放心了。但 zebra 泄漏的内存如果落在不可回收的 slab 或者匿名页上,available 会持续下降,而 buff/cache 可能纹丝不动。这时候你如果只是echo 3 > /proc/sys/vm/drop_caches,会发现根本没释放多少,因为问题不在 page cache。
我自己的习惯是,遇到内存缓慢增长,先跑一条组合命令:
watch -n 5 'free -m; echo "---"; cat /proc/meminfo | grep -E "Slab|SReclaimable|SUnreclaim|AnonPages"'重点看SUnreclaim和AnonPages这两个值。如果SUnreclaim持续涨,基本可以锁定是内核对象泄漏,再结合 slabtop 找具体缓存名;如果AnonPages涨,那就是用户态进程的匿名内存,得回到进程层面查。
2.3 zebra 的特殊性:多进程架构
zebra 通常不是单独跑的,它和 bgpd、ospfd、ripd 这些兄弟进程一起工作,彼此之间通过 ZAPI(Zebra API)通信。内存泄漏可能发生在 zebra 主进程,也可能发生在某个协议进程,甚至可能是 ZAPI socket 缓冲区堆积导致的。如果你只盯着 zebra 一个进程看,很容易漏掉真正的元凶。
所以排查前先理清楚:这台机器上跑了哪些路由相关进程?它们之间的依赖关系是什么?用ps -ef | grep -E "zebra|bgpd|ospfd|ripd"先摸清底数。这一步看着简单,但我见过太多人一上来就扎进 zebra 的源码,结果发现是 bgpd 把路由灌给 zebra 时触发了泄漏,方向从一开始就错了。
3. 定位 zebra 内存泄漏的完整排查链路
3.1 第一步:确认内存增长的时间规律
别急着上工具,先看监控。把过去一周的内存曲线拉出来,重点观察三点:增长是线性的还是阶梯式的?有没有和某个操作(比如路由震荡、配置变更)对应的时间点?重启 zebra 后内存是否回落?
线性增长通常意味着每次事件处理都有小量泄漏,比如每收到一条路由更新就漏一点;阶梯式增长往往对应批量操作,比如一次性下发几千条路由。如果重启后内存明显回落,那基本可以确认是 zebra 用户态的问题;如果重启也不回落,那泄漏可能在内核侧,zebra 只是触发者。
我遇到的那次是典型的线性增长,每天涨 200MB 左右,重启 zebra 后回落,但过一天又开始涨。这个规律直接帮我排除了内核 slab 泄漏的可能,把范围缩小到 zebra 进程本身。
3.2 第二步:用 valgrind 做粗筛
确认是用户态问题后,下一步是抓现场。最直接的工具是 valgrind,虽然它会让 zebra 跑得慢十倍,但用来定位泄漏点非常有效。操作上建议在测试环境复现,不要直接在生产上挂 valgrind,否则路由收敛会慢到影响业务。
启动方式大概是这样:
valgrind --leak-check=full --show-leak-kinds=all --log-file=/tmp/zebra_valgrind.log /usr/lib/frr/zebra -f /etc/frr/zebra.conf -d跑上几个小时,等内存涨到一定程度后停掉,看日志里的definitely lost和indirectly lost。如果这两项很大,说明确实有泄漏,而且 valgrind 会给出调用栈,直接指向泄漏的代码位置。
但 valgrind 有个坑:zebra 这种事件驱动的进程,很多内存是在事件回调里分配的,valgrind 的调用栈可能被优化得看不清。这时候可以配合--track-origins=yes,虽然更慢,但能追溯到内存最初分配的地方。
3.3 第三步:用 gdb 和 malloc 统计做精确定位
如果 valgrind 因为性能问题没法在接近生产的环境跑,或者日志太乱看不清,可以换 gdb 加 malloc 统计的思路。具体做法是让 zebra 链接一个带统计功能的 malloc 库,比如libtcmalloc或者自己写个简单的 hook,记录每次分配和释放的调用栈。
更轻量的办法是用 gdb attach 到 zebra 进程,定期 dump 堆内存的统计信息。比如:
gdb -p $(pidof zebra) -batch -ex "call malloc_stats()" -ex "detach"malloc_stats()会输出当前堆的总分配量、空闲量等信息。如果你隔一段时间执行一次,看到Total (incl. mmap)持续上涨而Free不涨,就说明有泄漏。再结合malloc_info或者malloc_trim可以进一步分析。
我实际用下来,最有效的组合是:先用 valgrind 在测试环境定位到可疑函数,再到生产环境用 gdb 定期采样确认增长趋势,两者互相印证。
3.4 第四步:检查 ZAPI 和路由表相关数据结构
zebra 的内存泄漏,十有八九和路由表、邻居表、或者 ZAPI 消息处理有关。重点检查这几个地方:
- 路由条目释放逻辑:当一条路由被撤销时,zebra 是否真的 free 了对应的
route_node和rib结构?有些版本里,如果路由被多个协议进程引用,引用计数没减到零就不会释放。 - ZAPI 消息队列:如果某个协议进程异常退出,zebra 这边对应的 ZAPI 连接可能没有正确清理,导致消息缓冲区一直挂着。
- 邻居表老化:IPv6 邻居发现或者 ARP 表项如果老化逻辑有问题,表项会越积越多。
可以用vtysh连上 zebra,执行show memory看各模块的内存统计。FRR 和 Quagga 都内置了这个命令,输出会按模块列出当前分配的内存块数量和总大小。如果你发现某个模块的块数只增不减,那就是泄漏点。
vtysh -c "show memory"输出里重点关注Route table、RIB、ZAPI、Neighbor这几项。正常情况下,路由撤销后这些计数应该回落;如果只涨不跌,问题就找到了。
4. 根因分析:zebra 内存泄漏的几种典型模式
4.1 路由引用计数未正确递减
这是最常见的一种。zebra 内部用引用计数管理路由节点,当多个协议进程(比如 bgpd 和 ospfd)同时下发同一条路由时,route_node的引用计数会累加。如果某个协议进程撤销路由时,引用计数没有正确减一,这个节点就永远不会被释放。
这种泄漏的特点是:泄漏量和路由震荡频率成正比。网络越不稳定,路由反复撤销下发,泄漏越快。排查时可以在show memory里看Route node的数量,如果它和实际路由数对不上,而且持续增长,基本就是这个问题。
修复思路通常是检查route_unlock_node的调用路径,确保每个route_lock_node都有对应的解锁。有些老版本代码里,错误处理分支忘了解锁,正常路径没问题,一旦出错就漏。
4.2 ZAPI 消息缓冲区堆积
zebra 和协议进程之间通过 Unix socket 通信,每个连接都有发送和接收缓冲区。如果某个协议进程处理消息很慢,或者干脆卡死了,zebra 这边的发送缓冲区就会堆积。这些缓冲区占用的内存不会体现在 zebra 的 RSS 里(因为可能是 mmap 或者内核 socket buffer),但会实实在在吃掉系统内存。
判断方法:用ss -x看 Unix socket 的发送队列和接收队列。如果某个 ZAPI socket 的Send-Q持续很大,那就是对端处理不过来。
ss -x -p | grep zebra这种情况的“修复”不一定是改代码,有时候调整对端进程的处理逻辑,或者给 ZAPI 加流控,就能缓解。但如果是对端进程已经崩溃但 socket 没关闭,那就得检查 zebra 的连接清理逻辑了。
4.3 定时器和事件回调未注销
zebra 大量使用事件驱动模型,每个定时器、每个文件描述符可读可写事件都会注册回调。如果某个回调在对象销毁时没有注销,那么即使对象已经不用了,事件循环每次还是会调用它,而且回调里可能又分配了新内存。
这种泄漏比较隐蔽,因为show memory里可能看不到明显的模块增长,但进程的匿名内存会慢慢涨。用perf或者gdb抓一下调用栈,如果发现某个回调被频繁调用但对应的对象已经不在路由表里了,那就是它了。
我见过一个案例是接口 down 掉后,接口相关的定时器没注销,每次定时器触发都重新分配一个结构体,结果接口虽然 down 了,内存却一直在涨。这种问题只能靠代码审查加动态跟踪来定位。
4.4 配置解析阶段的临时对象泄漏
还有一种容易被忽略的情况:zebra 在加载配置文件或者执行vtysh命令时,会创建大量临时对象。如果解析逻辑里有分支没释放这些临时对象,每次配置变更都会漏一点。这种泄漏在稳定运行的网络里可能几个月都不明显,但如果你经常改配置,内存就会涨得很快。
排查方法是反复执行同一条配置命令,观察show memory里相关模块的计数是否回落。比如反复ip route添加删除,看Route node是否回到初始值。
5. 修复方案与验证手段
5.1 代码层面的修复要点
如果确认是引用计数问题,修复的核心是保证lock和unlock严格配对。建议在代码里加断言,比如在route_unlock_node里检查引用计数不能小于零,一旦小于零就打印警告。这样能在测试阶段就暴露问题,而不是等到生产环境内存涨爆。
对于 ZAPI 缓冲区问题,可以在 zebra 侧加一个上限,当某个连接的发送队列超过阈值时,主动断开或者丢弃消息,避免无限堆积。同时要确保连接关闭时,相关的缓冲区和事件回调都被清理干净。
定时器泄漏的修复相对直接:在对象销毁函数里,显式调用THREAD_OFF或者对应的取消注册接口,确保所有挂在这个对象上的事件都被摘掉。FRR 的thread.c里有现成的宏可以用,关键是别漏。
5.2 验证修复是否生效
修完不能只看代码,得用数据验证。我的做法是搭一个和现网类似的测试环境,用脚本模拟路由震荡,比如每秒钟撤销再下发一批路由,持续跑 24 小时。同时用监控脚本每 5 分钟记录一次show memory的输出和进程 RSS。
如果修复有效,内存曲线应该是平稳的,show memory里各模块的计数在震荡结束后能回落到基线。如果还在涨,说明要么没修干净,要么还有第二个泄漏点。
验证时要注意一点:zebra 本身有一些缓存和预分配机制,内存不会立刻回落到最低点,只要不持续增长就算正常。别看到内存没降到零就以为没修好。
5.3 生产环境的灰度与回滚
生产环境不能直接全量替换,建议先在一台非核心设备上灰度。灰度期间重点观察:内存增长是否停止、路由收敛是否正常、CPU 占用有没有异常升高。如果一切正常,再逐步扩大范围。
回滚方案也要提前准备好。zebra 的二进制替换很简单,但要注意配置文件的兼容性。如果新版本改了配置语法,回滚时得把配置也回退,否则 zebra 可能起不来。我一般会在灰度前把旧二进制和旧配置一起备份,出问题一条命令切回去。
6. 日常运维中如何提前发现这类隐形泄漏
6.1 建立内存基线监控
别等出问题了才看内存。平时就要给关键进程建立基线,记录正常运行时的 RSS、show memory各模块计数、slab 占用等。一旦发现某个指标偏离基线超过一定比例,就触发告警。
监控项不用太多,但要有针对性。对于 zebra,我建议至少监控这几个:
| 监控项 | 采集方式 | 告警阈值建议 |
|---|---|---|
| zebra RSS | ps 或 /proc/pid/status | 比基线高 50% |
| SUnreclaim | /proc/meminfo | 持续增长超过 1 小时 |
| Route node 数量 | vtysh show memory | 比实际路由数高 20% |
| ZAPI socket 队列 | ss -x | Send-Q 持续大于 0 |
这些指标采集成本低,但能提前几天甚至几周发现异常。
6.2 定期做路由震荡测试
如果条件允许,可以在业务低峰期做小规模的路由震荡测试,主动触发潜在的泄漏路径。比如模拟某个邻居断开重连,或者撤销再下发一批路由,然后观察内存是否回落。这种主动测试比被动等故障要可控得多。
测试时要注意控制范围,别把核心路由震没了。可以先在测试环境验证脚本,再到生产上小范围执行。
6.3 关注版本更新和社区反馈
zebra 的内存泄漏很多是已知问题,在 FRR 或 Quagga 的更新日志里会有修复记录。平时多留意社区邮件列表和 issue 跟踪,看到和自己版本相关的内存修复,及时评估升级。别等到自己踩坑了才去搜,那时候业务可能已经受影响了。
另外,升级前一定要看 changelog 里的“已知问题”部分,有些版本修了旧泄漏但引入了新问题,盲目升级反而更糟。
7. 几个容易踩的坑和实操心得
第一个坑是只盯 RSS 不看 slab。前面说过,zebra 触发的内核对象泄漏不会体现在进程 RSS 上。我建议排查内存问题时,养成同时看free、slabtop、show memory三个地方的习惯,缺一个都可能漏掉线索。
第二个坑是在生产上直接跑 valgrind。valgrind 会让 zebra 慢十倍以上,路由收敛时间从秒级变成分钟级,核心业务可能直接中断。正确做法是在测试环境复现,或者用 gdb 采样这种轻量方式。
第三个坑是忽略配置变更的影响。很多人觉得内存泄漏一定是代码 bug,但实际上,不合理的配置也可能触发泄漏。比如配置了大量的静态路由或者策略路由,zebra 内部的数据结构可能因为设计上限而表现异常。排查时不妨回顾一下最近有没有配置变更,有时候改回旧配置就能定位问题。
第四个坑是修复后不验证直接全量。我见过有人改了一行代码就全量推生产,结果新代码引入了更严重的泄漏。灰度验证这一步不能省,哪怕你觉得改动很小。
最后一个心得是:内存泄漏的排查本质是耐心活。它不像崩溃那样有明确的现场,你需要反复采样、对比、排除。别指望一两个命令就找到根因,做好打持久战的准备。但只要思路对,工具用对,再隐蔽的泄漏也能揪出来。