almdev Технический блог

Makefile как интерфейс проекта
README с двадцатью командами не читают. make init читают все.

Makefile как единая точка входа в проект: одна команда старта, самодокументируемая справка, группы целей и отделение опасных операций от повседневных.

Подкатегория: Docker

Чтение
5 мин
Технологии / версии
Docker · Окружение · Makefile · Автоматизация
23 апр 2026 · 5 мин · 8 просмотров · Ops
Простой пульт управляет сложным механизмом проекта
Docker 04/2026

Инструкция по запуску проекта состоит из двенадцати шагов: скопировать переменные, поднять контейнеры, установить зависимости, собрать стили, создать базу, накатить миграции, залить демо-данные. Каждый шаг — команда с флагами и путями.

Такую инструкцию читают один раз, потом копируют команды из истории оболочки, потом забывают, зачем нужен седьмой шаг. Makefile превращает всё это в make init.

Одна команда старта

init: ## Полная инициализация проекта
	$(MAKE) up
	$(MAKE) composer-install
	$(MAKE) npm-install
	$(MAKE) build-css
	$(MAKE) db-create
	$(MAKE) db-migrate
	$(MAKE) db-seed
	@echo "Готово: https://$(PROJECT_DOMAIN)"

Семь отдельных целей, каждая делает свою часть. Рекурсивные вызовы идут строка за строкой, поэтому порядок сохраняется даже при запуске внешнего make -j. Обычный список prerequisites такой гарантии при параллельной сборке не дал бы.

Это осознанное исключение из правила про короткие цели ниже: init не содержит собственной логики, а только последовательно вызывает цели с говорящими именами.

Это не только удобство для новых людей — их у меня в проектах не бывает. Это про меня же через полгода. Вернуться к проекту и набрать make init вместо чтения собственной инструкции — экономия двадцати минут и, что важнее, отсутствие ошибки на седьмом шаге.

Справка внутри самого файла

Приём, который я ставлю первым в каждый Makefile:

.DEFAULT_GOAL := help

help: ## Показать эту справку
	@grep -E '^[a-zA-Z0-9_.-]+:.*?## .*$$' $(MAKEFILE_LIST) \
		| awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-22s\033[0m %s\n", $$1, $$2}'

Описание пишется в той же строке, что и цель, после двух решёток. Отдельной документации не существует, значит, ей нечего расходиться с реальностью.

make без аргументов показывает справку, а не запускает первую попавшуюся цель. Это спасает от случайного пересоздания базы человеком, который набрал make из любопытства.

Группы целей

Порядок в файле — по частоте использования, а не по алфавиту.

Контейнеры: up, down, restart, ps, logs, php-bash.

Зависимости и сборка: composer-install, npm-install, build-css, build-js.

База: db-create, db-migrate, db-migrations-status, db-seed, db-reset, db-fresh.

Проверки: check, test, lint, phpstan.

Одинаковый набор во всех проектах важнее его полноты. Когда make php-bash работает везде, переключение между проектами перестаёт требовать переключения в голове.

Команды внутри контейнера

Все вызовы PHP идут в контейнер, и это надо написать один раз:

DC := docker compose --env-file .env -f docker-compose.yml
PHP := $(DC) exec -T webserver

db-migrate: ## Применить новые миграции
	$(PHP) php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration

php-bash: ## Войти в bash PHP-контейнера
	$(DC) exec webserver bash

Флаг -T отключает выделение терминала — без него команды падают в неинтерактивном окружении, например в скрипте развёртывания. Исключение — цели, где терминал нужен по назначению, как вход в оболочку.

Флаг --allow-no-migration превращает «нечего применять» из ошибки в нормальный исход. Без него повторный make init падает на пустом списке миграций.

Цели, которые спасают

Пять целей, которыми я реально пользуюсь чаще всего.

db-reset — пересоздать базу, накатить миграции, залить демо-данные. Единственный способ вернуться к известному состоянию после экспериментов.

db-migrations-status — что применено, что нет, откуда несоответствие. Первое, что запускаю, когда приложение падает на запросе к отсутствующей колонке.

logs-nginx — логи одного сервиса вместо каши из всех. Отдельные цели на каждый значимый сервис стоят одной строки и экономят набор длинной команды.

restart-nginx — перезапуск одного контейнера после правки его конфигурации. Полный restart перезапускает и контейнер базы, что занимает время и рвёт соединения.

cache-clear — потому что в Symfony это делается по-разному в зависимости от окружения и прав, и помнить это наизусть незачем.

Опасные цели отдельно

Разница между db-migrate и db-reset — в том, что вторая удаляет данные. В списке справки они выглядят одинаково безобидно.

Я развожу их двумя способами. Первый — явное подтверждение:

db-reset: ## [!] Пересоздать БД, миграции и демо-данные
	@printf "Все данные будут удалены. Продолжить? [y/N] " && read ans && [ "$$ans" = "y" ]
	$(PHP) php bin/console doctrine:database:drop --force --if-exists
	$(PHP) php bin/console doctrine:database:create
	$(MAKE) db-migrate
	$(MAKE) db-seed

Второй — пометка в описании. Восклицательный знак в квадратных скобках в справке видно сразу, и это дешевле любой документации. Подтверждение намеренно принимает только строчную y; любой другой ответ останавливает цель.

Вызовы $(MAKE) вместо перечисления в зависимостях — намеренно. Зависимости выполняются до тела цели, то есть до подтверждения. Отдельные строки ещё и сохраняют порядок миграций и демо-данных при внешнем make -j.

Что не надо класть в Makefile

Логику. Make — плохой язык программирования: у него своя логика подстановок, свои правила экранирования и разные оболочки на разных системах.

Как только цель становится длиннее пяти строк или в ней появляется условие — это скрипт в отдельном файле, который Makefile вызывает:

deploy: ## Выкатить на стенд
	./scripts/deploy.sh

Скрипт можно отладить, проверить линтером и прочитать. Двадцать строк bash внутри Makefile с экранированными знаками доллара читать невозможно.

Второе, чего там быть не должно, — секретов. Makefile в репозитории, пароли в .env, который в нём не лежит.

Тонкости, на которых спотыкаются

Отступ в Makefile — только табуляция. Редактор, настроенный на пробелы, ломает файл с сообщением про пропущенный разделитель, которое ни на что не намекает.

Знак доллара в командах оболочки удваивается: $$ans, а не $ans. Одинарный означает подстановку переменной make.

Каждая строка выполняется в своей оболочке. Переход в каталог в одной строке не влияет на следующую — либо объединять через &&, либо использовать абсолютные пути.

И цель, совпадающая с именем существующего файла или каталога, не выполнится. Классика — цель test при наличии каталога test. Лечится объявлением фиктивных целей:

.PHONY: help init up down restart ps logs php-bash \
	composer-install npm-install build-css build-js \
	db-create db-migrate db-migrations-status db-seed db-reset db-fresh \
	check test lint phpstan cache-clear logs-nginx restart-nginx

Итог

Makefile — самый дешёвый инструмент в моём наборе. Полчаса на проект, а в моих Linux- и macOS-окружениях make, shell, grep и awk уже установлены. На чистой Windows эту схему сначала придётся обеспечить через WSL или другой Unix-подобный слой.

Ценность не в автоматизации как таковой, а в том, что у проекта появляется интерфейс. Не «двенадцать команд, которые надо знать», а «набери make и посмотри список». Через полгода это разница между «сейчас разберусь» и «сейчас запущу».

#docker #okruzhenie #makefile #avtomatizaciya