译文 定位并修复 Go 中的内存泄漏

fivezh for 翻译小组 · 2021年11月01日 · 最后由 NSObjects 回复于 2021年11月10日 · 462 次阅读
本帖已被设为精华帖!

这篇文章回顾了我是如何发现内存泄漏、如何修复它、如何修复 Google 中的 Go 示例代码中的类似问题,以及我们是如何改进我们的基础库防止未来再次发生这种情况。

Google 云的 Go 的客户端基础库通常在底层使用 gRPC 来连接 Google 云的接口。当你创建一个客户端时,库会初始化一个与该 接口的连接,然后让这个连接保持打开状态,直到你在该客户端上调用 Close 操作。

client, err := api.NewClient()
// Check err.
defer client.Close()

客户端并发地使用是安全的,所以应该在使用完之前保留同一个客户端。但是,如果在应该关闭客户端的时候而没有关闭,会发生什么呢?

你会得到一个内存泄漏:底层连接从未被释放过。


Google 有一堆的 GitHub 自动化机器人,帮助管理数以百计的 GitHub 仓库。我们的一些机器人通过云上运行的上的Go 服务代理它们的请求。我们的内存使用情况看起来就是典型的锯齿状内存泄漏情况。

我通过在程序中添加 pprof.Index 开始调试:

mux.HandleFunc("/debug/pprof/", pprof.Index)

pprof 提供运行时的分析数据,比如内存使用量。访问 Go 官方博客中的 分析 Go 程序 获取更多信息.

然后,我在本地构建并启动了该服务:

$ go build
$ PROJECT_ID=my-project PORT=8080 ./serverless-scheduler-proxy

然后,我向这个服务发送了一些构造的请求:

for i in {1..5}; do
curl --header "Content-Type: application/json" --request POST --data '{"name": "HelloHTTP", "type": "testing", "location": "us-central1"}' localhost:8080/v0/cron
echo " -- $i"
done

具体的负载和路径是针对我们服务的,与本文无关。 为了获取一个关于内存使用情况的基线数据,我收集了一些初始的 pprof 数据。

curl http://localhost:8080/debug/pprof/heap > heap.0.pprof

通过检查输出的结果,可以看到一些内存的使用情况,但并没有发现徒增的问题(这很好!因为我们刚刚启动服务!)。

$ go tool pprof heap.0.pprof
File: serverless-scheduler-proxy
Type: inuse_space
Time: May 4, 2021 at 9:33am (EDT)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top10
Showing nodes accounting for 2129.67kB, 100% of 2129.67kB total
Showing top 10 nodes out of 30
      flat  flat%   sum%        cum   cum%
 1089.33kB 51.15% 51.15%  1089.33kB 51.15%  google.golang.org/grpc/internal/transport.newBufWriter (inline)
  528.17kB 24.80% 75.95%   528.17kB 24.80%  bufio.NewReaderSize (inline)
  512.17kB 24.05%   100%   512.17kB 24.05%  google.golang.org/grpc/metadata.Join
         0     0%   100%   512.17kB 24.05%  cloud.google.com/go/secretmanager/apiv1.(*Client).AccessSecretVersion
         0     0%   100%   512.17kB 24.05%  cloud.google.com/go/secretmanager/apiv1.(*Client).AccessSecretVersion.func1
         0     0%   100%   512.17kB 24.05%  github.com/googleapis/gax-go/v2.Invoke
         0     0%   100%   512.17kB 24.05%  github.com/googleapis/gax-go/v2.invoke
         0     0%   100%   512.17kB 24.05%  google.golang.org/genproto/googleapis/cloud/secretmanager/v1.(*secretManagerServiceClient).AccessSecretVersion
         0     0%   100%   512.17kB 24.05%  google.golang.org/grpc.(*ClientConn).Invoke
         0     0%   100%  1617.50kB 75.95%  google.golang.org/grpc.(*addrConn).createTransport

接下来继续向服务发送一批请求,看看我们是否会出现(1)重现前面的内存泄漏,(2)确定泄漏是什么。

发送 500 个请求:

for i in {1..500}; do
curl --header "Content-Type: application/json" --request POST --data '{"name": "HelloHTTP", "type": "testing", "location": "us-central1"}' localhost:8080/v0/cron
echo " -- $i"
done

收集并分析更多的 pprof 数据:

$ curl http://localhost:8080/debug/pprof/heap > heap.6.pprof
$ go tool pprof heap.6.pprof
File: serverless-scheduler-proxy
Type: inuse_space
Time: May 4, 2021 at 9:50am (EDT)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top10
Showing nodes accounting for 94.74MB, 94.49% of 100.26MB total
Dropped 26 nodes (cum <= 0.50MB)
Showing top 10 nodes out of 101
      flat  flat%   sum%        cum   cum%
   51.59MB 51.46% 51.46%    51.59MB 51.46%  google.golang.org/grpc/internal/transport.newBufWriter
   19.60MB 19.55% 71.01%    19.60MB 19.55%  bufio.NewReaderSize
    6.02MB  6.01% 77.02%     6.02MB  6.01%  bytes.makeSlice
    4.51MB  4.50% 81.52%    10.53MB 10.51%  crypto/tls.(*Conn).readHandshake
       4MB  3.99% 85.51%     4.50MB  4.49%  crypto/x509.parseCertificate
       3MB  2.99% 88.51%        3MB  2.99%  crypto/tls.Client
    2.50MB  2.49% 91.00%     2.50MB  2.49%  golang.org/x/net/http2/hpack.(*headerFieldTable).addEntry
    1.50MB  1.50% 92.50%     1.50MB  1.50%  google.golang.org/grpc/internal/grpcsync.NewEvent
       1MB     1% 93.50%        1MB     1%  runtime.malg
       1MB     1% 94.49%        1MB     1%  encoding/json.(*decodeState).literalStore

google.golang.org/grpc/internal/transport.newBufWriter很明显占用了大量的内存! 这就是内存泄漏与什么有关的第一个迹象:gRPC。结合源码,我们唯一使用 gRPC 的地方是Google 云秘钥管理部分

client, err := secretmanager.NewClient(ctx) 
if err != nil { 
    return nil, fmt.Errorf("failed to create secretmanager client: %v", err) 
}

我们从未调用过client.Close(),并且在每个请求中都创建了一个Client! 所以,我添加了一个Close调用,问题就解决了。

defer client.Close()

我提交了这个修复, 它 自动部署完成后, 毛刺显现立即消失了!

哇呜! 🎉🎉🎉


大约在同一时间,一个用户在我们的云上的 Go 实例代码库上提出了一个问题,其中包含了cloud.google.com上文档的大部分 Go 示例程序。该用户注意到我们在其中一个程序中忘记了 client.Close() 关闭客户端!

我看到同样的事情出现过几次,所以我决定调查整个仓库。

我从粗略估计有多少受影响的文件开始。使用 grep 命令,我们可以得到一个包含 NewClient 风格调用的所有文件的列表,然后把这个列表传递给另一个 grep 调用,只列出包含Close的文件,同时忽略测试文件。

$ grep -L Close $(grep -El 'New[^(]*Client' **/*.go) | grep -v test

译者注:列出包含New[^(]*Client,但不包含Close的所有 go 文件

哇呜! 总共有 207 个文件,而整个 GoogleCloudPlatform/golang-samples 仓库中有大约 1300 个 .go 文件.

鉴于问题的规模,我认为一些简单的自动化会很值得。我不想写一个完整的 Go 程序来编辑这些文件,所以我选择用 Bash 脚本。

$ grep -L Close $(grep -El 'New[^(]*Client' **/*.go) | grep -v test | xargs sed -i '/New[^(]*Client/,/}/s/}/}\ndefer client.Close()/'

它是完美的吗?不,但它在工作量上能给我省好多事?是的!

第一部分(直到 test)与上面完全一样 -- 获得所有可能受影响的文件的列表(那些创建了 Client 但从未调用 Close 的文件)。

然后,我把这个文件列表传给 sed 进行实际编辑。xargs 调用传递的命令,stdin 的每一行都作为参数传递给特定的命令。

为了理解 sed 命令,看看 golang-samples 仓库中的示例程序通常是什么样子(省略导入和客户端初始化后)会很有帮助。

// accessSecretVersion accesses the payload for the given secret version if one
// exists. The version can be a version number as a string (e.g. "5") or an
// alias (e.g. "latest").
func accessSecretVersion(w io.Writer, name string) error {
    // name := "projects/my-project/secrets/my-secret/versions/5"
    // name := "projects/my-project/secrets/my-secret/versions/latest"
    // Create the client.
    ctx := context.Background()
    client, err := secretmanager.NewClient(ctx)
    if err != nil {
        return fmt.Errorf("failed to create secretmanager client: %v", err)
    }
    // ...
}

在高层次上,我们初始化客户端并检查是否有错误。每当你检查错误时,都有一个闭合的大括号(})。我使用这些信息来确定如何自动编辑。

不过,sed 命令仍然是个麻烦。

sed -i '/New[^(]*Client/,/}/s/}/}\ndefer client.Close()/'

-i 参数表明是原地编辑并替换文件。对此我是没问题,因为如果搞砸了,git 可以救我。

接下来,我使用 s 命令在检查错误时的关闭大括号(})之后插入 defer client.Close()

但是,我不想替换每一个},我只想替换调用 NewClient * 后的第一个。要做到这一点,你可以给一个让 sed 去搜索地址范围

地址范围可以包括开始和结束模式,以便在应用接下来的任何命令之前进行匹配。在这个例子中,开始是 /New[^(]*Client/,匹配 NewClient 类型的调用,结束(用,分隔)是/}/,匹配下一个大括号。这意味着我们的搜索和替换将只适用于对 NewClient 的调用和结尾的大括号之间。

通过了解上面的错误处理模式,if err != nil 条件的结束括号正是我们要插入 Close 调用的地方。


一旦自动编辑了所有的文件,运行 goimports 来修复格式化。然后,检查了每个编辑过的文件,确保它做了正确的事情。

  • 在服务器应用程序中,我们是应该真正关闭客户端,还是应该为未来的请求保留它?
  • 客户端的名字实际上是 client,还是别的什么?
  • 是否有更多的客户端需要 Close

一旦完成这些,我留下了180 个编辑的文件


最后的任务是努力使用户不再发生这种情况。我们想到了几种方法:

  1. 更好的示例程序。
  2. 更好的 GoDoc。我们更新了我们的库生成器,在生成的库中加入了一个注释,说当你用完后要 Close 客户端。参见https://github.com/googleapis/google-cloud-go/issues/3031
  3. 更好的基础库。有什么办法可以让我们自动 Close 客户?Finalizer 方法?有什么想法我们可以做得更好吗?请在https://github.com/googleapis/google-cloud-go/issues/4498 上告诉我们。

希望你能学到一些关于 Go、内存泄漏、pprofgRPCBash 的知识。我很想听听你关于你所发现的内存泄露的故事,以及你是如何解决这些问题的! 如果你对我们的代码库示例程序有什么想法,欢迎提交问题让我们知道。

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
kevin 将本帖设为了精华贴 11月01日 03:22

这个问题曾经遇到过,的确是没有 close 导致的

yulibaozi GoCN 每日新闻 (2021-11-02) 中提及了此贴 11月02日 09:42
just_for_fun 回复

如果只创建一个 client 以复用会有什么问题吗?

spf 回复

client 最好是复用, 但是也要 close , defaultTransport 实现了 socket 连接池, 如果每次都新建 client 高并发的时候会出现 too many open files ,

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册