MySQL 5.7 in Docker, geschützt mit nftables

Vorbemerkung: warum MySQL 5.7 in Docker

Wir brauchten MySQL 5.7 in Docker auf einem Server, der sonst mit einer aktuellen Distribution läuft. Einige Altanwendungen sind nicht mit MySQL 8 kompatibel (geänderte Standard-Authentifizierung, strengere SQL-Modi, neue reservierte Wörter), und sie kurzfristig zu migrieren kam nicht in Frage.

MySQL 5.7 in Docker: eine Datenbank in einem Kasten mit orangefarbenem gestricheltem Rand, davor ein Kreis und ein Balken

Eine so alte Version direkt auf dem Host zu installieren, bringt mehrere Probleme. Die offiziellen Repositories aktueller Distributionen bieten sie nicht mehr an. Pakete von Drittanbietern können mit dem Rest des Systems in Konflikt geraten, und ein OS-Update könnte sie zerstören.

Wir haben uns deshalb entschieden, sie in einem Docker-Container zu isolieren. Der Host bleibt sauber und aktuell, die Datenbankversion ist festgelegt und reproduzierbar. Daten und Konfiguration liegen in einem Ordner, der sich leicht sichern und verschieben lässt.

Dieser Artikel erzählt, wie wir sie eingerichtet haben und vor allem, auf welche Hindernisse wir unterwegs gestoßen sind. Es sind die unbeabsichtigte Freigabe des Ports 3306 und ein iptables-Fehler beim Neustart. Dazu kam das Schreiben einer Firewall mit nftables, die nur autorisierte IPs durchlässt.

Hinweis: MySQL 5.7 hat im Oktober 2023 das Ende des Supports (EOL) erreicht und erhält keine Sicherheitspatches mehr. Es ist eine Übergangslösung: Das Ziel bleibt die Migration auf MySQL 8.x oder MariaDB. Gerade deshalb ist es umso wichtiger, es nicht offen zu lassen.


1. Ordnerstruktur

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

Die Datei .env enthält die Zugangsdaten, damit sie nicht im Compose landen.

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

Schützen Sie sie mit chmod 600 .env.

2. Die 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

Einige Entscheidungen, die man beachten sollte.

  • restart: unless-stopped: Die Datenbank startet nach einem Neustart des Servers von selbst wieder.
  • Volumes als Bind-Mount: Daten, Konfiguration und Logs liegen auf dem Host, Backups und Migrationen sind also einfache Ordnerkopien.
  • Healthcheck: erlaubt anderen Diensten zu warten, bis MySQL wirklich bereit ist ($$ sorgt dafür, dass die Variable im Container und nicht von Compose aufgelöst wird).
  • ulimits nofile: vermeidet den Fehler „too many open files“ unter Last.

3. Erstes Problem: Der Port von MySQL 5.7 in Docker ist für die ganze Welt offen

Die Zeile - "3306:3306" entspricht 0.0.0.0:3306:3306: Docker veröffentlicht den Port auf allen Schnittstellen des Hosts, auch der öffentlichen.

Das Heimtückischste ist, dass Docker seine eigenen Regeln direkt in die Firewall des Kernels schreibt und diese vor denen von UFW oder firewalld ausgewertet werden. Eine aktive Firewall genügt nicht: Der Port bleibt erreichbar. Die Docker-Dokumentation beschreibt dieses Verhalten unter Packet filtering and firewalls.

Die möglichen Wege sind.

  1. Nur localhost: "127.0.0.1:3306:3306", wenn die Datenbank nur vom Host oder über einen SSH-Tunnel genutzt wird.
  2. Kein veröffentlichter Port: Sind die Clients andere Container, genügt ein gemeinsames Docker-Netzwerk.
  3. Bestimmte IP: nur auf einer Adresse eines privaten Netzes/VPN veröffentlichen.
  4. Öffentlicher Port mit IP-Filter: unser Fall, weil sich ein externer Client (eine phpMyAdmin-Instanz auf einem anderen Server) verbinden können muss.

Für Fall 4 braucht man eine Firewall, die den Verkehr der von Docker veröffentlichten Ports korrekt filtert.

4. Zweites Problem: iptables-Fehler beim Start

Nachdem wir die Firewall angefasst hatten, lieferte docker compose up.

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.

Der Fehler hängt nicht vom Compose ab: Die Kette DOCKER-FORWARD (in neueren Docker-Versionen eingeführt) existierte nicht mehr. Sie war von etwas gelöscht worden, das die Regeln nach dem Start des Daemons bereinigt hatte. Ein Neustart von Docker (systemctl restart docker) legt sie neu an, doch das Problem kehrt zurück, bis der wahre Schuldige beseitigt ist.

Der Schuldige: flush ruleset

Unsere /etc/nftables.conf begann mit.

flush ruleset

Dieser Befehl löscht alle nftables-Tabellen, auch die von Docker angelegten. Außerdem enthielt die Datei eine statische Kopie der Docker-Regeln, mit von Hand eingetragenem Bridge-Namen (br-278550235c3f) und Container-IP (172.18.0.2). Der Bridge-Name ändert sich jedoch jedes Mal, wenn das Compose-Netzwerk neu erstellt wird. Die Kopie war also sofort veraltet, und die Tabelle ip nat geriet in Konflikt mit der von Docker verwalteten.

Goldene Regel: Mit Docker verwendet man in der nftables-Datei kein flush ruleset und kopiert keine seiner Regeln. Man verwaltet nur seine eigene Firewall, in eigenen Tabellen.

5. Die Firewall mit nftables für MySQL 5.7 in Docker

Das Prinzip: Die von Docker veröffentlichten Ports werden mit DNAT umgeschrieben und dann vom Host an den Container weitergeleitet, durchlaufen also die Kette forward, nicht input. Eine Regel drop in einer eigenen Kette forward mit etwas niedrigerer Priorität (also zuerst ausgewertet) verwirft das Paket unabhängig von den accept-Regeln von Docker.

Um fremde Tabellen nicht anzufassen, verwenden wir das Muster table ... + delete table ...: Es macht die Datei ohne Fehler und ohne flush wiederholt ausführbar.

/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
    }
}

Wie die Schutzregel funktioniert.

  • ct status dnat wählt nur Pakete aus, die durch DNAT gelaufen sind, also die für die von Docker veröffentlichten Ports bestimmten.
  • Die erste Regel akzeptiert, wer im Set allowed ist; die zweite verwirft alle anderen und zählt sie (counter).
  • Lokaler Verkehr (127.0.0.1) und Verkehr zwischen Containern im selben Netzwerk läuft nicht über das Forward des Routers und wird daher nicht berührt.

Kompatibilität: In einer ersten Version verwendeten wir ct original proto-dst 3306, aber einige Versionen von nft antworten mit „Can’t parse symbolic invalid expressions“. Im Hook forward ist das DNAT bereits erfolgt und der Container-Port entspricht dem veröffentlichten (3306:3306). Daher ist tcp dport 3306 zusammen mit ct status dnat gleichwertig und funktioniert überall. Ändern Sie das Mapping (z. B. 3307:3306), muss die Regel an den internen Port angepasst werden.

Änderungen anwenden, ohne sich auszusperren

Halten Sie eine zweite SSH-Sitzung als Reserve offen, dann.

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

Damit es beim Booten geladen wird, stellen Sie sicher, dass der Dienst aktiv ist (systemctl enable --now nftables) und dass /etc/nftables.conf kein flush ruleset enthält.

IPs zur Laufzeit verwalten

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

Um es dauerhaft zu machen, aktualisieren Sie auch das Set in der Datei.

6. Überprüfung

Vom autorisierten Server.

nc -zv IP_PUBBLICO_DB 3306        # deve rispondere "succeeded"

Von jeder anderen IP.

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

Auf dem Server muss der Zähler der Drop-Regel bei jedem nicht autorisierten Versuch wachsen.

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

7. Zweite Barriere: die MySQL-Benutzer

Die Firewall ersetzt nicht die Berechtigungen der Datenbank. Legen Sie den Benutzer mit auf die IP des Clients beschränktem Host an.

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;

Gelingt die TCP-Verbindung, MySQL antwortet aber Access denied, liegt das Problem hier und nicht in der Firewall.

8. Was man sich zu MySQL 5.7 in Docker merken sollte

  • Docker und die Host-Firewall ignorieren sich nicht: Zu verstehen, welche Kette der Verkehr durchläuft (input oder forward), ist der Schlüssel zu allem.
  • Niemals flush ruleset auf einem Host mit Docker; verwenden Sie eigene Tabellen.
  • Kopieren Sie keine von Docker erzeugten Regeln in Ihre Dateien: Bridge-Namen und IPs ändern sich.
  • Haben Sie den Port schon freigegeben, prüfen Sie die Logs in ./logs und erwägen Sie, die Passwörter zu ändern, vor allem das von root.
  • Wo möglich, ziehen Sie ein VPN (WireGuard) oder einen SSH-Tunnel der öffentlichen Freigabe einer Datenbank vor.
  • Planen Sie die Migration von MySQL 5.7 auf eine unterstützte Version.

Haben Sie Fragen oder eine andere Lösung für dasselbe Problem? Schreiben Sie es in die Kommentare. Zum Thema MySQL und Debian lesen Sie auch: MySQL startet nach dem Debian-12-Upgrade nicht mehr.

Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

WordPress Cookie Hinweis von Real Cookie Banner