Skip to content

Niveau 1 : Introduction à Docker et docker-compose

Auteurs

Assemblé et rédigé par Martin Souchal, d'après les travaux de Fabrice Jammes et David Chamont

Creative Commons License
This work is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

Introduction

Qu'est-ce qu'un conteneur ?

L'industrie utilise aujourd'hui massivement les machines virtuelles. Ces machines exécutent des applications au sein d'un système d'exploitation "invité", qui exploite un matériel "virtuel" émulé par le système d'exploitation "hôte" de la vraie machine.

L'isolation entre invité et hôte est excellente, mais se paie cher par l'émulation du matériel virtuel et l'exécution d'un système d'exploitation invité complet.

Les conteneurs peuvent être vus comme une variante plus légère : en exploitant plus directement les couches basses du système hôte (noyau), les conteneurs fournissent une isolation presque aussi forte que les machines virtuelles, à un cout en performance beaucoup moins élevé.

Docker

Microservice

Dans l'écosystème des conteneurs, on parle de microservice : dans un conteneur il n'y a qu'un seul service (ou application). Ce n'est pas obligatoire mais cela fait partie des bonnes pratiques, et dans lorsque l'on parle d'orchestration de conteneurs cela devient obligatoire.

Qu'est-ce que Docker ?

Il s'agit d'un ensemble d'outils qui facilitent l'écriture, le partage et le déploiement d'applications au sein de conteneurs. Les conteneurs Docker sont originellement dédiés au monde linux. On dispose maintenant de distributions permettant de faire tourner ces conteneurs également sur des machines hôtes MacOSX ou Windows, par le biais de machines virtuelles légères.

Un peu de vocabulaire

Image

Dans le contexte de Docker (comme dans celui des machines virtuelles), on appelle "image" une description statique de conteneur, une sorte de photographie de machine, que vous pouvez échanger avec vos collaborateurs, et à partir de laquelle on peut instancier et exécuter des conteneurs. Pour optimiser le stockage, les images Docker sont découpées en couches, et se réutilisent les unes les autres pour éviter un stockage en double des couches communes.

Conteneur

Une machine virtuelle légère, chargée en mémoire, que l'on peut démarrer, arrêter, redémarrer, détruire... Un conteneur s'instancie à partir d'une image. Si il est correctement configuré, un conteneur peut-être redémarré sans perdre les modifications apportées à son système de fichiers interne. Pour repartir à zéro de l'image de départ, il faut détruire le conteneur et en recréer un nouveau.

Registre

Entrepôt ou l'on stocke des images. Une fois Docker installé, vous avez un registre local sur votre machine, ou seront stockées toutes les images que vous utilisez. Il existe aussi des registres destinés à partager des images entre utilisateurs, tel que le registre central officiel de Docker, qui est consultable à l'adresse http://hub.docker.com/. Chacun peut y créer un compte et y stocker ses images gratuitement (tant qu'elles sont publiques).

Premiers pas

Si vous avez installé Docker et vérifié qu'il fonctionnait, vous avez sans doute déjà exécuté un conteneur hello-world :

docker run hello-world
...

Pour lancer ce conteneur, Docker a besoin de l'image correspondante. Si elle n'est pas déjà dans votre registre local, elle sera préalablement téléchargée depuis un registre (il faut du réseau). Vous auriez pu le faire explicitement avec cette commande :

1
2
3
docker pull hello-world
docker pull quay.io/curl/curl
...

Si vous voulez connaître la liste des images actuellement disponibles sur votre machine locale :

docker images
...

Si vous voulez connaître la liste des conteneurs actuels de votre machine locale :

docker ps

En réalité, la commande ci-dessus, par défaut, ne montre que les conteneurs en cours d'exécution. Si vous voulez les voir tous, ajouter l'option -a :

> docker ps -a
ef2db9e71c15   hello-world   "/hello"   4 minutes ago   Exited (0) 3 minutes ago   sad_volhard

Surprise, notre conteneur hello-world, bien qu'ayant terminé sa tâche, est toujours là ! Vous pouvez le relancer, avec l'option -i pour que les affichages soient bien redirigés dans le terminal courant. Et il ne faut pas utiliser le nom de l'image d'origine, mais l'identifiant unique du conteneur, ou le nom unique attribué aléatoirement par Docker :

docker start -i <containerid>
...

Une image est configurée pour exécuter une certaine commande au lancement du conteneur. A la fin de la commande, le conteneur s'arrête. Le conteneur disparait alors de l'affichage de docker ps, mais il est toujours là, et visible par docker ps -a.

Un peu de ménage

Pour éliminer un conteneur qui n'a plus d'utilité, une commande "rm" fera l'affaire :

1
2
3
docker rm sad_volhard
docker rm <containerid>
...

Nous n'avons pas encore rencontré ce cas de figure, mais un conteneur de type "démon" peut être en cours d'exécution, même si il n'est pas actuellement connecté à un terminal. Pour supprimer un tel conteneur, il faut d'abord l'arrêter à l'aide d'une commande docker stop, ou bien ajouter l'option -f à votre commande docker rm.

Par défaut, lorsque la commande principale d'un conteneur est terminée, ce dernier est arrêté, mais il n'est pas détruit pour autant. En effet, l'utilisateur pourrait vouloir le redémarrer et s'y attacher, notamment pour inspecter le contenu de ses fichiers internes (lors de la destruction d'un conteneur, toutes les modifications apportées aux fichiers internes sont perdues).

Si vous préférez détruire directement un conteneur à la fin de sa commande principale, afin de ne pas polluer votre ordinateur avec des centaines de conteneurs dormants, vous pouvez utiliser l'option --rm lors du lancement :

docker run --rm -it quay.io/curl/curl
docker ps -a

Par ailleurs, à force d'essayer des images différentes, vous finirez par manquer de place dans votre registre local, et il faudra sans doute faire du ménage dans les images. Vous connaissez déjà la commande docker images qui vous en dresse la liste. Pour en supprimer une, vous avez docker rmi. Essayez :

1
2
3
4
5
docker images
...
docker rmi quay.io/curl/curl
docker images
...

Démarrer un conteneur avec une commande alternative

A chaque conteneur est associé une commande principale. Lorsque vous créez le conteneur, la commande principale est exécutée, et lorsque cette commande se termine, le conteneur s'arrête (mais ne disparait pas).

Lorsque vous démarrez un nouveau conteneur, vous pouvez aussi choisir explicitement une commande à exécuter, au lieu d'utiliser celle par défaut. Par exemple, si je veux faire un curl sur l'url ifconfig.me, je vais le faire ainsi :

docker run --rm -it quay.io/curl/curl ifconfig.me

Nous allons tester avec une distribution linux legère (alpine linux), je vais demarrer un conteneur et lancer une commande à l'intérieur de ce dernier :

1
2
3
4
docker run -it --rm quay.io/jitesoft/alpine:latest sh
/ # ls
bin    dev    etc    home   lib    media  mnt    opt    proc   root   run    sbin   srv    sys    tmp    usr    var
/ # exit

Manipuler le conteneur comme on manipulerait un processus

Comme nous l'avons vu précédemment, un conteneur peut-être démarré, arrêté... mais aussi redémarré, tué... un peu comme un processus.

# télécharger le conteneur
docker pull docker/welcome-to-docker:latest

# lancer le conteneur
docker run -d -p 8080:80 docker/welcome-to-docker

# recuperer le nom du conteneur
docker ps

# Restart the container
docker restart <container>

# Stop the container
docker stop <container>

# The container will not be destroyed by this command. The data will still live inside the container,
# even if mybusybox is not running. To restart the container and see our data, we can issue:
docker start <container>

# Set a timeout, after which the process will be immediately killed with a SIGKILL
docker stop --time=30 <container>

# Immediately kill the process
docker kill <container>

# Destroy a container
docker rm <container>

Les commandes stop ou kill se traduisent par des signaux SIGTERM ou SIGKILL transmis à la commande principale en cours d'exécution dans le conteneur.

Pour retracer l'historique d'un conteneur, et éventuellement rechercher la source d'un problème, vous disposez de la commande logs:

docker logs <container>

S'inviter dans un conteneur en cours d'exécution

Un conteneur, même si il lance initialement une commande principale, est tout à fait capable d'exécuter d'autres processus concurrents.

Ainsi, à supposer qu'une image dispose de l'interpréteur bash, on peut très bien "s'inviter" dans un conteneur pour aller inspecter le contenu de son système de fichier, à l'aide de la commande docker exec. Par exemple, avec l'image nginx qui lance un serveur web en tâche de fond :

docker run --name webserver -d nginx
e9061076d8db5948b8c29ced1ffe4267b8ec9a163e5af93c7eec766ed648351d
docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS               NAMES
e9061076d8db        nginx               "nginx -g 'daemon off"   7 seconds ago        Up 6 seconds        80/tcp, 443/tcp     webserver
docker exec -it webserver bash
# ls /usr/share/nginx/html
50x.html  index.html
# exit
...

Vous pouvez également modifier les fichiers internes du conteneur, tels que les fichiers html ci-dessus, ou procéder à des installations :

1
2
3
4
docker exec -it webserver bash
# apt-get update && apt-get upgrade --yes && apt-get install -y vim
...
# exit

Mais n'oubliez pas qu'à moins de vous placez dans un volume spécial, tout ce que vous allez faire sera perdu quand le conteneur sera détruit.

Communication entre conteneur et hôte

Par défaut, les conteneurs sont isolés de leur système hôte, ce qui peut devenir un problème dès que l'on veut coordonner un système de plusieurs conteneurs. Vous avez trois possibilités pour faire circuler de l'information entre hôte et conteneurs :

  1. La définition par l'hôte de variables d'environnement qui seront transmises au conteneur au moment de son lancement.

  2. Le renvoi de ports (port forwarding) permet de reproduire sur l'hôte des ports réseau des conteneurs.

  3. L'utilisation de volumes de données partagés, qui permet à un conteneur d'interagir directement sur le système de fichiers de son hôte.

Renvoi de ports

Lorsqu'on lance un conteneur nginx, on aimerait évidemment pouvoir consulter les pages web servies, depuis un navigateur de la machine hôte. Pour reproduire sur la machine hôte le port 80 du conteneur, on peut lancer le conteneur avec cette option -p :

docker run --name webserver -d -p 80:80 nginx

Vous pourrez ensuite vérifier la présence du serveur web en consultant l'adresse http://localhost/.

(ATTENTION : selon votre machine et votre système d'exploitation, il peut être nécessaire de remplacer localhostpar le numéro IP de votre machine, ou de la machine virtuelle qui permet d'exécuter le noyau linux.)

On peut aussi, à la faveur du renvoi, changer de numéro de port :

docker rm -f webserver
docker run --name webserver -d -p 8080:80 nginx

A présent l'adresse http://localhost/ ne répond plus, mais http://localhost:8080/ oui. On retiendra l'ordre des numéros de ports : d'abord celui sur l'hôte, puis celui au sein du conteneur.

Partage de fichiers

Si le conteneur est détruit, ses fichiers le sont avec lui. Dans le cas d'un serveur web, on aimerait que le contenu du site web soit stocké sur la machine hôte, et que le conteneur nginx ne serve qu'à lancer un serveur temporaire, pour vérifier le rendu des pages. Créez une page de test :

1
2
3
4
5
mkdir html
echo "<html>" >> html/index.html
echo "<head><title>Docker Run Tutorial</title></head>" >> html/index.html
echo "<body><h1>Hello World</h1></body>" >> html/index.html
echo "</html>" >> html/index.html

Voyons maintenant comment installer un lien, de sorte que notre conteneur voit ces fichiers, à l'aide de l'option -v(volumes). Au sein du conteneur, le serveur s'attend à trouver ses fichiers html au sein du répertoire /usr/share/nginx/html. Nous obtiendrons l'effet voulu ainsi :

docker run --name webserver -d -p 80:80 -v $PWD/html:/usr/share/nginx/html nginx

A nouveau, vous pouvez vérifier la présence du serveur web en consultant l'adresse http://localhost/. Et vous pouvez également modifier html/index.htmlet recharger la page pour constater l'effet produit.

Comme pour les ports, on donne d'abord le chemin sur la machine hôte, puis le chemin au sein du conteneur. Le chemin local doit être un chemin absolu, d'où la présence de $PWD (mais vous auriez pu mettre directement le chemin complet). On notera également que le répertoire ainsi lié masque l'éventuel répertoire de même nom dans le conteneur (/usr/share/nginx/html).

Téléchargement d'une image MariaDB

Lorsque vous voulez utiliser une image, elle d'abord recherchée dans votre registre local, puis dans le registre central par défaut, appelé Docker Hub. Dans ce dernier, on trouve notamment toutes les images officielles des produits informatiques libres, dont celle de la base de données MariaDB.

1
2
3
4
5
6
# Search Docker Hub for an image with this command
docker search mariadb
# Install the default MariaDB image
docker pull mariadb:latest
# List locally installed images
docker images

Lancement du premier conteneur MariaDB

Ci-dessous, l'option --name permet de préciser explicitement le nom donné à un conteneur, plutôt que laisser Docker le choisir au hasard. L'option -epermet de définir une variable d'environnement au sein du conteneur. L'option -d permet de lancer le conteneur en tâche de fond ("detached").

1
2
3
4
5
6
# mariadbtest is the name we want to assign the container. If we don't specify a name, an id will be automatically generated.
docker run --name mariadbtest -e MYSQL_ROOT_PASSWORD=mypass -d mariadb
# Optionally, after the image name, we can specify some options for mysqld. For example:
docker run --name mariadbtest -e MYSQL_ROOT_PASSWORD=mypass -d mariadb --log-bin --binlog-format=MIXED
# list running containers
docker ps

Allons inspecter quel est l'environnement shell de ce conteneur, en lancant dans notre conteneur mariadbtest une commande secondaire bash avec pour seule instruction printenv :

docker exec mariadbtest bash -c 'printenv'
MARIADB_MAJOR=10.2
HOSTNAME=2cabc261dd84
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PWD=/
SHLVL=1
HOME=/root
GOSU_VERSION=1.10
MARIADB_VERSION=10.2.14+maria~jessie
MYSQL_ROOT_PASSWORD=mypass
_=/usr/bin/printenv

Récupérer des images du Hub Docker ou de Gitlab

Le registre central de docker met à votre disposition une très grande quantité d'images prêtes à l'emploi, chacun avec éventuellement de multiples étiquettes. Vous pouvez utiliser votre navigateur pour parcourir le site web, ou bien utiliser la commande en ligne docker search pour trouver des images, que vous pourrez ensuite récupérer LOCALEMENT par docker pull.

Warning

soyez vigilant de ne pas télécharger n'importe quoi, au risque d'introduire un virus sur votre machine. Les images qualifiées d'official sont validées par Docker.

Warning

Les pulls d'image en provenance de Docker Hub sont maintenant limitées pour les utilisations anonymes. Pour lever cette limite vous devez avoir un compte sur Docker Hub. (cf docker-hub )

Pour les utilisateurs d'instances Gitlab, chaque projet peut également faire office de registre Docker. Pour voir ce qui est disponible, consultez l'onglet "registry" d'un projet.

Construire sa propre image

Les images sont des descriptions statiques de conteneurs. Précedemment nous avons téléchargé l'image MariaDB et nous avons demandé à Docker de créer un conteneur basé sur cette image. Pour voir la liste des images disponibles sur votre ordinateur, tapez docker images.

A présent, nous allons fabriquer notre propre image.

Voici un exemple de fichier Dockerfile :

FROM ubuntu:latest
MAINTAINER Martin Souchal "martin.souchal@inrae.fr"

# Update and install python
RUN apt-get update && apt-get install -y python3

# Subsequent commands will all be launched in WORKDIR
RUN mkdir -p /home/www
WORKDIR /home/www

# Launch command below at container startup
# It will serve files located where it has been launch
# so /home/www
CMD python3 /home/src/hello.py 

# Add local file inside container
# code can also be retrieved from git repository
ADD index.html /home/www/index.html

# This command is the last one so, if hello.py is changed
# only this container layer will be changed.
ADD hello.py /home/src/hello.py

Les conventions de nommage de docker imposent d'utiliser le nom "Dockerfile" (sans extensions).

Toutes les ressources nécessaires à notre fabrication ont été placées dans le sous-répertoire niveau1. La "recette" à appliquer est donnée dans le fichier Dockerfile, qui est abondamment commenté. En résumé : * FROM : une autre image servant de base. * RUN : permet d'exécuter une commande (par exemple un apt-get install). * WORKDIR: définit le répertoire dans lequel débutera l'exécution. * ADD: permet de copier dans l'image un fichier de la machine hôte. * CMD: commande à exécuter au lancement du conteneur.

Construisez ainsi votre image, qui sera nommée webserver (option --tagpour le nommage) :

1
2
3
4
docker build --tag=webserver niveau1/
...
docker images
...

Utiliser l'image précédente

Démarrer un conteneur webserver

1
2
3
4
# Option:
# --detach=true: run the container in the background and print the new container ID
# --publish: publish container's 8000 port to the host.
docker run --detach=true --publish 8000:8000 webserver

On notera l'option --detach=true (équivalente à -d), qui permet de laisser tourner le conteneur en tâche de fond.

On notera également l'option --publish 8000:8000 (équivalente à -p 8000:8000) qui permet de renvoyer le port 8000 du conteneur sur le port 8000 de la machine hôte. Ainsi, en consultant "http://localhost:8000" depuis votre navigateur web habituel, vous devriez voir ce que diffuse le conteneur webserver.

Lister les conteneurs en cours d'exécution

1
2
3
4
5
docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED
STATUS              PORTS                    NAMES
6cb4a5dd740e        webserver           "/bin/sh -c 'python3 "   19 seconds
ago      Up 17 seconds       0.0.0.0:8000->8000/tcp   hopeful_liskov

Supprimer un conteneur en cours d'exécution

docker rm -f 6cb4a5dd740e
6cb4a5dd740e

Démarrer un conteneur en le nommant explicitement

docker run --name my_webserver -p 8000:8000 -d webserver

Vérifier le nom du conteneur par docker ps.

Utiliser des fichiers html localisés sur la machine hôte

docker run --name my_webserver -p 8000:8000 -v $PWD/www:/home/www -d webserver

Ici les fichiers html utilisés sont ceux de la machine hôte, grâce à l'option -v qui monte le répertoire $PWD/www en tant que /home/www au sein du conteneur.

C'est un moyen pratique d'associer des données variables à un conteneur.

Consultez à nouveau "http://localhost:8000" depuis votre navigateur web. Que remarquez vous ? Ici le répertoire monté à l'aide de l'option -v s'est interposé devant celui qui était dans l'image.

Les etiquettes (tags)

Un registre peut contenir plusieurs versions de la même image, différenciées par une étiquette (tag). Cette étiquette peut être un numéro de version ou une chaine de caractère quelconque. Il n'y a pas vraiment de règle, à part le fait que l'étiquette par défaut est latest. Ainsi, la commande de construction donnée en exemple précédemment est parfaitement équivalente à :

docker build --tag=webserver:latest niveau1/
...

Si je préfère créer une version v1, je taperai :

docker build --tag=webserver:v1 niveau1/
...

Re-étiquettage

Dans le dernier exemple ci-dessus, vous aurez peut-etre remarqué que grâce à sa mécanique de cache, Docker n'a pas réellement recréé une nouvelle image, mais réutilisé celle qui s'appellait webserver:latest pour lui donner un deuxième nom, à savoir webserver:v1. On aurait pu le faire directement ainsi :

docker tag webserver:latest webserver:v1
...

Gestion des étiquettes

Rien n'est contrôlé ni permanent dans l'usage des étiquettes. On peut modifier, redéfinir, écraser à loisirs les images et leurs étiquettes. Donc il faut être très rigoureux dans ses manipulations d'étiquettes, et s'astreindre à un certain processus, sous peine de se perdre dans ses versions d'images.

Partager une image

Jusqu'à présent, nous n'avons fait que télécharger des images sur notre machine depuis le registre central Docker, par la commande docker pull. Puis nous avons créé nos propres images. A présent, comment les partager avec d'autres ?

Distribuer une image sous forme de fichier archive

Il s'agit de la méthode la plus rustre, qui vous permettra de distribuer votre image même en absence de réseau. Pour créer le fichier archive à partir d'une image déjà construite et disponible dans votre registre local :

docker save webserver:v1 -o webserver-v1.tar

On peut le faire également directement sur une image d'un registre distant. Le fichier archive peut ensuite être compressé et copié dans une clef USB.

A l'inverse, pour recharger une image dans un registre local, à partir d'un fichier archive, compressé ou non :

docker load -i webserver-v1.tar
docker images

Publier une image sur le Hub Docker

Le registre central de docker vous permet de stocker gratuitement une image privée (avec les étiquettes de vore choix), ou autant d'images publiques que vous le souhaiterez.

Après avoir créé un compte <MON-ID> sur le site hub.docker.com, puis un "dépôt" (repository) <MON-IMAGE>. Il vous suffira de créer localement une image s'appelant <MON-ID>/<MON-IMAGE>, de vous authentifier à l'aide d'une commande docker login, puis de faire une commande docker push pour téléverser votre image :

1
2
3
4
5
6
docker build --tag=<MON-ID>/<MON-IMAGE> webserver/
...
docker login
...
docker push <MON-ID>/<MON-IMAGE>
...

Publier une image sur Gitlab

Chaque projet d'une instance Gitlab est maintenant capable de stocker des images Docker. Sur le site web du serveur, l'onglet "Registry" de chaque projet liste les images stockées, ainsi que les instructions pour les télécharger ou les déposer. Par exemple, pour un projet public et une image <MON-IMAGE> :

1
2
3
4
docker build --tag=gitlab-registry.in2p3.fr/<MON-PROJET>/<MON-IMAGE> webserver/
...
docker push gitlab-registry.in2p3.fr/<MON-PROJET>/<MON-IMAGE>
...

Si le projet est privé, il faut d'abord se logger sur le repository avec votre compte gitlab :

docker login https://gitlab-registry.in2p3.fr/<MON-PROJET>
...

Ensuite il est possible de faire des push ou des pull selon ses droits dans le projet Gitlab.

Optimiser ses images Docker

Découpage de l'image en couches

Image

En apparence, nous manipulons des "images" monolithiques, mais en réalité Docker les découpe en couches réutilisables.

Réutilisation des couches

Si vous construisez tout un système d'images, et que vous souhaitez optimiser l'espace disque que vous utilisez, vous avez tout intérêt à hiérarchiser vos images et à partager tout ce qui peut l'être. La factorisation des couches communes réduira l'empreinte disque.

Limiter le nombre de commandes dans un Dockerfile reduira de fait la taille de l'image, car chaque commande crée une nouvelle couche.

Par exemple au lieu d'écrire :

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

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

Multi stage build

Un manifeste peut contenir un environnement de Build et un environnement d'execution (stages) :

# Build env
FROM maven:3-jdk-11 AS build
COPY src /app/src
COPY pom.xml /app
RUN mvn -f /app/pom.xml clean package

#Runtime env
FROM gcr.io/distroless/java:11
COPY --from=build /app/app.jar /usr/app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/usr/app/app.jar"]

Comme on le voit dans cet exemple, on peut copier des fichiers d'un stage à un autre avec la commande COPY.

  • On peut contruire chaque stage séparement, notamment a des fins de test ou debug :
docker build --target build -t my-image:tag .
  • On peut également copier un artefact d'une image externe :
1
2
3
FROM my-image:tag
...
COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf
  • L'interet est de produire des images les plus petites possibles et ainsi réduire la surface d'attaque et les temps de build.

Exercice

Convertissez le Dockerfile suivant pour utiliser le multi-stage :

FROM nginx
RUN apt-get update \
&& apt-get install -y curl python build-essential \
&& apt-get install -y nodejs \
&& apt-get clean -y
RUN mkdir -p /my_app
ADD ./config/nginx/docker.conf /etc/nginx/nginx.conf
ADD ./config/nginx/k8s.conf /etc/nginx/nginx.conf.k8s
ADD app/ /my_cool_app
WORKDIR /my_cool_app
RUN npm install -g ember-cli
RUN npm install -g bower
RUN apt-get update && apt-get install -y git \
&& npm install \
&& bower install \
RUN ember build  environment=prod
CMD [ “/root/nginx-app.sh”, nginx”, -g”, “daemon off;” ]
Solution

Une solution possible (l'accent est mis sur le passage de l'application à la première étape) :

FROM node:6
RUN mkdir -p /my_cool_app
RUN npm install -g ember-cli
RUN npm install -g bower
WORKDIR /my_cool_app
RUN npm install
ADD app/ /my_cool_app
RUN bower install
RUN ember build  environment=prod

FROM nginx
RUN mkdir -p /my_cool_app
ADD ./config/nginx/docker.conf /etc/nginx/nginx.conf
ADD ./config/nginx/k8s.conf /etc/nginx/nginx.conf.k8s
# Copy build artifacts from the first stage
COPY  from=0 /my_cool_app/dist /my_cool_app/dist
WORKDIR /my_cool_app
CMD [ “/root/nginx-app.sh”, nginx”, -g”, “daemon off;” ]
Les builds multi-étapes vous permettent de produire des images de conteneur plus petites en divisant le processus de build en plusieurs étapes, comme nous l'avons fait ci-dessus. L'image de l'application ne contient rien qui soit lié au processus de build, à l'exception de l'application elle-même.

Optimiser les directives d'un Dockerfile

Dans le Dockerfile donné en exemple au début, regardez l'ordre des directives : à chaque construction, Docker essaie de ne refaire que ce qui est nouveau et nécessaire. Quand c'est possible, vous avez tout intérêt à placer en fin de fichier ce qui change le plus souvent. Vos constructions seront ainsi beaucoup plus facile (et vos images plus petites ?).

Docker compose

La commande docker compose permet de gérer collectivement un ensemble de conteneurs appelés à fonctionner ensemble. On se placera dans un répertoire dédié (de votre choix), dont le ficher docker-compose.yml contiendra la configuration complète de l'ensemble, et toutes les actions vont se faire avec la seule commande docker compose.

Pour commencer doucement, nous allons faire tourner l'image hello-world non pas à l'aide de docker run, mais à travers docker compose. Pour ce faire, créez un répertoire tout neuf dans lequel vous vous placez et vous créez un ficher docker-compose.yml :

1
2
3
services:
  my-test:
    image: hello-world

Toujours dans ce même répertoire, tapez docker compose up:

1
2
3
4
5
6
7
docker compose up
Creating helloworld_my-test_1...
Attaching to helloworld_my-test_1
my-test_1 | 
my-test_1 | Hello from Docker.
my-test_1 | This message shows that your installation appears to be working correctly.
...

Les affichages nous informent de ce que fait Docker:

  1. Le client Docker contacte le démon Docker.
  2. Le démon télécharge l'image "hello-world" du hub Docker.
  3. Le démon instancie un nouveau conteneur à partir de l'image, qui exécute le processus qui affiche "Hello from Docker...".
  4. Le démon transmet les affichages au client, qui les affichent sur le terminal.

Si le processus ne s'arrête pas de lui-même, tapez CTRL-C.

Bien sur, cet exemple ne démontre pas encore l'intérêt majeur de docker-compose, qui est de gérer simultanément un ensemble de conteneurs. Nous allons y venir, mais jetons d'abord un premier regard aux sous-commandes de docker compose.

Un apercu des sous-commandes de docker compose

La commande docker compose travaille sur la base de répertoires. Si vous voulez faire tourner plusieurs groupes de conteneurs, faites un répertoire par groupe, chacun avec son propre fichier docker-compose.yml.

En production, on voudra faire tourner les conteneurs en tâches de fond, plutôt qu'en interactif. Faites le avec l'option -d : docker compose up -d (comme vous auriez fait docker run -d pour un conteneur isolé).

Pour voir l'état des conteneurs du groupe/répertoire courant, utilisez la commande docker compose ps:

1
2
3
4
docker-compose ps
    Name        Command   State    Ports 
----------------------------------------
tmp_my-test_1   /hello    Exit 0         

Les conteneurs en cours d'exécution ont le statut Up.

Pour un arrêt de tous les conteneurs : docker compose stop, voire docker compose kill.

Pour éliminer tous les conteneurs, ainsi que leurs éventuels volumes internes : docker compose rm. Cela permet de repartir sur des bases neuves.

Vous l'aurez compris, la commande docker compose reprend la plupart des commandes d'origine de docker, mais elles les applique à l'ensemble des conteneurs décrits dans docker-compose.yml.

Configuration à plusieurs conteneurs

Essayons maintenant un scénario plus complexe, ou nous démarrons à la fois une instance de l'image wordpress et une instance de l'image mariadb (que nous appellerons wordpress_db), avec un "lien" entre les deux. Le fichier docker-compose.yml est le suivant :

1
2
3
4
5
6
7
services:
  wordpress:
    image: wordpress
    links:
     - wordpress_db:mysql
  wordpress_db:
    image: mariadb

Au sein du conteneur wordpress, le conteneur wordpress_db apparait en tant que mysql.

Si on lance docker compose up, on verra qu'il manque encore un peu de configuration :

Starting tmp_wordpress_db_1
Starting tmp_wordpress_1
Attaching to tmp_wordpress_db_1, tmp_wordpress_1
wordpress_db_1  | error: database is uninitialized and password option is not specified 
wordpress_db_1  |   You need to specify one of MYSQL_ROOT_PASSWORD, MYSQL_ALLOW_EMPTY_PASSWORD and MYSQL_RANDOM_ROOT_PASSWORD
wordpress_1     | error: missing required WORDPRESS_DB_PASSWORD environment variable
wordpress_1     |   Did you forget to -e WORDPRESS_DB_PASSWORD=... ?
wordpress_1     | 
wordpress_1     |   (Also of interest might be WORDPRESS_DB_USER and WORDPRESS_DB_NAME.)
tmp_wordpress_db_1 exited with code 1
tmp_wordpress_1 exited with code 1

Il manque visiblement la définition de plusieurs variables d'environnement, ce que l'on peut faire facilement dans docker-compose.yml. Essayez :

1
2
3
4
5
wordpress_db:
...
  environment:
    MYSQL_ROOT_PASSWORD: examplepass
...

L'image mariadb est configurée pour vérifier l'existence de la variable MYSQL_ROOT_PASSWORD au lancement, et se charge alors de créer une base de donnée initiale et un compte root avec le mot de passe défini par MYSQL_ROOT_PASSWORD. Par ailleurs, le conteneur wordpress est capable de récupérer cette variable de wordpress_db et de s'en servir pour faire sa propre initialisation.

Pour voir ce qui s'affiche sur le serveur web du conteneur wordpress, on doit transmettre son port 80 sur un port de la machine hôte, par exemple 8080, afin de pouvoir consulter le serveur à l'aide d'un navigateur de l'hôte, en demandant l'adresse http://localhost:8080/ :

1
2
3
4
5
wordpress:
...
  ports:
    - 8080:80
...

Utilisation d'un volume partagé

Parce qu'il s'agit d'images officielles, respectant les bonnes pratiques définies par Docker, ces images sont dotées de volumes internes persistents, ce qui permet de retrouver ses données quand les conteneurs sont redémarrés (docker stop, start, restart)... mais pas si vous les effacez (docker rm).

On peut également vouloir placer les documents web dans un répertoire de la machine hôte, afin de préserver ces documents même en cas de destruction du conteneur wordpress :

1
2
3
4
5
wordpress:
...
  volumes:
    - ~/wordpress/wp_html:/var/www/html
...

Pour rendre en compte la modification, il faudra détruire le conteneur concerné avant de le démarrer de nouveau :

1
2
3
docker compose stop
docker compose rm wordpress
docker compose up -d

Maintenant, vous devriez trouver dans le répertoire ~/wordpress de votre hôte un sous-répertoire wp_html avec le contenu de votre site. Toute modification faite depuis l'hôte sera répercutée dans le /var/www/html du conteneur.

Personnaliser les images

En vérité, la commande docker compose up est l'équivalent d'une commande docker compose build suivie d'une commande docker compose run. Dans le fichier docker-compose.yml, on peut remplacer l'usage direct d'une image existante par la construction d'une image personnalisée. La commande docker compose build détecte et construit ces images personnalisées.

Essayons à présent de créer une variante personnalisée de mariadb, dans laquelle on définirait directement la variable MYSQL_ROOT_PASSWORD. Ceci pourrait se faire au sein d'un sous-répertoire MariaDb contenant le fichier Dockerfile approprié :

1
2
3
FROM mariadb
MAINTAINER Donald Duck <donald.duck@disney.com>
ENV MYSQL_ROOT_PASSWORD examplepass

Ensuite, mon fichier de configuration global docker-compose.yml doit être modifié comme suit :

1
2
3
4
5
6
7
8
9
services:
  wordpress:
    image: wordpress
    links:
      - wordpress_db:mysql
    ports:
      - 8080:80
  wordpress_db:
    build: MariaDb

Ajouter des volumes persistants

Afin de conserver les données utilisées dans un service (par exemple une base de donnée), on peut rendre persistant un volume en lui donnant un nom. Ce volume sera crée sur l'hote et survivra aux modifications du docker compose.

Dans l'exemple suivant on va rajouter un volume mysql pour la base de données et un volume wordpress avec les fichiers web.

version: '3.8'

services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: wordpress
      MYSQL_ROOT_PASSWORD: rootpassword
    volumes:
      - db_data:/var/lib/mysql

  wordpress:
    image: wordpress:latest
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
      WORDPRESS_DB_NAME: wordpress
    ports:
      - "8080:80"
    volumes:
      - wp_data:/var/www/html
    depends_on:
      - db

volumes:
  db_data:
  wp_data:

Pout lister les volumes présents sur un hote, et les supprimer on utilise la commande

docker volume ls
docker volume rm <volume>

... et un réseau privé

Pour finir de rendre ce docker compose persistant et sécurisé, on peut créer des réseaux virtuels afin de mieux controler la confidentialité des flux. Pour ce faire, il faut utiliser le mot clé "networks" et donner un nom a chaque réseau. Il faut ensuite déclarer dans le docker compose quels services ont accès au réseau :

version: '3.8'

services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: wordpress
      MYSQL_ROOT_PASSWORD: rootpassword
    volumes:
      - db_data:/var/lib/mysql
    networks:
      - wp_network

  wordpress:
    image: wordpress:latest
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
      WORDPRESS_DB_NAME: wordpress
    ports:
      - "8080:80"
    volumes:
      - wp_data:/var/www/html
    depends_on:
      - db
    networks:
      - wp_network

volumes:
  db_data:
  wp_data:

networks:
  wp_network:
    driver: bridge
Ici on va créer un réseau nommé "wp_network" qui va permettre la communication entre le service wordpress et la base de donnée mysql.

En résumé :

  • Les conteneurs connectés à ce réseau peuvent communiquer entre eux.
  • Ils sont isolés du réseau de l’hôte et des autres réseaux Docker, sauf si vous configurez des accès spécifiques.
  • "bridge" est le mode réseau par défaut pour les conteneurs Docker sur une machine locale.

Pour aller plus loin

Certaines possibilités de docker compose n'ont pas été traitées :

  • échelonner un service : docker compose scale ...
  • importations entre fichiers docker-compose.yml.

Tout ce qui précède fait sens si vous faite tourner vos conteneurs sur un seul hôte, typiquement votre ordinateur personnel. A partir du moment où vous exploitez une grappe de serveurs, il faut vous tourner vers l'orchestration de conteneurs, ce sera l'objet du Niveau 3 de ces Ateliers.

Wrap-up : conteneurisation d'une application complète

Création d'une application Django

Commencez par créer un répertoire vide.

mkdir django-tp

On va ensuite créer un environnement virtuel python dans ce répertoire :

python -m venv django-venv

Maintenant activez cet environnement virtuel :

source django-venv/bin/activate

Votre prompt doit maintenant commencer par (django-venv).

Pour vérifier que votre environnement est bien actif, la commande which python doit renvoyer un chemin dans le dossier actuel.

Si tout est bon, on peut maintenant installer Django :

python -m pip install django

On va maintenant initialiser un projet Django, ici on va l'appeller tp :

django-admin startproject tp
python tp/manage.py makemigrations

Si tout s'est bien passé, vous devez maintenant avoir un répertoire tp qui contient un fichier manage.py. Tapez la commande python tp/manage.py runserver pour lancer Django. Vous pouvez maintenant ouvrir un navigateur sur l'adresse http://127.0.0.1:8000/ et admirer la fusée Django.

On va ensuite personnaliser un peu le code pour afficher le message "Hello, World!". Commencez par créer une application, que l'on va appeller hello dans le projet Django :

cd tp
python manage.py startapp hello

Django à automatiquement crée un répertoire nommé hello qui va contenir le code de notre application. On va ajouter les fichiers suivants :

django-tp/tp/hello/urls.py

1
2
3
4
5
6
7
from django.urls import path

from . import views

urlpatterns = [
    path('', views.index, name='home')
]
et

django-tp/tp/hello/views.py

1
2
3
4
5
6
from django.shortcuts import render
from django.http import HttpResponse


def index(request):
    return HttpResponse('Hello, World!')

et enfin

django-tp/tp/tp/urls.py

"""tp URL Configuration

The `urlpatterns` list routes URLs to views. For more information please see:
    https://docs.djangoproject.com/en/3.0/topics/http/urls/
Examples:
Function views
    1. Add an import:  from my_app import views
    2. Add a URL to urlpatterns:  path('', views.home, name='home')
Class-based views
    1. Add an import:  from other_app.views import Home
    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
Including another URLconf
    1. Import the include() function: from django.urls import include, path
    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('hello.urls')),
]

Maintenant, si tout va bien, vous pouvez lancer le serveur Django qui doit afficher un magnifique "Hello, World!" sur la page http://127.0.0.1:8000/ :

python manage.py runserver

Nous avons maintenant un code en python, utilisant le framework django en local.

Création d'un conteneur

Exercice

écrivez un Dokerfile pour conteneuriser l'application que nous venons de créer.

Solution

Le Dockerfile suivant contient le minimum necessaire pour faire tourner une application Django : nous avons juste besoin d'un fichier requirements.txt pour pouvoir installer les dépendances python. Pour générer ce fichier, lancez la commande suivante depuis votre environnement virtuel python, toujours dans le repertoire tp (qui contient le fichier manage.py :

pip freeze > requirements.txt
FROM python:3.13

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        default-mysql-client \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /usr/src/app

COPY requirements.txt /usr/src/app/
RUN pip install --no-cache-dir -r requirements.txt

COPY . /usr/src/app

EXPOSE 8000
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

Ensuite on peut passer à la création de l'image Docker :

docker build --tag=django .

Si le build s'est bien passé, il devrait y avoir le message suivant :

1
2
Successfully built c3fc2e5e363d
Successfully tagged django:latest

Pour tester le conteneur, il faut lancer l'image que l'on vient de créer :

docker run -d -p 8000:8000 django

Véifiez que le message "Hello World" s'affiche bien dans un navigateur à l'adresse http://127.0.0.1:8000/

Votre application est conteneurisée !

Mise en place d'un environnement complet avec docker compose

Exercice

rajoutez une base de donnée dans un service docker compose pour gérer les données de l'application Django. Vous pouvez ensuite ajouter le service django. Dans l'idéal rajoutez des volumes persistants pour la base de donnée et un réseau privé pour la communication entre les django et mariadb.

Solution

Voici un fichier docker compose qui reprends tout ce qui est demandé :

version: '3.8'

services:
  db:
    image: mariadb:latest
    container_name: mariadb_container
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
      MYSQL_DATABASE: django_db
      MYSQL_USER: django_user
      MYSQL_PASSWORD: django_password
    volumes:
      - mariadb_data:/var/lib/mysql
    ports:
      - "3306:3306"
    networks:
      - django_network

  web:
    build: .
    container_name: django_container
    command: python manage.py runserver 0.0.0.0:8000
    volumes:
      - .:/app
    ports:
      - "8000:8000"
    environment:
      - DEBUG=1
      - DB_HOST=db
      - DB_PORT=3306
      - DB_NAME=django_db
      - DB_USER=django_user
      - DB_PASSWORD=django_password
    depends_on:
      - db
    networks:
      - django_network

volumes:
  mariadb_data:

networks:
  django_network:
    driver: bridge

Lancez ensuite cet environnement et connectez vous sur l'interface web.

Sources