結論
- make は Makefile をすべて読み込んで依存グラフを作ってから、レシピを実行する。
:=の右辺、ルールの前提条件、includeの作り直しは読み込みの段階で走る - make は失敗をほとんど報告しない。未定義の変数は空文字になり、
.PHONYに名前だけあるターゲットやファイルが既にあるターゲットは「やることなし」で exit 0 になる - レシピは既定で
/bin/shが実行し、pipefail が無い。シェルのメタ文字が無い行はシェルすら通らない - 前提条件の並び順は実行順ではない。順序は依存関係か
.WAITで書く - 外から来た値を
$(shell ...)に埋めると、値がコマンドとして実行される
以下のコードはすべて GNU Make 4.4.1 で実測した。
前提: 読み込みと実行の 2 段階
GNU make は 2 つの段階で動く。
- 読み込み: include 先を含む全 makefile を読み、変数とルールを取り込んで依存グラフを作る
- 実行: グラフから更新が要るターゲットを決め、レシピを実行する
展開には、読み込みの段階で行う「即時」と、値が使われるまで遅らせる「遅延」がある。
| 構文 | 即時に展開 | 遅延 |
|---|---|---|
VAR = value | 左辺 | 右辺 (参照されるたびに展開) |
VAR ?= value | 左辺 | 右辺 |
VAR := value | 左辺と右辺 | - |
target: prereq | ターゲットと前提条件 | - |
| レシピ | - | 全体 |
以下の罠の多くは、この表と違うタイミングを想定して書いたときに起きる。
変数と展開のタイミング
:= は読み込み時に 1 回、= は参照のたびに評価される
V_IMM := $(shell cat ver)
V_REC = $(shell cat ver)
bump: ; @echo 2 > ver
show: ; @echo "imm=$(V_IMM) rec=$(V_REC)"$ echo 1 > ver; make -s bump show
imm=1 rec=2
make bump build のように、同じ起動の中で前のターゲットがファイルを書き換えても、:= の変数は書き換え前の値のままになる。
症状は「中身は新しい version なのに、ファイル名は古い version」のようなずれとして出る。
- PITFALL:
=は参照のたびに$(shell ...)を実行するので、重いコマンドや副作用のあるコマンドには向かない
前提条件に書いた $(shell ...) は、どのターゲットを叩いても走る
ルールのターゲットと前提条件は読み込み時に展開される。= で定義した変数でも、前提条件に書けばその時点で評価される。
X = $(shell echo hit >&2; echo dep)
all: ; @echo all
build: $(X) ; @echo build
dep: ; @:$ make -s all
hit
all
all は X を使わないが、build の前提条件を確定させるために $(shell ...) が走る。include した .mk にこの形があると、make test のような無関係なターゲットまで遅くなる。
- PITFALL: 前提条件に使っている限り、
:=を=に変えても何も変わらない。重いコマンドはレシピの中に移す
export した = の変数は、レシピを実行するたびに展開される
export は値を子プロセスの環境へ入れる指示で、環境はレシピを実行する直前に組み立てられる。= の変数はそこで毎回展開される。
1 行目だけを差し替え、3 つのレシピを実行したときの $(shell ...) の実行回数を数えた。
export ver = $(shell echo hit >> count.log; echo 1.0) # 3 回
# ver = $(shell echo hit >> count.log; echo 1.0) # 0 回 (どこからも参照しない)
# export ver := $(shell echo hit >> count.log; echo 1.0) # 1 回
all: a b c
a: ; @true
b: ; @true
c: ; @true引数なしの export (全変数を子プロセスへ渡す) が 1 行でもあると、すべての = の変数がこの扱いになり、上の 2 行目も 3 回になる。
この Makefile で「使わないターゲットのために $(shell ...) が走る」のを直そうとして := を = に変えると、回数は逆に増える。
直し方は 2 つ。子プロセスへ渡す必要が無いなら export を外す。渡す必要があるなら := にして読み込み時の 1 回に固定する。
裸の export は export VAR1 VAR2 のように対象を列挙する形に変える。
- PITFALL:
includeした.mkがexportしていると、include した側の全レシピが影響を受ける。自分の Makefile に$(shell ...)が無くても遅くなるので、原因が見えにくい
include するファイルを作り直すと、make は最初から読み直す
make は全 makefile を読んだ後、include 先を含む各 makefile をターゲットとして更新しようとする。1 つでも実際に更新されると、状態を捨てて読み込みからやり直す。
設定ファイルから make 変数を生成して include する構成で、生成ルールに FORCE を付けると終わらなくなる。
include gen.mk
all: ; @echo "V=$(V)"
gen.mk: FORCE ; @echo gen >> gen.log; echo 'V := 1' > $@
FORCE:$ timeout 3 make -s all; echo "rc=$?"; wc -l < gen.log
rc=124
1146
生成レシピが毎回ファイルを書き換えるので、「更新 => 読み直し => FORCE で再度更新」が繰り返される。
内容が同じならファイルを書き換えない (mtime を変えない) 形にすると止まる。
gen.mk: FORCE ; @echo gen >> gen.log; echo 'V := 1' > $@.tmp; cmp -s $@.tmp $@ || mv $@.tmp $@それでも生成は、生成物が無いときに 2 回 (生成 => 読み直し => 同じ内容を確認)、最新のときも毎回 1 回走る。
FORCE をやめて実際の入力を前提条件にすれば、入力が変わらない限り走らない。
gen.mk: $(wildcard conf/*.tf) gen.sh ; ./gen.sh > $@- PITFALL: 前提条件の列挙を書き漏らすと、古い値のまま動く。
.PHONY: gen.mkで済ませることもできない。make は無限ループを避けるために phony の makefile を作り直さないので、ファイルが無ければgen.mk: No such file or directoryで止まる
変数の値の # は、クォートの中でもコメントになる
VAR := "^## v1.*"
c: ; @grep -E $(VAR) CHANGELOG.md$ make -s c
/bin/sh: -c: line 1: unexpected EOF while looking for matching `"'
make: *** [Makefile:2: c] Error 2
make は代入の右辺でも # をコメントの開始として扱い、クォートを見ない。値は "^ で切れる。\# でリテラルにする。
VAR := "^\#\# v1.*"
外から来た値
$(shell ...) に埋めた値はコマンドとして実行される
$(shell cmd $(v)) は、展開後の文字列をそのまま /bin/sh に渡す。make の展開は文字列の置換で、シェルの引数の境界を知らない。
$ printf 'x:\n\t@echo $(shell echo $(v))\n' > t.mk
$ make -s -f t.mk v='hi; echo INJECTED'
hi INJECTED
'$(v)' と囲んでも、値に ' が入れば閉じられる。
:= の変数に埋めると、その変数を使わないターゲットでも発火する。make help でも実行される。
stage := $(shell echo resolved-$(branch))
help: ; @echo help$ make -s help 'branch=x;touch pwned'; ls pwned
help
pwned
git のブランチ名は ; や $(...) を含められる。git check-ref-format が禁じるのは空白や ~ ^ : ? * [ \ などで、develop;touch も a$(id) も有効なブランチ名になる。
CI から make x branch=$BRANCH のように渡す値は、信頼できる入力ではない。
make には値をシェル用に引用する組み込み関数が無い。対策は 2 つ。
- make の中で完結させる。
filter/patsubstなどの文字列関数はシェルを起動しない - 外から来た値の処理はスクリプトに寄せ、シェルの位置引数として渡す。スクリプト内で
"$1"として扱えば、値はコマンドにならない
pairs := develop:dev staging:stg
stage := $(patsubst $(branch):%,%,$(filter $(branch):%,$(pairs)))
show: ; @echo "stage=[$(stage)]"$ make -s show branch=develop
stage=[dev]
$ make -s show 'branch=x;touch pwned'; ls pwned
stage=[]
ls: cannot access 'pwned': No such file or directory
filter は部分一致ではなく、% が 1 つだけのパターン一致
$(filter %prd,$(stage)) の %prd は「prd で終わる語」の意味になる。
stage | $(filter %prd,$(stage)) |
|---|---|
prd | prd |
jp-prd | jp-prd |
dev | (空) |
prd-tokyo | (空) |
「本番系か」の判定をツールごとに書くと、記法ごとに意味がずれる。Terraform の endswith(var.env, "prd") はこれと同じ意味だが、strcontains (部分一致) を混ぜると prd-tokyo で結論が割れる。
今の環境名で全部の判定が一致していても、それは偶然にすぎない。将来の環境名で 1 つだけ外れる。
ゴールとターゲット: exit 0 で素通りする
2 つ目以降のゴールもターゲットとして解決される
make には「引数」の概念が無い。make test foo/bar の foo/bar は test への引数ではなく、2 つ目のゴールになる。
test: ; @echo "args=[$(filter-out $@,$(MAKECMDGOALS))]"$ make -s test foo/bar; echo "rc=$?"
args=[foo/bar]
make: *** No rule to make target 'foo/bar'. Stop.
rc=2
%: ; @: の catch-all を置けば foo/bar の解決に成功して素通りする。ただし常に有効にすると、打ち間違い (make tset) まで exit 0 で「成功」する。
先頭のゴールが対象のときだけ定義する。
arg_passthrough_targets := test
test: ; @echo "args=[$(filter-out $@,$(MAKECMDGOALS))]"
ifneq ($(filter $(arg_passthrough_targets),$(firstword $(MAKECMDGOALS))),)
%: ; @:
endif$ make -s test foo/bar; echo "rc=$?"
args=[foo/bar]
rc=0
$ make -s tset; echo "rc=$?"
make: *** No rule to make target 'tset'. Stop.
rc=2
- PITFALL:
$(filter pattern,text)は第 1 引数がパターン。$(filter $(firstword $(MAKECMDGOALS)),$(arg_passthrough_targets))と逆に書くと、コマンドラインのゴールがパターンとして解釈される。make 't%'がtestに一致して catch-all が有効になり、何も出さずに exit 0 で終わる
$(MAKECMDGOALS) はコマンドラインのゴールで、今のターゲットではない
client-test: back-test front-test
back-test front-test: ; @echo "$@: MAKECMDGOALS=[$(MAKECMDGOALS)]"$ make -s client-test
back-test: MAKECMDGOALS=[client-test]
front-test: MAKECMDGOALS=[client-test]
$(subst -test,,$(MAKECMDGOALS)) のようにゴール名から処理を分ける書き方は、前提条件として呼ばれると client を返す。今のターゲット名は $@ で取る。
.PHONY に名前だけ残ったターゲットは exit 0 で終わる
$ printf '.PHONY: build install\nbuild:\n\t@echo build\n' > Makefile
$ make install; echo "rc=$?"
make: Nothing to be done for 'install'.
rc=0
$ make nosuch; echo "rc=$?"
make: *** No rule to make target 'nosuch'. Stop.
rc=2
.PHONY の前提条件に書いた時点で、make はその名前をターゲットとして扱う。レシピも前提条件も無いターゲットは作るものが無いので、成功扱いになる。
ルールを消して .PHONY の行に名前を残すと、それを呼ぶスクリプトや CI が失敗せずに素通りする。
- PITFALL: ターゲットを消すときは、
.PHONYの行と、それを呼ぶスクリプト・テスト・ドキュメントを一緒に grep する
前提条件の無いファイルターゲットは、ファイルがあればレシピを飛ばす
$ printf 'app:\n\tcp main.go app\n' > Makefile
$ echo v1 > main.go; make
cp main.go app
$ echo v2 > main.go; make; cat app
make: 'app' is up to date.
v1
make はターゲットと前提条件の mtime を比べる。前提条件が無ければ「存在すれば最新」とみなす。
ソースを前提条件に列挙するか、毎回作るなら .PHONY にする。
print-% は未定義の変数にも空文字と exit 0 を返す
print-%: ; @echo '$($*)'make の変数展開には「未定義」という失敗が無い。make -s print-<var> で値を取り出して CI の次の step へ渡す構成では、変数名の打ち間違いや改名漏れが空のまま流れる。
空のプロジェクト ID は認証で落ちるので気付けるが、空のタグやイメージパスは push まで到達しうる。
$(origin) で未定義を判定し、ルール側で落とす。
foo := bar
print-%:
@$(if $(filter undefined,$(origin $*)),$(error 未定義の変数: $*))echo '$($*)'
$ make -s print-foo; echo "rc=$?"
bar
rc=0
$ make -s print-typo; echo "rc=$?"
Makefile:3: *** 未定義の変数: typo. Stop.
rc=2
「定義済みだが空」は従来どおり空を返すので、空に意味を持たせている変数はそのまま使える。
make -n の出力には、進捗表示の echo の行も出る
lint:
@echo "==> Running biome lint"
@npx --yes @biomejs/biome lint
$ make -n lint
echo "==> Running biome lint"
npx --yes @biomejs/biome lint
make -n はレシピを実行せずに表示するので、表示のための echo も同じように出る。
Makefile の配線を「make -n の出力に biome lint が含まれるか」でテストすると、実行行を消しても echo の行に一致して通り続ける。
期待値は echo に現れない実行行全体にする。
- PITFALL: テストの検知力は、実行行を
@trueに置き換えてテストが落ちるかで確かめる
定番の help ワンライナーは、数字を含むターゲットを落とす
build: ## ビルド
e2e-test: ## e2e テスト
help: ## このヘルプ
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "%-10s %s\n", $$1, $$2}'
$ make -s help
build ビルド
help このヘルプ
文字クラス [a-zA-Z_-] に数字が無いので、e2e-test が黙って消える。コピーで広まった書き方なので気付きにくい。[a-zA-Z0-9_-] にする。
レシピの実行
既定のシェルは /bin/sh で、pipefail が無い
レシピは既定で /bin/sh (Debian 系なら dash) が実行する。パイプラインの終了状態は、最後のコマンドのものになる。
$ printf 'u: ; false | cat\n' > Makefile
$ make -s u; echo "rc=$?"
rc=0
go test ./... | grep -v ... は、grep -v が 1 行でも出力すれば 0 を返すので、テストが落ちても make は成功する。
Makefile の先頭で bash と pipefail を指定する。
SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c
$ make -s u; echo "rc=$?"
make: *** [Makefile:3: u] Error 1
rc=2
フィルタ側の非 0 を許したいなら、| { grep -v ... || true; } で個別に握り潰す。
cond && cmd || true のように || true で条件の偽を吸収する書き方は、cmd の失敗まで吸収する。
仕組みは bash の set -e が効かない場所と効きすぎる場所
の A && B || D と同じで、if / elif に分けて書く。
- PITFALL: この 2 行が既に入っている Makefile に「パイプが失敗を握り潰す」と指摘するのは誤り。最小再現を作るときは、対象の Makefile の先頭 (
SHELL/.SHELLFLAGS/.ONESHELL) もそのまま持ち込まないと結論が反転する
メタ文字の無いレシピ行は、シェルを通さずに exec される
GNU make はレシピの行に && | ; > * $ などのシェルのメタ文字が無いと、シェルを起動せずにコマンドを直接 exec する。
echo や test のようにシェル組み込みでもある名前も、この経路では PATH 上の外部コマンドとして探される。
$ printf 't: ; @echo hello\nu: ; @echo hello && true\n' > Makefile
$ ls tiny/ # make / sh / bash だけを置いたディレクトリ
bash make sh
$ env -i PATH=$PWD/tiny make t
make: echo: No such file or directory
make: *** [Makefile:1: t] Error 127
$ env -i PATH=$PWD/tiny make u
hello
u は && があるのでシェル経由になり、組み込みの echo が使われる。
テストやコンテナで PATH を絞ると、対話シェルでは通る Makefile がそこでだけ落ちる。最小の PATH を作るなら、レシピが exec する実体も入れる。
- PITFALL: bash の
command -v echoは、組み込みがあるとパスではなく名前echoを返す。実体のパスが要るならtype -P echoを使う
ディレクトリと再帰 make
$(PWD) は起動元のまま。-C に追随するのは $(CURDIR)
mkfile_dir := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST)))))
show: ; @echo "PWD=$(PWD)"; echo "CURDIR=$(CURDIR)"; echo "mkfile_dir=$(mkfile_dir)"$ cd /tmp && make -s -C /tmp/demo/p show
PWD=/tmp
CURDIR=/tmp/demo/p
mkfile_dir=/tmp/demo/p
$ cd /tmp/demo/p/sub && make -s -f ../Makefile show
PWD=/tmp/demo/p/sub
CURDIR=/tmp/demo/p/sub
mkfile_dir=/tmp/demo/p
PWD はシェルが export した環境変数を make がそのまま見ているだけで、make は更新しない。
--config $(PWD)/.tflint.hcl のようなパスは、リポジトリのルートで叩く限り動き、make -C を使う CI やサブディレクトリからの実行で初めて壊れる。
CURDIR も -C の移動先であって、Makefile の置き場所ではない。include される .mk が自分の位置を知りたいなら、$(lastword $(MAKEFILE_LIST)) から引く。
サブ make では := が親の export を上書きし、?= は親の値を残す
# Makefile
export V := parent
export W := parentw
all: ; @$(MAKE) --no-print-directory -f sub.mk# sub.mk
V := child
W ?= childw
all: ; @echo "V=$(V) W=$(W)"$ make -s
V=child W=parentw
$ make -s -f sub.mk
V=child W=childw
?= は「未定義のときだけ代入」で、環境変数から来た定義も定義済みとみなす。
親が共通のパス変数を持ち、サブディレクトリの Makefile も単体で動かす必要があるなら、サブ側は ?= で書く。
-w は -s では消えず、サブ make の出力に混ざる
-w (Entering directory / Leaving directory の表示) は -s とは別の出力で、MAKEFLAGS 経由でサブ make へ伝わる。
-C やサブ make で自動的に有効になる -w は -s で抑止されるが、明示した -w は -s を付けても残る。
# tools.mk
yq_version := v4.53.6
print-%: ; @echo $($*)# Makefile
get: ; @echo "VER=[$$($(MAKE) -s -f tools.mk print-yq_version)]"$ make -s get
VER=[v4.53.6]
$ make -w -s get
make: Entering directory '/tmp/demo/q'
VER=[make[1]: Entering directory '/tmp/demo/q'
v4.53.6
make[1]: Leaving directory '/tmp/demo/q']
make: Leaving directory '/tmp/demo/q'
VER="$(make -s print-version)" の形で値を取り出す構成は、親が -w 付き (環境変数 MAKEFLAGS=w を含む) で起動されたときだけ壊れる。
値を取り出す側に --no-print-directory を付ける。
- PITFALL: 手元では再現しないので、「CI でだけ版の比較が必ず外れる」形で出る。比較した値をログに出しておかないと、原因に辿り着けない
並列実行
前提条件の並び順は実行順ではない
all-bad: dl up
dl: ; @sleep 0.3; echo DL_DONE
up: ; @echo UPLOAD$ make -s all-bad
DL_DONE
UPLOAD
$ make -s -j2 all-bad
UPLOAD
DL_DONE
make が保証するのは「前提条件がすべて終わってからレシピを実行する」ことだけで、前提条件どうしの順序は依存グラフで決まる。
直列では左から順に見えるので、テストやふだんの実行では気付けない。-j は環境変数 MAKEFLAGS で外から入ることもある。
順序が要るなら、次のどれかで明示する。
# 1. 依存関係として書く。ただし make up を単体で叩いても dl が走る
up: dl
# 2. 順序が要る側をレシピから呼ぶ
all-good: dl
@$(MAKE) --no-print-directory up
# 3. .WAIT で区切る (GNU Make 4.4 以降)。左が終わってから右を始める
all-wait: dl .WAIT up2 と 3 は、-j2 でも DL_DONE => UPLOAD の順になった。
.NOTPARALLEL: target で、そのターゲットの前提条件だけを直列にできる (4.4 以降)
.NOTPARALLEL: all
all: a b
other: a b
a: ; @echo a-start; sleep 0.3; echo a-end
b: ; @echo b-start; sleep 0.3; echo b-end$ make -s -j2 all
a-start
a-end
b-start
b-end
$ make -s -j2 other
b-start
a-start
a-end
b-end
.NOTPARALLEL に前提条件としてターゲットを書くと、そのターゲットの前提条件だけが直列になる (各前提条件の間に .WAIT を挟んだのと同じ)。前提条件の無い .NOTPARALLEL: は Makefile 全体を直列にする。
同じ外部リソース (共有 DB やテスト用アカウント) を触る e2e テストを、単体で叩ける形のまま直列にできる。
前提条件付きの .NOTPARALLEL と .WAIT は GNU Make 4.4 で入った。それより前の版では、前提条件を書いても Makefile 全体の並列が止まるだけで、エラーにはならない。
make --version | head -1 # GNU Make 4.4 以上か- PITFALL: CI と開発機で make の版が違うと、片方だけ「並列が効かない」形で静かに遅くなる
まとめ
| 場面 | 挙動 | 対策 |
|---|---|---|
V := $(shell ...) | 読み込み時に 1 回だけ評価。実行中の書き換えを拾わない | 最新の値が要るならレシピで読む |
前提条件の $(shell ...) | どのターゲットでも走る | レシピの中へ移す |
export した = の変数 | レシピごとに展開される | export を外すか := にする |
include 先の FORCE 生成 | 毎回書き換えると無限に読み直す | 内容が同じなら書かない。実入力を前提条件にする |
変数の値の # | クォート内でもコメント | \# |
$(shell cmd $(v)) | 値がコマンドとして実行される | 文字列関数かスクリプトの位置引数で扱う |
$(filter %prd,...) | 末尾一致 | 判定の記法を揃える |
make test foo | foo もゴールになる | catch-all を対象ゴールに限定する |
$(MAKECMDGOALS) | コマンドラインのゴール | 今のターゲットは $@ |
.PHONY だけにある名前 | exit 0 | .PHONY と呼び出し元も一緒に消す |
| 前提条件の無いファイルターゲット | ファイルがあると飛ばす | ソースを前提条件に書く |
print-% の未定義変数 | 空文字で exit 0 | $(origin) で $(error) |
make -n の文字列一致テスト | echo の行にも一致する | 実行行全体で照合する |
help ワンライナーの [a-zA-Z_-] | 数字入りのターゲットが消える | [a-zA-Z0-9_-] |
| レシピのパイプ | 既定の /bin/sh に pipefail が無い | SHELL と .SHELLFLAGS を指定する |
| メタ文字の無いレシピ行 | 組み込みでなく PATH の実体を exec | 絞った PATH に実体を入れる |
$(PWD) | 起動元のディレクトリ | $(CURDIR) か $(MAKEFILE_LIST) |
サブ make の := | 親の export を上書き | ?= |
$(make -s print-x) | 親の -w で出力が混ざる | --no-print-directory |
| 前提条件の並び | -j で順序が崩れる | 依存関係 / 再帰呼び出し / .WAIT |
.NOTPARALLEL: target | 4.4 未満では全体が直列 | make --version を確認する |
