Aller au contenu

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 dossier fichier_local dans l'image sous le chemin /fichier_distant.
  • RUN ma_commande : exécute la commande ma_commande au sein de l'image.
  • ENV ma_variable=ma_valeur : définit la variable d'environnement ma_variable à la valeur ma_valeur si jamais la variable n'est pas déjà définie.
  • EXPOSE port : ouvre le port port, et indique qu'il sera utilisé.
  • VOLUME mon_dossier : indique que le dossier mon_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 dossier mon_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.


Dernière mise à jour: 2 septembre 2020