
经常发生的故事始于服务器上的一项服务崩溃。 更准确地说,该进程通过监视多余的内存使用情况而被杀死。 库存应该是多个,这意味着我们有内存泄漏。
有一个完整的内存转储,其中包含调试信息,有日志,但是无法复制。 泄漏异常缓慢,还是情况取决于火星的天气。 简而言之,另一个错误不是通过测试复制的,而是在野外发现的。 唯一真正的线索仍然是-内存转储。
主意
原始服务是用C ++和Perl编写的,尽管它没有特殊作用。 下文所述的所有内容几乎适用于所有语言。
从问题陈述开始,我们的过程是装入几百兆RAM,并完成了超过6 GB的内存。 因此,大多数进程内存是泄漏的对象及其数据。 只需找出内存中最多的对象类型。 当然,转储中没有包含类型信息的对象列表。 像垃圾收集器一样,跟踪关系并建立图表实际上是不可能的。 但是我们不必了解此二进制哈希,而只需计算出更多的对象。 非平凡类的对象具有指向虚拟方法表的指针,并且同一类的所有对象都具有相同的指针。 在内存中找到指向vtbl类的指针的次数-已经创建了该类的许多对象。
除了vtbl,还有其他频繁发生的序列:初始化字段的常量,行片段中的HTTP标头,函数指针。
如果您足够幸运地找到了一个指针,那么我们可以使用gdb来了解它指向的内容(当然,除非有调试字符)。 对于数据,您可以尝试查看它们并了解在何处使用它。 展望未来,我注意到这同时发生,并且通过一行的片段,很可能了解协议的这一部分是什么,以及在哪里需要进一步挖掘。
这个想法被窥探到了,第一个实现被从stackoverflow中复制出来了。 https://stackoverflow.com/questions/7439170/is-there-a-way-to-find-leaked-memory-using-a-core-file
hexdump core.10639 | awk '{printf "%s%s%s%s\n%s%s%s%s\n", $5,$4,$3,$2,$9,$8,$7,$6}' | sort | uniq -c | sort -nr | head
该脚本在我们的转储中工作了大约15分钟,返回了很多行,...什么都没有。 没有一个指针,没有任何用处。
整理出来
Stackoverflow驱动的开发有其缺点。 您不能只复制脚本并希望一切都能正常进行。 在此特定脚本中,某种形式的字节重排立即引起注意。 还会产生一个问题,为什么要进行4排列。您不必是超级专家,就可以了解这种排列取决于平台:位和字节顺序。
要确切了解外观,您需要了解内存转储的文件格式,LITTLE和BIG-endian,或者您可以简单地以不同的方式重新排列找到的片段中的字节并提供gdb。 哦,奇迹! gdb字节以直接顺序看到该字符,并说这是一个指向函数的指针!
在我们的例子中,它是一个指向openssl缓冲区中的读写功能之一的指针。 为了自定义输入和输出,使用了OOP系统方法-一种具有一组指向函数的指针的结构,这是一种接口,或者说是vtbl。 这些带有指针的结构竟然如此之多。 仔细研究负责设置这些结构和创建缓冲区的代码,可以使我们快速找到错误。 事实证明,在C ++和C的交界处没有RAII对象,并且在发生错误的情况下,提早返回并没有释放资源的机会。 没有人猜测要及时向服务加载错误的ssl握手,因此他们错过了它。 如何拨出6 GB的不正确的ssl握手也很有趣,但是正如他们所说,这是一个完全不同的故事。 问题已解决。
竭尽全力
该脚本原来是有用的,但仍存在频繁使用的严重缺陷:它非常慢,依赖于平台,后来证明转储文件也具有不同的偏移量,很难解释结果。 使用bash挖掘二进制转储的任务不太适合,因此我将编程语言更改为D。语言的选择实际上是由于用您喜欢的语言编写代码的自私愿望。 好吧,选择的合理性在于:速度和内存消耗至关重要,因此您需要一种本机编译语言,并且比C或C ++更快地编写D是平庸的。 在代码的后面,它将清晰可见。 如此艰巨的项目诞生了。
安装方式
没有二进制程序集,因此您将需要一种或多种方式从源代码汇编项目。 为此,您需要D编译器,共有三个选项:dmd是参考编译器,ldc基于llvm和gdc,包含在gcc中,从版本9开始。 因此,如果您具有最新的gcc,则可能无需安装任何软件。 如果安装,那么我建议使用ldc,因为它可以更好地优化。 这三者都可以在官方网站上找到。
dub软件包管理器随编译器一起提供。 使用它,topleaked使用一个命令安装:
dub fetch topleaked
将来,我们将使用以下命令启动:
dub run topleaked -brelease-nobounds -- <filename> [<options>...]
为了不重复dub run和brelease-nobounds编译器参数,您可以从github下载源代码并收集可执行文件:
dub build -brelease-nobounds
在项目文件夹的根目录中将显示为已耗尽。
使用方法
让我们看一个带有内存泄漏的简单C ++程序。
#include <iostream> #include <assert.h> #include <unistd.h> class A { size_t val = 12345678910; virtual ~A(){} }; int main() { for (size_t i =0; i < 1000000; i++) { new A(); } std::cout << getpid() << std::endl; sleep(200); }
我们通过kill -6完成它,然后得到一个内存转储。 现在您可以运行并查看结果
./toleaked -n10 leak.core
-n选项是我们需要的顶部大小。 通常,取10到200之间的值才有意义,这取决于有多少“垃圾”。 默认输出格式是易于理解的逐行顶部。
0x0000000000000000 : 1050347 0x0000000000000021 : 1000003 0x00000002dfdc1c3e : 1000000 0x0000558087922d90 : 1000000 0x0000000000000002 : 198 0x0000000000000001 : 180 0x00007f4247c6a000 : 164 0x0000000000000008 : 160 0x00007f4247c5c438 : 153 0xffffffffffffffff : 141
它没有什么用,只是我们可以看到数字0x2dfdc1c3e,它也是12345678910,发生了100万次。 这已经足够了,但是我想要更多。 为了查看泄漏对象的类名称,您可以通过使用打开的转储文件简单地将标准输出流重定向到gdb输入,将结果发送到gdb。 -ogdb-将格式更改为可理解的gdb的选项。
$ ./topleaked -n10 -ogdb /home/core/leak.1002.core | gdb leak /home/core/leak.1002.core ...< gdb > #0 0x00007f424784e6f4 in __GI___nanosleep (requested_time=requested_time@entry=0x7ffcfffedb50, remaining=remaining@entry=0x7ffcfffedb50) at ../sysdeps/unix/sysv/linux/nanosleep.c:28 28 ../sysdeps/unix/sysv/linux/nanosleep.c: No such file or directory. (gdb) $1 = 1050347 (gdb) 0x0: Cannot access memory at address 0x0 (gdb) No symbol matches 0x0000000000000000. (gdb) $2 = 1000003 (gdb) 0x21: Cannot access memory at address 0x21 (gdb) No symbol matches 0x0000000000000021. (gdb) $3 = 1000000 (gdb) 0x2dfdc1c3e: Cannot access memory at address 0x2dfdc1c3e (gdb) No symbol matches 0x00000002dfdc1c3e. (gdb) $4 = 1000000 (gdb) 0x558087922d90 <_ZTV1A+16>: 0x87721bfa (gdb) vtable for A + 16 in section .data.rel.ro of /home/g.smorkalov/dlang/topleaked/leak (gdb) $5 = 198 (gdb) 0x2: Cannot access memory at address 0x2 (gdb) No symbol matches 0x0000000000000002. (gdb) $6 = 180 (gdb) 0x1: Cannot access memory at address 0x1 (gdb) No symbol matches 0x0000000000000001. (gdb) $7 = 164 (gdb) 0x7f4247c6a000: 0x47ae6000 (gdb) No symbol matches 0x00007f4247c6a000. (gdb) $8 = 160 (gdb) 0x8: Cannot access memory at address 0x8 (gdb) No symbol matches 0x0000000000000008. (gdb) $9 = 153 (gdb) 0x7f4247c5c438 <_ZTVN10__cxxabiv120__si_class_type_infoE+16>: 0x47b79660 (gdb) vtable for __cxxabiv1::__si_class_type_info + 16 in section .data.rel.ro of /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (gdb) $10 = 141 (gdb) 0xffffffffffffffff: Cannot access memory at address 0xffffffffffffffff (gdb) No symbol matches 0xffffffffffffffff. (gdb) quit
阅读不是很简单,但是有可能。 $ 4 = 1,000,000格式的行反映了顶部的位置和找到的出现次数。 以下是对该值运行x和info符号的结果。 在这里,我们可以看到A的vtable发生了100万次,这对应于A类的100万个泄漏对象。
为了分析文件的一部分(如果文件太大),添加了offset和limit选项-从何处以及要读取的字节数开始。
结果
生成的实用程序比脚本快得多。 您仍然必须等待,但不必以加息的程度,而是在屏幕顶部出现几秒钟之前。 我绝对确定可以大大改进算法,并且可以显着优化繁重的输入和输出操作。 但这是未来发展的问题,现在一切正常。
多亏了-ogdb选项和gdb中的重定向功能,如果我们很幸运能够使用该函数,我们可以立即获得名称和值,有时甚至可以获得行号。
正面解决方案的明显但非常出乎意料的结果是跨平台。 是的,topleaked不知道字节顺序,但是由于它不解析文件格式,而只是逐字节读取文件,因此可以在Windows或具有任何内存转储格式的任何系统上使用。 仅要求数据在文件内对齐。
D语言
我想单独说明在D中开发此类程序的经验。第一个工作版本是在几分钟内编写的。 我必须说,到目前为止,主要算法仅占用三行:
auto all = input.sort; ValCount[] res = new ValCount[min(all.length, maxSize)]; return all.group.map!((p) => ValCount(p[0],p[1])) .topNCopy!"a.count>b.count"(res, Yes.sortOutput);
这一切都归功于惰性范围以及标准库中诸如group和topN之类的现成算法的存在。
后来,解析命令行参数,输出格式以及冗长但又写得很快的所有内容都排在最前面。 除非文件的读取结果证明是某种奇怪的东西,否则与一般样式无关。
在当前的最新版本中,出现了--find标志,用于通常的子字符串搜索,而该子字符串根本与频率无关。 由于这个琐事,代码的大小明显增加,但是很有可能会删除该功能,并且代码将返回其原始的简单状态。
总的来说,人工成本与脚本语言相当,并且性能要好得多。 由于C和D中相同的代码将以相同的速度工作,因此有可能将其发挥到最大。
使用适应症和禁忌症
- 当仅存在当前进程的内存转储时,需要使用Topleaked来搜索泄漏,但是无法在清理程序下重现它。
- 这不是另一种说法,也不声称是动态分析。
- 前面提到的一个有趣的例外可能是暂时的泄漏。 即,释放内存,但是为时已晚(例如,当服务器停止时)。 然后,您可以在适当的时候删除转储并进行分析。 在流程结束时工作的Valgrind或asan会使此情况变得更糟。
- 仅64位模式。 其他位和字节顺序的支持将在将来推迟。
已知问题
在测试过程中,使用了转储文件,这些文件是通过向进程发送信号来接收的。 有了这样的文件,一切都很好。 删除转储后,gcore命令将写入其他一些ELF标头,并且会出现不确定数量的字节偏移。 即,指针的值在文件中未与8对齐,因此获得了毫无意义的结果。 对于此解决方案,引入了offset选项-不是先读取文件,而是读取偏移字节(通常为4)。
为了解决这个问题,我计划添加从stdin读取objdump -s的结果。 好吧,要么连接libelf并自己解析它,但是它将杀死“跨平台”,并且stdout更加灵活并且更接近于Unix方式。
参考文献
Github项目
编译器D
关于stackoverflow的原始问题