Publié par

Il y a 5 mois -

Temps de lecture 14 minutes

Autour des conteneurs – Docker build et ses layers : docker en tient une couche !

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 :

  1. Les logs de téléchargement de Ubuntu 19.04
  2. Les logs de la commande apt (très verbeux)
  3. Quelques lignes qui indiquent qu’on lance un WORKDIR
  4. 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 :

docker build layer

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 ou ADD 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 et ADD 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 son build context, dossier passé en paramètre dans la commande docker 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 colonne CREATED BY de l’affichage du docker 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 un COPY, ce n’est pas le cas pour une modification d’un fichier de l’image en construction comme pour un RUN 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 OverlayFS par défaut (d’autres alternatives existent telles qu’UnionFS, le parent d’OverlayFS, ou encore AUFS), pour faire un Union Mount point. OverlaysFS regroupe les layers créés par Docker en un point de montage.

docker layer, conteneur layer, container layer, docker build, overlay construct

L’avantage de OverlayFS est d’économiser l’espace disque utilisé. En effet OverlayFS 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 / récupérés sur la machine qui construit / instancie l’image.

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.

Publié par

Commentaire

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.