定位並修復 Go 中的內存泄露
大家好,我是幽鬼。
Go 是一門帶 GC 的語言,因此,大家很容易認爲它不會有內存泄露問題。大部分時候確實不會,但如果有些時候使用不注意,也會導致泄露。
本文案例來自谷歌雲的代碼,探討如何找到並修復 Go 中的內存泄露。(確切來說是因爲資源泄露導致的內存泄露,除了本文介紹的,還有一些其他泄露的情況)
這篇文章回顧了我如何發現內存泄漏、如何修復它,以及我如何修復 Google 示例 Go 代碼中的類似問題,以及我們如何改進我們的庫以防止將來發生這種情況。
Google Cloud Go 客戶端庫 [1] 通常在後臺使用 gRPC 來連接 Google Cloud API。創建 API 客戶端時,庫會初始化與 API 的連接,然後保持該連接處於打開狀態,直到你調用 Client.Close
。
client, err := api.NewClient()
// Check err.
defer client.Close()
客戶端可以安全地同時使用,所以你應該保持相同Client
直到你的任務完成。但是,如果在應該 Close 的時候不 Close client 會發生什麼呢?
會出現內存泄漏。底層連接永遠不會被清理。
Google 有一堆 GitHub 自動化機器人來幫助管理數百個 GitHub 存儲庫。我們的一些機器人通過在 Cloud Run[2] 上運行的 Go 服務器 [3] 代理它們的請求。我們的內存使用看起來像一個經典的鋸齒形內存泄漏:
我通過向服務器添加 pprof.Index
處理程序開始調試:
mux.HandleFunc("/debug/pprof/", pprof.Index)
pprof
[4] 提供運行時 profiling 數據,如內存使用情況。有關更多信息,請參閱 Go 官方博客上的 profiling Go 程序 [5]。
然後,我在本地構建並啓動了服務器:
$ 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 Cloud Secret Manager[6]:
client, err := secretmanager.NewClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create secretmanager client: %v", err)
}
在每個請求創建 client
時,我們沒有調用 client.Close()
!所以,我添加了一個Close
調用,問題就消失了:
defer client.Close()
我提交了修復,然後自動部署 [7],鋸齒立即消失了!
大約在同一時間,用戶在我們的 Cloud 的 Go 示例存儲庫中 [8] 提交了一個問題,其中包含 cloud.google.com 上 [9] 文檔的大部分 Go 示例。用戶注意到我們忘記調用 client.Close
了。
我曾多次看到同樣的事情出現,所以我決定調查整個 repo。
我開始粗略估計有多少受影響的文件。使用grep
,我們可以獲得包含NewClient
樣式調用的所有文件的列表,然後將該列表傳遞給另一個調用grep
以僅列出不包含 Close
的文件,同時忽略測試文件:
$ grep -L Close $(grep -El 'New[^(]*Client' **/*.go) | grep -v test
竟然有 207 個文件…… 就上下文而言,我們 .go
在 GoogleCloudPlatform/golang-samples[10] 存儲庫中有大約 1300 個文件。
考慮到問題的規模,我認爲一些自動化是值得的 [11]。我不想寫一個完整的 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
repo 示例是什麼樣子有助於理解(省略導入和客戶端初始化後的所有內容):
// 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
後_的_第一個_。要做到這一點,你可以給一個地址範圍 [12] 的sed
搜索。
地址範圍可以包括在應用接下來的任何命令之前要匹配的開始和結束模式。在這種情況下,開始是/New[^(]*Client/
,匹配NewClient
類型調用,結束(由 a 分隔,
)是/}/
,匹配下一個大括號。這意味着我們的搜索和替換僅適用於調用NewClient
和結束大括號之間!
通過了解上面的錯誤處理模式,if err != nil
條件的右大括號正是我們想要插入Close
調用的位置。
一旦我自動編輯了所有示例文件,我用goimports
開始修復格式。然後,我檢查了每個編輯過的文件,以確保它做了正確的事情:
-
在服務器應用程序中,我們應該關閉客戶端,還是應該保留它以備將來的請求使用?
-
是
Client
實際的名字client
還是別的什麼? -
是否有一個以上的
Client
調用了Close
?
完成後,只剩下 180 個已編輯的文件 [13]。
最後一項工作是努力使其不再發生在用戶身上。我們想到了幾種方法:
-
更好的示例代碼;
-
更好的 GoDoc。我們更新了庫生成器,在生成庫時加上註釋,告知 client 需要調用 Close;
-
更好的庫。有沒有辦法可以自動
Close
客戶端?Finalizers?知道何能做得更好嗎?歡迎在 https://github.com/googleapis/google-cloud-go/issues/4498 上交流;
我希望你對 Go、內存泄漏pprof
、gRPC 和 Bash 有所瞭解。我很想聽聽你關於發現的內存泄漏以及修復它們的方法的故事!如果你對我們如何改進我們的庫 [14] 或示例 [15] 有任何想法,請通過提交 issue 告訴我們。
原文鏈接:https://dev.to/googlecloud/finding-and-fixing-memory-leaks-in-go-1k1h
參考資料
[1]
Google Cloud Go 客戶端庫: https://github.com/googleapis/google-cloud-go
[2]
Cloud Run: https://cloud.google.com/run/docs/quickstarts/build-and-deploy/go
[3]
Go 服務器: https://github.com/googleapis/repo-automation-bots/tree/main/serverless-scheduler-proxy
[4]
pprof
: https://pkg.go.dev/net/http/pprof
[5]
profiling Go 程序: https://go.dev/blog/pprof
[6]
Google Cloud Secret Manager: https://cloud.google.com/secret-manager/docs/quickstart
[7]
自動部署: https://cloud.google.com/build/docs/deploying-builds/deploy-cloud-run
[8]
Cloud 的 Go 示例存儲庫中: https://github.com/GoogleCloudPlatform/golang-samples
[9]
cloud.google.com 上: https://cloud.google.com/
[10]
GoogleCloudPlatform/golang-samples: https://github.com/GoogleCloudPlatform/golang-samples
[11]
值得的: https://xkcd.com/1205/
[12]
地址範圍: https://www.gnu.org/software/sed/manual/html_node/Addresses.html
[13]
180 個已編輯的文件: https://github.com/GoogleCloudPlatform/golang-samples/pull/2080
[14]
庫: https://github.com/googleapis/google-cloud-go
[15]
示例: https://github.com/GoogleCloudPlatform/golang-samples
歡迎關注「幽鬼」,像她一樣做團隊的核心。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/zcQxmqN0LT9L0qQsp2nypg