Introducción
Nos encontramos con la necesidad de tener una base de datos MySQL 5.7 en un servidor que, por lo demás, funciona con una distribución reciente. Algunas aplicaciones heredadas no son compatibles con MySQL 8 (cambio del plugin de autenticación predeterminado, modos SQL más estrictos, palabras reservadas nuevas). Migrarlas en poco tiempo no era una opción. La salida fue ejecutar MySQL 5.7 en Docker.

Instalar una versión tan antigua directamente en el host trae varios problemas. Los repositorios oficiales de las distribuciones actuales ya no la ofrecen, los paquetes de terceros pueden chocar con el resto del sistema. Y una actualización del sistema operativo podría romperla.
Decidimos entonces aislarla en un contenedor Docker. El host se mantiene limpio y actualizado, la versión de la base de datos queda fijada y es reproducible. Los datos y la configuración están en una carpeta fácil de guardar y mover.
Este artículo cuenta cómo la configuramos y, sobre todo, los obstáculos que encontramos por el camino. Fueron la exposición involuntaria del puerto 3306 y un error de iptables al reiniciar. Después, la escritura de un cortafuegos con nftables que deje pasar solo las IP autorizadas.
Nota: MySQL 5.7 llegó al final de su vida útil (EOL) en octubre de 2023 y ya no recibe parches de seguridad. Es una solución puente: el objetivo sigue siendo migrar a MySQL 8.x o MariaDB. Precisamente por eso es todavía más importante no dejarlo expuesto.
1. Estructura de la carpeta
/opt/mysql57/
├── docker-compose.yml
├── .env
├── conf.d/ # configurazioni personalizzate (my.cnf aggiuntivi)
├── data/ # dati del database (volume persistente)
└── logs/
El archivo .env contiene las credenciales, para que no acaben en el 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
Protégelo con chmod 600 .env.
2. El docker-compose.yml de MySQL 5.7 en Docker
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
Algunas decisiones que conviene señalar.
restart: unless-stopped: la base de datos arranca sola tras un reinicio del servidor.- Volúmenes con bind mount: datos, configuración y logs están en el host. Así, las copias de seguridad y las migraciones son simples copias de carpetas.
- Healthcheck: permite que los demás servicios esperen a que MySQL esté realmente listo. El
$$sirve para que la variable se expanda dentro del contenedor y no por Compose. ulimits nofile: evita el error «too many open files» bajo carga.
3. Primer problema: el puerto está abierto a todo el mundo
La línea - "3306:3306" equivale a 0.0.0.0:3306:3306. Docker publica el puerto en todas las interfaces del host, incluida la pública.
Lo más insidioso es que Docker escribe sus propias reglas directamente en el cortafuegos del kernel. Estas se evalúan antes que las de UFW o firewalld. Tener un cortafuegos activo no basta: el puerto sigue siendo alcanzable. La documentación de Docker sobre filtrado de paquetes y cortafuegos describe este comportamiento.
Los caminos posibles son estos.
- Solo localhost:
"127.0.0.1:3306:3306", si la base de datos solo la usa el host o se accede por túnel SSH. - Ningún puerto publicado: si los clientes son otros contenedores, basta una red Docker compartida.
- IP concreta: publicar solo en una dirección de una red privada/VPN.
- Puerto público con filtro por IP: nuestro caso. Un cliente externo (una instancia de phpMyAdmin en otro servidor) tiene que poder conectarse.
Para el caso 4 hace falta un cortafuegos que filtre correctamente el tráfico de los puertos publicados por Docker.
4. Segundo problema: error de iptables al arrancar
Después de tocar el cortafuegos, docker compose up devolvió.
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.
El error no depende del compose: la cadena DOCKER-FORWARD (introducida en las versiones recientes de Docker) ya no existía. La había borrado algo que limpió las reglas después de arrancar el demonio. Reiniciar Docker (systemctl restart docker) la recrea, pero el problema reaparece mientras no se elimine al verdadero culpable.
El culpable: flush ruleset
Nuestro /etc/nftables.conf empezaba con.
flush ruleset
Este comando borra todas las tablas de nftables, incluidas las creadas por Docker. Además, el archivo contenía una copia estática de las reglas de Docker. Llevaba el nombre del bridge (br-278550235c3f) y la IP del contenedor (172.18.0.2) escritos a mano. Pero el nombre del bridge cambia cada vez que se recrea la red de Compose. Así, la copia quedaba obsoleta enseguida, y la tabla ip nat entraba en conflicto con la gestionada por Docker.
Regla de oro: con Docker, en el archivo de nftables no se usa flush ruleset y no se copian sus reglas. Solo se gestiona el cortafuegos propio, en tablas dedicadas.
5. El cortafuegos para MySQL 5.7 en Docker
El principio es este. Los puertos publicados por Docker se reescriben con DNAT y luego el host los reenvía al contenedor. Así, atraviesan la cadena forward, no input. Una regla drop en una cadena nuestra, forward, con una prioridad ligeramente más baja (es decir, evaluada antes) descarta el paquete sin importar las reglas accept de Docker.
Para no tocar las tablas ajenas usamos el patrón table ... + delete table ...: hace que el archivo se pueda volver a ejecutar sin errores y sin 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
}
}
Cómo funciona la guardia.
ct status dnatselecciona solo los paquetes que han pasado por el DNAT, es decir, los dirigidos a los puertos publicados por Docker.- La primera regla acepta a quien esté en el conjunto
allowed. La segunda descarta a todos los demás y los cuenta (counter). - El tráfico local (
127.0.0.1) y el que va entre contenedores de la misma red no pasa por el forward del router. Así que no se toca.
Compatibilidad: en una primera versión usábamos
ct original proto-dst 3306. Pero algunas versiones denftresponden con «Can’t parse symbolic invalid expressions». En el hookforwardel DNAT ya ha ocurrido y el puerto del contenedor coincide con el publicado (3306:3306). Por esotcp dport 3306combinado conct status dnates equivalente y funciona en todas partes. Si cambias el mapeo (p. ej.3307:3306), la regla debe adaptarse al puerto interno.
Aplicar los cambios sin quedarse fuera
Mantén abierta una sesión SSH de reserva y después.
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
Para la carga en el arranque, asegúrate de que el servicio esté activo (systemctl enable --now nftables). Comprueba también que /etc/nftables.conf no contenga flush ruleset.
Gestionar las IP en tiempo de ejecución
nft add element ip mysql_guard allowed { 203.0.113.50 }
nft delete element ip mysql_guard allowed { 203.0.113.50 }
Para hacerlo permanente, actualiza también el conjunto en el archivo.
6. Verificación del acceso a MySQL 5.7 en Docker
Desde el servidor autorizado.
nc -zv IP_PUBBLICO_DB 3306 # deve rispondere "succeeded"
Desde cualquier otra IP.
nmap -Pn -p 3306 IP_PUBBLICO_DB # deve risultare "filtered" / timeout
En el servidor, el contador de la regla de descarte debe crecer con cada intento no autorizado.
nft list chain ip mysql_guard forward
ss -tlnp | grep 3306
7. Segunda barrera: los usuarios de MySQL
El cortafuegos no sustituye los permisos de la base de datos. Crea el usuario con el host limitado a la IP del cliente.
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 conexión TCP se establece pero MySQL responde Access denied, el problema está aquí y no en el cortafuegos.
8. Cosas que recordar
- Docker y el cortafuegos del host no se ignoran mutuamente. Entender por qué cadena pasa el tráfico (
inputoforward) es la clave de todo. - Nunca
flush ruleseten un host con Docker; usa tablas dedicadas. - No copies las reglas generadas por Docker en tus archivos: los nombres de bridge y las IP cambian.
- Si ya has expuesto el puerto, revisa los logs en
./logsy valora cambiar las contraseñas, sobre todo la de root. - Siempre que sea posible, prefiere una VPN (WireGuard) o un túnel SSH a la exposición pública de una base de datos.
- Planifica la migración de MySQL 5.7 a una versión con soporte.
Si lo que te da problemas es el arranque tras una actualización de Debian, tienes este otro caso: MySQL no arranca tras actualizar a Debian 12.
¿Tienes dudas sobre MySQL 5.7 en Docker o una solución distinta para el mismo problema? Escríbelo en los comentarios.
Deja una respuesta