結論

  • make は Makefile をすべて読み込んで依存グラフを作ってから、レシピを実行する。:= の右辺、ルールの前提条件、include の作り直しは読み込みの段階で走る
  • make は失敗をほとんど報告しない。未定義の変数は空文字になり、.PHONY に名前だけあるターゲットやファイルが既にあるターゲットは「やることなし」で exit 0 になる
  • レシピは既定で /bin/sh が実行し、pipefail が無い。シェルのメタ文字が無い行はシェルすら通らない
  • 前提条件の並び順は実行順ではない。順序は依存関係か .WAIT で書く
  • 外から来た値を $(shell ...) に埋めると、値がコマンドとして実行される

以下のコードはすべて GNU Make 4.4.1 で実測した。

前提: 読み込みと実行の 2 段階

GNU make は 2 つの段階で動く。

  1. 読み込み: include 先を含む全 makefile を読み、変数とルールを取り込んで依存グラフを作る
  2. 実行: グラフから更新が要るターゲットを決め、レシピを実行する

展開には、読み込みの段階で行う「即時」と、値が使われるまで遅らせる「遅延」がある。

構文即時に展開遅延
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))
prdprd
jp-prdjp-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-%: ; @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 up

2 と 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 foofoo もゴールになる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: target4.4 未満では全体が直列make --version を確認する

参考