Aller au contenu

Conteneurs Docker et réseaux

Objectifs :

  • Comprendre l'interaction de Docker avec le noyau Linux pour contrôler le trafic réseau

  • Comprendre le réseau par défaut utilisé pour les conteneurs

  • Inspecter les réseaux Docker

  • Déterminer l'adresse IP d'un conteneur

  • Créer des réseaux personnalisés qui permettent de séparer des groupes de conteneurs

  • Dockeriser une application web PHP

  • Utiliser le serveur DNS intégré dans Docker

Docker et le pare-feu Linux

Docker utilise le pare-feu Netfilter intégré au noyau Linux pour toutes les opérations de pare-feu et de routage. Pour manipuler et configurer le pare-feu, on utilise couramment Iptables.

L'outil Iptables est progressivement remplacé par Nftables.

Lancez un conteneur et regardez ce qui se passe sous le capot :

$ docker run -dit -p 8080:80 php:apache
71c2e5a3ab89984a1eca0fdf9496650b9fc9b45c1e498170356f4ff35c1c28a3

Comme son nom le suggère, l'image php:apache contient le serveur Web Apache avec le module mod_php. Si vous souhaitez dockeriser une application web écrite en PHP, c'est un bon point de départ.

Vérifions que le conteneur est en état de marche et que Docker a ouvert le port 8080 sur le système hôte pour le faire pointer vers le port 80 du conteneur :

$ docker ps
...
PORTS   0.0.0.0:8080->80/tcp 

Affichez la chaîne personnalisée DOCKER avec Iptables :

$ sudo iptables -nL "DOCKER"
Chain DOCKER (1 references)
target     prot opt source         destination
ACCEPT     tcp  --  0.0.0.0/0      172.17.0.2     tcp dpt:80

L'info tcp dpt:80 signifie que le port TCP 80 est le port de destination. Docker applique une adresse IP locale (c'est-à-dire non-routable sur Internet) à notre conteneur.

Essayez d'accéder au serveur web Apache par le biais de cette adresse :

$ curl http://172.17.0.2
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>403 Forbidden</title>
</head><body>
<h1>Forbidden</h1>
<p>You don't have permission to access this resource.</p>
<hr>
<address>Apache/2.4.53 (Debian) Server at 172.17.0.2 Port 80</address>
</body></html>

Pour avoir une première idée du fonctionnement du réseau sous Docker, connectez-vous maintenant à l'adresse IP de l'hôte sur le port TCP 8080 :

$ curl http://127.0.0.1:8080
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>403 Forbidden</title>
</head><body>
<h1>Forbidden</h1>
<p>You don't have permission to access this resource.</p>
<hr>
<address>Apache/2.4.53 (Debian) Server at 127.0.0.1 Port 8080</address>
</body></html>

Le routage interne de Docker fait transiter le trafic de manière transparente.

Arrêtez ce conteneur :

$ docker ps
...
$ docker stop upbeat_clarke
upbeat_clarke

Utiliser le réseau de l'hôte

Dans certains cas de figure, il peut arriver que vous souhaitez désactiver la mise en réseau sophistiquée de Docker.

Cette manière de procéder est peu commune, étant donné qu'elle n'est pas du tout sécurisée. Le conteneur et le système hôte ne sont pas isolés dans ce scénario. Quoi qu'il en soit, sachez que cette possibilité existe.

Vous pouvez activer cette fonctionnalité en utilisant l'option --network host dans la commande docker run. Cette option permet au conteneur de partager le réseau de l'hôte. Le conteneur n'a pas d'adresse IP qui lui est propre.

$ docker run -dit --network host php:apache
e68ad367f3e986af0c754c1c9445135a1558bf7c54578edfb17f50199ffb5e59

Si nous jetons un œil aux chaînes personnalisés d'Iptables, nous voyons qu'aucun routage n'est effectué :

$ sudo iptables -nL "DOCKER"
Chain DOCKER (1 references)
target     prot opt source               destination

Voyons si nous pouvons accéder au serveur web qui tourne dans ce conteneur :

$ curl http://127.0.0.1
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>403 Forbidden</title>
</head><body>
<h1>Forbidden</h1>
<p>You don't have permission to access this resource.</p>
<hr>
<address>Apache/2.4.53 (Debian) Server at 127.0.0.1 Port 80</address>
</body></html>

Le réseau bridge par défaut

Si vous utilisez un orchestrateur, vous n'aurez probablement pas à vous soucier de la façon dont Docker gère la mise en réseau sous le capot. En revanche, c'est toujours utile à savoir si vous devez plonger les mains dans le cambouis en cas de dysfonctionnement.

Pour afficher les réseaux utilisés par Docker, invoquez la commande docker network ls :

$ docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
229c7d253edb        bridge              bridge              local
ccc87db3bab0        host                host                local
6d0117185770        none                null                local
  • Le premier réseau affiché ici est le réseau bridge. C'est le réseau par défaut. Si vous ne spécifiez pas explicitement un réseau pour un conteneur donné, il utilisera le réseau nommé bridge.

  • Sans trop compliquer les choses, un bridge ou pont est un dispositif qui permet de relier deux réseaux.

  • Concrètement, si vous avez un conteneur avec un serveur web attaché au réseau par défaut bridge et un conteneur avec un serveur de bases de données attaché à ce même réseau bridge, ils seront capables de communiquer entre eux.

  • Les deux autres réseaux répertoriés par docker network ls relient les piles réseau du conteneur et du système hôte. Vous pouvez sereinement faire abstraction de ces deux réseaux.

  • La chose la plus importante à garder en tête ici est que le réseau nommé bridge est le réseau utilisé par défaut pour les conteneurs, à moins que vous ne spécifiez explicitement autre chose.

Pour en savoir plus sur un réseau donné, invoquez la commande docker network inspect :

$ docker network inspect bridge
...
"Containers": {},
        "Options": {
            "com.docker.network.bridge.default_bridge": "true",
            "com.docker.network.bridge.enable_icc": "true",
            "com.docker.network.bridge.enable_ip_masquerade": "true",
            "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
            "com.docker.network.bridge.name": "docker0",
            "com.docker.network.driver.mtu": "1500"
        },

La section Containers vous permet d'afficher le ou les conteneurs qui utilisent actuellement le pont pour se connecter à la pile réseau du système hôte, ainsi que les adresses IP associées :

$ docker run -dit --name webserver1 php:apache 
09d1724677fd99f8bdf984336b4a88d638e5882b5b6f5...
$ docker network inspect bridge
...
"Containers": {
          "09d1724677fd99f8bdf984336b4a88d638e58...": {
              "Name": "webserver1",
              "EndpointID": "c61a0f9906cafc3a5d703...",
              "MacAddress": "02:42:ac:11:00:02",
              "IPv4Address": "172.17.0.2/16",
              "IPv6Address": ""
          }
      },
  • Ici vous voyez les détails du conteneur webserver1 qui utilise le réseau bridge. Repérez l'adresse MAC du conteneur ainsi que son adresse IP.

Démarrez un autre conteneur :

$ docker run -dit --name webserver2 php:apache
99a76a5bb45422031f274b194b6d966545f0a850ab4677...
$ docker network inspect bridge
...
"Containers": {
          "09d1724677fd99f8bdf984336b4a88d638e588...": {
              "Name": "webserver1",
              "EndpointID": "c61a0f99d64fd49d9fd566...",
              "MacAddress": "02:42:ac:11:00:02",
              "IPv4Address": "172.17.0.2/16",
              "IPv6Address": ""
          },
          "99a76a5bb45422031f274b194b6d966545f0a8...": {
              "Name": "webserver2",
              "EndpointID": "7b34786eda8133913a308d...",
              "MacAddress": "02:42:ac:11:00:03",
              "IPv4Address": "172.17.0.3/16",
              "IPv6Address": ""
          }
      },
  • Repérez le conteneur webserver2 qui utilise également le réseau bridge, avec son adresse MAC et son adresse IP.

À présent, nous pouvons vérifier si les conteneurs du réseau bridge sont effectivement capables de communiquer entre eux. Pour ce faire, nous allons ouvrir un shell dans l'un des conteneurs et contacter le serveur web qui tourne sur l'autre.

$ docker exec -it webserver1 bash
root@09d1724677fd:/var/www/html# apt update
root@09d1724677fd:/var/www/html# apt install -y iproute2
root@09d1724677fd:/var/www/html# ip addr
...
7: eth0@if8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 
qdisc noqueue state UP group default
    link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
       valid_lft forever preferred_lft forever
root@09d1724677fd:/var/www/html# curl http://172.17.0.3
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>403 Forbidden</title>
</head><body>
<h1>Forbidden</h1>
<p>You don't have permission to access this resource.</p>
<hr>
<address>Apache/2.4.53 (Debian) Server at 172.17.0.3 Port 80</address>
</body></html>
root@09d1724677fd:/var/www/html# exit

Affichons les logs générés par le conteneur webserver2 :

$ docker logs webserver2
...
172.17.0.2 - - [03/Aug/2020:06:51:19 +0000] "GET / HTTP/1.1" 403 436 "-" 
"curl/7.74.0"

Isoler un réseau pour une application

Prenons un cas de de figure concret. Admettons que vous souhaitez créer un réseau isolé pour un groupe de deux conteneurs qui vont héberger un blog. Un des conteneurs fera office de serveur web, l'autre va fournir un serveur de bases de données. Le serveur web doit pouvoir communiquer avec le serveur de bases de données, mais aucun autre conteneur ne doit pouvoir y accéder. De même, aucune machine extérieure au système hôte Docker ne doit pouvoir communiquer avec la base de données. Autrement dit, la base de données ne doit pas être accessible au public.

Pour ce faire, nous allons créer un réseau pour notre blog en y attachant les deux conteneurs :

$ docker network create blog
52288618e976b3566f2e84b208944def99267537f470bd7c437d551f4edb36b4

Le nouveau réseau blog est de type bridge :

$ docker network ls
NETWORK ID     NAME      DRIVER    SCOPE
52288618e976   blog      bridge    local
615abd56a58a   bridge    bridge    local
8d2b8dfe5352   host      host      local
0bd337e274c2   none      null      local

Nous allons lancer un conteneur basé sur l'image php:apache en l'assignant au réseau blog. Mais avant de faire ça, nous allons créer un volume pour stocker les pages web de ce conteneur :

$ docker volume create blog_web_data
blog_web_data
$ docker run -dit --name web --network blog -p 80:80 \
  --mount src=blog_web_data,dst=/var/www/html php:apache
cde718794ee225e952330cc4acf306615eb89e790ba26c83f884a8e40de120bc

Inspectons notre réseau blog :

$ docker network inspect blog
...
"Containers": {
            "cde718794ee225e952330cc4acf306615...": {
                "Name": "web",
                "EndpointID": "efd4ba429ed678d5c3fcd5f8...",
                "MacAddress": "02:42:ac:12:00:02",
                "IPv4Address": "172.18.0.2/16",
                "IPv6Address": ""
            }
        },

Pour montrer que les conteneurs d'un réseau ne peuvent pas communiquer avec les conteneurs d'un autre réseau, attachons-nous au conteneur webserver1 sur le réseau bridge et essayons d'accéder au conteneur qui tourne sur le réseau blog :

$ docker exec -it webserver1 bash
root@8f891d85f3fb:/var/www/html# curl -m 5 http://172.18.0.2
curl: (28) Connection timed out after 5001 milliseconds
root@8f891d85f3fb:/var/www/html# exit
exit
  • L'option -m 5 définit une attente maximale de cinq secondes pour curl.

  • Nous avons effectivement réussi à isoler notre conteneur web en l'assignant à un réseau distinct.

Dans certains cas de figure - comme par exemple sur un système hôte tournant sous Red Hat Enterprise Linux 7 ou un clone compatible - l'isolation fonctionne uniquement entre réseaux personnalisés, mais pas avec le réseau bridge par défaut.

La prochaine étape consiste à créer un conteneur qui fera office de serveur de bases de données pour notre blog. Dans notre cas pratique, nous allons utiliser l'image du serveur de bases de données mariadb.

MariaDB est un fork libre du serveur de bases de données MySQL. L'image mariadb utilise un volume pour le répertoire /var/lib/mysql. Cette info nous est fournie par la documentation de l'image sur Docker Hub. En particulier, repérez la ligne suivante dans le Dockerfile utilisé pour construire l'image :

VOLUME /var/lib/mysql

Ici, nous avons le choix. Nous pouvons créer notre propre volume et le monter sur /var/lib/mysql. Ou alors nous pouvons simplement démarrer le conteneur et Docker se charge de créer un volume pour nous en le montant sur /var/lib/mysql.

Nous allons opter pour la première solution et créer un volume personnalisé, ce qui nous permet de lui donner un nom parlant et de faire les choses plus clairement :

$ docker volume create blog_db_data
blog_db_data

À présent nous disposons de deux volume, un pour le conteneur web, l'autre pour le conteneur database :

$ docker volume ls
DRIVER    VOLUME NAME
local     blog_db_data
local     blog_web_data

Retournez sur la documentation de MariaDB sur Docker Hub et repérez la section Environment Variables. En résumé, vous devez fournir au moins une variable d'environnement au lancement du conteneur.

Nous allons utiliser l'option -e conjointement avec la commande docker run pour définir la variable d'environnement MYSQL_ROOT_PASSWORD, en spécifiant le mot de passe root à utiliser. Lorsque le conteneur démarre, il définira le mot de passe root en conséquence.

Si nous définissons la variable d'environnement MYSQL_DATABASE, le conteneur va créer une base de données avec le nom fourni en argument au lancement. Nous allons en profiter pour créer une base de données nommée wordpress :

$ docker run -dit --name database --network blog \
  -e MYSQL_ROOT_PASSWORD=password123 -e MYSQL_DATABASE=wordpress \
  --mount src=blog_db_data,dst=/var/lib/mysql mariadb
08ca4854ab6fd7cec74c61c1c988dc943b099bd5687e19d2029fc24167b8943f

Notez bien que nous n'avons pas utilisé l'option -p pour publier le port MySQL 3306. Nous ne souhaitons pas que ce port soit publiquement accessible. Tout ce que nous voulons, c'est que le serveur web puisse y accéder.

À présent, inspectons notre réseau blog :

$ docker network inspect blog
...
"Containers": {
            "3bc4c3eb1bc814bc6e2bb70a15682c1ea9c7649...": {
                "Name": "web",
                "EndpointID": "efd4ba429ed678d5c3fcd5...",
                "MacAddress": "02:42:ac:12:00:02",
                "IPv4Address": "172.18.0.2/16",
                "IPv6Address": ""
            },
            "793c643328a9019f45fd8d02dbf8bca50039a7d...": {
                "Name": "database",
                "EndpointID": "ae17989e2b6388b09ff90...",
                "MacAddress": "02:42:ac:12:00:03",
                "IPv4Address": "172.18.0.3/16",
                "IPv6Address": ""
            }
        },

En passant, jetons un œil sur le volume utilisé pour le conteneur database :

$ docker volume inspect blog_db_data | grep Mountpoint
        "Mountpoint": "/var/lib/docker/volumes/blog_db_data/_data",
$ sudo ls -1F /var/lib/docker/volumes/blog_db_data/_data
aria_log.00000001
aria_log_control
ddl_recovery.log
ib_buffer_pool
ibdata1
ib_logfile0
ibtmp1
multi-master.info
mysql/
mysql_upgrade_info
performance_schema/
sys/
wordpress/
  • Voilà l'ensemble des données créées et utilisées par le serveur de bases de données. Si le conteneur database venait à être détruit, les données générées resteraient persistantes dans ce volume. Ce qui signifie que nous pourrions démarrer un autre conteneur avec le même point de montage, et nos données seraient préservées.

  • Notez aussi qu'aucune des variables d'environnement comme MYSQL_ROOT_PASSWORD ou MYSQL_DATABASE n'aura d'effet si le répertoire de données contient déjà une base de données. Autrement dit, une base de données préexistante sera toujours gardée en l'état au lancement du conteneur.

Voyons si nous pouvons nous connecter au serveur de bases de données depuis le serveur web. Pour ce faire, nous devons installer un client MySQL :

$ docker exec -it web bash
root@3bc4c3eb1bc8:/var/www/html# apt update
root@3bc4c3eb1bc8:/var/www/html# apt install -y default-mysql-client
root@c7efa289fa0c:/var/www/html# mysql -h 172.18.0.3 -p
Enter password: ***********    <-- password123
Welcome to the MariaDB monitor.
...
MariaDB [(none)]> status;
...
MariaDB [(none)]> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| wordpress          |
+--------------------+
4 rows in set (0.003 sec)
MariaDB [(none)]> quit;
Bye
root@c7efa289fa0c:/var/www/html# exit
exit

Un aspect fort pratique des réseaux personnalisés, c'est que Docker fournit un serveur DNS embarqué. Ce qui veut dire que nous pouvons très bien accéder aux conteneurs en utilisant leur nom. Concrètement, cela nous évite la corvée d'avoir à déterminer l'adresse IP du conteneur database, puisque nous pouvons directement le contacter en utilisant son nom :

$ docker exec -it web bash
root@c7efa289fa0c:/var/www/html# mysql -h database -p
Enter password:
Welcome to the MariaDB monitor.
...

C'est vraiment la meilleure façon de procéder, étant donné que Docker peut très bien assigner une adresse IP différente au conteneur lorsque celui-ci redémarre. Il vaut donc mieux l'adresser en utilisant son nom et laisser Docker gérer la correspondance entre le nom et l'adresse IP.

Dans l'état actuel des choses, le DNS de Docker fonctionne uniquement avec les réseaux personnalisés, mais pas avec le réseau bridge par défaut. Par ailleurs, le DNS ne fonctionne pas non plus à travers des réseaux cloisonnés, ce qui veut dire que vous ne pourrez pas résoudre le nom d'un conteneur qui se trouve sur un réseau différent.

Continuons dans la mise en place de notre blog. Nous allons utiliser WordPress, qui est écrit en PHP, et c'est pourquoi nous utilisons l'image PHP correspondante. WordPress doit pouvoir se connecter à la base de données, ce qui requiert l'extension PHP mysqli correspondante.

Heureusement pour nous, le conteneur php nous permet de faire cela assez facilement. Là encore, jetez un œil à la documentation de l'image et repérez la section How to install more PHP extensions :

How to install more PHP extensions

Many extensions are already compiled into the image, so it's worth checking the output of php -m or php -i before going through the effort of compiling more.

We provide the helper scripts docker-php-ext-configure, docker-php-ext-install, and docker-php-ext-enable to more easily install PHP extensions.

La documentation nous explique l'utilisation de ces scripts dans un Dockerfile, et c'est ce que nous ferions en temps normal.

Ici, nous avons déjà lancé notre conteneur, et nous allons installer manuellement l'extension mysqli à des fins de démonstration :

$ docker exec -it web bash
root@cde718794ee2:/var/www/html# docker-php-ext-install mysqli
Configuring for:
PHP Api Version:         20210902
Zend Module Api No:      20210902
Zend Extension Api No:   420210902
...
Libraries have been installed in:
   /usr/src/php/ext/mysqli/modules

Maintenant que l'extension mysqli est installée, nous devons redémarrer le serveur web Apache. Pour ce faire, il suffit d'arrêter et de redémarrer le conteneur :

root@c7efa289fa0c:/var/www/html# exit
exit
$ docker stop web
web
$ docker start web
web

Voyons si tout se passe comme prévu :

$ docker top web
UID     PID      PPID     STIME    TTY      TIME        CMD
root    19315    19294    10:56    pts/0    00:00:00    apache2 -DFOREGROUND
33      19390    19315    10:56    pts/0    00:00:00    apache2 -DFOREGROUND
33      19391    19315    10:56    pts/0    00:00:00    apache2 -DFOREGROUND
33      19392    19315    10:56    pts/0    00:00:00    apache2 -DFOREGROUND
33      19393    19315    10:56    pts/0    00:00:00    apache2 -DFOREGROUND
33      19394    19315    10:56    pts/0    00:00:00    apache2 -DFOREGROUND

Effectivement, nous voyons une série de processus apache2 en train de tourner dans le conteneur.

Nous pouvons passer à l'installation de WordPress. Dans un premier temps, nous allons télécharger WordPress dans le volume du conteneur correspondant :

$ docker volume inspect blog_web_data | grep Mountpoint
  "Mountpoint": "/var/lib/docker/volumes/blog_web_data/_data",
$ su -
Mot de passe : **********
# cd /var/lib/docker/volumes/blog_web_data/_data
# wget -c https://wordpress.org/wordpress-latest.tar.gz
# tar -xzf wordpress-latest.tar.gz
# exit
logout
$ docker exec -it web bash
root@cde718794ee2:/var/www/html# ls
wordpress  wordpress-latest.tar.gz
root@cde718794ee2:/var/www/html# chown -R www-data:www-data wordpress
root@cde718794ee2:/var/www/html# exit
exit

À présent, ouvrez l'installateur de WordPress dans un navigateur web :

  • URL : http://localhost/wordpress

  • Langue : Français

  • Nom de la base de données : wordpress

  • Identifiant : root

  • Mot de passe : password123

  • Adresse de la base de données : database (nom du conteneur correspondant)

  • Préfixe des tables : wp_

Lancez l'installation en choisissant un utilisateur WordPress et un nom correspondant. Si tout se passe bien, le tableau de bord de WordPress s'affiche dans le navigateur.

Même si Docker facilite énormément les choses par rapport à une installation manuelle classique de WordPress, nous aurions pu nous y prendre de façon beaucoup plus simple en optant directement pour l'image wordpress toute faite. Si nous avons procédé comme nous l'avons fait, c'est surtout pour illustrer par la pratique une série de concepts comme l'isolation des réseaux, le fonctionnement du DNS sous Docker, les variables d'environnement, etc.

Exercices

Exercice 1

  • Arrêtez et supprimez le conteneur web.

  • Rafraîchissez la page de votre blog. Vous constatez que WordPress ne s'affiche plus.

  • Écrivez un Dockerfile simple pour construire une image PHP qui intègre l'extension mysqli.

  • Construisez l'image <votre_identifiant>/php:mysqli à partir de ce Dockerfile.

  • Publiez cette image sur Docker Hub.

  • Utilisez l'image <votre_identifiant>/php:mysqli pour relancer votre blog WordPress.

  • Arrêtez et supprimez tous les conteneurs.

  • Faites le ménage dans les volumes et les réseaux personnalisés.

Exercice 2

Dans cet exercice, vous allez installer le système de gestion de contenu Drupal comme vous l'avez fait avec WordPress.

  • Créez un réseau isolé drupal.

  • Créez un volume db qui contiendra les données du serveur de bases de données.

  • Récupérez l'image de PostgreSQL dans sa version 11.15.

  • Ouvrez la documentation de l'image PostgreSQL sur Docker Hub et repérez les variables d'environnement pour le mot de passe, l'utilisateur et le nom de la base de données.

  • Lancez un conteneur nommé db et basé sur PostgreSQL 11.15. Intégrez-le au réseau drupal. Montez le volume db sur le répertoire /var/lib/postgresql/data du conteneur. Créez une base de données drupal exploitée par l'utilisateur drupal avec le mot de passe password123.

  • Inspectez le réseau drupal pour vous assurer que le conteneur db y est bien connecté.

  • Récupérez l'image de l'application Drupal dans sa version 8.8.12.

  • Lancez un conteneur nommé drupal et basé sur Drupal 8.8.12. Intégrez-le au réseau drupal. Publiez le port 80 du conteneur et mappez-le vers le port 80 du système hôte.

  • Inspectez le réseau drupal et repérez vos deux conteneurs db et drupal.

  • Ouvrez un navigateur web à l'adresse http://localhost.

  • Lancez l'installation de Drupal. Sélectionnez la langue anglaise. Renseignez les paramètres de connexion à votre base PostgreSQL. Affichez les options avancées pour renseigner l'hôte PostgreSQL.

  • L'application Drupal met quelques minutes à s'installer.

  • Si le tableau de bord de Drupal s'affiche, vous pouvez considérer que vous avez réussi l'exercice.


La rédaction de ces cours demande du temps et des quantités significatives de café espresso. Vous appréciez cette formation ? Offrez un café au formateur en cliquant sur la tasse.