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éseaubridge
, 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éseaubridge
. 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éseaubridge
, 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 pourcurl
. -
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
ouMYSQL_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
orphp -i
before going through the effort of compiling more.We provide the helper scripts
docker-php-ext-configure
,docker-php-ext-install
, anddocker-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 :
-
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'extensionmysqli
. -
Construisez l'image
<votre_identifiant>/php:mysqli
à partir de ceDockerfile
. -
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éseaudrupal
. Montez le volumedb
sur le répertoire/var/lib/postgresql/data
du conteneur. Créez une base de donnéesdrupal
exploitée par l'utilisateurdrupal
avec le mot de passepassword123
. -
Inspectez le réseau
drupal
pour vous assurer que le conteneurdb
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éseaudrupal
. 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 conteneursdb
etdrupal
. -
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.