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_gopublic_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連携を実現した。

blog.stormcat.io

完成したシェルスクリプトは次のようになった。

#!/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連携も見通しよく整理できそうな感触を得た。

コード

github.com

BazelでDockerイメージのビルドとレジストリへのプッシュをする

前回までのエントリに引き続きBazelのビルドをまとめていく。

今回はDockerイメージのビルドとビルドしたイメージをレジストリにプッシュする方法をまとめる。

Bazelの魅力

具体的な方法に入る前にBazelの魅力を整理したい。

Bazelの魅力はマルチ言語のビルドをサポートしている点が挙げられる。GoプロジェクトであればGazelleがWORKSPACEとBUILDファイルの中間コードを補完してくれる。そしてコンテナ周辺のビルドタスクも定義できる。

コンテナ周辺のビルドタスクのサポートがBazelの最大の魅力だと感じる。マイクロサービスを取り入れたプロジェクトであればサービスをどのような単位で管理するだろうか。シンプルに考えればサービス1つに対してレポジトリを作るだろう。そのレポジトリにDockerfileを置けばビルド時にイメージのビルドとレジストリへのプッシュも行える。しかしいくつかの課題がある。レポジトリ間の依存である。ユーティリティ系のレポジトリやgRPCのprotoファイルなどレポジトリを分けることによって運用の手数が増えることになる。

その点Bazelは1つのレポジトリにマルチプロジェクトを構成しgRPCのprotoファイルを集約させることができる。そしてDockerイメージのビルドとプッシュも行えるとあれば一連の運用フローがBazelで完遂することができるのだ。

Goプロジェクトをdistrolessをベースイメージとしてビルドする

Goプロジェクトをdistrolessイメージでビルドしたい。

github.com

これまでは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との連携を考えていきたい。

コード

github.com

gRPCサーバを含むGoプロジェクトをBazelでビルドする

前回までのエントリに引き続きBazelのビルドをまとめていく。

blog.soushi.me

blog.soushi.me

今回のエントリではgRPCサーバを含むGoプロジェクトをBazelでビルドする方法をまとめる。

Protocol BufferとgRPCサーバのInterfaceの生成をBazelで行う

これまではprotocをつかって.protoファイルからProtocol BufferとgRPCサーバのInterfaceの生成を行っていたがBazelのruleを使えば簡単にビルドタスクに追加することができる。

利用するruleはrules_proto

github.com

この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サーバにリクエストを送り確認する。

github.com

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プロジェクト周辺のビルドユースケースが整っている

コード

github.com