search term:

Bazel を用いて何でもクロスビルドする方法

一般論として Bazel は、モノバージョンニングといって、(JUnit や Pandas など) どのライブラリでもモノリポ内の全てのターゲットが同一のバージョンを使うという形態を好む。 モノバージョニングは、モノリポ内で発生しうるバージョン衝突を劇的に減らすため、よりコード再利用性を改善させるという効果がある。 しかし、実際に運用してみると全社が二人三脚状態になるという欠点も出てくる。 例えばサービス A、B、C、D の全てが Big Framework 1.1 を使っていると、デグレ (regression) があるかもしれないので全てを同時に Big Framework 1.2 に移植するのは人的負荷が高かったりする。 そんなこんなで数年が経ち、Big Framework 2.0 がリリースされて、やっぱりこれも採用はリスキーなのではということになる。

Scala エコシステムでは、sbt を用いてライブラリ作者がライブラリを複数の Scala 標準ライブラリやその他のフレームワークに対してビルドするというのは普通に行われている。 これはクロスビルドと呼ばれている。(x86 から aarch64 など CPU アーキテクチャをまたいだコンパイルをクロスコンパイルと言ったりするがそれとは別なことに注意)

このクロスビルドという概念は、同ブランチ内で中長期に渡って色々な軸のマイグレーションを可能とすることから、Bazel においても有用なものじゃないかと思っている。 例えば現行のモノリポが Scala 2.12 だとして、徐々にマイグレーションを行ってほとんどのターゲットが Scala 2.12 と Scala 2.13 の両方でビルド可能な状態へ持っていく。 これは、一部のチームが全社に先行して新バージョンを試しつつ、コードベースとしては普通に進んでいくことができる。

local_repository ハック

先週、@ianocさんに Bazel でクロスビルドを可能とする機構を教えてもらった。 僕たちがやったのは Python の外部ライブラリの切り替えだが、本稿では Scala のクロスビルドを実装する。 (思い出すと去年、Long Cao さんがバーに入る行列で待っている間にこの説明を試みてくれた気がするが、当時はこのテクニックが非常に強力なものだと僕がイマイチ理解できなかった。)

まず基本を先に言うと、ルートの WORKSPACE 内でサブディレクトリを参照する local_repository を宣言して、入れ子ワークスペースを作る。 実行時に --override_repository オプションを使って、これを別のワークスペースへとオーバーライドする。 このローカル・リポジトリは定数、マクロ、ファイルを含むターゲットなどを公開することができ、これを使うことで何でもオーバーライドできるはずだ。

Hello world の例

WORKSPACE

WORKSPACE から一部抜粋:

....

rules_scala_version = "56bfe4f3cb79e1d45a3b64dde59a3773f67174e2"
http_archive(
    name = "io_bazel_rules_scala",
    sha256 = "f1a4a794bad492fee9eac1c988702e1837373435c185736df45561fe68e85227",
    strip_prefix = "rules_scala-%s" % rules_scala_version,
    type = "zip",
    url = "https://github.com/bazelbuild/rules_scala/archive/%s.zip" % rules_scala_version,
)

local_repository(
    name = "scala_multiverse",
    path = "tools/local_repos/default",
)

load("@scala_multiverse//:cross_scala_config.bzl", "cross_scala_config")
cross_scala_config()

....

このファイルの残りは rules_scala 参照。

tools/local_repo/default/WORKSPACE

rules_scala_version = "56bfe4f3cb79e1d45a3b64dde59a3773f67174e2"
http_archive(
    name = "io_bazel_rules_scala",
    sha256 = "f1a4a794bad492fee9eac1c988702e1837373435c185736df45561fe68e85227",
    strip_prefix = "rules_scala-%s" % rules_scala_version,
    type = "zip",
    url = "https://github.com/bazelbuild/rules_scala/archive/%s.zip" % rules_scala_version,
)

tools/local_repo/default/BUILD.bazel

# Empty file

tools/local_repo/default/cross_scala_config.bzl

load("@io_bazel_rules_scala//:scala_config.bzl", "scala_config")

MULTIVERSE_NAME="default"
IS_SCALA_2_12 = True

def cross_scala_config(enable_compiler_dependency_tracking = False):
  scala_config(
    "2.12.14",
    enable_compiler_dependency_tracking=enable_compiler_dependency_tracking,
  )

tools/local_repo/scala_2.13/WORKSPACE

rules_scala_version = "56bfe4f3cb79e1d45a3b64dde59a3773f67174e2"
http_archive(
    name = "io_bazel_rules_scala",
    sha256 = "f1a4a794bad492fee9eac1c988702e1837373435c185736df45561fe68e85227",
    strip_prefix = "rules_scala-%s" % rules_scala_version,
    type = "zip",
    url = "https://github.com/bazelbuild/rules_scala/archive/%s.zip" % rules_scala_version,
)

tools/local_repo/scala_2.13/BUILD.bazel

# Empty file

tools/local_repo/scala_2.13/cross_scala_config.bzl

load("@io_bazel_rules_scala//:scala_config.bzl", "scala_config")

MULTIVERSE_NAME="scala_2.13"
IS_SCALA_2_12 = False

def cross_scala_config(enable_compiler_dependency_tracking = False):
  scala_config(
    "2.13.6",
    enable_compiler_dependency_tracking = enable_compiler_dependency_tracking,
  )

hello/BUILD.bazel

load("@io_bazel_rules_scala//scala:scala.bzl", "scala_binary")

scala_binary(
    name = "bin",
    srcs = ["Hello.scala"],
    main_class = "hello.Hello",
)

hello/Hello.scala

package hello

import scala.util.Properties.versionNumberString

object Hello extends App {
  println(s"hello, Scala $versionNumberString")
}

demo 1

$ bazel run //hello:bin
INFO: Analyzed target //hello:bin (25 packages loaded, 460 targets configured).
INFO: Found 1 target...
Target //hello:bin up-to-date:
  bazel-bin/hello/bin.jar
  bazel-bin/hello/bin
INFO: Elapsed time: 0.426s, Critical Path: 0.02s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Build completed successfully, 1 total action
hello, Scala 2.12.14

$ bazel run //hello:bin --override_repository="scala_multiverse=$(pwd)/tools/local_repos/scala_2.13"
INFO: Analyzed target //hello:bin (25 packages loaded, 460 targets configured).
INFO: Found 1 target...
Target //hello:bin up-to-date:
  bazel-bin/hello/bin.jar
  bazel-bin/hello/bin
INFO: Elapsed time: 0.379s, Critical Path: 0.02s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Build completed successfully, 1 total action
hello, Scala 2.13.6

これは、コマンドライン上からビルドに使う Scala バージョンを切り替えれることを示す。

3rdparty 解決の変更 (従来型)

3rdparty 解決処理をどのように行っているかによるが、基本的な考え方としてはロックファイルか deps.bzltools/local_repos/default/tools/local_repos/scala_2.13 に入れるということだ。

bazel-multiversion の場合は、このような手順となる:

echo 'multiversion_config(scala_versions = ["2.12.14"])' > 3rdparty/jvm/BUILD
bin/multiversion import-build --output-path=tools/local_repos/default/jvm_deps.bzl
echo 'multiversion_config(scala_versions = ["2.13.6"])' > 3rdparty/jvm/BUILD
bin/multiversion import-build --output-path=tools/local_repos/scala_2.13/jvm_deps.bzl

そして WORKSPACE 内で:

WORKSPACE

load("@scala_multiverse//:jvm_deps.bzl", "jvm_deps")
jvm_deps()
load("@maven//:jvm_deps.bzl", "load_jvm_deps")
load_jvm_deps()

demo 2

$ bazel build //core/src/main:main
....
Target //core/src/main:main up-to-date:
  bazel-bin/core/src/main/main.jar
INFO: Elapsed time: 14.986s, Critical Path: 13.53s
INFO: 10 processes: 4 internal, 2 darwin-sandbox, 4 worker.
INFO: Build completed successfully, 10 total actions

$ bazel query 'deps(//core/src/main:main)' | grep '@maven//:com.*ssl-config'
@maven//:com.typesafe/ssl-config-core_2.12/0.6.1/ssl-config-core_2.12-0.6.1-sources.jar
@maven//:com.typesafe/ssl-config-core_2.12/0.6.1/ssl-config-core_2.12-0.6.1.jar
@maven//:com.typesafe_ssl-config-core_2.12_0.6.1_-1177452640

$ bazel build //core/src/main:main --override_repository="scala_multiverse=$(pwd)/tools/local_repos/scala_2.13"
....
Target //core/src/main:main up-to-date:
  bazel-bin/core/src/main/main.jar
INFO: Elapsed time: 14.648s, Critical Path: 13.32s
INFO: 10 processes: 4 internal, 2 darwin-sandbox, 4 worker.
INFO: Build completed successfully, 10 total actions

$ bazel query 'deps(//core/src/main:main)' --override_repository="scala_multiverse=$(pwd)/tools/local_repos/scala_2.13" | grep '@maven//:com.*ssl-config'
@maven//:com.typesafe/ssl-config-core_2.13/0.6.1/ssl-config-core_2.13-0.6.1-sources.jar
@maven//:com.typesafe/ssl-config-core_2.13/0.6.1/ssl-config-core_2.13-0.6.1.jar
@maven//:com.typesafe_ssl-config-core_2.13_0.6.1_-1177452640

ソースコードの切り替え

Scala バージョンなどによってソースコードごと切り替えてしまった方が便利な場合もある。 ローカル・リポジトリから変数を公開できるので、簡単に実装できる。

IS_SCALA_2_12 という変数を定義したことを思い出してほしい:

IS_SCALA_2_12 = True

先ほどの hello world の例で別のソースコードを使いたいとすると、以下のように実装できる:

hello/BUILD.bazel

load("@io_bazel_rules_scala//scala:scala.bzl", "scala_binary")
load("@scala_multiverse//:cross_scala_config.bzl", "IS_SCALA_2_12")

scala_binary(
    name = "bin",
    srcs = ["Hello.scala"] if IS_SCALA_2_12 else ["Hello_2.13.scala"],
    main_class = "hello.Hello",
)

demo 3

$ bazel run //hello:bin --override_repository="scala_multiverse=$(pwd)/tools/local_repos/scala_2.13"
....
hi, Scala 2.13.6!

コマンドライン・オプションの隠蔽

コマンドライン・オプションが冗長なので、これを隠したいとする。 これは .bazelrc ファイルを使って行う。

bazelenv

#!/usr/bin/env bash

MODE=$1
function usage() {
  echo "usage ./bazelenv [<multiverse>]"
  echo ""
  echo "available multiverses are:"
  candidates=$(/bin/ls tools/local_repos)
  echo "$candidates"
}
if [[ "$MODE" == "" ]]; then
  usage
elif [[ -d tools/local_repos/$MODE ]]; then
  echo "common --override_repository=scala_multiverse=$(pwd)/tools/local_repos/$MODE" > ".bazelenv"
else
  usage; exit 1
fi

.bazelrc

try-import ".bazelenv"

demo 4

$ chmod +x bazelenv
$ ./bazelenv
usage ./bazelenv [<multiverse>]

available multiverses are:
default
scala_2.13
$ ./bazelenv default
$ bazel query 'deps(//core/src/main:main)' | grep '@maven//:com.*ssl-config'
@maven//:com.typesafe/ssl-config-core_2.12/0.6.1/ssl-config-core_2.12-0.6.1-sources.jar
@maven//:com.typesafe/ssl-config-core_2.12/0.6.1/ssl-config-core_2.12-0.6.1.jar
@maven//:com.typesafe_ssl-config-core_2.12_0.6.1_-1177452640
$ ./bazelenv scala_2.13
$ bazel query 'deps(//core/src/main:main)' | grep '@maven//:com.*ssl-config'
@maven//:com.typesafe/ssl-config-core_2.13/0.6.1/ssl-config-core_2.13-0.6.1-sources.jar
@maven//:com.typesafe/ssl-config-core_2.13/0.6.1/ssl-config-core_2.13-0.6.1.jar
@maven//:com.typesafe_ssl-config-core_2.13_0.6.1_-1177452640

.bazelrc を使うことで、コマンドライン・オプション無しで Scala バージョンを切り替えることができた。

3rdparty 解決の変更 (MODULE.bazel)

Bazel 6 より MODULE.bazel (コードネーム Bzlmod) は実験的機能でな無くなったため、このテクニックがどのように実装できるか当然気になった。従来のワークスペースと違って、module extension は extension 自体かリポジトリしか公開することができない。

そのため、大まかな戦略としては MODULE.bazel ファイル内ではタグクラスを用いて依存性の宣言を行い、module extension 内で従来どおり repository rule を呼び出して http_archive を含む副作用を実行するということらしい。 Bazel の Slack での Xudong Yang さんの発言を引用すると:

The module extension is as simple as

def http_stuff():
  http_file(...)
  http_file(...)
  http_archive(...)

my_ext = module_extension(implementation=lambda ctx: http_stuff())

it’s basically a workspace macro

幸いなことに rules_jvm_external は、maven_install(...) を従来の repositori rule として公開しているので、それを Coursier フロントエンドとして使うことができる。

tools/local_modules/default/WORKSPACE

# Empty file

tools/local_modules/default/BUILD

# Empty file

tools/local_modules/default/MODULE.bazel

module(
  name = "mod_scala_multiverse",
)
bazel_dep(
  name = "rules_jvm_external",
  version = "4.5",
)
bazel_dep(
  name = "bazel_skylib",
  version = "1.3.0",
)

tools/local_modules/default/extensions.bzl

load("@rules_jvm_external//:defs.bzl", "artifact", "maven_install")

SCALA_SUFFIX = "_2.12"

_install = tag_class(
  attrs = {
    "artifacts": attr.string_list(
      doc = "Maven artifact tuples, in `artifactId:groupId:version` format",
      allow_empty = True,
    ),
  },
)

def _modify_artifact(coordinates_string):
  coord = _parse_maven_coordinates(coordinates_string)
  if coord["is_scala"]:
    return "{}:{}:{}".format(
      coord["group_id"],
      coord["artifact_id"] + SCALA_SUFFIX,
      coord["version"],
    )
  else:
    return coordinates_string

def _local_ext_impl(mctx):
  artifacts = []
  for mod in mctx.modules:
    for install in mod.tags.install:
      artifacts += [_modify_artifact(artifact) for artifact in install.artifacts]
  maven_install(
    artifacts=artifacts,
    repositories=[
      "https://repo1.maven.org/maven2",
    ],
  )

maven = module_extension(
  implementation=_local_ext_impl,
  tag_classes={"install": _install},
)

def _parse_maven_coordinates(coordinates_string):
    """
    Given a string containing a standard Maven coordinate (g:a:[p:[c:]]v),
    returns a Maven artifact map (see above).
    See also https://github.com/bazelbuild/rules_jvm_external/blob/4.3/specs.bzl
    """
    if "::" in coordinates_string:
      idx = coordinates_string.find("::")
      group_id = coordinates_string[:idx]
      rest = coordinates_string[idx + 2:]
      is_scala = True
    elif ":" in coordinates_string:
      idx = coordinates_string.find(":")
      group_id = coordinates_string[:idx]
      rest = coordinates_string[idx + 1:]
      is_scala = False
    else:
      fail("failed to parse '{}'".format(coordinates_string))
    parts = rest.split(":")
    artifact_id = parts[0]
    if (len(parts)) == 1:
      result = dict(group_id=group_id, artifact_id=artifact_id, is_scala=is_scala)
    elif len(parts) == 2:
      version = parts[1]
      result = dict(group_id=group_id, artifact_id=artifact_id, version=version, is_scala=is_scala)
    elif len(parts) == 3:
      packaging = parts[1]
      version = parts[2]
      result = dict(group_id=group_id, artifact_id=artifact_id, packaging=packaging, version=version, is_scala=is_scala)
    elif len(parts) == 4:
      packaging = parts[1]
      classifier = parts[2]
      version = parts[3]
      result = dict(group_id=group_id, artifact_id=artifact_id, packaging=packaging, classifier=classifier, version=version, is_scala=is_scala)
    else:
      fail("failed to parse '{}'".format(coordinates_string))
    return result

tools/local_modules/scala_2.13/WORKSPACE

# Empty file

tools/local_modules/scala_2.13/BUILD

# Empty file

tools/local_modules/scala_2.13/MODULE.bazel

module(
  name = "mod_scala_multiverse",
)
bazel_dep(
  name = "rules_jvm_external",
  version = "4.5",
)
bazel_dep(
  name = "bazel_skylib",
  version = "1.3.0",
)

tools/local_modules/scala_2.13/extensions.bzl

load("@rules_jvm_external//:defs.bzl", "artifact", "maven_install")

SCALA_SUFFIX = "_2.13"

# ... 以下 tools/local_modules/default/extensions.bzl と同じ

MODULE.bazel

bazel_dep(name = "mod_scala_multiverse")
local_path_override(
  module_name="mod_scala_multiverse",
  path="tools/local_modules/default",
)

maven = use_extension("@mod_scala_multiverse//:extensions.bzl", "maven")

maven.install(
    artifacts = [
        "com.squareup.okhttp3:okhttp:3.14.2",
        "com.typesafe::ssl-config-core:0.6.1",
        "org.asynchttpclient:async-http-client:2.0.39",
        "org.scalatest::scalatest:3.2.10",
        "org.slf4j:slf4j-api:1.7.28",
        "org.reactivestreams:reactive-streams:1.0.3",
    ],
)
use_repo(maven, "maven")

demo 5

$ bazel query 'filter('com_typesafe_ssl', @maven//...)'
@maven//:com_typesafe_ssl_config_core_2_12
@maven//:com_typesafe_ssl_config_core_2_12_0_6_1
Loading: 0 packages loaded

$ bazel query 'filter('com_typesafe_ssl', @maven//...)' --override_module=mod_scala_multiverse=$(pwd)/tools/local_modules/scala_2.13
@maven//:com_typesafe_ssl_config_core_2_13
@maven//:com_typesafe_ssl_config_core_2_13_0_6_1

これは、module extension を使って Scala 2.13 ライブラリ群へと依存性解決を切り替えることができたことを示す。 些細な問題だが、Scala バージョンの接尾詞がターゲット名に漏れ出しているのが分かる。

違いの取り繕い

maven_dep() というマクロを定義することで @maven//:com_typesafe_ssl_config_core_2_12@maven//:com_typesafe_ssl_config_core_2_13 の違いを取り繕うことができる。

tools/local_repos/default/cross_scala_config.bzl

load("@io_bazel_rules_scala//:scala_config.bzl", "scala_config")

MULTIVERSE_NAME="default"
IS_SCALA_2_12 = True

def cross_scala_config(enable_compiler_dependency_tracking = False):
  scala_config(
    "2.12.14",
    enable_compiler_dependency_tracking=enable_compiler_dependency_tracking,
  )

TARGET_SCALA_SUFFIX="_2_12"

def maven_dep(coordinates_string):
  coord = _parse_maven_coordinates(coordinates_string)
  if coord["is_scala"]:
    artifact_id = coord["artifact_id"] + TARGET_SCALA_SUFFIX
  else:
    artifact_id = coord["artifact_id"]
  if "version" in coord:
    str = "@maven//:{}_{}_{}".format(coord["group_id"], artifact_id, coord["version"])
  else:
    str = "@maven//:{}_{}".format(coord["group_id"], artifact_id)
  return str.replace(".", "_").replace("-", "_")

def _parse_maven_coordinates(coordinates_string):
    """
    Given a string containing a standard Maven coordinate (g:a:[p:[c:]]v),
    returns a Maven artifact map (see above).
    See also https://github.com/bazelbuild/rules_jvm_external/blob/4.3/specs.bzl
    """
    if "::" in coordinates_string:
      idx = coordinates_string.find("::")
      group_id = coordinates_string[:idx]
      rest = coordinates_string[idx + 2:]
      is_scala = True
    elif ":" in coordinates_string:
      idx = coordinates_string.find(":")
      group_id = coordinates_string[:idx]
      rest = coordinates_string[idx + 1:]
      is_scala = False
    else:
      fail("failed to parse '{}'".format(coordinates_string))
    parts = rest.split(":")
    artifact_id = parts[0]
    if (len(parts)) == 1:
      result = dict(group_id=group_id, artifact_id=artifact_id, is_scala=is_scala)
    elif len(parts) == 2:
      version = parts[1]
      result = dict(group_id=group_id, artifact_id=artifact_id, version=version, is_scala=is_scala)
    elif len(parts) == 3:
      packaging = parts[1]
      version = parts[2]
      result = dict(group_id=group_id, artifact_id=artifact_id, packaging=packaging, version=version, is_scala=is_scala)
    elif len(parts) == 4:
      packaging = parts[1]
      classifier = parts[2]
      version = parts[3]
      result = dict(group_id=group_id, artifact_id=artifact_id, packaging=packaging, classifier=classifier, version=version, is_scala=is_scala)
    else:
      fail("failed to parse '{}'".format(coordinates_string))
    return result

適当な BUILD

これは :: を使って、_2.12 という接尾詞付きの Scala ライブラリであることを表記する。

load("@io_bazel_rules_scala//scala:scala.bzl", "scala_library")
load("@scala_multiverse//:cross_scala_config.bzl", "maven_dep")

scala_library(
    name = "main",
    srcs = glob(["*.scala"]),
    deps = [
        maven_dep("com.typesafe::ssl-config-core"),
        maven_dep("com.typesafe:config"),
        maven_dep("org.reactivestreams:reactive-streams"),
        maven_dep("org.slf4j:slf4j-api"),
    ],
    visibility = ["//visibility:public"],
)

bazelenv

#!/usr/bin/env bash

MODE=$1
function usage() {
  echo "usage ./bazelenv [<multiverse>]"
  echo ""
  echo "available multiverses are:"
  candidates=$(/bin/ls tools/local_repos)
  echo "$candidates"
}
if [[ "$MODE" == "" ]]; then
  usage
elif [[ -d tools/local_repos/$MODE ]]; then
  echo "common --override_repository=scala_multiverse=$(pwd)/tools/local_repos/$MODE" > ".bazelenv"
  echo "common --override_module=mod_scala_multiverse=$(pwd)/tools/local_modules/$MODE" >> ".bazelenv"
else
  usage; exit 1
fi

demo 6

$ bazel query 'deps(//core/src/main:main)' | grep '@maven//:.*ssl-config.*'
Loading: 0 packages loaded
@maven//:v1/https/repo1.maven.org/maven2/com/typesafe/ssl-config-core_2.12/0.6.1/ssl-config-core_2.12-0.6.1.jar

$ ./bazelenv scala_2.13
$ bazel query 'deps(//core/src/main:main)' | grep '@maven//:.*ssl-config.*'
@maven//:v1/https/repo1.maven.org/maven2/com/typesafe/ssl-config-core_2.13/0.6.1/ssl-config-core_2.13-0.6.1.jar

実用例

これらをまとめた実用例は https://github.com/eed3si9n/gigahorse/pull/85 を参照。

まとめ

ライセンス

法令上認められる最大限の範囲で作者は、本稿におけるコード例の著作権および著作隣接権を放棄して、全世界のパブリック・ドメインに提供している。 コード例は一切の保証なく公開される。http://creativecommons.org/publicdomain/zero/1.0/ 参照。