🧟

Goでメモリリークの発生箇所を特定する方法(サンプル付き)

に公開

バックエンドサーバーのメモリリークに遭遇

ある日CloudRunのログでこんなエラーに遭遇しました。

Memory limit of 512 MiB exceeded with 518 MiB used. Consider increasing the memory limit, see https://cloud.google.com/run/docs/configuring/memory-limits

なんだこれ?と思ってメモリ使用率の推移を見てみると、、、

メモリ使用量が徐々に増えていって、100%に到達した時点でガクンと下がっています。これぞメモリリーク!っていう動きをしていますね。
幸い即時に対応せずとも問題ないバックエンドサーバーだったので、落ち着いて調査を進めていくことにしました。

みなさんもメモリリークの調査を体験しませんか?

ではどこでメモリリークをしているのか?それを知る必要があります。
この記事ではそれの具体的な調査方法を説明します。

みなさんにもこの調査を追体験していただけるけるように、この記事で使っているサンプルコードをGitHubに置いておきます。
https://github.com/issuy/go-leak-sample
良かったら試してみてください!

メモリリークするコードとプロファイリング設定の例

サンプルコードではHTTPリクエストのハンドラーとして以下を実装しています

func leakTestHandler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	app, err := firebase.NewApp(ctx, nil)
	if err != nil {
		log.Fatalf("error initializing app: %v\n", err)
	}
	_, err = app.Auth(ctx)
	if err != nil {
		log.Fatalf("error initializing auth client: %v\n", err)
	}

	// do something

	w.WriteHeader(http.StatusOK)
	w.Write([]byte("OK"))
}

ほぼネタバレ状態なので言ってしまうと、app.Auth()が今回のメモリリークの原因です。

ではどういった方法で app.Auth() がメモリリーク箇所だと判断するか?ですね。
Goにはこういった問題のための "net/http/pprof" というプロファイリングツールが用意されています。
これを有効にするために、main関数は以下のように実装しました。

func main() {
	http.HandleFunc("/leak-test", leakTestHandler)
	http.HandleFunc("/debug", pprof.Index)

	// Start the HTTP server
	log.Println("Starting server on :8080")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		log.Fatalf("Failed to start server: %v", err)
	}
}

※詳細はサンプルコードを御覧ください

メモリリークを発生させる

調査のためにメモリリークを発生させます。

まずはバックエンドサーバーの起動です。
ローカルで起動できるので、以下の様にコマンドを実行します。

-> % go run main.go
2025/05/12 13:18:47 Starting server on :8080

次にメモリリークを起こすエンドポイントに大量のリクエストを送信します。

-> % for ((i=0; i<1000; i++)); do curl -X POST http://localhost:8080/leak-test -i; done   
HTTP/1.1 200 OK
Date: Mon, 12 May 2025 04:51:21 GMT
Content-Length: 2
Content-Type: text/plain; charset=utf-8

OKHTTP/1.1 200 OK
Date: Mon, 12 May 2025 04:51:21 GMT
Content-Length: 2
Content-Type: text/plain; charset=utf-8
~~ 以下略 ~~

これで開放されないメモリが溜まり続ける状態になっているはずです。

調査開始!!

やっと準備が整ったのでメモリリークの調査を開始します!

どのくらいメモリが占有されているか見てみましょう。
以下のコマンドで計測されたメモリの使用状況を leak-test.prof というファイルに出力します。

-> % curl -o leak-test.prof http://localhost:8080/debug/pprof/heap

このファイルを分析するために以下のコマンド実行します。

-> % go tool pprof leak-test.prof
File: main
Type: inuse_space
Time: 2025-05-12 13:55:21 JST
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) 

この状態で top と打つと、メモリ使用量の多い関数が表示されます

(pprof) top
Showing nodes accounting for 9223.68kB, 85.72% of 10759.77kB total
Showing top 10 nodes out of 61
      flat  flat%   sum%        cum   cum%
    2052kB 19.07% 19.07%     2052kB 19.07%  runtime.allocm
 1536.12kB 14.28% 33.35%  1536.12kB 14.28%  go.opentelemetry.io/otel/internal/global.(*meter).Int64Counter
 1024.09kB  9.52% 42.87%  1024.09kB  9.52%  runtime.acquireSudog
 1024.08kB  9.52% 52.38%  1024.08kB  9.52%  go.opentelemetry.io/otel/internal/global.(*meter).Float64Histogram
 1024.03kB  9.52% 61.90%  4096.23kB 38.07%  go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp.(*Transport).createMeasures
     513kB  4.77% 66.67%      513kB  4.77%  cloud.google.com/go/monitoring/apiv3/v2/monitoringpb.init
     513kB  4.77% 71.44%      513kB  4.77%  github.com/envoyproxy/go-control-plane/envoy/service/status/v3.init
  512.88kB  4.77% 76.20%   512.88kB  4.77%  google.golang.org/protobuf/internal/filedesc.(*File).initDecls (inline)
  512.25kB  4.76% 80.96%   512.25kB  4.76%  cloud.google.com/go/internal/btree.init
  512.23kB  4.76% 85.72%  1025.11kB  9.53%  google.golang.org/protobuf/internal/filedesc.newRawFile

runtime.allocm はGoランタイム内部で使用されているメモリに関する情報のようなので、それ以外に着目します。
すると go.opentelemetry.io/otel/internal/global.(*meter).Int64Counter というのがメモリを多く使用している事が分かります。

サンプルコードではこの様なメソッドを実装していません。
go.opentelemetry.io とある通り、外部のパッケージのとある関数がメモリを使用しているようです。

メモリを使用している関数がどこから呼ばれているか確認

では go.opentelemetry.io/otel/internal/global.(*meter).Int64Counter がどこから呼ばれているのか確認しましょう。
先ほどの続きに traces .Int64Counter と打つと以下のように表示されます。

(pprof) traces .Int64Counter
File: main
Type: inuse_space
Time: 2025-05-12 13:55:21 JST
-----------+-------------------------------------------------------
     bytes:  80B
  512.04kB   go.opentelemetry.io/otel/internal/global.(*meter).Int64Counter
             go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp.(*Transport).createMeasures
             go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp.NewTransport
             google.golang.org/api/transport/http.addOpenTelemetryTransport (inline)
             google.golang.org/api/transport/http.newTransport
             google.golang.org/api/transport/http.NewClient
             google.golang.org/api/transport.NewHTTPClient (inline)
             firebase.google.com/go/v4/auth.NewClient
             firebase.google.com/go/v4.(*App).Auth
             main.leakTestHandler
             net/http.HandlerFunc.ServeHTTP
             net/http.(*ServeMux).ServeHTTP
             net/http.serverHandler.ServeHTTP
             net/http.(*conn).serve
-----------+-------------------------------------------------------

見知った関数名が見えますね

             firebase.google.com/go/v4.(*App).Auth
             main.leakTestHandler

これでメモリリークの原因が leakTestHandler() 内で呼ばれている app.Auth() だと判明しました。

メモリリークの原因箇所を修正した結果

以上のように、今回のメモリリークの原因は app.Auth() ということが分かりました。

この関数は auth.Client を生成する機能を持っていますが、HTTPリクエストのたびに呼び出す使い方に問題があったようです。
そのためバックエンドサーバーを起動するタイミングで app.Auth() を使い、リクエスト間で auth.Client を共有する実装にしました。
こうすれば app.Auth() を呼び出すのは1回で済むので、メモリリークの問題は解決します。

実際に修正を加えた後のメモリ使用量を確認すると、10%未満で推移しています。
開放されないメモリの蓄積がなくなり、メモリリークが解消していることが分かります。

まとめ

今回は "net/http/pprof" を使うことで、メモリリークの原因が自分たちのコードのどこなのか特定する方法をご紹介しました。

私もこのような調査をやったのは今回が始めてでした。
始めは "net/http/pprof" の使い方もよくわからず、疑心暗鬼になりながら調査を進めていました。
ですが一度やってしまえば、今後は効率よく調査を進められそうです。

みなさんも一度体験して、このツールの使い方をしっかり抑えておけばどこかで役立つはず!
サンプルコードもあるので、皆さんのローカルでも是非試してみてください!

株式会社ドクターズプライム

Discussion