Prometheus Exporterの「死に至る病」を解剖する:GoのメモリリークをPprofで根絶せよ
オブザーバビリティの世界において、監視対象そのものが「沈黙の病」に侵されることほど皮肉なことはない。
Prometheus Exporterを自作する際、多くのエンジニアはメトリクスの定義と収集ロジックに集中する。だが、本番環境で数週間放置した瞬間にExporterがOOM Killerの餌食になる――これはGo製のネットワークアプリケーションにおいて、避けては通れない「洗礼」のようなものだ。
今日は、小手先の修正ではない。Goのランタイムの深淵を覗き込み、メモリとGoroutineのリークを外科手術のように特定・切除する、極限のトラブルシューティング手法を伝授する。
—
1. 現場が直面する「沈黙のリーク」の兆候
Exporterのメモリリークは、突然死ではない。緩やかな階層構造を伴う「右肩上がりのグラフ」として現れる。
- RSSの持続的な増加: `process_resident_memory_bytes`が、トラフィック量に関係なく単調増加している。
- GC停止時間の増大: ヒープ上の生存オブジェクト数が増え、GCが領域確保のためにCPUを食いつぶす。`go_memstats_gc_cpu_fraction`を確認せよ。
- メトリクス収集のタイムアウト: `/metrics`エンドポイントが応答しなくなる。これは、重いリクエスト処理がスタックしているか、Goroutineがリークしてスケジューラが悲鳴を上げている証拠だ。
2. 禁断の果実:net/http/pprofによる深層解析
Exporterにプロファイラを仕込むことは、もはや必須の「作法」だ。標準の`net/http/pprof`をインポートするだけで、Goのランタイム内部を可視化できる。
導入の定石
単にインポートするだけでは不十分だ。本番環境で安全に叩くためのエンドポイントを制御せよ。
import (
_ “net/http/pprof” // これだけで /debug/pprof が生える
“net/http”
)
// 内部的には別ポートでListenし、社内ネットワークからのみアクセスを許可する
go func() {
log.Println(http.ListenAndServe(“localhost:6060”, nil))
}()
ヒーププロファイルの採取と解析
メモリリークの犯人は、概ね「生存し続けているオブジェクト」だ。以下のコマンドで、30秒間のサンプリングを行い、差分を抽出する。
30秒間ヒープをサンプリングし、PDFで可視化する
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
あるいは、CLIで「どこが一番メモリを食っているか」を特定する
go tool pprof -top http://localhost:6060/debug/pprof/heap
ここでのコツ:
`inuse_space`だけでなく、`alloc_objects`も確認せよ。もし特定の関数で`alloc`が異常に高いなら、ループ内での不要なアロケーションがGCの負荷を押し上げている。
3. Goroutineリークを「殺す」ための実装ハック
メモリリークの真犯人は、往々にして「終わらないGoroutine」だ。Prometheusのクライアントライブラリを使用する際、コンテキストの管理を怠ると、リクエスト終了後もGoroutineが宙に浮く。
陥りやすい罠:Contextの無視
外部APIを叩くクライアントで、`context.Context`を正しく伝播させていないと、接続が切れた後もGoroutineがレスポンスを待ち続ける。
// 修正前:タイムアウトを伝播していない
func FetchMetrics(ctx context.Context) {
// この中でブロックされると、親が死んでもGoroutineは残る
resp, _ := http.Get(“http://target-service”)
}
// 修正後:contextを付与する
func FetchMetrics(ctx context.Context) {
req, _ := http.NewRequestWithContext(ctx, “GET”, “http://target-service”, nil)
client := &http.Client{}
resp, err := client.Do(req)
// …
}
Prometheusコレクターの「完全終了」
カスタムコレクターを実装する際、`Register`したまま放置すると、メモリ上に古いコレクターが残り続ける。
// 適切に終了させるためのパターン
type CustomCollector struct {
// …
}
func (c CustomCollector) Unregister() {
prometheus.Unregister(c) // ライフサイクルを厳密に管理する
}
—
4. エキスパートの知見:メモリリークを「未然に防ぐ」自動化
手動のプロファイリングは、発生後の対処に過ぎない。真のアーキテクトは、「リークを検知して自動でプロファイルを保存する仕組み」を構築する。
1. Prometheus Alertmanagerとの連携: `go_memstats_alloc_bytes`が一定閾値を超えたら、Webhook経由で `curl -s http://localhost:6060/debug/pprof/heap > /tmp/leak.pb` を実行する独自サイドカーを走らせる。
2. 型安全な構造体の再利用: `sync.Pool`を用いて、メトリクス収集時に生成される短命なオブジェクト(特にスライスや一時的なstruct)を使い回せ。これにより、GCのトリガーとなるアロケーションを劇的に減らせる。
結論:ツールを「所有」せよ
Prometheus Exporterは、単なる監視の足掛かりではない。それはシステムの心拍を刻む心臓部だ。その心臓に負荷をかけることは、観測者自身がシステムを歪める行為に等しい。
Pprofでヒープを覗き込み、Goroutineのスタックトレースを読み解け。そして、Goのランタイムがメモリをどう解放し、どう再利用しているのか、そのリズムを身体に叩き込め。
「観測対象に負荷を与えず、かつ観測対象の不調を即座に感知する」。これが、高次元なオブザーバビリティを設計する者の矜持である。さあ、今すぐプロファイラを起動し、君のExporterの「魂」を清めよ。