Docker en opération

Pour ce cours, nous avons opté pour une solution self-hosting. Si on voulait mettre en production notre SGBDR, il y a quelques considérations :

  • L'emplacement physique de nos données

  • La sécurité de nos données at rest

  • La sécurité de nos communications avec le SGBDR

  • Les options de configuration de MariaDB

Pour suivre ces cours, vous allez créer une instance chez votre fournisseur cloud (e.g. Scaleway)

Installer Docker sur votre Serveur

Nous faisons un déploiement de MariaDB avec Docker. Jusqu'au présent, nous avons installé Docker sur un PC type Desktop, en utilisant Docker Desktop. Vous trouverez un guide d'installation ici.

Pour installer docker sur une instance Linux, il y aura quelques démarches à faire. Vous pouvez consulter la documentation officielle ici : https://docs.docker.com/engine/install/

Par exemple, sur Ubuntu, les étapes sont les suivantes (attention, à faire avec sudo ou en tant que root) :

# Désinstaller les anciennes versions de docker
sudo apt-get remove docker docker-engine docker.io containerd runc

# Mettre à jour les indexes des packages
sudo apt-get update

# Installer les packages tiers nécessaires pour ajouter Docker parmi nos indexes
sudo apt-get install \
    ca-certificates \
    curl \
    gnupg \
    lsb-release
    
# Télécharger la clé publique de Docker qui va vérifier l'authenticité
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

# Installer le dépot Docker parmi les sources connues de notre distribution
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo chmod a+r /etc/apt/keyrings/docker.gpg
    
# Remettre à jour nos indexes
sudo apt-get update

# Installer Docker 
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin

Une fois installé, vous pouvez vérifier que le processus fonctionne correctement avec :

systemctl status docker

Accorder accès au groupe Docker

Certains de vos utilisateurs Linux auront le droit à contrôler les processus Docker, sans passer par sudo.

Pour leur donner ce droit, ajoutez les utilisateurs concernés au groupe docker :

usermod -aG docker [nom d'utilisateur]

Attention !

Seulement les utilisateurs privilégiés devront posséder ce droit.

L'emplacement des données

Lorsqu'on crée un Container pour tourner MariaDB, Docker crée par défaut un volume de stockage associé au Container. En revanche, ce volume sera supprimé avec le Container.

Il faudrait provisionner un emplacement fixe pour les données de MariaDB :

  1. qui est bien connu

  2. qui sera toujours présent, même si l'instance redémarre

  3. facilement montable sur une autre instance, si jamais la première disparaît

  4. idéalement, le volume sera crypté

Nous avons déjà vu comment provisionner un volume qui répond aux critères 1, 2 et 3 en montant un volume sur notre instance Ubuntu.

En revanche, comment faire une sorte que les données soient cryptées sur le disque dur ? Il y a trois niveaux de cryptage possibles :

  1. Au niveau du disque dur

  2. Par table par MariaDB

  3. Via votre API

Ici, nous regardons le premier niveau de cryptage, avec l'extension LUKS (Linux Unified Key Setup). Nous allons provisionner un volume qui est crypté par défaut.

D'abord, il faut provisionner un volume chez votre fournisseur, identifier le volume avec lsblk, et formater le volume :

# Lister les volumes attachés à l'instance
lsblk

# Formater le périphérique à /dev/sda avec le format "ext4" 
mkfs.ext4 /dev/sda

Nous installons LUKS avec la commande cryptsetup :

# Installer
sudo apt install cryptsetup

Nous avons déjà vu que quand on parle de la cryptographie, il faudrait nécessairement un (ou plusieurs) clé(s) qui permettent de crypter et décrypter des données. Avec LUKS, nous allons créer et gérer ces clés.

Nous commençons par générer une clé forte, en utilisant l'outil openssh par exemple. Plus la clé est longue, plus elle est sécurisée :

# Générer un mot de passe bien unique
openssl rand -base64 32

# Formater le disque pour cryptage LUKS
cryptsetup -c aes-xts-plain64 -v luksFormat /dev/sda

# On vous demande de fournir un mot de passe fort. 
# Utilisez le mot de passe généré par openssl juste avant

Nous avons formaté le volume crypté. Ce volume ne peut pas être lu comme un volume normal. Il faudrait fournir la clé de cryptage. Donc avant de le monter, il faut configurer le périphérique :

# Configurer le peripherique 
# On précise :
# - l'opération: luksOpen (ouvrir un volume)
# - le péripherique physique concerné : /dev/sda
# - l'alias pour ce volume dans LUKS : mariadbdata
cryptsetup luksOpen /dev/sda mariadbdata

# On vous demande de fournir le mot de passe utilisé à la création du volume

# Le nouveau péripherique devrait être présent
ls /dev/mapper

Nous disposons maintenant d'un nouveau périphérique "virtuel" qui se trouve à /dev/mapper/mariadbdata. Ce périphérique peut être formaté et monté comme n'importe quel autre périphérique :

# Formater le nouveau volume
mkfs.ext4 /dev/mapper/mariadbdata

# Si pas déjà fait, créer le point de montage /mnt/mariadbdata
mkdir /mnt/mariadbdata

# Monter
mount /dev/mapper/mariadbdata /mnt/mariadbdata

Nous pouvons donc naviguer à /mnt/mariadbdata et créer et modifier les fichiers. Nous pouvons aussi préciser ce chemin pour le stockage de nos données MariaDB dans notre docker-compose.yml par exemple.

Mais, avant de le faire, il y a un problème : le volume crypté n'existera uniquement tant que l'instance ne redémarre pas. Au redémarrage, il faudrait fournir de nouveau le mot de passe de cryptage.

L'avantage de LUKS est qu'on peut ajouter plusieurs clés en parallèle. Nous allons créer une autre clé qui sera plutôt stocké dans un fichier sur un autre volume. Au démarrage, nous allons utiliser ce fichier pour déverrouiller notre volume.

# Créer uns chaîne de caractères aléatoire, et les sauvegarder dans /etc/.luks-mariadbdata-key
dd if=/dev/random of=/etc/.luks-mariadbdata-key bs=32 count=1

# Regarder ce fichier :
cat /etc/.luks-mariadbdata-key

Nous ajoutons cette clé parmi les clés possibles pour LUKS :

# Ajouter la clé /etc/.luks-mariadbdata-key parmi le trousseau pour /dev/sda
cryptsetup luksAddKey /dev/sda /etc/.luks-mariadbdata-key

# Attention: il va vous demander le mot de passe précédemment crée.
# C'est normale ! Il faut connaître déjà un mot de passe avant d'en ajouter un autre !

A tout moment, nous pouvons visionner l'état de nos clés :

cryptsetup luksDump /dev/sda

Testons cette nouvelle clé. D'abord, fermons le volume qui a été ouvert via la clé initiale :

# Démonter le volume
umount /mnt/mariadbdata 

# Fermer le périphérique
cryptsetup luksClose mariadbdata

Ensuite, montons le volume en précisant plutôt le fichier comme clé :

cryptsetup -v luksOpen /dev/sda mariadbdata --key-file /etc/.luks-mariadbdata-key

# Key slot 1 unlocked.
# Command successful.

C'est super. Maintenant, on peut déverrouiller un volume sans fournir manuellement un mot de passe. Comment le faire au démarrage ?

Vous vous souvenez du fichier /etc/fstab ? Nous avons l'équivalent pour les volumes cryptés : /etc/crypttab, qui prend le format suivant :

<target name> <source device> <key-file> <options>

Les options sont :

  • target name : le nom de notre volume, pour nous mariadbdata

  • source device : le UUID de notre volume. On peut trouver le UUID avec blkid /dev/sda. Dans mon exemple, j'ai trouvé le UUID 9096ac61-20bf-4c88-a770-35e6b71897b7.

  • key-file : le chemin absolu du fichier avec la clé, pour nous /etc/.luks-mariadbdata-key

  • options : les options du système de cryptographie. Pout nous luks

Nous ajoutons alors la ligne suivante :

# Ajouter cette ligne à /etc/crypttab, ex: 
# nano /etc/crypttab
mariadbdata  UUID="9096ac61-20bf-4c88-a770-35e6b71897b7"  /etc/.luks-mariadbdata-key  luks

Cette procédure assurera que le périphérique virtuel est créé (le volume crypté est ouvert). En revanche, il faut quand même monter le volume au démarrage, comme nous avons fait avec un volume normal. On modifie donc /etc/fstab :

# Ajouter cette ligne à /etc/fstab, ex: 
# nano /etc/fstab
/dev/mapper/mariadbdata  /mnt/mariadbdata ext4 defaults,nofail 0 0

Vérifiez bien que vous votre volume monte correctement :

umount /mnt/mariadbdata
mount /mnt/mariadbdata/

Ensuite, redémarrez votre instance pour tester.

La rotation des clés

Vous avez sûrement remarqué qu'on peut ajouter plusieurs clés pour décrypter notre volume. Comment cela fonctionne ?

Il y a deux niveaux de clé :

  1. Une clé pour nous, l'utilisateur

  2. Une clé "interne" connu et utilisé uniquement par LUKS

Les données sur le disque sont cryptées avec la deuxième clé.

Notre clé à nous sert uniquement à décrypter la clé interne.

Cela veut dire qu'on peut crypter plusieurs fois la clé interne avec différentes clés "externe", sans crypter à nouveau toutes les données sur le volume !

L'autre avantage est qu'on peut implémenter de la rotation des clés. Tous les X jours/semaines/mois, nous ajoutons une nouvelle clé, et on supprime l'ancienne. L'idée est d'encore minimiser les dégâts si une des clés est divulguée, car on rend obsolète les clés précédentes.

Le stockage des clés

Pour le moment, nous avons 2 clés :

  • La clé initiale qu'on a mémorisé ou stocké sur notre machine locale

  • La clé dans /etc/.luks-mariadbdata-key qui est stocké sur un autre volume

Vous remarquez qu'aucune clé n'est stockée sur le même volume que les données. Donc si quelqu'un pénètre la data-center et vol le disque dur avec nos données, il ne pourra pas décrypter nos données.

Références:

docker-compose.yml

Nous allons créer un sous-répertoire sur notre volume crypté pour stocker les données de notre SGBDR:

mkdir /mnt/mariadbdata/data

Nous sommes prêts maintenant à rédiger une docker-compose.yml pour production.

Avant, nous avons marqué le mot de passe pour l'utilisateur root directement dans le docker-compose.yml. Ceci est une très mauvaise idée en production, car on pourrait récupérer le mot de passe en lisant le fichier docker-compose.yml.

Nous allons plutôt créer des variables d'environnement dans notre shell (qui persisteront uniquement la durée de notre SHELL) :

export MYSQL_ROOT_PASSWORD=abcd1234
export MYSQL_DATABASE=mydbprod

Dans le docker-compose.yml, nous utilisons ces variables avec ${ NOM_DE_VARIABLE }.

docker-compose.yml
version: '3.9'

services:
  mon_sgbdr:
    image: mariadb
    restart: always
    # port-externe (hôte) : port-interne (container)
    ports:
      - "7100:3306"    
    # variables d'environnement à créer dans le container
    environment:      
      - MYSQL_ALLOW_EMPTY_PASSWORD=false
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
      - MYSQL_DATABASE=${MYSQL_DATABASE}
    # options de lancement (encodage)
    command: [
      "--character-set-server=utf8mb4",
      "--collation-server=utf8mb4_unicode_ci",
    ]
    volumes:
      # stocker les données sur le volume cryptée : /mnt/mariadbdata/data
      - /mnt/mariadbdata/data:/var/lib/mysql
    networks:
      # Ce service existera sur le réseau virtuel suivant
      - sgbdr-network-prod
      
networks:
  sgbdr-network-prod:
    driver: bridge
    name: sgbdr-network-prod

On lance notre DB avec :

docker compose up

Testez la connexion à votre base de données déployée (attention, ici, on utilise le port 7100) !

Obliger une connexion sécurisée

Actuellement, toute communication avec notre SGBDR sera non crypté. Nous ne l'avons pas obligé, et en production, ceci est une très mauvaise pratique ! N'importe qui pourrait sniffer nos communications et dériver nos mots de passes ou nos données secrètes.

Idéalement, on sécurise des communications avec TLS. C'est l'équivalent de la connexion SSL qu'on voit dans les navigateurs. Non seulement on établit des connexions cryptées, mais aussi, on établit un niveau de confiance auprès de notre base de données.

En revanche, créer des certificats exige un nom de domaine, qu'on ne va pas créer pendant ce cours. Je vous encourage quand même à suivre le guide marque dessus.

Pour ce cours, nous nous contenterons à obliger des connexions via SSH exclusivement. C'est-à-dire, nous devons d'abord établir un tunnel SSH vers l'instance hôte, et ensuite envoyer des commandes via ce tunnel crypté.

Ceci est facile avec Docker ! Nous modifions notre docker-compose.yml, en ajoutant 127.0.0.1 devant le port qu'on a ouvert :


version: '3.9'

services:
  mon_sgbdr:
    image: mariadb
    restart: always
    # port-externe (hôte) : port-interne (container)
    ports:
      - "127.0.0.1:7100:3306"    
    ...

En ajoutant 127.0.0.1, nous signalons à Docker qu'il faut accepter uniquement les connexions provenant de l'hôte même, et pas du monde extérieur.

Essayer : modifiez vous-même votre docker-compose.yml, redémarrez votre service, et essayez de vous connecter via votre client local.

La seule façon d'établir une connexion du monde extérieur est d’établir d'abord une connexion SSH à l'instance. Dans votre client, vous auriez l'option de configurer la connexion SSH et fournir la clé privée nécessaire pour la connexion.

Connexions par SSH

Cette stratégie implique que même vos APIs doivent se connecter via SSH, qui n'est pas forcément voulu, surtout s'ils existent sur le réseau local. À ce moment-là, on n'ouvre pas de port du tout (on enlève la partie ports du docker-compose.yml). Seulement les services qui existent sans le réseau docker auront accès (mais en non sécurisé).

Sinon, on peut aussi créer des tunnels SSH dans nos APIs, si nécessaire avec des librairies tierces (ex. ssh2 pour nodejs). En revanche, nous créons une dépendance sur une tech basée sur l'architecture de prod qui n'est pas idéal.

Enfin, il sera peut-être prudent de suivre les démarches de mise en place des certificats TLS. Cette solution est effectivement plus souple dans le long terme.

Sécuriser MariaDB

MySQL (et MariaDB) contient par défaut un script qui permet d’affecter des règles de sécurité de base

  • Désactiver les connexions anonymes

  • Assurer des utilisateurs admin/root ne peuvent connexion uniquement par la machine locale

  • Assurer que l’utilisateur root a un mot de passe (et qu’il est suffisamment sécurisé)

  • Supprimer la base de données de test (parfois installé par défaut)

docker exec -it [Container ID] mysql_secure_installation

Options de production MariaDB

Parfois, il est pertinent de modifier la configuration de MariaDB pour mieux répondre à la charge sur le SGBDR. Par exemple :

  • Le nombre de connexions concurrentes autorisées

  • La durée de vie des connexions avant de les fermer

Par défaut, un fichier de configuration se trouve déjà dans l'image MariaDB à /etc/mysql/mariadb.cnf.

Nous pouvons remplacer ce fichier, simplement en le recréant sur notre instance locale (à côté de docker-compose.yml) :

# The MariaDB configuration file
#
# The MariaDB/MySQL tools read configuration files in the following order:
# 0. "/etc/mysql/my.cnf" symlinks to this file, reason why all the rest is read.
# 1. "/etc/mysql/mariadb.cnf" (this file) to set global defaults,
# 2. "/etc/mysql/conf.d/*.cnf" to set global options.
# 3. "/etc/mysql/mariadb.conf.d/*.cnf" to set MariaDB-only options.
# 4. "~/.my.cnf" to set user-specific options.
#
# If the same option is defined multiple times, the last one will apply.
#
# One can use all long options that the program supports.
# Run program with --help to get a list of available options and with
# --print-defaults to see which it would actually understand and use.
#
# If you are new to MariaDB, check out https://mariadb.com/kb/en/basic-mariadb-articles/

#
# This group is read both by the client and the server
# use it for options that affect everything
#
[client-server]
# Port or socket location where to connect
# port = 3306
socket = /run/mysqld/mysqld.sock

# Import all .cnf files from configuration directory
[mariadbd]
skip-host-cache
skip-name-resolve
max_connections=1000
wait_timeout=130

Nous ajoutons les deux dernières lignes :

  • max_connections : fixer le nombre de connexions total à 1000

  • wait_timeout : fermez les connexions après 130 secondes

Afin d'appliquer ces réglages, il suffit d'ajouter ce fichier dans la partie volumes de docker-compose.yml :

    volumes:
      # stocker les données sur le volume cryptée : /mnt/mariadbdata/data
      - /mnt/mariadbdata/data:/var/lib/mysql
      # remplacer le fichier de configuration avec notre propre version
      - ./mariadb.cnf:/etc/mysql/mariadb.cnf      

Il faut redémarrer votre service pour que la modification soit prise en compte.

À tout moment, on peut récupérer la liste de sous-processus en train de traiter des requêtes, avec (en tant que root) :

SHOW PROCESSLIST;

Dernière mise à jour