2020-04-26

Bazel で Go のソースコードをビルドするぞ

Goの開発で Bazel を使っているので簡単にまとめてみました。

サンプル用のプロジェクトを作ったのでこれをもとに進めていこうと思います。 GitHub - righ/bazel-sample-projectContribute to righ/bazel-sample-project development by creating an account on GitHub.https://github.com/righ/bazel-sample-project

利用する Bazel のバージョンは以下です。

info

今回使う開発環境は Mac Catalina 64bit です。

Bazel

Bazelは Google が開発したOSSのビルドツールです。

キャッシュが効くため高速にビルドできたり、バイナリを作るだけでなくコンテナに乗せるところもやってくれるので 私達は開発に専念することができます。

Rule

Bazel はルールと呼ばれる拡張機能を用いることでさまざまな言語のビルドに対応できます。

今回は以下のルールを使います。

GitHub - bazelbuild/rules_go: Go rules for BazelGo rules for Bazel. Contribute to bazelbuild/rules_go development by creating an account on GitHub.https://github.com/bazelbuild/rules_go GitHub - bazelbuild/rules_docker: Rules for building and handling Docker images with BazelRules for building and handling Docker images with Bazel - GitHub - bazelbuild/rules_docker: Rules for building and handling Docker images with Bazelhttps://github.com/bazelbuild/rules_docker

設定ファイル

Bazel で重要なのは WORKSPACEBUILD.bazel (BUILD) という2種類のテキストファイルです。

フォーマットは Python に似た Starlark と呼ばれる 言語で記述します。 Python の構文がすべて使えるわけではないので注意してください。例えば import 文は使えません。

WORKSPACE

WORKSPACE が配置されたディレクトリ配下は Bazel ではワークスペースとして認識されます。

このファイルにはプロジェクト全体に関する設定を書いていきますが、必要がなければ空でも構いません。

WORKSPACEでは主に以下のことを行います。

  • load と呼ばれる関数を使って必要なルールを読み込み
    • Python でいうなら from import のようなものと考えるとわかりやすい
  • 使用するルールを外部から読み込む http_archive, http_file の実行とそれらの設定を有効にする命令の実行

Bazel で指定するターゲットは @ルール//ワークスペース直下からBUILD.bazelまでのパス:名前 のようなフォーマットになっています。 先頭が // から始まる場合はローカルを指します。load に指定する場合、 名前 の部分はマクロファイル名になります。

WORKSPACE に書くことはほかにもありますが、詳しくは gazelle セクションで話します.

BUILD.bazel

BUILD.bazel (あるいは BUILD) はビルド対象のソースコードが配置されたディレクトリごとに配置し、 このファイルが置かれているディレクトリを Bazel では パッケージ と呼びます。

BUILD.bazel と BUILD はいずれもパッケージを定義するための設定ファイルで同じ意味を持つので片方だけあれば問題ありません。 どちらにすべきというのは公式には書かれていないようですが、両方置いた場合には BUILD.bazel が優先されるっぽいです(未検証) 資料はこのあたり

In Docs: clarify BUILD vs. BUILD.bazel · Issue #4517 · bazelbuild/bazelOne can use either BUILD or BUILD.bazel as the filename for config files. AIUI, the preferred name is BUILD.bazel (because why? because this?) but the docs mostly reference BUILD. Can we update the...https://github.com/bazelbuild/bazel/issues/4517

BUILD.bazel にはビルドに必要なソースコードや依存パッケージの指定などを行います。 以下のようなものがあります。

go_library
  • パッケージ内のソースをビルドして GoLibrary を作成します。
  • これは直接実行することはできず、後述する go_binary などから参照される形で利用されます。
  • オプションは以下のURLを参照してもらうとして、重要なのは依存パッケージを指定する deps です。
  • 必要なパッケージがここに指定されていないとビルドが通らないので注意です。
go_binary
  • mainパッケージに属するファイル群をビルドして実行ファイルを作ります。
  • 実行するには bazel runbazel build を実行します。
go_test
  • $ bazel test とすることでテストをビルドします。
  • ワークスペース内のすべてのテストを行う場合は bazel test --test_output=errors //... とします。
  • これは go test ./... と同じです。

https://github.com/bazelbuild/rules_go/blob/master/go/core.rst

gazelle

ビルドするのにルールなんていちいち覚えてられないし、 何よりファイルを作るたびにビルドの設定ファイルを手動で書き換えるのはめんどくさいですね。

この設定を自動生成するためのツールが gazelle です。

以下のようにセットアップします。

これで bazel run //:gazelle のように呼び出せるようになります。

$ bazel run //:gazelle INFO: Analyzed target //:gazelle (3 packages loaded, 139 targets configured). INFO: Found 1 target... Target //:gazelle up-to-date: bazel-bin/gazelle-runner.bash bazel-bin/gazelle INFO: Elapsed time: 4.819s, Critical Path: 0.01s INFO: 0 processes. INFO: Build completed successfully, 1 total action INFO: Build completed successfully, 1 total action

このコマンドは ワークスペース配下の goのソースコードが含まれるすべてのディレクトリに BUILD.bazel を配置します。 単純な上書きではないので、もともと書いてある内容を勝手に消し去ったりすることはありません。

実際に作成された BUILD.bazel は 後のセクションで見ていくことにします。

info
  • # gazelle:prefix github.com/example/project のようなコメントを BUILD.bazel に追加するか -go_prefix のようなオプションを渡してあげないと以下のようなエラーが発生します。
  • [gazelle: /bazel-sample-project/echo: go prefix is not set, so importpath can't be determined for rules. Set a prefix with a '\# gazelle:prefix' comment or with -go_prefix on the command line
  • 今回は bazel-sample-project/BUILD.bazel にコメントを記述しました。

update-repos

このままでは 外部パッケージのバージョンが固定されていないためビルドに失敗することがあります。

そこで、 update-repos というサブコマンドを使います。 -- の後に指定した引数が gazelle に渡ります。(ターゲットに渡す引数は -- 以降に記述します)

$ bazel run //:gazelle -- update-repos -from_file ./go.mod INFO: Analyzed target //:gazelle (59 packages loaded, 6979 targets configured). INFO: Found 1 target... Target //:gazelle up-to-date: bazel-bin/gazelle-runner.bash bazel-bin/gazelle INFO: Elapsed time: 5.798s, Critical Path: 0.01s INFO: 0 processes. INFO: Build completed successfully, 1 total action INFO: Build completed successfully, 1 total action

このコマンドは go.modGopkg.lock のようなパッケージのバージョンを管理するファイルを指定することで 依存パッケージのリポジトリ情報を WORKSPACE に書き出してくれます。

先程の WORKSPACE の下の方に書かれていた大量の go_repository はこれによるものだったのですね。

GitHub - bazelbuild/bazel-gazelle: Gazelle is a Bazel build file generator for Bazel projects. It natively supports Go and protobuf, and it may be extended to support new languages and custom rule sets.Gazelle is a Bazel build file generator for Bazel projects. It natively supports Go and protobuf, and it may be extended to support new languages and custom rule sets. - GitHub - bazelbuild/bazel-g...https://github.com/bazelbuild/bazel-gazelle https://github.com/bazelbuild/bazel-gazelle/blob/master/repository.rst#go_repository

実際に使ってみる

これまでの概要だけでは何が嬉しいのかイマイチ分かりづらいと思うので、 以降のセクションではサンプルプロジェクトを動かして解説していきます。 最終的に k8s で動かすのが目標です。

GitHub - righ/bazel-sample-projectContribute to righ/bazel-sample-project development by creating an account on GitHub.https://github.com/righ/bazel-sample-project

gateway サーバと echo サーバがあって、 gateway にきたリクエストを echo に gRPC で受け流してリクエストをそのままgatewayに返却するという簡単な構成です。

sequenceDiagram participant client participant gateway participant echo client->>gateway: HTTP Request gateway->>echo: gRPC Request echo-->>gateway: gRPC Response gateway-->>client: HTTP Response

それぞれのプログラム自体は簡易ですが 別々のサーバなのでビルドはそれぞれで行わなければなりません。

bazel を使わずにビルドして実行してみます。

  • go run gateway/main.go
  • $ go run echo/main.go

localhost:8000 にアクセスしてから、以下のように /echoaaa を渡します。

aaa が返ってきたので期待通りです。

bazel run

続いて bazel を使って動かしてみます。

bazel でビルドしたものをそのまま動かすには bazel run ターゲット のようにします。 ターゲットの名前部分(: の右側)を省略するとパッケージ名で補完されます。

以下は 実行対象の BUILD.bazel と登録された go_binary を呼び出す例です。

  • $ bazel run //gateway --platforms=@io_bazel_rules_go//go/toolchain:darwin_amd64 INFO: Build option --platforms has changed, discarding analysis cache. INFO: Analyzed target //gateway:gateway (1 packages loaded, 7590 targets configured). INFO: Found 1 target... Target //gateway:gateway up-to-date: bazel-bin/gateway/darwin_amd64_pure_stripped/gateway INFO: Elapsed time: 20.093s, Critical Path: 19.34s INFO: 82 processes: 82 darwin-sandbox. INFO: Build completed successfully, 85 total actions INFO: Build completed successfully, 85 total actions
  • $ go run echo/main.go $ bazel run //echo --platforms=@io_bazel_rules_go//go/toolchain:darwin_amd64 INFO: Build option --platforms has changed, discarding analysis cache. INFO: Analyzed target //echo:echo (0 packages loaded, 7590 targets configured). INFO: Found 1 target... Target //echo:echo up-to-date: bazel-bin/echo/darwin_amd64_pure_stripped/echo INFO: Elapsed time: 2.091s, Critical Path: 1.56s INFO: 2 processes: 2 darwin-sandbox. INFO: Build completed successfully, 5 total actions INFO: Build completed successfully, 5 total actions

(service_image というのは次のセクションで説明しますが、今回の実行には関係ありません)

先ほどと同じように localhost:8000 にアクセスしてから、以下のように /echoaaa を渡します。

期待通りですがなんか普通のビルドよりも複雑になっている気がします。 --platforms=@io_bazel_rules_go//go/toolchain:darwin_amd64 ってなんなの?って思いましたよね。

実は最終的に Dockerのコンテナとして動かすことを想定しているため、Linuxのバイナリとして出力されるように .bazelrc を設定しているのです。

ビルドだけなら動作しますがそのバイナリをMacの環境で動かそうとすると当然動かないのです。 (cannot execute binary file って言われる)

そこで私が使っている環境で動くように \--platforms オプションでMac用バイナリを吐くように制御しています。 通常(Dockerで動かさない場合)は bazel run //xxxx だけでうまく動くはずです。

Container で動かす

コンテナを作るには (container_imageと) go_image を使います。

が、今回は作成した BUILD.bazel には直接これらを書かず、service.bzl というマクロファイルに独自定義した service_image を介して利用しています。 わざわざ関数化した理由は単に複数回利用するからです。今回の例ではサービスは2つですが多くなると同じような処理を何回も書きたくありません。

マクロ関数は以下のようになっています。

このマクロ関数は サービス名を受け取り、そのサービス名を元にビルドしたバイナリが指定されます。

実はただコンテナを作るだけであれば go_image だけでよいのですが、コンテナを tar 形式のファイルとして出力するために container_image で作ったイメージをベースに go_image を呼び出しています。 そして container_imagetars 引数に pkg_tar の tar ファイルを指定すれば tar にコンテナイメージを埋め込めるようです。 これはドキュメントにも書かれている custom base と呼ばれる手法だそうです。

一番最後に書いた container_push はその名の通り作成したコンテナをレジストリに登録するためのものです。 Push のセクションで解説しますが、ここでは利用しません。

前置きが長くなりましたが実際に実行してみます。

  • $ bazel run //gateway:image INFO: Analyzed target //gateway:image (0 packages loaded, 0 targets configured). INFO: Found 1 target... Target //gateway:image up-to-date: bazel-bin/gateway/image-layer.tar INFO: Elapsed time: 0.556s, Critical Path: 0.19s INFO: 1 process: 1 darwin-sandbox. INFO: Build completed successfully, 5 total actions INFO: Build completed successfully, 5 total actions 1410da508793: Loading layer [==================================================>] 10.17MB/10.17MB Loaded image ID: sha256:6d5a86f263dbe7c9d67bed2f929227b47e0575622103f2249951b301b9d19d26 Tagging 6d5a86f263dbe7c9d67bed2f929227b47e0575622103f2249951b301b9d19d26 as bazel/gateway:image
  • $ bazel run //echo:image INFO: Analyzed target //echo:image (0 packages loaded, 0 targets configured). INFO: Found 1 target... Target //echo:image up-to-date: bazel-bin/echo/image-layer.tar INFO: Elapsed time: 0.393s, Critical Path: 0.15s INFO: 1 process: 1 darwin-sandbox. INFO: Build completed successfully, 5 total actions INFO: Build completed successfully, 5 total actions ae82e6e875bc: Loading layer [==================================================>] 9.943MB/9.943MB Loaded image ID: sha256:64abfa06b48b087828fa48e05f2e4d1906f92689f8f1c3895b530d608094c5a3 Tagging 64abfa06b48b087828fa48e05f2e4d1906f92689f8f1c3895b530d608094c5a3 as bazel/echo:image

一見動いているように見えますが、 http://localhost:8000/ にアクセスすることができません。 理由は gateway が echo サーバ (localhost:8001) にアクセスしてもDocker的に別ホストのため localhost で接続できず処理がブロックされHTTPサーバが起動しないためです (ブロックしないようにもできるけどechoサーバに接続できないのは同じこと)

このために gateway のプログラムは echo のホストを環境変数(ECHO_HOST)で変えられるようにしています。 つまり ECHO_HOST に実際の echo コンテナのIPアドレスを指定すればうまくいきそうです。

ただ、今回は以下の理由で bazel run で通信させることはできませんでした。

  • コンテナ起動時に特定のDockerネットワークに所属させることができなかった
  • BUILD.bazel でシェルからわたした環境変数を読み出すことができなかった
    • container_image には env 引数で辞書形式で受け取れるが全環境で固定になってしまうのでホストから渡した環境変数を読みたい

というわけで、ビルドしたコンテナを docker コマンドで呼び出すようにしてみます。

docker network create test-networktest-network を作り、以下を実行します。

  • # gateway $ docker run --rm --name="gateway" --net=test-network --env ECHO_HOST=echo -p 8000:8000 07908b17146
  • # echo $ docker run --rm --name="echo" --net=test-network 82ccbd677fb

今度は http://localhost:8000/ にアクセスできました。

なんとか動いたようです。

bazel から直接呼び出せているわけではないのでなんか気持ち悪いですが、今回のゴールはここじゃないので目を瞑ってください。

info

Push する

Bazelを使って任意のコンテナレジストリに Push することができます。

先程 container_push を書いたのはこれが理由です。

info
  • container_push には go_image の名前を指定します。
  • container_image の名前を指定して Push しても イメージの CMDがなく No command specified と言われます。

今回のアップロード先は Docker Hub です。 予めログインを済ませて、リポジトリを作成しておく必要があります。

ここはレジストリによって操作が異なるのであんまり詳しくやりませんが、 私の場合は以下のようなリポジトリがある状態です。

今回 DockerHub に Push するための、 push_to_dockerhubpush_to_gcr の2つを用意しました。

以下は DockerHub に Push する例です。

  • $ bazel run //gateway:push_to_dockerhub INFO: Analyzed target //gateway:push_to_dockerhub (0 packages loaded, 1 target configured). INFO: Found 1 target... Target //gateway:push_to_dockerhub up-to-date: bazel-bin/gateway/push_to_dockerhub.digest bazel-bin/gateway/push_to_dockerhub INFO: Elapsed time: 0.277s, Critical Path: 0.04s INFO: 1 process: 1 darwin-sandbox. INFO: Build completed successfully, 5 total actions INFO: Build completed successfully, 5 total actions 2020/05/03 23:12:28 Destination docker.io/righm9/gateway:{BUILD_TIMESTAMP} was resolved to docker.io/righm9/gateway:1588515147 after stamping. 2020/05/03 23:12:38 Successfully pushed Docker image to docker.io/righm9/gateway:1588515147
  • bazel run //echo:push_to_dockerhub INFO: Analyzed target //echo:push_to_dockerhub (0 packages loaded, 1 target configured). INFO: Found 1 target... Target //echo:push_to_dockerhub up-to-date: bazel-bin/echo/push_to_dockerhub.digest bazel-bin/echo/push_to_dockerhub INFO: Elapsed time: 0.267s, Critical Path: 0.03s INFO: 1 process: 1 darwin-sandbox. INFO: Build completed successfully, 5 total actions INFO: Build completed successfully, 5 total actions 2020/05/03 23:13:06 Destination docker.io/righm9/echo:{BUILD_TIMESTAMP} was resolved to docker.io/righm9/echo:1588515186 after stamping. 2020/05/03 23:13:13 Successfully pushed Docker image to docker.io/righm9/echo:1588515186

GCR へ Push するのは GKE に kustomize を使ってデプロイしてみる を御覧ください。

Skaffold で動かす

ローカルで Kubernetes を動かすために Skaffold というツールを使います。

Bazel と同様に Google が開発したツールで、 Bazel でビルドしたコンテナを指定することができます。

以下のような設定ファイルを用意しました。

artifacts の bazel.target にコンテナを指定できますが、 tar 形式である必要があります。 先程のマクロ関数で pkg_tar を使っていたのはこれが理由だったのです。

Kubernetes の解説になってしまいますが、 deploy.kustomize には Kustomization の定義を指定できるので、 kube/skaffold を指定しています。 これにより kube/skaffold/kustomization.yaml が読み込まれます。

    skaffold/kustomization.yaml では echo と gateway のローカル用設定を 読み込み、 ローカル設定はそれぞれの base 設定を読み込んでいるという具合です。 (この記事では production は使いません)

    構造化しているのでファイルは多いですがやっていることは割と単純です。

    また、 gateway の ECHO_HOST 環境変数は echo.default.svc.cluster.local にしています。 これは Kubernetes 内で Pod のアドレスを解決するための FQDN です。 (詳しくは こちら)

    Skaffold でサーバを起動するために skaffold dev を実行します。 これはファイルの変更検知をして Bazel のリビルドが実行されるというすぐれものです。 (Realize とか使わなくていいですね

    $ skaffold dev Listing files to watch... - righm9/echo - righm9/gateway Generating tags... - righm9/echo -> righm9/echo:0907753 - righm9/gateway -> righm9/gateway:0907753 Checking cache... - righm9/echo: Found Locally - righm9/gateway: Found Locally Tags used in deployment: - righm9/echo -> righm9/echo:82ccbd677fb859dd731e61c4d4e2cf5f80a50636eb56e85f8263b320263cf080 - righm9/gateway -> righm9/gateway:07908b17146c811733912239c242080108fcf917580cbfe0cd97e2d613cd10a2 local images can't be referenced by digest. They are tagged and referenced by a unique ID instead Starting deploy... - configmap/nginx-conf created - service/echo created - service/gateway created - deployment.apps/echo created - deployment.apps/gateway created Waiting for deployments to stabilize... - deployment/echo: waiting for rollout to finish: 0 of 1 updated replicas are available... - deployment/gateway: waiting for rollout to finish: 0 of 1 updated replicas are available... - deployment/echo is ready. [1/2 deployment(s) still pending] - deployment/gateway is ready. Deployments stabilized in 2.752441541s Watching for changes...

    今回はせっかくの k8s なので gateway の前に Nginx を噛ませてみました。 gateway と同じ Pod で稼働させています。

    sequenceDiagram participant client participant nginx participant gateway participant echo client->>nginx: HTTP Request nginx->>gateway: HTTP Request gateway->>echo: gRPC Request echo-->>gateway: gRPC Response gateway-->>nginx: HTTP Response nginx-->client: HTTP Response

    Nginxが待ち受けている ポート 80番 にアクセスしてみます。

    うまく動いているようなので完了ですね

    Errors

    bazel run でいくつかエラーがでてハマったので対応方法を残しておきます。

    no such package '@zlib//': The repository '@zlib' could not be resolved and referenced by '@com_google_protobuf//:protobuf'

    WORKSPACEで以下を実行する。

    load("@com_google_protobuf//:protobuf_deps.bzl", "protobuf_deps") protobuf_deps()

    com.google.devtools.build.lib.packages.BuildFileContainsErrorsException: error loading package '@io_bazel_rules_docker//toolchains/docker': Unable to load file '@bazel_skylib//:bzl_library.bzl': file doesn't exist

    WORKSPACEで以下を実行する。

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

    no such package '@org_golang_google_grpc//': no such package '@bazel_gazelle_go_repository_cache//': gazelle could not find a Go SDK. Specify which one to use with gazelle_dependencies(go_sdk = "go_sdk").

    WORKSPACEで以下を実行する。

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

    Failed to load Starlark extension '@rules_python//python:pip.bzl'. Cycle in the workspace file detected. This indicates that a repository is used prior to being defined.

    WORKSPACEで以下を実行する。

    load( "@io_bazel_rules_docker//repositories:repositories.bzl", container_repositories = "repositories", ) container_repositories()

    no such package '@go_image_base//image': The repository '@go_image_base' could not be resolved

    WORKSPACEで以下を実行する。

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

    終わりに

    デバッグのやり方まで書きたかったんですが skaffold を通していい感じに設定する方法がわからなかったので今回はここまでにしときます。

    bazel でデバッグするだけなら -c dbg でビルドしたものを delve で解析する感じかな?

    (資料だけおいて撤退

    わかったら別の記事に上げます。もし知ってる方がいたら教えてもらえるとうれしいです。

    その他の参考資料