使用A-Ops内存火焰图定位内存泄漏问题

之前在这篇帖子里介绍了A-Ops火焰图的特性和安装等,这里我们继续介绍下在实际应用中使用A-Ops火焰图定位问题的案例。

问题现象:

测试l7probe的redis解析功能的过程中,发现l7probe进程占用的内存以肉眼可见的速度持续快速增长:

明显发生了内存泄漏的问题。接下来我们要定位下代码哪里导致了内存泄漏。

工具选择:

我首先尝试使用常见的C语言内存泄漏工具进行定位,但是均未成功:生成asan包执行时并未生成内存泄漏报告;使用valgrind命令启动l7probe时,因为包含bpf程序不能启动成功;使用bcc中的memleak工具,工具启动时报错uprobe没有正常加载。

于是我决定使用A-Ops的可观测组件gala-gopher中的火焰图工具stackprobe实时生成内存火焰图进行定位。

定位过程:

启动gala-gopher后启动stackprobe探针,配置被观测进程pid,并配置pyroscope地址方便查看火焰图:

curl -X PUT http://localhost:9999/flamegraph -d json=‘{ “cmd”: {“probe”: [“mem_glibc”] }, “snoopers”: {“proc_id”:[1605758] }, “params”: { “perf_sample_period”: 50, “svg_period”: 300, “multi_instance”: 1, “native_stack”: 0, “pyroscope_server”: “10.137.17.122:4040”}, “state”: “running”}’

查看内存火焰图如下:

此火焰图的原理是将uprobe ebpf程序挂载在glibc的内存申请和释放的函数(例如malloc, free等)上,当被观测应用调用了这些函数时,ebpf程序会采集应用当前的调用栈以及申请和释放的内存大小,用于最终形成可视化的火焰图。所以内存申请越多的函数的火焰会越宽。但是这个图还不足以判断哪个函数在持续申请内存且没有释放,于是我们使用diff视图和comparison视图来查看前后两段时间的内存火焰图(下图):

由diff视图(上图)可见,l7_parser为起始的内存火焰图占比随着时间在增长(红色),应当为重点排查目标。

由comparision视图(上图)可进一步确认上述结论。l7_load_tcp_fd只是在程序初始化时申请了内存,后续则不再申请。tracker_msg虽然也在持续申请,但是占比是在逐渐减少的。只有l7_parser的子函数在持续申请内存。另外,strdup函数是单独成列,可能由于函数内联等原因没有抓到其调用栈,也是需要排查的。

可以点开l7_parser进一步看它的调用栈(下图):l7_parser函数本身应该是没有申请内存的,因为它的子函数data_stream_parse_frames和l7_parser的火焰宽度相等,以此类推,一直到redis_parse_frame及以下才形成火焰宽度差异,右侧的proto_match_frames栈也是同理。因此只需要重点排查红色框起来的函数的内存申请操作。

至此,我们依据上述火焰图结果形成代码排查思路:优先排查redis_parse_frame及其子函数的内存申请是否没有被释放,另外重点检查下l7probe中的strdup调用。

*注意:gcc编译的时候如果加上-fno-omit-frame-pointer和-O0参数,最终的调用栈会更加完整,否则调用栈会比较简化

修改验证:

按照上述思路进行代码检视,果然最终发现redis_parse_frame->parse_msg_recursive中有几处malloc/strdup以后,在外面的异常分支没有释放掉。修改代码以后再启动l7probe,果然内存稳定不增长:

再查看内存火焰图,只有l7probe刚启动/新建打流的短暂时间范围内有内存申请,后续内存申请量降低,直至为空:

参考:
stackprobe源码: gala-gopher: A low-overhead eBPF-based probes framework - Gitee.com

2 Likes