Upgrade to Pro — share decks privately, control downloads, hide ads and more …

go test を速くする

Avatar for sivchari sivchari
August 24, 2026
420

go test を速くする

Avatar for sivchari

sivchari

August 24, 2026

More Decks by sivchari

Transcript

  1. whoami @sivchari LayerX バクラク事業部 (2026 -) CI/CD・AI Agent 基盤などの開発基盤を担当 Go

    Conference 主催 / golang.tokyo 運営 OSS kumo Kubernetes maintainer ArgoCD Envoy Gateway Kubernetes Contributor Award 2025 受賞 © LayerX Inc.
  2. 前提 LayerX の Go monorepo バクラクを支える巨大な Go monorepo 数千の package、多数のサービスが同居

    PR のたびに全 package を go test するのは現実的ではない そこで affected package testing 変更されたファイルから「影響を受ける package」だけを算出してテストする 2023 年から存在する仕組み (by @izumin5210) © LayerX Inc. 3
  3. 前提 affected package testing の仕組み PR の変更ファイル一覧 git diff /

    GitHub API ファイル → 所属 package 起点 package 集合 import の逆依存を辿る 影響を受ける package 集合 対象ディレクトリ配下だけに絞る go test $packages 影響のない package は実行しない © LayerX Inc. 4
  4. 入社時点のボトルネック 検出のために go list が走っていた 旧実装は golang.org/x/tools/go/packages (= go list

    のラッパー) NeedImports は 3rd party module まで含めて依存を解決する go mod download か module cache の restore が事実上必須 「テストを減らすための検出」のために module 解決を待つのは本末転倒 © LayerX Inc. 5
  5. 観察 本当に必要な情報はなにか 欲しいのはリポジトリ内の import グラフだけ 3rd party の変更は go.mod /

    go.sum の変更として別途扱えばよい グラフ構築時にリポジトリ外の import は捨てるだけ 型チェックも、ビルドも、module 解決も要らない Go の import 関係は ソースコードの import 宣言だけで決まる import 宣言は AST の入り口にある。そこまで読めば止まっていい © LayerX Inc. 6
  6. 解決 1 | AST 時点で落とす go/build.ImportDir だけで import を集める 各

    .go を go/parser の parser.ImportsOnly モードでパースする package 節と import 宣言を読んだら、本文を読まずに即 return 型チェックも、コンパイルも、module 解決もネットワークも一切起きない 標準ライブラリだけで完結。 go list の起動すら不要 © LayerX Inc. 8
  7. 解決 1 | AST 時点で落とす 実装: ディレクトリを歩いて import を集める 本体の

    import もテストの import も、区別せず同じグラフに入れる テストだけが使う package の変更も、同じ仕組みで拾える あとは変更ファイルの package から逆依存 map を辿るだけ © LayerX Inc. 9
  8. 解決 1 | AST 時点で落とす 結果: 検出は数十秒で終わる sparse checkout +

    affected 検出まで含めて数十秒で完了 go mod download も module cache の restore も不要 「対象外 package のビルド (型チェック) すらしたくない」を実現 コンパイルの検証は別 workflow (go-build) が全量で担保する役割分担 検出側は import 解析だけに専念できる © LayerX Inc. 10
  9. 解決 2 | パッケージ構成で依存を切る 前提: 全 service 入りの universal バイナリ

    バクラクは複数 service からなる分散構成 service ごとにバイナリをビルド・デプロイするのは手間が大きい そこで全 service を 1 つに束ねた universal バイナリを採用 エントリポイントの server package が全 service を import する 起動時の指定で「どの service として動くか」を切り替えられる 詳細: connect-go による複数サービスの開発とユニバーサルバイナリによる改善 © LayerX Inc. 13
  10. 解決 2 | パッケージ構成で依存を切る server package の中身: 自動生成された「対応表」 server package

    の中にある自動生成ファイル (コード上の名前は registry) 名前から初期化関数を引く表 → そのために全 service を import する 中身は import と map リテラルだけで、分岐もロジックも無い © LayerX Inc. 14
  11. 解決 2 | パッケージ構成で依存を切る 原因: 全 service を import する「対応表」

    server package に同居していたもの 全 service を import する対応表 (自動生成) server 起動の logic テスト 逆依存グラフのハブになっていた どの service を 1 つ変えても affected に入る 全 service を埋め込むテストバイナリの link に数十秒 かかり、 しかもキャッシュが効かない © LayerX Inc. svc A svc B … svc N server package 対応表 + logic + テスト どの service を変えてもここが affected 15
  12. 解決 2 | パッケージ構成で依存を切る 対応表を、テストを持たない子 package に切り出す 対応表にテストしたいロジックは無い。問題はテストと同居していたこと Before server

    package = 対応表 + logic + テスト → テストが全 service を link する After 対応表はテストを持たない子 package へ server package は logic のみ (service import ゼロ) テストは fake の対応表を注入して数秒に © LayerX Inc. ↓ 16
  13. 解決 2 | パッケージ構成で依存を切る package を分けただけで、link が消えた affected 検出のコードは 1

    行も変えていない テストを持つ package が、全 service を import しなくなった 全 service を link するテストバイナリが、そもそも作られない テストを持たない package は compile されるだけ (build cache が効く) 効果 全 service の link 数十秒 → compile のみ数秒 test job の中央値が分単位で短縮 © LayerX Inc. 17
  14. 解決 3 | テストの無いビルドを消す テストの有無は、import を集めた時点で分かる go/build.Package はファイルと import を役割ごとに別フィールドで返す

    フィールド GoFiles / Imports TestGoFiles / TestImports XTestGoFiles / XTestImports ImportDir © LayerX Inc. 中身 package 本体 package foo のテスト (internal test) package foo_test (external test) の戻り値に「テストを持つか」の情報が最初から含まれている 19
  15. 解決 3 | テストの無いビルドを消す テストが無い package はテスト対象にしない 追加の走査ゼロ・ go list

    実行ゼロで判定できる 対応表は全 service を import したままなので、分離後も必ず affected に入る ただしテストを持たないので、入っても「コンパイルするだけ」 affected から除外し、コンパイル検証は go-build workflow に任せる テストを持たない package だけの run は test job を丸ごとスキップ 大型 runner + MySQL/Postgres/Redis 起動込みの数分が消える © LayerX Inc. 20
  16. 解決 4 | キャッシュの取り方を変える tar を展開し終わるまで、テストは始まらない affected を絞っても、残ったテストは build cache

    を復元してから始まる actions/cache 方式 = 数 GB の tar を実行前に全部展開する 実測 100s の内訳は、ダウンロード 4s に対して展開が 95s GOCACHEPROG なら、必要なものだけをその場で取れる © LayerX Inc. 22
  17. 解決 4 | キャッシュの取り方を変える リクエストは並行に飛んでくる GOCACHEPROG に渡すのは、自分で書いたプログラム go build は多数の

    goroutine から並行に get / put を投げてくる リクエストには ID が振られており、レスポンスは順不同で返してよい (protocol の仕様) なので、リクエストごとに goroutine を起こして並行に処理できる © LayerX Inc. 23
  18. 解決 4 | キャッシュの取り方を変える 常駐プロセスを 1 つ立てて、S3 を集約する GOCACHEPROG は

    go コマンド 1 回につき 1 プロセスだけ起動される テストは go run で wrapper を起動し、その中から go test を呼ぶ構成 1 ステップの中で go が何度も起動する。そのままだと毎回 S3 に繋ぎ直すことになる そこで役割を 2 つに分けた は CI job に 1 つだけ常駐し、S3 と手元の cache を持つ。起動直後から先読みする connect は GOCACHEPROG として起動され、socket で serve に取り次ぐだけ serve serve は go test の前のステップで起動する。そこから先読みが始まる warm job build cache を 64 個のチャンクに分ける © LayerX Inc. → S3 manifest + チャンク (tar.zst) × 64 → serve (常駐 1 つ) S3 と手元の cache を集約 → connect × 多数 go test の各プロセス 24
  19. 解決 4 | キャッシュの取り方を変える 分割で転送量は減らない。効いたのは待ち時間 Go は build cache を

    action ID の先頭 1 バイトごとのディレクトリに置く (00〜ff の 256 個) warm job はこれを 4 個ずつまとめ、64 個のチャンクにして S3 に置く action ID は SHA-256 なので、どのディレクトリに入るかは一様に分布する 数千件のエントリを引けば 256 個すべてに当たる → ほぼ全部のチャンクが要る つまり転送量は tar 方式と変わらない 効いたのは、ダウンロード (ネットワーク) と展開 (CPU) をパイプライン化できること チャンクがその単位。細かすぎると 1 つあたりの overhead、粗いと並行度が落ちる 実測で 64 に決めた (32 では 293s、64 で 270s) © LayerX Inc. 25
  20. まとめ go test を速くする = やらないことを増やす 検出を速くする: 汎用ツールの便利さの代償を知る 欲しいのが import

    グラフだけなら、AST の入り口だけ読めばいい グラフを小さくする: 逆依存のハブはパッケージ構成そのものを直して潰す 対象を減らす: 言語が構造として持つ情報を使い切る Test / XTest が分かれているので「テストの無い package」を追加コストなしで判定できる 全部展開しない: キャッシュも要る分だけその場で取る 1〜2 割の PR では test job 自体が走らない。走る分も同条件の実測で 373s → 270s © LayerX Inc. 27