Aller au contenu
Le cache de BuildKit en profondeur

Le cache de BuildKit en profondeur

300 Concevoir ⏱ 1 h 10 dockerbuildkitpython

À la fin, vous saurez

  • Expliquer la clé de cache d'une instruction et prévoir ce qui l'invalide
  • Utiliser RUN --mount=type=cache pour pip et apt, en connaissant le piège des images Debian
  • Découpler une couche de ses précédentes avec COPY --link
  • Exporter et importer le cache (registry, local, inline) et choisir entre mode=min et mode=max
  • Diagnostiquer une construction qui n'utilise pas le cache

Prérequis

Testé avec buildkit 0.33.1 (builders docker-container ; 0.33.0 intégré au démon) buildx 0.37.1 docker 29.8.1 python 3.14.7 registry 3 , vérifié le 1 octobre 2026

Pourquoi

Sur votre poste, une construction Docker est rapide dès la deuxième fois : le cache de BuildKit (leçon 8 du cours précédent) réutilise tout ce qui n'a pas changé. En intégration continue, c'est l'inverse. Chaque tâche démarre sur un agent neuf, sans aucun cache, et l'image de Signalements avec psycopg[c] (leçon 2) met plus de 80 secondes à se construire, dont plus de 20 à recompiler un module qui ne change jamais. Multipliez par le nombre de constructions quotidiennes d'une équipe, et le cache devient une question de productivité, de coût (les minutes de CI se paient) et de rapidité de correction en cas d'incident.

Le cache est aussi une source de surprises : une construction qui « ne prend plus le cache » sans raison apparente, une dépendance mise à jour qui n'apparaît pas parce qu'une couche ancienne a été réutilisée. Cette leçon ouvre la boîte : comment BuildKit calcule ce qu'il peut réutiliser, les trois outils qui changent la donne (montages de cache, COPY --link, export du cache), et la méthode pour diagnostiquer.

Les concepts

La clé de cache d'une instruction

Pour chaque instruction, BuildKit calcule une clé de cache à partir de :

  • la clé de l'étape précédente (d'où l'effet en cascade : une instruction invalidée invalide toutes les suivantes de la même étape) ;
  • le texte de l'instruction et ses options (RUN apt-get update est identique aujourd'hui et dans six mois, quelle que soit la réponse du miroir) ;
  • pour COPY et ADD, l'empreinte du contenu des fichiers copiés (pas leur date de modification) ;
  • les arguments de construction (ARG) déclarés avant l'instruction, qui sont passés aux RUN comme variables d'environnement ;
  • l'empreinte de l'image de base, pour FROM.

Si une entrée de cache existe pour cette clé, l'instruction est marquée CACHED et son résultat réutilisé ; sinon, elle est exécutée.

Trois portées du cache

OutilCe qui est réutiliséOù il vit
Cache de couches (implicite)Le résultat entier d'une instructionDans le builder
Montage de cache (RUN --mount=type=cache)Un répertoire partagé entre constructions, même quand l'instruction est réexécutéeDans le builder, jamais dans l'image
Cache exporté (--cache-to, --cache-from)Le cache de couches, transporté hors du builderRegistre, répertoire, service de cache de la CI

Le cache de couches est du tout ou rien : une instruction est réutilisée entièrement ou réexécutée entièrement. Le montage de cache adoucit le second cas : quand pip install doit être relancé parce que requirements.txt a changé, il retrouve les paquets déjà téléchargés et compilés.

Les builders

Le builder par défaut (pilote docker) utilise le BuildKit intégré au démon Docker et partage le magasin d'images de Docker. On peut créer d'autres builders, notamment avec le pilote docker-container, qui fait tourner BuildKit dans un conteneur dédié : il a son propre cache, isolé, et il sait exporter le cache vers un registre en mode complet, ce que le pilote docker ne sait pas faire dans tous les cas. C'est aussi ce qu'utilisent la plupart des chaînes de CI (l'action docker/setup-buildx-action en crée un). Toutes les mesures de cette leçon utilisent un tel builder, pour partir d'un cache vide et maîtrisé.

$ docker buildx create --name cours --driver docker-container
$ docker buildx inspect cours --bootstrap
...
Name:          cours
Driver:        docker-container
...
Status:                running
BuildKit version:      v0.33.1

En pratique

Le point de départ

Prenons le Dockerfile final de la leçon 2 (étapes construction, test, execution), et construisons-le avec ce builder neuf. --load charge l'image résultante dans Docker (un builder docker-container ne partage pas le magasin d'images du démon).

$ /usr/bin/time -f "à froid : %e s" docker buildx build --builder cours --progress=plain -t signalements:1 --load .
...
à froid : 82.01 s
$ /usr/bin/time -f "sans changement : %e s" docker buildx build --builder cours --progress=plain -t signalements:1 --load .
...
sans changement : 1.79 s

82 secondes à froid, dont près de 50 pour télécharger les images de base dans le builder : son cache est distinct de celui de Docker, il ne profite pas des images déjà présentes sur la machine. Puis moins de 2 secondes quand rien ne change. Ajoutons maintenant une dépendance :

$ echo "requests==2.32.5" >> requirements.txt
$ /usr/bin/time -f "une dépendance ajoutée : %e s" docker buildx build --builder cours --progress=plain -t signalements:1 --load .
...
#12 4.637 Building wheels for collected packages: psycopg-c
#12 4.638   Building wheel for psycopg-c (pyproject.toml): started
#12 27.86   Building wheel for psycopg-c (pyproject.toml): finished with status 'done'
#12 DONE 29.0s
une dépendance ajoutée : 31.52 s

requirements.txt a changé, l'instruction pip install est invalidée et rejouée entièrement : 23 secondes pour recompiler psycopg, qui n'a pourtant pas changé. Le cache de couches ne sait pas faire mieux.

RUN --mount=type=cache : un cache qui survit à l'invalidation

FROM python:3.14 AS construction
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PIP_ROOT_USER_ACTION=ignore
RUN python -m venv --without-pip /opt/venv
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip --python /opt/venv/bin/python install -r requirements.txt

Deux changements par rapport à la leçon 2 :

  • RUN --mount=type=cache,target=/root/.cache/pip monte, pendant l'exécution de cette seule instruction, un répertoire persistant du builder sur /root/.cache/pip, l'emplacement où pip garde ses téléchargements et les roues qu'il a compilées ;
  • PIP_NO_CACHE_DIR=1 a disparu : il désactivait justement ce cache. Il n'a plus de raison d'être, puisque le répertoire monté n'entre pas dans la couche : l'image ne grossit pas.

Le premier passage remplit le cache (et recompile psycopg une dernière fois). Ajoutons ensuite une autre dépendance :

$ echo "python-dateutil==2.9.0.post0" >> requirements.txt
$ /usr/bin/time -f "durée : %e s" docker buildx build --builder cours --progress=plain -t signalements:2 --load .
...
#12 0.967   Using cached psycopg-3.3.6-py3-none-any.whl.metadata (4.4 kB)
#12 1.146 Collecting psycopg-c==3.3.6 (from psycopg[c]==3.3.6->-r requirements.txt (line 3))
#12 1.146   Using cached psycopg_c-3.3.6-cp314-cp314-linux_x86_64.whl
#12 1.154 Using cached psycopg-3.3.6-py3-none-any.whl (215 kB)
#12 1.248 Installing collected packages: urllib3, six, psycopg-c, psycopg, markupsafe, itsdangerous, idna, gunicorn, click, charset_normalizer, certifi, blinker, werkzeug, requests, python-dateutil, jinja2, flask
...
#12 DONE 2.2s
durée : 4.77 s

L'instruction est toujours rejouée (la couche est invalidée), mais pip trouve dans le cache la roue psycopg_c-3.3.6-cp314-cp314-linux_x86_64.whl qu'il avait compilée : 4,8 secondes au lieu de 31,5. Et l'environnement virtuel copié dans l'image ne contient aucune trace du cache :

$ docker history signalements:2 --format '{{.Size}}\t{{.CreatedBy}}' | grep venv
30.4MB	COPY /opt/venv /opt/venv # buildkit

Le cache apt et le piège des images Debian

Le même principe vaut pour apt, avec une subtilité. Les images Debian et Ubuntu officielles contiennent un fichier qui vide le cache d'apt après chaque installation :

$ docker run --rm python:3.14-slim cat /etc/apt/apt.conf.d/docker-clean
...
DPkg::Post-Invoke { "rm -f /var/cache/apt/archives/*.deb /var/cache/apt/archives/partial/*.deb /var/cache/apt/*.bin || true"; };
APT::Update::Post-Invoke { "rm -f /var/cache/apt/archives/*.deb /var/cache/apt/archives/partial/*.deb /var/cache/apt/*.bin || true"; };

Dir::Cache::pkgcache "";
Dir::Cache::srcpkgcache "";
...

C'est un bon réglage par défaut (les paquets téléchargés ne finissent pas dans les couches), mais il rend inutile un montage de cache : les .deb seraient supprimés aussitôt téléchargés. La recette documentée par Docker retire ce fichier et demande à apt de garder ses paquets :

# syntax=docker/dockerfile:1
FROM python:3.14-slim
ARG PAQUETS="libpq5"
RUN rm -f /etc/apt/apt.conf.d/docker-clean \
 && echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update \
 && apt-get install -y --no-install-recommends $PAQUETS

Deux montages : /var/cache/apt pour les paquets téléchargés, /var/lib/apt pour les listes de paquets (on ne fait donc plus de rm -rf /var/lib/apt/lists/*, puisque ce répertoire n'est plus dans la couche). L'option sharing=locked empêche deux constructions parallèles d'utiliser le même cache apt en même temps, ce qu'apt ne supporte pas.

Comparons deux installations successives, la seconde ajoutant curl (l'option -o type=cacheonly construit sans exporter d'image, pratique pour mesurer) :

$ docker buildx build --builder cours -f Dockerfile.sans --build-arg PAQUETS="libpq5 postgresql-client-17" -o type=cacheonly .
...
#7 1.693 Fetched 10.0 MB in 2s (6366 kB/s)
#7 4.821 Fetched 10.9 MB in 1s (10.2 MB/s)
sans [libpq5 postgresql-client-17] : 7.78 s
$ docker buildx build --builder cours -f Dockerfile.sans --build-arg PAQUETS="libpq5 postgresql-client-17 curl" -o type=cacheonly .
...
#7 1.582 Fetched 10.0 MB in 1s (6858 kB/s)
#7 5.166 Fetched 14.9 MB in 2s (9485 kB/s)
sans [libpq5 postgresql-client-17 curl] : 8.90 s
$ docker buildx build --builder cours -f Dockerfile.avec --build-arg PAQUETS="libpq5 postgresql-client-17" -o type=cacheonly .
...
#8 1.634 Fetched 10.2 MB in 2s (6785 kB/s)
#8 3.880 Fetched 10.9 MB in 1s (10.1 MB/s)
avec [libpq5 postgresql-client-17] : 6.92 s
$ docker buildx build --builder cours -f Dockerfile.avec --build-arg PAQUETS="libpq5 postgresql-client-17 curl" -o type=cacheonly .
...
#8 2.139 Fetched 4004 kB in 0s (9043 kB/s)
avec [libpq5 postgresql-client-17 curl] : 6.15 s

(Durées prises avec /usr/bin/time ; sorties abrégées.) Sans montage, la seconde construction retélécharge tout : 10 Mo de listes et 14,9 Mo de paquets. Avec montage, seuls les 4 Mo de curl et de ses dépendances sont téléchargés. Sur une connexion rapide vers un miroir Debian proche, le gain en temps reste modeste (2,7 secondes) ; derrière un proxy d'entreprise lent, ou pour une longue liste de paquets, il devient considérable, et il soulage les miroirs.

COPY --link : une couche indépendante de ce qui la précède

Par défaut, une couche produite par COPY dépend de toutes les couches précédentes : si l'une d'elles change, la copie est rejouée, et la couche recalculée et recompressée, même si les fichiers copiés sont identiques. L'option --link crée la couche indépendamment de ce qui précède, comme si elle était posée sur une image vide, puis la superpose. Mesurons avec un fichier de 300 Mo, après la modification d'une instruction précédente :

# syntax=docker/dockerfile:1
FROM alpine:3.22
ARG VERSION=1
RUN echo "$VERSION" > /version
COPY gros.bin /donnees/gros.bin
$ docker buildx build --builder cours --progress=plain -t essai-link --load --build-arg VERSION=a .
$ /usr/bin/time -f "après changement de la couche précédente : %e s" docker buildx build --builder cours --progress=plain -t essai-link --load --build-arg VERSION=b .
...
#9 [3/3] COPY  gros.bin /donnees/gros.bin
#9 DONE 0.2s
#10 exporting layers 3.3s done
après changement de la couche précédente : 7.03 s

Avec COPY --link gros.bin /donnees/gros.bin :

...
#9 [3/3] COPY --link gros.bin /donnees/gros.bin
#9 DONE 0.0s
#10 exporting layers 0.0s done
après changement de la couche précédente : 3.41 s

La couche de 300 Mo n'a été ni recopiée ni recompressée : BuildKit a réutilisé la même couche telle quelle. Cela permet aussi, au moment de pousser, de ne pas renvoyer une couche identique, et de « rebaser » une image sur une nouvelle version de son image de base sans reconstruire les couches liées. La condition : la copie ne doit pas dépendre du contenu des couches précédentes. Par exemple, un lien symbolique présent dans le chemin de destination n'est pas suivi, puisque la copie est faite sur un système de fichiers vide.

Exporter le cache pour la CI

Le cache d'un builder meurt avec lui. Pour qu'un agent de CI neuf en profite, il faut l'exporter à la fin d'une construction et l'importer au début de la suivante. Simulons des agents successifs : chacun est un builder neuf, configuré pour joindre un registre local en HTTP.

$ docker run -d --name registre -p 127.0.0.1:5000:5000 registry:3
$ cat buildkitd.toml
[registry."127.0.0.1:5000"]
  http = true
$ docker buildx create --name agent1 --driver docker-container \
    --driver-opt network=host --buildkitd-config buildkitd.toml

(Dans les sorties de cette leçon, les noms des builders et le port du registre ont été simplifiés ; sur la machine de test, ils portaient un préfixe et un port propres à l'expérience.) L'option network=host donne au conteneur BuildKit l'accès au réseau de l'hôte (sinon 127.0.0.1 y désignerait le conteneur lui-même) ; buildkitd.toml autorise ce registre sans TLS, ce qu'on ne fait que pour un registre local de test.

Agent 1, sans cache, qui pousse l'image et exporte son cache en mode complet :

$ /usr/bin/time -f "agent 1, sans cache : %e s" docker buildx build --builder agent1 --progress=plain \
    -t 127.0.0.1:5000/signalements:1 --push \
    --cache-to type=registry,ref=127.0.0.1:5000/signalements:cache,mode=max .
...
#18 preparing build cache for export 0.8s done
#18 writing cache image manifest sha256:8c9c7caa072c898913eb754a12090f89c1384fcb5279ab774ca8616b6785f4df 0.0s done
agent 1, sans cache : 84.18 s

Agent 2, neuf, qui importe ce cache :

$ /usr/bin/time -f "agent 2, cache importé : %e s" docker buildx build --builder agent2 --progress=plain \
    -t 127.0.0.1:5000/signalements:2 --push \
    --cache-from type=registry,ref=127.0.0.1:5000/signalements:cache .
...
#7 importing cache manifest from 127.0.0.1:5000/signalements:cache
...
agent 2, cache importé : 4.47 s

De 84 secondes à 4,5 secondes, sur un builder qui n'avait jamais vu ce projet. Le cache est stocké dans le registre comme un artefact OCI à part, à côté de l'image.

mode=min ou mode=max

L'export accepte deux modes :

  • mode=min (par défaut) n'exporte que les couches de l'image finale ;
  • mode=max exporte les couches de toutes les étapes, y compris les étapes intermédiaires comme construction.

Exportons, depuis un agent neuf et à froid, un cache en mode=min, et comparons sa taille au cache mode=max :

$ for t in cache cache-min2; do printf "%s : " $t; curl -s -H 'Accept: application/vnd.oci.image.manifest.v1+json' \
    http://127.0.0.1:5000/v2/signalements/manifests/$t | jq -c '{couches: (.layers | length), octets: ([.layers[].size] | add)}'; done
cache : {"couches":18,"octets":482789429}
cache-min2 : {"couches":8,"octets":56199455}

483 Mo et 18 couches contre 56 Mo et 8 couches. Maintenant, un agent neuf construit une version dont l'étape d'exécution change (ajout du paquet tzdata), ce qui oblige à refaire la copie de l'environnement virtuel depuis l'étape construction :

$ /usr/bin/time -f "agent neuf, mode=min, étape d'exécution modifiée : %e s" docker buildx build --builder agent8 \
    --progress=plain -t 127.0.0.1:5000/signalements:t8 --push \
    --cache-from type=registry,ref=127.0.0.1:5000/signalements:cache-min2 .
...
#14 28.75   Building wheel for psycopg-c (pyproject.toml): finished with status 'done'
agent neuf, mode=min, étape d'exécution modifiée : 84.69 s

Avec le cache mode=min, les couches de l'étape construction n'ont pas été exportées : l'agent recompile psycopg et met autant de temps qu'à froid. Avec le cache mode=max, la même construction, sur un autre agent neuf :

$ /usr/bin/time -f "agent neuf, cache, étape d'exécution modifiée : %e s" docker buildx build --builder agent9 \
    --progress=plain -t 127.0.0.1:5000/signalements:t-cache --push \
    --cache-from type=registry,ref=127.0.0.1:5000/signalements:cache .
...
agent neuf, cache, étape d'exécution modifiée : 19.26 s

Le journal détaillé d'un essai identique, sur un autre agent neuf, montre pourquoi :

#13 [construction 4/4] RUN --mount=type=cache,target=/root/.cache/pip     pip --python /opt/venv/bin/python install -r requirements.txt
#13 CACHED
...
#13 DONE 8.7s

L'étape construction est CACHED : elle n'est pas rejouée, son résultat (l'environnement virtuel) est simplement téléchargé du registre, ce que montrent les lignes DONE qui s'allongent sur la même étape. Les 19,3 secondes sont pour l'essentiel des téléchargements. Règle pratique : mode=max pour les Dockerfiles multi-étapes, au prix d'un cache plus volumineux à stocker.

Note

Un premier essai de cette comparaison a donné deux caches identiques : le cache mode=min avait été exporté par un agent qui venait lui-même d'importer le cache mode=max, et qui avait donc toutes les couches à disposition. Pour comparer les modes, il faut les exporter depuis des builders partis de zéro.

Les autres stockages de cache

TypeOùUsage
registryUne étiquette dédiée dans un registreLe plus universel ; fonctionne avec toute CI qui a accès au registre
localUn répertoire (format OCI layout)Cache sur disque persistant d'un agent, ou transporté comme artefact
inlineDans la configuration de l'image elle-mêmeSimple, mais seulement en mode=min
ghaLe service de cache de GitHub ActionsLeçon 10
s3, azblobUn stockage objetAgents auto-hébergés, cache partagé entre dépôts (le stockage objet de Scaleway est compatible S3)
$ docker buildx build --builder cours --progress=quiet \
    --cache-to type=local,dest=../cache-local,mode=max -o type=cacheonly .
$ du -sh ../cache-local
461M	../cache-local
$ ls ../cache-local
blobs
index.json
ingest
oci-layout

On reconnaît la structure de la leçon 7 du cours précédent : un cache BuildKit est un ensemble de blobs OCI.

Sous le capot

Le cache est un graphe, pas une pile. BuildKit indexe le résultat de chaque opération du graphe LLB par sa clé de cache. Quand il importe un cache, il récupère d'abord un manifeste qui décrit ces clés et les couches associées, puis ne télécharge une couche que si une instruction doit utiliser son contenu. C'est pourquoi, dans la construction de l'agent 2, les instructions de l'étape construction sont CACHED sans téléchargement tant que rien n'en dépend, et pourquoi, lorsqu'une étape suivante a besoin de l'environnement virtuel, on voit sa couche se télécharger (DONE qui s'allonge sur la même instruction) au lieu d'être reconstruite.

Les montages de cache ne sont pas des couches. Un montage type=cache est un répertoire géré par le builder, identifié par son id (par défaut sa cible). Ses options font partie de l'instruction, mais son contenu n'entre dans aucune clé de cache et dans aucune couche ; il n'est pas exporté par --cache-to. Sur un agent de CI neuf, il est vide : il accélère les reconstructions sur un même builder, pas entre agents (sauf à persister le builder ou son répertoire de données).

COPY --link change la forme du graphe. Sans --link, l'opération de copie prend en entrée l'état précédent du système de fichiers ; avec --link, elle prend une entrée vide et son résultat est fusionné (merge) avec l'état précédent. La couche produite ne dépend donc plus de ce qui précède, ni pour sa clé de cache, ni pour son contenu.

Pièges courants

Un ARG qui change à chaque construction. On ajoute en tête d'étape un ARG DATE_CONSTRUCTION pour l'inscrire dans une étiquette de l'image, et l'on passe la date du jour à chaque construction :

$ docker buildx build --builder cours --progress=plain -f Dockerfile.arg --build-arg DATE_CONSTRUCTION=2026-10-01T10:00 -o type=cacheonly .
...
#10 [construction 2/4] RUN python -m venv --without-pip /opt/venv
#12 [construction 4/4] RUN --mount=type=cache,target=/root/.cache/pip     pip --python /opt/venv/bin/python install -r requirements.txt
$ docker buildx build --builder cours --progress=plain -f Dockerfile.arg --build-arg DATE_CONSTRUCTION=2026-10-01T10:05 -o type=cacheonly .
...
#10 [construction 2/4] RUN python -m venv --without-pip /opt/venv
#12 [construction 4/4] RUN --mount=type=cache,target=/root/.cache/pip     pip --python /opt/venv/bin/python install -r requirements.txt

Aucune des deux instructions RUN n'est CACHED, alors qu'elles n'utilisent pas DATE_CONSTRUCTION : un argument déclaré est passé comme variable d'environnement à tous les RUN qui suivent sa déclaration, et entre dans leur clé de cache. Ici, le montage de cache pip limite les dégâts ; sans lui, chaque construction recompilerait psycopg. Déclarez les arguments variables au plus tard, juste avant l'instruction qui s'en sert (souvent un LABEL dans l'étape finale), ou utilisez les annotations calculées par l'outillage (leçon 8).

Un contexte qui change sans qu'on le voie. COPY . . invalide le cache dès qu'un fichier du contexte change : un fichier de journal, un .git modifié par chaque commit, un fichier généré par l'éditeur. Le .dockerignore (leçon 8 du cours précédent) est aussi un outil de cache.

Croire que le cache garantit la fraîcheur. RUN apt-get update && apt-get install en cache depuis trois semaines installe des paquets d'il y a trois semaines. Le cache ne sait rien des mises à jour extérieures. Pour récupérer les correctifs, reconstruisez régulièrement avec --pull (nouvelle image de base) et, si nécessaire, --no-cache-filter execution (rejoue une étape précise sans tout invalider).

--cache-to avec le pilote docker. Avec l'ancien stockage d'images de Docker, le builder par défaut ne sait exporter qu'un cache inline, et refuse les autres types. Avec le magasin d'images containerd, par défaut depuis Docker 29, il accepte aussi local et registry (vérifié ici avec un export type=local,mode=max réussi). Sur un hôte mis à jour depuis une version antérieure, qui a gardé l'ancien stockage, ou dans le doute, créez un builder docker-container.

Un cache qui grossit sans fin. Le cache d'un builder s'accumule. docker buildx du --builder <nom> le mesure (5,5 Go pour le builder de cette leçon, après une heure d'essais) ; un builder a une politique de ramasse-miettes réglable dans buildkitd.toml. Sur un cache exporté dans un registre, prévoyez une règle de rétention, ou une étiquette de cache par branche que l'on supprime avec la branche.

Sécurité

  • Le cache est une entrée de la construction. Un attaquant qui peut écrire dans votre cache exporté (étiquette de cache dans le registre, répertoire partagé) peut injecter une couche qui sera réutilisée telle quelle dans vos images, sans passer par votre Dockerfile. Protégez le cache comme l'image : mêmes droits d'écriture restreints, et ne partagez jamais un cache entre projets de niveaux de confiance différents (par exemple entre les demandes de fusion venant de dépôts forkés et la branche principale).
  • Les montages de cache ne fuient pas dans l'image, mais ils persistent dans le builder. N'y mettez pas de secrets ; la leçon suivante présente le montage prévu pour cela (type=secret).
  • Reconstruire sans cache, régulièrement. Une construction complète périodique (--no-cache, ou un agent sans import de cache) garantit que l'image peut toujours être reproduite à partir des sources, et qu'aucune couche ancienne, éventuellement vulnérable, ne survit par inertie.

En production

  • Une étiquette de cache par branche, avec repli sur celle de la branche principale : --cache-from ...:cache-<branche> puis --cache-from ...:cache-main. BuildKit accepte plusieurs --cache-from et prend ce qu'il trouve.
  • mode=max pour les multi-étapes, en surveillant le volume dans le registre ; mode=min peut suffire pour une image en une étape.
  • Mesurez. docker buildx history garde la trace des constructions récentes et de leur durée ; comparer la durée médiane avant et après une modification du Dockerfile évite les optimisations imaginaires.
  • Sur Scaleway, le registre rg.fr-par.scw.cloud peut héberger les étiquettes de cache à côté des images, et le stockage objet (compatible S3) un cache partagé par des agents auto-hébergés. La leçon 10 assemble ces éléments dans un workflow GitHub Actions.

Exercices

1. Prévoir l'invalidation. Dans le Dockerfile final de la leçon, quelles instructions sont rejouées si l'on modifie : (a) app.py ; (b) requirements.txt ; (c) la valeur d'un ARG VERSION déclaré en tête de l'étape execution ; (d) l'image de base python:3.14-slim, publiée dans une nouvelle version, avec --pull ?

Solution

(a) Seulement le COPY app.py final. (b) Le COPY requirements.txt et le pip install de construction, puis le COPY --from et les instructions suivantes d'execution (pas l'apt-get d'execution, qui ne dépend pas de construction). (c) Toutes les instructions RUN d'execution qui suivent la déclaration (l'apt-get), et par cascade toutes les instructions suivantes de cette étape ; construction n'est pas touchée. (d) Toute l'étape execution ; construction aussi si python:3.14 a également changé. Vérifiez chaque cas avec --progress=plain en repérant les lignes CACHED.

2. Un cache pour Go (niveau 200). Écrivez un Dockerfile qui compile signalements-export dans golang:1.26 avec des montages de cache pour le cache de compilation (/root/.cache/go-build) et les modules (/go/pkg/mod). Mesurez une reconstruction après modification d'une ligne de main.go, avec et sans les montages.

Solution
# syntax=docker/dockerfile:1
FROM golang:1.26 AS construction
WORKDIR /src
COPY go.mod ./
COPY *.go ./
RUN --mount=type=cache,target=/root/.cache/go-build \
    --mount=type=cache,target=/go/pkg/mod \
    CGO_ENABLED=0 go build -o /signalements-export .

Mesuré sur la machine de test, après une modification d'une ligne de main.go : 7,1 secondes sans les montages, 1,5 seconde avec. Sans montage, la modification recompile aussi toute la partie de la bibliothèque standard utilisée (net/http, encoding/json...) ; avec le cache de compilation de Go, seul le paquet modifié est recompilé. Sur un vrai projet avec des centaines de dépendances, l'écart se compte en minutes. signalements-export n'a pas de dépendance externe : le cache de modules ne sert à rien ici, mais il devient essentiel dès qu'un go.sum existe (copiez alors go.mod et go.sum, puis lancez go mod download dans une instruction séparée, avant de copier le code).

3. Simuler la CI (niveau 300). Reproduisez l'expérience des agents : un builder neuf qui pousse vers un registre local et exporte son cache en mode=max, puis un second builder neuf qui l'importe. Ajoutez une troisième construction qui importe deux caches : celui d'une branche qui n'existe pas encore, puis celui de la branche principale. Que se passe-t-il ?

Solution
$ docker buildx build --builder agent3 --progress=plain -t 127.0.0.1:5000/signalements:branche --push \
    --cache-from type=registry,ref=127.0.0.1:5000/signalements:cache-ma-branche \
    --cache-from type=registry,ref=127.0.0.1:5000/signalements:cache .
#7 importing cache manifest from 127.0.0.1:5000/signalements:cache-ma-branche
#7 ERROR: failed to configure registry cache importer: 127.0.0.1:5000/signalements:cache-ma-branche: not found
#8 importing cache manifest from 127.0.0.1:5000/signalements:cache
...
#12 CACHED
#13 CACHED

BuildKit signale que la première référence est introuvable (la ligne ERROR n'interrompt pas la construction), continue avec la seconde, et la construction profite du cache de la branche principale. C'est le motif utilisé en CI : chaque branche exporte son propre cache, et importe le sien puis celui de la branche principale en repli.

4. Diagnostiquer (niveau 300). Un collègue se plaint que sa construction en CI met toujours 6 minutes, alors qu'il exporte et importe le cache. Donnez quatre causes possibles et la vérification correspondante.

Solution
  1. Le cache est exporté en mode=min sur un Dockerfile multi-étapes : vérifier l'option mode, comparer le nombre de couches du manifeste de cache. 2. Une instruction en tête invalide tout : un ARG variable (date, numéro de construction), un COPY . . précoce, un contexte non filtré ; vérifier avec --progress=plain la première instruction non CACHED. 3. L'import échoue silencieusement (droits sur le registre, mauvaise référence) : chercher la ligne importing cache manifest et ses erreurs dans le journal. 4. Le temps est ailleurs que dans les instructions : téléchargement des images de base dans un builder neuf, export et poussée de grosses couches ; regarder les durées des lignes FROM et exporting.

Récapitulatif

  • La clé de cache d'une instruction dépend de l'étape précédente, du texte de l'instruction, du contenu des fichiers copiés et des ARG déclarés avant elle. Une invalidation se propage à toute la suite de l'étape.
  • RUN --mount=type=cache garde les téléchargements et compilations d'une construction à l'autre, sans grossir l'image : 31,5 s ramenées à 4,8 s pour une dépendance ajoutée. Sur Debian, retirez docker-clean pour en profiter avec apt.
  • COPY --link rend une couche indépendante de ce qui la précède : ni recopie ni recompression quand une couche antérieure change.
  • Pour la CI, exportez et importez le cache (--cache-to, --cache-from) : 84 s ramenées à 4,5 s sur un agent neuf. mode=max pour les multi-étapes.
  • Le cache est une entrée de la construction : protégez-le, et reconstruisez sans cache régulièrement.

Pour aller plus loin

  • Les pages Build cache de la documentation Docker, en particulier Cache storage backends.
  • La documentation du solveur de BuildKit, pour comprendre le graphe LLB et le calcul des clés de cache.
  • Leçon suivante : utiliser des secrets pendant la construction sans qu'ils ne laissent de trace.
Voir ma constellation →

Sources