MySQL 5.7 dans Docker protégé par nftables

Préambule : MySQL 5.7 dans Docker

Nous avions besoin d’une base MySQL 5.7 dans Docker. Le serveur, lui, tourne pour le reste avec une distribution récente. Certaines applications héritées ne sont pas compatibles avec MySQL 8. Les raisons : changement du plugin d’authentification par défaut, modes SQL plus stricts, nouveaux mots réservés. Les migrer rapidement n’était pas envisageable.

MySQL 5.7 dans Docker : une base de données dans un cadre à contour orange en pointillés, avec un cercle et une barre

Installer une version aussi ancienne directement sur l’hôte pose plusieurs problèmes. Les dépôts officiels des distributions actuelles ne la proposent plus. Les paquets tiers risquent d’entrer en conflit avec le reste du système. Une mise à jour de l’OS pourrait la casser.

Nous avons donc décidé de l’isoler dans un conteneur Docker. L’hôte reste propre et à jour. La version de la base est figée et reproductible. Données et configuration se trouvent dans un dossier facile à sauvegarder et à déplacer.

Cet article raconte comment nous l’avons configurée et, surtout, les obstacles rencontrés en chemin. Il y a l’exposition involontaire du port 3306 et une erreur iptables au redémarrage. Il y a aussi l’écriture d’un pare-feu avec nftables qui ne laisse passer que les IP autorisées.

Note : MySQL 5.7 a atteint sa fin de vie (EOL) en octobre 2023. Il ne reçoit plus de correctifs de sécurité. C’est une solution de transition : l’objectif reste de migrer vers MySQL 8.x ou MariaDB. C’est précisément pour cela qu’il est encore plus important de ne pas la laisser exposée.


1. Structure du dossier

/opt/mysql57/
├── docker-compose.yml
├── .env
├── conf.d/        # configurazioni personalizzate (my.cnf aggiuntivi)
├── data/          # dati del database (volume persistente)
└── logs/

Le fichier .env contient les identifiants, afin qu’ils ne se retrouvent pas dans le compose. Voici son contenu :

TZ=Europe/Rome
MYSQL_ROOT_PASSWORD=una_password_molto_lunga_e_casuale
MYSQL_DATABASE=nome_db
MYSQL_USER=nome_utente
MYSQL_PASSWORD=altra_password_robusta

Protégez-le avec chmod 600 .env.

2. Le docker-compose.yml

services:
  mysql57:
    image: mysql:5.7
    container_name: mysql57
    restart: unless-stopped
    env_file:
      - .env
    ports:
      - "3306:3306"
    environment:
      TZ: "${TZ:-Europe/Rome}"
      MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
      MYSQL_DATABASE: "${MYSQL_DATABASE}"
      MYSQL_USER: "${MYSQL_USER}"
      MYSQL_PASSWORD: "${MYSQL_PASSWORD}"
    command:
      - --default-authentication-plugin=mysql_native_password
    volumes:
      - ./data:/var/lib/mysql
      - ./conf.d:/etc/mysql/conf.d:ro
      - ./logs:/var/log/mysql
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
      interval: 20s
      timeout: 5s
      retries: 10
      start_period: 45s
    ulimits:
      nofile:
        soft: 65535
        hard: 65535

Quelques choix à noter :

  • restart: unless-stopped : la base redémarre toute seule après un redémarrage du serveur.
  • Volumes en bind mount : données, configuration et logs sont sur l’hôte. Sauvegardes et migrations se résument donc à des copies de dossiers.
  • Healthcheck : permet aux autres services d’attendre que MySQL soit vraiment prêt. Le $$ sert à faire développer la variable dans le conteneur et non par Compose.
  • ulimits nofile : évite l’erreur « too many open files » sous charge.

3. Premier problème : MySQL 5.7 dans Docker exposé

La ligne - "3306:3306" équivaut à 0.0.0.0:3306:3306. Docker publie le port sur toutes les interfaces de l’hôte, y compris la publique.

Le plus insidieux : Docker écrit ses propres règles directement dans le pare-feu du noyau. Elles sont évaluées avant celles d’UFW ou de firewalld. Avoir un pare-feu actif ne suffit pas : le port reste joignable.

Voici les solutions possibles.

  1. Localhost uniquement : "127.0.0.1:3306:3306", si la base ne sert qu’à l’hôte ou via un tunnel SSH.
  2. Aucun port publié : si les clients sont d’autres conteneurs, un réseau Docker partagé suffit.
  3. IP précise : publier uniquement sur une adresse d’un réseau privé/VPN.
  4. Port public avec filtrage par IP : notre cas. Un client externe (une instance phpMyAdmin sur un autre serveur) doit pouvoir se connecter.

Pour le cas 4, il faut un pare-feu qui filtre correctement le trafic des ports publiés par Docker. La documentation de Docker sur le filtrage des paquets explique comment Docker interagit avec les règles de l’hôte.

4. Deuxième problème : erreur iptables au démarrage

Après avoir touché au pare-feu, docker compose up a renvoyé cette erreur.

failed to create network mysql57_default: Error response from daemon:
Failed to Setup IP tables: Unable to enable ACCEPT OUTGOING rule:
(iptables failed: iptables --wait -t filter -A DOCKER-FORWARD -i br-... -j ACCEPT:
iptables: No chain/target/match by that name.

L’erreur ne dépend pas du compose. La chaîne DOCKER-FORWARD (introduite dans les versions récentes de Docker) n’existait plus. Elle avait été supprimée par quelque chose qui avait nettoyé les règles après le démarrage du démon. Un redémarrage de Docker (systemctl restart docker) la recrée. Mais le problème revient tant que le vrai coupable n’est pas éliminé.

Le coupable : flush ruleset

Notre /etc/nftables.conf commençait par cette ligne.

flush ruleset

Cette commande supprime toutes les tables nftables, y compris celles créées par Docker. De plus, le fichier contenait une copie statique des règles de Docker. Le nom du bridge (br-278550235c3f) et l’IP du conteneur (172.18.0.2) y étaient écrits à la main. Or le nom du bridge change chaque fois que le réseau Compose est recréé. La copie devenait aussitôt obsolète. La table ip nat entrait en conflit avec celle gérée par Docker.

Règle d’or : avec Docker, dans le fichier nftables on n’utilise pas flush ruleset. On ne copie pas ses règles. On ne gère que son propre pare-feu, dans des tables dédiées.

5. Le pare-feu avec nftables

Le principe est le suivant pour MySQL 5.7 dans Docker. Les ports publiés par Docker sont réécrits par DNAT, puis transférés par l’hôte vers le conteneur. Ils traversent donc la chaîne forward, pas input. Une règle drop dans une chaîne à nous, forward, rejette le paquet. Sa priorité est légèrement plus basse, donc elle est évaluée avant. Les règles accept de Docker n’y changent rien.

Pour ne pas toucher aux tables des autres, nous utilisons le motif table ... + delete table ... : il rend le fichier ré-exécutable sans erreur et sans flush.

/etc/nftables.conf:

#!/usr/sbin/nft -f

# NIENTE "flush ruleset": cancellerebbe anche le tabelle di Docker

# ---------------- firewall host ----------------
table inet filter
delete table inet filter

table inet filter {

    chain input {
        type filter hook input priority filter; policy drop;
        ct state established,related accept
        iifname "lo" accept
        ip6 nexthdr icmpv6 accept
        iifname "enp7s0" accept                       # rete privata
        ip saddr 203.0.113.20 tcp dport 22 accept     # SSH da host fidato
        ip saddr 203.0.113.30 tcp dport 22 accept     # SSH da VPN
        ip saddr 203.0.113.20 icmp type echo-request accept
        tcp dport { 80, 443 } accept
    }

    chain output {
        type filter hook output priority filter; policy accept;
    }
}

# ---------------- guardia MySQL (porta pubblicata da Docker) ----------------
table ip mysql_guard
delete table ip mysql_guard

table ip mysql_guard {
    set allowed {
        type ipv4_addr
        flags interval
        elements = {
            203.0.113.10        # phpMyAdmin
        }
    }

    chain forward {
        type filter hook forward priority filter - 5; policy accept;
        iifname "enp7s0" accept
        ct status dnat tcp dport 3306 ip saddr @allowed accept
        ct status dnat tcp dport 3306 counter drop
    }
}

Voici comment fonctionne la garde.

  • ct status dnat ne sélectionne que les paquets passés par le DNAT, c’est-à-dire ceux destinés aux ports publiés par Docker.
  • La première règle accepte ceux qui sont dans l’ensemble allowed. La seconde rejette tous les autres et les compte (counter).
  • Le trafic local (127.0.0.1) et celui entre conteneurs du même réseau ne passent pas par le forward. Ils ne sont donc pas touchés.

Compatibilité : dans une première version, nous utilisions ct original proto-dst 3306. Mais certaines versions de nft répondent par « Can’t parse symbolic invalid expressions ». Dans le hook forward, le DNAT a déjà eu lieu. Le port du conteneur coïncide avec le port publié (3306:3306). L’expression tcp dport 3306 combinée à ct status dnat est donc équivalente et fonctionne partout. Si vous changez le mapping (par exemple 3307:3306), la règle doit être adaptée au port interne.

Appliquer les modifications sans se verrouiller dehors

Gardez une session SSH de secours ouverte. Puis lancez les commandes suivantes.

nft -c -f /etc/nftables.conf     # solo verifica della sintassi
nft delete table ip nat          # una tantum: elimina la vecchia copia statica
nft -f /etc/nftables.conf
systemctl restart docker         # Docker ricrea le sue tabelle pulite
cd /opt/mysql57 && docker compose up -d

Pour le chargement au démarrage, assurez-vous que le service est activé (systemctl enable --now nftables). Vérifiez aussi que /etc/nftables.conf ne contient pas flush ruleset.

Gérer les IP à l’exécution

nft add element ip mysql_guard allowed { 203.0.113.50 }
nft delete element ip mysql_guard allowed { 203.0.113.50 }

Pour le rendre permanent, mettez aussi à jour l’ensemble dans le fichier.

6. Vérification

Depuis le serveur autorisé, testez.

nc -zv IP_PUBBLICO_DB 3306        # deve rispondere "succeeded"

Depuis une autre IP, testez à nouveau.

nmap -Pn -p 3306 IP_PUBBLICO_DB   # deve risultare "filtered" / timeout

Sur le serveur, le compteur de la règle de rejet doit augmenter à chaque tentative non autorisée.

nft list chain ip mysql_guard forward
ss -tlnp | grep 3306

7. Seconde barrière : les utilisateurs MySQL

Le pare-feu ne remplace pas les droits de la base de données. Créez l’utilisateur avec un hôte limité à l’IP du client.

CREATE USER 'pma_user'@'203.0.113.10' IDENTIFIED BY 'password_robusta';
GRANT ALL ON nome_db.* TO 'pma_user'@'203.0.113.10';
FLUSH PRIVILEGES;

Si la connexion TCP aboutit mais que MySQL répond Access denied, le problème est ici. Il n’est pas dans le pare-feu.

8. À retenir

  • Docker et le pare-feu de l’hôte ne s’ignorent pas. Comprendre quelle chaîne le trafic traverse (input ou forward) est la clé de tout.
  • Jamais flush ruleset sur un hôte avec Docker ; utilisez des tables dédiées.
  • Ne copiez pas les règles générées par Docker dans vos fichiers : noms de bridge et IP changent.
  • Si vous avez déjà exposé le port, vérifiez les logs dans ./logs. Envisagez aussi de changer les mots de passe, surtout celui de root.
  • Quand c’est possible, préférez un VPN (WireGuard) ou un tunnel SSH à l’exposition publique d’une base de données.
  • Planifiez la migration de MySQL 5.7 vers une version prise en charge.

Si MySQL refuse de redémarrer après une mise à jour, voyez aussi MySQL qui ne démarre plus sous Debian 12.

Vous avez des questions ou une solution différente pour MySQL 5.7 dans Docker ? Écrivez-le dans les commentaires.

Commentaires

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Mentions de Cookies WordPress par Real Cookie Banner