メモリモンスターへの対処
ある朝、ステージング環境で動作していたGoマイクロサービスの1つが1.5GBものRAMを消費していることに気づきました。このサービスは単純なデータアグリゲーターであるはずで、そのロジックからすれば200MBを超えることはないはずでした。もしこれが本番環境で起きていれば、OOM(Out of Memory)killerによってプロセスが繰り返し強制終了され、ダウンタイムが発生していたでしょう。
Goのガベージコレクタ(GC)は非常に優秀ですが、魔法ではありません。不要になったオブジェクトへの参照を誤って保持し続けてしまうようなロジックエラーから守ってくれるわけではないのです。こうしたリークを見つけるには、pprofが必要です。私はこの手法を本番環境で適用し、一貫して安定した結果を得ており、場合によってはメモリ使用量を70%削減することに成功しています。
クイックスタート:5分でpprofを有効にする
Webアプリケーションのプロファイリングを開始する最も簡単な方法は、net/http/pprofパッケージを使用することです。これにより、HTTP経由でプロファイリングデータを公開するハンドラーが自動的に登録されます。
package main
import (
"fmt"
"log"
"net/http"
_ "net/http/pprof" // 副作用のためのインポート
"time"
)
func main() {
// pprof用のバックグラウンドサーバーを起動
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// アプリケーションのロジックをここに記述
select {}
}
単に _ "net/http/pprof" をインポートするだけで、/debug/pprof/ 配下にいくつかのエンドポイントが公開されます。ブラウザで http://localhost:6060/debug/pprof/ にアクセスして利用可能なプロファイルの一覧を確認できますが、本当の威力はコマンドラインツールで発揮されます。
ディープダイブ:ヒーププロファイルを理解する
Goにおけるメモリプロファイリングは、主にヒープ(heap)に焦点を当てます。ヒープとは、関数のスコープを超えて生存するオブジェクトや、スタックに置くには大きすぎるオブジェクトが配置される場所です。ヒーププロファイルをキャプチャして分析するには、ターミナルで次のコマンドを実行します。
go tool pprof http://localhost:6060/debug/pprof/heap
pprofの対話型シェルに入ると、メモリを確認する主な方法が2つあります。
- inuse_space: 現在アプリケーションによって保持されているメモリ量を示します。メモリリークを見つけるのに最適です。
- alloc_space: プログラム開始時からの総割り当てメモリ量を示します。既にガベージコレクションされたものも含まれます。これは「GCプレッシャー(ゴミを大量に生成し、CPUに負荷をかけているコード)」を見つけるのに役立ちます。
これらを切り替えるには、pprofプロンプト内で sample_index=inuse_space または sample_index=alloc_space と入力します。
「top」コマンド
top10 と入力すると、最もメモリを消費している関数が表示されます。flat(その関数自体が使用しているメモリ)や cum(その関数と、そこから呼び出されたすべての関数が使用しているメモリ)といった列が表示されます。
(pprof) top10
合計96.74MBのうち95MB(98.2%)を占めるノードを表示中
flat flat% sum% cum cum%
80MB 82.69% 82.69% 80MB 82.69% main.generateData
15MB 15.51% 98.20% 15MB 15.51% runtime.allocm
高度な使い方:リークを可視化する
テキスト形式の表を読むのも良いですが、コールグラフを可視化すると、原因の特定がはるかに容易になります。Graphvizがインストールされていれば、より直感的なWebベースのUIを生成できます。
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
これによりブラウザでタブが開きます。ここでお気に入りのビューはフレームグラフ(Flame Graph)です。フレームグラフでは、各ボックスの幅がその関数(およびその子関数)が使用しているメモリ量に対応しています。異常に幅の広いバーがあれば、そこがボトルネックです。
実世界でのリーク特定
以前、map[string]*User が際限なく肥大化する状況に遭遇したことがあります。pprofのWeb UIでPeekビューを使用することで、特定のバックグラウンドワーカーがこのマップにユーザーを追加しているものの、削除を一切行っていないことが判明しました。pprofツール内で list コマンドを使用し、具体的にどの行に責任があるかを確認しました。
(pprof) list main.WorkerProcess
ROUTINE ======================== main.WorkerProcess in /app/main.go
40MB 40MB (flat, cum) 全体の41.35%
. . 38: func WorkerProcess(u *User) {
40MB 40MB 39: globalCache[u.ID] = u // リークの原因はここでした!
. . 40: }
メモリ最適化のための実践的なヒント
pprofで問題を見つけたら、次は修正です。メモリ使用量を低く抑えるために私が使用している、最も効果的なパターンをいくつか紹介します。
1. スライスとマップの事前割り当て
スライスに追加するアイテム数がわかっている場合は、キャパシティを指定して初期化してください。これにより、スライスの成長に伴う再割り当てとデータのコピーを防ぐことができます。
// 悪い例:頻繁な割り当てが発生
var data []int
for i := 0; i < 1000; i++ {
data = append(data, i)
}
// 良い例:一度の割り当てで済む
data := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
data = append(data, i)
}
2. 頻繁な割り当てには sync.Pool を使用する
JSONバッファや一時的な構造体など、同じ型のオブジェクトを頻繁に作成・破棄する場合は、sync.Pool を使用してください。これにより、GCがメモリを常に解放・再割り当てする代わりに、メモリを再利用できるようになります。
3. スライスのスライスに注意する
よくある間違いは、非常に大きなスライスから小さなサブスライスを切り出すことです。小さなスライスは依然として背後にある大きな配列への参照を保持しているため、大きな配列がガベージコレクションされるのを妨げます。これを解決するには、データを新しい小さなスライスにコピーします。
// リークの可能性:smallPartがbigData全体をメモリに保持し続ける
bigData := make([]byte, 100*1024*1024) // 100MB
smallPart := bigData[:10]
// 解決策:データをコピーする
smallPart := make([]byte, 10)
copy(smallPart, bigData[:10])
4. Goroutineリークに注意する
すべてのGoroutineは、最小で2KBのスタックメモリを消費します。終了しないGoroutine(例:閉じられることのないチャネルを待ち続けているもの)を大量に開始すると、最終的にメモリ不足に陥ります。pprof/goroutine を使用して、Goroutineの数が時間の経過とともに増加し続けていないか確認してください。
メモリの最適化は、すべての行を微細に最適化することではなく、負荷の80%を引き起こしている20%のコードを特定することです。pprof をワークフローに取り入れることで、「推測」から「メモリがどこで使われているかの確信」へとステップアップできるでしょう。

