10月12, 2022

bpftrace 实战 - 排查内存泄漏

内存泄漏是 C 开发非常经典的问题,目前已经存在很多优秀的内存检测工具,比如强大的 Valgrind。

借助 ebpftrace 我们可以非常简单、直观的找到泄漏的位置,而且非常灵活,自己决定其中的细节。

文中我构建了一个 leak.c,其中同时存在正确释放的内存和泄漏的内存,模拟一些比较复杂的内存泄漏情况。通过 ebpftrace 对运行中的 leak 程序统计内存的申请和释放过程定位内存泄漏,也就是 probe 两个关键的接口调用:

  • malloc
  • free

这两个接口的实现在 libc 中,后面我们会找到 leak 连接的准确 libc.so 文件。

leak.c

#include <stdio.h>
#include <stdlib.h>
#include <curses.h>

char * wrapper1(){
    return malloc(8);
}

char * wrapper2(){
    // leak here
    char * p = wrapper1();
    return malloc(16);
}

int main(){
    char * p = NULL;
    for(int i = 0; i < 5; i ++){
        p = wrapper2();
        free(p);
    }

      /* wait */
    getchar();
    return 0;
}

代码非常简单,wrapper2 内调用 wrapper1 申请到的内存没有被返回,当然永远也不会被释放,发生了泄漏;而 wrapper2 自己 malloc 一块内存返回,并在 main 中被正确释放。

  • wrapper1 中申请的内存泄漏了;
  • wrapper2 中申请的内存没有泄漏;

只需要简单的编译即可使用:

gcc leak.c -o leak -g

我的工作路径是: /home/test

探测 leak 中的 malloc 调用

bpftrace 主要的 probe 类型:

  • kprobe/kretprobe,内核探针
  • uprobe/uretprobe,用户态探针

本文的目标是 leak 程序,一个用户态进程,显然应当使用 用户态探针:uprobe/uretprobe。

首先通过 uprobe 探测 malloc 调用。

bpftrace -e 'uprobe:/home/test/leak:malloc {printf("malloc call\n")}'

参数格式是 uprobe:可执行文件:函数名,看起来一切都没有问题,但是运行上面的指令会得到这样的输出:

No probes to attach

原因是在 /home/test/leak 中找不到符号 malloc,我们可以自己确认这一事实:

# nm leak | grep malloc
  U malloc@@GLIBC_2.2.5

可以看到 malloc 是一个链接自 GLIBC_2.2.5 的符号,并不是 leak 自身的符号,因此参数中可执行文件名应当改为 GLIBC_2.2.5 对应的 so 文件 (Linux 系统的动态链接库),具体方法稍后介绍。

其实,前面的指令只要修改需要探测的函数名为 leak.c 中定义的函数,也可以正常工作,比如:

# bpftrace -e 'uprobe:/home/test/leak:wrapper1 {printf("wrapper call\n")}'
Attaching 1 probe...
wrapper call
.... 省略一些输出
wrapper call

只不过探测的是 leak 自身的函数:wrapper1,并不是我们关注的目标。

所以,uprobe 需要指定准确的可执行文件,和该文件中的一个符号(函数)。

需要注意 printf 中的换行符 \n,如果没有换行符,数据只会在缓存中,只能在 bpftrace 退出时打印出结果,如果希望实时看到输出,就不要忘记它。

让我们继续完成 malloc 的探测,系统中可能存在多个 GLIBC 库,我们必须找到 leak 程序连接的准确文件:

# ldd leak
linux-vdso.so.1 (0x00007ffc0afaa000)
libc.so.6 => /lib64/libc.so.6 (0x00007f55ea078000)
/lib64/ld-linux-x86-64.so.2 (0x00007f55ea43d000)

第二行即 leak 链接的 libc 动态库文件,将前面 bpftrace 参数中 “可执行文件” 替换为 /lib64/libc.so.6,再次尝试:

bpftrace -e 'uprobe:/lib64/libc.so.6:malloc {printf("malloc call\n")}'

屏幕立刻输出了大量内容,但 leak 还没有开始运行啊?!

其实这些输出是系统中其他进程调用 malloc 引起的,动态库在系统中被所有进程共享,显然我们只关心 leak 程序的 malloc 调用,其他的应当被忽略。

我们需要在此参数基础上,增加 filter,过滤掉无关进程引起的函数调用事件。

bpftrace 提供了系统变量 comm 表示可执行文件名 (进程名),只需要在上述指令中增加 filter,只处理 comm =="leak"malloc 调用事件:

# bpftrace -e 'uprobe:/lib64/libc.so.6:malloc /comm == "leak"/{printf("malloc call\n")}'
Attaching 1 probe...

bpftrace 提示 Attaching 1 probe...,成功了,只有 leak 运行时才会触发输出。

不要关闭 bpftrace 所在终端,在另外一个终端运行 leak,在 bpftrace 所在终端会看到这样的输出:

# bpftrace -e 'uprobe:/lib64/libc.so.6:malloc /comm == "leak"/{printf("malloc call\n")}'
Attaching 1 probe...
malloc call
.... 省略一些输出
malloc call
malloc call

成功探测到 leakmalloc 调用!

目前参数已经变得很长了,最好将它们放在一个脚本文件 trace.bt 中,便于编辑。

uprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
  printf("malloc call\n");
}

再次运行它:

# bpftrace trace.bt

然后运行 leak 程序,会得到相同的结果。

探测 malloc 的 size

bpftrace 的 uprobe 和 kprobe 可以通过内置变量 arg0arg1 ··· ··· 访问函数参数,对 trace.bt 稍作修改就可以打印 malloc 的参数:

uprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
  printf("malloc(%d)\n", arg0);
}

malloc 的原型:

void *malloc(size_t size);

第一个参数是整数,直接按 C 语言风格输出就可以。

运行新的 trace.bt:

# bpftrace trace.bt 
Attaching 1 probe...
malloc(8)
malloc(8)
malloc(16)
.... 省略一些输出
malloc(8)
malloc(16)
malloc(1024)

可以看到 malloc 调用和对应的参数,最后一个 malloc(1024)leak 自动创建输出缓冲区内存申请,其他的 malloc 都是 wrapper1wrapper2 中的内存申请,参数与 leak.c 源码一致。

探测 malloc 的返回值

我们更关心的是 malloc 返回的内存地址,需借助 uretprobe 进行探测,函数返回值可通过内置变量 retval 访问。uretprobe 的 filter 与 malloc 参数探测时类似:

uprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
  printf("malloc(%d)\n", arg0);
}

uretprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
  printf("addr = %p\n", retval);
}

再次运行 bpftrace:

# bpftrace trace.bt 
Attaching 2 probes...
malloc(8)
malloc(8)
addr = 0xb892a0
addr = 0xb892a0
malloc(16)
.... 省略一些输出
malloc(16)
addr = 0xb89340
malloc(1024)
addr = 0xb89360

此时 trace.bt 中定义了 2 个 probe,分别用于探测 malloc 申请的内存大小和返回的地址。

探测 free 的地址

目前已经可以准确捕获 leak 申请内存的大小和返回地址,下一步探测 free 了哪些地址,只需要对比 mallocfree 内存地址集合的差异,就能找到内存泄漏。

参考探测 malloc 的方法,探测 free 调用非常简单,free 的内存地址正好是其第一个参数,我们使用 uprobe 即可,下面继续扩展 trace.bt:

uprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
  printf("malloc(%d)\n", arg0);
}

uretprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
  printf("alloc addr = %p\n", retval);
}

/* 探测 free 的地址 */
uprobe:/lib64/libc.so.6:free /comm == "leak"/{
  printf("free addr = %p\n", arg0);
}

运行 trace.bt:

# bpftrace trace.bt 
Attaching 3 probes...
malloc(8)
malloc(8)
alloc addr = 0x19e72a0
alloc addr = 0x19e72a0
malloc(16)
.... 省略一些输出
alloc addr = 0x19e7340
free addr = 0x19e7340
malloc(1024)
alloc addr = 0x19e7360

探测泄漏地址

现在已经获得了:

  • malloc 的大小、地址;
  • free 的地址;

只需计算二者地址集合的差,就可以得到泄漏的地址有哪些了。

bpftrace 底层使用的是 eBPF 的 map 作为存储结构,可以简单的看作 K-V 存储,比如:

# bpftrace -e 'BEGIN{@kv["hello"] = 1; @kv["world"] = 2 } END{delete(@kv["world"]); return;}'
Attaching 2 probes...
^C

@kv[hello]: 1

BEGIN 中 在 @kv 新增了 “hello”->1, 'world' ->2 两组值,END 通过 delete 方法移除了 @kv 中的 ‘world’->2。bpftrace 结束时自动输出 @kv, 其中只剩下 “hello”->1 一组值。

这里注意 3 点:

  1. @kv 在多个 probe 之间共享;
  2. @kv[KEY]赋值 VALUE 即创建对应对 KEY-VALUE;
  3. 调用 delete(@kv[KEY]) 函数删除 KEY-VALUE;

那么可以用一个 map @mem 保存 malloc 返回的内存地址,当发生 free 调用时,从 @mem 中删除释放的地址,最后 @mem 中剩余的就是泄漏的内存地址。

uprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
  printf("malloc(%d)\n", arg0);
  @size = arg0;
}

uretprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
  printf("alloc addr = %p\n", retval);
  /* 
  @size 在第一个 probe 被赋值, 内存大小。
  @mem 中记录的是 内存地址 -> 内存大小
  */
  @mem[retval] = @size;
}

uprobe:/lib64/libc.so.6:free /comm == "leak"/{
  printf("free addr = %p\n", arg0);
  /* arg0 是 free 的内存地址,删除被 free 的地址记录 */
  delete(@mem[arg0])
}

可以看到这样的结果:

# bpftrace trace.bt 
Attaching 3 probes...
malloc(8)
.... 省略一些输出
alloc addr = 0x225b360
^C

@mem[36025120]: 8
@mem[36025056]: 8
@mem[36024992]: 8
@mem[36025024]: 8
@mem[36025088]: 8
@mem[36025184]: 1024

@size: 1024

可以看到 @mem 中记录了一些内存地址和它们的大小,这些大小都是 8,对比 leak.c 代码:

char * wrapper1(){
    return malloc(8);
}

char * wrapper2(){
    char * p = wrapper1();
    return malloc(16);
}

可知 wrapper1 中的 malloc 发生了泄漏,与预期一致。

准确定位内存泄漏

上一部分已经可以得到有价值结论:发生了内存,并且根据内存大小确定了发生泄漏的位置。

但更普遍的情况下,通过内存地址或内存大小,无法确认泄漏的位置,更可靠的方法是获得内存泄漏时的调用栈。

bpftrace 的内置函数 ustack 可以获得用户态程序当前调用栈,并且可以接受参数指定获取的栈深度,默认为获得最大深度栈。

前文 @mem 中记录的是内存地址->内存大小,我们将其修改为记录内存地址->栈:

uprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
  printf("malloc(%d)\n", arg0);
  @size = arg0;
}

uretprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
  printf("alloc addr = %p\n", retval);
  /* 修改了这里 */
  // @mem[retval] = @size;
  @mem[retval] = ustack();
}

uprobe:/lib64/libc.so.6:free /comm == "leak"/{
  printf("free addr = %p\n", arg0);
  delete(@mem[arg0])
}

运行结果:

# bpftrace trace.bt 
Attaching 3 probes...
malloc(8)
.... 省略一些输出
alloc addr = 0x206a360
^C

@mem[33989280]: 
        wrapper1+14
        wrapper2+18
        main+35
        __libc_start_main+243
        0x5541f689495641d7
.... 省略一些输出
@mem[33989408]: 
        wrapper1+14
        wrapper2+18
        main+35
        __libc_start_main+243
        0x5541f689495641d7
@mem[33989472]: 
        _IO_file_doallocate+144

@size: 1024

可以看到泄漏的内存都是在 wrapper1+14 申请,已经成功定位内存泄漏的位置。

本文链接:http://www.thinkinpython.com/post/bpftrace_example_mem_leak_detect.html

-- EOF --