Premessa
Ci siamo trovati nella necessità di avere un database MySQL 5.7 su un server che, per il resto, gira con una distribuzione recente. Alcune applicazioni legacy non sono compatibili con MySQL 8 (cambio del plugin di autenticazione predefinito, modalità SQL più rigide, parole riservate nuove) e migrarle in tempi brevi non era un’opzione.
Installare una versione così datata direttamente sull’host comporta diversi problemi: i repository ufficiali delle distribuzioni attuali non la offrono più, i pacchetti di terze parti rischiano di entrare in conflitto con il resto del sistema, e un eventuale aggiornamento dell’OS potrebbe romperla.
Abbiamo quindi deciso di isolarla in un container Docker: l’host resta pulito e aggiornato, la versione del database è fissata e riproducibile, e dati e configurazione stanno in una cartella facile da salvare e spostare.
Questo articolo racconta come l’abbiamo configurata e, soprattutto, gli ostacoli incontrati lungo la strada: l’esposizione involontaria della porta 3306, un errore di iptables al riavvio e la scrittura di un firewall con nftables che lasci passare solo gli IP autorizzati.
Nota: MySQL 5.7 è giunto a fine vita (EOL) a ottobre 2023 e non riceve più patch di sicurezza. È una soluzione ponte: l’obiettivo resta migrare a MySQL 8.x o MariaDB. Proprio per questo è ancora più importante non lasciarlo esposto.
1. Struttura della cartella
/opt/mysql57/
├── docker-compose.yml
├── .env
├── conf.d/ # configurazioni personalizzate (my.cnf aggiuntivi)
├── data/ # dati del database (volume persistente)
└── logs/
Il file .env contiene le credenziali, così non finiscono nel compose:
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
Proteggilo con chmod 600 .env.
2. Il 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
Alcune scelte da notare:
restart: unless-stopped: il database riparte da solo dopo un riavvio del server.- Volumi con bind mount: dati, configurazione e log sono sull’host, quindi backup e migrazioni sono semplici copie di cartelle.
- Healthcheck: permette agli altri servizi di aspettare che MySQL sia davvero pronto (
$$serve a far espandere la variabile dentro il container e non da Compose). ulimits nofile: evita l’errore “too many open files” sotto carico.
3. Primo problema: la porta è aperta a tutto il mondo
La riga - "3306:3306" equivale a 0.0.0.0:3306:3306: Docker pubblica la porta su tutte le interfacce dell’host, compresa quella pubblica.
La parte più insidiosa è che Docker scrive le proprie regole direttamente nel firewall del kernel e queste vengono valutate prima di quelle di UFW o firewalld. Avere un firewall attivo non basta: la porta resta raggiungibile.
Le strade possibili sono:
- Solo localhost:
"127.0.0.1:3306:3306", se il database serve solo all’host o tramite tunnel SSH. - Nessuna porta pubblicata: se i client sono altri container, basta una rete Docker condivisa.
- IP specifico: pubblicare solo su un indirizzo di una rete privata/VPN.
- Porta pubblica con filtro per IP: il nostro caso, perché un client esterno (un’istanza di phpMyAdmin su un altro server) deve poter collegarsi.
Per il caso 4 serve un firewall che filtri correttamente il traffico delle porte pubblicate da Docker.
4. Secondo problema: errore di iptables all’avvio
Dopo aver toccato il firewall, docker compose up ha restituito:
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’errore non dipende dal compose: la catena DOCKER-FORWARD (introdotta nelle versioni recenti di Docker) non esisteva più. Era stata cancellata da qualcosa che aveva ripulito le regole dopo l’avvio del daemon. Un riavvio di Docker (systemctl restart docker) la ricrea, ma il problema si ripresenta finché il vero colpevole non viene eliminato.
Il colpevole: flush ruleset
Il nostro /etc/nftables.conf iniziava con:
flush ruleset
Questo comando cancella tutte le tabelle nftables, comprese quelle create da Docker. Inoltre il file conteneva una copia statica delle regole di Docker, con il nome del bridge (br-278550235c3f) e l’IP del container (172.18.0.2) scritti a mano. Ma il nome del bridge cambia ogni volta che la rete Compose viene ricreata, quindi la copia diventava subito obsoleta, e la tabella ip nat entrava in conflitto con quella gestita da Docker.
Regola d’oro: con Docker, nel file nftables non si usa flush ruleset e non si copiano le sue regole. Si gestisce solo il proprio firewall, in tabelle dedicate.
5. Il firewall con nftables
Il principio è questo: le porte pubblicate da Docker vengono riscritte con DNAT e poi inoltrate dall’host verso il container, quindi attraversano la catena forward, non input. Una regola drop in una nostra catena forward con priorità leggermente più bassa (quindi valutata prima) scarta il pacchetto a prescindere dalle regole accept di Docker.
Per non toccare le tabelle altrui, usiamo il pattern table ... + delete table ...: rende il file rieseguibile senza errori e senza 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
}
}
Come funziona la guardia:
ct status dnatseleziona solo i pacchetti passati dal DNAT, cioè quelli diretti alle porte pubblicate da Docker.- La prima regola accetta chi è nel set
allowed; la seconda scarta tutti gli altri e li conta (counter). - Il traffico locale (
127.0.0.1) e quello tra container sulla stessa rete non passa dal forward del router, quindi non viene toccato.
Compatibilità: in una prima versione usavamo
ct original proto-dst 3306, ma alcune versioni dinftrispondono con “Can’t parse symbolic invalid expressions”. Nel hookforwardil DNAT è già avvenuto e la porta del container coincide con quella pubblicata (3306:3306), quinditcp dport 3306combinato conct status dnatè equivalente e funziona ovunque. Se cambi il mapping (es.3307:3306), la regola va adattata alla porta interna.
Applicare le modifiche senza chiudersi fuori
Tieni aperta una sessione SSH di riserva, poi:
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
Per il caricamento al boot, assicurati che il servizio sia attivo (systemctl enable --now nftables) e che /etc/nftables.conf non contenga flush ruleset.
Gestire gli IP a runtime
nft add element ip mysql_guard allowed { 203.0.113.50 }
nft delete element ip mysql_guard allowed { 203.0.113.50 }
Per renderlo permanente, aggiorna anche il set nel file.
6. Verifica
Dal server autorizzato:
nc -zv IP_PUBBLICO_DB 3306 # deve rispondere "succeeded"
Da qualsiasi altro IP:
nmap -Pn -p 3306 IP_PUBBLICO_DB # deve risultare "filtered" / timeout
Sul server, il contatore della regola di drop deve crescere a ogni tentativo non autorizzato:
nft list chain ip mysql_guard forward
ss -tlnp | grep 3306
7. Seconda barriera: gli utenti MySQL
Il firewall non sostituisce i permessi del database. Crea l’utente con host limitato all’IP del 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;
Se la connessione TCP riesce ma MySQL risponde Access denied, il problema è qui e non nel firewall.
8. Cose da ricordare
- Docker e firewall dell’host non si ignorano a vicenda: capire quale catena attraversa il traffico (
inputoforward) è la chiave di tutto. - Mai
flush rulesetsu un host con Docker; usa tabelle dedicate. - Non copiare le regole generate da Docker nei tuoi file: nomi di bridge e IP cambiano.
- Se hai già esposto la porta, controlla i log in
./logse valuta di cambiare le password, soprattutto quella di root. - Dove possibile, preferisci VPN (WireGuard) o tunnel SSH all’esposizione pubblica di un database.
- Pianifica la migrazione da MySQL 5.7 a una versione supportata.
Hai domande o una soluzione diversa per lo stesso problema? Scrivilo nei commenti.
Lascia un commento