Docker#
Docker est un logiciel qui permet la création de conteneurs agissant comme de mini serveurs indépendants. Cela offre la possibilité de déployer une application facilement sur un serveur virtuel dédié uniquement à cette application.
On trouvera sa documentation ici : https://docs.docker.com/
Installation#
Docker s'installe très facilement via les paquets APT :
sudo apt install docker docker-compose
L'utilité de docker-compose
sera expliquée plus tard.
Introduction#
Qu'est-ce que Docker ?#
On appelle image Docker l'ensemble des fichiers qui composent un serveur avec une application installée. L'image est généralement versionnée.
Par exemple, l'image debian
contient un serveur Debian fraîchement installé.
À une image, on associe un tag. Ce peut être son numéro de version, ou une variante de l'image. Le tag par défaut est latest
, correspondant à la dernière version de la variante la plus utilisée.
Par exemple, debian:buster
est la dernière version de Debian Buster, debian:jessie
est la dernière version de Debian Jessie.
Les images peuvent être bien sûr plus évoluées : l'image nginx:alpine
contient un serveur sous Alpine Linux avec nginx préinstallé.
Pour tester rapidement, vous pouvez exécuter :
sudo docker run -it debian bash
Cela a pour effet de créer un conteneur en partant de l'image debian
, d'exécuter bash
et d'attacher le shell courant dans le conteneur. Vous vous trouvez alors dans un serveur Debian à l'intérieur de votre serveur :)
Le conteneur utilise les ressources de votre serveur, utilise de la place sur le serveur, mais il ne peut pas intéragir avec le reste du serveur. Vous pouvez exécuter un rm -rf /
sans aucun risque ! Votre conteneur sera endommagé, sans toucher au serveur. Mais cela est vraiment sans conséquence.
Ceci est en partie faux : les fichiers système (dans /proc
, /sys
, /dev
ou encore /etc/hosts
, /etc/hosts
et /etc/resolv.conf
) sont en lecture seule et ne peuvent pas être touchés, même si vous essayez.
Données persistantes#
Comme dit juste au dessus, les conteneurs Docker offrent l'avantage de ne pas communiquer avec le reste du serveur, afin de préserver une certaine indépendance. Toutefois, on peut vouloir assez légitimement préserver certains fichiers, tels que les fichiers de données ou de configuration.
Pour cela, on utilise des volumes. Un volume consiste à monter un dossier existant physiquement sur le serveur dans le conteneur. Par exemple, en exécutant :
sudo docker run -it -v /mon/dossier:/data debian bash
ls /data
On pourra voir exactement tout ce que contient /mon/dossier
, et intéragir avec les fichiers présents dans le dossier virtuel de la même manière qu'on le ferait dans le dossier physique. Attention : les attributs meta sont partagés (permissions, propriétaire), c'est une information importante à prendre en compte au moment de la création des volumes. Si l'image n'est pas suffisamment bien conçue, les permissions des dossiers de volume ne seront pas forcément mises à jour automatiquement. Des options plus complexes pour la création de volumes existent, dont on ne détaillera pas ici.
Ouverture dans le monde#
Il est également souhaitable que nos services communiquent avec l'extérieur. C'est le cas par exemple d'un serveur Web qui peut accepter des connexions entrantes sur les ports 80 et 443. On peut pour cela utiliser l'option -p XXXXX:YYYYY
qui a pour effet de rediriger le port XXXXX
entrant sur le serveur dans le port YYYYY
dans le conteneur. Ainsi, pour déployer un serveur nginx, il suffit de faire :
sudo docker run -p 80:80 -p 443:443 nginx
Dans un navigateur, en se rendant sur l'adresse de son serveur, vous pouvez désormais découvrir la page d'accueil de Nginx :)
Ouverture à l'intérieur de nos frontières#
On peut vouloir faire en sorte que les conteneurs communiquent entre eux. C'est le cas par exemple si on veut qu'une base de données soit accessible dans un autre service. Dans ce cas, on crée un lien avec l'option --link
:
sudo docker run postgresql
sudo docker run --link postgresql -p 80:80 -p 443:443 php:apache
De la sorte, le deuxième conteneur comprendra que le DNS postgresql
correspondra au premier conteneur, et pourra s'y connecter simplement.
Paramétrisation#
On peut souhaiter injecter des paramètres initiaux, souvent donnés dans les conteneurs via des variables d'environnement. Pour ajouter une variable d'environnement au conteneur, l'option -e
est faite pour :
sudo docker run -e WORDPRESS_DB_USER=wordpress -e WORDPRESS_DB_HOST=mysql -e WORDPRESS_DB_NAME=wordpress -e WORDPRESS_DB_PASSWORD=wordpress --link mysql -p 80:80 -p 443:443 wordpress
Ce qui a pour effet de lancer un serveur Wordpress avec des variables d'environnement initiant les paramètres de connexion à la base de données.
En pratique : Docker-Compose#
Une configuration plus simple#
Le dernier exemple montre que le lancement d'un conteneur est certes simple et se fait en une seule ligne, mais peut-être très inconfortable pour un serveur de production ...
Un outil est disponible pour gérer la configuration Docker : Docker-compose
. L'installation est très simple, comme indiqué plus haut :
sudo apt install docker-compose
Tout passe par un unique fichier, nommé docker-compose.yml
. Un exemple de fichier docker-compose.yml
:
version: '3.3'
services:
mysql:
image: mysql
restart: always
volumes:
- "/srv/data/mysql:/var/lib/mysql"
environment:
- MYSQL_ROOT_PASSWORD=root
wordpress:
image: wordpress
links:
- mysql
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- "./wordpress/wp-content:/var/www/html/wp-content"
- "/etc/localtime:/etc/localtime:ro"
environment:
- WORDPRESS_DB_USER=wordpress
- WORDPRESS_DB_HOST=mysql
- WORDPRESS_DB_NAME=wordpress
- WORDPRESS_DB_PASSWORD=wordpress
La configuration paraît tout de suite plus claire : on crée deux services, une base de données MySQL ainsi qu'un site Wordpress. L'ajout de volumes est relativement simple, tout comme l'ajout de variables d'environnement, de liens entre les services ou d'exposition des ports.
On peut noter l'ajout d'un paramètre restart: always
qui permet de redémarrer automatiquement le service en cas de crash ou au lancement du serveur.
Un volume a été ajouté : lier le fichier /etc/localtime
(en lecture seule par précaution, via le paramètre :ro
en bout de ligne) permet d'avoir le conteneur configuré à la bonne heure, et ce afin d'éviter des décalages horaires dans les différents services.
Il n'est enfin pas souhaitable de toujours afficher les mots de passe dans la configuration, en particulier parce qu'on peut vouloir gitter sa configuration. On peut alors préférer utiliser un fichier contenant les variables d'environnement, en rajoutant le bloc :
env_file:
- /chemin/vers/mon/fichier.env
Pour les utilisateurs ayant suffisamment de droits, la configuration utilisée par le TFJM² est accessible ici : https://gitlab.com/animath/si/tfjm-server-configuration.
Les configurations spécifiques aux services peuvent normalement être trouvées dans les pages des services concernés.
Le lancement#
Le lancement d'un ou des services se fait simplement par la commande :
docker-compose up -d <service1> <service2> ... <servicen>
L'option -d
permet de se détacher des conteneurs et de laisser les services tourner en arrière-plan. Ne spécifier aucun service lance tous les services éteints.
Il est possible de redémarrer un service via la commande docker-compose restart <service>
.
L'accès aux logs se fait via la commande docker-compose logs <service>
. Rajouter l'option -f
ou --follow
permet de suivre les logs dès qu'il y a des nouveaux. Il est possible de tronquer aux n
dernières lignes avec l'option --tail n
.
Un conteneur s'arrête via docker-compose stop <service>
. Il se détruit avec docker-compose rm <service>
, ce qui a pour effet de supprimer tous les fichiers éphémères.
On peut enfin exécuter une commande à l'intérieur d'un conteneur pour du développement ou de la maintenance en exécutant :
docker-compose exec <service> <comande>
C'est notamment utile si l'on veut ouvrir un terminal, en exécutant la commande bash
(ou sh
sur les images sous Alpine Linux ne disposant pas de bash).
Les réseaux#
La configuration d'un réseau permet de faire communiquer les containers entre eux. Deux services dans le même sous-réseau pourront alors échanger des informations. C'est notamment utile pour que le revere-proxy (voir ci-dessous) puisse inspecter la liste des services.
Un réseau se crée avec la commande :
``bash
docker network create
Ou plutôt avec Docker-compose :
```yaml
networks:
network_name:
driver: bridge
Il existe plusieurs types de réseaux (voir docs.docker.com/network/), nous nous contenterons du type bridge
par défaut.
Dans Docker-compose, pour déclarer l'utilisation d'un réseau créé ailleurs, il suffit d'écrire, toujours dans la section networks
:
networks:
network_name:
external: true
Cela est notamment utile lorsque des services Docker sont déclarés dans plusieurs fichiers docker-compose.yml
distincts.
Pour déclarer qu'un service utilise un ou plusieurs réseaux, on ajoute une catégorie network
dans la description du service :
services:
mon_service:
image: mon_image
...
networks:
- reseau_1
- reseau_2
Si aucun réseau n'est défini, alors Docker compose crée un nouveau réseau appelé default
et connecte le service à ce réseau.
Dans l'architecture du serveur d'Animath, un réseau animath_global
est créé pour permettre à Traefik de voir tous les services, et un réseau est créé par action.
Un reverse-proxy#
On peut noter que seul un seul service peut exposer les ports 80 et 443. Ce problème peut être résolu en passant par un reverse-proxy. Le service utilisé est Traefik.
Construire sa propre image#
Construire une image à partir des sources#
Il n'est pas rare de ne pas trouver son bonheur dans les images pré-construites dans le dépôt Docker Hub. On peut souvent avoir besoin de compiler une image à partir de ses sources.
Pour cela, il suffit de remplacer la ligne image: <image>
par build: /chemin/vers/les/sources
. Dans ce dossier doit se trouver un fichier nommé Dockerfile
. C'est lui qui contient les instructions de création de l'image.
Le lancement se fait de la même manière. Si jamais il est nécessaire de reconstruire une image, le paramètre --build
peut être rajouté.
Il est à noter que le premier lancement est plus long que d'habitude, car l'image doit s'installer, ce qui n'est pas le cas avec une image toute prête.
Écrire sa propre image#
Pour un service personnel ou pour un service ne disposant pas d'image Docker propre, on peut avoir besoin d'écrire sa propre image. Cela équivaut à écrire son propre fichier Dockerfile
.
Un script Dockerfile
s'écrit peu ou prou comme une suite d'instructions qu'on écrirait dans un terminal.
Une image Docker basique doit partir d'une image de base. Par exemple, on peut partir de l'image debian:buster
, alpine
, python:alpine
, nginx:alpine
, ...
Mieux vaut privilégier les images légères, et n'installer que ce qui est utile. Il n'y a pas besoin d'autant d'outils de développement qu'on pourrait avoir sur un serveur. À titre d'exemple, un éditeur de texte tel que vim
est superflu. On privilégiera alors si on est suffisamment adepte une image de base sous Alpine Linux qui se veut plus léger plutôt que Debian.
On commence alors son fichier par une ligne :
FROM image:tag
On peut utiliser ensuite diverses instructions pour construire son image :
COPY fichier_local fichier_distant
: Copie le fichier ou le dossierfichier_local
dans l'image sous le chemin/fichier_distant
.RUN ma_commande
: exécute la commandema_commande
au sein de l'image.ENV ma_variable=ma_valeur
: définit la variable d'environnementma_variable
à la valeurma_valeur
si jamais la variable n'est pas déjà définie.EXPOSE port
: ouvre le portport
, et indique qu'il sera utilisé.VOLUME mon_dossier
: indique que le dossiermon_dossier
doit être dans un volume pour stockage persistant.ENTRYPOINT "/chemin/vers/mon/script"
: indique le fichier à exécuter au lancement de l'image.WORKDIR mon_dossier
: se déplace dans le dossiermon_dossier
dans l'image. En ouvrant un shell, c'est dans ce dossier qu'on se trouvera.
Ce qu'il faut comprendre, c'est qu'à chaque ligne, on crée une nouvelle image. Une image est en réalité une succession de différentes couches, et on construit une image en ajoutant des choses sur une précédente. Cela permet de gagner du temps notamment lors de la construction d'image : on ne recrée pas une image si elle existe déjà. Ainsi, on préférera placer les actions longues telles qu'un appel à apt
en début d'image et placer les actions courtes à la fin, telles que la copie de fichiers de configuration.
À nouveau, on s'assurera de construire des images légères. Les paquets recommendés non indispensables sont inutiles, et le cache d'APT est dispensable.
Ne pas hésiter à regarder des modèles d'image.
La mise à jour#
Pour des images hébergées sur Docker Hub, la mise à jour est simple : il suffit d'exécuter docker-compose pull
et de redémarrer les services (via un up
). Les tags des images doivent être corrects. Selon les services, il faut peut-être procéder à des opérations post mise à jour, notamment s'il y a des données à migrer ou une base de données à maintenir.
Sur une image construite soi-même, il est préférable d'utiliser un dépôt Git qui contient les sources de l'image afin de faire uniquement un git pull
dans le bon dossier. Il faut ensuite reconstruire l'image en faisant docker-compose up -d --build <service>
. De la même manière, des opérations post mise à jour seront peut-être requise.
Données persistantes et mises à jour
Cette opération de mise à jour montre à quel point il est important de distinguer sources et données. Les sources se récupèrent, se recompilent et peuvent être remplacées sans scrupule, les données doivent être préservées. Il est fortement déconseillé, hors développement, de garder dans un volume les sources d'un service. Cela peut poser problème lors de mises à jour.
Pour plus de détails concernant la configuration d'un service en particulier, se référer à la page du service en question.