Makefileとは、「何を作るか(ターゲット)」「何に依存するか」「どう作るか(レシピ)」を書いておき、make コマンドで必要な処理だけを実行させるための設定ファイルです。 もともとはC言語のビルド用ですが、現在は make test や make up のように、プロジェクトのコマンドをまとめる「タスクランナー」 としても広く使われています。

この記事では、基本構文から自動変数・変数代入・関数・条件分岐までを実際に make を実行した出力つきで解説し、help を自動生成する Docker Compose / Python(uv) / Node.js 用テンプレートを載せます。

検証環境: GNU Make 4.3(make --version の出力は GNU Make 4.3 / Built for x86_64-pc-linux-gnu)、gcc、uv 0.8.17、Node.js 22。GNU Make の最新版は 4.4.1(2023年2月)です。

Makefileの書き方の早見表

やりたいこと 書き方 ポイント
基本のルール ターゲット: 依存 の次行に タブ + コマンド スペースだと missing separator
最初に実行されるターゲット ファイル内で最初のターゲット .DEFAULT_GOAL := help で変更
ファイルを作らないコマンド .PHONY: clean test 同名ファイルがあっても実行される
作るファイル名 $@ build/app
最初の依存 $< コンパイル元の .c
全部の依存(重複なし) $^ リンク時に使う
更新された依存だけ $? 差分処理
パターンの % 部分 $* out/a.log なら a
ターゲットのディレクトリ $(@D) mkdir -p $(@D)
使うときに展開する変数 A = 値 後から上書きされた値になる
その場で確定する変数 A := 値 通常はこちらを推奨(?= 未定義なら、+= 追記)
拡張子を変換 $(patsubst src/%.c,build/%.o,$(SRCS)) $(SRCS:src/%.c=build/%.o) も同じ
ファイル一覧・コマンド結果 $(wildcard src/*.c) / $(shell git --version) := と組み合わせる
条件分岐 ifeq ($(OS),Linux) 〜 endif 行頭はタブにしない
別ファイルを読み込む include config.mk / -include local.mk - 付きはファイルがなくても続行
実行せずに確認 make -n dry-run
並列実行 make -j4 依存関係が正しいことが前提
行頭の記号 @(表示しない) / -(エラーでも続行) Error 1 (ignored)
レシピを1つのシェルで .ONESHELL: cd が次の行に引き継がれる

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Makefileの書き方|自動変数一覧とタスクランナー用テンプレート」

基本の書き方:ターゲット・依存・レシピ

ターゲット: 依存ファイル1 依存ファイル2
    レシピ(実行するコマンド)※行頭はタブ

make は 「ターゲットが存在しない」か「依存ファイルの方が新しい」ときだけレシピを実行します。

C言語のビルドで動きを確認する

次の構成で試します。

src/main.c  src/util.c  src/util.h  Makefile
CC      := gcc
CFLAGS  := -Wall -O2
SRCS    := $(wildcard src/*.c)
OBJS    := $(patsubst src/%.c,build/%.o,$(SRCS))
TARGET  := build/app

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
    $(CC) $(CFLAGS) -o $@ $^

build/%.o: src/%.c src/util.h
    @mkdir -p $(@D)
    $(CC) $(CFLAGS) -c -o $@ $<

clean:
    rm -rf build

実行結果:

$ make
gcc -Wall -O2 -c -o build/main.o src/main.c
gcc -Wall -O2 -c -o build/util.o src/util.c
gcc -Wall -O2 -o build/app build/main.o build/util.o

$ make
make: Nothing to be done for 'all'.

$ touch src/util.c && make
gcc -Wall -O2 -c -o build/util.o src/util.c
gcc -Wall -O2 -o build/app build/main.o build/util.o

$ ./build/app
total=5

2回目は何も実行されず、util.c を更新すると util.o とリンクだけがやり直されました。 これが依存関係を書く意味です。

タブではなくスペースで書くと「missing separator」

レシピの行頭をスペース4つにしたMakefileを実行すると、次のエラーになりました。

$ make -f Makefile.space
Makefile.space:2: *** missing separator.  Stop.

レシピの行頭は必ずタブ文字です。.editorconfig に [Makefile] と indent_style = tab を書いておくと防げます。cat -A Makefile ではタブが ^I と表示されます。

自動変数の一覧($@ $< $^ $? $*)

自動変数は、ルールごとにmakeが自動で値を入れてくれる変数です。依存に a.txt を重複して書いた例で確認しました。

out/result.txt: a.txt b.txt a.txt
    @echo '$$@    = $@'
    @echo '$$<    = $<'
    @echo '$$^    = $^'
    @echo '$$+    = $+'
    @echo '$$?    = $?'
    @echo '$$(@D) = $(@D)'
    @echo '$$(@F) = $(@F)'
    @touch $@

out/%.log: %.txt
    @echo '$$*    = $*'
$ make
$@    = out/result.txt
$<    = a.txt
$^    = a.txt b.txt
$+    = a.txt b.txt a.txt
$?    = a.txt b.txt
$(@D) = out
$(@F) = result.txt

$ touch b.txt && make | grep '\$?'
$?    = b.txt

$ make out/a.log
$*    = a
変数 意味 実行結果
$@ ターゲット名 out/result.txt
$< 最初の依存 a.txt
$^ 全依存(重複を除く) a.txt b.txt
$+ 全依存(重複もそのまま) a.txt b.txt a.txt
$? ターゲットより新しい依存 初回は全部、2回目は b.txt
$* パターンの % に一致した部分 a
$(@D) / $(@F) ディレクトリ/ファイル名 out / result.txt

レシピの中でシェルの $ を使いたいときは $$ と2つ重ねます(上の '$$@' はそのためです)。

変数の代入:= と := と ?= と += の違い

NAME = before
LAZY  = $(NAME)
NOW  := $(NAME)
NAME = after

ENV ?= dev
FLAGS := -Wall
FLAGS += -O2

TIME_LAZY  = $(shell date +%N)
TIME_NOW  := $(shell date +%N)

show:
    @echo 'LAZY  = $(LAZY)'
    @echo 'NOW   = $(NOW)'
    @echo 'ENV   = $(ENV)'
    @echo 'FLAGS = $(FLAGS)'
    @echo 'TIME_LAZY: $(TIME_LAZY) / $(TIME_LAZY)'
    @echo 'TIME_NOW : $(TIME_NOW) / $(TIME_NOW)'
$ make
LAZY  = after
NOW   = before
ENV   = dev
FLAGS = -Wall -O2
TIME_LAZY: 193890592 / 196437124
TIME_NOW : 190171156 / 190171156

$ ENV=prod make | grep ENV
ENV   = prod
$ make ENV=stg | grep ENV
ENV   = stg
代入 展開のタイミング 実行結果から読み取れること
= 使うたびに展開 後から NAME = after にすると LAZY も after。$(shell) は参照のたびに実行され、値が毎回変わる
:= その行で確定 NOW は before のまま。$(shell) は1回だけ実行
?= 未定義のときだけ設定 環境変数 ENV=prod やコマンド引数 ENV=stg が優先
+= 追記 -Wall -O2

迷ったら := を使うと、値がいつ決まるかが明確になり、$(shell ...) の実行回数も1回で済みます。

なお A = x の次に A = $(A) y と書くと、*** Recursive variable 'A' references itself (eventually). Stop. で止まりました。追記は A += y か A := $(A) y と書きます。

.PHONY:ファイルを作らないターゲット

clean という名前のファイルがある状態で、.PHONY なしの clean を実行した結果です。

$ touch clean && make clean
make: 'clean' is up to date.

clean を「既にあるファイル」とみなして何もしません。 .PHONY: clean を追加するとレシピが実行されました。test build docs などフォルダ名と重なりやすいターゲットは必ず .PHONY に書きます。

パターンルール %.o: %.c

最初のC言語の例の build/%.o: src/%.c src/util.h がパターンルールです。% は任意の文字列に一致し、左右で同じ文字列が使われます(build/main.o → src/main.c)。ヘッダー更新後に何が実行されるかを make -n(dry-run)で確認しました。

$ touch src/util.h && make -n
mkdir -p build
gcc -Wall -O2 -c -o build/main.o src/main.c
mkdir -p build
gcc -Wall -O2 -c -o build/util.o src/util.c
gcc -Wall -O2 -o build/app build/main.o build/util.o

-n は @ で隠したコマンドも表示し、実際には何も実行しません。

よく使う関数(wildcard・patsubst・shell・foreach ほか)

SRCS  := $(wildcard src/*.c)
OBJS  := $(patsubst src/%.c,build/%.o,$(SRCS))
NAMES := $(notdir $(basename $(SRCS)))
GIT   := $(shell git --version)
DIRS  := api web worker
EACH  := $(foreach d,$(DIRS),svc-$(d))

.PHONY: show
show:
    @echo 'SRCS   = $(SRCS)'
    @echo 'OBJS   = $(OBJS)'
    @echo 'NAMES  = $(NAMES)'
    @echo 'FILTER = $(filter %b.c,$(SRCS))'
    @echo 'GIT    = $(GIT)'
    @echo 'EACH   = $(EACH)'

src/a.c、src/b.c、src/sub/c.c がある状態での出力:

SRCS   = src/a.c src/b.c
OBJS   = build/a.o build/b.o
NAMES  = a b
FILTER = src/b.c
GIT    = git version 2.43.0
EACH   = svc-api svc-web svc-worker
関数 役割 注意点
wildcard ファイル一覧 サブディレクトリは含まない(src/sub/c.c は出ない)。再帰したいなら $(shell find src -name '*.c')
patsubst パターン置換 $(VAR:a=b) は短縮形
filter / notdir / basename 抽出・パス操作 filter-out は除外
shell / foreach コマンド出力/繰り返し shell は := と組み合わせる

条件分岐 ifeq・ifdef と include

OS := $(shell uname -s)

ifeq ($(OS),Linux)
  OPEN := xdg-open
else ifeq ($(OS),Darwin)
  OPEN := open
else
  OPEN := start
endif

ifdef DEBUG
  CFLAGS := -g -O0
else
  CFLAGS := -O2
endif

Linuxでの実行結果は OPEN = xdg-open、CFLAGS = -O2、make DEBUG=1 では CFLAGS = -g -O0 になりました。ifeq などの行はタブで始めない(タブで始めるとレシピとして扱われる)のがポイントです。

include で設定を分けると、個人ごとの上書きファイルも作れます。

# config.mk の中身は「APP_PORT ?= 8000」と「export DB_NAME := app」
include config.mk
-include local.mk

.PHONY: p
p:
    @echo port=$(APP_PORT) db=$$DB_NAME
$ make
port=8000 db=app
$ echo 'APP_PORT := 9000' > local.mk && make
port=9000 db=app

-include はファイルがなくても続行します。 - なしだと No rule to make target 'nothing.mk'. Stop. で止まりました。export した変数はシェルの環境変数になります($$DB_NAME)。

並列実行 -j と .ONESHELL・デフォルトゴール

-j で並列実行

1秒かかる独立したターゲット3つを実行した時間です。

コマンド 実行時間
make 3.03秒
make -j3 1.03秒
make -j3 --output-sync=target 1.01秒

依存関係が正しければ -j だけで速くなります。 ログが混ざるなら --output-sync=target でターゲットごとにまとめます。-j でだけ失敗する場合は依存の書き漏れを見直します。

レシピは1行ずつ別のシェルで実行される(.ONESHELL)

normal:
    cd /tmp
    pwd
    X=1
    echo "X=$$X"

make normal の結果、pwd は /tmp ではなくMakefileのあるディレクトリを表示し、最後は X= と空になりました。各行は別のシェルなので、cd も変数も引き継がれません。 ファイル先頭に .ONESHELL: を書くと /tmp と X=1 が出力されました。使わない場合は cd /tmp && pwd と1行につなげます。

デフォルトゴールとエラー時の動き

書き方 動き(実行結果)
何も指定しない/.DEFAULT_GOAL := second 最初のターゲット/second が実行された
レシピがエラー make: *** [Makefile:4: fail] Error 1 で停止、終了コード 2
レシピ行頭に - make: [Makefile:7: ignore] Error 1 (ignored) と表示して続行

タスクランナー用テンプレート(help自動生成つき)

ターゲットの行末に ## 説明 を書き、grep と awk で一覧を表示するのが定番の書き方です。

Python(uv)プロジェクト用

# ==== Python (uv) プロジェクト用 Makefile ====
SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c
.DEFAULT_GOAL := help
MAKEFLAGS += --no-builtin-rules

.PHONY: help setup lint format test check clean

help: ## コマンド一覧を表示
    @grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) \
      | awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-10s\033[0m %s\n", $$1, $$2}'

setup: ## 依存関係をインストール(uv sync)
    uv sync
    uv add --dev ruff pytest

lint: ## ruff で静的チェック
    uv run ruff check .

format: ## ruff で整形
    uv run ruff format .

test: ## pytest を実行
    uv run pytest -q

check: lint test ## lint と test をまとめて実行

clean: ## キャッシュを削除
    rm -rf .pytest_cache .ruff_cache
    find . -name __pycache__ -type d -prune -exec rm -rf {} +

uv init --lib taskapp での実行結果(色コードは省略):

$ make
  help       コマンド一覧を表示
  setup      依存関係をインストール(uv sync)
  lint       ruff で静的チェック
  format     ruff で整形
  test       pytest を実行
  check      lint と test をまとめて実行
  clean      キャッシュを削除

$ make check
uv run ruff check .
I001 [*] Import block is un-sorted or un-formatted
(中略)
make: *** [Makefile:18: lint] Error 1

check: lint test は lint が失敗した時点で止まり、test は実行されません。 uv run ruff check --fix . で直した後は次のとおり通りました(ruff 0.16.9 / pytest 9.1.1)。

$ make check
uv run ruff check .
All checks passed!
uv run pytest -q
.                                                                        [100%]
1 passed in 0.01s

.SHELLFLAGS := -eu -o pipefail -c はパイプ途中の失敗や未定義変数でも止める設定です。

Node.js プロジェクト用(npm install を必要なときだけ)

# ==== Node.js 用 Makefile ====
STAMP := node_modules/.installed
.DEFAULT_GOAL := help
.PHONY: help install test build

help: ## コマンド一覧
    @grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "  %-10s %s\n", $$1, $$2}'

# package.json か package-lock.json が変わったときだけ npm install
$(STAMP): package.json package-lock.json
    npm install
    @mkdir -p $(@D) && touch $@

install: $(STAMP) ## 依存関係をインストール

test: $(STAMP) ## テスト
    npm test --silent

build: $(STAMP) ## ビルド
    npm run build --silent

目印ファイル(スタンプ)を依存にしたことで、make build の1回目は npm install → ビルド、2回目は npm run build --silent だけが実行されました。 更新日時で判断するmakeならではの使い方です。

Docker Compose 用

# ==== Docker Compose 用 Makefile ====
COMPOSE ?= docker compose
SERVICE ?= app
.DEFAULT_GOAL := help
.PHONY: help up down restart logs sh

help: ## コマンド一覧
    @grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "  %-10s %s\n", $$1, $$2}'

up: ## バックグラウンドで起動
    $(COMPOSE) up -d

down: ## 停止してコンテナを削除
    $(COMPOSE) down

restart: down up ## 再起動

logs: ## ログを追いかける(SERVICE=名前 で絞り込み)
    $(COMPOSE) logs -f $(SERVICE)

sh: ## コンテナに入る
    $(COMPOSE) exec $(SERVICE) sh

make -n で展開結果を確認しました(docker compose 自体は未実行)。

$ make -n restart
docker compose down
docker compose up -d
$ make -n logs SERVICE=db
docker compose logs -f db

SERVICE ?= app にしておくと、make logs SERVICE=db のように引数で切り替えられます。

GNU make と BSD make の違い

この記事の構文はGNU make向けです。FreeBSD等の標準 make はBSD makeで、条件分岐は .if / .endif、変数は ${.TARGET} ${.ALLSRC}、関数の代わりに修飾子 ${VAR:M*.c} を使うため、gmake として GNU make を入れて使います(BSD make は未実行、マニュアルに基づく)。macOS標準は GNU Make 3.81 なので、.ONESHELL(3.82以降)を使うなら Homebrew の gmake を使います。

just・npm scripts・Taskfile との比較

ツール 設定ファイル 強み 注意点
make Makefile 標準で入っていることが多い、更新日時で差分実行 タブ必須、$$
just justfile 引数が素直、タブ不要 別途インストール
npm scripts package.json Nodeなら追加不要 差分実行はない
Taskfile Taskfile.yml YAML、sources で差分実行 別途インストール

よくある質問

Q. make: *** No rule to make target 'xxx' と出ます。 A. 依存に書いたファイルが存在せず、それを作るルールもないときのエラーです。ファイル名のつづり、パス、include したファイルの有無を確認します。

Q. Makefile と makefile のどちらがよいですか? A. GNU make の公式マニュアルは Makefile を推奨しています(探す順は GNUmakefile → makefile → Makefile)。

Q. レシピでシェル変数が空になります。 A. 各行が別のシェルで動くためです。&& でつなぐか .ONESHELL: を使い、$$X と書きます。

まとめ

  • Makefileは「ターゲット: 依存」+タブで始まるレシピ。スペースだと missing separator
  • 自動変数 $@ $< $^ $? $* $(@D) でファイル名を繰り返し書かずに済む
  • 変数は基本 :=、上書き可能にしたい値は ?=、追記は +=
  • ファイルを作らないターゲットは .PHONY に書く
  • make -n で確認、make -j で並列化、.ONESHELL でレシピを1つのシェルに
  • ## コメント と grep/awk の help で、タスクランナーとしてチームに共有しやすくなる

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Makefileの書き方|自動変数一覧とタスクランナー用テンプレート」

株式会社RJCでは、チームの誰もが同じコマンドで開発を始められるように、環境の整え方をプロジェクトの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?