virli/tutorial/dockerfiles/dockerfile.md

15 KiB

Ma première image ... par Dockerfile

Pour construire une image, nous ne sommes pas obligés de passer par une série de commits. Docker dispose d'un mécanisme permettant d'automatiser la construction de nouvelles images. Nous pouvons arriver au même résultat que ce que l'on a réussi à faire précédemment en utilisant le Dockerfile suivant :

```dockerfile FROM ubuntu:jammy

RUN apt-get update RUN apt-get install -y nano

</div>

La syntaxe d'un `Dockerfile` est simple : le premier mot de chaque ligne est
l'intitulé d'une instruction (que l'on écrit généralement en majuscule), elle
est suivie de ses arguments.

Dans notre exemple, nous utilisons `FROM`{.dockerfile} qui indique une image de
départ à utiliser ; `RUN`{.dockerfile} est une commande qui sera exécutée dans
le conteneur intermédiaire, dans le but de construire l'image. De la même
manière que les `docker container run` de la partie précédente.

::::: {.warning}

Vous avez remarqué que la première instruction que l'on utilise est
`FROM`. Chaque image construite par un `Dockerfile` doit dépendre d'une autre
image. Ici nous avons choisi de partir de l'image `ubuntu`.

:::::

Pour lancer la construction de la nouvelle image, créons un nouveau dossier ne
contenant que notre fichier `Dockerfile`, plaçons-nous ensuite dedans, puis
lançons la commande `build` :

<div lang="en-US">
```bash
docker image build --tag=my_editor .

On utilise l'option --tag pour donner un nom et un tag à l'image qui résultera de l'exécution de cette construction.

::::: {.warning}

Attention de ne pas oublier le point à la fin de la commande ! {-}

Vous n'êtes plus sans savoir que Docker se compose d'un client et d'un serveur. Et c'est la partie serveur qui va s'occuper de construire l'image.

Le client transmet donc tout le contexte autour du Dockerfile (les fichiers, dossiers, sons-dossiers) à partir du chemin qu'on lui indique en dernier argument. Le point représente donc ici simplement le dossier courant. Tous les fichiers et dossiers présents ici seront transmis au daemon.

:::::

Une fois la construction de l'image terminée, nous pouvons la lancer et constater l'existence de notre éditeur favori :

```bash docker container run -it my_editor /bin/bash (in_cntr)# nano ```

RUN dans le Dockerfile

Dans un Dockerfile, chaque ligne est exécutée indépendamment des autres et correspondra à une nouvelle couche de notre image. Exactement comme on a réalisé le script à la fin de la partie précédente.

Cela signifie que l'exemple suivant ne fonctionne pas :

```dockerfile COPY db.sql /db.sql RUN service mysqld start RUN mysql -u root -p toor virli < /db.sql ```

Cet exemple ne fonctionne pas car le serveur MySQL est bien lancé dans le premier RUN{.dockerfile}, mais il se trouve brutalement arrêté dès lors que la commande service se termine. En fait, à chaque instruction, Docker réalise automatiquement l'équivalent un docker run suivi d'un commit. Et vous pouvez constater par vous-même que, en créant l'image tinysql à partir d'un simple apt install mysql :

```bash docker container run tinysql service mysqld start ```

rend la main directement, sans laisser de mysqld dans l'arborescence de processus.\

Pour avoir le résultat escompté, il faut exécuter les commandes ensemble :

```dockerfile COPY db.sql /db.sql RUN service mysqld start && mysql -u root -p toor virli < /db.sql ```

Après le RUN{.dockerfile}, MySQL sera de nouveau tué, mais la seconde commande aura entre-temps pu ajouter des données.\

::::: {.warning}

En aucun cas, une commande exécutée par un RUN{.dockerfile} se retrouvera en cours d'exécution lorsque l'on invoquera un conteneur par docker container run. Seul la commande fournie par l'utilisateur ou la commande par défaut de l'image sera exécutée au lancement d'un conteneur.

:::::

Exposer des ports

Construisons maintenant un conteneur avec un service web :

```dockerfile FROM my_editor

RUN apt-get update RUN apt-get install -y nginx

EXPOSE 80

</div>

L'instruction `EXPOSE`{.dockerfile} sera traitée plus tard par le client Docker
(équivalent à l'argument `--expose`). Il s'agit d'une métadonnée qui sera
attachée à l'image (et à toutes ses images filles). Elle ne crée d'ailleurs pas
de couche supplémentaire dans notre image.\

En précisant tous les ports qu'expose une image dans ses métadonnées, ces
ports seront automatiquement exposés en utilisant l'option `-P` du `run` : cela
assigne une redirection de port aléatoire sur la machine hôte vers votre
conteneur :

<div lang="en-US">

42sh$ docker image build --tag=my_webserver . 42sh$ docker container run -it -P my_webserver /bin/bash (cntnr)# service nginx start

</div>

Dans un autre terminal, lançons un `docker container ls`, pour consulter la colonne
*PORTS* afin de connaître le port choisi par Docker pour effectuer la redirection.

Rendez-vous ensuite dans votre navigateur sur <http://localhost:49153/>.


### Copier des fichiers dans l'image

Une autre action très courante est de vouloir recopier un fichier ou un binaire
dans notre image : un fichier de configuration, un produit de compilation, des
scripts pour contrôler l'exécution, ...

On va utiliser pour cela l'instruction `COPY` :

<div lang="en-US">

COPY myconfig.conf /etc/nginx/conf.d/my.conf

</div>

Cette instruction permet également de copier l'arborescence d'un dossier :

<div lang="en-US">

COPY myconfs/ etc/nginx/conf.d/ COPY mywebsite /usr/share/nginx/html/

</div>

::::: {.warning}

Le comportement de la copie de dossier est différente du comportement que l'on
a l'habitude d'avoir avec `cp -r`. Si la source du `COPY` est un dossier, c'est
son contenu qui sera recopié récursivement, habituellement avec `cp` le dossier
recopié puis son contenu.

Pour obtenir le même comportement, il faut bien indiquer une cible
incluant le nom du dossier :

<div lang="en-US">

COPY docker-entrypoint.d /docker-entrypoint.d

</div>

Le dossier sera créé s'il n'existe pas, et le contenu du dossier source ser
recopié.

:::::

:::::: {.exercice}

#### À vous de jouer {-}

Utilisez l'instruction `COPY`{.dockerfile} pour afficher votre propre
`index.html` remplaçant celui installé de base par `nginx`. <!--Si vous manquez
d'inspiration, utilisez [cette page de compte à
rebours](https://virli.nemunai.re/countdown.html).-->

:::::

### Les caches

Nous avons vu que chaque instruction de notre `Dockerfile` est exécutée dans un
conteneur, qui génère une image intermédiaire. Cette image intermédiaire sert
ensuite d'image de base pour le conteneur qui sera lancé avec l'instruction
suivante.

Lorsqu'on lance la reconstruction du même `Dockerfile`, les images
intermédiaires sont réutilisées, comme un cache d'instructions. Cela permet de
gagner du temps sur les étapes qui n'ont pas changé. Ainsi, lorsque vous
modifiez une instruction dans votre `Dockerfile`, les instructions précédentes
ne sont pas réexécutées mais sont ressorties du cache.

Le cache se base principalement sur le contenu de chaque instruction du
`Dockerfile` (pour les `COPY` et `ADD`, il va aussi regarder la date de
dernière modification de fichier à copier ou à ajouter). Donc tant qu'une
instruction n'est pas modifiée dans le `Dockerfile`, le cache sera utilisé.

Il est possible de ne pas utiliser le cache et de relancer toutes les étapes du
`Dockerfile` en ajoutant l'option `--no-cache` au moment du `docker image
build`.

Les couches du cache peuvent être partagées entre plusieurs conteneurs, c'est
ainsi que vous pouvez partager facilement une plus grosse partie du système de
fichiers.\

Pour profiter au mieux du cache, on place les instructions qui sont le moins
susceptibles de changer en haut du `Dockerfile`, celles qui changent le plus
régulièrement à la fin. Ainsi, lorsqu'une reconstruction de l'image sera
nécessaire, on gagnera du temps puisque le cache sera utilisé jusqu'à la
première instruction changeante. Un `Dockerfile` bien ordonné peu facilement
faire gagner de nombreuses minutes à ses utilisateurs.

::::: {.question}

#### Quelle place cela prend-t-il sur mon disque ? {-}

Nous pouvons afficher la taille de chaque image via la commande `docker image
ls` :

<div lang="en-US">

42sh$ docker image ls REPOSITORY TAG IMAGE ID CREATED SIZE nginx latest 2d389e545974 6 days ago 142MB debian stable 9b4953ae981c 7 days ago 124MB nemunaire/youp0m latest 2c06880e48aa 12 days ago 25MB

</div>

Si vous avez beaucoup d'images, cela peut paraître beaucoup, mais rappelez-vous
que les images sont composées de couches qui sont souvent partagées entre
plusieurs conteneurs.

Si on regarde l'espace vraiment utilisé, il est moindre :

<div lang="en-US">

42sh$ docker system df TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 3 3 167MB 0B Containers 0 0 0B 0B Local Volumes 0 0 0B 0B Build Cache 0 0 0B 0B

</div>

:::::

Les couches partagées sont un gain non négligeable pour l'espace de stockage !

Par exemple, prenons le `Dockerfile` suivait :

<div lang="en-US">
```Dockerfile
FROM python:3.10
COPY build /usr/lib/python/grapher
EXPOSE 8080
RUN pip install pillow pygal

Il y a de fortes chances pour que vous travailliez sur le code de l'application, le dossier build sera donc très souvent mis à jour, alors que les dépendances ne bougeront sans doute plus ...

Avec un tel Dockerfile, dès que le dossier build sera mis à jour les dépendances seront à nouveau téléchargées, puisque toutes les couches suivant la première qui change sont invalidées.

Une approche plus optimale serait donc de faire la COPY en dernier, car c'est l'opération qui changera le plus souvent. L'idéal étant que 90 % des reconstructions ne refassent que la dernière instruction, toutes les autres devraient être récupérées du cache.

Métadonnées pures

L'instruction LABEL{.dockerfile} permet d'ajouter une métadonnée à une image, sous forme de clef/valeur.

Une métadonnée courante1 est d'indiquer le nom du mainteneur de l'image :

```dockerfile LABEL maintainer="Pierre-Olivier Mercier " ```

Dans notre Dockerfile, indiquez juste après l'image de base, vos noms, prénoms et mails de contact avec l'instruction LABEL maintainer{.dockerfile}, pour indiquer que c'est vous qui maintenez cette image, si des utilisateurs ont besoin de vous avertir pour le mettre à jour ou s'ils rencontrent des difficultés par exemple.

On le place dès le début, car comme c'est une information qui n'est pas amenée à changer, elle sera toujours retrouvée en cache.

Commande par défaut

Vous pouvez placer dans un Dockerfile une instruction CMD{.dockerfile} qui sera exécutée si aucune commande n'est passée lors du run, par exemple :

```dockerfile CMD nginx -g "daemon off;" ```
```bash 42sh$ docker image build --tag=my_nginx . 42sh$ docker container run -d -P my_nginx ```

L'option -d passée au run lance le conteneur en tâche de fond. Si vous constatez via un docker container ls que le conteneur s'arrête directement, retirez cette option pour voir ce qui ne va pas, ou utilisez la commande docker container logs.

Comme les LABEL, ce n'est pas une instruction qui change régulièrement. On la place plutôt au début du Dockerfile.

Construire son application au moment de la construction du conteneur ?

Comment faire lorsque l'on a besoin de compiler une application avant de l'intégrer dans le conteneur ?

On peut vouloir lancer la compilation sur notre machine, mais cela ne sera pas très reproductible et cela aura nécessité d'installer le compilateur et les outils liés au langage que l'on souhaite compiler. Peut-être que plusieurs versions de ces outils existent, laquelle choisir ? ... Ok c'est trop compliqué.

D'un autre côté, si l'on fait cela dans un conteneur, celui-ci contiendra dans ses couches des données inutiles à l'exécution : les sources, les produits intermédiaires de compilation, le compilateur, n'ont rien à faire dans les couches de notre image.

Le meilleur des deux mondes se trouve dans les Multi-stage builds : au sein du même Dockerfile, on va réaliser les opérations de préparation dans un ou plusieurs conteneurs, avant d'agréger le contenu compilé au sein du conteneur final :

```dockerfile FROM gcc:4.9 COPY . /usr/src/myapp WORKDIR /usr/src/myapp RUN gcc -static -static-libgcc -o hello hello.c

FROM scratch COPY --from=0 /usr/src/myapp/hello /hello CMD ["/hello"]

</div>

Dans cet exemple, deux images distinctes sont créées : la première à partir de
l'image `gcc`, elle contient tout le nécessaire pour compiler notre
`hello.c`. Mais l'image finale (le dernier `FROM`{.dockerfile} de notre
`Dockerfile`) est l'image vide, dans laquelle nous recopions simplement le
produit de notre compilation.

L'image ainsi générée est minime, car elle ne contient rien d'autre que le
strict nécessaire pour s'exécuter.

#### Étapes nommées

Nous avons utilisé `--from=0` pour désigner la première image de notre
`Dockerfile`. Lorsque l'on réalise des montages plus complexes, on peut vouloir
donner des noms à chaque image, plutôt que de devoir jongler avec les
numéros. Dans ce cas, on indiquera :

<div lang="en-US">
```dockerfile
FROM gcc:4.9 as builder
COPY . /usr/src/myapp
WORKDIR /usr/src/myapp
RUN gcc -static -static-libgcc -o hello hello.c

FROM scratch
COPY --from=builder /usr/src/myapp/hello /hello
CMD ["/hello"]

Par défaut la dernière étape du Dockerfile est retenue comme étant l'image que l'on souhaite tagger, mais il est possible de préciser quelle image spécifiquement on souhaite construire avec l'option --target :

``` 42sh$ docker build --target builder -t hello-builder . ```

Cela peut être particulièrement utile si l'on dispose d'une image de debug, incluant tous les symboles, et une image de production, plus propre. On sélectionnera ainsi avec l'option --target l'un ou l'autre en fonction de l'environnement dans lequel on souhaite se déployer.

Déclarer des volumes

Tout comme nous pouvons déclarer préalablement dans le Dockerfile les ports qui sont normalement exposés par le conteneur, nous pouvons déclarer les volumes. L'instruction pour cela est VOLUME.

Il convient de l'utiliser pour déclarer les emplacements qui vont par défaut contenir des données à faire persister. Ce serait le cas de /var/lib/mysql pour les conteneurs MariaDB ou MySQL, /images/ pour notre image youp0m ...

D'autres instructions ?

Nous avons fait le tour des principales instructions et de leurs différents usages classiques. Il existe quelques autres instructions que nous n'avons pas présentées ici, pour aller plus loin, consultez la référence sur :
https://docs.docker.com/engine/reference/builder/