465 lines
15 KiB
Markdown
465 lines
15 KiB
Markdown
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 :
|
||
|
||
<div lang="en-US">
|
||
```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 .
|
||
```
|
||
</div>
|
||
|
||
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 :
|
||
|
||
<div lang="en-US">
|
||
```bash
|
||
docker container run -it my_editor /bin/bash
|
||
(in_cntr)# nano
|
||
```
|
||
</div>
|
||
|
||
|
||
### `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** :
|
||
|
||
<div lang="en-US">
|
||
```dockerfile
|
||
COPY db.sql /db.sql
|
||
RUN service mysqld start
|
||
RUN mysql -u root -p toor virli < /db.sql
|
||
```
|
||
</div>
|
||
|
||
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` :
|
||
|
||
<div lang="en-US">
|
||
```bash
|
||
docker container run tinysql service mysqld start
|
||
```
|
||
</div>
|
||
|
||
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 :
|
||
|
||
<div lang="en-US">
|
||
```dockerfile
|
||
COPY db.sql /db.sql
|
||
RUN service mysqld start && mysql -u root -p toor virli < /db.sql
|
||
```
|
||
</div>
|
||
|
||
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 :
|
||
|
||
<div lang="en-US">
|
||
```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
|
||
```
|
||
</div>
|
||
|
||
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 courante[^MAINTAINER] est d'indiquer le nom du
|
||
mainteneur de l'image :
|
||
|
||
[^MAINTAINER]: Voir par exemple : <https://github.com/nginxinc/docker-nginx/blob/master/stable/debian/Dockerfile#L8>
|
||
|
||
<div lang="en-US">
|
||
```dockerfile
|
||
LABEL maintainer="Pierre-Olivier Mercier <nemunaire@nemunai.re>"
|
||
```
|
||
</div>
|
||
|
||
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 :
|
||
|
||
<div lang="en-US">
|
||
```dockerfile
|
||
CMD nginx -g "daemon off;"
|
||
```
|
||
</div>
|
||
|
||
<div lang="en-US">
|
||
```bash
|
||
42sh$ docker image build --tag=my_nginx .
|
||
42sh$ docker container run -d -P my_nginx
|
||
```
|
||
</div>
|
||
|
||
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 :
|
||
|
||
<div lang="en-US">
|
||
```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"]
|
||
```
|
||
</div>
|
||
|
||
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` :
|
||
|
||
<div lang="en-US">
|
||
```
|
||
42sh$ docker build --target builder -t hello-builder .
|
||
```
|
||
</div>
|
||
|
||
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/>
|