本文へ移動
購読
GUIDE#devtools更新 2026.10.03

Makefileの書き方:タスクランナーとして使う基本と.PHONY・変数の落とし穴

Makefileをタスクランナーとして使う書き方を、GNU Make 4.4.1の実行結果で確認。missing separator、.PHONY、cdと$$、変数の落とし穴を再現して直し、完成版まで作ります。

level
初級
time
約40分
stack
Ubuntu 26.04.1 LTS, GNU Make 4.4.1(make 4.4.1-3), dash 0.5.12-12ubuntu3(/bin/sh), bash 5.3-2ubuntu1, GNU sed 4.9, mawk 1.3.4
author
@ryoma_t
// 前提条件
Linuxのシェルでcd・cat・sedなどの基本操作ができること / sudoでパッケージを入れられるUbuntu環境(コンテナやWSLでもよい)があること
Makefileのタブをスペースに替えてmissing separatorを出し、buildが up to date になる状態を.PHONYで直したターミナル
画像:nagoya.dev(実際の画面・実行結果をもとに作成)

Makefileとは、makeコマンドに実行させる手順を「ターゲット: 前提」の行とタブで字下げしたコマンドの組で書くテキストファイルです。C言語のビルド専用ではなく、lintや設定ファイルの生成、デプロイといった運用作業に名前を付け、make deploy ENV=prodの形で呼び出すタスクランナーとしても使えます。

Docker・Terraform・Ansible・Pythonのプロジェクトで、打つコマンドを人とCIでそろえたいインフラ担当や情シス向けの手順です。Ubuntu 26.04.1のコンテナとGNU Make 4.4.1で、1つのMakefileを段階的に組み立てました。途中でわざとエラーや「何も実行されない」状態を起こし、メッセージの読み方と直し方を実行結果のまま載せています。最後まで進めると、help・lint・build・deploy・cleanの5つのタスクを持ち、設定ファイルの生成を更新時刻で省略でき、本番向けのコマンドをドライランで確かめられるMakefileが手元に残ります。

この記事でわかること

  • ターゲット・前提・レシピの書き方と、タブの字下げをcat -Aで確かめる方法
  • make: 'build' is up to date.と出て何も実行されない原因と、.PHONYに入れるものの決め方
  • レシピが一行ごとに別のシェルで動くことから来る、cdと$$の落とし穴
  • =・:=・?=の違いと、make deploy ENV=prodで引数を渡すときの優先順位
  • make・シェルスクリプト・just・miseのタスクを選び分ける判断基準

準備:makeのインストールと練習用のファイル

Ubuntu 26.04.1のコンテナイメージにmakeは入っていなかったので、aptで入れます。入ったのはGNU Make 4.4.1でした(執筆時点)。コマンドはすべて、sudoが使える一般ユーザーで実行しています。以降のコード欄では、$ で始まる行が入力するコマンド、それ以外の行が出力です。$ のない欄(次の2つのように、ヒアドキュメントでファイルを作る欄)は、欄全体が入力です。

terminal(抜粋)
$ grep PRETTY_NAME /etc/os-release
PRETTY_NAME="Ubuntu 26.04.1 LTS"
$ sudo apt update
$ sudo apt install -y make
$ make --version
GNU Make 4.4.1
Built for x86_64-pc-linux-gnu

題材は、社内向けWebアプリの設定ファイルを環境ごとに生成して配置する小さなリポジトリです。env/に環境別の値、templates/にテンプレート、scripts/に生成・検査・配置のスクリプトを置きます。実際の現場では配置の中身がrsyncやansible-playbook、docker compose up -dに変わりますが、Makefile側の書き方は同じです。次のコマンドを順に貼り付けると、作業ディレクトリ~/ops-tasksと、stg(検証)・prod(本番)それぞれの値、テンプレートができます。

terminal
mkdir -p ~/ops-tasks && cd ~/ops-tasks
mkdir -p env templates scripts
cat > env/stg.env <<'EOF'
PORT=8080
SERVER_NAME=stg.example.com
EOF
cat > env/prod.env <<'EOF'
PORT=80
SERVER_NAME=app.example.com
EOF
cat > templates/app.conf.tmpl <<'EOF'
# generated by make for @ENV@
listen      @PORT@;
server_name @SERVER_NAME@;
EOF

スクリプトは3本あります。render.shはテンプレートの@PORT@などを環境別の値に置き換えて標準出力に書き、check.shは@NAME@の形の置き換え漏れが残っていないかを調べ、deploy.shは生成物をreleases/以下にコピーします。この3本に順番と条件を付けて呼び出すのが、Makefileの役目です。

terminal
cat > scripts/render.sh <<'EOF'
#!/usr/bin/env bash
# render.sh <env> <template> : env/<env>.env の値でテンプレートの @NAME@ を置き換えて標準出力に書く
set -euo pipefail
env_name="${1:?usage: render.sh <env> <template>}"
tmpl="${2:?usage: render.sh <env> <template>}"
. "./env/${env_name}.env"
sed -e "s/@ENV@/${env_name}/" -e "s/@PORT@/${PORT}/" -e "s/@SERVER_NAME@/${SERVER_NAME}/" "$tmpl"
EOF
cat > scripts/check.sh <<'EOF'
#!/usr/bin/env bash
# check.sh <conf> : 生成した設定に @NAME@ 形式の置き換え漏れが残っていないか調べる
set -euo pipefail
conf="${1:?usage: check.sh <conf>}"
if grep -n '@[A-Z_]*@' "$conf"; then
  echo "NG: ${conf} に置き換え漏れがあります" >&2
  exit 1
fi
echo "OK: ${conf}"
EOF
cat > scripts/deploy.sh <<'EOF'
#!/usr/bin/env bash
# deploy.sh <env> : build/<env>/app.conf を releases/<env>/ に配置する
set -euo pipefail
env_name="${1:?usage: deploy.sh <env>}"
install -D -m 644 "build/${env_name}/app.conf" "releases/${env_name}/app.conf"
echo "deployed: releases/${env_name}/app.conf"
EOF
chmod +x scripts/*.sh

最初のMakefileを書く:ターゲット・レシピとタブの字下げ

~/ops-tasksに、Makefileという名前で次の内容を保存します。字下げは必ずタブ1文字です。コード欄のcopyボタンで写せばタブのまま入りますが、エディタがタブをスペースに変換する設定だと、保存したときにスペースへ変わります。

Makefile
lint:
	bash -n scripts/render.sh
	bash -n scripts/check.sh
	bash -n scripts/deploy.sh

build:
	mkdir -p build
	./scripts/render.sh stg templates/app.conf.tmpl > build/app.conf

lint:やbuild:のようにコロンで終わる行がターゲット(タスクの名前)、その下のタブで始まる行がレシピ(実行するコマンド)です。コロンの右側には、先に済ませておくターゲットやファイル(前提)を書けます。この段階ではまだ空です。保存したら、実行の前にcat -Aでタブが入っているかを確かめます。タブは^I、行末は$と表示されます。

terminal
$ cat -A Makefile
lint:$
^Ibash -n scripts/render.sh$
^Ibash -n scripts/check.sh$
^Ibash -n scripts/deploy.sh$
$
build:$
^Imkdir -p build$
^I./scripts/render.sh stg templates/app.conf.tmpl > build/app.conf$

レシピの行がすべて^Iで始まっていれば問題ありません。実行すると、makeはレシピを一行ずつ表示してから実行します。bash -nは構文を調べるだけで、問題がなければ何も出力しません。下のmake lintの後に並んでいるのは、makeが表示したコマンド行そのものです。

terminal
$ make lint
bash -n scripts/render.sh
bash -n scripts/check.sh
bash -n scripts/deploy.sh
$ make build
mkdir -p build
./scripts/render.sh stg templates/app.conf.tmpl > build/app.conf
$ cat build/app.conf
# generated by make for stg
listen      8080;
server_name stg.example.com;
$ make
bash -n scripts/render.sh
bash -n scripts/check.sh
bash -n scripts/deploy.sh

ターゲット名を付けずにmakeだけを実行すると、Makefileの先頭にあるターゲット(ここではlint)が動きます。先頭がdeployのMakefileなら、makeと打っただけで配置が始まるということです。この既定の動作は、完成版でhelpに差し替えます。

*** missing separator. Stop. が出たら字下げのタブを疑う

字下げがスペースになっていると、makeはmissing separatorというエラーで止まります。再現のため、行頭のタブをスペース4文字に置き換えたコピーと、8文字に置き換えたコピーを作り、make -fで読み込むファイルに指定して実行します(\tでタブを表す書き方は、Ubuntuに入っているGNU sedで確かめたものです)。

terminal(エラー)
$ sed 's/^\t/    /' Makefile > Makefile.spaces
$ make -f Makefile.spaces lint
Makefile.spaces:2: *** missing separator.  Stop.
$ cat -A Makefile.spaces | head -n 2
lint:$
    bash -n scripts/render.sh$
$ sed 's/^\t/        /' Makefile > Makefile.spaces8
$ make -f Makefile.spaces8 lint
Makefile.spaces8:2: *** missing separator (did you mean TAB instead of 8 spaces?).  Stop.

Makefile.spaces:2:はファイル名と行番号で、問題のある行を指しています。makeは:や=、行頭のタブといった区切りを手がかりに行の種類を見分けるので、スペースで始まるコマンド行は何の行なのか判定できず、「区切りが見つからない」というエラーになります。行頭がスペース8文字のときは、タブの間違いではないかという補足が付きました。直すには行頭のスペースをタブに打ち直します。字下げの幅がそろっていればsedでも戻せます。

terminal
$ sed -i 's/^    /\t/' Makefile.spaces
$ make -f Makefile.spaces lint
bash -n scripts/render.sh
bash -n scripts/check.sh
bash -n scripts/deploy.sh
$ rm Makefile.spaces Makefile.spaces8

よくある原因は、エディタがTabキーの入力をスペースに変換する設定になっていることです。リポジトリの直下に.editorconfigを置き、[Makefile]セクションにindent_style = tabと書いておくと、EditorConfigを読むエディタはMakefileだけタブで字下げします。エディタごとの対応状況はこの環境では試していないので、設定した後にcat -Aでもう一度確かめてください。

.PHONY:同じ名前のファイルやディレクトリがあると実行されない

ここでmake buildをもう一度実行すると、今度は何も実行されません。

terminal
$ make build
make: 'build' is up to date.
$ ls -dF build
build/

makeはターゲット名をファイル名として扱います。buildという名前のファイルかディレクトリがすでにあり、前提が何も書かれていなければ、「すでに最新」と判断してレシピを飛ばします。さきほどのレシピが自分でbuild/ディレクトリを作ったので、次の実行から動かなくなったわけです。終了コードは0のままなので、CIでも失敗として検知されません。test/やdocs/、deploy/といったディレクトリがあるリポジトリで、同じ名前のタスクを作ったときにも同じことが起きます。

対策は、ファイルを作らないターゲットを.PHONYの前提として宣言することです。宣言されたターゲットは、同じ名前のファイルがあるかどうかに関係なく、呼ばれるたびにレシピが実行されます。宣言の行は、ファイルの先頭に足します。エディタで書き足しても、次のsedでも結果は同じです。

terminal
$ sed -i '1i .PHONY: lint build\n' Makefile
$ head -n 3 Makefile
.PHONY: lint build

lint:
$ make build
mkdir -p build
./scripts/render.sh stg templates/app.conf.tmpl > build/app.conf

判断の基準は、レシピがターゲットと同じ名前のファイルを作るかどうかです。作らないなら.PHONYに入れます。lint・test・deploy・cleanのようなタスクはすべて該当します。反対に、完成版に出てくるbuild/stg/app.confのように実際のファイルを作るターゲットは入れません。入れると毎回作り直され、更新時刻による省略が効かなくなります。

レシピは一行ごとに別のシェルで動く:cdと$$

レシピはシェルスクリプトのように見えますが、makeは一行ごとに新しいシェル(既定は/bin/sh)を起動し、その行だけを渡します。シェルスクリプトの感覚で書くと、cdと$の2か所でつまずきます。確認用にtry.mkという別のファイルを保存し、make -fで指定して実行します。

try.mk
.PHONY: where where-fixed dollar

where:
	cd build
	pwd

where-fixed:
	cd build && pwd

dollar:
	@echo "1: $HOME"
	@echo "2: ${HOME}"
	@echo "3: $$HOME"
	@for f in scripts/*.sh; do echo "4: [$f] [$$f]"; done
terminal
$ make -f try.mk where
cd build
pwd
/home/ryoma/ops-tasks
$ make -f try.mk where-fixed
cd build && pwd
/home/ryoma/ops-tasks/build

whereではcd buildとpwdが別々のシェルで実行されるので、cdの効果は次の行に残りません。エラーにもならないため、cd buildの次の行にtar czfやrm -rfを書くと、意図と違うディレクトリで実行されます。同じ行に&&でつなげば、1つのシェルの中で順に実行されます。シェル変数への代入が次の行で消えるのも同じ理由です。なお、出力の/home/ryomaは検証機のホームディレクトリなので、自分の環境のパスに読み替えてください。

GNU Makeには、レシピ全体を1つのシェルに渡す.ONESHELLという特殊ターゲットもあります。ただし公式マニュアルには、.ONESHELLのもとでは最後の行以外が失敗してもmakeは気づかない、という注意が書かれています。途中で止まってほしい運用タスクには向かないので、この記事では使いません。&&でつなぐか、長くなる処理はスクリプトに出してレシピから呼びます。

terminal
$ make -f try.mk dollar
1: OME
2: /home/ryoma
3: /home/ryoma
4: [] [scripts/check.sh]
4: [] [scripts/deploy.sh]
4: [] [scripts/render.sh]

もう1つが$です。makeはレシピをシェルに渡す前に、$で始まる部分を自分の変数として展開します。1:の行の$HOMEは、make変数H(未定義なので空)と文字列OMEに分けて解釈されました。シェルに$を渡すには、$$と2つ重ねます(3:の行)。forのループ変数も同じで、$fは空になり、$$fと書いて初めてファイル名が入ります。空になるだけでエラーは出ません。レシピにrm -rf $DIR/と書いた場合、シェルに渡るのはrm -rf IR/です。

2:の行の${HOME}が正しく見えるのは、makeが起動時の環境変数を同じ名前のmake変数として取り込むからです。展開しているのはシェルではなくmakeなので、ループ変数のようにシェルの中で決まる値には使えません。レシピの中でシェルの変数を使うときは必ず$$、と決めておくほうが間違いが減ります。

変数と引数:Makefileの=・:=・?=の違いとENV=prodの渡し方

変数の代入には=・:=・?=の3つの書き方があり、値が決まるタイミングが違います。vars.mkを保存して確かめます。

vars.mk
ENV ?= stg
REGION = tokyo
NAME_LAZY = app-$(REGION)
NAME_NOW := app-$(REGION)
REGION = osaka
T_LAZY = $(shell date +%N)
T_NOW := $(shell date +%N)

.PHONY: flavor print-env

flavor:
	@echo "NAME_LAZY = $(NAME_LAZY)"
	@echo "NAME_NOW  = $(NAME_NOW)"
	@echo "T_LAZY    = $(T_LAZY) / $(T_LAZY)"
	@echo "T_NOW     = $(T_NOW) / $(T_NOW)"

print-env:
	@echo "make : ENV=$(ENV) (origin: $(origin ENV))"
	@echo "shell: ENV=$$ENV"
terminal
$ make -f vars.mk flavor
NAME_LAZY = app-osaka
NAME_NOW  = app-tokyo
T_LAZY    = 116382840 / 121165228
T_NOW     = 110484952 / 110484952

=で代入したNAME_LAZYは、使われる時点で中の$(REGION)を展開するので、後から書き換えたosakaになりました。:=のNAME_NOWは、代入の行を読んだ時点で展開を済ませるのでtokyoのままです。$(shell date +%N)(現在時刻のナノ秒部分)を入れたT_LAZYは、参照のたびにdateが実行されて2つの値が食い違い、T_NOWは一度だけ実行されて同じ値が並びます(数値そのものは実行のたびに変わります)。日時を入れたバックアップファイル名を=で作り、次の行で同じ名前を参照するようなレシピでは、名前が一致しなくなることがあります。

書き方値が決まるとき上の実行結果向いている用途
NAME = 値参照されるたびに展開するapp-osaka。dateは参照のたびに実行後ろで定義する変数を参照したいとき
NAME := 値代入の行を読んだ時点で一度だけ展開するapp-tokyo。dateは一度だけ実行$(shell …)や$(wildcard …)の結果、日時、コミットID。迷ったらこれ
NAME ?= 値変数が未定義のときだけ代入する(展開のしかたは=と同じ)次のprint-envで確認利用者やCIに上書きさせたい既定値

makeにコマンドラインから値を渡す方法は、名前=値の形だけです。ENV ?= stgと書いておくと、指定がなければstg、コマンドラインか環境変数で指定があればその値になります。print-envで使っている$(origin ENV)は、値の出どころを返す関数です。

terminal
$ make -f vars.mk print-env
make : ENV=stg (origin: file)
shell: ENV=
$ make -f vars.mk print-env ENV=prod
make : ENV=prod (origin: command line)
shell: ENV=prod
$ ENV=prod make -f vars.mk print-env
make : ENV=prod (origin: environment)
shell: ENV=prod

?=を=に変えると、環境変数での指定は無視されます。優先順位が「コマンドラインのENV=prod > Makefile内の代入 > 環境変数」だからです。CIの設定でENVを環境変数として渡す運用にするなら、?=で受ける必要があります。

terminal
$ sed 's/^ENV ?= /ENV = /' vars.mk > vars-eq.mk
$ ENV=prod make -f vars-eq.mk print-env
make : ENV=stg (origin: file)
shell: ENV=stg
$ make -f vars-eq.mk print-env ENV=prod
make : ENV=prod (origin: command line)
shell: ENV=prod

もう1つ見ておきたいのがshell:の行です。ファイルの中で代入しただけの変数は、レシピから起動されるコマンドの環境変数にはなりません(最初の実行結果ではshell: ENV=の右が空です)。引き継がれるのは、コマンドラインか環境変数から来た変数だけです。環境変数で設定を受け取るツールに既定値まで渡したいときは、代入の前にexportを付けます。この記事の完成版では、スクリプトに$(ENV)を引数として渡す形にして、環境変数には頼っていません。

terminal
$ sed 's/^ENV ?= /export ENV ?= /' vars.mk > vars-ex.mk
$ make -f vars-ex.mk print-env
make : ENV=stg (origin: file)
shell: ENV=stg

完成版のMakefile:更新時刻による作り直しとhelp・ドライラン

ここまでの内容をまとめて、Makefileを次の内容に置き換えます。追記ではなく、ファイル全体の差し替えです。

Makefile(完成版)
# 既定はstg。本番は make deploy ENV=prod と明示したときだけ
ENV ?= stg
CONF := build/$(ENV)/app.conf
SCRIPTS := $(wildcard scripts/*.sh)

.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
.PHONY: help lint build deploy clean

help: ## タスクの一覧を表示する
	@awk -F ':.*## ' '/^[a-z-]+:.*## / {printf "  make %-7s %s\n", $$1, $$2}' $(MAKEFILE_LIST)

lint: ## scripts/*.sh の構文を調べる
	@for f in $(SCRIPTS); do bash -n "$$f" || exit 1; echo "syntax ok: $$f"; done

build: $(CONF) ## ENVの設定ファイルを生成して検査する

$(CONF): templates/app.conf.tmpl env/$(ENV).env scripts/render.sh
	mkdir -p $(@D)
	./scripts/render.sh $(ENV) $< > $@
	./scripts/check.sh $@

deploy: lint build ## lintとbuildを通してから配置する
	./scripts/deploy.sh $(ENV)

clean: ## 生成物を消す
	rm -rf build releases
  • ENV ?= stg:既定値は、間違って実行しても困らない側に置きます。ENV ?= prodにすると、引数を付け忘れたmake deployがそのまま本番に向かいます。
  • CONF :=とSCRIPTS :=:読み込み時に一度だけ展開する:=です。$(wildcard scripts/*.sh)は、その時点で存在するファイル名の一覧になります。
  • .DEFAULT_GOAL := help:引数なしのmakeで動くターゲットを指定します。何も変更しないhelpを既定にしておけば、打ちかけのmakeで配置が走ることはありません。
  • .DELETE_ON_ERROR::レシピが失敗したとき、作りかけのターゲットファイルをmakeに削除させます。この行がないと何が起きるかは、つまずきポイントで再現します。
  • .PHONY:ファイルを作らない5つを宣言します。$(CONF)は実際のファイルなので入れません。
  • help:ターゲット行の##より後ろを説明文として拾い、awkで整形して表示します。タスクを足すときに同じ行へ説明を書くので、一覧と実体がずれません。$$1と書いているのはawkに$1を渡すためで、行頭の@はコマンド行そのものの表示を抑える記号です。
  • lint:|| exit 1を外すと、bash -nが失敗しても直後のechoが成功するので、行全体の終了コードは0になります。makeが確かめるのは行ごとのシェルの終了コードだけなので、構文エラーを見逃します。
  • build: $(CONF):人が打つ短い名前から、実際のファイルのターゲットへつなぐ行です。レシピはありません。
  • $(CONF): …:コロンの右に、テンプレート・環境別の値・生成スクリプトを前提として並べています。どれかが生成物より新しいときだけレシピが実行されます。$@はターゲット名、$<は最初の前提、$(@D)はターゲットのディレクトリ部分に展開されます。check.shによる検査までを生成のレシピに含め、検査に落ちたファイルがbuild/に残らないようにしています。
  • deploy: lint build:前提に書いたlintとbuildのレシピがどちらも成功してから、deploy.shが動きます。
  • clean:消す対象は固定の名前で書いています。rm -rf $(OUT_DIR)/のように変数だけで場所を決めると、変数が未定義や空のときにrm -rf /へ展開されるためです。行頭に-を付けてエラーを無視させる書き方も使っていません。

.PHONYに入れたターゲットを、実際のファイルを作るターゲットの前提にしないでください。たとえば$(CONF)の前提にlintを足すと、lintは毎回実行されるものとして扱われ、設定ファイルも毎回作り直しになります。順序を付けたいときは、完成版のdeploy: lint buildのように、.PHONYのターゲット同士でつなぎます。

引数なしで実行するとタスクの一覧が出ます。続けて、最初のMakefileが作ったbuild/をmake cleanで消しておきます。

terminal
$ make
  make help    タスクの一覧を表示する
  make lint    scripts/*.sh の構文を調べる
  make build   ENVの設定ファイルを生成して検査する
  make deploy  lintとbuildを通してから配置する
  make clean   生成物を消す
$ make clean
rm -rf build releases

ファイルの更新時刻で作り直しを決める

make buildを続けて実行し、そのあとenv/stg.envのポート番号を書き換えてからもう一度実行します。

terminal
$ make build
mkdir -p build/stg
./scripts/render.sh stg templates/app.conf.tmpl > build/stg/app.conf
./scripts/check.sh build/stg/app.conf
OK: build/stg/app.conf
$ make build
make: Nothing to be done for 'build'.
$ sed -i 's/^PORT=.*/PORT=8081/' env/stg.env
$ make -n --debug=b build | grep -E 'newer|remake'
  Prerequisite 'env/stg.env' is newer than target 'build/stg/app.conf'.
 Must remake target 'build/stg/app.conf'.
Must remake target 'build'.
$ make build
mkdir -p build/stg
./scripts/render.sh stg templates/app.conf.tmpl > build/stg/app.conf
./scripts/check.sh build/stg/app.conf
OK: build/stg/app.conf
$ cat build/stg/app.conf
# generated by make for stg
listen      8081;
server_name stg.example.com;

Nothing to be doneは、build/stg/app.confが前提のどのファイルよりも新しいので作り直さなかった、という意味です。.PHONYの節の「up to date」と違い、こちらは意図した省略です。env/stg.envを書き換えると前提のほうが新しくなり、makeは作り直しが必要だと判断します。なぜ作り直すのか、なぜ作り直さないのかを知りたいときは、-nに--debug=bを足すと、判断の理由をmake自身が表示します。比べているのは更新時刻だけで、内容が同じかどうかは見ていません。

ドライランで本番向けのコマンドを確かめる

-nを付けると、makeは実行する予定のコマンドを表示するだけで実行しません。変数が展開された後の形で出るので、ENV=prodがどのコマンドのどこに入るのかを、配置の前に確認できます。@で表示を抑えた行も-nでは表示されます。

terminal
$ make -n deploy ENV=prod
for f in scripts/check.sh scripts/deploy.sh scripts/render.sh; do bash -n "$f" || exit 1; echo "syntax ok: $f"; done
mkdir -p build/prod
./scripts/render.sh prod templates/app.conf.tmpl > build/prod/app.conf
./scripts/check.sh build/prod/app.conf
./scripts/deploy.sh prod
$ ls build
stg

build/にprodができていないので、何も実行されていないとわかります。内容に問題がなければ-nを外して実行します。

terminal
$ make deploy ENV=prod
syntax ok: scripts/check.sh
syntax ok: scripts/deploy.sh
syntax ok: scripts/render.sh
mkdir -p build/prod
./scripts/render.sh prod templates/app.conf.tmpl > build/prod/app.conf
./scripts/check.sh build/prod/app.conf
OK: build/prod/app.conf
./scripts/deploy.sh prod
deployed: releases/prod/app.conf
$ cat releases/prod/app.conf
# generated by make for prod
listen      80;
server_name app.example.com;

-nで見えるのは、makeが起動するコマンドまでです。deploy.shの中で何が行われるかは表示されません。また、行頭に+を付けたレシピ行と、$(MAKE)を含むレシピ行は、-nを付けても実行されるとマニュアルに書かれています。本番を変更するスクリプトには、ツール自身の確認手段(terraform planやansible-playbook --checkなど)を呼ぶターゲットを別に用意してください。

つまずきポイント:実際に出したエラーと対処

make: *** No targets specified and no makefile found. Stop.

Makefileのないディレクトリでmakeを実行したときのエラーです。makeが自動で探す名前はGNUmakefile・makefile・Makefileだけなので、Makefile.txtのような名前で保存した場合にも出ます。ディレクトリを移動せずに実行したいとき、たとえばcronやCIから呼ぶときは、-CでMakefileのあるディレクトリを指定します。

terminal(エラー)
$ cd /tmp && make
make: *** No targets specified and no makefile found.  Stop.
$ make -C ~/ops-tasks lint
make: Entering directory '/home/ryoma/ops-tasks'
syntax ok: scripts/check.sh
syntax ok: scripts/deploy.sh
syntax ok: scripts/render.sh
make: Leaving directory '/home/ryoma/ops-tasks'
$ cd ~/ops-tasks

make: *** No rule to make target 'prod'. Stop.

引数のつもりでmake deploy prodと打つと、makeはdeployとprodという2つのターゲットを、書かれた順に処理しようとします。

terminal(エラー)
$ make deploy prod
syntax ok: scripts/check.sh
syntax ok: scripts/deploy.sh
syntax ok: scripts/render.sh
./scripts/deploy.sh stg
deployed: releases/stg/app.conf
make: *** No rule to make target 'prod'.  Stop.

エラーで終わっていますが、その前にdeployが既定値のstgで最後まで実行されています。失敗したと思い込んでやり直す前に、どこまで実行されたかを出力で確かめてください。既定値をprodにしていた場合は、make deploy stgのつもりの操作が本番への配置になります。環境は必ずENV=prodの形で指定します。ENVの値のほうを打ち間違えたときは、前提のファイルが見つからないという形で、生成や配置が始まる前に止まります(make deploy ENV=devでも、先に動くのはlintだけです)。

terminal(エラー)
$ make build ENV=dev
make: *** No rule to make target 'env/dev.env', needed by 'build/dev/app.conf'.  Stop.

needed byの後ろが、そのファイルを必要としたターゲットです。「env/dev.envというファイルがなく、それを作るルールもない」と読みます。

/bin/sh: 1: source: not found

レシピを実行するシェルは、ログインシェルがbashであっても/bin/shです。Ubuntuでは/bin/shがdashなので、sourceのようなbashの書き方は失敗します。環境別の値を読み込んで表示するだけのMakefileをprintfで作って確かめます。

terminal(エラー)
$ printf 'show-port:\n\tsource env/stg.env && echo "port: $$PORT"\n' > sh.mk
$ make -f sh.mk
source env/stg.env && echo "port: $PORT"
/bin/sh: 1: source: not found
make: *** [sh.mk:2: show-port] Error 127
$ ls -l /bin/sh
lrwxrwxrwx 1 root root 4 Feb  2  2026 /bin/sh -> dash
$ SHELL=/bin/bash make -f sh.mk
source env/stg.env && echo "port: $PORT"
/bin/sh: 1: source: not found
make: *** [sh.mk:2: show-port] Error 127

make: *** [sh.mk:2: show-port] Error 127は、「sh.mkの:2が示す行にある、ターゲットshow-portのレシピが終了コード127で失敗した」という意味です。直前の行のとおり、sourceというコマンドが見つかっていません。環境変数でSHELL=/bin/bashを渡しても結果が変わらないのは、makeがSHELLだけは環境変数から取り込まないためです。直し方は2通りあります。ファイルの先頭でSHELL := /bin/bashと指定するか、sourceを.に、[[ ]]を[ ]にといったPOSIXの書き方に直すかです。下では前者を試したあと、先頭の指定を消して後者に書き換えています。

terminal
$ sed -i '1i SHELL := /bin/bash' sh.mk
$ make -f sh.mk
source env/stg.env && echo "port: $PORT"
port: 8081
$ sed -i -e '1d' -e 's/source /. /' sh.mk
$ make -f sh.mk
. env/stg.env && echo "port: $PORT"
port: 8081

SHELL := /bin/bashは手軽ですが、bashが/bin/bashにない環境ではMakefile全体が動かなくなります。完成版では、レシピに書くのを短いコマンドにとどめ、bashが必要な処理は#!/usr/bin/env bashで始まるスクリプトに出しました。Pythonの仮想環境も、レシピの中でsource .venv/bin/activateとは書かず、.venv/bin/pythonや.venv/bin/pipを直接呼びます。行が変われば別のシェルなので、activateの効果はどのみち次の行に残りません。

検査で止まったのに、もう一度実行すると通ってしまう

完成版から.DELETE_ON_ERROR:の行を抜いたコピーnodelete.mkを作り、この行がないと何が起きるかを再現します。テンプレートに新しい項目@DOC_ROOT@を足したのに、render.shに置き換えを足し忘れた、という場面です。

terminal(エラー)
$ grep -v '^\.DELETE_ON_ERROR' Makefile > nodelete.mk
$ echo 'root        @DOC_ROOT@;' >> templates/app.conf.tmpl
$ make -f nodelete.mk deploy
syntax ok: scripts/check.sh
syntax ok: scripts/deploy.sh
syntax ok: scripts/render.sh
mkdir -p build/stg
./scripts/render.sh stg templates/app.conf.tmpl > build/stg/app.conf
./scripts/check.sh build/stg/app.conf
4:root        @DOC_ROOT@;
NG: build/stg/app.conf に置き換え漏れがあります
make: *** [nodelete.mk:20: build/stg/app.conf] Error 1
$ make -f nodelete.mk deploy
syntax ok: scripts/check.sh
syntax ok: scripts/deploy.sh
syntax ok: scripts/render.sh
./scripts/deploy.sh stg
deployed: releases/stg/app.conf
$ grep -n DOC_ROOT releases/stg/app.conf
4:root        @DOC_ROOT@;

最初の実行は検査で止まりました。ところが、検査に落ちたbuild/stg/app.confはディスクに残っていて、テンプレートより新しくなっています。次の実行でmakeは「作り直す必要なし」と判断し、検査ごと飛ばして、置き換え漏れのある設定を配置してしまいました。失敗したので同じコマンドをもう一度打つ、というのは誰でもやる操作です。.DELETE_ON_ERROR:のある完成版では、レシピが失敗するとmakeがそのターゲットを削除するので、何度実行しても同じ場所で止まります。

terminal(エラー)
$ touch templates/app.conf.tmpl
$ make deploy
syntax ok: scripts/check.sh
syntax ok: scripts/deploy.sh
syntax ok: scripts/render.sh
mkdir -p build/stg
./scripts/render.sh stg templates/app.conf.tmpl > build/stg/app.conf
./scripts/check.sh build/stg/app.conf
4:root        @DOC_ROOT@;
NG: build/stg/app.conf に置き換え漏れがあります
make: *** [Makefile:21: build/stg/app.conf] Error 1
make: *** Deleting file 'build/stg/app.conf'
$ ls build/stg/app.conf
ls: cannot access 'build/stg/app.conf': No such file or directory
$ make deploy 2>&1 | tail -n 2
make: *** [Makefile:21: build/stg/app.conf] Error 1
make: *** Deleting file 'build/stg/app.conf'

touchは、残っていた生成物よりテンプレートを新しくして、作り直しを起こすために実行しました。エラー行のMakefile:21は、失敗した./scripts/check.sh $@の行番号です。確かめ終わったら、テンプレートに足した行を消して元に戻し、コピーも消します。

terminal
$ sed -i '$d' templates/app.conf.tmpl
$ rm nodelete.mk

同じ穴は、コマンド > $@の形でファイルを作るレシピすべてにあります。コマンドが失敗しても、リダイレクト先のファイルは空のまま作られるからです。ファイルを生成するルールを持つMakefileには、.DELETE_ON_ERROR:を最初から入れておくことを勧めます。GNU Makeがこの動作を既定にしていないのは、従来のmakeとの互換性のためだとマニュアルに説明があります。

動作確認

生成物をすべて消した状態から、既定のstgに配置できれば完成です。続けてもう一度実行すると、生成と検査は省略され、lintと配置だけが動きます。

terminal
$ make clean
rm -rf build releases
$ make deploy
syntax ok: scripts/check.sh
syntax ok: scripts/deploy.sh
syntax ok: scripts/render.sh
mkdir -p build/stg
./scripts/render.sh stg templates/app.conf.tmpl > build/stg/app.conf
./scripts/check.sh build/stg/app.conf
OK: build/stg/app.conf
./scripts/deploy.sh stg
deployed: releases/stg/app.conf
$ make deploy
syntax ok: scripts/check.sh
syntax ok: scripts/deploy.sh
syntax ok: scripts/render.sh
./scripts/deploy.sh stg
deployed: releases/stg/app.conf
$ cat releases/stg/app.conf
# generated by make for stg
listen      8081;
server_name stg.example.com;

確かめるのは次の3点です。最初のmake deployの出力が、syntax ok、OK: build/stg/app.conf、deployed: releases/stg/app.confの順に並ぶこと。続けて実行したほうにはrender.shとcheck.shの行が出ないこと。releases/stg/app.confに@で囲まれた文字列が残っていないこと。練習用に作ったファイルは消しておきます。

terminal
$ rm try.mk vars.mk vars-eq.mk vars-ex.mk sh.mk

Docker・Terraform・Ansible・Pythonのプロジェクトに当てはめる

題材のスクリプトを自分の道具に置き換えるときの対応を表にしました。コマンドの書式は各ツールの公式リファレンスで確認したものですが、この環境にはDockerやTerraformを入れておらず、表の内容は実行していません。

対象タスクとレシピの例この記事のどの仕組みを使うか
Docker Composeconfig:にdocker compose config --quiet(設定の検証だけ)、up:にdocker compose up -dどちらもファイルを作らないので.PHONY。build/やdocker/と同じ名前のターゲットに注意
Terraformplan:にterraform plan -var-file=env/$(ENV).tfvarsENV ?= stgとmake plan ENV=prod。make -n plan ENV=prodで展開後のコマンドを確認
Ansiblecheck:にansible-playbook -i inventories/$(ENV) site.yml --check --diff変更を加えない確認用のタスクを、適用するタスクの前提に置く
Python.venv/.stamp: requirements.txtにpython3 -m venv .venv、.venv/bin/pip install -r requirements.txt、touch $@を順に書く更新時刻。requirements.txtが変わったときだけ入れ直す

Pythonの行の.venv/.stampは、作業が成功した印としてtouchで作る空のファイルです。pip installのように出力が1つのファイルに定まらない作業でも、印のファイルをターゲットにすれば、requirements.txtが変わったときだけやり直す、という更新時刻の仕組みに乗せられます。GNU Makeのマニュアルにも、空のファイルでレシピの実行時刻を記録する方法として載っています。AnsibleのチェックモードはAnsibleのガイド、terraform planの流れはTerraformのガイドで扱っています。

make・シェルスクリプト・just・miseの使い分け

タスクランナーの候補はmakeだけではありません。シェルスクリプト1本にまとめる方法、コマンドランナーのjust、ツールのバージョン管理を兼ねるmiseのタスクと比べると、次のようになります。justとmiseの欄は公式ドキュメントの記述によるもので、この記事では動かしていません。

比べる点makeシェルスクリプトjustmiseのタスク
Ubuntu 26.04.1での入手apt install make(main)bashがあれば追加なしapt install just(universe)標準のリポジトリにはなく、公式の手順で入れる
タスク同士の順序前提として書く関数の呼び出しを自分で並べるレシピの依存として書くdependsに書く
更新時刻による省略標準の動作比較を自分で書く対象外(公式がビルドシステムではないと説明)sourcesとoutputsを書けば省略される
引数名前=値だけ$1などで自由に受けられるレシピ名の後ろに並べるタスク名の後ろに並べる
書き方の罠タブ、$$、一行ごとのシェル、.PHONYset -eを書かないと、失敗しても次の行へ進む.PHONYは不要(公式の説明)TOMLのmise.tomlに書く
  • 入力ファイルが変わったときだけやり直したい工程(設定やドキュメントの生成、依存パッケージの導入)が1つでもあるなら、makeを選びます。表の4つのうち、更新時刻の比較を自分で書かずに済むのはmakeとmiseです。makeはUbuntuのmainリポジトリから入るので、サーバーやCIのイメージにも足しやすい道具です。
  • タスクが数個で、順序も生成物もなく、条件分岐や引数の処理が中心なら、シェルスクリプト1本で足ります。レシピが長くなってきたときも、その部分をスクリプトに出してmakeから呼ぶ形(この記事の構成)にします。
  • just deploy prodのように位置引数で呼びたい、更新時刻は使わない、メンバー全員の端末に入れてよい、という条件がそろうならjustが合います。
  • すでにmiseでNode.jsやPythonのバージョンをそろえているリポジトリなら、タスクも同じmise.tomlに置くと設定ファイルが増えません。導入手順はmiseのガイドにまとめています。

makeが向かないのは、WSLを使わないWindows端末で作業するメンバーがいる場合と、引数の種類が多く、入力のチェックやヘルプまで欲しい場合です。前者はmakeもシェルもそのままでは動かず、後者は名前=値しか渡せないmakeでは無理が出ます。

よくある質問

Makefileとは何ですか?

makeコマンドが読む手順書です。「ターゲット: 前提」の行と、タブで字下げしたコマンド(レシピ)を並べます。makeはGNUmakefile・makefile・Makefileの順に探し、ほかの名前のファイルはmake -f ファイル名で指定します。この記事でもtry.mkやvars.mkを-fで実行しました。

Makefileとシェルスクリプトの違いは何ですか?

シェルスクリプトは書かれた順に全部を実行します。一方のMakefileはタスクごとに名前と前提を宣言し、makeが必要なものだけを選んで実行します。この記事の例では、設定ファイルが前提より新しければ生成を飛ばし、検査に落ちれば配置は動きませんでした。その代わり、レシピは一行ごとに別のシェルで動き、シェルの$は$$と書く必要があります。

Makeとはビルドのことですか?

makeは、ファイルの更新時刻を比べて必要なコマンドだけを実行するツールの名前です。C言語のコンパイルに長く使われてきたため「ビルドする」の意味でも使われますが、実行するコマンドは何でもかまいません。この記事ではコンパイラを使わず、設定ファイルの生成と配置だけにmakeを使いました。

MakefileはC言語以外でも使えますか?

使えます。レシピに書けるのはシェルで実行できるコマンド全般で、docker composeやterraform、ansible-playbook、Pythonのスクリプトも呼べます。ファイルを作らないタスクは.PHONYに入れること、環境の切り替えはENV=prodのような変数で渡すことを押さえれば、言語を問わずタスクランナーとして使えます。

まとめ

Makefileは、タスクに名前と前提を付け、ファイルの更新時刻を見て必要な分だけ実行させるための手順書です。運用作業に使うときの落とし穴は、タブの字下げ、同じ名前のファイルによる「up to date」、一行ごとに別になるシェルと$$、失敗した生成物の残骸に集まっています。次の作業として、いま手で打っているコマンドを3つ選び、完成版を土台に.PHONY・.DEFAULT_GOAL := help・.DELETE_ON_ERROR:を入れたMakefileを自分のリポジトリに置いて、make -nの出力を確かめてください。

次に読む

$ subscribe --weekly
毎週月曜、企業ITとホームラボの要点を5分で。