GoとKotlinのマルチプロジェクトをBazelでビルドする


Googleが開発するビルドツールのBazelを試していく。

Bazel - a fast, scalable, multi-language and extensible build system” - Bazel

モチベーション

GoやKotlinでつくるマイクロサービス開発は1つのサービスに1つのレポジトリで行ってきた経緯がある。またCIではDockerfileをレポジトリに内包することでビルド時にDockerイメージの錬成を行いRegistryへのPushを行う。GoやKotlinのビルド環境が異なることやイメージのビルドがDockerfileドリブンであるためサービス対レポジトリが1:1の関係であった。

Bazelは複数言語のビルドをサポートする。またDockerfileを使わずにイメージをビルドができる。

1つのレポジトリにGoとKotlinのマイクロサービスを構造化したプロジェクトのビルドをBazelで実現できるのか?を検証することがエントリのモチベーションである。

ゴール

簡易的なHello Worldを出力するアプリケーションをGoとKotlinでつくる。また言語ごとにアプリケーションから参照するライブラリを作りマルチプロジェクトの構成も取り入れる。そしてBazelでアプリケーションをDockerイメージにビルドするまでをゴールと定義する。

プロジェクト構成

pkg以下にアプリケーションを配置する構成を定義する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
./
├── README.md
└── pkg
    ├── common_go
    │   └── util
    │       └── string.go
    ├── common_kt
    │   └── util
    │       └── string.kt
    ├── public_go
    │   └── main.go
    └── public_kt
        └── main.kt

public_gopublic_ktはHello Worldを出力するアプリケーションを配置する。common_gocommon_ktは各言語が参照するライブラリを配置する。

Kotlin

まずはKotlinからビルドしていく。Bazelのインストールは調べるとキャッチアップできるので割愛する。

BazelはWORKSPACEというファイルをプロジェクトの最上位に配置する。そしてBUILDというファイルを各アプリケーションに配置することでビルドを行う。

WORKSPACE

WORKSPACEはビルド全般の設定を定義するものと理解すると分かりやすい。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# ./WORKSPACE

load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")

rules_kotlin_version = "87bd13f91d166a8070e9bbfbb0861f6f76435e7a"

http_archive(
    name = "io_bazel_rules_kotlin",
    urls = ["https://github.com/bazelbuild/rules_kotlin/archive/%s.zip" % rules_kotlin_version],
    type = "zip",
    strip_prefix = "rules_kotlin-%s" % rules_kotlin_version
)

load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kotlin_repositories", "kt_register_toolchains")

kotlin_repositories()

kt_register_toolchains()

pkgと同じ階層に上記のWORKSPACEファイルを配置する。Bazelはrulesという各言語ごとのビルド定義を用意している。Kotlinは次のレポジトリから参照できる。

WORKSPACEで行っていることはbazel_toolsからhttp_archiveを読み込みio_bazel_rules_kotlinの名前でrulesをインストールしている。そしてkotlin_repositorieskt_register_toolchainsを有効にしている。

Bazelの良いところはWORKSPACEに定義したビルド設定を各自が実行すればビルド環境を簡単に再現できることである。リリース前のrulesを試したければrules_kotlin_versionを個人で更新して試せば良い。他者に影響することなくローカルにインストールできる。

BUILD

次に各アプリケーションを見ていく。pkg/public_kt/main.ktはHello Worldを出力する簡単なコードで動いている。

1
2
3
4
5
6
7
package com.github.soushin.multipojects.publickt

import com.github.soushin.multipojects.commonkt.util.add

fun main(args : Array<String>) {
    println("Hello kotlin!".add())
}

Stringクラスの拡張関数であるadd()をつかっている。この拡張関数はpkg/common_kt/util/string.ktのライブラリを参照している。このようなマルチプロジェクトな仕組みをBazelで実現する方法を整理する。


まずはcommon_kt/util/string.ktをライブラリとしてビルドする方法をまとめる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# ./pkg/common_kt/BUILD

package(default_visibility = ["//visibility:public"])

load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_jvm_library")

kt_jvm_library(
    name = "common_kt_lib",
    srcs = ["util/string.kt"],
)

このBUILDファイルでadd()を含むライブラリがビルドできる。kt_jvm_libraryを有効にしてcommon_kt_libの名前でビルドしている。このビルドはpublic_ktから参照するのでvisibilityをpublicに定義している。

このcommon_ktをビルドするにはbazelコマンドを実行する。

1
 (bazel-multiprojects) $ bazel build //pkg/common_kt:common_kt_lib

上記のコマンドはWORKSPACEが配置されたプロジェクトのルートディレクトリで実行している。//pkg/common_ktがBUILDファイルまでのパスでcommon_kt_libがBUILDファイルで定義したビルドタスクである。

bazel-multiprojects/pkg/common_ktのディレクトリで実行する場合は次のコマンドになる。

1
(bazel-multiprojects/pkg/common_kt) bazel build common_kt_lib

ライブラリとしてビルドしたcommon_kt_libを利用するpkg/public_kt/BUILDは次のようになる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# ./pkg/public_kt/BUILD

package(default_visibility = ["//visibility:public"])

load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_jvm_library")

kt_jvm_library(
    name = "public_kt_lib",
    srcs = glob(["main.kt"]),
    deps = ["//pkg/common_kt:common_kt_lib"],
)

java_binary(
    name = "java_bin",
    main_class = "com.github.soushin.multipojects.publickt.MainKt",
    runtime_deps = [":public_kt_lib"],
)

kt_jvm_libraryを使いmain.ktを含むpublic_kt_libを定義している。public_kt_libではdeps”//pkg/common_kt:common_kt_lib”が追加 してcommon_ktを依存させている。

depsの定義によりmain.ktからadd()の拡張関数が参照できるようになる。

java_binaryはjarを錬成するruleである。main_classにはmain()関数があるクラスを定義する。runtime_depsではkt_jvm_libraryでビルドしたpublic_kt_libを追加する。

これまで複数のruleを定義してきたがHello Worldを出力するビルドはjava_binaryのruleで定義したjava_binaryを実行するだけである。java_binaryはpublic_kt_libに依存しているのでBazelが依存関係をよしなに解決して実行してくれる。

java_binを実行した結果が次のようになる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
 (bazel-multiprojects) $ bazel run //pkg/public_kt:java_bin
INFO: Analysed target //pkg/public_kt:java_bin (2 packages loaded).
INFO: Found 1 target...
Target //pkg/public_kt:java_bin up-to-date:
  bazel-bin/pkg/public_kt/java_bin.jar
  bazel-bin/pkg/public_kt/java_bin
INFO: Elapsed time: 8.150s, Critical Path: 7.60s
INFO: 2 processes: 2 darwin-sandbox.
INFO: Build completed successfully, 6 total actions
INFO: Build completed successfully, 6 total actions
Hello kotlin! - built by Bazel!

錬成したjarを実行すると失敗する。

1
2
 (bazel-multiprojects) $ java -jar bazel-bin/pkg/public_kt/java_bin.jar
 bazel-bin/pkg/public_kt/java_bin.jarにメイン・マニフェスト属性がありません

これは単体で動かないjarのため単体で動かすには_deploy.jarをタスクに追加する。

1
2
3
4
5
6
7
8
 (bazel-multiprojects) $ bazel build //pkg/public_kt:java_bin_deploy.jar
 INFO: Analysed target //pkg/public_kt:java_bin_deploy.jar (0 packages loaded).
 INFO: Found 1 target...
 Target //pkg/public_kt:java_bin_deploy.jar up-to-date:
   bazel-bin/pkg/public_kt/java_bin_deploy.jar
 INFO: Elapsed time: 0.359s, Critical Path: 0.13s
 INFO: 1 process: 1 darwin-sandbox.
 INFO: Build completed successfully, 2 total actions

このjarを実行すると正常にHello Worldが出力したjarが起動する。

1
2
 (bazel-multiprojects) $ java -jar bazel-bin/pkg/public_kt/java_bin_deploy.jar
Hello kotlin! - built by Bazel!

name_deploy.jarの仕組みは次のDocsにまとまっているので参照してほしい。

Java Rules#Implicit output targets

Go

ここまでjarをBazelで錬成できるところまでまとめた。Dockerイメージのビルドは最後にまとめるのでGoプロジェクトを整理する。

WORKSPACE

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# ./WORKSPACE

load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")

http_archive(
    name = "io_bazel_rules_go",
    url = "https://github.com/bazelbuild/rules_go/releases/download/0.16.2/rules_go-0.16.2.tar.gz",
    sha256 = "f87fa87475ea107b3c69196f39c82b7bbf58fe27c62a338684c20ca17d1d8613",
)

http_archive(
    name = "bazel_gazelle",
    urls = ["https://github.com/bazelbuild/bazel-gazelle/releases/download/0.15.0/bazel-gazelle-0.15.0.tar.gz"],
    sha256 = "6e875ab4b6bf64a38c352887760f21203ab054676d9c1b274963907e0768740d",
)

load("@io_bazel_rules_go//go:def.bzl", "go_rules_dependencies", "go_register_toolchains")

go_rules_dependencies()

go_register_toolchains()

load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies")

gazelle_dependencies()

http_archiveで必要なruleをインストールしているがGoではgazelleというGoプロジェクトに特化したBUILDファイルジェネレーターが用意されている。

Gazelleの威力

KotlinではHello Worldを出力するアプリケーションとライブラリの依存関係をBUILDファイルに定義したがgazelleはプロジェクト内のgoファイルを解析してBUILDファイルを錬成してくれる!

WORKSPACEと同階層に次のようなBUILDファイルを配置する。

1
2
3
4
5
6
# ./BUILD

load("@bazel_gazelle//:def.bzl", "gazelle")

# gazelle:prefix github.com/soushin/bazel-multiprojects
gazelle(name = "gazelle")

gazelleを有効にしている箇所でgazelle:prefixでgoのimportパスのprefixを定義している。このBUILDファイルを配置した状態で次のコマンドを実行する。

1
 (bazel-multiprojects) $ bazel run gazelle

上記のコマンドを実行すると自動でpublic_gocommon_goのgoコードを解析してBUILDファイルが錬成される。錬成したBUILDファイルを見ていく。

まずはcommon_goを見ていく。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# ./pkg/common_go/BUILD.bazel ※ gazelleが錬成したファイルは`.bazel`が含まれる。

load("@io_bazel_rules_go//go:def.bzl", "go_library")

go_library(
    name = "go_default_library",
    srcs = ["string.go"],
    importpath = "github.com/soushin/bazel-multiprojects/pkg/common_go/util",
    visibility = ["//visibility:public"],
)

go_libraryのruleが定義されている。importpathはgazelle:prefixで定義したプレフィックスが適応されている。

このgo_default_libraryをpublic_ktに依存させていく。


次にpublic_goのBUILDファイルは次のようになっている。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# ./pkg/public_go/BUILD.bazel

load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")

go_library(
    name = "go_default_library",
    srcs = ["main.go"],
    importpath = "github.com/soushin/bazel-multiprojects/pkg/public_go",
    visibility = ["//visibility:private"],
)

go_binary(
    name = "public_go",
    embed = [":go_default_library"],
    visibility = ["//visibility:public"],
)

main.goはmainパッケージに配置しているのでgo_binaryのruleが追加になっている。このgo_binaryを実行してみると次のようになる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 (bazel-multiprojects) $ bazel run //pkg/public_go:public_go    
INFO: Analysed target //pkg/public_go:public_go (0 packages loaded).
INFO: Found 1 target...
Target //pkg/public_go:public_go up-to-date:
  bazel-bin/pkg/public_go/darwin_amd64_stripped/public_go
INFO: Elapsed time: 0.571s, Critical Path: 0.37s
INFO: 2 processes: 2 darwin-sandbox.
INFO: Build completed successfully, 3 total actions
INFO: Build completed successfully, 3 total actions
Hello World!

Hello World!が出力できた。次にcommon_goを依存させてHello kotlin! - built by Bazel!と出力するようにしてみる。


錬成したライブラリを依存させるにはKotlinと同様にdepsを用いる。

1
2
3
4
5
6
7
go_library(
    name = "go_default_library",
    srcs = ["main.go"],
    importpath = "github.com/soushin/bazel-multiprojects/pkg/public_go",
    visibility = ["//visibility:private"],
    deps = ["//pkg/common_go/util:go_default_library"],
)

depsにgo_default_libraryのライブラリを追加した。この状態でgo_default_libraryをビルドするとmain.goからcommon_go/util/stirng.goが参照できるようになる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package main

import (
    "fmt"

    "github.com/soushin/bazel-multiprojects/pkg/common_go/util"
)

func main() {
    fmt.Println(util.Add("Hello World!"))
}

最後にgo_binaryを実行する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 (bazel-multiprojects) $ bazel run //pkg/public_go:public_go 
INFO: Analysed target //pkg/public_go:public_go (0 packages loaded).
INFO: Found 1 target...
Target //pkg/public_go:public_go up-to-date:
  bazel-bin/pkg/public_go/darwin_amd64_stripped/public_go
INFO: Elapsed time: 0.629s, Critical Path: 0.41s
INFO: 2 processes: 2 darwin-sandbox.
INFO: Build completed successfully, 3 total actions
INFO: Build completed successfully, 3 total actions
Hello World! - built by Bazel!

無事、util/stirng.goがmain.goに依存できた。

Dockerイメージのビルド

KotlinとGoのDockerイメージのビルドを見ていく。今回はビルドしたイメージをローカルにインストールするところまでをまとめていく。

Kotlin

WORKSPACEに次のhttp_archiveを追加する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# ./WORKSPACE

load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")

http_archive(
    name = "io_bazel_rules_docker",
    sha256 = "29d109605e0d6f9c892584f07275b8c9260803bf0c6fcb7de2623b2bedc910bd",
    strip_prefix = "rules_docker-0.5.1",
    urls = ["https://github.com/bazelbuild/rules_docker/archive/v0.5.1.tar.gz"],
)

Dockerをビルドするためのruleをインストールする定義である。

Kotlinプロジェクトで錬成したjarを動かすJavaベースのDockerイメージをプロジェクト内にインストールしたい。そのような場合は次のようにWORKSPACEに追加する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# ./WORKSPACE

load("@io_bazel_rules_docker//container:container.bzl", "container_pull")

container_pull(
    name = "java_base_image",
    registry = "index.docker.io",
    repository = "library/openjdk",
    tag = "12-ea-18-jdk-alpine3.8",
)

container_pullのruleを有効にしてdocker.ioにあるalpineベースのjdkイメージをjava_baze_imageとしてプロジェクトにインストールした。

ここまでがWORKSPACEに追加するKotlin向けのプロジェクト全般の定義である。

BUILD

container_pullしたjava_base_imageをベースにHello Worldを出力するアプリケーションを内包したDockerイメージを錬成していく。

次のようなcontainer_imageのruleを有効にして定義する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# ./pkg/public_kt/BUILD

load("@io_bazel_rules_docker//container:container.bzl", "container_image")

container_image(
    name = "public_kt_image",
    base = "@java_base_image//image",
    files = [":java_bin_deploy.jar"],
    cmd = [
        "java",
        "-jar",
        "java_bin_deploy.jar",
    ],
)

baseはWORKSPACEに追加したjava_base_imageを設定している。filesにはjava_binのタスクで錬成するjarファイルを設定しDockerイメージ内に配置されるようにしている。あとはイメージ内に配置したjarを実行するcmdを設定している。

このタスクを実行すると次のようになる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
 (bazel-multiprojects) $ bazel run //pkg/public_kt:public_kt_image
 INFO: Analysed target //pkg/public_kt:public_kt_image (14 packages loaded).
 INFO: Found 1 target...
 Target //pkg/public_kt:public_kt_image up-to-date:
   bazel-bin/pkg/public_kt/public_kt_image-layer.tar
 INFO: Elapsed time: 5.201s, Critical Path: 2.85s
 INFO: 4 processes: 4 darwin-sandbox.
 INFO: Build completed successfully, 5 total actions
 INFO: Build completed successfully, 5 total actions
 84c6c9b35317: Loading layer [==================================================>]  972.8kB/972.8kB
 Loaded image ID: sha256:a5cf4f2786c475fe57d020a86d74f2b686a85701908e9c9128c24219b078e809
 Tagging a5cf4f2786c475fe57d020a86d74f2b686a85701908e9c9128c24219b078e809 as bazel/pkg/public_kt:public_kt_image

ローカルにbazel/pkg/public_kt:public_kt_imageの名前とタグでイメージがビルドできた。このDockerイメージを起動する。

1
2
 (bazel-multiprojects) $ docker run -it bazel/pkg/public_kt:public_kt_image
 Hello kotlin! - built by Bazel!

Bazelで錬成したjarをBazelによって錬成したDockerイメージに内包することができた。

Go

GoのDockerイメージの錬成を見ていく。

WORKSPACEに次のhttp_archiveを追加する。

1
2
3
4
5
6
7
# ./WORKSPACE

load(
    "@io_bazel_rules_docker//go:image.bzl",
    _go_image_repos = "repositories",
)
_go_image_repos()

Kotlinとは異なりBazelが提供するイメージを利用する。

BUILD
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# ./pkg/public_go/BUILD

load("@io_bazel_rules_docker//go:image.bzl", "go_image")

go_image(
    name = "public_go_base_image",
    embed = [":go_default_library"],
    importpath = "github.com/soushin/bazel-multiprojects/pkg/public_go",
    goarch = "amd64",
    goos = "linux",
    pure = "on",
)

load("@io_bazel_rules_docker//container:container.bzl", "container_image")

container_image(
    name = "public_go_image",
    base = ":public_go_base_image",
)

go_imageのruleを有効にしてGoのDockerイメージを錬成している。embedにはライブラリのgo_default_libraryを設定している。このpublic_go_base_imageをcontainer_imageのbaseにしてローカルにDockerイメージがインストールする。

go_imageのpublic_go_base_imageだけでもDockerイメージはインストールできるのだがcontainer_imageはportやenvなどの細かいビルド設定が定義できる。今回のエンドリでは利用用途がなく割愛しているがHTTP Serverのようなアプリケーションではもう少し設定が増えると思う。

public_go_imageのタスクを実行すると次のようになる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
 (bazel-multiprojects) $ bazel run //pkg/public_go:public_go_image
INFO: Analysed target //pkg/public_go:public_go_image (0 packages loaded).
INFO: Found 1 target...
Target //pkg/public_go:public_go_image up-to-date:
  bazel-bin/pkg/public_go/public_go_image-layer.tar
INFO: Elapsed time: 1.137s, Critical Path: 0.49s
INFO: 2 processes: 2 darwin-sandbox.
INFO: Build completed successfully, 3 total actions
INFO: Build completed successfully, 3 total actions
84ff92691f90: Loading layer [==================================================>]  10.24kB/10.24kB
Loaded image ID: sha256:71560af2643cc9a1f0593ff72940ae99711d5d572156187c930bfdb6aa740bbf
Tagging 71560af2643cc9a1f0593ff72940ae99711d5d572156187c930bfdb6aa740bbf as bazel/pkg/public_go:public_go_image

ローカルにDockerイメージがインストールできたのでDockerイメージを起動する。

1
2
(bazel-multiprojects) $ docker run -it bazel/pkg/public_go:public_go_image
Hello World! - built by Bazel!

Kotlinと同様にバイナリの錬成からDockerイメージのビルドまでBazelのみで完遂できた。

まとめ

最終的なプロジェクトの構造は次のようになった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
./
├── BUILD
├── README.md
├── WORKSPACE
├── bazel-bazel-multiprojects -> /path/to/symlink
├── bazel-bin -> /path/to/symlink
├── bazel-genfiles -> /path/to/symlink
├── bazel-out -> /path/to/symlink
├── bazel-testlogs -> /path/to/symlink
└── pkg
    ├── common_go
    │   └── util
    │       ├── BUILD.bazel
    │       └── string.go
    ├── common_kt
    │   ├── BUILD
    │   └── util
    │       └── string.kt
    ├── public_go
    │   ├── BUILD.bazel
    │   └── main.go
    └── public_kt
        ├── BUILD
        └── main.kt

※Bazelが出力するbazel-*のシンボリックリンクのパスは/path/to/symlinkに置換しています。

プロジェクトのルートにWORKSPACEが配置され各アプリケーションとライブラリにBUILDファイルが配置されているのが分かる。BazelはGoとKotlinの言語が混合したプロジェクト構造であってもビルド環境は1つなので言語環境が競合することなく1つのレポジトリに配置することができる。

GoとKotlinが言語間でやり取りすることはないので1つのレポジトリに配置することはメリットがないかもしれない。あるとすればWORKSPACEの設定は共有されるのでGoとKotlinプロジェクトのビルド環境の差異を防げるくらいでだろうか。このエントリを通して今後プロジェクトに導入して事例を貯めていけるようなフェーズに入れたので引き続きBazelを推進していきたい。

今回では触れられなかったところを備忘録としてリストする。

コード

このエントリで紹介したコード一式は次のレポジトリに置いてあるので参照してください。