使用cgo绑定的Go应用程序时内存泄漏的解决办法

1,898 阅读5分钟

在这篇文章中,我想分享一个故事,我的团队是如何在一个通过cgo使用泄漏的C扩展的Go应用中找到并修复内存泄漏的。

通常情况下,发现Go应用中的内存泄漏是相当简单的,这要归功于Go内置的剖析工具。只需通过最简单的设置步骤,go tool pprof ,就可以显示所有最近的分配和内存堆的概况。我们的案例变得更加有趣。

在工作中,我们有一个用Go编写的内部服务发现程序,并由Zookeeper支持。Zookeeper作为一个分布式的配置存储是非常好的,但是它的协议相当复杂,所以我们在它上面有一个Go的REST API包装器,使它很容易被其他应用程序所使用。这个服务发现工具回答了诸如 "一个商店住在哪个数据中心和地区?"或 "我们应该把一个新的商店送到哪个地区?"或 "什么是坏公民的IP列表?"的问题。

我们一直看到的问题是,应用程序所消耗的内存一直在意外地快速增长。它开机后会占用大约50Mb的RSS,然后在几个小时内增长到>500Mb,直到它被容器的OOM设置杀死。 image.png

这是一个典型的锯齿波内存泄漏情况。

我们可以让容器占用更多的内存,争取更多的时间,直到它被杀死,但这只能威胁到症状。我们真的想弄清楚它出了什么问题。

要做的第一件事是添加import _ "net/http/pprof" ,并附加到剖析端口。你甚至可以在Kubernetes中用kubectl port-forward !对一个生产容器这样做。

然而,附加到一个有数百兆字节的RSS的进程,你可以看到Go的堆只有不到数百兆。这意味着内存被Go的虚拟机以外的东西占用了。Go剖析器无法显示这些内存的任何情况。

那会是什么呢?我们使用gozk作为Zookeeper的客户端。gozk使用cgo来调用libzookeeper,这是Zookeper的一个C客户端。

通常情况下,当有C语言参与时,就会在某处出现内存泄漏。我在gozk的代码中看到了一堆的mallocs,不说在libzookeeper 中生成的C代码,那就更难懂了。

我最近很喜欢读Sam Saffron的Debugging hidden memory leaks in Ruby博文,他提到heaptrack可以找到C语言绑定中的泄漏。

我和我的同事Jared花了一些时间才让它在Debian容器中工作。在这个应用程序中,我们使用了两个阶段的Docker构建,使最终的容器非常轻。

我们的Docker文件是这样的。

FROM golang:1.12-buster AS buildstage
go build -o /bin/appname

FROM debian:buster-slim

COPY --from=buildstage /bin/appname /bin/appname
ENTRYPOINT ["/bin/appname"]

heaptrack需要实际的Go工具来检查这个过程,所以我们不得不取消两阶段构建的优化。

FROM golang:1.12-buster
go build -o /bin/appname
ENTRYPOINT ["/bin/appname"]

当我们最终能够运行heaptrack ,由于一些语法错误,它没有工作。

$ heaptrack --pid 12

heaptrack output will be written to "/go/heaptrack.magellan.578.gz"
injecting heaptrack into application via GDB, this might take some time...
warning: File "/usr/local/go/src/runtime/runtime-gdb.py" auto-loading has been declined by your `auto-load safe-path' set to "$debugdir:$datadir/auto-load".
A syntax error in expression, near `) __libc_dlopen_mode("/usr/lib/heaptrack/libheaptrack_inject.so", 0x80000000 | 0x002)'.
A syntax error in expression, near `) heaptrack_inject("/tmp/heaptrack_fifo578")'.
injection finished

我能够追踪到这些错误发生的地方,但在这一点上,我缺乏heapstack本身的知识来修复这些语法错误。我去寻找另一个工具。

我问我的朋友Javier Honduco,他用什么工具来寻找与高级语言(甚至可以是Ruby而不是Go)的应用程序绑定的C代码的漏洞。他提出了几个选择:

用具有查漏功能的jemalloc 替换malloc ,看起来是一个简单的选择,但我不确定这将如何与cgo 。我总是听说基于BPF的工具是多么强大,所以我决定尝试一下iovisor/bccmemleak.py

下面是我从memleak ,当我把它连接到我的PID时得到的结果。

$ memleak-bpfcc -p 3584581
Attaching to pid 3584581, Ctrl+C to quit.
[12:22:50] Top 10 stacks with outstanding allocations:
    0 bytes in 1228 allocations from stack
        [unknown] [libzookeeper_mt.so.2.0.0]
        [unknown]
    24 bytes in 1 allocations from stack
        [unknown] [libzookeeper_mt.so.2.0.0]
        [unknown]
    256 bytes in 2 allocations from stack
        [unknown] [libzookeeper_mt.so.2.0.0]

堆栈跟踪不是很有信息量,但我了解到,这肯定是与C部分和zookeeper有关的问题!

有人告诉我,我的构建一定是缺少调试符号,这就是为什么堆栈跟踪缺少确切的行。

为了确保所有的东西都包括调试符号,我不得不从上面重写我的Docker文件。

我没有从deb包中安装libzookeeper ,而是最终自己构建了它,以便能够将--enable-debug 标志传递给make

RUN curl -o zk.tar.gz https://archive.apache.org/dist/zookeeper/zookeeper-3.4.14/zookeeper-3.4.14.tar.gz && tar -xf zk.tar.gz && cd zookeeper-3.4.14/zookeeper-client/zookeeper-client-c && ./configure --enable-debug && make && make install && ldconfig

不得不提的是,即使安装了一些额外的软件包,我也无法在一个瘦小的容器上构建它。我把ubuntu:bionic 作为基础,自己安装了Go。

FROM ubuntu:bionic

RUN apt-get update && apt-get install -y curl build-essential

RUN curl -sSL https://storage.googleapis.com/golang/go1.12.5.linux-amd64.tar.gz \
  | tar -C /usr/local -xz
ENV PATH /usr/local/go/bin:$PATH
RUN mkdir -p /go/src /go/bin && chmod -R 777 /go
ENV GOROOT /usr/local/go
ENV GOPATH /go
ENV PATH /go/bin:$PATH
WORKDIR /go

# ZK
RUN curl -o zk.tar.gz https://archive.apache.org/dist/zookeeper/zookeeper-3.4.14/zookeeper-3.4.14.tar.gz && tar -xf zk.tar.gz && cd zookeeper-3.4.14/zookeeper-client/zookeeper-client-c && ./configure --enable-debug && make && make install && ldconfig

这花了我几个小时的时间,现在我能够抓取泄漏的全部痕迹。

# use interval of 30 seconds and prune any allocations newer than 5000ms
$ memleak-bpfcc -p 1174052 -o 5000 30
Attaching to pid 1174052, Ctrl+C to quit.
[17:34:55] Top 10 stacks with outstanding allocations:
    0 bytes in 6261 allocations from stack
        deserialize_String_vector+0x4c [libzookeeper_mt.so.2.0.0]
        deserialize_GetChildren2Response+0x4b [libzookeeper_mt.so.2.0.0]
        process_sync_completion+0x27e [libzookeeper_mt.so.2.0.0]
        zookeeper_process+0x5e7 [libzookeeper_mt.so.2.0.0]
        do_io+0x277 [libzookeeper_mt.so.2.0.0]
        start_thread+0xdb [libpthread-2.27.so]

我对调试符号所带来的变化感到惊讶。

有了这些数据,现在就可以追踪到实际的C函数,并试图找到可疑的部分。这时,团队的其他成员(Scott和Hormoz,他们对C语言有更多的经验)开始行动了。

现在有了一个可以查看的指针,斯科特能够阅读可能泄漏的代码,并想出了修复方法。我不想在这里重述,所以请阅读PR,那是一篇很好的文章!

当我们完成修复并发现它的影响有多大时,那是一个令人惊讶的时刻。

image.png


正如你所看到的,我们花了几次失败(用go tool profileheapstack ),直到我们找到一个工具,使我们能够确定这个问题。我几乎要放弃了,不再继续探索这个问题。我希望这能激励其他人,让他们相信,如果你花足够的时间从不同的角度看问题,任何问题都可以被解决。