マルチプラットフォーム向けの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) も用意しています。
幸いにも「闇」というタイトルの割にはシンプルな内容となりました。

Makefile
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 を使うように変更しました。 CPCP_Rを追加しました。

それでは早速、 Make の闇を覗いてみましょう。

闇 1. Windows

シェル

GNU Make はマルチプラットフォームでルールの実行機能を提供しますが、ルールの中で実行されるコマンドの解釈はプラットフォームのシェルに任されています。Make はデフォルトで sh を使用します。Windowsでは PATHsh.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 で展開します。ダブルクォート等のエスケープ、ワイルドカードの扱いも異なるので要注意です。

Makefile
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 の組み込み関数は貧弱なので大変です。

Makefile
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 の機能を使ってエクスポートします。

Makefile
$(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 を使ったほうが良さそうです。

3. スタティック・リンク+その他ビルドオプション

Go のバイナリは基本的にはシングルバイナリと言われていますが、実はごく一部のライブラリはダイナミックリンクされるようになっています。すべてスタティック・リンクできれば、 Docker の scratch イメージに 1 ファイル加えるだけで動かせる (イメージサイズを小さくできます)、libcの異なる環境への可搬性がある、等のメリットがあります。

Makefile
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 は以下の通りです。
    Dockerfile
    FROM scratch
    
    COPY ./app /app
    CMD ["/app"]
    

【参考資料】