🔍

goreach: 実行されないGoコードを見つけるツールを作った

に公開

まえがき

ここ3~4年くらいで、転職・転勤・単身赴任・子が生まれる・ ベイスターズが日本一になる など目まぐるしく日々が回っています。
単身赴任の生活も落ち着き始めると、今度は週末にどう過ごして良いのかわからなくなり、個人開発で謎のアプリを量産しては時間を浪費しています。

つい最近も「ランチマップみたいなものを共有しつつ、どの飲食店へどれくらいの頻度で行ったかを競い合えるアプリ」を作り、職場のメンバーと共有したりして遊んでいました。

※気になる方はこちら logru help
https://logru.link/

AIの登場でこういった遊びがすぐ出来るのは良いのですが、リポジトリ内のコードでデッドコードが多いことが気になり始めました。
ログを仕込むのも面倒だということで、Go のネイティブなカバレッジ機能を使って本番の到達性を分析するツール goreachを作りました。[1]

https://github.com/yag13s/goreach

goreach とは

goreach は実行中の Go サービスから「どのコードが実際に通っていないか」を特定するツールです。

go build -cover によるカバレッジ計測と、runtime/coverage パッケージを組み合わせて、サービスを止めずにカバレッジデータを収集・分析できます。

WebUIもコマンド経由でlocalで見られます。[2]

internal/handler/ai_search.go の validationError.Error() が呼び出されていないということが分かる
Web UI: パッケージ・ファイル・関数ごとのカバレッジと、未到達コードブロックをソース付きで確認できます

主にやれることは以下の通りです。

  • 本番トラフィックで到達していない関数・コードブロックを JSON レポートで出力
  • 複数ビルドバージョンのカバレッジを自動マージ
  • ブラウザで未到達コードをインタラクティブに確認(Web UI)
  • S3 / GCS / Azure などクラウドストレージへのカバレッジ送信(GCS, Azureは試してないです)
  • lambda などのサーバレスでも実行可能(条件付き)

なぜ作ったか

テストで 90% カバーしていても、本番で実際に通るパスは一部だったりしますよね。
特に AI が書いたコードだと、無理やりテストを通すためのテストを書いてたりしていて、あんまり信用ならん!というのが本音だと思います。

知りたかったのはシンプルに「このコード、本番で使われてんのか?」というものです。
デッドコードの削除、リファクタリングの優先順位付け、不要なエラーハンドリングの整理など、コードの到達性が分かると判断がしやすくなります。

Go にはもともと go build -coverGOCOVERDIR という仕組みがあるので、そこに乗っかれば大掛かりな計装なしでやれるかも?と思い作ってみました。

使い方

基本的な流れ

3 ステップで使えます。

1. カバレッジ付きでビルド

go build -cover -covermode=atomic -o myserver ./cmd/myserver

2. GOCOVERDIR を指定して実行

mkdir -p /tmp/coverage
GOCOVERDIR=/tmp/coverage ./myserver

プロセス終了時にカバレッジデータが /tmp/coverage に書き出されます。

3. goreach コマンドで分析

goreach analyze -coverdir /tmp/coverage -pretty

これだけで、関数ごとのカバレッジと未到達ブロックが JSON で出力されます。ブラウザで見たい場合は view コマンドを使います。

goreach analyze -coverdir /tmp/coverage -pretty -o report.json
goreach view -src . report.json

CLI コマンド一覧

コマンド 機能
analyze カバレッジデータを分析して JSON レポートを出力
merge 複数レポートを統合(関数ごとに最大カバレッジを採用)
view ブラウザで Web UI を起動
summary テキスト形式でサマリーを出力

Flush SDK: サービスを止めずにカバレッジを取る

基本的な使い方だと、プロセスを停止しないとカバレッジデータが書き出されません。
本番サービスをいちいち止めるわけにはいかないので、goreach には Flush SDK という組み込み用ライブラリを用意しました。
我々エンジニアはせっかちな生き物なのですぐに結果を欲しがってしまいますね。

import "github.com/yag13s/goreach/flush"

flush.Enable(flush.Config{
    Storage:      flush.LocalStorage{Dir: "/var/coverage"}, // カバレッジファイルの保存先
    ServiceName:  "myserver",                               // サービス識別名(キーのプレフィックスに使用)
    BuildVersion: version,                                  // ビルドバージョン(git hashなど)
    Interval:     5 * time.Minute,                          // 定期フラッシュの間隔(0で無効)
    Clear:        true,                                     // フラッシュ後にカウンターをリセット
})
defer flush.Stop() // シャットダウン時に最終フラッシュ

標準ライブラリ + runtime/coverage のみで動きます。
-cover なしでビルドしたバイナリでも安全に no-op になります。

Flush SDK 自体は GOCOVERDIR なしで動きますが、クラッシュ時のセーフティネットや警告抑止のために設定しておくのがおすすめです。

トリガー方式

用途に合わせて複数のトリガーを選べます。

方式 用途 設定
定期実行 長時間稼働サーバー Config{Interval: 5 * time.Minute}
手動 Lambda、リクエスト単位 flush.Emit()
HTTP CronJob、外部トリガー flushhttp.Handler()
シグナル バッチ処理、非 HTTP flush.HandleSignal(syscall.SIGUSR1)
シャットダウン 全プロセス共通 defer flush.Stop()

Storage インターフェース

カバレッジの送信先は Storage で抽象化しています。

type Storage interface {
    Store(ctx context.Context, files []string, meta Metadata) error
}

ローカルディスク、S3、GCS、Azure など好きなバックエンドを使えます。S3 の場合はこんな感じです。
呼び出し側がアップロード関数を渡すだけなので、goreach SDK 自体はクラウド SDK に依存しません。

import "github.com/yag13s/goreach/flush/objstore"

storage := &objstore.Storage{
    Upload: func(ctx context.Context, key string, body io.Reader) error {
        _, err := s3Client.PutObject(ctx, &s3.PutObjectInput{
            Bucket: &bucket, Key: &key, Body: body,
        })
        return err
    },
}

パフォーマンスへの影響

気になるオーバーヘッドですが、AWS Lambda(ARM64, 256MB)での実測値はこんな感じです。

構成 p50 レイテンシ ベースラインとの差
ベースライン(通常ビルド) 58.4 ms
-cover のみ(flush なし) 62.7 ms +4.3 ms (+7%)
-cover + S3 flush(毎リクエスト) 123.0 ms +64.6 ms

-cover の計装自体は約 4ms の追加で、バイナリサイズも +0.1% 程度でした。

長時間稼働するサーバーでも定期 flush にすれば、リクエストあたりのコストはほぼゼロになるはずです。知らんけど。

内部の仕組み: AST 解析とマルチビルドマージ

AST 解析で未到達ブロックを特定する

go tool covdata の出力は行番号の範囲とステートメント数だけです。「どの関数のどの部分が通っていないか」を知るには、ソースコードの AST と突き合わせる必要があります。

goreach の analyze コマンドでは以下の処理をしています。

  1. go tool covdata textfmt でカバレッジデータをテキストプロファイルに変換
  2. go/parser でソースコードの AST を解析し、関数ごとの範囲(行・列)を取得
  3. カバレッジブロックと関数の範囲を突き合わせ、未到達ブロックを特定

これにより「この関数の 42〜45 行目が到達していない」というレベルで特定できます。

マルチビルドのマージ

Kubernetes などで Rolling Update すると、同じサービスの異なるビルドバージョンが同時に稼働します。ビルドが変わると covmeta のハッシュが変わるので、単純にデータを結合できません。

goreach はこれを以下のように処理しています。

  1. covmeta.<hash> のハッシュでディレクトリをビルドバージョンごとにグルーピング
  2. 最新ビルドは AST 解析で詳細な未到達ブロックを取得
  3. 古いビルドは go tool covdata func でカバレッジ率のみ取得
  4. マージ時は関数ごとに最大カバレッジ率を採用

古いビルドのほうがカバレッジが高い場合でも情報が失われないようにしています。

さいごに

実際にlogru (冒頭出てきた自作謎アプリ) ではデッドコードの整理やリファクタリングの判断材料として使いました。

指標
導入前 6,899 行
現在 4,197 行
削減 -2,702 行 (-39%)

未使用ルーティングなどを消したら4割近いコードが消せたのでスッキリしました。(元が汚すぎる)
オーバーヘッドとか色々心配してたのもあんまり影響がなくもっと大きなシステムでも試してみたいなと考えています。

ただこの辺りは運用も含めあんまり詳しくない内容も含まれているのでフィードバックなどをいただけると非常に嬉しく思います。みんなデッドコードの管理とかどうしてるんだろう。

https://github.com/yag13s/goreach

脚注
  1. goreach と言いつつ通っていない部分を見つけるものなので矛盾しているのに後で気づきましたが、目を逸らすことでこの問題を回避しています。(通っている部分もわかるのでヨシ) ↩︎

  2. 正直、JSONで出力できればWebUIで確認は不要でAIにそのまま食わせれば良いのですが、UIがあったほうがテンションが上がるので実装しました。 ↩︎

Discussion