Maintenant que nous en savons plus sur ce qu’est un conteneur, je vous propose de voir ensemble comment faire pour construire une image de conteneur avec Docker.
Docker est un outil qui permet de construire des images et d’exécuter des conteneurs en respectant les spécifications de l’Open Container Initiative (OCI). Dans cet article, découvrons ensemble comment se fait la construction d’une image, et plus précisément comment Docker construit le FileSystem de notre futur conteneur.
Comment Docker construit-il une image ?
Prenons ce Dockerfile en exemple :
FROM ubuntu:19.04 # J'installe une app RUN apt update && apt install curl -y # Je change de répertoire # Je copie un fichier # J'affiche le contenu du répertoire /my-app WORKDIR /my-app COPY file1 /my-app/file1 RUN ls # Je change de répertoire # J'affiche mon répertoire courant RUN cd / RUN ls # Je rechange de répertoire et j'affiche mon répertoire courant RUN cd / && ls CMD ["curl", "google.com"]
Si je le construis une première fois sans préparer mon environnement de build, je vais voir s’afficher :
- Les logs de téléchargement de Ubuntu 19.04
- Les logs de la commande
apt
(très verbeux) - Quelques lignes qui indiquent qu’on lance un
WORKDIR
- La copie du fichier file1 qui a échoué car j’ai oublié (pour les besoins de l’exemple) de l’ajouter à côté de mon
Dockerfile
Ce qui donne :
$ docker build -t ps-eng-fr/test . Sending build context to Docker daemon 2.048kB Step 1/9 : FROM ubuntu:19.04 19.04: Pulling from library/ubuntu 4dc9c2fff018: Pull complete 0a4ccbb24215: Pull complete c0f243bc6706: Pull complete 5ff1eaecba77: Pull complete Digest: sha256:2adeae829bf27a3399a0e7db8ae38d5adb89bcaf1bbef378240bc0e6724e8344 Status: Downloaded newer image for ubuntu:19.04 ---> c88ac1f841b7 Step 2/9 : RUN apt update && apt install curl -y ---> Running in c8cb43f69515 # # Beaucoup de log pour l'apt # Removing intermediate container c8cb43f69515 ---> 0dd2dfb24eb7 Step 3/9 : WORKDIR /my-app ---> Running in 3af68af78720 Removing intermediate container 3af68af78720 ---> 1e1a8da29c9f Step 4/9 : COPY file1 /my-app/file1 COPY failed: stat /var/lib/docker/tmp/docker-builder259311790/file1: no such file or directory
Corrigeons l’erreur en ajoutant le fichier à côté du Dockerfile et relançons le build.
$ docker build -t ps-eng-fr/test . Sending build context to Docker daemon 2.56kB Step 1/9 : FROM ubuntu:19.04 ---> c88ac1f841b7 Step 2/9 : RUN apt update && apt install curl -y ---> Using cache ---> 0dd2dfb24eb7 Step 3/9 : WORKDIR /my-app ---> Using cache ---> 1e1a8da29c9f Step 4/9 : COPY file1 /my-app/file1 ---> 807c9fb3f556 Step 5/9 : RUN ls ---> Running in c68baf181b9a file1 Removing intermediate container c68baf181b9a ---> fd108b95772a Step 6/9 : RUN cd / ---> Running in 1b7d9017f7ef Removing intermediate container 1b7d9017f7ef ---> 9d822dfb90ea Step 7/9 : RUN ls ---> Running in ccc1828fad02 file1 Removing intermediate container ccc1828fad02 ---> 0bec375c6ac5 Step 8/9 : RUN cd / && ls ---> Running in 967c57292540 # # on coupe il a juste affiché tous les dossiers à la racine # Removing intermediate container 967c57292540 ---> c0235b8aaf9c Step 9/9 : CMD ["curl", "google.com"] ---> Running in 4d0dd24c80af Removing intermediate container 4d0dd24c80af ---> 0838a4555e23 Successfully built 0838a4555e23 Successfully tagged ps-eng-fr/test:latest
Comme on peut le voir, l’étape d’installation du curl
n’a rien affiché et n’a pris qu’une seconde, là où auparavant c’était l’inverse : beaucoup de logs et beaucoup de temps. De même avec l’étape 1 (Step 1/9) qui téléchargeait Ubuntu. Qu’est-ce qui a changé ? Intéressons-nous aux logs pour comprendre.
Step 1/9 : FROM ubuntu:19.04 ---> c88ac1f841b7 Step 2/9 : RUN apt update && apt install curl -y ---> Using cache ---> 0dd2dfb24eb7 Step 3/9 : WORKDIR /my-app ---> Using cache ---> 1e1a8da29c9f
Étape 1/9, on voit juste l’affichage d’un hash. Étape 2 et 3, on a une ligne en plus :
---> Using cache
Qu’est-ce que ça signifie ? Docker utilise un cache pour éviter de refaire une étape qu’il aurait déjà faite. Systématiquement, la ligne qui suit Using cache
est ce fameux hash qui qui permet de retrouver l’étape déjà exécutée.
Chaque hash sert à identifier le FileSystem d’un conteneur intermédiaire dans lequel Docker a sauvegardé le résultat d’une exécution. Grâce à ça, quand Docker arrive à une étape et se rend compte qu’il l’a déjà exécutée par le passé, il récupère directement son résultat plutôt que de la rejouer. On peut retrouver tous les hash des étapes d’une image à l’aide de la commande docker history
, de l’étape la plus ancienne à la plus récente.
$ docker history ps-eng-fr/test IMAGE CREATED CREATED BY SIZE 0838a4555e23 48 minutes ago /bin/sh -c #(nop) CMD ["curl" "google.com"] 0B c0235b8aaf9c 48 minutes ago /bin/sh -c cd / && ls 0B 0bec375c6ac5 48 minutes ago /bin/sh -c ls 0B 9d822dfb90ea 48 minutes ago /bin/sh -c cd / 0B fd108b95772a 48 minutes ago /bin/sh -c ls 0B 807c9fb3f556 48 minutes ago /bin/sh -c #(nop) COPY file:df33e88fd32a1937… 0B 1e1a8da29c9f 49 minutes ago /bin/sh -c #(nop) WORKDIR /my-app 0B 0dd2dfb24eb7 49 minutes ago /bin/sh -c apt update && apt install curl -y 39.8MB c88ac1f841b7 2 weeks ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0B <missing> 2 weeks ago /bin/sh -c mkdir -p /run/systemd && echo 'do… 7B <missing> 2 weeks ago /bin/sh -c set -xe && echo '#!/bin/sh' > /… 933B <missing> 2 weeks ago /bin/sh -c [ -z "$(apt-get indextargets)" ] 985kB <missing> 2 weeks ago /bin/sh -c #(nop) ADD file:98c7df2bed4738dde… 69MB
Vous l’avez sûrement remarqué, mais lors de l’étape 1, Docker n’avait pas affiché de Using cache
. Lorsqu’on regarde l’historique, on voit plusieurs étapes qui ne viennent pas de notre Dockerfile. Elles sont notées en dans la colonne image. Ces étapes viennent de l’image sur laquelle se base notre Dockerfile : Ubuntu 19.04. Le seul hash renseigné est
c88ac1f841b7
, qui est également l’ID de l’image Ubuntu :
$ docker images ubuntu:19.04 REPOSITORY TAG IMAGE ID CREATED SIZE ubuntu 19.04 c88ac1f841b7 2 weeks ago 70MB
Comme pour notre image, cet ID correspond au hash de la dernière étape exécutée par Docker lors de la construction :
$ docker images ps-eng-fr/test REPOSITORY TAG IMAGE ID CREATED SIZE ps-eng-fr/test latest 0838a4555e23 About an hour ago 110MB $ docker history ps-eng-fr/test IMAGE CREATED CREATED BY SIZE COMMENT 0838a4555e23 48 minutes ago /bin/sh -c #(nop) CMD ["curl" "google.com"] 0B ...
Nous y sommes enfin ! Comment Docker construit une image ? Il télécharge les étapes d’une image qui servira de base, celle définie en FROM
dans le Dockerfile
, puis exécute chaque commande qui suit en les rattachant à un hash unique. Le hash résultant de l’éxecution de la dernière instruction sert de référence pour notre image finale.
Comme un schéma vaut 1000 mots, en voici un :
Comment Docker sait-il à l’avance s’il doit exécuter une étape ?
Dans notre exemple, on a vu que Docker avait utilisé le cache des 3 premières étapes, et a commencé à « vraiment travailler » à l’étape 4. On peut facilement se dire « L’étape 4 n’avait jamais été exécutée donc forcément il n’a rien en cache ». Et c’est exactement ça, mais que se passe-t-il lors d’autres cas d’usage plus complexes ?
- La commande d’une étape a changé
- Les fichiers ciblés par
COPY
ouADD
ont changé - La commande
RUN
lance une exécution non déterministe
Docker a des règles très strictes pour utiliser au mieux son cache que vous pouvez retrouver dans la documentation officielle au paragraphe Leverage build cache.
- Docker commence par comparer la séquence des étapes de l’exécution courante à la précédente. Tant que les étapes n’ont pas changé, il utilise le cache. À la première étape rencontrée qui a changé, le cache est invalidé pour toutes les suivantes.
- Les commandes
COPY
etADD
sont particulières. Il est souhaitable que Docker n’utilise pas son cache si jamais un des fichiers cibles a été modifiés. Pour cela, Docker utilise un checksum sur les fichiers de sonbuild context
, dossier passé en paramètre dans la commandedocker build
. Dans notre exemple, ce dossier est./
. Si le contenu d’un fichier ou ses metadata ont été modifiés, le cache est invalidé. Vous pouvez voir le résultat du checksum dans la colonneCREATED BY
de l’affichage dudocker history
. - La commande
RUN
peut tout faire. Modifier le FileSystem, lancer une commande non déterministe (qui donnerait un résultat différent à chaque exécution), ou ne rien modifier du tout. Pourtant, elle n’a pas de cas particulier. S’il est nécessaire de vérifier si les fichiers de la machine hôte ont été modifiés pour unCOPY
, ce n’est pas le cas pour une modification d’un fichier de l’image en construction comme pour unRUN apt install curl -y
. Docker se contente de comparer les commandes exécutées comme pour tout le reste.
L’instruction FROM
n’utilise pas le cache. Soit vous avez déjà une image avec le même hash en local soit Docker la télécharge. Peu importe le nom de l’image indiqué. En effet une même image peut être construite sous des noms différents. Le meilleur exemple est une image qui serait construite avec le tag 1.0
et latest
. Comme l’image est la même, le hash aussi. Il ne faut pas se tromper, seul le hash identifie une image de manière unique et pour la vie. Si jamais vous récupérez une nouvelle image manuellement avec la commande docker pull
sur le même tag, vous perdrez votre cache.
Avez-vous noté la différence entre le parcours rouge et bleu clair sur le schéma ? Dans le parcours bleu clair on retrouve l’inscription « Removing intermediate container », qu’est-ce que cela signifie ?
Docker build et ses layers !
On y vient enfin. Il faut savoir qu’une image OCI, c’est un assemblage de layers. C’est-à-dire que le FileSystem d’une image Docker n’est pas composé d’un seul élément, qui serait le dossier contenant tous les fichiers d’une image, mais d’une union de plusieurs dossiers contenant une partie de notre futur FileSystem. Docker utilise AnotherUnionFS (AUFS) par défaut (d’autres alternatives existent telles qu’UnionFS, le parent d’AUFS, ou encore OverlayFS, supporté nativement par le Kernel Linux), pour faire un Union Mount point
. Ce que Docker nomme layer
, AUFS l’appelle branche
.
L’avantage de AUFS est d’économiser l’espace disque utilisé. En effet AUFS est conçu pour faire du copy-on-write. Cela signifie que lorsqu’une image est construite, les layers sauvegardés sont immuables : on ne peut que les lire. Lorsqu’on crée un conteneur à partir d’une image, notre Container Runtime Interface (CRI) ne fait que lire les layers qui composent notre image sans les modifier. Si lors de l’exécution des modifications du FileSystem doivent être opérés, notre CRI utilisera un layer créé à l’exécution pour faire de la lecture / écriture et modifiera une copie d’un fichier. C’est le Container layer
, qui comme on voit sur le schéma est monté dans le même dossier que les autres layers.
Cela permet de lancer plusieurs fois la même image sans avoir peur de perdre le modèle de base et d’éviter, si l’image fait 200Mo, de tout de suite utiliser 2Go d’espace disque au dixième lancement.
Mais revenons sur nos layers. Tout d’abord, comment Docker crée un layer ?
Un layer est le résultat d’une étape de notre construction d’image. Mais toutes les étapes ne créent pas de layer. Seules les étapes RUN
, ADD
et COPY
créent des layers. Pourquoi ? Parce que ce sont les seules étapes qui modifient notre FileSystem, et qu’un layer est un morceau de notre FileSystem. Ces layers sont sauvegardés sur la machine qui construit l’image, dans un dossier comme indiqué sur le schéma ci-dessus.
Quel est le rapport avec les « intermediate container » ? On sait déjà que Docker sauvegarde le résultat d’une modification du FileSystem dans un layer, identifié par un hash. Mais pour exécuter un process qu’une étape aurait besoin de lancer (n’importe quelle instruction d’une commande RUN
ou le fait de modifier notre dossier courant avec WORKDIR
par exemple), Docker instancie l’image générée à l’étape précédente sous forme de conteneur. Comme ce conteneur n’est pas utile en dehors de la construction de notre image, il le supprime juste après et sauvegarde la modification du FileSystem dans un layer. D’où la log « Removing intermediate container ».
Conclusion
Maintenant que nous avons bien décortiqué le fonctionnement d’une construction d’image avec Docker, essayons de relire notre Dockerfile et comprendre le résultat de certaines instructions.
FROM ubuntu:19.04 # J'installe une app RUN apt update && apt install curl -y # * WORKDIR /my-app COPY file1 /my-app/file1 RUN ls # ** RUN cd / RUN ls # *** RUN cd / && ls CMD ["curl", "google.com"]
Dans mon Dockerfile, j’ai 3 groupes d’instructions avec au-dessus un commentaire marqué par *
, **
et ***
. Dans chacun des groupes, j’affiche le contenu d’un dossier.
Dans le groupe *
, j’utilise l’instruction WORKDIR /my-app
puis j’exécute un ls
dans le dossier courant. Résultat ? J’affiche le contenu du dossier /my-app
. Pourquoi ? D’après ce que je sais, WORKDIR
ne modifie pas mon FileSystem et ne crée pas de layer. Pourtant il m’a créé un dossier. Mais c’est faux, ce n’est pas l’instruction WORKDIR
qui a créé ce dossier. Il n’a fait que modifier notre « working directory », c’est à dire le dossier dans lequel Docker va exécuter les instructions qui suivent pour construire son image et exécuter le conteneur (les instructions ENTRYPOINT
et CMD
prennent en compte le « working directory »). Même si un working directory n’a pas été utilisé car nous en avons tout de suite utilisé un autre, l’instruction qui suivra créera tous les dossiers de tous les WORKDIR
qui précèdent. Exemple :
FROM ubuntu WORKDIR /app WORKDIR /toto RUN ls CMD bash
Si on créait une image basée sur ce Dockerfile, on retrouverait notre dossier /toto
ET /app
.
Dans le groupe **
maintenant, nous avons une première instruction RUN cd /
puis une autre RUN ls
. Lors de la construction de notre image, Docker nous affiche le contenu du dossier /app
. C’est étrange, normalement avec l’instruction au-dessus il aurait du se déplacer dans le dossier racine ? Et non ! Si vous avez suivi, tout ce qui est sauvegardé après l’exécution de l’instruction RUN
c’est un layer qui représente ce qui a changé dans notre FileSystem. L’exécution de la commande cd
a été supprimée avec notre « intermediate container ».
Dans le groupe ***
, le cd
et le ls
sont exécutés dans la même instruction RUN
. Comme nous sommes toujours dans le même « intermediate container », le ls
affiche bien le dossier racine dans lequel le cd
nous a déplacé.
Si vous aviez déjà tout deviné, bravo ! C’est que vous n’avez plus rien à apprendre de cet article.
Vous voulez allez plus loin ? Je vous invite à découvrir dive, un outil d’exploration d’image docker, de contenu de layer, et d’indicateur d’optimisation de taille d’image.