Images, couches, build multi-étapes, volumes et réseaux : ce qu’on demande vraiment sur la conteneurisation.
Cliquez sur une question pour dérouler la réponse attendue.
L’image est le modèle immuable. Le conteneur est une instance de cette image : une couche d’écriture, des namespaces, et un processus avec un cycle de vie.
Lire la réponse détailléeChaque instruction produit une couche. Dès qu’une instruction change, Docker invalide celle-ci et toutes les suivantes. L’ordre décide donc ce qui est recalculé.
Lire la réponse détailléePresque toujours. COPY ne fait qu’une chose : copier des fichiers du contexte de build vers l’image. ADD en fait trois : copier, télécharger une URL, et extraire un archive local (tar, gzip, bzip2, xz). Cette magie est le problème.
ADD https://exemple.com/app.tar.gz /opt/ télécharge pendant le build, sans checksum que vous contrôlez vraiment, et mélange réseau et couches. ADD app.tar.gz /opt/ décompresse sans que le lecteur du Dockerfile s’y attende. Deux sources de surprises, zéro bénéfice dans un build d’application.
La règle qu’on attend : COPY par défaut. Si vous devez extraire un archive, un RUN tar le dit explicitement. Si vous devez récupérer un artefact, vous le faites dans le pipeline avant le build, ou avec un outil dont vous pinez la version — pas avec ADD.
Que vous avez déjà lu la documentation officielle et choisi le plus prévisible. Ce n’est pas une question de syntaxe. C’est : savez-vous qu’un Dockerfile se relit sous pression, et que chaque instruction « intelligente » est une instruction ambiguë.
La mention utile : COPY --from d’un build multi-étapes n’a rien à voir avec ADD. C’est COPY, et c’est le bon outil.
Les deux décrivent le processus PID 1, mais pas au même endroit. ENTRYPOINT est l’exécutable. CMD est l’argument par défaut. Concaténés, ils forment la commande réellement lancée.
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8080"]docker run image lance python app.py --port 8080. docker run image --port 9090 remplace CMD, pas ENTRYPOINT : vous obtenez python app.py --port 9090. Pour remplacer l’exécutable, il faut --entrypoint.
La forme exec (["cmd", "arg"]) est celle qu’on veut : le binaire est PID 1, les signaux (SIGTERM) lui arrivent. La forme shell (CMD python app.py) enveloppe tout dans /bin/sh -c. Votre application n’est plus PID 1 : un docker stop peut la tuer brutalement après le délai, sans lui laisser le temps de se terminer.
Que vous savez ce que docker run image args remplace. Beaucoup de candidats inversent les deux mots, ou croient que CMD est « la commande » et ENTRYPOINT « le hook ». La relance classique : « comment je passe un flag à ce conteneur sans reconstruire l’image ? » — en laissant CMD porter les défauts.
Si vous ajoutez : je n’utilise la forme shell que si j’ai vraiment besoin d’un shell — variables expansées, pipes — vous montrez que vous avez déjà débogué un arrêt qui ne se terminait pas.
ARG vit au build. Il n’existe pas dans le conteneur lancé, sauf si vous le recopiez dans un ENV. Vous le passez avec docker build --build-arg VERSION=1.4. Un ARG déclaré avant le FROM sert uniquement à paramétrer l’image de base.
ENV vit dans l’image. Il est visible au runtime (docker inspect, printenv dans le processus), hérité par chaque RUN suivant, et souvent surchargé par -e ou un fichier d’environnement Compose.
ARG APP_VERSION=0.0.0
ENV APP_VERSION=$APP_VERSIONCette paire est légitime pour une version non secrète. Elle est un piège pour un mot de passe : l’ARG apparaît dans l’historique du build, l’ENV dans l’image poussée. Ni l’un ni l’autre n’est un coffre.
Que vous séparez temps de construction et temps d’exécution. La confusion produit deux défauts fréquents : une variable de runtime oubliée parce qu’elle n’était qu’un ARG, ou un secret cuit dans l’image « pour que ça marche partout ».
La relance : « comment je change le niveau de log sans rebuild ? » — ENV avec une valeur par défaut, puis surcharge au docker run. Pas un ARG.
Séparer l’atelier et le produit : compiler avec tout l’outillage, ne livrer que le binaire et son runtime. Moins de surface, moins de poids, un cache de dépendances qui survit au code.
Lire la réponse détailléeÀ réduire le contexte de build : l’arbre de fichiers envoyé au démon (ou à BuildKit) avant même la première instruction. Sans lui, vous envoyez .git/, node_modules/, target/, des dumps, parfois un .env.
Deux conséquences, toutes les deux visibles en entretien :
COPY . . emporte ce que le ignore n’a pas écarté : clés, fichiers d’environnement, artefacts déjà compilés. Même sans COPY de ces chemins, un contexte énorme reste un coût et une habitude sale.Un ignore minimal, à adapter :
.git
**/node_modules
**/target
**/.env*
*.mdQue vous ne le présentez pas comme « un .gitignore pour Docker ». L’intention se ressemble ; la cible est le contexte, pas le dépôt. Un fichier versionné peut devoir être dans l’image ; un fichier secret ne doit être ni dans l’image ni dans le contexte.
La phrase qui clôt : si ce n’est pas nécessaire au COPY, ça n’a rien à faire dans le contexte.
Par couches, dans cet ordre — du plus rentable au plus cosmétique :
*-slim, JRE plutôt que JDK, distroless si vous assumez l’absence de shell. Alpine n’est pas gratuit : musl casse certains binaires glibc. Le citer sans cette réserve est un signal junior..dockerignore : .git, caches, docs, binaires déjà construits.apt-get update && apt-get install --no-install-recommends && rm -rf /var/lib/apt/lists/* dans un seul RUN. Une couche plus tard, les listes sont déjà gravées.COPY . . puis un RUN rm ne retire rien du poids de la couche précédente.La taille se lit avec docker images et, pour comprendre quoi, docker history ou un analyseur de couches (dive). Sans ça, on « optimise » au hasard.
Que vous avez une méthode, pas une religion Alpine. Il veut entendre qu’est-ce qui est nécessaire à l’exécution, puis la preuve (historique des couches). La recette Java est le cas d’école : 900 Mo à ~200 Mo sans changer une ligne métier.
Parce que root dans le conteneur est encore root pour tout ce que le processus touche : son système de fichiers, ses sockets, et — si un jour l’isolation lâche — trop de choses sur l’hôte. Une faille d’application en root devient une faille d’administration. En utilisateur dédié, elle reste une faille d’application.
RUN useradd --system --uid 10001 --create-home app
USER appLes détails qui montrent que vous l’avez déjà fait :
/tmp, un volume). Sinon vous « corrigez » en restant root.NET_BIND_SERVICE, pas le UID 0.runAsNonRoot et un UID numérique dans l’image. Un USER app sans UID stable casse un cluster qui refuse les noms.Que USER n’est pas une ligne décorative en bas du fichier. C’est une réduction de privilège, et elle impose de penser aux chemins en écriture. La mauvaise réponse : « de toute façon le conteneur est isolé ». L’isolation est une couche, pas une autorisation.
Chaque couche est un diff conservé. Un secret copié puis « effacé » dans un RUN suivant est toujours dans l’historique. docker history et un export le ressortent.
Lire la réponse détailléeParce que latest n’est pas une version. C’est un pointeur mobile : le même nom peut désigner une autre image demain, sans que votre manifeste Kubernetes, votre Compose ou votre collègue n’ait rien changé. Deux hôtes qui font docker pull monapp:latest à cinq minutes d’intervalle n’ont aucune garantie d’avoir le même contenu.
Un tag immuable (1.4.2, un SHA de commit) est déjà mieux, et encore imparfait : n’importe qui avec le droit de push peut retaguer 1.4.2. L’identifiant qui ne ment pas est le digest :
monapp@sha256:4f3c… # contenu, pas un surnomEn production on déploie le digest (ou un tag et le digest vérifié par la CI). Le tag reste pour les humains ; le digest est ce que l’orchestrateur tire.
Que vous reliez ça à la reproductibilité et à la chaîne d’approvisionnement, pas à une lubie de naming. La relance : « on a le même tag en recette et en prod, et prod ne se comporte pas comme recette » — très souvent, ce n’était pas la même image. docker image inspect sur le RepoDigests tranche en trente secondes.
Les deux font survivre des fichiers au cycle de vie du conteneur. Ils ne sont pas administrés pareil.
Un volume nommé est un objet Docker (docker volume create). Docker choisit l’emplacement sur l’hôte, gère les permissions de base, le laisse inspectable (docker volume ls) et le rend raisonnablement portable d’un moteur à l’autre. C’est le bon défaut pour une base, un dépôt d’uploads, un cache à conserver.
Un bind mount colle un chemin de l’hôte (/home/dev/projet, C:\data) dans le conteneur. Idéal en développement : vous éditez sur la machine, le processus voit le fichier tout de suite. En production, c’est une dépendance à la topologie de l’hôte — chemins Windows contre Linux, SELinux, utilisateurs numériques qui ne correspondent pas.
Le troisième, à citer : tmpfs, mémoire uniquement, rien sur disque. Utile pour un secret dépaqueté ou un cache sensible.
Ce qu’un volume n’est pas : une sauvegarde. Personne ne le versionne pour vous. Et monter le code de production en bind « pour patcher vite » reconstitue le marche sur ma machine.
Que vous choisissez selon qui possède le chemin. L’application possède un volume. Le développeur possède un bind. Confondre les deux, c’est soit un service qui écrit dans une couche éphémère et « perd ses données au redémarrage », soit un déploiement qui suppose que /opt/app existe sur chaque nœud.
bridge est le défaut, et le bon défaut. Le conteneur a son empilement réseau, Docker fait du NAT vers l’hôte. Sur le bridge par défaut, les conteneurs se joignent par IP, pas par nom. Sur un bridge défini par l’utilisateur (docker network create ou Compose), la résolution DNS interne fonctionne : http://api:8080. C’est la distinction que beaucoup omettent.
host partage la pile réseau de l’hôte. Plus de -p : écouter 8080 dans le processus, c’est occuper 8080 sur la machine. Moins d’isolement, parfois moins de latence, des collisions de ports garanties dès que deux conteneurs veulent le même. On le réserve à un besoin mesuré (observabilité bas niveau, un démon qui doit voir toutes les interfaces), pas à « j’en avais marre du mapping ».
none : loopback seulement. Pas de sortie, pas d’entrée. Batch isolé, tâche qui ne doit parler à personne, ou étape où vous ajoutez vous-même une interface. Rare, et c’est précisément pour ça qu’on le nomme : pour montrer que « pas de réseau » est un choix.
Que bridge n’est pas un seul objet dans votre tête. Bridge par défaut versus réseau nommé, et le DNS qui va avec, est le vrai test junior-plus. La relance host : « deux conteneurs en host qui écoutent 80 » — l’un des deux ne démarre pas, ou pire, celui qui a gagné le bind.
Compose décrit un graphe de conteneurs sur une machine : réseaux, volumes, variables, démarrage. Il ne remplace pas un orchestrateur dès qu’il faut plusieurs nœuds ou une réparation.
Lire la réponse détailléeQue le processus répond à une sonde que vous avez écrite — pas qu’il existe. Un conteneur « Up » a un PID 1. Un conteneur healthy a, à intervalle régulier, réussi un CMD (souvent curl sur /health ou un pg_isready).
HEALTHCHECK --interval=10s --timeout=3s --retries=3 --start-period=20s \
CMD wget -qO- http://127.0.0.1:8080/health || exit 1Trois états : starting (pendant --start-period), healthy, unhealthy. Compose peut attendre service_healthy. Une politique on-failure ne redémarre pas un processus encore vivant mais maladif : le HEALTHCHECK ne tue pas tout seul. Il étiquette. C’est un orchestrateur, ou un humain, qui décide de la suite.
Ce que la sonde doit tester : une dépendance critique du point de vue de ce processus. Un /health qui renvoie 200 alors que la base est down ment. Un /health trop profond (trois API + le disque) crée des redémarrages en cascade.
Que vous séparez vivant, prêt, et capable de travailler. Docker n’a qu’un HEALTHCHECK ; Kubernetes a liveness, readiness, startup. En entretien Docker, on attend que vous sachiez que « le process tourne » n’est pas un critère métier — et que depends_on sans cette sonde est un mensonge poli.
Parce que sans plafond, un seul processus peut manger l’hôte. Fuite mémoire, requête pathologique, fork bomb involontaire : le voisin — et le démon Docker lui-même — suffoque. Les cgroups existent pour ça ; encore faut-il les régler.
--memory (et l’équivalent Compose mem_limit) fixe un plafond. Au-delà, le noyau tue (OOM killer). Ce n’est pas une exception Java élégante : c’est un SIGKILL. --memory-swap mal compris (égal à la RAM, ou illimité selon les versions et l’hôte) change le moment où ça arrive, pas le principe.
Le CPU est plus souple : --cpus (quota) borne l’usage moyen ; les shares ne s’expriment qu’en cas de contention. Un service « lent » n’est pas forcément limité : il peut simplement n’avoir jamais eu plus d’un demi-cœur, et vous le découvrez le jour où l’hôte est chargé.
Côté JVM / Node / Go, le processus ne voit pas toujours la limite comme sa capacité. Une JVM qui croit avoir 16 Go d’hôte et un cgroup à 512 Mo se fait tuer. Il faut aligner -Xmx (ou les options cgroup-aware) sur la limite.
Que vous parlez d’isolation de voisins et d’OOM, pas de « performance ». La relance : un conteneur qui « disparaît » sans stack trace — allez voir dmesg / les événements OOM, pas seulement docker logs. Et que limiter n’est utile que si la limite est cohérente avec ce que le runtime croit avoir.
Sur la sortie standard et l’erreur standard du PID 1. docker logs <conteneur> lit ce que le logging driver a collecté (souvent json-file sur le poste, parfois journald ou un driver distant).
La règle à énoncer : l’application écrit sur stdout/stderr, pas dans un fichier oublié dans la couche. Un log dans /var/log/app.log disparaît avec le conteneur, n’apparaît pas dans docker logs, et remplit le disque du writable layer. En production, un agent (Loki, Fluent Bit, le driver) prend stdout ; ce n’est plus votre problème de rotation locale — encore faut-il que le driver ait une rotation (max-size, max-file sur json-file), sinon c’est l’hôte qui sature.
Pour un processus déjà mort : docker logs marche encore tant que le conteneur n’est pas rm. docker inspect donne le code de sortie. Si vous avez fait docker rm -f trop vite, vous avez jeté la scène.
Le réflexe 12-factor, pas la mémorisation des drivers. Si vous ajoutez : je ne fais pas docker exec pour ouvrir un fichier de log en première intention — vous avez déjà débogué un conteneur qui meurt au démarrage.
Celle qui correspond à qui a le droit d’arrêter le service, pas « always parce que c’est plus sûr ».
no : défaut. Le processus meurt, il reste mort. Correct pour un job one-shot et pour le debug.on-failure[:n] : redémarre seulement si le code de sortie n’est pas 0. Un exit 0 volontaire (migration finie, tâche cron) ne boucle pas.always : redémarre quoi qu’il arrive, y compris après un stop, quand le démon Docker redémarre. Au reboot de l’hôte, le conteneur revient même si vous l’aviez arrêté à la main hier.unless-stopped : comme always, sauf si vous l’avez arrêté explicitement. C’est souvent le bon défaut d’un service long sur une VM.Aucune de ces politiques ne remplace une sonde. Un processus zombie qui « tourne » sans répondre reste Up. Et un crash en boucle (always + binaire cassé) est un ventilateur de logs, pas de la haute disponibilité.
Que vous connaissez la différence always / unless-stopped au redémarrage du démon. C’est la question piège. La phrase de clôture : la politique décide quoi faire d’un exit ; elle ne décide pas si le service est sain.
docker attach se branche sur le stdio du PID 1 : ce que le processus principal lit et écrit. Utile pour une appli vraiment interactive que vous avez lancée au premier plan. Dangereux le jour où vous faites Ctrl+C : selon les flags (-t, --sig-proxy), vous envoyez un signal au processus principal et vous tuez le conteneur.
docker exec lance un autre processus dans les mêmes namespaces (fichiers, réseau, PID). docker exec -it web sh ouvre un shell à côté de l’application. L’application n’est pas le shell. Quitter le shell ne quitte pas le service.
En entretien, le réflexe sain : exec pour inspecter, attach presque jamais sur un service. Et exec n’est pas une porte d’exploitation permanente : pas de shell dans une image distroless, et ce n’est pas un mal.
Que vous savez quel processus reçoit vos touches. La confusion attach/exec est le classique qui produit un incident de prod (« j’étais juste connecté »). Si vous mentionnez exec en lecture (ls, wget localhost) plutôt qu’installer des paquets dans le conteneur vivant, vous montrez que vous ne réparez pas une image à la main.
Il publie un port du conteneur sur une adresse de l’hôte. -p 8080:80 signifie : depuis l’hôte (et souvent depuis le LAN), hôte:8080 est NATé vers le port 80 dans le réseau du conteneur. Le processus, lui, écoute 80 à l’intérieur. Inverser les deux nombres est l’erreur de début de stage.
EXPOSE 80 ne publie rien. C’est de la documentation, et un défaut pour -P (port hôte aléatoire). Beaucoup de Dockerfile exposent un port que personne n’a mappé : le service parle en interne, pas depuis votre navigateur.
Le bind compte. -p 127.0.0.1:8080:8080 n’écoute que sur la loopback de l’hôte. -p 8080:8080 écoute en général sur 0.0.0.0 : tout le monde sur le réseau de la machine. Sur un laptop de café, ce n’est pas le même risque.
En network_mode: host, le mapping n’a plus de sens : il n’y a plus de NAT. En bridge nommé, les conteneurs se parlent par nom sans -p ; le mapping est pour le monde extérieur.
Que vous séparez écouter, exposer et publier. La relance : « ça marche en exec curl localhost et pas depuis mon navigateur » — presque toujours un -p manquant, un bind hôte sur 127.0.0.1, ou un process qui n’écoute que 127.0.0.1 dans le conteneur. Dans ce dernier cas le mapping ne sauve rien : Docker NAT vers l’interface du conteneur, pas vers sa loopback, et le processus refuse le paquet. C’est une heure de perdue une fois ; on la raconte.
L’intérêt : il n’y a presque plus d’OS. Pas de gestionnaire de paquets, souvent pas de shell, une surface CVE réduite à la libc (ou à rien) et à votre runtime. scratch va plus loin : image vide, réservée aux binaires vraiment autonomes (Go, Rust liés statiquement). Distroless (Debian ou autre, dépouillé) garde juste assez pour une JVM ou un interpréteur.
Les limites, à citer sans qu’on vous les tire :
docker exec plus un bash. Le debug passe par les logs, un sidecar, une variante -debug de la même image, ou un ephemeral container côté Kubernetes. Ce n’est pas un inconvénient : c’est le contrat.CGO_ENABLED=1 lié à glibc ne démarre pas sur scratch. Un Alpine (musl) n’est pas un Distroless Debian.scratch non. Oublier ca-certificates est le classique HTTPS qui « marche en local » sur une image grasse.CMD-SHELL) tombent. Il faut un binaire présent, ou déplacer la sonde hors de l’image.Que vous ne récitez pas « Distroless c’est plus sécure » comme un autocollant. Vous savez ce que vous perdez (debug, outils, parfois glibc) et ce que vous gagnez (moins de paquets à patcher). La phrase senior : on choisit Distroless quand le pipeline de debug existe déjà ; on ne l’impose pas pour cacher un build qu’on ne sait pas lire.
BuildKit est le moteur de build moderne (le défaut depuis Docker 23). Le builder historique enchaînait les instructions en série, avec un cache de couches assez naïf. BuildKit ajoute ce qui change vraiment un pipeline :
FROM indépendantes se construisent en même temps.--mount=type=cache,target=/root/.m2 garde le dépôt Maven hors des couches, d’un build à l’autre, sans le livrer dans l’image.--mount=type=secret et type=ssh : le jeton n’entre pas dans l’historique. C’est la réponse propre à un secret dans les couches.# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.m2 mvn -B packageSans la première ligne # syntax=, certaines syntaxes de montage ne sont pas garanties.
Que vous ne dites pas « BuildKit, c’est plus rapide » et vous arrêtez. La vitesse vient de décisions (cache mounts, étapes parallèles), pas d’un bouton. La relance : un cache mount n’invalide pas comme une couche — c’est un disque partagé. Utile, et dangereux si vous y laissez un artefact corrompu que plus personne ne reconstruit.
Le socket parle à l’API du démon. Qui le tient peut lancer un conteneur privilégié, monter le disque de l’hôte, et en pratique devenir root sur la machine.
Lire la réponse détailléeVous ne « patchez pas Docker ». Vous triez, puis vous reconstruisez.
Un scan (Trivy, Grype, le scanner du registre) liste des CVE dans les paquets de la base, dans vos dépendances applicatives, parfois dans un binaire copié. La majorité n’est pas exploitable dans votre surface : une lib de CLI absente du runtime, un paquet que le process n’ouvre jamais, un vecteur local alors que le conteneur n’a pas de shell. La minorité l’est : une lib HTTP dans le PID 1, une crypto utilisée au démarrage.
L’ordre de travail, celui qu’on attend :
FROM), étage builder oublié dans l’image finale, ou vos deps (package-lock, pom.xml). Le remède n’est pas le même.distroless, multi-étapes), fermer le capabilité, limiter le réseau. Documenter le risque. Ne pas le cacher sous un badge vert.Un scan qui échoue la CI sur tout Critical sans tri produit des exceptions silencieuses au bout d’un mois. Un scan informatif sans seuil ne produit rien.
Que vous avez un processus, pas une peur. La mauvaise réponse : « on bloque le merge dès qu’il y a un CVE ». L’autre mauvaise : « on scanne une fois par an ». La bonne cite la provenance du paquet et le fait qu’une image n’est saine que jusqu’au prochain advisory — d’où des rebuilds de base réguliers, même sans commit métier.
Une machine virtuelle emporte un noyau invité. L’hyperviseur simule du matériel ; l’OS à l’intérieur croit avoir une machine. Isolation forte, démarrage lent, densité faible, patch de noyau par VM.
Un conteneur emprunte le noyau de l’hôte. Ce que Docker (via runc, les cgroups, les namespaces) isole, ce sont des vues : processus, réseau, montage, utilisateurs. Le filesystem de l’image n’est pas un disque de VM : c’est un assemblage de couches. Démarrage en secondes, dizaines ou centaines d’instances sur la même machine, un seul noyau à attaquer.
D’où les conséquences qu’on veut entendre :
CAP_SYS_ADMIN mal donné : le rayon est l’hôte, pas « cette VM ».Dockerfile. FROM ubuntu n’installe pas un noyau Ubuntu ; il installe userland Ubuntu sur le noyau de l’hôte.Le mot noyau partagé. Tout le reste — « plus léger », « plus rapide » — en découle et ne suffit pas. Si vous ajoutez qu’un conteneur n’est pas une frontière de sécurité équivalente à une VM, vous évitez le discours marketing que le recruteur a déjà entendu dix fois ce matin-là.
Docker fabrique et peut faire tourner l’image. Kubernetes place des réplicas, les soigne, leur donne une adresse stable. Ils s’enchaînent ; ils ne se concurrencent pas.
Lire la réponse détailléeParce que ce n’était souvent pas la même image, ou pas le même contrat autour. « Ça marche chez moi » décrit un poste, pas un artefact.
Les causes, dans l’ordre où je les cherche :
latest ou dev n’est pas le même digest sur les deux machines. docker image inspect / le digest du registre tranche. C’est latest contre digest.amd64 sur un Mac ARM, ou l’inverse, avec de l’émulation qui passe en local et explose en CI x86 — ou un binaire natif absent.$HOME/.aws, un .env chargé par Compose et absent du pipeline, un secret dans votre shell. Ailleurs, le fichier n’est pas là ; le process meurt ou parle à la mauvaise API.network_mode: host, localhost qui désigne votre laptop, un VPN. Dans le pipeline, localhost est le runner.La méthode : comparer digest, plateforme, variables, montages, commande. Pas relancer en boucle en priant.
Que votre premier réflexe n’est pas « Docker est non déterministe ». Docker est déterministe à image et à contrat égaux. C’est le poste qui ne l’est pas. Si vous proposez de reproduire avec le même docker run que la CI (mêmes flags, même digest), vous avez la discipline.
On lit le code de sortie et les logs du PID 1 avant d’ouvrir un shell. La plupart des morts au boot sont une commande fausse, un fichier absent, ou un process qui n’est pas prévu pour être PID 1.
Lire la réponse détailléeJe sépare les deux symptômes. Ils se soignent à l’opposé.
Cache cassé (tout rebuild à chaque fois). Je lis quelle instruction est le premier miss : docker build --progress=plain. Le coupable habituel est un COPY . . trop haut, un .dockerignore absent (le .git change à chaque commit), un ARG déclaré tôt et passé systématiquement (un timestamp, un SHA) qui invalide toute la suite, ou une CI sans cache (--no-cache copié-collé, runner sans BuildKit cache export). Le remède : réordonner le Dockerfile, ignorer le bruit, descendre les ARG volatils sous les couches stables, brancher un cache de registre.
Cache trop agressif (l’image « marche » avec d’anciennes deps). npm ci réutilisé alors que le lock a été mal copié ; un RUN apt-get update datant de trois semaines ; un cache mount BuildKit qui sert un ~/.m2 corrompu ou incomplet ; une base FROM ubuntu:latest que personne n’a re-tirée. Le remède n’est pas --no-cache en permanence — ça masque le Dockerfile mal écrit. C’est : hasher ce qui doit invalider (le lockfile dans le COPY), piner les bases, et, pour les index apt, accepter une invalidation contrôlée (un ARG CACHE_BUST daté en CI nocturne, pas à chaque PR).
# L’ARG volatil après les couches de deps, pas avant.
COPY package-lock.json ./
RUN npm ci
ARG VCS_REF
LABEL org.opencontainers.image.revision=$VCS_REFQue vous diagnostiquez avant de couper le cache. --no-cache est un rustine de pipeline, pas une architecture. La phrase senior : le cache est correct s’il rate quand l’entrée métier change, et seulement alors. Si vous nommez BuildKit (--cache-from, cache mounts) sans accuser le moteur au premier miss, vous avez déjà tenu une CI.
Une base pinée, un ignore, des couches ordonnées, un multi-étapes, un USER non root, aucun secret en couche, un process en forme exec. Le reste est du confort.
Lire la réponse détaillée