
Summary
この文書の要点
- サービスごとのforループをやめ、必要な複数ターゲットを一度のBazel呼び出しへ渡して、依存関係に基づく並列実行を任せます。
- Docker起動をすべての検証へ課さず、インプロセステスト、Bazel管理のテスト、変更対象だけのコンテナsmokeへ責務を分けます。
- 巨大な共通ライブラリと過剰なdepsを分割し、依存グラフの幅を広げてクリティカルパスを短くします。
- 端末性能が上限になったら、再現可能性を整えてリモートキャッシュを先に導入し、キャッシュミス時の実行能力が不足する段階でRBEを検討します。
Bazelを導入しただけでは、ビルド全体は速くなりません
Bazelが最適化できるのは、Bazelへ渡された依存グラフと、その中に定義されたactionです。シェルスクリプトがサービスを一つずつ選び、そのたびにBazelを起動し、成果物をdocker loadし、HTTP応答を待ってから次へ進む構成では、Bazel内部に並列化できる仕事が見えてきません。各サービスのビルドが速くても、全体は外側の待ち行列に支配されます。
反対に、一度の呼び出しへ何でも詰め込めばよいわけでもありません。ワークスペース全体を意味する//...は、安定ブランチで常に成立させたい重要なターゲットですが、変更のたびに全サービスを作ると不要な分析とactionまで増えます。原則は『必要な複数ターゲットを一度に渡す』ことであり、『常に全ターゲットを作る』ことではありません。
導入後の性能改善では、計測対象をBazelの実行時間だけに限定しません。CIジョブ開始から成果物検証の完了までを全体時間とし、その中をランナー、検証、依存グラフ、実行資源の四層に分けます。この境界が曖昧なまま--jobsだけを増やすと、メモリ不足やI/O競合を増やしながら、実際の直列区間は残り続けます。
最初にプロファイルと全体時間から、待っている場所を特定します
BazelのJSON Trace Profileには、actionの同時実行数、CPU使用量、メインスレッド、ガベージコレクション、クリティカルパスが記録されます。まず同じターゲット集合について、キャッシュのないclean相当と、変更後のincrementalを分けて記録します。両者を混ぜると、依存グラフの改善とキャッシュヒットの効果を区別できません。
プロファイル上でaction countが低いままなら、実行可能なactionが不足しているか、依存関係の直列化、リソース予約、外部処理による待機を疑います。CPUが余っているのにクリティカルパスが一本だけ長い場合は、--jobsを増やしても短くなりません。反対に、実行可能なactionが多いのにCPUとメモリが張り付くなら、ローカル資源またはリモート実行の問題です。
Bazel外の時間は、ランナー側でも区間を記録します。ターゲット選定、Bazel呼び出し、成果物転送、docker load、コンテナ起動、HTTP Probe、後片付けを別々に計測します。終了時刻だけを比較するのではなく、Bazelが仕事をしていない空白時間を見つけることが、最初の改善になります。
bazel build //services/... \
--profile=/tmp/bazel-profile.json.gz
bazel analyze-profile /tmp/bazel-profile.json.gzボトルネック1:ランナーのforループを、一度のBazel呼び出しへ畳みます
サービスごとにbazel buildを呼ぶforループは、毎回トップレベルターゲットを一つしか渡しません。Bazelサーバーの状態やローカルキャッシュを再利用できる場合でも、サービスAの完了までサービスBのactionを開始できないという直列化は残ります。ランナーが依存順序を手作業で決めるほど、Bazelのスケジューラが全体を見て配置する余地は小さくなります。
修正は、リリースや検証に必要なラベル集合を先に決め、それを一つのbazel buildまたはbazel testへ渡すことです。対象がservices配下の全ルールなら//services/...、明確な成果物だけなら個別ラベルを複数並べます。ワークスペース全体を確認する時だけ//...を選びます。対象選択と実行順序を分離し、対象はランナー、順序は依存グラフからBazelが決める境界にします。
複数サービスの成果物を後段へ渡す場合も、Bazel呼び出しを分ける必要はありません。トップレベルターゲットを集約する専用のtarget patternや、明示的なラベル一覧を生成し、1回の呼び出しの結果として必要な成果物を取得します。失敗を最後まで収集したいCIでは--keep_goingも選択肢ですが、失敗した依存の下流は実行できないため、完了時間とのトレードオフを計測して決めます。
- 対象が全体なら//...、一領域なら//services/...、限定された成果物なら複数の明示ラベルを使う。
- ランナーは対象を選ぶ。依存順序と並列実行はBazelへ任せる。
- 一括化の前後で、analysis時間、action数、クリティカルパス、Bazel外の空白時間を比較する。
for service in api worker gateway; do
bazel build "//services/$service:image"
donebazel build \
//services/api:image \
//services/worker:image \
//services/gateway:imageボトルネック2:Docker検証を、速い確認と配布物の確認へ分けます
成果物ごとにdocker loadし、コンテナを起動し、HTTP Probeを待つ検証は、イメージ展開のI/O、ポート割り当て、プロセス起動、準備完了待ちを伴います。100を超えるサービスを一台で同時に起動できないなら、単純な並列数の引き上げは解決になりません。ディスクI/Oとメモリを奪い合い、タイムアウトや不安定な検証を増やす可能性があります。
最初に、検証の目的を分けます。HTTP handler、認証判定、ルーティング、シリアライズなど、プロセス境界を必要としない振る舞いはインプロセスで確認します。GoならhttptestのResponseRecorderやServerを使い、他の言語でもhandlerまたはアプリケーションをテストプロセス内で起動します。これらをBazelのtest targetへ置けば、依存グラフに沿ってキャッシュと並列実行を利用できます。
一方、エントリポイント、イメージ内のファイル配置、非root権限、OS依存ライブラリ、実際の待ち受けを確認するsmokeは、Dockerを外せません。ここは変更されたサービスだけ、または代表的なイメージ集合だけに絞り、独立したポートと作業領域で並列化します。単体・契約テストでコンテナ検証を置き換えるのではなく、各検証が保証する境界を小さくする設計です。
Bazel testではsizeやtimeoutを実態に合わせ、重いテストの資源予約を正しく宣言します。shardingに対応するテストは分割できますが、同じDocker daemonや共有DBを取り合うテストを無条件に並列化してはいけません。外部状態へ依存する検証はキャッシュ可能なhermetic testと区別し、実行条件と後片付けを明示します。
- インプロセス:handler、ルーティング、入力検証、認証判定、レスポンス契約。
- コンテナsmoke:起動コマンド、成果物配置、権限、待ち受け、ヘルスチェック。
- 統合検証:DBやキューなど外部状態との接続。共有資源と再実行時の初期化を明示する。
- サービス数ではなく、変更影響と検証責務から起動するコンテナ数を決める。
ボトルネック3:巨大な共通ライブラリを分け、依存グラフの幅を取り戻します
全サービスが一つの巨大な共通ライブラリへ依存すると、そのビルドがクリティカルパスの入口になります。内部に互いに無関係なHTTP補助、データ変換、監視、認証、生成コードが同居していれば、どれか一つの変更で大きなtargetが無効化され、すべての下流が完了を待ちます。CPUが空いていても、依存上まだ実行できないactionは並列化できません。
Bazelの推奨は、実際に必要な直接依存だけを宣言し、buildableなファイルを含むディレクトリを適切なpackageへ分けることです。BUILD定義を小さくする目的はファイル数を減らすことではなく、変更の無効化範囲と依存辺を小さくすることです。分割後もすべてのサービスがすべてのtargetへ依存していれば、グラフの形は変わりません。
queryでtarget graph、cqueryで設定後のtarget graph、aqueryで実際のaction graphを確認します。somepathで意図しない共通targetへの経路を探し、aqueryのsummaryで生成・コンパイル・リンクなどのaction増加を追います。分割の効果はBUILDファイルの行数ではなく、変更時に再実行されるaction数とクリティカルパスの短縮で判定します。
共通化そのものが悪いわけではありません。安定し、計算量が小さく、多数のサービスが本当に使う契約は共有に向きます。問題は、変更頻度が高い機能実装や重い生成処理まで便利な共通targetへ集めることです。安定したinterfaceと変わりやすいimplementationを分け、visibilityで利用範囲を制限すると、再び巨大targetへ戻ることを防げます。
- 変更頻度、計算量、利用サービス数の三軸で共通targetを評価する。
- 直接使う依存だけをdepsへ置き、推移依存への偶然の参照と過剰な依存を減らす。
- package分割、target分割、visibility、依存辺の修正を一組として行う。
bazel query \
'somepath(//services/orders:image, //libs/common:core)'
bazel aquery \
'deps(//services/orders:image)' \
--output=summaryボトルネック4:ローカル資源の上限を、キャッシュとRBEで別々に解きます
依存グラフに十分な幅があり、実行可能なactionも多いのに、CPUやメモリが上限へ達する段階ではローカル資源がボトルネックです。--jobsは実行できるjob数の上限であり、増やすほど常に速くなる値ではありません。メモリを使うコンパイルやテストを過密にすると、スワップ、ガベージコレクション、I/O待ちが増え、かえって全体が遅くなります。
最初の選択肢はリモートキャッシュです。Bazelはactionの入力、出力名、コマンド、環境変数などから結果を識別し、同じ結果があれば別の端末やCIで再利用できます。ただし、キャッシュは実行能力を追加しません。低い層の変更やキャッシュミスで大量のactionが必要になれば、ローカル端末がすべて実行します。
RBEは、buildとtestのactionを複数のremote workerへ分散し、キャッシュミス時の実行そのものをスケールさせます。端末差を減らせる一方、ruleとtoolchainがremote executionに適合し、入力が宣言され、ホスト上の未追跡ツールや暗黙の環境変数へ依存しないことが前提です。ネットワーク転送、queue時間、worker構成、費用も新しい運用対象になります。
導入順は、再現性の確保、キャッシュヒット率の計測、remote cache、必要ならRBEが基本です。再現不能なactionをそのまま共有すると、キャッシュ汚染や端末間の不一致を広げます。cache writerをCIへ限定して開発端末はread-onlyにするなど、認証、書込権限、保持期間もビルド設計に含めます。
- remote cache:すでに存在するaction結果を再利用する。キャッシュミスは実行しないと埋まらない。
- RBE:キャッシュミスを含むaction実行をremote workerへ分散する。
- --jobs:並列上限。CPU、メモリ、I/O、テスト資源の実態より大きくしない。
- 共有前提:入力、toolchain、環境変数、出力が再現可能で、認証と書込権限が管理されている。
build --remote_cache=<REMOTE_CACHE_ENDPOINT>
build --remote_upload_local_results=false100サービスを超えたら、全件起動ではなく変更影響から検証対象を決めます
サービス数が増えるほど、毎回すべてをビルドしてすべてを起動する方針は、端末性能だけでなくフィードバック時間の設計として成立しにくくなります。Bazelの依存グラフを使う価値は、全件を高速に処理することだけではなく、変更の影響を受けるtargetへ処理量を絞れることにもあります。
日常の変更では、変更されたpackageのreverse dependencyから必要なbuildとtestを選び、対象ラベルを一度にBazelへ渡します。安定ブランチや定期実行では//...と広いtest suiteを通し、日常経路の選択漏れを検査します。高速経路と網羅経路を分けることで、すべての変更へ最も重い検証を課さず、検証範囲の誤りも放置しません。
コンテナsmokeも同じ考え方です。変更サービス、共通のベースイメージや起動規約の影響を受けるサービス、代表的な異種構成を対象にします。100個を同時起動する代わりに、独立した少数のworkerで有界に並列実行し、失敗時のログ、終了コード、起動時間を成果物として残します。
- pull request:変更影響のbuild、test、必要なコンテナsmokeを一括実行する。
- stable branch://...と広いtest suiteでターゲット選定の漏れを検出する。
- 定期実行:clean build、低いcache hit条件、代表的な統合検証を計測する。
改善は一層ずつ適用し、同じ指標で前後を比較します
複数の改善を同時に入れると、速くなった理由と不安定になった理由を切り分けられません。最初にランナーの呼び出しを一括化し、次に検証責務を分け、その後に依存グラフを細粒度化します。ローカルで十分な並列性が得られてからremote cacheとRBEを評価すると、基盤費用でグラフの欠陥を隠しにくくなります。
各段階では、wall timeの中央値だけでなくP95、analysis時間、実行action数、クリティカルパス、平均同時action数、CPUとメモリのピーク、remote cache hit、remote queue、Docker起動時間を記録します。速度改善がP50だけに現れ、P95が悪化した場合は、競合やネットワーク待ちを増やした可能性があります。
最終的な完了条件は、最速の一回を作ることではありません。同じ変更条件で再現して速いこと、失敗時にどの層が詰まったか分かること、キャッシュが空でも許容時間で完了すること、開発端末とCIで同じ成果物を扱えることを確認します。
- 1. ランナー外側の直列呼び出しを除く。
- 2. インプロセス、Bazel test、コンテナsmokeへ検証責務を分ける。
- 3. クリティカルパス上の巨大targetと過剰なdepsを分割する。
- 4. 実行可能actionが資源上限へ届いたらremote cache、必要ならRBEを導入する。
- 5. cleanとincremental、P50とP95を分け、同じ条件で回帰を監視する。
リモート化しても解消しない詰まりがあります
remote cacheは、同じaction keyの結果が存在する時だけ効きます。毎回変わるtimestampを成果物へ埋め込む、未宣言のホストツールを使う、環境変数が端末ごとに異なる、といったactionはヒット率を下げます。低いヒット率をネットワークやcache容量だけの問題と決めつけず、execution logとaction入力を比較する必要があります。
RBEも、直列な依存グラフを並列にはできません。クリティカルパス上の一つの重いactionは、より速いworkerへ移せても一つのままです。また、Docker daemonへ直接依存するテストや、端末上の状態を前提にする処理は、そのままremote workerへ送れない場合があります。
コンテナ検証を減らす時は、失った保証を明示します。インプロセステストでは、イメージのentrypoint、ファイル所有者、OSライブラリ、signal処理は分かりません。速い検証を増やしつつ、配布物を実際に起動する少数のsmokeを残すことが、速度と信頼性の現実的な境界です。
Bazelの外側に順番待ちを作らず、内側の一本道を短くします
Bazel導入後の高速化で最も重要なのは、サービスごとの手続きではなく、必要なターゲット集合と依存関係を一度に渡すことです。ランナーが順序を握らなければ、Bazelは実行可能なactionを同時に進められます。その上で、Dockerを必要としない検証をtest targetへ移し、巨大な共通依存を分割すると、並列に流せる仕事の幅が広がります。
CPUとメモリが本当に上限へ達した時に、remote cacheは再利用を、RBEは実行能力を追加します。どちらも、再現可能なactionと正確な依存グラフの代わりにはなりません。外側の直列化、内側のクリティカルパス、ローカル資源の上限を順に計測し、それぞれに対応する手段を選ぶことが、規模が増えても維持できるBazel運用になります。
参考資料
計測方法、依存関係、テスト、リモートキャッシュ、リモート実行の仕様は、次の公式資料で確認できます。Bazelのフラグやexperimental機能はバージョンによって変わるため、実際に利用するバージョンの文書も併せて確認してください。


