Makefile : cibles, dépendances de fichiers et auto-documentation
On met un Makefile dans à peu près tous les repos et 9 fois sur 10 c'est un lanceur de commandes déguisé : une liste de cibles qui appellent chacune un docker build ou un kubectl apply. Ça marche très bien, mais on paye la syntaxe de make sans utiliser ce qui la justifie.
À quoi ça sert, vraiment
make n'est pas un task runner, c'est un moteur de dépendances qui compare des dates de modification. On lui déclare qu'un fichier B dépend d'un fichier A et il ne reconstruit B que si A est plus récent. Tout le reste en découle.
L'usage en task runner est un détournement et un détournement légitime : la syntaxe est partout, make deploy se devine sans lire la doc et il n'y a rien à installer. Mais dès qu'une étape est lente et idempotente, par exemple un terraform init ou une installation de dépendances, le moteur de dépendances devient l'argument principal.
La syntaxe qui pique
3 règles expliquent la quasi-totalité des messages d'erreur incompréhensibles de make :
- l'indentation d'une recette est une tabulation, pas des espaces. Un
Makefile:4: *** missing separator. Stop.veut toujours dire ça. - chaque ligne d'une recette tourne dans son propre shell. Un
cd buildsur une ligne n'a plus aucun effet sur la suivante. - une cible est un nom de fichier par défaut. Si un fichier
testexiste dans le repo,make testrépondmake: 'test' is up to dateet ne fait rien.
La troisième est ce que .PHONY corrige : elle déclare qu'une cible ne produit pas de fichier du même nom.
Les blocs de cet article utilisent des espaces pour la lisibilité
Dans un vrai Makefile, chaque ligne de recette doit commencer par une tabulation. Un copier-coller depuis une page web récupère souvent des espaces, d'où le missing separator immédiat.
Les 4 affectations et laquelle utiliser
C'est la source de bug la plus discrète du format, parce que les 4 se ressemblent et ne s'évaluent pas au même moment.
| Forme | Nom | Évaluation |
|---|---|---|
VAR = valeur | récursive | à chaque utilisation |
VAR := valeur | immédiate | une fois, à la lecture du fichier |
VAR ?= valeur | conditionnelle | seulement si VAR n'est pas déjà définie |
VAR += valeur | ajout | concatène à l'existant |
# Évalué une seule fois, c'est ce qu'on veut pour un appel de commande
GIT_SHA := $(shell git rev-parse --short HEAD)
# Surchargeable depuis l'environnement ou la ligne de commande
REGISTRY ?= europe-west9-docker.pkg.dev/myproject/apps
TAG ?= $(GIT_SHA)
IMAGE := $(REGISTRY)/myapp:$(TAG)
La règle pratique : := par défaut, ?= pour tout ce qu'on veut pouvoir surcharger et = seulement quand la valeur doit être recalculée à chaque appel. Écrire GIT_SHA = $(shell git rev-parse --short HEAD) avec un simple = relance un git rev-parse à chaque fois que la variable apparaît, ce qui se voit sur un Makefile bavard.
Une variable posée en ligne de commande gagne sur celle du fichier, sauf si on utilise override :
Rendre les recettes strictes
Par défaut, make appelle /bin/sh sans aucun garde-fou : un pipe qui échoue au milieu passe pour un succès et une variable non définie devient une chaîne vide. Sur un Makefile qui déploie, c'est le genre de silence qu'on ne veut pas.
Ce que chaque ligne apporte :
SHELLet.SHELLFLAGSdonnent à chaque recette le même contrat qu'un script défensif : sortie sur erreur, sur variable non définie et propagation de l'échec dans un pipe.ONESHELLexécute toute la recette dans un seul shell, donc uncdtient sur les lignes suivantes et on arrête d'écrire des&& \en fin de ligne.DELETE_ON_ERRORsupprime le fichier produit quand la recette échoue, sinonmakele considère à jour au prochain appel et saute l'étape
Ces 4 lignes en tête de fichier changent plus la fiabilité d'un Makefile que n'importe quelle astuce de syntaxe.
Ne refaire que ce qui a changé
C'est là que make gagne contre un script shell. On déclare une cible qui est un vrai fichier et l'étape est sautée tant que ses sources n'ont pas bougé.
# Le binaire dépend des sources : pas de rebuild si rien n'a changé
bin/myapp: $(shell find . -name '*.go') go.mod go.sum
go build -o $@ ./cmd/myapp
# terraform init est lent : on pose un fichier témoin
.terraform/.init: main.tf versions.tf
terraform init -upgrade
@touch $@
plan: .terraform/.init
terraform plan -out tfplan.binary
Le $@ est le nom de la cible en cours, $< la première dépendance et $^ toutes les dépendances. Le fichier témoin, souvent appelé fichier sentinelle, est le motif à connaître : quand une commande ne produit pas de fichier identifiable, on en crée un à la main avec touch pour donner à make une date à comparer.
Sur un terraform init de 40 secondes appelé 15 fois par jour, le gain est immédiat.
Auto-documenter les cibles
Avec un peu de malice, rien de plus simple :
.DEFAULT_GOAL := help
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
Pour vous décripter ce que fait cette ligne automagique, tout d'abord, nous allons appliquer un grep sur les lignes commencant par une (ou plusieurs) lettre(s) suivi d'un :. Nous allons prendre tout le contenu qui suit les signes ##. Une fois cela fait, nous allons le strier, puis appliquer un filtre pour les afficher de manière élégante.
Tout ceci pour nous donner le résultat suivant :
Le .DEFAULT_GOAL := help est la moitié utile du motif : un make sans argument affiche l'aide au lieu de lancer la première cible du fichier, ce qui évite le make qui déploie en prod par accident.
Les cibles se documentent ensuite avec un commentaire ## sur la ligne de déclaration :
build: ## Construire l'image Docker locale
docker build -t $(IMAGE) .
deploy: build ## Pousser l'image et appliquer le manifest
docker push $(IMAGE)
kubectl set image deployment/myapp myapp=$(IMAGE)
Les pièges
.PHONYoublié sur une cible dont un fichier porte le nom. Le symptôme est unmakequi ne fait rien et annonceup to date. On déclare toutes les cibles non-fichier en une ligne en tête de Makefile.make -jsans dépendances déclarées. Le parallélisme suit le graphe : sans dépendance entre 2 cibles,make -j4les lance en même temps, y compris quand l'une avait besoin de l'autre. Le bug est intermittent, donc pénible.- La récursion avec
makeau lieu de$(MAKE). Seul$(MAKE)transmet les flags du parent, notamment-jet-n. Unmake -C sous-repoécrit en dur casse le dry-run et le parallélisme. - Les variables d'environnement qui traversent. Tout l'environnement du shell est visible dans le Makefile, donc une variable
TAGdéjà exportée dans le shell surcharge unTAG ?=sans prévenir. Sur les noms génériques, préfixer (APP_TAG) évite la collision. @devant chaque ligne par réflexe. Le@masque la commande affichée. C'est bien pour unecho, c'est une mauvaise idée sur une commande qui déploie : quand ça casse en CI, on veut voir la commande exacte dans les logs.
Voir aussi
- Écrire des scripts bash défensifs - le
set -euo pipefailque reprend.SHELLFLAGS - Template de script bash - quand le Makefile ne suffit plus
- Accélerer Terraform - les cibles Terraform qui gagnent à être cachées
