ビルドツールのあれこれとGolang+Bazelの導入

この記事は Voicy Advent Calendar 2021 3日目の記事です。

Voicyのバックエンドエンジニアの会沢です。

突然ですが、みなさんは、ビルドツールについて興味はありますか? あまり目立たないところですが、ビルドの待ち時間は開発の効率化に大きく影響する部分の一つなので、常にキャッチアップしていきたいところです。

前職では、OpenCVやQTやffmpegなどマルチプラットフォームの大規模ライブラリのカスタムコンパイル職人をさせられたりしていたので、今回は、各種のビルドツールを軽く振り返りつつ、最近注目のBazelをgolangでビルドする方法についてまとめてみました。

ビルドツールについて

プログラミング言語の仕様は、本来、標準ライブラリとコンパイラの実装に依存します。 そのため環境環境が違えば異なるソースコードを書く必要があります。 しかし、C言語C++、Cuda、assemblerなどの言語は、古くから標準化が行われているため、同じソースコードを異なるコンパイラでビルドすることができます。

例えば、C++だけとっても、GCC,MinGW GCC, MSVC, Clang, LLVMなど複数のコンパイラが存在していますが、標準ライブラリのみ使用したコードであれば、基本的にどのコンパイラでもビルドすることができます。ビルド設定を複数のコンパイラ間で共有したり、ビルド作業自体を自動化するのがビルドツールです。ここではmake, scons, ninjaについて紹介します。

make

コンパイル、リンク、インストール等のプログラムのビルド作業を自動化するツールです。

メリット

  • 1つ以上のコマンドをタスクとしてまとめることができる
  • 変更差分だけコンパイルし直すことができ、時間短縮できる
  • 基本構造はシンプルで、一度テンプレートを作成すれば、他のプロジェクトにも流用できる

Makfile

makeにはルールとタスクというものがあります。

ルール

ルールの基本的なフォーマットは以下のとおりです。 この一かたまりをルールと呼び、ルール内の出力ファイルをターゲットと呼びます。

出力ファイル名:
    ファイルをビルドするためのコマンド

タスク

ターゲットとして、実際に存在しないファイル名を指定することができます。 擬似ターゲットとかダミーターゲットと呼ばれたりしますが、フォーマットは以下のようになっています。

.PHONY: タスク名
タスク名: 
    コマンド

PHONYはタスクターゲットを宣言するためのターゲットです。偽物、偽の、という意味があります。PHONYへ指定しなくてもタスクターゲットを作成することはできますが、タスク名と同名のファイルやディレクトリがあると混乱するので、積極的に書いた方がわかりやすいです。

下記はGolangのルールとタスクの例です。

# defines
GO          := go
GO_BUILD    := $(GO) build
GO_TEST     := $(GO) test -v
GO_LDFLAGS  = -ldflags="-s -w"
GOOS        := linux
DC          := docker-compose -f docker-compose.yml

TARGETS := bin/main
GO_PKGROOT  := ./...
GO_PACKAGES := $(shell $(GO_LIST) $(GO_PKGROOT) | grep -v vendor)

.PHONEY: build test clean lint docker-build

# rules
build: $(TARGETS)

bin/main:
    env GO111MODULE=on GOOS=$(GOOS) -tags=$(GO_BUILD_TAGS) $(GO_BUILD) $(GO_LDFLAGS) -o $@

docker-build:
    ${DC} build
    # docker-compose -f docker-compose.yml build

test:
    env GOOS=$(GOOS) $(GO_TEST) $(GO_PKGROOT)
    # GOOS=linux go test -v ./...
clean:
    rm -rf $(TARGETS) ./vendor
    # rm -rf bin/man ./vendor 

基本的にルールを直接変更するよりも、コマンドやオプションを変数で持っておき、条件分岐や、実行時に変数に値を指定して動作を切り替えてやることが多いです。

例えば、先ほどのMakefileを例にとると

make test 

でプロジェクトルート以下のテストを実行

make test GO_PKGROOT=./internal/...

とすればinternal以下のテストを実行するように動作を切り替えられます。

makeでは複数のコマンドを一つのタスクにまとめて実行できるので、ビルド以外にもテストやDockerの操作やCIなどのタスクランナーとして利用したりします。 また、PHONYを分割して宣言するよりも、1行にまとめるてPHONY行を見ることで何が準備されているのかを把握できるようしておくとルールが増えていった時にわかりやすいです

SCons

makeと同時期からあるPythonで実装されたビルドツールです。 MongoDBのコンパイルとかに使われています。 最近はあまり見かけることもないですが、goをビルドできるgosconsというツールセットがあるので、試してみても面白いかもしれません。

ninja

ninjaは、Chromiumプロジェクトの開発者、Evan Martin氏が開発した高速なビルドシステムです。 makeは増分ビルド、またはリビルドした場合に低速になる問題があり、約40,000の膨大なソースからChromeLinuxにポーティングする際に、SConsやmakeではパフォーマンスに課題を感じたために開発を開始した背景がありmakeやSConsと比べてもかなり早く動作します。 makeだと10分以上掛かるものが1分で終わるくらいのスピード感なので、大きなライブラリをビルドしなければならない時には、makeとninjaでは顕著に差がでます。

公式のマニュアルのPhilosophical overviewによると、ninjaはビルドツールのアセンブラを指向しているそうです。 機能を最小限にすることで性能を保ち、生成されたファイルは記述が冗長で手書きはしにくいものの、ユーザーが直接ninjaを記述するのではなく、より高度なビルドツールからninjaを生成して使うような思想になっています。

V8エンジンとかとかWebRTCのビルドに使われています。

インストール

brew install ninja-build

make:build.ninja

makeのMakefileに相当するのが、build.ninjaです。 基本的に弄ることはないですがほぼmakeと同じ記述です。

cc = g++
cxxflags = -Wall

rule obj
  depfile = $out.d
  command = $cc -MMD -MF $out.d $cxxflags -c $in -o $out

rule app
  command = $cc $ldflags $in -o $out

build main.o: obj main.go
build gcd.o: obj gcd.go
build main: app main.o gcd.o

ここまで色々紹介しましたが、最近はDocker buildでビルドする構成も多いので、dockerの タスクランナーとして使うのであれば、基本的にmakeを使うのがシンプルで良いと思います。

より高度なビルドツール

ここで紹介するCMakeやBazelと言ったビルドツールは、コンパイラに依存しないビルド設定を用いてビルドを自動化するためのツールです。

前述したように標準化が進んでいる言語のソースコードに関してはコンパイラ間で共有が可能ですが、一方でコンパイラのオプションなどのビルドに際しての設定項目の仕様やそれを格納する設定ファイルのフォーマットは統一されていません。

そのため異なるコンパイラでビルドするためには、

というように、開発環境に合わせてプロジェクトファイルを使い分ける必要があります。

実際には、コンパイラIDEのバージョン毎にプロジェクトファイルの仕様も変わっていくので、対応するバージョン全てのプロジェクトファイルを用意しなければならず、そのすべてを管理し続けることは不可能です。

設定ファイルを作成して、そこから任意のビルドツールのビルド設定ファイルを生成することで、どの開発環境でもビルド設定ファイルの生成と生成ファイルを使用してビルドするという2ステップだけでプロジェクトを一括で管理できるようになります。

色々なコンパイラでビルドできるということは、色々なOS上でビルドできるということで、つまりはクロスプラットフォームに対応します。

Cmake

そもそもITKやVTKなどの低レイヤーのコンピュータビジョンライブラリをマルチプラットフォームでサポートさせるために開発されましたが、最近はいろんなところで使われていてAndroidのNDKにも使われていたりします。 AndroidではGradle->CMake->Ninjaの順番でコンパイルしています。

特徴

out-of-sourceについて

ビルドの手法として、in-sourceビルドとout-of-sourceビルドがあります。

in-sourceビルドは、ソースコードのあるパスでビルドを行います。 作中間生成ファイルをソースディレクトリのおくのでソースディレクトリが汚れ、中間生成ファイルや生成物を削除する時も手間がかかります。

out-of-sourceビルドは、ソースコードのあるパスとは異なる場所でビルドを行う方法です。 中間ファイルや生成物が整理されるのでソースディレクトリをクリーンに保つことができますし、削除する際もビルドディレクトリを削除するだけでよくなります。

Cmakeはout-of-sourceをサポートしているので、大規模プロジェクトをビルドシステムの構築する際に、makefileを各モジュールのディレクトリに配置したり、中間生成ファイルがあちこちのディレクトリに点在してディレクトリが汚れてしまうのを防げます。

チュートリアルについて

かなり詳細な公式のチュートリアルが用意されています。 ただし、入門者には情報量が多すぎて何がなんだかわかりにくい上、設定できる項目や使える機能も豊富なので入門の敷居はかなり高めです。

インストール

homebrewからインストールできます。

brew install cmake

他のインストール方法についてはInstalling|CMakeから確認してください。

注意点:

CMakeのバージョンによっては、サポート先のコンパイラと機能が衝突したり、変数名が大きく異なっていたして正しく機能しない事があるのでバージョンの確認は重要です。複数のバージョンを共存させることもあります。

CMakeLists.txt

CMakeでは、CMakeLists.txtに設定を書いていきます。 C++の 最小のコンパイルの例です。

# CMakeのバージョンを設定
cmake_minimum_required(VERSION 2.8)

# プロジェクト名と使用する言語を設定
project(test_cmake CXX)

# a.outという実行ファイルをmain.cppとhello.cppから作成
add_executable(a.out main.cpp hello.cpp)

CMakeを使ってビルドの際は、-Gオプションにより生成するビルドファイルをmakeやninjaなどに切り替えることができます。

Bazel

BazelはGoogleが社内開発に使用していた独自のビルドツールをオープンソース化したプロダクトです。

メリット

  • 高度なローカルおよび分散キャッシング、最適化された依存関係分析、および並列実行により、高速でインクリメンタルなビルドが可能
  • 多くのプログラミング言語に対応
  • 複数のリポジトリまたは巨大なサイズのコードにも対応できるスケーラビリティ
  • 対応言語やプラットフォームを簡単に追加できる拡張性

フォーマットはPythonににたStarlarkと呼ばれる言語で記述します。improt文など一部の構文は使用できません。

インストール

Homebrew経由が楽です

brew install bazel

正しくインストールされていれば以下のコマンドでWORKSPACEの配置フォルダなどのワークスペースの情報が表示されます。

$ bazel info workspace

Bazelisk

bazeliskはGoで書かれたBazelのラッパーです。 bazelコマンドの代わりにbazeliskコマンドを利用することで、環境変数や、bazelversionファイルに記述したバージョンのbazelコマンドを実行します。

インストールは、Goが利用できる環境で以下を実行します。

go get github.com/bazelbuild/bazelisk

設定ファイルについて

Bazelを使い始めるには2つのファイルが必要です。

  • WORKSPACE.bazel(WORKSPACE)
  • BUILD.bazel(BUILD)

WORKSPCE.bazel, BUILD.bazelにはエイリアスとして、WORKSPACEとBUILDがあり、どちらか片方だけがあれば問題ありません。

WORKSPACE

このファイルには、プロジェクト全体に関する設定を書いていきますが、必要がなければ空でも構いません。WORKSPACEは他のすべてのリソースが参照できる最上位ディレクトリにある必要があり、WORKSPACEが配置されたディレクトリ配下はBazelではワークスペースとして認識されます。

WORKSPACEで主に行うことは主に以下です。

  • loadメソッドを使って必要なルールを読み込み
  • 使用するルールを外部から読み込むhttp_archive, http_fileの実行とそれらの設定を有効にする命令の実行

WORKSPACEファイルに表示される最初の行はLoadメソッドの定義です。

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

このloadメソッドを使用すると、その場所にある他のスクリプトにアクセスできます。 ここでは、http_archiveを使用しています。

http_archive

GitHubからのライブラリリリースなど、Webから他のリモートリソースをフェッチします。 Bazelでは、ダウンロードしたファイルの整合性を検証をサポートしており、そのための引数としてsha256があります。 下記の例ではgo ruleをを読み込んでいます。

http_archive(
    name = "io_bazel_rules_go",
    sha256 = "2b1641428dff9018f9e85c0384f03ec6c10660d935b750e3fa1492a281a53b0f",
    urls = [
        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.29.0/rules_go-v0.29.0.zip",
        "https://github.com/bazelbuild/rules_go/releases/download/v0.29.0/rules_go-v0.29.0.zip",
    ],
)

BUILD.Bazel

ビルド対象のソースコードが配置されたディレクトリごとに配置します。 BUILD.bazel にはビルドに必要なソースコードや依存パッケージの指定などを行います。 このファイルが配置されているディレクトリを Bazel では パッケージと呼びます。

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

go_library(
    name = "app_lib",
    srcs = ["main.go"],
    importpath = "github.com/example/example-project/cmd/app",
    visibility = ["//visibility:private"],
    deps = [
        "//internal/app/config",
        "//internal/app/framework",
        "//internal/app/util",
        "//vendor/github.com/volatiletech/sqlboiler/v4/boil",
    ],
)

go_binary(
    name = "app",
    embed = [":app_lib"],
    visibility = ["//visibility:public"],
)

go_test(
    name = "e2e_test",
    srcs = [
        "test.go"
    ],
    deps = [
        "//internal/app/config",
        "//internal/app/framework",
        "//internal/app/util",
        "//vendor/github.com/volatiletech/sqlboiler/v4/boil",
    ],
)

それぞれの値についてみていきます。

go_library

パッケージ内のソースをビルドして GoLibrary を作成します。 これは直接実行することはできず、上記した go_binary などから参照される形で利用されます。 depでは依存パッケージを指定できます。 必要なパッケージの指定漏れがあるとビルドが通らないので注意してください。

go_binary

mainパッケージに属するファイル群をビルドして実行ファイルを作ります。 実行するには bazel run か bazel build を実行します。

go_test

$ bazel test とすることでテストをビルドします。 ワークスペース内のすべてのテストを行う場合は bazel test --test_output=errors //... と記述します。 これは go test ./... と同等です。

その他のオプションはに関しては公式を参照してください。

gazelle

Bazelではプロジェクトのサブディレクトリごとにビルドファイルを配置して設定を記述していく必要があります。Cmakeにも言えることですが、ソースに変更があるたびにこの設定ファイルを手動で書き換えたりするのは結構手間だったりします gazelleは、そのbazelのビルドファイルであるBUILD.bazelを自動生成したり、外部依存関係の指定をWORKSPACEに追記するためのgolang向けのツールです。

gazelleの導入

rules_goのReleaseからWORKSPACEのコードを取得できるのでWORKSPACEに追記します。

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

http_archive(
    name = "io_bazel_rules_go",
    sha256 = "2b1641428dff9018f9e85c0384f03ec6c10660d935b750e3fa1492a281a53b0f",
    urls = [
        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.29.0/rules_go-v0.29.0.zip",
        "https://github.com/bazelbuild/rules_go/releases/download/v0.29.0/rules_go-v0.29.0.zip",
    ],
)
load("@io_bazel_rules_go//go:deps.bzl", "go_register_toolchains", "go_rules_dependencies")
go_rules_dependencies()

go_register_toolchains(version = "1.17.1")

http_archive(
    name = "bazel_gazelle",
    sha256 = "de69a09dc70417580aabf20a28619bb3ef60d038470c7cf8442fafcf627c21cb",
    urls = [
        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.24.0/bazel-gazelle-v0.24.0.tar.gz",
        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.24.0/bazel-gazelle-v0.24.0.tar.gz",
    ],
)
load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies", "go_repository")

## 以下gazelleにより自動生成されたgo_repository
go_repository(
    name = "co_honnef_go_tools",
    importpath = "honnef.co/go/tools",
    sum = "h1:UoveltGrhghAA7ePc+e+QYDHXrBps2PqFZiHkGR/xK8=",
    version = "v0.0.1-2020.1.4",
)

## bazel
load("//:repositories.bzl", "go_repositories")

# gazelle:repository_macro repositories.bzl%go_repositories
go_repositories()

gazelle_dependencies()

Golangではgo.modで管理しているためそのままBazelで依存関係を管理するとgo.modとWORKSPACEのgo_repositoryで2重管理になってしまいますが、gazelleのupdate-reposコマンドを使えば、go.mod からBazelの依存関係の定義へ変換することができます。

bazel run //:gazelle -- update-repos -from_file=go.mod

またgoからvenderingする場合には以下のように指定することで、ファイルの更新だけ行うことができます。ただしgo mod vendorコマンドはBUILD.bazelを削除しないため、依存しているライブラリでもBazelを使っている場合にはコンフリクトするかもしれないです。 例えば、grpc-gatewayなどはコンフリクトするのでgazelle updateする前にBULD .bazelをクリーンした方が良いです。

$ go mod vendor
$ bazel run //:gazelle -- update

なお、プライベートレポジトリ を読みこむ場合には、GOPRIVATEを以下を参考に自分の環境に合わせて設定してください。 export GOPRIVATE='github.com/mycompany/myrepo,*.example.com'

BUILD.Bazel

プロジェクトルートのBUILD.Bazel

gazelleの設定を追加します。

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

# gazelle:prefix github.com/example/example-project
gazelle(name = "gazelle")

サブフォルダ内のBUILD.Bazel

gazelleによって自動生成されます。

Makefile

スクランナーとしてmakefileを用意してみます

BAZEL := bazelisk
ROOTPKG := //...

.PHONY: gazelle build 

gazelle:  ## gazelle によって設定ファイルを自動生成する
    ${BAZEL} run //:gazelle -- update-repos -from_file ./go.mod
    ${BAZEL} run //:gazelle 

build:
    ${BAZEL} build ${ROOTPKG} 

test:
     ${BAZEL} test ${ROOTPKG} --test_output=errors 

最終的に

色々なビルドツールを振り返ってみましたが、APIサーバーでgolangで使う場合、マルチプラットフォーム対応が必要になることはないので、make+docker buildで十分のように思いました。例えばTinyGoなど組み込み向けでは、makeの代わりにninjaを使うことを検討できるかもしれません。

CmakeにしろBazelにしろ、コンパイル職人が必要になる程度にはキャッチアップコストがかかります。そのためにkubernetesからBazelが外されたりもしています。

それでもBazelに関してはリモートキャッシュがかなり強力なので、docker buildのパフォーマンスにどうしても不満が出てきたら、Bazelの導入を考えても良いかもしれませんし、またdockerのみならず、ビルドに時間が係りがちなAndroidIOSでも力を発揮しそうです。circleci2.0はJOBの並列実行ができるので、Bazelと組み合わせたらbitrise以上の速度向上も期待できると思います。

とはいえ、環境変数ファイルの取り扱いに対応していないなど、まだまだサポートしてる機能も潤沢ではないので今後より発展していってさらに使いやすくなると嬉しいです。リモートキャッシュについては今回取り上げられなかったので今後はBazelのリモートキャッシュでのCI環境の高速化などもまとめたいです。

次回は @moonumさんです!