首页 / 资讯中心 / 文章详情

keepalived内存泄漏排查实战:健康检查脚本导致内存爬升的定位与修复

keepalived内存泄漏排查实战:健康检查脚本导致内存爬升的定位与修复 ★ FEATURED ARTICLE
当时监控面板上那条内存曲线我盯了一下午斜率始终是正的。keepalived这个进程在LVS高可用集群里跑了快两年头一回见面生。排查了差不多三天最后定位到是健康检查脚本里一个不起眼的逻辑在悄悄吃内存。这类问题在线上最坑人不会立刻炸但会慢慢拖垮整台机器。今天把完整的定位过程写出来包括用到的命令、分析思路和踩过的坑希望对遇到类似问题的朋友有点帮助。1. 问题现场内存曲线先涨后不降先交代一下环境背景。某公司的业务网关集群用LVS架在两台物理机上keepalived承担VRRP虚拟IP漂移和真实服务器健康检查。这台机器是某项目X的生产节点内核版本比较旧keepalived版本是1.2.19。近期监控平台开始在凌晨高峰时段推送内存水位告警一开始没太在意以为是业务进程占用。后来有一次SSH登上去敲了top按内存排序后第一眼就觉得不对劲keepalived的RES从50MB一路上涨到快1GB而机器总内存才16GB。更诡异的是重启keepalived之后内存立刻降回50MB左右但运行十几个小时又会涨上去。这一升一降的循环基本坐实了内存泄漏。1.1 确认泄漏进程的第一步到了现场先冷静下来不要急着翻日志。第一件事是用系统自带工具确认到底是谁在吃内存。我一般喜欢先跑一段采样命令把基线记录下来top -b -n 60 -d 5 | grep keepalived /tmp/keep_mem.log这里-b是批处理模式-n 60采样60次-d 5每5秒一次总共坚持5分钟。同时再用free -m记录总内存和可用内存的走势。如果只靠肉眼看top的瞬时值很容易误判尤其是业务高峰内存本来就波动大。采样几分钟后明显看到keepalived的RES从初始值缓慢爬升其他业务进程基本恒定那就把注意力锁定在这个守护进程上。1.2 隔离变量主进程还是子进程keepalived启动后会有父进程和子进程分别负责VRRP协议收发和健康检查调度。用ps -ef看一下进程数如果配置了多实例或双网卡可能不止一个子进程。这时候需要知道到底是哪一层在泄漏。我用pidstat -r -p按进程号监控了一段时间发现子进程的RSS增长最明显父进程相对稳定。这说明问题大概率出在健康检查或VRRP报文处理逻辑里而不是初始化的网络配置阶段。2. 排查工具组合从pmap到valgrind定位进程之后就要开始“解剖”这个进程的内存映像了。Linux下排查内存泄漏的工具有很多但每个工具适用的场景不同。先别急着上重型工具按步来。2.1 pmap快照找异常内存段pmap能展示进程地址空间的映射情况包括每段内存的起始地址、大小、权限和对应的文件或匿名内存。我连续拍了几次快照重点看匿名内存anon的变化pmap -x 12345-x选项会给出RSS、Dirty、共享等详细信息。对比几次快照如果某个地址段的RSS持续增加那它就是嫌疑最大的内存区域。我当时观察到有几段地址在反复分配和释放但并没有完全回收malloc堆段的Dirty值一直在涨。这是典型的C库堆内存碎片化或泄漏特征。2.2 /proc文件系统看内存动态变化/proc/pid/status里的VmRSS、VmData、VmStk字段能精确反映内存统计。我写了个小循环定时抓取这些数值同时配合/proc/pid/smaps观察具体映射段while true; do awk /VmRSS|VmData|VmStk/ /proc/12345/status sleep 3 done这样能获得比top更细粒度的数据避免top四舍五入带来的干扰。如果VmData持续上涨那就说明是堆上分配的内存没有释放而不是栈或共享库的问题。2.3 在测试环境跑valgrind确认泄漏点线上环境跑valgrind会有性能损耗而且keepalived是常驻进程直接attach会干扰服务。所以我做了个稳妥的操作从版本库拉取相同的keepalived源码在测试机器上编译一个调试版本然后用valgrind的memcheck工具跑一段时间的压力测试模拟健康检查频繁切换的场景valgrind --leak-checkfull --show-leak-kindsall \ --log-file/tmp/valgrind_keepalived.log \ ./keepalived -f /etc/keepalived/keepalived.conf -d-d开启了前台debug模式keepalived不会把进程守护化valgrind可以直接跟踪。跑几轮健康检查之后日志里出现了明确的全量报告显示某个函数分配的内存块没有释放。这条信息非常关键直接指向了源码层面。3. 深入源码抓出泄漏函数valgrind只告诉我们“内存块起始地址和大小”要定位到具体函数还得结合源码阅读。keepalived的源码并不复杂核心逻辑集中在VRRP报文处理和健康检查执行路径上。3.1 健康检查脚本的循环分配valgrind报告里出现的函数名是健康检查模块里的tcp_check_thread或类似逻辑。简单来说Keepalived每秒钟会执行一次健康检查如果配置的是MISC_CHECK方式调用外部脚本它会通过fork、exec执行脚本并捕获输出。这个过程中每次检查都会为脚本输出缓冲区分配内存检查完成后应当释放。但问题在于源码里有一个很隐蔽的分支如果脚本执行超时或者被信号打断某些路径会直接返回跳过释放操作。日积月累每秒泄漏几十字节看似不多但一周下来就是几百MB。这解释了为什么重启后能缓解运行时间越长越明显。3.2 配置项里藏着“帮凶”还有一个值得注意的配置项是interval和weight。如果配置的健康检查间隔很短比如1秒那么泄漏频率会被无限放大。另外如果配置了多个检查脚本每个脚本都有自己的超时时间和重试次数这个泄漏是成倍叠加的。我当时仔细核对了keepalived.conf里的检查配置发现写了一个外部脚本脚本内部用while循环不断输出一些调试信息而keepalived会把这些输出持续读到内存缓冲区里进一步加剧了内存增长。3.3 用gdb attach看实时调用栈测试环境确认了泄漏来源生产环境就得判断是否同一个问题。我用gdb直接attach到keepalived子进程抓取当前线程的调用栈gdb -p 12345 (gdb) thread apply all bt如果栈上能看到健康检查相关函数的帧基本可以判断线上和测试环境是同一路径。不过在生产环境用gdb会有短暂阻塞风险我一般只在业务低峰期操作而且限定几条命令抓完立刻退出。按照这个流程线上进程的栈帧也确实出现在健康检查模块中。4. 修复方案从临时止血到彻底解决定位到问题之后解决手段可以分为三个层次。第一层次是临时抢救生产环境第二层次是调整配置规避触发条件第三层次是补丁或者升级版本。4.1 临时重启与监控脚本既然是常驻进程内存持续增长最直接的办法就是定时重启。我当时写了一个简单的crontab任务每天凌晨4点重启一次keepalived0 4 * * * /usr/sbin/service keepalived restart这个方案能保证内存不会涨到触发OOM但治标不治本。重启期间虽然VRRP切换很快但如果恰好有健康检查任务在执行可能会有一次检查超时对后端业务产生短暂影响。所以这只能作为紧急预案不能长期依赖。4.2 调整配置降低泄漏频率配置层面能做的优化是增大健康检查间隔和超时时间或者把MISC_CHECK换成TCP_CHECK、HTTP_CHECK这类内置检查避免依赖外部脚本。前置条件是我们后端服务支持TCP探测或HTTP探测。比如原来用外部脚本检查一个服务端口现在直接改用TCP_CHECKvirtual_server 10.0.0.1 80 { delay_loop 10 lb_algo wrr lb_kind DR protocol TCP real_server 192.168.1.10 80 { weight 1 TCP_CHECK { connect_timeout 5 nb_get_retry 3 } } }这样从根源上避开了外部脚本执行路径内存分配自然就少了。不过对于某些复杂业务健康检查逻辑无法用简单的TCP端口探测替代那就只能从代码层面修复。4.3 升级keepalived版本我查了一下官方更新记录后面几个小版本确实修复过内存泄漏相关问题。如果线上版本太旧最稳妥的做法是升级到修复后的稳定版并在测试环境用valgrind跑同样的场景验证通过后再灰度上线。升级前记得备份配置查看版本迁移说明因为有的参数在旧版本里默认值与新版本不同可能导致行为变化。5. 预防与监控别再等内存报警才动手这个问题排查清楚之后我又花时间完善了监控体系确保下次问题出现的第一时间就能定位而不是等内存快打满才收到告警。5.1 借助Prometheus监控专用指标在机器上部署一个简单的node_exporter暴露进程内存指标然后通过Prometheus和Grafana配置一条持续上涨的告警规则。关键指标是process_resident_memory_bytes。规则可以写成取最近6小时的曲线如果标准差很小但平均值明显上行且增长率超过某个阈值就触发告警。process_resident_memory_bytes{jobnode} - process_resident_memory_bytes{jobnode} offset 1h 50MB这里的offset 1h比较的是一个小时前的数值如果增量超过50MB说明有可能在泄漏。配合pagerduty或企业微信机器人推送给值班同学比单纯等内存吃紧再人工发现强得多。5.2 引入的系统层面OOM保护泄漏再严重最终可能触发Linux OOM killer。为避免影响同机的业务进程我给keepalived进程设置了/proc/pid/oom_score_adj提高它的优先级让内核在内存紧张时优先杀掉它而不是杀数据库进程。只需要运行echo -100 /proc/$(pidof keepalived)/oom_score_adj不过这个值在进程重启后失效需要配合/etc/sysctl.conf里的参数或者写一个systemd unit覆盖文件在服务启动后自动调整。这个措施不能阻止内存泄漏但能防止牵连核心业务。5.3 在CI/CD流水线中加入内存检测如果keepalived是从源码编译的建议在CI阶段就加入静态分析和内存泄漏检测。常用的静态工具比如cppcheck能发现部分可疑的内存路径但不一定能找到运行时泄漏。更靠谱的方式是在测试环境定期用valgrind跑集成测试用例把泄漏检测结果作为一个发布门禁。很多运维团队只关注功能通过率忽略了长期运行稳定性而内存泄漏恰恰是这类常驻服务的大敌。6. 常见问题与排查技巧在排查过程中我也踩了一些坑把高频误区整理成一张表方便大家参考。常见误区正确做法原因只看top瞬时内存持续采样并记录日志瞬时值受业务波动影响大认为重启就万事大吉持续监控并分析趋势重启只治标不治本第一时间上valgrind先确认是哪个进程、哪个线程gdb/valgrind不宜直接用于生产遗漏配置变更记录排查前对比历史配置差异配置改动可能诱导泄漏触发忽略日志中的错误输出排查时同步check log超时或信号中断才会走泄漏分支还有一个细节就是排查时一定要留意环境差异。同样一套keepalived配置文件在旧内核和旧glibc上可能没事但换到新内核上内存分配器的行为变化就会暴露泄漏路径。所以线上出了内存问题不要只盯着应用层系统库版本也要纳入排查范围。6.1 生产环境禁止直接跑valgrindvalgrind的memcheck对程序运行速度影响极大会让服务延迟翻倍甚至更多。在线上直接attach轻则导致健康检查超时重则让VRRP协议报文处理错过时间窗直接触发主备切换。我建议无论如何都要先在测试环境复现如果实在复现不了可以尝试用gdb的malloc断点配合make malloc钩子来观测但操作难度更高适用场景也有限。6.2 抓取heap profile更高效一种比valgrind更轻量的方式是使用gperftools的heap profiler。给keepalived加上环境变量让它启动时启用tcmalloc的heap profile定期输出堆栈统计。这样即使线上跑性能影响也可控并能看到内存是由哪个调用栈分配的。我在后续复现中就是用这种方式确认了realloc之后未free的路径。具体操作env HEAPPROFILE/tmp/keepalived.hprof LD_PRELOAD/usr/lib/libtcmalloc.so.4 ./keepalived -f /etc/keepalived/keepalived.conf运行一段时间后用pprof分析输出文件能直接看到内存分配热点函数比单纯靠猜高效得多。6.3 strace也能辅助看内存操作用strace跟踪brk、mmap等系统调用能观察进程堆扩展的情况。如果mmap的系统调用频繁增加且没有对应的munmap释放那就是内核态层面的内存映射泄漏。虽然keepalived是C进程内存分配主要还是走glibc的malloc但检查一下总没坏处。我当时用strace -e tracemmap,munmap,brk -p pid捕获了十几秒的记录发现mmap次数和munmap次数有明显差距进一步佐证了堆内存无法归还。7. 全程复盘定位思路与关键启示这个问题花了三天才彻底解决主要原因是前期过于依赖直觉没有把系统化的排查流程走完整。第一天只看了top和重启了进程第二天才意识到需要做pmap采样和valgrind测试第三天白天在源码里翻找晚上才把补丁验证完。如果一开始就按“进程确认 → 内存映像分析 → 堆分配跟踪 → 源码定位”的顺序走可能半天就能锁定方向。提示排查内存泄漏时关键不是记住某个命令而是理解每个工具能回答什么样的“问题”。top回答“谁占内存多”pmap回答“哪块区域在增长”valgrind回答“具体哪次分配没有释放”。一层层问下去顺序错了就会绕路。另外在代码层面修复泄漏不只是简单加一个free就行。如果分配的内存被插入到某个链表或者缓存池里直接释放可能导致野指针。正确的修复方式是保留引用计数或延迟释放策略。我阅读了keepalived源码后发现这个问题本质上是没有处理脚本执行线程被取消时清理临时缓冲区的逻辑所以补丁的落点是在线程取消前的cleanup函数里释放相关资源。我也顺便看了看其他常见的高可用组件比如vrrpd、nginx等发现类似的健康检查类功能都有可能出现这类问题。凡是需要定期执行外部命令或动态构造请求的模块都应该对每次循环分配的内存敲响警钟。在编码规范上我后来要求团队在写这类逻辑时一律用“资源获取即初始化”的封装方式把分配和释放放在同一个作用域避免散落多个分支。8. 个人经验这类问题后续还能怎么扩展排查完keepalived的内存泄漏我又试着把同样的思路套用到了其他常驻进程上。比如某跨平台系统里的agent进程、日志采集组件等都曾经出现过类似的内存缓慢增长问题。流程完全一致先确认进程 → 观察内存增长周期 → 在测试环境复现 → 用工具定位分配点 → 修复验证。唯一要留意的是不同编程语言写出来的进程定位工具不同。C/C用valgrind、gdb、gperftoolsJava进程则用jstat、jmap、Eclipse MATGo进程用pprof。思路相通但工具链要跟着语言切换。另外如果公司内部有全链路监控体系建议把进程粒度内存监控做成默认项而不是等出了事故才临时补。像keepalived这种守护进程平时内存占用可能很低一旦开始泄漏就会像温水煮青蛙一样把你的系统资源慢慢耗尽。只有持续监控并且让告警规则具备趋势判断能力才能在真正出事之前提前介入。这次定位过程也让我养成了一个习惯排查任何慢速问题时先做“基准记录”。无论是内存还是CPU还是网络延迟没有基线就无法对比变化严重性。后来我在处理其他项目时也会在服务上线后第一天记录一段正常运行的指标快照存到运维数据库里事后排查时会省掉大量猜疑时间。希望这篇基于实战经验的记录能帮你少走一点弯路。如果你们线上也遇到keepalived内存持续上涨的情况不妨从健康检查脚本和VRRP报文处理这两个方向去查尤其是在配置了较多外部脚本检查的场景下概率很高。还有记得先想办法在测试环境把问题复现出来复现成功就等于解决了一半。
阅读完成 · 觉得有帮助?
咨询建站