📚

Go で使う Makefile の育て方

2021/05/05に公開

Go を使ってプロダクトを作る時、Makefile を使ってビルドを指定することが多いです。

理由としては、

  1. バージョン情報などを埋め込むのに都合がいい
  2. 複数のバイナリを吐き出す時に都合がいい
  3. Go のビルドオプションを指定するのにいろいろあって整理しておきたい
  4. 事前にコードジェネレータで書き出す部分があり、それを考えると Makefile などで整理したい

などなどです。なので今回はプロジェクトが大きくなっていく中でどういう Makefile の書き方をしているか、というのをご紹介しようと思います。

サンプルとして、今回のプロジェクトでは gRPC を使ったチャットサービスのサーバーとクライアントを作ることにします。リポジトリは https://github.com/rosylilly/gochat に置いておきました。

Step 1. バージョン情報を埋める

今回はサーバーとクライアントで2つのバイナリを吐くので、リポジトリの構造はこんな感じにします。

- ./          # リポジトリルート。 package gochat
- bin/        # バイナリを吐く場所。gitignore する。
- cmd/        # バイナリのパッケージ
  - server/   # サーバーバイナリ。 package main
    - main.go # エントリーポイント
  - client/   # クライアントバイナリ。 package main
    - main.go # エントリーポイント
- proto/      # .proto ファイル置き場
- .gitignore  # gitignore ファイル
- go.mod      # go.mod
- go.sum      # go.sum
- Makefile    # 肝心要の奴
- gochat.go   # バージョン情報なんかを埋める対象

ちなみに gochat.go の中身はこんな感じ

gochat.go
package gochat

var (
	VERSION  = "0.0.0"
	REVISION = ""
)

バージョン情報をどのファイルに書くかという議論

gochat.goVERSION があるので、直にそこに書いてしまえばいいのでは?という気もするのですが、一応 VERSION というファイルをリポジトリルートに作って別管理する方法を今回は採用します。

単にその方が管理が簡単だからとかの理由です。

最初の Makefile

build でバイナリを吐き出すようにしたいので、 make build で目的の serverclient のバイナリが bin 以下に出力されるようにします。

パッケージ名が変わっても使い回せるように、 Go の機能を活用して対象とするファイルを絞り込むのを忘れないように。

Makefile
# 出力先のディレクトリ
BINDIR:=bin

# ルートパッケージ名の取得
ROOT_PACKAGE:=$(shell go list .)
# コマンドとして書き出されるパッケージ名の取得
COMMAND_PACKAGES:=$(shell go list ./cmd/...)

# 出力先バイナリファイル名(bin/server など)
BINARIES:=$(COMMAND_PACKAGES:$(ROOT_PACKAGE)/cmd/%=$(BINDIR)/%)

# ビルド時にチェックする .go ファイル
GO_FILES:=$(shell find . -type f -name '*.go' -print)

# ビルドタスク
.PHONY: build
build: $(BINARIES)

# 実ビルドタスク
$(BINARIES): $(GO_FILES)
	@go build -o $@ $(@:$(BINDIR)/%=$(ROOT_PACKAGE)/cmd/%)

ここに VERSIONgit のリビジョンを埋められるようにします。

Makefile
# バージョン
VERSION:=$(shell cat VERSION)
# リビジョン
REVISION:=$(shell git rev-parse --short HEAD)

バージョンやリビジョンが変わったら再度ビルドされて欲しいので、そこらも依存関係に追加しておきます。

Makefile
$(BINARIES): $(GO_FILES) VERSION .git/HEAD
	@go build -o $@ $(@:$(BINDIR)/%=$(ROOT_PACKAGE)/cmd/%)

肝心の ldflags を埋める箇所を書きます。

Makefile
# ldflag
GO_LDFLAGS_VERSION:=-X '${ROOT_PACKAGE}.VERSION=${VERSION}' -X '${ROOT_PACKAGE}.REVISION=${REVISION}'
GO_LDFLAGS:=$(GO_LDFLAGS_VERSION)

# go build
GO_BUILD:=-ldflags "$(GO_LDFLAGS)"

$(BINARIES): $(GO_FILES) VERSION .git/HEAD
	@go build -o $@ $(GO_BUILD) $(@:$(BINDIR)/%=$(ROOT_PACKAGE)/cmd/%)

これで何らかコミットしてから(git rev-parse する関係上、最低でも1コミット必要)ビルドすれば、ビルド内にバージョン情報とリビジョンが入ったものが出ます。

$ make build
$ ./bin/server
v0.0.1-15cc88f

Step 2. -race や -installsuffix などオプションを環境変数で指定する

デバッグビルドやリリースビルドでオプションを切り替えたい時があるので、それらのオプションを指定するようにします。今回は RELEASE の環境変数がオンならリリースビルド、そうでなければデバッグビルドということにして、 make build RELEASE=1 のようにしてビルドを切り替えます。

Makefile
# version ldflag
GO_LDFLAGS_VERSION:=-X '${ROOT_PACKAGE}.VERSION=${VERSION}' -X '${ROOT_PACKAGE}.REVISION=${REVISION}'
# symbol table and dwarf
GO_LDFLAGS_SYMBOL:=
ifdef RELEASE
	GO_LDFLAGS_SYMBOL:=-w -s
endif
# static ldflag
GO_LDFLAGS_STATIC:=
ifdef RELEASE
	GO_LDFLAGS_STATIC:=-extldflags '-static'
endif
# build ldflags
GO_LDFLAGS:=$(GO_LDFLAGS_VERSION) $(GO_LDFLAGS_SYMBOL) $(GO_LDFLAGS_STATIC)
# build tags
GO_BUILD_TAGS:=debug
ifdef RELEASE
	GO_BUILD_TAGS:=release
endif
# race detector
GO_BUILD_RACE:=-race
ifdef RELEASE
	GO_BUILD_RACE:=
endif
# static build flag
GO_BUILD_STATIC:=
ifdef RELEASE
	GO_BUILD_STATIC:=-a -installsuffix netgo
	GO_BUILD_TAGS:=$(GO_BUILD_TAGS),netgo
endif
# go build
GO_BUILD:=-tags=$(GO_BUILD_TAGS) $(GO_BUILD_RACE) $(GO_BUILD_STATIC) -ldflags "$(GO_LDFLAGS)"

ごそっと追加したけど1つ1つは特殊なことをしてないのでわからない部分はそんなに無いはず。使うかわからないけどビルドタグで releasedebug かというのをつけておくことで、 Go のソースコード側でも挙動制御できるようにしておく。あとは単純に make build RELEASE=1 をするしないの2択にできる。

Step 3. 外部ツールへの依存

Ruby だと bundle exec とかすれば便利になるのだが、Go のツールは通常 go install でグローバルにインストールされてしまう。リポジトリ毎に依存するバージョンがちょっと違う(sqlboiler などがいい例)場合、環境毎に少しずつ違う結果が出て毎度差分が出てしまってやっかい、などの場合もある。

今回は protobuf はグローバルなものを使うけど、protoc-gen-go はリポジトリローカルに管理する。ということでまずはリポジトリにこんな内容でファイルを作る。

tools/tools.go
// +build tools
package gochat

import (
  _ "google.golang.org/protobuf/cmd/protoc-gen-go"
)

これで google.golang.org/protobufgo.mod で管理されるようになるので、以降はそれを対象にすればいい。あとは protobuf するのと、 go buildbin 以下に protoc-gen-go を配置してやる部分を書く。

Makefile
# gRPC ファイル
PB_FILES:=$(shell find . -type f -name '*.proto' -print)
# proto から生成される .go ファイル
GOPB_FILES:=$(PB_FILES:%.proto=%.pb.go)

# 実ビルドタスク
$(BINARIES): $(GO_FILES) $(GOPB_FILES) VERSION .git/HEAD

# protoc のビルド
$(GOPB_FILES): $(PB_FILES) $(BINDIR)/protoc-gen-go
	@protoc \
		--plugin=protoc-gen-go=$(BINDIR)/protoc-gen-go \
		-I ./proto \
		--go_out=./proto \
		--go_opt=paths=source_relative \
		$(@:%.pb.go=%.proto)

$(BINDIR)/protoc-gen-go: go.sum
	@go build -o $@ google.golang.org/protobuf/cmd/protoc-gen-go

今回はこのサービスのことだけ考えるので、 .proto から書き出す .go は同ディレクトリに入れて、かつ ignore しておく。

.gitignore
/proto/*.go

都度確認しつつ書き上げる

make でキャッシュが効かない、などの場合や、とある環境ではビルドがうまくいかない……などの場合に備えて、逐次試しておくのは結構大事。特に以下のようなオプションを活用していくとよい。

  • -B : 対象ファイルがソースファイルより新しくてもタスクを強制的に実行する
  • -n : コマンドを実行せず、実行する予定だったコマンドを表示する

Step 4. clean を正しく実装しておく

make clean がなくとも git で管理されてないファイルを消せ、でもいいんんだけど、やはりあると便利なのには間違いないので作っておくと便利。

Makefile
# お掃除
.PHONY: clean
clean:
	@$(RM) $(GOPB_FILES) $(BINARIES) $(BINDIR)/protoc-gen-go

Step 5. docker build とか用に .git がない状況に備える

.git.dockerignore で転送してない、けど REVISION ってファイルで書き出しておくよ、みたいな時用にフォールバックを書いておく。

Makefile
# リビジョン
REVISION:=$(shell git rev-parse --short HEAD 2> /dev/null || cat REVISION)

完成形

『なるべく複雑なことはしない』『まっさらな環境でいきなり make build してもなんとかなる』を目指すとこんな感じの Makefile になる。

Makefile
# バージョン
VERSION:=$(shell cat VERSION)
# リビジョン
REVISION:=$(shell git rev-parse --short HEAD 2> /dev/null || cat REVISION)

# 出力先のディレクトリ
BINDIR:=bin

# ルートパッケージ名の取得
ROOT_PACKAGE:=$(shell go list .)
# コマンドとして書き出されるパッケージ名の取得
COMMAND_PACKAGES:=$(shell go list ./cmd/...)

# 出力先バイナリファイル名(bin/server など)
BINARIES:=$(COMMAND_PACKAGES:$(ROOT_PACKAGE)/cmd/%=$(BINDIR)/%)

# ビルド時にチェックする .go ファイル
GO_FILES:=$(shell find . -type f -name '*.go' -print)

# gRPC ファイル
PB_FILES:=$(shell find . -type f -name '*.proto' -print)
# proto から生成される .go ファイル
GOPB_FILES:=$(PB_FILES:%.proto=%.pb.go)

# version ldflag
GO_LDFLAGS_VERSION:=-X '${ROOT_PACKAGE}.VERSION=${VERSION}' -X '${ROOT_PACKAGE}.REVISION=${REVISION}'
# symbol table and dwarf
GO_LDFLAGS_SYMBOL:=
ifdef RELEASE
	GO_LDFLAGS_SYMBOL:=-w -s
endif
# static ldflag
GO_LDFLAGS_STATIC:=
ifdef RELEASE
	GO_LDFLAGS_STATIC:=-extldflags '-static'
endif
# build ldflags
GO_LDFLAGS:=$(GO_LDFLAGS_VERSION) $(GO_LDFLAGS_SYMBOL) $(GO_LDFLAGS_STATIC)
# build tags
GO_BUILD_TAGS:=debug
ifdef RELEASE
	GO_BUILD_TAGS:=release
endif
# race detector
GO_BUILD_RACE:=-race
ifdef RELEASE
	GO_BUILD_RACE:=
endif
# static build flag
GO_BUILD_STATIC:=
ifdef RELEASE
	GO_BUILD_STATIC:=-a -installsuffix netgo
	GO_BUILD_TAGS:=$(GO_BUILD_TAGS),netgo
endif
# go build
GO_BUILD:=-tags=$(GO_BUILD_TAGS) $(GO_BUILD_RACE) $(GO_BUILD_STATIC) -ldflags "$(GO_LDFLAGS)"

# ビルドタスク
.PHONY: build
build: $(BINARIES)

# お掃除
.PHONY: clean
clean:
	@$(RM) $(GOPB_FILES) $(BINARIES) $(BINDIR)/protoc-gen-go

# 実ビルドタスク
$(BINARIES): $(GO_FILES) $(GOPB_FILES) VERSION .git/HEAD
	@go build -o $@ $(GO_BUILD) $(@:$(BINDIR)/%=$(ROOT_PACKAGE)/cmd/%)

# protoc のビルド
$(GOPB_FILES): $(PB_FILES) $(BINDIR)/protoc-gen-go
	@protoc \
		--plugin=protoc-gen-go=$(BINDIR)/protoc-gen-go \
		-I ./proto \
		--go_out=./proto \
		--go_opt=paths=source_relative \
		$(@:%.pb.go=%.proto)

$(BINDIR)/protoc-gen-go: go.sum
	@go build -o $@ google.golang.org/protobuf/cmd/protoc-gen-go

これでも十分長いけど読める範囲。ここから更に make help をつけるだとかいろいろ改造する先はあるが、一旦この程度まで作って雛形みたいにしておくと、どのプロジェクトでも使い回せて便利。

という、Makefile を育てる話でした。ここからさらに go generate なども使い始めるともっと複雑になってくるが、そのあたりはプロジェクトごとでまたいろいろ変わってくるかと思うので、今回は省略。

Discussion