記事のサマリー(TL;DR)
- Go 製プロダクトの CI が SonarQube の C1(ブランチカバレッジ)計測ツール gobco のアーキテクチャ起因で 40 分超に肥大化していた
- 並列化チューニング・キャッシュ修正など複数の施策が期待外れに終わり、計測ツール自体を社内製 bcov へ置き換えたことでブランチカバレッジ計測が 32.3 分→4.2 分(-87%)、CI 全体が 40.7 分→12.8 分(-69%)に短縮
- 「実測前の思い込みで動かない」「1回の計測で判断しない」「ツールの構造的限界まで掘り下げる」の 3 点が最大の学び
GitHub Actions × SonarQube 構成を持つ開発チームが参照すべき改善ポイント
マネーフォワードのこの事例は、GitHub Actions の GitHub-hosted runner(プライベートリポジトリでは標準 2vCPU・8GB)を使い、SonarQube で C0(行カバレッジ)と C1(ブランチカバレッジ)の両方を PR ごとに計測している構成に広く当てはまります。特に SonarQube の Quality Gate で「New Code カバレッジ」の条件を課している場合、PR 時の C1 計測を省略する回避策は取れず、ツールの設計そのものがボトルネックになりやすい点は注目に値します。
日本の SaaS 開発現場でも GitHub Actions + SonarQube の組み合わせは珍しくなく、かつ CI 高速化の取り組みは「並列化」や「キャッシュ最適化」といった表層施策で頭打ちになるケースが多いです。本記事が示すように、actions/cache のキー設計不備(プライマリキー完全一致でスキップされる仕様)は Go 固有ではなく GitHub Actions 全般の落とし穴であり、ワークフロー内でステップを条件付きスキップしているチームは一度キャッシュ保存条件を見直す価値があります。また、bcov が利用した Go の -overlay フラグのように、言語ランタイムが提供する低レイヤーの仕組みへのアクセスが根本的な高速化に直結した点は、他言語・他ツールチェーンでの改善検討にも示唆を与えます。
詳細
背景と課題
マネーフォワードの開発者 @luccafort 氏は、退職前の置き土産として担当プロダクトの CI 改善に着手しました。対象は組織横断型のプロダクトで、他チームから「指定日までにリリースしたい」「デプロイ後すぐ反映したい」という要望が頻繁に寄せられていました。
PR を出すたびに CI が 40〜50 分かかるという状態は、レビューのフィードバックループ全体を遅くし、開発体験を大きく損なう要因になっていました。
開発環境の前提
- 言語: Go(go.mod では Go 1.25 系を指定)
- CI: GitHub Actions(ubuntu-latest の GitHub-hosted runner。プライベートリポジトリのため標準スペックは 2vCPU・8GB)
- チェックアウト: actions/checkout
- Go セットアップ: actions/setup-go
- ビルドキャッシュ: actions/cache(restore と save に分割した構成)
- 静的解析・カバレッジ計測: SonarQube(レポート送信は sonarqube-scan-action)
何が課題だったか
このプロダクトでは、PR ごとに SonarQube による静的解析とカバレッジ計測を CI で実行していました。カバレッジ計測は 2 段構成で、C0(行カバレッジ:各命令文が最低 1 回実行されたか)と C1(ブランチカバレッジ:if/switch などの各分岐が実行されたか)の両方を生成し、SonarQube へレポートとして渡していました。
C0 のステップは数分程度で完了する一方、C1 のステップだけで CI ジョブ全体の 7〜8 割の時間を占めており、これが CI 全体を 40 分台後半まで押し上げていました。
SonarQube の Quality Gate には「New Code カバレッジ」条件が設定されており、この判定には PR 自身の差分を含む C1 の計測結果が必要でした。そのため「PR のタイミングでは C1 計測を省略し、後でまとめて計測する」という単純な回避策は取れず、品質ゲートを維持したまま速くする方向でしか解決できない制約がありました。
まずは実測から
当初は「並列度を上げれば速くなる」「C1 計測をスキップできるかもしれない」という 2 つの仮説を持っていました。後者は前述の理由から早々に否定されました。
最適化に着手する前に、CI ランナーの実態を診断ログで確認したところ 2vCPU であることが判明します。パブリックリポジトリでは 4 コアが割り当てられますが、プライベートリポジトリではデフォルトで 2 コアになります(GitHub 公式ドキュメントによる仕様)。
CPU 使用率はすでに 2 コア分をほぼ使い切っており、GNU Parallel などで並列ワーカー数を増やしても同じ 2 コアを取り合うだけで速度向上は見込めません。この時点で「並列度チューニング」の方向性は「効果がない」と判断し、早々に打ち切りました。
同じ悩みを抱えている方は、まず自分たちのランナーが何コアなのかを確認するところから始めることをおすすめします。
効果があった施策
依存関係を軽くするリファクタリング
カバレッジ計測対象のパッケージが、HTTP トレーシングや ORM などの重い外部ライブラリに間接的に依存していたことが判明しました。薄いパッケージへ切り出すリファクタリングを複数回重ね、依存数を数百単位で削減し、個々の変更では計測時間の短縮を確認できました。
ただし、後述するツール自体の構造的なオーバーヘッドが支配的だと判明したため、費用対効果が低いと判断し、この施策はこの時点で打ち切りました。
ビルドキャッシュの不具合修正
調査中に CI のビルドキャッシュの保存・復元ロジックに不具合が発見されました。
- プライマリキー完全一致でスキップ: actions/cache は同一プライマリキーがヒットすると保存処理自体が走らない仕様です。main ブランチへの push では C1(gobco)のステップをスキップしていたため、go.sum 変更後の最初の main push で保存されるキャッシュは C1 のビルド成果物を含まない「C1 コールド」な状態になります。その後、次に go.sum が変わるまで、スケジュール実行や PR のすべてがそのコールドなキャッシュを参照し続ける構造になっていました。
- restore-keys の表記ゆれ: プレフィックスに
something-wrong-とsomething-wrong-1という表記ゆれがあり、go.sum 変更時のフォールバック復元が機能していないことも判明しました。
再発防止として、キャッシュのステップを「復元専用(すべての実行)」と「保存専用(週次スケジュール・手動実行などフル C1 を実行するジョブのみ)」に分割し、保存キーに run_id を付与することで同一キーへの保存が拒否される問題を解消しました。
効果がなかった・見送った施策
4vCPU ランナー(GitHub Larger Runners)への移行: 単価が 2 倍になる一方、時間が半分になるかどうかは実測しないと分からず、コスト影響の確認とチーム内合意が必要でした。後述のツール置き換えで十分な効果が出たため、棚上げしました。
キャッシュ修正の過大評価: キャッシュ不具合修正後は「33 分台→19〜23 分台まで縮む」と期待していましたが、これは誤りでした。複数回実測を重ねた結果、この差は主に CI ランナーの性能ばらつきによるものと判明しました。1 回の計測結果だけで判断しなかったことで無駄な実装を避けられましたが、当初の期待値そのものが誤りだったという誤算でもあります。
カバレッジ計測ステップの PR からの除外: 品質ゲートの必須要件として見送り。将来的には外せる可能性もありますが、現時点では未確定のため実施が必要という結論になりました。
大規模リファクタリング: 依存削減をさらに進める案は、既存テストで担保している品質を崩さない制約の中では得られる効果が限定的で、工数に見合わないと判断し着手を見送りました。
これらは「永久に却下」ではなく、ツール置き換えで得られた効果を踏まえて優先度を下げたものです。4vCPU 化などは残存ボトルネックが再び目立つようになれば、費用対効果を計算し直す余地があります。
計測ツール(gobco)の構造的な限界
ここまでの施策でも 10 分程度の改善(着手前比 20% 程度)はできましたが、40 分台からは大きく縮まりませんでした。掘り下げた結果、遅さの根本的な原因は使用していたブランチカバレッジ計測ツール gobco のアーキテクチャにありました。
gobco の処理フローは以下の通りです。
- 対象パッケージを列挙(数十パッケージ規模)
- 各パッケージについて gobco を起動
- モジュール全体を一時ディレクトリへコピー
- 型情報・依存関係を解析
- 対象パッケージのテストを実行
- 分岐カバレッジの JSON を生成
- これをパッケージの数だけ反復(限られた並列度で実行)
- 生成された JSON をすべて 1 つのファイルへ逐次マージ
- 最終的なブランチカバレッジレポートを生成
並列実行はされているものの、同じモジュールのコピー・共通依存の解析・プロセス起動・一時ファイルの作成がパッケージの数だけ重複して発生します。2vCPU という制約下では、この反復コストがそのまま C1 ステップの実行時間を支配していました。
キャッシュの効きやリファクタリングでは解消できない、ツールの構造に起因する問題であることが明確になりました。
計測ツールの置き換え(gobco → bcov)
そこで、社内で開発されている別のブランチカバレッジ計測ツール bcov への置き換えを、今年入社の新卒エンジニアが提案しました。
bcov は Go 言語の -overlay フラグの仕組みを利用しています。これは、ディスク上の実ファイルを書き換えることなく、ビルド時に特定のファイルを別の内容に仮想的に置き換えたり追加・削除したりできる機能です(Linux の OverlayFS とは異なります)。
bcov の処理フローは以下の通りです。
- 対象パッケージをまとめてロード
- ソースを一度解析して分岐カウンターを挿入
- overlay 用の一時ファイルを生成(元のソースコードは変更しない)
- 対象パッケージ全体に対して
go testを 1 回だけ実行 - 実行結果を内部で集約し、レポートを直接生成
「1 回だけ実行する」といっても全テストが 1 つのプロセスで動くわけではなく、Go 自体はパッケージごとにテストバイナリをビルド・実行します。削減されたのは、外側から gobco を何十回も起動してモジュールのコピーや解析を繰り返していた部分です。
ツール置き換え時の注意点:分岐の数え方の違い
分岐(if/switch/for/select など)の数え方がツールによって異なるため、置き換え前後でカバレッジ率は単純比較できません。
例えば、次のような複数値の case を持つ switch 文があった場合:
switch value {
case 1, 2, 3:
return "small"
default:
return "other"
}
- 旧ツール(gobco): case 内の値ひとつひとつを個別の分岐として計上
- 新ツール(bcov):
case 1, 2, 3をまとめて 1 つの分岐として扱う
この違いにより、カバレッジ % の分母・分子が変わります。「厳密に同じ基準を求めるか、新しい基準をベースラインとして受け入れるか」はチーム内で事前に合意してから移行することが重要です。
結果
同一の GitHub-hosted 2vCPU ランナーでの実測比較です(いずれも 1 回の実測値のため、目安としてご参照ください)。
| 項目 | Before(gobco) | After(bcov) | 削減率 |
|---|---|---|---|
| ブランチカバレッジ計測 | 32.3 分 | 4.2 分 | -87% |
| 行カバレッジ計測 | 3.2 分 | 3.3 分 | ほぼ変化なし |
| CI ジョブ全体(SonarQube) | 40.7 分 | 12.8 分 | -69% |
「50 分近く待たされていた CI が 10 分台になった」という体感に近い数字まで縮められました。
ただし、この -87% という削減率は「パッケージ数が多く、旧ツールのモジュールコピー・再コンパイルの繰り返しがボトルネックの大半を占めていた」という今回の構成だからこそ出た数字です。パッケージ数が少ないリポジトリや、テスト実行自体が支配的な構成では、同じ削減効果は見込めません。
また、ツール乗り換え後は XML 生成の失敗やカバレッジ数値の急な変動が起きていないかを監視する期間を設けました。速度だけでなく「乗り換え後に正常に機能しているか」を見張る期間もセットで設計することが重要です。
学び
今回の調査を振り返ると、「思い込みを実測に裏切られ続けた」過程でもありました。「並列度を上げれば速くなるはず」も「キャッシュさえ直せば劇的に縮むはず」も、実測してみると期待通りにはいきませんでした。逆に、着手当初は本命候補ですらなかった「計測ツールそのものの置き換え」が最終的に最も効いた施策になったのも想定外でした。
今回の改善から得られた教訓を整理します。
- 最適化の前に環境の前提を実測して確認する: 思い込みで手を動かすと的外れな施策に時間を浪費する
- 1 回の計測結果で結論づけない: CI 実行時間はランナー性能のばらつきが大きく、複数回の実測で裏取りしないと誤った期待値を持ってしまう
- 表面的な改善の積み重ねには頭打ちがある: ツール自体の構造的なオーバーヘッドが支配的な場合、根本原因まで掘り下げて計測方式・ツールそのものの置き換えを検討する
- 仮説の当たり外れに一喜一憂せず、実測ベースで淡々と検証する: 「効果がありそう」で始めた施策ほど外れ、「ダメ元」に近かった施策が本命になることもある
- ツールの効果は自分たちのコード構成に強く依存する: 他社事例の削減率をそのまま鵜呑みにせず、自分たちの構成でボトルネックがどこにあるかをまず特定する
actions/cache に関する共通の注意点
actions/cache はプライマリキーが完全一致した時点で保存処理そのものをスキップする仕様です。「特定の条件でのみステップをスキップする」ワークフローと組み合わせると、意図せず古い成果物だけがキャッシュに固定され続けることがあります。これは Go・SonarQube 固有の話ではなく、GitHub Actions でキャッシュを使うすべてのワークフローに共通する落とし穴です。心当たりのある方はキャッシュの保存条件を一度見直すことをおすすめします。