Un build Docker qui recompile tout à chaque commit, y compris des dépendances qui n’ont pas changé depuis des semaines, perd le bénéfice même du cache : chaque layer d’un Dockerfile est mis en cache indépendamment, mais un seul layer invalidé par erreur annule le cache de tous ceux qui le suivent, peu importe la sophistication du backend de cache utilisé derrière.
Un layer, un cache, une invalidation en cascade
Docker (et BuildKit, son moteur de build moderne) met en cache chaque instruction du Dockerfile séparément, mais l’invalidation se propage : dès qu’un layer change, tous les layers suivants sont reconstruits, même s’ils n’avaient aucune raison de changer.
# Mauvais ordre : le code source change à chaque commit,
# donc npm install se relance à chaque fois, quoi qu'il arrive
COPY . .
RUN npm install
# Bon ordre : les dépendances ne changent que si package.json
# change, npm install reste en cache le reste du temps
COPY package.json package-lock.json ./
RUN npm install
COPY . .
Le second ordre isole ce qui change rarement (les dépendances) de ce qui change à chaque commit (le code source), pour que seule la partie réellement modifiée reconstruise. Cette règle de base précède toute question d’outillage : un mauvais ordre de layers rend inutile le backend de cache le plus sophistiqué.
Le cache par défaut ne survit pas à un nouveau runner
Un cache Docker local (celui construit implicitement pendant un build) vit sur la machine qui a exécuté le build. Sur un runner CI hébergé, qui démarre une machine neuve à chaque exécution (ou presque), ce cache local disparaît systématiquement : chaque build repart de zéro, sans aucun layer réutilisable, peu importe l’ordre du Dockerfile.
# Sans cache externe : chaque run CI reconstruit tout,
# le bon ordre de layers ne sert à rien sur ce runner-là
- uses: docker/build-push-action@v6
with:
context: .
GitHub Actions cache : un backend externe qui survit entre runs
cache-from/cache-to avec le backend gha externalise le cache vers le stockage de cache natif de GitHub Actions, qui persiste entre exécutions même sur des runners hébergés éphémères. C’est exactement le mécanisme que ce site utilise dans son propre pipeline de déploiement :
- uses: docker/build-push-action@v6
with:
context: .
cache-from: type=gha
cache-to: type=gha,mode=max
mode=max mérite d’être compris avant d’être copié aveuglément : le mode par défaut ne met en cache que les layers de l’étape finale d’un build multi-étapes, tandis que mode=max conserve aussi les layers intermédiaires (les dépendances installées dans une étape de build séparée, par exemple). mode=max améliore le taux de cache hit sur un build multi-étapes typique, au prix d’un cache plus volumineux à stocker et transférer.
Les builds multi-étapes profitent le plus d’un bon cache
Un Dockerfile multi-étapes (une étape de build lourde en dépendances, une étape finale légère qui ne copie que l’artefact compilé) isole naturellement ce qui bénéficie le plus du cache : l’étape de build, généralement la plus longue, redevient quasi instantanée si ses propres dépendances n’ont pas changé, même quand l’étape finale change à chaque commit.
À retenir
L’ordre des instructions dans un Dockerfile détermine le taux de cache hit avant même le choix d’un backend : isoler ce qui change rarement de ce qui change souvent limite l’invalidation en cascade. Un runner CI hébergé, sans machine persistante, perd tout cache local à chaque exécution, ce qui rend un backend externe comme type=gha nécessaire pour en profiter réellement. mode=max conserve aussi les layers intermédiaires d’un build multi-étapes, un détail qui compte pour tirer parti d’un pipeline CI/CD qui construit des images à chaque déploiement. Ce même souci du cache s’applique au cache distant d’un monorepo : deux mécanismes différents, la même discipline d’isoler ce qui change de ce qui ne change pas.