Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

社内で初めてビルドツール Bazel を導入した話

はじめに

こんにちは、 Gunosy Tech Lab 所属の hyamamoto です。 最近は暇さえあればパルデア地方をウロウロしています。 この記事は Gunosy Advent Calendar 2022 の 15 日目の記事です。

14 日目の記事は johnmanjiro さんの Go でサクッと GitHub CLI の拡張機能を作る でした。

さて今回は Bazel を社内で初めて導入した話をします。 知見がない中、手探りの導入を行ったのでまだまだ使いこなせてはいないのですが、 導入に際しての Tips や今まだ困っていることなどをご紹介することで、Bazel 導入を悩んでいる人への手助けになれば幸いです。

導入の背景

今回 Bazel の導入に至った背景として扱っているプロジェクトの特性が以下のような性質を持っていることが挙げられます。

  • Go, Python, Rust の 3 言語を扱うモノレポ構成である
  • Python から Go, Rust を FFI で呼び出すためのバインディングをビルドしている
    • ビルドに言語とプロジェクトをまたがった依存がある
  • Python は複数のパッケージにより構成されており、リポジトリ内でパッケージ間の依存がある
    • 物によって poetry による pyproject.toml と setup.py を書き分ける必要が出てきた
    • プロジェクト内に複数の venv が発生し、また変更の度に更新対象となる依存先を考えて再インストールの必要がでていた
      • venv の中のパッケージが更新されないため、venv を削除して作り直すということを行っていた

Bazel とは

Bazel は Google が開発したビルドツールです。 簡単に Bazel の特徴をご紹介します。

Bazel の特徴

  • 多言語をサポートしており、モノレポ構成の大規模プロジェクトに向いている
  • 導入企業や OSS も多くコミュニティーが活発で比較的情報を調べやすい
  • 変更検知に基づく粒度の細かいキャッシュによる高速なビルドができる
  • sandbox 環境という独立したビルド環境を持つため、開発環境への依存が少ない
  • Starlark という Python に近い言語で複雑なビルドロジックを記述できる
  • Skaffold からネイティブにサポートされており、k8s 上へのデプロイが容易

以上が Bazel の簡単な特徴であり、本プロジェクトで導入に至った理由でもあります。

比較対象

モノレポに向けたビルドツールの比較は monorepo.tools によくまとまっています。 今回の比較対象としては GNU MakePants を対象としました。

GNU Make

GNU Make は Makefile というファイルにビルドロジックを記述することでビルドを行うツールです。 ツールとして特定の言語を扱うことを前提としていないため、拡張性は高いですが、単体で複雑なビルドロジックを記述するのが難しいという課題があります。 また、Bazel が持つようなキャッシュ機構を持たないため、当初の課題であった依存解決の自動化や高速化が難しいです。

このため、今回は採用から外しました。 一方で、プロジェクト間の依存が存在しない場面や単一言語で複数のバイナリを作る場合などには GNU Make は適していると思います。 簡単なタスクランナーとしても優秀です。

Pants

Pants は Twitter が開発したビルドツールです。 Pants は Bazel と比較してビルドロジックの記述が少なくなるように設計されており、初学者でも比較的用意に扱うことができます。 特に、元々は Python のみのモノレポを想定して作られたツールであるため、Python との相性は良いです。 キャッシュ機能も保持しており、Bazel と同様にビルド時間が短くなることが期待されます。

一方で、今回のプロジェクトでは Rust や Helm を採用しており、Pants はこれらに非対応・アルファ版対応となっており採用から外しました。

導入の流れ

まず、デモプロジェクトを作成し、そこで今回試したいビルドロジックの検証を行いました。 初手で本番プロジェクトに採用すると Conflict や不具合が発生する可能性があるため、デモプロジェクトで検証を行うことで安全に導入できると考えました。 また、今回のプロジェクトはモブプロで行っていたのですが、デモプロジェクトにおける検証は 1 人で行いました。 というのも、誰も全く知見がない作業をモブプロで行うには効率面で課題があったためです。

デモプロジェクトを通じてプロジェクト導入に向けた PoC だけでなく、Starlark でのビルドロジックの記述方法や Bazel に関する情報の調べ方などを学ぶことができました。 その後、PoC の結果をチームメンバーに伝え正式にプロジェクトに取り込むことが決まりました。

モブプロにおいては自身が基本モブに徹することで、チームメンバーに Bazel の知見を共有しつつスムーズに実装を進めるようにしました。 今現在、私はこのプロジェクトを離れ気味ではあるのですが、Bazel の課題についてもチームメンバーで解決しているため、ソロ作業とモブプロを組み合わせた試みは良かったと思っています。

導入時に得た開発上の Tips

ここでは Bazel 導入時に早く知りたかった開発時に役立つ Tips を紹介します。

Bazel の出力フォルダ構成

Bazel はビルドの成果物をシンボリックリンクでリポジトリ内に作成します。 当初、謎にいくつかシンボリックリンクが作成されているなというのは認識していたのですが、そのそれぞれの役割については深く考えていませんでした。 しかしながら、どういうファイルがどこに作成されているのかを知ることは、Bazel のデバッグ時に大きく役立ちます。

出力ディレクトリのレイアウトについては Output Directory Layout に詳しくまとめられています。

細かい内容は上記ドキュメントに譲りますが、Bazel の出力フォルダをデバッグ時に漁ることになった場合は、ぜひ上記ドキュメントを参考にしてください。 個人的には bazel-bin にビルド成果物が作成されているを知らずに初期は開発していたため、非効率だったなと感じています。

また、自身の PC 上における Bazel の出力フォルダ構成を知るためには、bazel info コマンドを実行すると良いです。

--sandbox_debug オプションでデバッグ

Bazel のビルドには --sandbox_debug というオプションがあります。 これは、ビルド時に作成される sandbox という一時ディレクトリをビルド後も残すためのオプションです。 sandbox ディレクトリには、ビルド時に作成されたファイルや実行ファイルなどが格納されています。 このため、ビルド時に作成されたファイルや実行ファイルを確認したい場合には、このオプションを付けてビルドを実行すると良いです。 特にビルドに失敗したが要因がわからない場合において、実際の実行環境が確認できるためデバッグに役立ちます。 この際、上記で示した Bazel の出力フォルダ構成を知っていると、どのファイルがどこに作成されているのかの確認が容易になります。

Starlark に慣れる

Bazel を用いたビルドロジックを作成する際に Starlark に慣れ親しむことは重要です。 理由としてドキュメントが未成熟なプロジェクトも多く設定オプションの挙動がよくわからなかったり、デバッグ時に出てくるエラーは Bazel と直接関係ないものがでてくるため、原因を特定するには Bazel が実際にどういうロジックを動かしているかを知る必要があるためです。 また、ある rule と他の rule の API をつなぐ際など少し API が想定しているユースケースと異なる使い方をする場合にも、自分で Starlark による定義を用意することでかなり柔軟に設定を行うことができます。

Starlark に慣れるための第一歩としては Bazel の rule の実装を clone してきて自分の環境ですぐ参照できる状態にすることが有効だと思います。 おおよそ API の公開場所は defs.bzl に記述されているため、これをエントリーポイントとして参照することで、実際の挙動を確認することができます。 例えば rules_rust の場合はここになります。

GitHub でコード検索

Bazel はコミュニティーが大きく情報が多いという特徴を述べました。 これはある程度事実ではあるもの詳細な実装やユースケースを含めた技術ブログなどは少ないです。 要因としてはどうして各社のプロジェクト構成に大きく依存しており、外部に切り出して公開することが難しいことや、 そもそも大規模なプロジェクト以外に適用するメリットが少ないため、個人のアウトプットとしても少ないということが考えられます。

そのため、Bazel に関する情報を探す際には GitHub で利用したい API 名をそのまま検索することが有効です(それでも物によっては検索ヒット数はかなり少ないですが)。 例えば Rust の build.rs を実行するための API に関しては以下のように検索できます。 検索結果を Starlark に絞り込むことも重要です。

github.com

sandbox 環境であることを理解する

Bazel の実行が sandbox 環境という特殊な環境で実行されることを意識に置くことも重要です。 初期は手元ではない謎のフォルダで実行されているんだなぁぐらいの理解でしたが、環境パスもホスト環境と異なることを把握しておらずハマった経験があります。 例えば、ビルド内で呼び出したいコマンドラインツールや共有ライブリも sandbox 環境からは見える状態ではありません。 これを避けるためには、これらのツールすべてを Bazel でビルドするか、絶対パスで指定して Bazel に渡す必要があります。 前者の場合は Bazel で完結するもののビルド時間が伸びることにつながり、後者の場合は開発環境や実行環境への依存が増えることにつながります。 この辺りの何をホスト環境への依存とするかなどは模索しながら進めると良いと思います。

開発環境も合わせて整備する

私がデモプロジェクトの段階で手を抜いていて、後ほど問題になった内容として開発環境の整備・検証を十分に行っていなかったことがあります。 Bazel は各言語環境も自前で用意しているため、開発環境も Bazel に合わせて整備する必要があります。 特に Bazel は thirdparty の package を独自の仕組みで管理しているため、interpreter の設定を Bazel 向けに行う必要があります。

Rust の場合

rules_rust は thirdparty の package 情報を rust-analyzer に渡すための設定を行う必要があります。 この設定にやや詰まったので Rust の知識に偏ってしまいますが、詳しめに書かせて頂きます。

まず、rust-project.json というファイルを出力する task を記述します。 特に gRPC のような proto ファイルから Rust のコードを Bazel 経由で生成する場合は、生成された Rust コードへの参照を IDE に伝えるためにこのファイルは必要になります。

一方で、rust-project.json を利用した場合、デフォルトでは cargo check が実行されません。 このため VSCode の setting.json で以下の記述を追記しています((ただし、これはプロジェクトルートに Cargo.toml が存在するプロジェクトのみ有効です。))。

  "rust-analyzer.checkOnSave.overrideCommand": [
    "cargo",
    "check",
    "--message-format=json",
    "--features=check"
  ],

ここで --features=check というプロジェクト固有の feature を指定しています。 というのも、Bazel によるビルド時には自動的に生成した Rust コードが読み込まれるものの、 外部から cargo check を実行した際にはこれらのコードを明示的に読み込まないと利用されずコンパイルエラーになってしまうためです。

よって、cargo check 時にのみ読み込まれるモジュールを以下のように記述することで、この問題を回避しています。

// rust analyzer が実行する cargo check 時に Bazel が生成した binding を読み込むための設定
#[cfg(feature = "check")]
mod foo {
    include!(concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/../bazel-bin/foo__bindgen.rs"
    ));
}

Go の場合

rules_go は thirdparty の package 情報を gopls に渡すための設定を行う必要があります。 その内容は Editor setup にまとまっているので参考にしてください。

覚悟を持つ

割とネタなようですが、Bazel 導入に際してチーム全体で覚悟を持つというのは大事かなと思います。 Bazel は確かに便利なツールではあるのですが、難易度が高く皆で粘り強く学び取り組むことは重要です。 今回の導入時にも「便利は便利、でも辛いは辛い」という前提で始めたのでチーム内の期待値コントロールを行いながら進めることができたのかなと思います。

導入の結果と今後の課題

最後に、導入の結果と今後の課題について述べます。

導入の結果

導入の結果、以下のようなメリットが得られました。

  • CI の記述がシンプルになり管理が用意になった
  • 自動的に依存解決をしてくれるので、人間が手動で成果物を削除することがなくなった
  • 言語に関係なく Bazel コマンドでビルドやテストが実行できるようになった
  • ビルドロジックが各コンポーネントのフォルダで記述されているので、見通しが良くなった
  • 最終的に Skaffold につながることからも一気通貫での開発が可能になった
  • 開発環境が Bazel に合わせて整備された

今後の課題

  • 意外とビルドが遅い
    • 我々は CircleCI でビルドを行っているのですが、ビルド時間が意外にも長いことに気づきました
    • bazel-remote による remote cache も導入はしていますが、cache hit が意外と低かったり、通信部分にも時間がかかったりしているようです
    • CircleCI 自体の cache 機構も試したのですが、cache の読み込み・更新だけで 15 分ほどかかっており現実的ではありませんでした
    • 切実にビルド実行環境には悩んでいるので知見があれば教えていただきたいです...
      • ビルドが早くなることを目指して採用したので、それが遅いのはとても辛いです
  • Bazel 実行環境の差異による難しさ
    • 弊社のエンジニアは主に M1 Mac を利用しており、一方でデプロイ先の環境は x86_64 なインスタンスです
    • Bazel の場合 Bazel で作った成果物をそのまま Docker image に埋め込むのですが、この環境差異から手元から Skaffold を叩くことが不可能になっていしましました
      • 手元で Build するバイナリは arm 向けのバイナリで作成されるため
    • Bazel は cross-compile の機能も充実してはいるものの、現状では十分に調査・対応することはできていません
    • また、C++ のライブラリビルドの際にはホスト環境の C++ コンパイラを利用しており、このバージョンのズレも実行環境とのズレを生む原因になっています
    • これに関しては Bazel を実行するための Docker image を用意することで対応することを考えています

おわりに

今回は Bazel を導入した話をしました。 あらためてなかなか銀の弾丸は存在しないんだなぁと思わされました。 一方で、我々の使い方が悪い設定もまだまだ沢山あると思うので、今後もカイゼンしていきたいと思います。

次回も私 (hyamamoto) が「家造りとソフトウェアエンジニアリング」 というタイトルで記事を書きます。 引き続き楽しんでもらえると幸いです!