BazelでビルドするマルチプロジェクトとCircleCIを連携する
今回のエントリではBazelでビルドしたマルチプロジェクトとCircleCIの連携をまとめていく。マルチプロジェクトとCIを連携する際の実運用の課題を洗い出して解決方法を考えた。
プロジェクト構成
CIと連携させるマルチプロジェクトは次のような構成である。
(bazel-multiprojects) $ tree -I 'bazel-*|common_kt*|public_kt*|script' . ├── BUILD ├── Makefile ├── README.md ├── WORKSPACE ├── pkg │ ├── common_go │ │ └── util │ │ ├── BUILD.bazel │ │ ├── string.go │ │ └── string_test.go │ └── public_go │ ├── BUILD.bazel │ ├── injector.go │ ├── main.go │ ├── main_test.go │ ├── usecase │ │ ├── BUILD.bazel │ │ └── greet_usecase.go │ └── wire_gen.go └── proto ├── echo │ ├── BUILD.bazel │ ├── echo.pb.go │ └── echo.proto └── greet ├── BUILD.bazel ├── greet.pb.go └── greet.proto
CIと連携する上で実現したいこと
pkg以下にはcommon_go
とpublic_go
のパッケージが配置されている。
public_goはcommon_goに依存している。
CIと連携する上で実現したいことを一覧にする。
- パッケージ内のコードに修正が入ればパッケージのテストを実行したい
- パッケージ内のコードに修正が入ればパッケージのバイナリビルドを実行したい
- パッケージ内のコードに修正が入りコンテナビルドのタスクがあればレジストリへプッシュを行いたい
- common_goに修正が入れば依存している全てのパッケージのテストからバイナリビルド、コンテナビルドとレジストリへのプッシュを行いたい
ここから1つずつ実現方法をまとめていく。
CircleCIのDockerイメージにbazelをインストールする
CircleCIのDockerイメージにはbazelは標準でセットアップされていないのでインストールする必要がある。
Bazelをインストールするジョブは次のようになった。
# .circleci/config.yml jobs: preparetool: docker: - image: circleci/golang:1.10.3 steps: - restore_cache: keys: - v1-build-bazel-cache-{{ .Branch }}-- - v1-build-bazel-cache-master-- - v1-build-bazel-cache- - run: name: setup command: | if ! type ./bin/bazel >/dev/null 2>&1; then sudo apt-get install pkg-config zip g++ zlib1g-dev unzip python wget -q -O bazel-installer.sh https://github.com/bazelbuild/bazel/releases/download/0.19.2/bazel-0.19.2-installer-linux-x86_64.sh chmod +x ./bazel-installer.sh ./bazel-installer.sh --user mkdir -p ./bin cp /home/circleci/.bazel/bin/* ./bin/ fi - run: name: version command: ./bin/bazel version - save_cache: key: v1-build-bazel-cache-{{ .Branch }}--{{ .Revision }} paths: - ./bin
Installing Bazel on Ubuntu - Bazel
公式ドキュメントに載っているとおりのインストール手順をCI上で実行する。
パッケージ内のコードに修正が入ればパッケージのテストとバイナリビルドを実行したい
テストとバイナリビルドの方法をまとめていく。
方法としてはCI上にチェックアウトされたコードとmasterまたはHEADとの差分をとり修正されたコードのパスを取得する。そのパスからbazelコマンドに必要な文字列を抜き出せば良い。
先人の知恵を借りてBazel連携を実現した。
完成したシェルスクリプトは次のようになった。
#!/usr/bin/env bash PROJECT_DIR=$1 COMMAND=$2 if [[ ${COMMAND} != "build" && ${COMMAND} != "push" ]]; then echo "$COMMAND is invalid command. (Required build or push)." 1>&2 exit 1 fi CURRENT_BRANCH=`git rev-parse --abbrev-ref @` IMAGE_TAG=${CURRENT_BRANCH/\//_} # 変更があったdockerイメージを取得 if [ ${CURRENT_BRANCH} = "master" ]; then # 現在がmasterであれば、直前のコミットと比較 TARGET="HEAD^ HEAD" else # masterブランチ以外であれば、origin/masterの最新と比較 TARGET="origin/master" fi git diff ${TARGET} --name-only | awk '/^pkg/' | awk '{sub("pkg/", "", $0); print $0}' | awk '{print substr($0, 0, index($0, "/") -1)}' | awk '!a[$0]++' > check.tmp for pkgname in `cat check.tmp`; do if [[ ${COMMAND} == "build" ]]; then # test ${PROJECT_DIR}/bin/bazel query //... | grep "//pkg/$pkgname" | xargs ${PROJECT_DIR}/bin/bazel test --define IMAGE_TAG=${IMAGE_TAG} --local_resources=4096,2.0,1.0 # build ${PROJECT_DIR}/bin/bazel query //... | awk "/^\/\/pkg\/$pkgname:$pkgname$/" | xargs ${PROJECT_DIR}/bin/bazel build --define IMAGE_TAG=${IMAGE_TAG} --local_resources=4096,2.0,1.0 elif [[ ${COMMAND} == "push" ]]; then # docker login // should login to docker ${PROJECT_DIR}/bin/bazel query //... | awk "/^\/\/pkg\/$pkgname:container_push$/" | xargs ${PROJECT_DIR}/bin/bazel run --define IMAGE_TAG=${IMAGE_TAG} fi done rm check.tmp
common_goとpublic_goに修正が入ればcheck.tmp
には次のような文字列が格納される。
common_go public_go
check.tmpの1行ずつを抜き出してbazel queryを使いテストとバイナリビルドに合致するビルドタスクを抽出して実行している。
bazel build //pkg/...
のようにして全体をビルドせずに更新のあったパッケージのみビルドすることができた。
local_resourcesのフラッグを指定する
protobufが含まれるプロジェクトのためビルドに必要なメモリを確保する必要がある。Dockerイメージ内でBazelを動かしているのでlocal_resourcesのフラッグを指定してメモリ調整をしている。
--local_resources=4096,2.0,1.0
これを指定しないとOOMが発生してしまうので注意が必要である。
パッケージ内のコードに修正が入りコンテナビルドのタスクがあればレジストリへプッシュを行いたい
Dockerイメージをビルドするタスクを container_push
のnameに統一してbazel queryで抽出することで更新のあったパッケージのみがレジストリにプッシュされるようにした。
for pkgname in `cat check.tmp`; do ${PROJECT_DIR}/bin/bazel query //... | awk "/^\/\/pkg\/$pkgname:container_push$/" | xargs ${PROJECT_DIR}/bin/bazel run --define IMAGE_TAG=${IMAGE_TAG} done
またcontainer_push
のタスクは次のように定義してある。
container_push( name = "container_push", image = ":public_go_image", format = "Docker", registry = "index.docker.io", repository = "soushin/bazel-multiprojects-go", tag = "$(IMAGE_TAG)", )
tagに"$(IMAGE_TAG)"
を指定することでビルド時のオプションを参照できるようにした。ビルドのオプションに--define IMAGE_TAG=${IMAGE_TAG}
を加えることでブランチごとのDockerイメージがプッシュできる。
CURRENT_BRANCH=`git rev-parse --abbrev-ref @` IMAGE_TAG=${CURRENT_BRANCH/\//_} ${PROJECT_DIR}/bin/bazel query //... | awk "/^\/\/pkg\/$pkgname:container_push$/" | xargs ${PROJECT_DIR}/bin/bazel run --define IMAGE_TAG=${IMAGE_TAG}
rule内のIMAGE_TAGを変数化する方法は他にもありworkspace_status_command
を使えば変数をファイルで管理できる。
GitHub - bazelbuild/rules_docker: Rules for building and handling Docker images with Bazel
common_goに修正が入れば依存している全てのパッケージのテストからバイナリビルド、コンテナビルドとレジストリへのプッシュを行いたい
最後に共通パッケージに修正が入れば依存するパッケージすべてにビルドタスクを実行するための実現方法をまとめる。
方法は2つある。
1つ目はrdep
を使って依存するパッケージをqueryで抽出する方法である。
bazel query 'rdeps(pkg/..., pkg/common_go/...)' --output package pkg/common_go/util pkg/public_go
pkg以下でpkg/common_go
が利用されているパッケージが抽出できる。
2つ目はシンプルにbazel build //pkg/...
を実行してしまう方法である。
共通パッケージに依存が多ければビルド時間が長くなっていくのでCIで自動化するべきかはプロジェクト規模による。自動化せずにChatOpsなどで特定のパッケージをビルドするインターフェースを用意したほうが効率が良いかもしれない。
まとめ
- BazelでビルドするマルチプロジェクトとCircleCIを連携する方法をまとめた。
- CIのDockerイメージ内のメモリ調整にlocal_resourcesのフラッグを指定する知見が溜まった。
- bazel queryを駆使して実行したいタスクを抽出すればCI連携も見通しよく整理できそうな感触を得た。
コード
BazelでDockerイメージのビルドとレジストリへのプッシュをする
前回までのエントリに引き続きBazelのビルドをまとめていく。
- GoとKotlinのマルチプロジェクトをBazelでビルドする - 平日インプット週末アウトプットぶろぐ
- BazelでGoプロジェクトのビルド。Gazelleのgo_repositoryで外部ライブラリの依存とBazelのgo_testでテスト。 - 平日インプット週末アウトプットぶろぐ
- gRPCサーバを含むGoプロジェクトをBazelでビルドする - 平日インプット週末アウトプットぶろぐ
今回はDockerイメージのビルドとビルドしたイメージをレジストリにプッシュする方法をまとめる。
Bazelの魅力
具体的な方法に入る前にBazelの魅力を整理したい。
Bazelの魅力はマルチ言語のビルドをサポートしている点が挙げられる。GoプロジェクトであればGazelleがWORKSPACEとBUILDファイルの中間コードを補完してくれる。そしてコンテナ周辺のビルドタスクも定義できる。
コンテナ周辺のビルドタスクのサポートがBazelの最大の魅力だと感じる。マイクロサービスを取り入れたプロジェクトであればサービスをどのような単位で管理するだろうか。シンプルに考えればサービス1つに対してレポジトリを作るだろう。そのレポジトリにDockerfileを置けばビルド時にイメージのビルドとレジストリへのプッシュも行える。しかしいくつかの課題がある。レポジトリ間の依存である。ユーティリティ系のレポジトリやgRPCのprotoファイルなどレポジトリを分けることによって運用の手数が増えることになる。
その点Bazelは1つのレポジトリにマルチプロジェクトを構成しgRPCのprotoファイルを集約させることができる。そしてDockerイメージのビルドとプッシュも行えるとあれば一連の運用フローがBazelで完遂することができるのだ。
Goプロジェクトをdistrolessをベースイメージとしてビルドする
Goプロジェクトをdistrolessイメージでビルドしたい。
これまではalpineイメージをベースにRUNでglibc, openssl , ca-certificateなどをインストールしていたのだがdistrolessはこれらがセットアップされた軽量なイメージである。また調べる限りではcontainer_imageなどのDockerビルドの機能にRUN相当のオプションが指定できない。
distrolessのイメージを使わずともBazelにはgo_image
が用意されていてDockerイメージのビルドが行えるのだがentrypointやcmdが指定できない。これらの理由からベースイメージを変える方法の知見を残す意味でもdistrolessを利用している。
WORKSPACEにベースイメージとするdistroless/baseを定義する。container_pullを定義することで以降のビルド定義でdistroless_base_image
でベースイメージを参照できる。
# WORKSPACE container_pull( name = "distroless_base_image", registry = "gcr.io", repository = "distroless/base", digest = "sha256:628939ac8bf3f49571d05c6c76b8688cb4a851af6c7088e599388259875bde20" )
pkg/public_go/BUILD.bazelのcontainer_imageでdistroless_base_image
を参照する。
# pkg/public_go/BUILD.bazel container_image( name = "public_go_image", base = "@distroless_base_image//image", files = [":public_go"], cmd = [ "/public_go", "-greet", "Awesome", ], )
filesに指定している:public_go
はgo_binaryで定義されたタスクでGoバイナリを生成する。これによりイメージ内にGoバイナリが配置される。あとはcmdにアプリケーションを起動するコマンドを定義する。ここらへんの定義方法はDockerとほぼ同じである。
public_go_imageのタスクを実行するとDockerイメージがビルドできる。
(bazel-multiprojects) $ bazel run --platforms=@io_bazel_rules_go//go/toolchain:linux_amd64 //pkg/public_go:public_go_image INFO: Build options have changed, discarding analysis cache. INFO: Analysed target //pkg/public_go:public_go_image (97 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: 5.632s, Critical Path: 1.53s INFO: 6 processes: 6 darwin-sandbox. INFO: Build completed successfully, 11 total actions INFO: Build completed successfully, 11 total actions Loaded image ID: sha256:5626914bed1706245748cf5a090fcf4afed675592d7a17ec0116907b49102758 Tagging 5626914bed1706245748cf5a090fcf4afed675592d7a17ec0116907b49102758 as bazel/pkg/public_go:public_go_image
--platforms=@io_bazel_rules_go//go/toolchain:linux_amd64
でクロスコンパイルオプションを追加している。
レジストリにプッシュする
レジストリへのプッシュは次のように定義する。
# pkg/public_go/BUILD.bazel container_push( name = "push_public_go_image", image = ":public_go_image", format = "Docker", registry = "index.docker.io", repository = "soushin/bazel-multiprojects-go", tag = "latest", )
imageでcontainer_imageのpublic_go_image
を参照する。
push_public_go_imageのタスクを実行するとレジストリにプッシュされたことが確認できる。
(bazel-multiprojects) $ bazel run //pkg/public_go:push_public_go_image INFO: Build options have changed, discarding analysis cache. INFO: Analysed target //pkg/public_go:push_public_go_image (0 packages loaded). INFO: Found 1 target... Target //pkg/public_go:push_public_go_image up-to-date: bazel-bin/pkg/public_go/push_public_go_image INFO: Elapsed time: 3.065s, Critical Path: 2.48s INFO: 5 processes: 5 darwin-sandbox. INFO: Build completed successfully, 7 total actions INFO: Build completed successfully, 7 total actions index.docker.io/soushin/bazel-multiprojects-go:latest was published with digest: sha256:3025afe23e007e6d48b7b661a3a2726f7bc32940bbb56feeb6a14acded613dc1
まとめ
- BazelでDockerイメージのビルドとレジストリへのプッシュする方法をまとめた。
- Bazelの魅力でまとめたとおりContainer周辺のタスクもビルド定義できるのでコードのビルド、テスト、コンテナのビルド、プッシュまでワンストップで行えるBazelはすごい。
- マルチプロジェクトでBazelをつかっていきたいので効率的なCIとの連携を考えていきたい。
コード
gRPCサーバを含むGoプロジェクトをBazelでビルドする
前回までのエントリに引き続きBazelのビルドをまとめていく。
今回のエントリではgRPCサーバを含むGoプロジェクトをBazelでビルドする方法をまとめる。
Protocol BufferとgRPCサーバのInterfaceの生成をBazelで行う
これまではprotocをつかって.protoファイルからProtocol BufferとgRPCサーバのInterfaceの生成を行っていたがBazelのruleを使えば簡単にビルドタスクに追加することができる。
利用するruleはrules_proto
。
このruleに辿り着く前にpubref/rules_protobufを見つけたがpubrefのほうは周辺のruleのアップデートに追いついておらずstackbのほうがメンテナンスが活発である。
WORKSPACEにruleの追加と.protoファイルの追加
WORKSPACEにruleを追加する。
# WORKSPACE http_archive( name = "build_stack_rules_proto", urls = ["https://github.com/stackb/rules_proto/archive/1d6550fc2e62.tar.gz"], sha256 = "113e6792f5b20679285c86d91c163cc8c4d2b4d24d7a087ae4f233b5d9311012", strip_prefix = "rules_proto-1d6550fc2e625d47dc4faadac92d7cb20e3ba5c5", )
シンプルなecho.protoをproto/echo
ディレクトリ以下に追加する。
# proto/echo/echo.proto syntax = "proto3"; package echo; service Echo { rpc Echo (EchoInbound) returns (EchoOutbound); } message EchoInbound { string message = 1; } message EchoOutbound { string message = 1; }
この状態でgazelle
を実行するとproto/echo
以下にBUILD.bazel
が追加されている。
(bazel-multiprojects) $ bazel run //:gazelle
# proto/echo/BUILD.bazel load("@io_bazel_rules_go//go:def.bzl", "go_library") load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library") proto_library( name = "echo_proto", srcs = ["echo.proto"], visibility = ["//visibility:public"], ) go_proto_library( name = "echo_go_proto", compilers = ["@io_bazel_rules_go//proto:go_grpc"], importpath = "github.com/soushin/bazel-multiprojects/proto/echo", proto = ":echo_proto", visibility = ["//visibility:public"], ) go_library( name = "go_default_library", embed = [":echo_go_proto"], importpath = "github.com/soushin/bazel-multiprojects/proto/echo", visibility = ["//visibility:public"], )
ここで生成されたgo_libraryのgo_default_library
を後でgRPCサーバ側のソースに依存させる。
go_grpc_compileをつかってProtocol Bufferを生成するタスクを追加する
go_default_library
のビルドタスクのみだとecho.pb.go
ファイルが生成されない。ここでstackb/rules_proto
のruleを追加する。proto/echo/BUILD.bazel
に以下を追加する。
load("@build_stack_rules_proto//go:go_grpc_compile.bzl", "go_grpc_compile") go_grpc_compile( name = "proto_buf", deps = [":echo_proto"], )
このproto_buf
のタスクを実行するとbazel-genfiles/proto/echo/proto_buf/proto/echo/
以下にecho.pb.go
ファイルが生成される。
(bazel-multiprojects) $ bazel build //proto/echo:proto_buf INFO: Analysed target //proto/echo:proto_buf (0 packages loaded). INFO: Found 1 target... Target //proto/echo:proto_buf up-to-date: bazel-genfiles/proto/echo/proto_buf/proto/echo/echo.pb.go INFO: Elapsed time: 0.261s, Critical Path: 0.08s INFO: 1 process: 1 darwin-sandbox. INFO: Build completed successfully, 2 total actions
生成されたecho.pb.go
ファイルをプロジェクトの/proto/echo
に配置することでgRPCサーバ側のコードにimportすることができる。
gRPCサーバを実装する
gRPCサーバのコードではgoogle.golang.org/grpc
のライブラリを利用するのでWORKSPACEに外部ライブラリの依存を追加しておく。
go_repository( name = "org_golang_google_grpc", importpath = "github.com/grpc/grpc-go", tag = "v1.16.0", )
main.goのコードは次のような差分になった。シンプルにechoServerを実装しているコードである。
# pkg/public_go/main.go diff --git a/pkg/public_go/main.go b/pkg/public_go/main.go index 73c925b..34bddcc 100644 --- a/pkg/public_go/main.go +++ b/pkg/public_go/main.go @@ -1,17 +1,29 @@ package main import ( + "context" "flag" "fmt" - "context" "log" + "net" "net/http" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" + "github.com/soushin/bazel-multiprojects/pkg/common_go/util" + "github.com/soushin/bazel-multiprojects/proto/echo" ) var msg string +type server struct{} + +func (s *server) Echo(ctx context.Context, in *echo.EchoInbound) (*echo.EchoOutbound, error) { + return &echo.EchoOutbound{ + Message: in.Message}, nil +} + func main() { greet := flag.String("greet", "Hello", "greet message") flag.Parse() @@ -21,6 +33,22 @@ func main() { } msg = greetUsecase.Msg + // serve gRPC server + lis, err := net.Listen("tcp", ":50051") + defer lis.Close() + if err != nil { + log.Fatalf("failed to listen: %v", err) + } + grpcServer := grpc.NewServer() + server := &server{} + echo.RegisterEchoServer(grpcServer, server) + reflection.Register(grpcServer) + go func() { + if err := grpcServer.Serve(lis); err != nil { + log.Fatalf("failed to serve grpc server") + } + }() + http.HandleFunc("/", handler) http.ListenAndServe(":8080", nil) }
上記のコードの状態でgazelle
を実行するとpkg/public_go/BUILD.bazel
に依存が追加される。
diff --git a/pkg/public_go/BUILD.bazel b/pkg/public_go/BUILD.bazel index 0c4c20d..24250db 100644 --- a/pkg/public_go/BUILD.bazel +++ b/pkg/public_go/BUILD.bazel @@ -11,6 +11,9 @@ go_library( deps = [ "//pkg/common_go/util:go_default_library", "//pkg/public_go/usecase:go_default_library", + "//proto/echo:go_default_library", + "@org_golang_google_grpc//:go_default_library", + "@org_golang_google_grpc//reflection:go_default_library", ], )
gazelleがWORKSPACEの外部ライブラリ依存の定義とmain.goのコードを解析して依存関係をBUILD定義に反映してくれている。
ここまでくればgRPCサーバを起動することができる。
gRPCサーバの起動
pkg/public_go/BUILD.bazel
に定義されているgo_binaryのタスクのrunコマンドを実行してサーバを起動させる。
$ (bazel-multiprojects) $ bazel run //pkg/public_go:public_go
gRPCのデバッグに最適なevansで起動したgRPCサーバにリクエストを送り確認する。
evans --port 50051 proto/echo/echo.proto ______ | ____| | |__ __ __ __ _ _ __ ___ | __| \ \ / / / _. | | '_ \ / __| | |____ \ V / | (_| | | | | | \__ \ |______| \_/ \__,_| |_| |_| |___/ more expressive universal gRPC client 127.0.0.1:50051> show package +---------+ | PACKAGE | +---------+ | echo | +---------+ 127.0.0.1:50051> package echo echo@127.0.0.1:50051> show service +---------+------+-------------+--------------+ | SERVICE | RPC | REQUESTTYPE | RESPONSETYPE | +---------+------+-------------+--------------+ | Echo | Echo | EchoInbound | EchoOutbound | +---------+------+-------------+--------------+ echo@127.0.0.1:50051> service Echo echo.Echo@127.0.0.1:50051> call Echo message (TYPE_STRING) => Awesome Bazel! { "message": "Awesome Bazel!" }
EchoSeverが正常に起動していることを確認できた。
まとめ
- gRPCサーバを含むGoプロジェクトをBazelでビルドする方法をまとめた
- Protocol Bufferを生成するruleもありGoプロジェクト周辺のビルドユースケースが整っている