マルチプラットフォーム向けのMakefileは闇
前回、 Go の学習を始めた際のまとめを記事として書きましたが、Makefileの作成は後回しにしていました。
Go のプロジェクトでは殆どの場合、 Makefile によりビルドを記述しています。Makefileは GNU Make 用のルールを記述したファイルで、 マルチプラットフォーム で利用することができます。
Makefile 完成版
今回、 Go 用のマルチプラットフォーム (Windows-cmd.exe, Windows-PowerShell, Windows-Git-Bash, Linux(ChromeOS-Crostini(Debian), WSL(Ubuntu), 他)) で動作する Makefile を作ってみました (GNU Make は Chocolatey の非 MinGW 版 4.3 を使用しました)。
Docker の scratch
イメージで動作するようにスタティック・リンクしています。また、簡易的にクロスビルドできるターゲット (xbuild
) も用意しています。
幸いにも「闇」というタイトルの割にはシンプルな内容となりました。
DEVNUL := /dev/null
DIRSEP := /
SEP := :
RM_F := rm -f
RM_RF := rm -rf
CP := cp
CP_FLAGS :=
CP_R := cp -RT
CP_R_FLAGS :=
FINDFILE := find . -type f -name
WHICH := which
PRINTENV := printenv
GOOS := linux
GOARCH := amd64
GOARM :=
GOXOS := darwin windows linux
GOXARCH := 386 amd64 arm
GOXARM := 7
GOCMD := go
GOBUILD := $(GOCMD) build
GOTIDY := $(GOCMD) mod tidy
GOCLEAN := $(GOCMD) clean
GOTEST := $(GOCMD) test
GOVET := $(GOCMD) vet
GOLINT := $(GOPATH)/bin/golint -set_exit_status
SRCS :=
TARGET_CLI := ./cmd/myapp
BIN_CLI := app
ifeq ($(OS),Windows_NT)
BIN_CLI := $(BIN_CLI).exe
ifeq ($(MSYSTEM),)
SHELL := cmd.exe
DEVNUL := NUL
DIRSEP := \\
SEP := ;
RM_F := del /Q
RM_RF := rmdir /S /Q
CP := copy
CP_FLAGS := /Y
CP_R := xcopy
CP_R_FLAGS := /E /I /Y
FINDFILE := cmd.exe /C 'where /r . '
WHICH := where
PRINTENV := set
endif
endif
define normalize_dirsep
$(subst /,$(DIRSEP),$1)
endef
define find_file
$(subst $(subst \,/,$(CURDIR)),.,$(subst \,/,$(shell $(FINDFILE) $1)))
endef
# Usage of cp -R and cp
# $(CP_R) $(call normalize_dirsep,path/to/src) $(call normalize_dirsep,path/to/dest) $(CP_R_FLAGS)
# $(CP) $(call normalize_dirsep,path/to/src) $(call normalize_dirsep,path/to/dest) $(CP_FLAGS)
SRCS := $(call find_file,'*.go')
VERSION := $(shell git describe --tags --abbrev=0 2> $(DEVNUL) || echo "0.0.0-alpha.1")
REVISION := $(shell git rev-parse --short HEAD)
LDFLAGS := -ldflags="-s -w -buildid= -X \"main.Version=$(VERSION)\" -X \"main.Revision=$(REVISION)\" -extldflags \"-static\""
.PHONY: printenv clean tidy test lint
all: clean test build
printenv:
@echo SHELL : $(SHELL)
@echo CURDIR : $(CURDIR)
@echo DEVNUL : $(DEVNUL)
@echo SEP : "$(SEP)"
@echo WHICH GO : $(shell $(WHICH) $(GOCMD))
@echo GOOS : $(GOOS)
@echo GOARCH : $(GOARCH)
@echo GOARM : $(GOARM)
@echo VERSION : $(VERSION)
@echo REVISION : $(REVISION)
@echo SRCS : $(SRCS)
@echo LDFLAGS : $(LDFLAGS)
@echo TARGET_CLI : $(TARGET_CLI)
@echo BIN_CLI : $(BIN_CLI)
clean:
$(GOCLEAN)
-$(RM_F) $(BIN_CLI)
tidy:
$(GOTIDY)
test:
$(GOTEST) ./...
lint:
@echo "Run go vet..."
$(GOVET) ./...
@echo "Run golint..."
$(GOLINT) ./...
$(BIN_CLI): export CGO_ENABLED:=0
$(BIN_CLI): $(SRCS)
$(GOBUILD) \
-a -tags osusergo,netgo -installsuffix netgo \
-trimpath \
$(LDFLAGS) \
-o $(BIN_CLI) $(TARGET_CLI)
$(BIN_CLI)_quick: $(SRCS)
$(GOBUILD) -o $(BIN_CLI) $(TARGET_CLI)
build: $(BIN_CLI) ;
quickbuild: $(BIN_CLI)_quick ;
xbuild: export GOOS:=$(GOOS)
xbuild: export GOARCH:=$(GOARCH)
xbuild: export GOARM:=$(GOARM)
xbuild: build ;
docker:
docker build -t example:$(VERSION) .
docker-test:
docker build -t example:rev-$(REVISION) .
追記: MinGW の場合において、環境により
MSYSTEM_CHOST
が存在しないケースがあったため、MSYSTEM
を使うように変更しました。CP
・CP_R
を追加しました。
それでは早速、 Make の闇を覗いてみましょう。
闇 1. Windows
シェル
GNU Make はマルチプラットフォームでルールの実行機能を提供しますが、ルールの中で実行されるコマンドの解釈はプラットフォームのシェルに任されています。Make はデフォルトで sh
を使用します。Windowsでは PATH
に sh.exe
が存在すればこれを使用し、無ければ cmd.exe
を使用します。
Go を使う開発者であれば恐らくほぼ全員 Git をインストールしているでしょうから、 Git for Windows のインストールパス (C:\Program Files\Git\bin
) にある sh.exe
が参照されます。 Git for Windows は MinGW / MSYS2
の *nix ライクな環境 (コマンド群・デバイス等) を提供しますが、 Make が cmd.exe
または PowerShell から起動されているときは、 Make のシェルに sh
が選ばれていてもコマンド群・デバイスにアクセスできず中途半端な状態となります。一方で Git bash から起動した場合は、 *nix 上のように sh
が動作します。
*nix ライクな環境で動作できない場合は、強制的に Make のシェルを cmd.exe
とすることで安定的に cmd.exe
と PowerShell の両方をサポートします。
コマンドは cmd.exe
が解釈できる程度に単純でなければなりません。また、シェルに変数等を展開させると違いが出るので Make で展開します。ダブルクォート等のエスケープ、ワイルドカードの扱いも異なるので要注意です。
DEVNUL := /dev/null
SEP := :
...
ifeq ($(OS),Windows_NT)
BIN_CLI := $(BIN_CLI).exe
ifeq ($(MSYSTEM),)
# cmd.exe または PowerShell から呼ばれたとき
SHELL := cmd.exe # Make のシェルを強制的に cmd.exe に変更
DEVNUL := NUL # cmd.exe では null デバイスの名前も違う
SEP := ; # 複数パスの区切り文字も違う
...
endif
endif
【参考資料】
コマンド
Make が cmd.exe
または PowerShell から起動されている場合、利用できるのは Windows 標準のコマンド群です。 cmd.exe
と PowerShell の両方を考慮するとすれば、 PowerShell のコマンドレットは使用できません。
すべての環境で利用できるようにコマンドを変数として定義したり(例: RM_RF
)、マクロを定義したり(例: find_file
)します。 Make の組み込み関数は貧弱なので大変です。
RM_F := rm -f
RM_RF := rm -rf
FINDFILE := find . -type f -name
WHICH := which
PRINTENV := printenv
...
ifeq ($(OS),Windows_NT)
BIN_CLI := $(BIN_CLI).exe
ifeq ($(MSYSTEM),)
# cmd.exe または PowerShell から呼ばれたとき
...
RM_F := del /Q
RM_RF := rmdir /S /Q
CP := copy
CP_FLAGS := /Y
CP_R := xcopy
CP_R_FLAGS := /E /I /Y
FINDFILE := cmd.exe /C 'where /r . '
WHICH := where
PRINTENV := set
endif
endif
# マクロ
define normalize_dirsep
# Windows の copy, xcopy はパスの途中の `/` をオプションと認識してエラーとなるため
# セパレーターを `\` に変換します
$(subst /,$(DIRSEP),$1)
endef
# マクロ
define find_file
# 今回のパターンでは必須ではありませんが Linux 側出力に合わせて
# 常に 相対パス、セパレーター= `/` となるようにしています
$(subst $(subst \,/,$(CURDIR)),.,$(subst \,/,$(shell $(FINDFILE) $1)))
endef
SRCS := $(call find_file,'*.go') # 複数のワイルドカードを検索するにはもうひと工夫必要
VERSION := $(shell git describe --tags --abbrev=0 2> $(DEVNUL) || echo "0.0.0-alpha.1")
...
clean:
$(GOCLEAN)
-$(RM_F) $(BIN_CLI)
MinGW / MSYS
MinGW では 非 MinGW プログラムに引数を渡す際にファイルパスと思われる値を自動的に *nix スタイル から Windows スタイルに変換します。しかし、パスでないものも誤判定で変換されることがあるので、コマンドに渡す際にどのようにテキストが変化するか気をつける必要があります。
Make 自体も MinGW 版 / 非 MinGW 版があり、上記変換のタイミングが異なるため同じ動作とならない可能性があります。
このあたりの問題があるためかは分かりませんが、 MinGW 版の古いバージョン (3.81
:2006年) を使っている方が多く見受けられます。 (4.x
系でいくつかの大きな機能追加があります)
【参考資料】
闇 2. (特になし)
つまり、 Make の闇とは色々と種類がありすぎる Windows 環境のせいなので、ネイティブのWindowsでの実行は Git Bash のみ等、利用方法を絞り込めば楽になります。
- Windows の環境 (
>= 6パターン
)
呼び出し元シェル | MinGW版Make | 非MinGW版Make |
---|---|---|
Git Bash | 🤔 | ☑ |
cmd.exe | 🤔 | 🤔 |
PowerShell | 🤔 | 🤔 |
Appendix
その他、今回の Makefile 作成時に得た、その他の参考情報も記しておきたいと思います。
1. Makeのターゲット内での環境変数エクスポート
cmd.exe
が使われる可能性があるため、 環境変数=値 コマンド
という形で処理を記述することができません。 Make の機能を使ってエクスポートします。
$(BIN_CLI): export CGO_ENABLED:=0 # ターゲットに対して環境変数をエクスポート
$(BIN_CLI): $(SRCS)
$(GOBUILD) \
-a -tags osusergo,netgo -installsuffix netgo \
-trimpath \
$(LDFLAGS) \
-o $(BIN_CLI) $(TARGET_CLI)
build: $(BIN_CLI) ;
xbuild: export GOOS:=$(GOOS) # 複数の設定も可能
xbuild: export GOARCH:=$(GOARCH)
xbuild: export GOARM:=$(GOARM)
xbuild: build ; # 依存関係にもエクスポートは引き継がれる
2. GitHub Actions
CI として GitHub Actions の Matrix build に Windows も含める場合、ジョブのシェルを Bash として、 run
に直接コマンドを書くか、シェルスクリプトでビルドしたほうが簡単です (シェルを Bash とすると、 Git Bash が使われます)。二重メンテは面倒ですが・・・。
リリースについては、 GoReleaser を使ったほうが良さそうです。
- GitHub Actions Virtual Environments
- GitHub Actionsのワークフロー構文
- Go で書いた CLI ツールのリリースは GoReleaser と GitHub Actions で個人的には決まり
- GoReleaser
3. スタティック・リンク+その他ビルドオプション
Go のバイナリは基本的にはシングルバイナリと言われていますが、実はごく一部のライブラリはダイナミックリンクされるようになっています。すべてスタティック・リンクできれば、 Docker の scratch
イメージに 1 ファイル加えるだけで動かせる (イメージサイズを小さくできます)、libcの異なる環境への可搬性がある、等のメリットがあります。
LDFLAGS := -ldflags="-s -w -buildid= -X \"main.Version=$(VERSION)\" -X \"main.Revision=$(REVISION)\" -extldflags \"-static\""
...
$(BIN_CLI): export CGO_ENABLED:=0
$(BIN_CLI): $(SRCS)
$(GOBUILD) \
-a -tags osusergo,netgo -installsuffix netgo \
-trimpath \
$(LDFLAGS) \
-o $(BIN_CLI) $(TARGET_CLI)
-
-a
オプションはライブラリの強制リビルドを行うため非常に遅いです。 -
CGO_ENABLED=0
・-tags osusergo,netgo
・-extldflags \"-static\""
を設定することで完全にスタティック・リンクさせています。 -
-buildid=
(値はなし) を指定することで、同一内容のビルドが同一バイナリになることを保証します。 -
-trimpath
を指定することで、デバッグ情報からビルド時ソースの絶対パス情報を削除します。- (
-gcflags
に指定する方法は古いです)
- (
- static にリンクされたかどうかは以下で確認できます。
> file ./app ./app: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped
- テストに使用した
Dockerfile
は以下の通りです。DockerfileFROM scratch COPY ./app /app CMD ["/app"]
Discussion