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
以下にアプリケーションを配置する構成を定義する。
./ ├── README.md └── pkg ├── common_go │ └── util │ └── string.go ├── common_kt │ └── util │ └── string.kt ├── public_go │ └── main.go └── public_kt └── main.kt
public_go
とpublic_kt
はHello Worldを出力するアプリケーションを配置する。common_go
とcommon_kt
は各言語が参照するライブラリを配置する。
Kotlin
まずはKotlinからビルドしていく。Bazelのインストールは調べるとキャッチアップできるので割愛する。
BazelはWORKSPACE
というファイルをプロジェクトの最上位に配置する。そしてBUILD
というファイルを各アプリケーションに配置することでビルドを行う。
WORKSPACE
WORKSPACEはビルド全般の設定を定義するものと理解すると分かりやすい。
# ./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_repositories
とkt_register_toolchains
を有効にしている。
Bazelの良いところはWORKSPACEに定義したビルド設定を各自が実行すればビルド環境を簡単に再現できることである。リリース前のrulesを試したければrules_kotlin_version
を個人で更新して試せば良い。他者に影響することなくローカルにインストールできる。
BUILD
次に各アプリケーションを見ていく。pkg/public_kt/main.kt
はHello Worldを出力する簡単なコードで動いている。
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
をライブラリとしてビルドする方法をまとめる。
# ./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コマンドを実行する。
(bazel-multiprojects) $ bazel build //pkg/common_kt:common_kt_lib
上記のコマンドはWORKSPACEが配置されたプロジェクトのルートディレクトリで実行している。//pkg/common_kt
がBUILDファイルまでのパスでcommon_kt_lib
がBUILDファイルで定義したビルドタスクである。
bazel-multiprojects/pkg/common_kt
のディレクトリで実行する場合は次のコマンドになる。
(bazel-multiprojects/pkg/common_kt) bazel build common_kt_lib
ライブラリとしてビルドしたcommon_kt_lib
を利用するpkg/public_kt/BUILD
は次のようになる。
# ./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を実行した結果が次のようになる。
(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を実行すると失敗する。
(bazel-multiprojects) $ java -jar bazel-bin/pkg/public_kt/java_bin.jar bazel-bin/pkg/public_kt/java_bin.jarにメイン・マニフェスト属性がありません
これは単体で動かないjarのため単体で動かすには_deploy.jar
をタスクに追加する。
(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が起動する。
(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
# ./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ファイルを配置する。
# ./BUILD load("@bazel_gazelle//:def.bzl", "gazelle") # gazelle:prefix github.com/soushin/bazel-multiprojects gazelle(name = "gazelle")
gazelle
を有効にしている箇所でgazelle:prefix
でgoのimportパスのprefixを定義している。このBUILDファイルを配置した状態で次のコマンドを実行する。
(bazel-multiprojects) $ bazel run gazelle
上記のコマンドを実行すると自動でpublic_go
とcommon_go
のgoコードを解析してBUILDファイルが錬成される。錬成したBUILDファイルを見ていく。
まずはcommon_goを見ていく。
# ./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ファイルは次のようになっている。
# ./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を実行してみると次のようになる。
(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を用いる。
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が参照できるようになる。
package main import ( "fmt" "github.com/soushin/bazel-multiprojects/pkg/common_go/util" ) func main() { fmt.Println(util.Add("Hello World!")) }
最後にgo_binaryを実行する。
(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を追加する。
# ./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に追加する。
# ./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を有効にして定義する。
# ./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を設定している。
このタスクを実行すると次のようになる。
(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イメージを起動する。
(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を追加する。
# ./WORKSPACE load( "@io_bazel_rules_docker//go:image.bzl", _go_image_repos = "repositories", ) _go_image_repos()
Kotlinとは異なりBazelが提供するイメージを利用する。
BUILD
# ./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
のタスクを実行すると次のようになる。
(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イメージを起動する。
(bazel-multiprojects) $ docker run -it bazel/pkg/public_go:public_go_image Hello World! - built by Bazel!
Kotlinと同様にバイナリの錬成からDockerイメージのビルドまでBazelのみで完遂できた。
まとめ
最終的なプロジェクトの構造は次のようになった。
./ ├── 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を推進していきたい。
今回では触れられなかったところを備忘録としてリストする。
- grpcを利用するのでBazelのビルド定義は今のところ分からない。grpc-javaやgoもexampleはあるようなので見ていきたい。
- container_imageのビルドまでをまとめたが実際のプロジェクトではcontainer_pushを利用することになる。CI視点の知見も貯めていきたい。
- KtorやSpring-fu、Goであれば各種ライブラリが依存するような実戦形式のBUILDファイルの運用知見も引き続きまとめていきたい。
コード
このエントリで紹介したコード一式は次のレポジトリに置いてあるので参照してください。