Claude Media
Claude Codeでgoroutineリークを直す — goleakとpprofで見つける

Claude Codeでgoroutineリークを直す — goleakとpprofで見つける

Goのgoroutineリークを、goleakのテスト失敗と稼働中のgoroutineプロファイルで見つけ、スタックをClaude Codeに渡してcontextのキャンセル漏れとerrgroupの使い方を直させる手順です。

goroutineリークの修正をClaude Codeに任せるときは、先にリークをテストで失敗させておきます。直す対象と合否の基準がそろい、修正が効いたかを再実行だけで判定できるからです。

リークとは、本来終わるはずのgoroutineが終了条件に届かず、プロセスの中に残り続ける状態です。メモリも増えますが、症状が出るより前に、終わらないgoroutineそのものをテストで捕まえられます。この記事では、テスト終了時に検出するgoleakと、稼働中のプロセスから取るgoroutineプロファイルの2つを使い、そのスタックをClaude Codeに渡して context のキャンセル漏れと errgroup の誤用を直させる流れを扱います。

goleakとgoroutineプロファイルはどう使い分けるか

見つける場面で道具が変わります。テストの中で再現できるならgoleak、本番や長時間動かした開発環境でしか増えないならgoroutineプロファイルです。

場面道具得られるもの
テスト終了時道具goleak得られるもの残っていたgoroutineを説明するエラー。テストが失敗する
稼働中のプロセス道具goroutineプロファイル得られるもの現在のgoroutineすべてのスタック
取得を2回行う道具プロファイルの差分得られるもの増えたgoroutineだけの内訳

goleakは「Goroutine leak detector」と説明されるライブラリです。リポジトリは github.com/uber-go/goleak にあり、v1として公開されています。

プロファイル側は、runtime/pprof の定義済みプロファイルに goroutine があり、現在のすべてのgoroutineのスタックを返します。ページには goroutineleak(leaked goroutinesのスタック)も並んでいます。使えるGoのバージョンはページ側の表示で確かめてください。この記事では、どの環境でも使える goroutine を使います。

くらべる

どちらから始めるか

テストで再現できる

goleak

go test の中で完結します。失敗が再現可能で、Claude Codeが何度でも回して確かめられます。

稼働中にしか出ない

goroutineプロファイル

再現手順が分からないときの入口です。出たスタックから、最小のテストを書く材料を取ります。

プロファイルで見つけた場合も、最終的にはgoleakのテストへ落とすのが近道です。直したあとの回帰を、同じ仕組みで守れるからです。

TestMainにgoleakを入れてリークをテストで失敗させる

まず依存を足します。goleakは直近2つのマイナーバージョンのGoだけを対象にしています。

go get -u go.uber.org/goleak

検出の入れ方は2通りあります。テスト関数ごとに入れる方法と、パッケージ全体で1回だけ入れる方法です。

// 1テスト単位: テストの最後に残りを調べる
func TestWatch(t *testing.T) {
	defer goleak.VerifyNone(t)
 
	// テスト本体
}
 
// パッケージ単位: すべてのテストが終わったあとに調べる
func TestMain(m *testing.M) {
	goleak.VerifyTestMain(m)
}

VerifyTestMain は、全テストが通ったあとで追加のgoroutineを探し、あればテストを失敗させます。VerifyNone は t.Parallel と相性が悪く、並列実行中の別テストのgoroutineで誤検出する可能性があるとドキュメントにあります。並列で回すテストが多いパッケージは、TestMain に入れる方を選びます。

すでに TestMain があるときは、中身を消さずに VerifyTestMain で包みます。後始末の関数は goleak.Cleanup で渡せ、VerifyTestMain に渡した場合はTestMainの終了コードが引数になります。

失敗したテストを特定する

TestMain 方式の弱点は、リークがあるとき、どのテストが原因か分からない点です。goleakのREADMEに、テストバイナリを作って1件ずつ走らせるスクリプトがあります。

go test -c -o tests
for test in $(go test -list . | grep -E "^(Test|Example)"); do
  ./tests -test.run "^$test\$" &>/dev/null && echo -n "." || echo -e "\n$test failed"
done

成功したテストは . を、失敗したテストは名前を出します。この結果はClaude Codeにそのまま渡せます。「TestLeakyTestが原因」と分かれば、調べるコードが数百行から1関数に絞れます。

稼働中のプロセスからgoroutineのスタックを取る

テストで再現しないリークは、稼働中のプロセスからスタックを取ります。net/http/pprof を空のimportで読み込むと、/debug/pprof/ 以下にハンドラーが登録されます。

import (
	"log"
	"net/http"
	_ "net/http/pprof"
)
 
func main() {
	go func() {
		log.Println(http.ListenAndServe("localhost:6060", nil))
	}()
	// アプリ本体
}

アドレスを localhost にしているのは、ドキュメントの例に合わせた書き方です。プロファイルのエンドポイントは、外部のネットワークに公開しない前提で組みます。

取得にはクエリパラメーターを使います。debug=2 を付けると、goroutine プロファイルはパニック時と同じ形式でスタックを出します。seconds=N を付けると、goroutineプロファイルでも差分のプロファイルが返ります。

# 1回目
curl -s 'http://localhost:6060/debug/pprof/goroutine?debug=2' > goroutines-1.txt
# 負荷をかけたあと2回目
curl -s 'http://localhost:6060/debug/pprof/goroutine?debug=2' > goroutines-2.txt
grep -c '^goroutine ' goroutines-1.txt goroutines-2.txt

2つのファイルでgoroutineの数が増えていたら、増えた分のスタックを見ます。同じ関数で止まったgoroutineが数百本並んでいれば、そこがリークの出口です。pprofの対話ツールで見る方法もあります。

go tool pprof -top http://localhost:6060/debug/pprof/goroutine

pprofの出力をClaude Codeに渡す際の共通の注意点は、Claude Codeでpprofを解析する記事にまとめています。CPUやヒープのプロファイルは時間やバイト数を見ますが、goroutineプロファイルが数えるのはgoroutineの本数です。数字の意味が違うことを、依頼文に一言書いておくと読み違いが減ります。別言語の同種の調査は、Pythonのメモリリークをtracemallocで特定する記事が参考になります。

goleakの失敗スタックをClaude Codeに渡す

直させる前に、検出結果をファイルに残します。途中で切れないよう、全文を保存します。

go test -count=1 ./... 2>&1 | tee leak.log

-count=1 はテスト結果のキャッシュを使わないための指定です。直前の結果が使い回されると、直したあとの再実行が意味を持たなくなります。

対話セッションで渡す依頼文は、症状・手がかり・禁止事項の3つを入れます。

leak.log は go test の出力で、goleak が goroutine のリークを報告しています。
リークしている goroutine のスタックから、起動している場所と、
終了できずに待っている場所を特定してください。
修正は次の方針で行ってください。
- context のキャンセルと、チャネルの close で終了条件を作る
- goleak の IgnoreTopFunction / IgnoreAnyFunction でテストを通さない
- 直したら go test -count=1 と go vet ./... を実行して結果を貼る

3つ目の禁止事項が重要です。goleakには IgnoreTopFunction と IgnoreAnyFunction があり、特定の関数にいるgoroutineを検出から外せます。本来は、ライブラリの常駐goroutineのように終わらないと分かっているものを除くための仕組みです。Claude Codeに「テストを通して」と頼むと、リークを直さずこの除外で済ませる解決も選択肢に入ります。依頼文で塞ぎます。

同じ指示をCLAUDE.mdに置く

毎回同じ依頼文を書くなら、CLAUDE.md に規約として置きます。

## Goの並行処理
- goroutine を起動する関数には、終了条件(ctx.Done() かチャネルの close)をコメントで書く
- 並行処理を触ったら `go test -race -count=1 ./...` と `go vet ./...` を実行する
- goleak の Ignore 系オプションを足してテストを通さない。原因をコードで直す

CLAUDE.md は、モデルが読んで従う指示です。ほぼ守られますが、毎回の遵守は保証されません。除外を確実に止めたいなら、フックで機械的に検査する方法があります。まず CLAUDE.md で運用し、破られたときにフックへ移す順序が無理がありません。

非対話モードで回す

CIや手元のスクリプトから呼ぶときは、claude -p にログを流します。非対話モードは標準入力を読めます。

cat leak.log | claude -p "goleak が報告した goroutine リークの原因を特定し、\
context のキャンセル漏れを直して go test -count=1 ./... で確かめて" \
  --allowedTools "Bash(go test *),Bash(go vet *),Read,Edit"

--allowedTools に列挙したツールは、確認なしで使えます。Bash(go test *) のような * の書き方は、権限ルールでコマンドの前方一致を表す書式で、Bash(npm run *) が npm run build に一致する例がドキュメントにあります。標準入力は10MBが上限なので、巨大なダンプはファイルに保存してパスを渡します。

Claude Codeに直させるときの典型パターン

リークの原因は、パターンがほぼ決まっています。スタックの「どこで待っているか」を見て、次の3つに当てはめます。

ループにキャンセルの出口がない

select に ctx.Done() がないgoroutineは、チャネルが閉じるまで永遠に待ちます。

// 修正前: events が閉じられないかぎり終わらない
func (s *Server) Watch(ctx context.Context, events <-chan Event) {
	go func() {
		for {
			select {
			case e := <-events:
				s.handle(e)
			}
		}
	}()
}
 
// 修正後: ctx のキャンセルとチャネルの close のどちらでも終わる
func (s *Server) Watch(ctx context.Context, events <-chan Event) {
	go func() {
		for {
			select {
			case <-ctx.Done():
				return
			case e, ok := <-events:
				if !ok {
					return
				}
				s.handle(e)
			}
		}
	}()
}

スタックの先頭がこの select の行なら、このパターンです。依頼文で「ctx.Done() の分岐と、チャネルのcloseの両方を見てください」と書くと、片方だけの修正を防げます。

cancel関数を呼んでいない

context.WithCancel・WithTimeout・WithDeadline は、キャンセル関数も返します。ドキュメントには、キャンセル関数を呼ばないと、親がキャンセルされるまで子とその子孫がリークすると書かれています。呼び出しの漏れは go vet が制御フローを追って検査します。

// 修正前: キャンセル関数を捨てている
ctx, _ := context.WithTimeout(ctx, 5*time.Second)
 
// 修正後: 処理が終わったらすぐ解放する
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()

この種のリークはgoleakより先に go vet ./... で見つかります。Claude Codeには、修正後に必ず go vet を実行させます。

errgroupの派生contextを使っていない

errgroup.WithContext が返す ctx は、最初にエラーを返したとき、または Wait が返ったときにキャンセルされます。この派生 ctx を捨てて、元の ctx を子の処理に渡すと、1つが失敗しても残りが止まらず、ブロックしたままのgoroutineが残ります。

// 修正前: 派生 ctx を使っておらず、1つ失敗しても他が止まらない
g, _ := errgroup.WithContext(ctx)
for _, u := range urls {
	u := u
	g.Go(func() error { return fetchOne(ctx, u) })
}
return g.Wait()
 
// 修正後: 派生 ctx を子に渡す
g, gctx := errgroup.WithContext(ctx)
for _, u := range urls {
	u := u
	g.Go(func() error { return fetchOne(gctx, u) })
}
return g.Wait()

Wait は、Go で起動した関数がすべて返るまで待ち、最初のエラーを返します。Wait を呼ばない経路があれば、その経路も漏れの出口です。SetLimit を使う場合、制限に達すると Go が空きを待ってブロックする点も、スタックを読むときの手がかりになります。

修正をテストで確かめる

直したあとは、リークを再現するテストに goleak を付けて、通ることを確かめます。

func TestFetchCancels(t *testing.T) {
	defer goleak.VerifyNone(t)
 
	ctx, cancel := context.WithCancel(context.Background())
	cancel() // すでにキャンセル済みの ctx で呼ぶ
	_ = Fetch(ctx, []string{"http://example.invalid"})
}

確かめる順序は次のとおりです。

手順

リーク修正の確認手順

  1. 1

    修正前に失敗させる

    直す前のコードで、go test -count=1 がgoleakの報告つきで失敗することを確かめます。失敗しないテストは、修正の効果を測れません。

  2. 2

    Claude Codeに直させる

    失敗ログと禁止事項を渡し、修正を依頼します。

  3. 3

    再実行で通す

    go test -count=1 ./... と go vet ./... を実行し、どちらも通ることを確かめます。

  4. 4

    競合も見る

    並行処理に触れたので go test -race -count=1 ./... も実行します。競合の読み方は、go test -raceのレポートを読む記事で扱っています。

よくあるつまずき

  • VerifyNone を入れたテストが、並列実行で別テストのgoroutineを拾って失敗する。t.Parallel との組み合わせは非対応と明記されています。TestMain の VerifyTestMain に切り替えます
  • TestMain 方式で失敗しても、どのテストか分からない。READMEのスクリプトで1件ずつ回します
  • 起動前から存在するgoroutineを除きたい。goleak.IgnoreCurrent() が、オプション生成時点のgoroutineを以後の検査から除きます。テストの冒頭で呼ぶ使い方です
  • 依存ライブラリの常駐goroutineだけが残る。IgnoreTopFunction か IgnoreAnyFunction に、完全修飾の関数名を渡します。メソッドは go.uber.org/goleak.(*MyType).MyMethod の形です。この除外は人が確認してから足し、理由をコメントに残します
  • プロファイルの数が減らない。debug=2 のダンプで、同じ待ち行で止まっているgoroutineを数えます。数が減るかどうかは、負荷を止めて少し待ってから、もう一度取って判定します

まとめ

goroutineリークは、先にgoleakでテストを失敗させ、その失敗ログと禁止事項をClaude Codeに渡すと、修正の合否が go test の終了コードで決まります。除外オプションで逃げる経路を依頼文か CLAUDE.md で塞ぐことが、この流れの要です。

テストで再現しないリークは、2回取ったgoroutineプロファイルの差分から出発して、再現する最小のテストを書く材料にします。原因が ctx の扱いに絞られているときは、go vet の検査だけで終わる場合もあります。長く動かさないと増えないリークは、この手順だけでは足りないことがあるので、取得を数時間おきに繰り返して数の推移を見ます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. pkg.go.dev/go.uber.org/goleak
  2. pkg.go.dev/net/http/pprof
  3. pkg.go.dev/runtime/pprof
  4. pkg.go.dev/context
  5. pkg.go.dev/golang.org/x/sync/errgroup
  6. code.claude.com/docs/en/headless
この記事を共有:XはてブLinkedIn