【実務・中級編】Prometheus ExporterのメモリリークをGoのPprofで特定して修正する実録トラブルシューティング – 運用監視・オブザーバビリティ活用バイブル

Prometheus Exporterが「静かに死ぬ」日:GoのPprofでメモリリークを外科手術する技術

PrometheusのカスタムExporterを書いているとき、ある日突然、コンテナがOOM Killerに食い殺される経験はないだろうか?

「メモリを食っているのはExporterだ」と分かっていても、Goのランタイムの中で何が起きているのかを突き止められなければ、それは単なるお祈りデバッグだ。今日は、泥臭いトラブルシューティングを卒業し、Goのランタイムを透視して、メモリとGoroutineのリークを根こそぎ駆除する外科手術の作法を伝授する。

—

1. 現場が直面する「静かなる死」の兆候

カスタムExporterのメモリリークは、多くの場合、次の3つのパターンに集約される。

1. クライアントライブラリのRegistry汚染: `prometheus.MustRegister()` をループ内で呼び出し、同じメトリクスを重複登録している。
2. Goroutineの迷子: `http.Handler` 内部で起動したGoroutineが、Contextのキャンセルを無視して無限ループやブロッキングに陥っている。
3. HTTPリクエストの未クローズ: `resp.Body.Close()` の忘れ物。これはメモリというよりファイルディスクリプタの枯渇を招くが、結果としてランタイムが異常動作する。

これらを「勘」で修正してはいけない。Pprofというメスを入れろ。

—

2. 現場の生産性を極限まで高める「神設定」

まず、トラブルを早期発見するために、`net/http/pprof` を本番環境でも安全に(隔離されたポートで)起動させておくのはエンジニアの嗜みだ。

おすすめの構成:Pprof隔離ポート

メインのメトリクスポート(9090)とは別に、デバッグ専用のポート(6060)を立てる。

// main.go
go func() {
// 外部公開厳禁。セキュリティグループで社内VPN/踏み台からのみ許可すること
log.Println(http.ListenAndServe(“localhost:6060”, nil))
}()

【神プラグイン/ツール】

  • [Graphviz](https://graphviz.org/): Pprofの `top` コマンドだけでは見えない、呼び出し関係の「淀み」を可視化するために必須。
  • [VS Code Go Extension](https://marketplace.visualstudio.com/items?itemName=golang.go): プロファイリング結果の `.pb.gz` を直接開いて可視化できる。コマンドパレットから `Go: Profile` を使いこなせ。

—

3. 実践:Pprofでのヒープ解析とGoroutine特定

メモリが右肩上がりに増えているなら、まずはヒープを叩く。

手順1:プロファイルの採取

30秒間ヒープをサンプリングして保存
curl -o heap.out http://localhost:6060/debug/pprof/heap

手順2:可視化と解析

go tool pprof -http=:8080 heap.out

ブラウザが立ち上がったら「VIEW」メニューから「Flame Graph」を選択せよ。横に広いバーは、その関数がメモリを大量に占有している証拠だ。

Goroutineリークの特定

メモリではなくGoroutineが溢れているなら以下を叩け。

go tool pprof http://localhost:6060/debug/pprof/goroutine
コンソールで以下のコマンドを打つ
top
traces

`traces` を叩くと、スタックトレースがずらりと並ぶ。同じ場所で止まっているGoroutineが数百個あるなら、それが犯人だ。

—

4. 修正の真髄:Registryを適切に管理する

多くの初心者が陥る罠は、リクエストのたびにメトリクスを新規生成することだ。

【Bad Code】

// リクエストのたびに登録してはダメ!
func handler(w http.ResponseWriter, r http.Request) {
m := prometheus.NewGauge(prometheus.GaugeOpts{Name: “my_metric”})
prometheus.MustRegister(m) // ここで無限に登録されメモリが死ぬ
m.Set(1)
}

【Good Code: ベストプラクティス】
`init()` または構造体のコンストラクタで一度だけ登録し、`prometheus.GaugeVec` を使ってラベルで管理しろ。

var (
myMetric = prometheus.NewGaugeVec(
prometheus.GaugeOpts{Name: “my_metric_total”},
[]string{“instance_id”},
)
)

func init() {
prometheus.MustRegister(myMetric)
}

func handler(w http.ResponseWriter, r http.Request) {
myMetric.WithLabelValues(“node-01”).Set(1)
}

—

5. チームで共有すべき「設定の掟」

1. YAMLの構造化: 設定ファイルは `config.yaml` ではなく、`config/base.yaml` と `config/env/prod.yaml` のように環境差分を明示的に分離せよ。
2. Contextの伝搬: HTTPリクエストのハンドラから呼び出す全ての処理には必ず `context.Context` を通せ。これにより、タイムアウト時にGoroutineが自発的に死ぬ安全装置になる。
3. テストでのリーク検知: CI環境で `testing.T.Run()` 内に `runtime.NumGoroutine()` の監視を仕込め。テスト終了後のGoroutine数が開始前と一致しない場合、即座にビルドを落とせ。

—

最後に:オブザーバビリティは「心構え」である

ツールはあくまで補助輪だ。一番の武器は、「自分の書いたコードがGoのランタイム上でどう展開されているか」を脳内でシミュレーションできるエンジニアの想像力である。

Pprofで見たFlame Graphの頂点に、あなたのコードの無駄が刻まれている。それを削ぎ落とすことこそが、真のパフォーマンスチューニングだ。健闘を祈る。

タイトルとURLをコピーしました