1. Fréquence des modifications et cache Docker
Maintenant que tu sais qu'il faut construire les COPY du stable vers le volatile, une question pratique se pose : qu'est-ce qui est considéré comme stable dans un projet Gradle, et qu'est-ce qui vit dans la zone des modifications quotidiennes ? Les projets Java/Gradle ont une particularité : le « poids » de la construction se trouve souvent non pas dans ton code, mais dans les dépendances et les étapes de build. C'est pourquoi une bonne séparation des fichiers par fréquence de modification, ce n'est pas de l'esthétique — c'est une vraie économie de minutes, parfois même de nerfs.
Imagine que le cache Docker est comme une liste de contrôle à l'entrée d'un club : si tu changes de passeport à chaque fois, on te vérifie à chaque fois. Notre objectif, c'est de faire en sorte que les « données du passeport » (wrapper + fichiers de build) changent rarement et se trouvent dans les étapes précoces, tandis que la « tenue du jour » (src/) peut changer cent fois par jour, mais ne touche que les étapes tardives.
Il est important de ne pas se tromper ici : on ne cherche pas à atteindre « 0 seconde de reconstruction » et on ne transforme pas le Dockerfile en une compétition de micro-optimisations. On veut un comportement prévisible : tu modifies du code — seule la partie qui doit logiquement être reconstruite l'est.
2. Carte du dépôt Gradle/Spring Boot
Quand tu regardes la racine du dépôt, ça ressemble à un grand dossier : « ben, tout le projet est là ». Mais le cache Docker ne voit pas les choses ainsi. Ce qui compte pour lui, c'est autre chose : « dans cette couche, je copie ces fichiers-là ». Et si tu n'as pas la carte du projet en tête, tu vas presque inévitablement glisser vers COPY . . et ensuite souffrir héroïquement (en écrivant dans le chat : « pourquoi Docker ne met rien en cache ??? »).
Voyons ensemble la carte minimale sur laquelle on va s'appuyer dans notre docker-java-catalog-service :
docker-java-catalog-service/
|-- gradlew
|-- gradle/
|-- build.gradle.kts
|-- settings.gradle.kts
|-- src/
\-- build/ # généré par Gradle, pas commité dans Git habituellement
La logique de la leçon du jour est déjà cachée dans cette image. gradlew et le dossier gradle/ — c'est le « moteur » qui permet de lancer Gradle de la même façon pour tout le monde. build.gradle.kts et settings.gradle.kts — c'est la « recette » selon laquelle Gradle construit le projet et décide quelles dépendances télécharger. src/ — ce que tu modifies le plus souvent. Et build/ — le résultat, qui ne devrait pas du tout être une entrée pour la construction : c'est un « produit », pas une « matière première ».
3. Gradle Wrapper
C'est généralement ici qu'a lieu le dialogue typique du développeur débutant avec la réalité : « Pourquoi j'aurais besoin de ce gradlew, je peux juste faire gradle bootJar ». Tu peux… tant que tu es seul sur le projet, sur une seule machine, et que tu n'as pas changé la version de Gradle depuis six mois. Dès qu'apparaît une deuxième personne (ou que tu as réinstallé le système), c'est la loterie : l'un a Gradle 9.4, l'autre 8.7, et un troisième « ne l'a pas installé ».
Gradle Wrapper est justement la solution à cette loterie. Il fixe la version de Gradle pour le projet et rend la construction reproductible. Pour Docker, c'est particulièrement important : à l'intérieur du conteneur, on ne veut pas « deviner » quelle version de Gradle est installée. On veut apporter dans l'image exactement ce dont on a besoin pour construire.
La mini-carte du wrapper ressemble à ça :
.
|-- gradlew
\-- gradle/
\-- wrapper/
|-- gradle-wrapper.properties
\-- gradle-wrapper.jar
Si on simplifie en une phrase, gradlew — c'est le « bouton pour lancer Gradle », et gradle/wrapper/* — « la notice intégrée indiquant quel Gradle télécharger et lancer ». Et tout ça change rarement. Du point de vue du cache Docker, ce sont donc des candidats idéaux pour des COPY précoces.
Il y a encore un détail pratique qui remonte tôt ou tard pour une partie des gens : gradlew doit être exécutable (chmod +x). Sur Linux et macOS, ça marche généralement « tout seul », mais sur Windows il arrive d'avoir un permission denied inattendu. Ce n'est pas une raison de détester Docker, c'est une raison de se souvenir que docker build s'exécute le plus souvent dans un contexte Linux.
4. Fichiers de build et dépendances
Après le wrapper, le deuxième pilier du projet, ce sont les fichiers de build. En Kotlin DSL, ce sont généralement build.gradle.kts et settings.gradle.kts. Ils contiennent tout ce que Gradle considère comme la description du build : plugins, dépendances, version du projet, nom du projet, structure des modules. Tu ne les changes pas toutes les heures, mais pas non plus « une fois par an » : tu as ajouté une bibliothèque, mis à jour une version, connecté un plugin — et voilà, le fichier a changé.
Du point de vue du cache Docker, les fichiers de build se situent dans une zone de stabilité moyenne. Il faut les copier avant src/, parce qu'une modification du code métier ne doit pas invalider le cache là où tu résous, disons, les dépendances. Mais si tu modifies vraiment les dépendances — oui, c'est une bonne raison d'invalider une partie du cache et de reconstruire les couches lourdes. Ici, Docker se comporte correctement, et il ne faut pas lui chercher querelle.
Un exemple d'extrait de build.gradle.kts dans notre service pédagogique peut ressembler à ça :
dependencies {
// Couche web (contrôleurs, traitement HTTP). Si tu changes les dépendances ici — le graphe change.
implementation("org.springframework.boot:spring-boot-starter-webmvc")
// Endpoints /actuator pour le monitoring et la santé de l'application.
implementation("org.springframework.boot:spring-boot-starter-actuator")
// JPA + transactions. C'est généralement une couche "lourde" qui tire un paquet de dépendances.
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
// Le driver de BDD n'est nécessaire qu'au runtime (pas importé directement dans le code en général).
runtimeOnly("org.postgresql:postgresql")
}
Il faut voir ici la logique : si tu as modifié ce bloc — le projet est devenu différent du point de vue du graphe de dépendances. C'est normal que le cache Docker « dise » ici : ok, je vais reconstruire la couche où Gradle résout, télécharge et reconstruit. Mais si tu n'as pas changé les dépendances et que tu as juste corrigé CatalogItemService, Docker doit avoir la possibilité de ne pas toucher à cette couche. C'est précisément pour ça qu'on sépare les fichiers en groupes.
Un autre fichier fréquemment rencontré — gradle.properties. S'il existe et contient des paramètres de build (versions, flags), il appartient par sa nature à la même catégorie que build.gradle.kts : c'est une partie de la « recette » de build, il est donc logique de le garder dans le même groupe.
5. Sources : src/
Si le wrapper et les fichiers de build sont les fondations, alors src/ est ton chantier quotidien. C'est là que tu vis : tu écris des classes Java, tu modifies des contrôleurs, tu corriges des services, tu ajoutes des DTO, tu ajustes application.yml, tu modifies les migrations SQL, etc. Et oui, ça changera souvent. Parfois des dizaines de fois par jour, surtout quand tu apprends.
C'est pourquoi src/ devrait presque toujours apparaître dans le Dockerfile après le wrapper et les fichiers de build. Ce n'est pas une « optimisation pour le principe », c'est une façon de conserver un cache honnête pour les couches plus stables. Une modification de src/ doit invalider la couche COPY src ... et tout ce qui suit dans le Dockerfile (par exemple, l'étape de build bootJar). C'est normal. Ce qui n'est pas normal, c'est quand une modification de src/ invalide aussi les étapes qui n'ont rien à voir avec les sources, comme la préparation des dépendances ou la copie du wrapper.
Pour simplifier : src/, c'est comme le bureau du développeur, et le wrapper + les fichiers de build, c'est comme la boîte à outils et la notice. Tu peux déplacer des papiers sur le bureau autant que tu veux, mais ça ne doit pas t'obliger à acheter un nouveau marteau à chaque fois.
6. build/ et artefacts générés
À ce stade, l'envie est souvent de faire quelque chose de « simple » : « Je vais construire le bootJar localement et ensuite simplement copier build/ dans l'image ». Au début, ça marche vraiment, et on l'a déjà fait quand on voulait rapidement voir que « le conteneur démarre ». Mais si on parle d'une construction cache-friendly et du développement progressif d'un Dockerfile, il est important de ne pas transformer le dossier build/ en données d'entrée pour docker build juste parce que c'est plus court à écrire.
Le dossier build/ est le résultat du travail de Gradle. À l'intérieur se trouvent les classes compilées, les ressources, les rapports, les résultats de tests, parfois des fichiers temporaires, et oui, ton jar aussi. Ce dossier est par nature « bruyant » : il change même quand tu as modifié un détail. Et en plus, il accumule facilement des restes de builds précédents. Si Docker voit que build/ arrive dans le build context, il va considérer ça comme une partie de l'entrée. Et c'est là que commence l'histoire « pourquoi mon cache rate toujours ».
La règle est donc très simple et très utile : build/ devrait le plus souvent être exclu du build context via .dockerignore, et le Dockerfile doit travailler avec les sources et les recettes de build, pas avec ce que Gradle a « craché » sur ton système de fichiers.
Mini-exemple de .dockerignore pour un projet Java/Gradle :
# Output Gradle : sortie "bruyante", ne doit pas entrer dans le build context
build
.gradle
# Fichiers IDE : changent d'eux-mêmes et cassent le cache "sans raison"
.idea
*.iml
out
# Généralement inutiles pour construire le JAR dans l'image (si tu ne les utilises pas dans le build-script)
# requests
# scripts
Même si tu ne fais rien d'autre, ça va déjà réduire notablement le nombre de raisons accidentelles pour lesquelles le cache Docker devient inutile.
7. Caches locaux et traces de l'IDE
Il existe une catégorie particulière de fichiers qui vivent à côté du projet, mais qui ne font absolument pas partie des « sources de l'application ». Ce sont les caches locaux de Gradle (.gradle/), les paramètres de l'IDE (.idea/, *.iml), les dossiers de service (out/) et autres traces de ta machine. Ils changent souvent sans que tu t'en rendes compte : l'IDE a sauvegardé quelque chose, Gradle a mis à jour son cache, tu as changé de branche.
Si ces fichiers entrent dans le build context et que tu utilises un COPY . . large, Docker sera obligé de réagir à leurs modifications comme si tu avais changé une partie importante du projet. Et toi tu vas rester là à te demander : « Je n'ai rien changé, pourquoi le cache est cassé ? ». Spoiler : tu n'as pas modifié le code métier, mais des fichiers dans le contexte de build ont changé.
C'est pourquoi .dockerignore fonctionne ici comme un filtre de bon sens : on dit explicitement à Docker « ça ne concerne pas la construction de l'image, ne l'amène pas dans le build context ». Et oui, .dockerignore ne remplace pas l'ordre des COPY, mais il réduit considérablement le niveau de bruit accidentel, surtout chez les débutants.
D'ailleurs, dans le dépôt pédagogique, on peut avoir des dossiers requests/ et scripts/ pour les smoke-tests. Ce sont des choses utiles pour le développeur, mais pour construire le jar à l'intérieur de l'image, elles ne sont généralement pas nécessaires. Si tu copies tout le projet en bloc, les modifications dans requests/catalog.http vont aussi casser le cache Docker juste parce qu'un fichier dans le contexte a changé. L'étape suivante logique — ne copier dans l'image que ce dont on a vraiment besoin pour la construction.
8. Tableau des fréquences de modification
Après toutes ces explications, il est utile de fixer ça non pas comme un ensemble d'intuitions (« ça a l'air stable »), mais comme une carte d'ingénierie. Voici le tableau qu'il est pratique de garder en tête quand tu écris COPY dans le Dockerfile.
| Groupe de fichiers | Exemples | Fréquence de modification | Ce que ça signifie pour le cache Docker | Position habituelle dans le Dockerfile |
|---|---|---|---|---|
| Gradle Wrapper | |
rarement | excellent candidat pour un COPY précoce et un cache longue durée | tout au début (après WORKDIR) |
| Description du build et des dépendances | |
parfois | doit invalider le cache quand tu changes vraiment les dépendances (c'est normal) | après le wrapper, mais avant src/ |
| Sources de l'application | |
souvent | invalidera souvent le cache des étapes suivantes, et c'est attendu | vers la fin |
| Tests (si présents) | |
parfois/souvent | si tu lances les tests dans l'image, c'est une autre couche « chaude » | dépend de la stratégie, mais généralement séparé |
| Build output (généré) | |
constamment | casse le cache avec du « bruit », généralement nuisible comme entrée | pas copié dans l'image habituellement |
| Déchets locaux | |
constamment et de façon imprévisible | crée des cache miss accidentels | exclus via .dockerignore |
Attention à une idée importante : le cache Docker, ce n'est pas « faire en sorte que tout soit en cache ». Le cache Docker, c'est « mettre en cache ce qu'il est logique de mettre en cache, et reconstruire ce qui a vraiment changé ». Si tu as ajouté une nouvelle dépendance, la reconstruction est le juste prix. Mais si tu as corrigé une méthode dans un service, retélécharger tout l'internet à nouveau sonne comme une plaisanterie de mauvais goût — et on règle ça avec la structure du Dockerfile.
9. Regrouper les COPY dans le Dockerfile
Ce qui compte maintenant, ce n'est pas de compléter le Dockerfile jusqu'à la dernière ligne, mais de traduire la carte du dépôt en ordre de COPY. Au niveau de la structure des fichiers, la logique ressemble généralement à ça : d'abord le wrapper, ensuite les fichiers de build, ensuite src.
Mini-fragment qui reflète précisément l'idée du regroupement :
WORKDIR /app
# 1) Modifications les plus rares : wrapper (donne une version reproductible de Gradle)
COPY gradlew ./
COPY gradle/ ./gradle/
# 2) "Recette" de build : change rarement/parfois, mais critique pour les dépendances
COPY build.gradle.kts settings.gradle.kts ./
# 3) Modifications les plus fréquentes : sources de l'application
COPY src/ ./src/
Ce fragment suffit déjà pour voir la bonne frontière d'invalidation. src/ doit arriver après les fichiers de build, pour qu'une simple correction de code ne réveille pas la partie supérieure du build. Et si build.gradle.kts change, la frontière monte — et c'est le juste prix d'une modification du graphe de dépendances.
À partir de cette carte, on obtient presque un Dockerfile complet : en haut restent les entrées stables du build, plus bas descend la zone chaude de src/, et le lourd bootJar doit se trouver après cette frontière.
10. Erreurs typiques avec le cache Docker
Erreur n°1 : percevoir tout le projet comme un dossier indivisible et écrire COPY . . au début.
Un tel Dockerfile est agréablement court, mais il demande littéralement à Docker de considérer « le moindre frémissement » comme une raison de reconstruire : tu as modifié un fichier .java, .idea s'est mis à jour, tu as corrigé README.md, l'IDE a créé un nouveau fichier — et te voilà à reconstruire la partie lourde. C'est particulièrement douloureux dans un projet Gradle, où le build et les dépendances coûtent cher.
Erreur n°2 : amener build/ dans le build context et s'étonner que le cache ne soit jamais atteint.
build/ — c'est un output, il change souvent et bruyamment. Quand il participe comme input, tu dis en fait à Docker : « Considère que le projet change toujours ». Après ça, le cache devient une loterie, et la reconstruction — perpétuelle. La bonne approche — exclure build/ dans .dockerignore et ne copier que les sources et les fichiers de build.
Erreur n°3 : oublier Gradle Wrapper et essayer d'« installer Gradle dans l'image ».
Parfois, l'envie vient de faire RUN apt-get install gradle ou de « prendre Gradle depuis l'image ». Mais tu perds alors la reproductibilité et tu commences à dépendre de ce qui se trouve dans l'image de base et dans le gestionnaire de paquets. Le Wrapper est fait précisément pour éviter ça. Pour un projet pédagogique, c'est particulièrement important : on veut que les commandes fonctionnent de la même façon pour tous les étudiants.
Erreur n°4 : ne pas comprendre que les fichiers de build et les sources sont des « classes de modifications » différentes.
Si tu copies les fichiers de build et src/ en une seule étape, tu mélanges les « modifications rares » (par exemple, mise à jour des dépendances) avec les « modifications fréquentes » (correction de code). Résultat : les parties rares n'ont pas vraiment de chance d'être mises en cache correctement. Séparer les COPY par sens — c'est l'une des choses les plus simples et les plus efficaces qu'on puisse faire dans un Dockerfile.
Erreur n°5 : laisser .gradle/ et .idea/ dans le build context et ensuite avoir des cache miss accidentels « sans raison apparente ».
Ces dossiers changent facilement d'eux-mêmes. Tu peux même ne pas toucher au code, mais l'IDE va mettre à jour ses fichiers, Gradle va mettre à jour son cache, et Docker va voir des modifications. Tu perds alors confiance dans le build (« c'est imprévisible »). Ça se règle généralement par deux choses : un bon .dockerignore et un ensemble de fichiers plus étroit dans COPY.
GO TO FULL VERSION