Chapter 01

Bazelの基本的な仕組み

Kesin11
Kesin11
2020.11.08に更新

Bazelは以前から気になっていたもののなかなか機会がなかったのですが、最近v1.0になったという噂を聞いたのとiOS界隈でBazelを活用する事例を目にすることが増えたので、秋の4連休を使ってBazelに入門してみました。

TypeScript + Bazelの組み合わせを試してそこそこ理解できたのですが、全ての解説を書くと1記事では収まりそうになかったのでZennの本にしてみました。最初の1章ではBazelの仕組みと、Bazelを使うとなぜビルドが高速になるのかを説明したいと思います。

なお、なるべく公式ドキュメントから学んだ内容を紹介しているつもりですが、自分のBazel経験はまだ1週間足らずですので理解が浅かったり誤っているところもあるかと思います。詳しい方から見て誤っている内容がありましたらTwitterなどで訂正コメントを頂けると助かります。(Zennの本では記事に対してコメントできないため)

まず、以前の自分のBazelについての前知識は以下のようなものでした。

  • ビルドが早い
  • リモートキャッシュが強力
  • 依存関係が明確

これらはそれぞれ正しいのですが、それを実現しているのはGoogleの謎技術ではありません。「ビルド」という工程をいかに再現性があるものにするかという思想・実装から生じる結果なのです。これについて順を追って説明していきます。

ビルドの再現性

これはBazelの思想の根幹をなすものです。ビルドの再現性とは、端的に言えばビルドに使用するinputが同じであれば何度ビルドし直そうともoutputは必ず同一になるということです。

ではビルドにおけるinputとoutputとは何でしょうか?outputはコンパイルされた実行バイナリやコンテナイメージが一般的ですね。一方でinputとして最も分かりやすいのは自分が書いたソースコードですが、よくよく考えてみるとビルドという工程の裏では様々な要素がinputとして使われているのです。

  • 外部のライブラリ
  • 設定ファイル
  • 環境変数
  • コンパイラなどのビルドに使うツール(ツールのバージョンも重要)
  • 実行ホストのOSの種類、バージョン

私達は普段特に意識することなくローカルでのビルドを繰り返し、CI環境でのビルド設定をしているかと思いますが、それぞれの環境は完全に同一ではありません。違うマシンでビルドした場合は何らかの差異によってビルド結果が微妙に異なってしまっても何ら不思議ではないのです。

Bazelは様々な方法を組み合わせることで、どの環境で実行されてもinputに相当する要素が同一となるようにしています。これによりBazelを使用したビルドは再現性がとても高くなっています。

依存関係と設定ファイル

https://docs.bazel.build/versions/3.6.0/build-ref.html#types_of_dependencies

Bazelではビルドに必要なファイルの依存関係を”全て明示的に”指示する必要があります。例として、TypeScriptをjavascriptにコンパイルするts_projectというBazelの設定(rule)を見てみましょう。

ts_project(
  name = "lib", # outputを後に参照するための名前なので一旦関係ない
  srcs = glob(["src/**/*.ts"]),
  tsconfig = "tsconfig.json",
  deps = [
    "@npm//lodash",
    "@npm//@types/lodash",
  ],
  # declarationやsouce_mapなどはtsconfig.jsonと一致しない場合はエラーになるので必ず合わせる
  declaration = True,
  source_map = True,
)

通常のTypeScript開発であればtsconfig.jsonを少し変更してtscを実行するだけで済みますが、Bazelではこのように全てを明示する必要があります。特に依存するライブラリをdepsに全て書く必要がある点は通常のts/js開発とは異なるでしょう。

全ての依存関係が明示的に書かれていることで、Bazelはinputとoutputの関係をすべて把握します。ts_projectの場合はコンパイルされたjsのファイル全てを後工程で参照が可能ですし(name = "lib"はそのために意味を持ちます)、inputをこのようにgraphvizで図示することも可能です。

Bazelのビルドではdepsに必要なパッケージを1つでも書き忘れてしまうとビルド時にtscからファイルが存在しないというエラーが発生します。この理由は後ほどsandboxの項で説明します。

ビルドに使うツール

https://bazelbuild.github.io/rules_nodejs/install.html

ビルドにはコードや外部ライブラリ以外にコンパイラやパッケージマネージャも必要です。Bazelはこのあたりも徹底しており、まずBazel自身の各言語に対応したプラグイン的なもの(rules)からバージョンとsha256を明示してダウンロードする必要があります。nodejsの場合はrules_nodejsダウンロードします。

load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_archive(
    name = "build_bazel_rules_nodejs",
    sha256 = "4952ef879704ab4ad6729a29007e7094aef213ea79e9f2e94cbe1c9a753e63ef",
    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/2.2.0/rules_nodejs-2.2.0.tar.gz"],
)

load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories")

そしてrules_nodejsの場合はオプショナルですが、バージョンやパッケージマネージャのyarnのバージョンも明示的に指定します。

node_repositories(
    package_json = ["//:package.json"],
    node_version = "8.11.1",
    yarn_version = "1.5.1",
)

rules_nodejsではnodejsのバイナリをダウンロードするurlなど、さらに詳細を指定することも可能です。気になる人はドキュメントを覗いてみてください。

このように、ビルドに使うツールはBazel自身を拡張するものも含めて全てを明記し外部からダウンロードすることで、ビルドマシンの環境に元々インストールされているツールに一切依存しないビルドが実現されています。

sandbox

https://docs.bazel.build/versions/3.6.0/guide.html#sandboxed-execution

ここまでBazelがinputに相当する要素を厳格に明示する必要があることを見てきましたが、ここまで厳格であってもまだ実行ホストの環境によってビルド結果に差異が生じる可能性はあります。例えば環境変数の差異などです。

自分もまだ詳しく分かっていないのですが、BazelはOSの機能を利用してビルド時に完全に隔離されたsandboxを作り、その中でビルドを行います。[1]

一般的なビルドツールではカレントディレクトリや、プロジェクトのルートディレクトリを起点にして必要なソースコードを参照し、ビルド結果は例えばdistoutputなどのディレクトリに出力するでしょう。


Bazelでは ~/.cache/bazel/_bazel_codespace/67085502ec47a674aa5100ec0f33102e/execroot/typescript_ts_project のような全く別のディレクトリに必要な全てのソースコードをコピーし、隔離されたsandbox環境でビルドを行います。

先程のts_projectの例で、depsに1つでも依存パッケージを書き忘れてしまうとファイルが存在しないというエラーが発生すると説明した理由がまさにここにあります。依存関係に含まれるファイルのみがsandboxにコピーされるため、1つでも抜けてしまうと当然ビルド時に存在せずエラーとなってしまうのです。

実際に意図的に@types/lodashをコメントアウトしてからビルドし、そのときにsandboxの状態がどうなっているのか確認してみます。

ts_project(
  name = "lib",
  srcs = glob(["src/**/*.ts"]),
  tsconfig = "tsconfig.json",
  deps = [
    "@npm//lodash",
    # tscが失敗するように@types/lodashを削除してみる
    # "@npm//@types/lodash",
  ],
  declaration = True,
  source_map = True,
)

# @types/lodashが存在しないことでエラーになる
$ bazel build --sandbox_debug  //...

# sandboxの中のnode_moduelsを調べると、たしかに@types/lodashが存在していないことが分かる
$ ll ~/.cache/bazel/_bazel_codespace/67085502ec47a674aa5100ec0f33102e/sandbox/processwrapper-sandbox/15/execroot/typescript_ts_project/node_modules/
total 24K
drwxr-xr-x 3 codespace codespace  20K Oct 13 00:33 lodash
drwxr-xr-x 5 codespace codespace 4.0K Oct 13 00:33 typescript

キャッシュ

https://docs.bazel.build/versions/3.6.0/bazel-overview.html#how-does-bazel-work
https://docs.bazel.build/versions/3.6.0/build-ref.html#dependencies

ここまで解説してきた機能により、ビルドに必要なinput要素全てが明記され、sandboxにより実行環境の依存性も排除されました。従って、inputのそれぞれの要素が全て同一ならばoutput、すなわちビルド結果もまた全く同じになるはずです。考え方を少し変えると、inputの要素がいずれも変化していないのであれば再ビルドする代わりに前回のビルド結果をそのまま使いまわしても問題がない、ということになります。

例えば1つのリポジトリにモジュールAとモジュールBが含まれており、ビルドを行うとそれぞれからビルド結果としてバイナリが生成されるとします。

  • モジュールA → (ビルド) → バイナリA
  • モジュールB → (ビルド) → バイナリB

そしてこの2つのバイナリをセットとしてv1というタグを付けてリリースするとします。

次のバージョンではモジュールAのみを少し変更する必要がありました。このプロダクトは2つのバイナリをセットでリリースするため、バイナリA'とBがセットでv1.1としてリリースされます。

  • モジュールA' → (ビルド) → バイナリA'
  • モジュールB → (ビルド) → バイナリB

愚直に行う方法は全てのモジュールを再ビルドすることですが、モジュールBは一切変更が行われていないのにも関わらず再ビルドすることになってしまいます。一方、BazelではモジュールBに関するinputが前回と全て同一であることが分かっていますので、ビルドを一切行わずに前回のビルド結果をそのまま持ってきます。

  • モジュールA' → (ビルド) → バイナリA'
  • モジュールB → (前回のビルド結果をそのまま使い回す) → バイナリB

これこそBazelが特にmonorepoやマルチモジュールの構成でビルドが早いとされている理由です。たとえコード全体の規模が非常に大きかったとしても、1つのモジュールしか変更しなかった場合は他の関係のないモジュールは再ビルドされないためビルド時間を圧倒的に短縮できるのです。

実際のところ、各言語のコンパイラやビルドツールはほとんどが何らかのキャッシュ機構を備えているため、Bazelを使わずとも一般的にキャッシュが存在する場合の再ビルドは十分高速でしょう。一方でBazel自身はコンパイラではありませんがこれを可能にしています。Bazelが統一的なキャッシュの仕組みを用意することで、各言語ごとのコンパイラやツールのキャッシュ機構に依存することなく無駄な再ビルドを省くビルドフローが構築されます。これがBazelの強みです。

リモートキャッシュ

https://docs.bazel.build/versions/master/remote-caching.html

Bazelに限らず一般的なビルドツールではキャッシュの仕組みを備えていることは先述しましたが、残念ながら初めてビルドしたときやブランチを切り替えたときのビルドはキャッシュが効きにくいためやはりビルド時間は長くなってしまいます。

Bazelではsandboxによってビルド環境がホストに依存していないので、全てのinputが前回ビルドと同一であるなら再ビルドせずにキャッシュを安全に使い回すことができます。ならば、inputさえ同じであれば別のマシンでビルドされた結果をキャッシュとして使いまわしても問題がないということになります。リモートキャッシュを有効にするとこれを簡単に実現できます。

例えば、チームメンバー全員のマシンとCI環境のビルドマシンで共通のリモートキャッシュを使うと、ブランチを切り替えたり、git cloneした直後であっても自分のマシンで実際にビルドが行われることはありません。誰かがビルド済みであれば、全てリモートキャッシュからビルド済みの結果をもらってくるだけで完了します。キャッシュを取得するだけですので、ネットワークが十分に高速であれば手元で再ビルドするよりも圧倒的に短時間で済むでしょう。

なお、リモートキャッシュはnginxなどで自分でwebサーバーを立ち上げるか、もしくはGCSをリモートキャッシュとして使うことが可能です。有償の特別なサービスを使う必要はありません。

GCSをリモートキャッシュとして使用する具体的な方法は4章で紹介する予定です。

テスト

https://docs.bazel.build/versions/3.6.0/test-encyclopedia.html

Bazelはビルドだけではなくテストの実行も同等に扱います。すなわち、ビルドと同様にテストに使用するソースコード(テストコードや本体コード)、外部ライブラリなどを全て明示する必要があります。

ts_project(
  name = "lib",
  srcs = glob(["src/**/*.ts"]),
  tsconfig = "tsconfig.json",
  deps = [
    "@npm//lodash",
    "@npm//@types/lodash",
  ],
  declaration = True,
  source_map = True,
)

# index.jsを実行してexitコードが0かどうかチェックするだけ
nodejs_test(
  name = "index_test",
  # ts_projectによってsrc/index.tsから生成されたindex.jsを使うという意味
  # ts_projectはsrc/index.tsからsrc/index.jsが生成されることを追跡できている
  entry_point = ":src/index.ts",
  # index.tsはlib/の他の.tsやlodashをimportしている
  # そのため、ts_projectで生成された他のjs(:lib)とlodashを依存物として明示する必要がある
  data = [
    "@npm//lodash",
    ":lib"
  ],
)

Bazelにおけるテストとは、何かを実行してそのexitステータスが0であるか確認することであり、名前はxxx_testという規則になっているようです。上記コードのnodejs_testは単にindex.tsから生成されたindex.jsをnodejsで実行し、そのexitステータスが0であるかどうかを判定しています。2章ではeslintやjestといった実践的なツールを使用する方法を紹介します。

Bazelではテストもビルドと同様にinputが同じであればoutputも必ず同じなるという考え方をしています。すなわち、何度実行しようともテストの結果は必ず同じになるということです。[2]

よってビルドと同様に、inputが変化していないのであれば実際にテストを行わず過去のテストの結果(キャッシュ)を使いまわします。ビルドと同様にリモートキャッシュを使うことも可能です。

巨大なリポジトリであっても全てのテストを再実行する必要はありません。Bazelでは修正した差分が関係するコードだけが再ビルド、再テストされるのでビルドと同様にテストの時間も大幅に短縮することが可能になります。

1章のまとめ Bazelとは

ここまでBazelの基本的な仕組みをそれぞれ解説してきました。最後に全体像としてまとめてみます。

  • Bazelはコンパイルなどを早くする魔法的な技術を使っているわけではない
  • inputを明示させ、sandboxで実行することを前提としたキャッシュの積極的利用により、ビルド・テスト時間を大幅に減らすことを実現している
  • 仕組み上、特定の言語やビルドツールに依存しないのであらゆる言語やビルドツールを組み合わせることが可能

1章ではBazelの仕組みについて自分が理解した内容をまとめました。

次の2章ではnodejsとTypeScriptをBazelで扱う方法を解説する予定です。

脚注
  1. OSやツールなどによってsandboxに隔離することが不可能な場合もあります。その場合はその旨がドキュメントに書かれているはずなので注意する必要があります。 ↩︎

  2. 外部APIを呼び出したりE2Eのテストなどはその前提が通用しませんが、そのようなテストはテストダブルを用いて外部に依存しない形にするか、あるいはBazelとは別の仕組みを使うべきでしょう ↩︎