Respaldos de MariaDB con Python: mariadb-dump, Rotación y systemd
El respaldo más común de MariaDB es una línea en el crontab:
1mysqldump --all-databases | gzip > /backup/db-$(date +%F).sql.gz
Funciona hasta el día en que no. Si mysqldump falla a la mitad — el servidor se reinicia, se llena el disco, una tabla está corrupta — gzip recibe un flujo cortado, lo comprime sin quejarse y el pipeline termina con código 0, porque el shell solo mira el código del último comando. Tienes un archivo con fecha de hoy, tamaño razonable y la mitad de tus datos. Te enteras cuando intentas restaurar.
Este post construye un script en Python que resuelve eso y otros detalles que la línea del crontab ignora: revisa el código de salida y el final de cada volcado, separa cada base en su propio archivo, respalda usuarios y permisos, genera checksums, rota los respaldos viejos solo cuando el actual salió bien y corre desde un timer de systemd. Usa solo la librería estándar: nada de pip install.
Todo lo que aparece aquí se probó contra MariaDB 12.3, incluyendo la restauración. Funciona igual desde MariaDB 10.5, donde los binarios pasaron a llamarse mariadb y mariadb-dump (los nombres mysql y mysqldump siguen existiendo como enlaces, y el script los usa como respaldo).
Lógico o físico
Hay dos formas de respaldar MariaDB, y conviene elegir a propósito:
Lógico (mariadb-dump) | Físico (mariadb-backup) | |
|---|---|---|
| Qué genera | SQL: CREATE TABLE, INSERT… | Copia de los archivos de datos de InnoDB |
| Portabilidad | Entre versiones y arquitecturas | Misma versión mayor de MariaDB |
| Restaurar una sola tabla | Fácil (es texto) | Posible, pero laborioso |
| Velocidad de restauración | Lenta: re-ejecuta todo y reconstruye índices | Rápida: copiar archivos y arrancar |
| Tamaño práctico | Hasta decenas de GB | Cientos de GB o más |
Para la mayoría de servidores — aplicaciones web, un ERP, un Zabbix pequeño, el FreeRADIUS de un ISP — el respaldo lógico es la opción correcta: es legible, portable y fácil de restaurar parcialmente. Cuando la restauración de un volcado empieza a tardar horas, es momento de pasar a mariadb-backup. Hay una nota sobre eso al final.
1. Un usuario solo para respaldos
No uses root. Crea un usuario con los privilegios mínimos para leer todo sin poder modificar nada:
1CREATE USER 'backup'@'localhost' IDENTIFIED BY 'una-contraseña-larga-y-aleatoria';
2
3GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES, EVENT, PROCESS
4 ON *.* TO 'backup'@'localhost';
| Privilegio | Para qué lo necesita mariadb-dump |
|---|---|
SELECT | Leer los datos, y mysql.proc para los procedimientos |
SHOW VIEW | Volcar la definición de las vistas |
TRIGGER | Volcar los triggers |
LOCK TABLES | Bloquear tablas que no son InnoDB durante el volcado |
EVENT | Volcar los eventos programados |
PROCESS | Leer información de tablespaces |
'backup'@'localhost' solo acepta conexiones locales: el script corre en el mismo servidor.
2. Credenciales fuera de la línea de comandos
Pasar la contraseña con -p la deja visible en ps para cualquier usuario del sistema. En su lugar, guárdala en un archivo de opciones que solo root pueda leer:
1sudo mkdir -p /etc/mariadb-backup
2sudo tee /etc/mariadb-backup/backup.cnf > /dev/null <<'CNF'
3[client]
4user = backup
5password = una-contraseña-larga-y-aleatoria
6# socket = /run/mysqld/mysqld.sock
7CNF
8sudo chmod 600 /etc/mariadb-backup/backup.cnf
Si el cliente no encuentra el socket, descomenta la línea socket con la ruta de tu distribución (Debian usa /run/mysqld/mysqld.sock; openSUSE, /run/mysql/mysql.sock).
El script le pasa este archivo a cada comando con --defaults-extra-file. Pruébalo a mano:
1sudo mariadb --defaults-extra-file=/etc/mariadb-backup/backup.cnf -e 'SHOW DATABASES'
--defaults-extra-file debe ser la primera opción de la línea de comandos; si va después de otra, el cliente lo ignora o falla.
3. Las opciones de mariadb-dump
Antes del script, vale la pena entender qué le vamos a pedir a mariadb-dump, porque los valores por defecto no bastan para un respaldo completo:
| Opción | Qué hace |
|---|---|
--single-transaction | Abre una transacción REPEATABLE READ y vuelca todo desde esa foto. Consistente para InnoDB sin bloquear escrituras. |
--quick | Lee fila por fila en lugar de cargar cada tabla completa en memoria. |
--routines | Incluye procedimientos y funciones almacenadas (no van por defecto). |
--triggers | Incluye triggers (van por defecto, pero mejor explícito). |
--events | Incluye eventos del event scheduler (no van por defecto). |
--hex-blob | Escribe columnas binarias en hexadecimal, inmune a problemas de codificación. |
--default-character-set=utf8mb4 | Evita que los acentos y emojis se corrompan en el viaje. |
Lo que no usamos: --all-databases. Un solo archivo con todo es cómodo hasta que necesitas restaurar una base de 200 MB que está enterrada en un volcado de 40 GB. El script vuelca cada base por separado.
Tampoco volcamos la base mysql directamente. Sus tablas internas cambian entre versiones, y restaurarlas sobre un servidor más nuevo puede romperlo. En su lugar, el script genera un _grants.sql con sentencias CREATE USER y GRANT, que son portables.
4. El script
Guárdalo como /usr/local/sbin/mariadb-backup.py:
1#!/usr/bin/env python3
2"""Respaldo lógico de MariaDB: un archivo .sql.gz por base de datos."""
3
4import argparse
5import fcntl
6import gzip
7import hashlib
8import logging
9import os
10import shutil
11import subprocess
12import sys
13import tempfile
14from datetime import datetime, timedelta
15from pathlib import Path
16
17EXCLUDE = {"information_schema", "performance_schema", "sys", "mysql"}
18DUMP_OPTS = [
19 "--single-transaction",
20 "--quick",
21 "--routines",
22 "--triggers",
23 "--events",
24 "--hex-blob",
25 "--default-character-set=utf8mb4",
26]
27STAMP = "%Y%m%d-%H%M%S"
28CHUNK = 1024 * 1024
29
30log = logging.getLogger("mariadb-backup")
31
32
33class BackupError(Exception):
34 pass
35
36
37def find_binary(*names):
38 for name in names:
39 path = shutil.which(name)
40 if path:
41 return path
42 raise BackupError(f"no se encontró ninguno de: {', '.join(names)}")
43
44
45def run_query(client, defaults_file, sql):
46 """Ejecuta SQL con el cliente mariadb y devuelve las filas como listas."""
47 result = subprocess.run(
48 [client, f"--defaults-extra-file={defaults_file}", "-N", "-B", "-r", "-e", sql],
49 capture_output=True, text=True,
50 )
51 if result.returncode != 0:
52 raise BackupError(result.stderr.strip())
53 return [line.split("\t") for line in result.stdout.splitlines()]
54
55
56def list_databases(client, defaults_file):
57 rows = run_query(client, defaults_file, "SHOW DATABASES")
58 return [row[0] for row in rows if row[0] not in EXCLUDE]
59
60
61def dump_grants(client, defaults_file, dest):
62 """Genera CREATE USER + GRANT para cada usuario (sin roles ni cuentas internas)."""
63 users = run_query(
64 client, defaults_file,
65 "SELECT user, host FROM mysql.user "
66 "WHERE is_role = 'N' AND user NOT IN ('', 'mariadb.sys')",
67 )
68 if not users:
69 return
70 sql = ""
71 for user, host in users:
72 account = "'{}'@'{}'".format(user.replace("'", "''"), host.replace("'", "''"))
73 sql += f"SHOW CREATE USER {account}; SHOW GRANTS FOR {account};"
74 rows = run_query(client, defaults_file, sql)
75 dest.write_text("".join(f"{row[0]};\n" for row in rows))
76
77
78def dump_database(dump_bin, defaults_file, db, dest):
79 """Vuelca una base a dest (.sql.gz). Escribe primero a .partial y renombra al final."""
80 partial = dest.with_name(dest.name + ".partial")
81 cmd = [dump_bin, f"--defaults-extra-file={defaults_file}", *DUMP_OPTS, db]
82 tail = b""
83
84 with tempfile.TemporaryFile() as err:
85 with open(partial, "wb") as raw, gzip.GzipFile(fileobj=raw, mode="wb", compresslevel=6) as gz:
86 proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=err)
87 for chunk in iter(lambda: proc.stdout.read(CHUNK), b""):
88 gz.write(chunk)
89 tail = (tail + chunk)[-512:]
90 proc.stdout.close()
91 rc = proc.wait()
92 err.seek(0)
93 stderr = err.read().decode(errors="replace").strip()
94
95 if rc != 0:
96 partial.unlink(missing_ok=True)
97 raise BackupError(f"mariadb-dump salió con código {rc}: {stderr}")
98 if b"-- Dump completed" not in tail:
99 partial.unlink(missing_ok=True)
100 raise BackupError("el volcado no terminó con '-- Dump completed'")
101 if stderr:
102 log.warning("%s: %s", db, stderr)
103
104 os.replace(partial, dest)
105
106
107def sha256sum(path):
108 digest = hashlib.sha256()
109 with open(path, "rb") as fh:
110 for chunk in iter(lambda: fh.read(CHUNK), b""):
111 digest.update(chunk)
112 return digest.hexdigest()
113
114
115def prune(root, keep_days, current):
116 cutoff = datetime.now() - timedelta(days=keep_days)
117 for entry in sorted(root.iterdir()):
118 if not entry.is_dir() or entry == current:
119 continue
120 try:
121 taken = datetime.strptime(entry.name, STAMP)
122 except ValueError:
123 continue # no es un directorio creado por este script
124 if taken < cutoff:
125 log.info("eliminando respaldo antiguo %s", entry.name)
126 shutil.rmtree(entry)
127
128
129def main():
130 parser = argparse.ArgumentParser(description=__doc__)
131 parser.add_argument("--defaults-file", default="/etc/mariadb-backup/backup.cnf")
132 parser.add_argument("--dest", type=Path, default=Path("/var/backups/mariadb"))
133 parser.add_argument("--keep-days", type=int, default=14)
134 args = parser.parse_args()
135
136 logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
137 os.umask(0o077)
138 args.dest.mkdir(parents=True, exist_ok=True)
139
140 lock = open(args.dest / ".lock", "w")
141 try:
142 fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
143 except BlockingIOError:
144 log.error("otro respaldo está en curso")
145 return 1
146
147 try:
148 client = find_binary("mariadb", "mysql")
149 dump_bin = find_binary("mariadb-dump", "mysqldump")
150 databases = list_databases(client, args.defaults_file)
151 except BackupError as exc:
152 log.error("%s", exc)
153 return 1
154
155 run_dir = args.dest / datetime.now().strftime(STAMP)
156 run_dir.mkdir()
157 log.info("respaldando %d bases en %s", len(databases), run_dir)
158
159 failed = []
160 try:
161 dump_grants(client, args.defaults_file, run_dir / "_grants.sql")
162 except BackupError as exc:
163 log.error("usuarios y permisos: %s", exc)
164 failed.append("_grants")
165
166 for db in databases:
167 dest = run_dir / f"{db}.sql.gz"
168 try:
169 dump_database(dump_bin, args.defaults_file, db, dest)
170 except BackupError as exc:
171 log.error("%s: %s", db, exc)
172 failed.append(db)
173 continue
174 log.info("%s: %.1f MiB", db, dest.stat().st_size / 2**20)
175
176 files = sorted(p for p in run_dir.iterdir() if p.is_file())
177 (run_dir / "SHA256SUMS").write_text(
178 "".join(f"{sha256sum(p)} {p.name}\n" for p in files)
179 )
180
181 if failed:
182 log.error("fallaron: %s — no se borran respaldos antiguos", ", ".join(failed))
183 return 1
184
185 prune(args.dest, args.keep_days, run_dir)
186 log.info("respaldo completo")
187 return 0
188
189
190if __name__ == "__main__":
191 sys.exit(main())
1sudo chmod 700 /usr/local/sbin/mariadb-backup.py
Qué hace y por qué
Lista las bases en cada corrida. SHOW DATABASES en lugar de una lista fija: si mañana alguien crea una base nueva, entra al respaldo sin tocar nada. EXCLUDE deja fuera las bases virtuales (information_schema, performance_schema, sys) y mysql, que se cubre con _grants.sql.
Transmite en bloques. dump_database() lee la salida de mariadb-dump de a 1 MiB y la escribe directo al GzipFile. El volcado nunca vive completo en memoria, así que una base de 30 GB consume lo mismo que una de 30 MB.
Detecta volcados truncados, de dos formas. Primero, revisa el código de salida de mariadb-dump — justo lo que el pipeline de shell pierde. Segundo, guarda los últimos 512 bytes del flujo y busca la marca -- Dump completed que mariadb-dump escribe solo cuando termina bien. Si cualquiera de las dos falla, el archivo se descarta.
Escribe a .partial y renombra. El volcado se escribe a tienda.sql.gz.partial y solo al terminar se renombra con os.replace(), que es atómico. Nunca existe un tienda.sql.gz a medias: o está completo, o no está.
stderr a un archivo temporal. Si stderr fuera un PIPE que nadie lee mientras leemos stdout, un proceso que escribe muchas advertencias llenaría el buffer del pipe y se bloquearía para siempre. Mandarlo a un TemporaryFile elimina ese riesgo. Si el volcado sale bien pero hubo advertencias, se registran.
Una base que falla no detiene las demás. Se registra el error, se sigue con la siguiente y el script termina con código 1. Así systemd marca el servicio como fallido.
Rotación solo si todo salió bien. prune() borra los directorios con más de --keep-days días, pero únicamente cuando ninguna base falló. Si los respaldos llevan una semana fallando sin que nadie lo note, lo último que quieres es que el script borre los últimos buenos. Además, solo borra directorios cuyo nombre coincide con el formato de fecha del script; cualquier otra cosa en /var/backups/mariadb queda intacta.
Un solo respaldo a la vez. fcntl.flock() sobre .lock evita que dos ejecuciones se pisen si una se alarga más que el intervalo del timer. El kernel libera el lock automáticamente al terminar el proceso, incluso si muere de forma abrupta.
Permisos restrictivos. os.umask(0o077) hace que todo lo creado sea legible solo por el dueño. Un respaldo contiene todos tus datos y los hashes de contraseña de todos los usuarios: trátalo como tal.
Checksums compatibles con sha256sum. SHA256SUMS usa el mismo formato que la herramienta estándar, así que se verifica con sha256sum -c en cualquier máquina, sin el script.
5. Primera ejecución
1sudo /usr/local/sbin/mariadb-backup.py
1INFO respaldando 3 bases en /var/backups/mariadb/20260928-185715
2INFO radius: 412.3 MiB
3INFO tienda: 18.7 MiB
4INFO wiki: 2.1 MiB
5INFO respaldo completo
El resultado:
1/var/backups/mariadb/
2├── .lock
3└── 20260928-185715/
4 ├── _grants.sql
5 ├── SHA256SUMS
6 ├── radius.sql.gz
7 ├── tienda.sql.gz
8 └── wiki.sql.gz
Verifica la integridad:
1cd /var/backups/mariadb/20260928-185715 && sha256sum -c SHA256SUMS
Las opciones se pueden cambiar sin editar el script:
1sudo /usr/local/sbin/mariadb-backup.py --dest /srv/backups/db --keep-days 30
6. Programarlo con systemd
Un timer de systemd tiene ventajas claras sobre cron: los logs quedan en el journal, Persistent=true ejecuta el respaldo perdido si el servidor estaba apagado a la hora programada, y el servicio puede aislarse del resto del sistema.
/etc/systemd/system/mariadb-backup.service:
1[Unit]
2Description=Respaldo lógico de MariaDB
3After=mariadb.service
4Requires=mariadb.service
5
6[Service]
7Type=oneshot
8ExecStart=/usr/local/sbin/mariadb-backup.py
9Nice=10
10IOSchedulingClass=idle
11ProtectSystem=strict
12ReadWritePaths=/var/backups/mariadb
13ProtectHome=true
14PrivateTmp=true
15NoNewPrivileges=true
/etc/systemd/system/mariadb-backup.timer:
1[Unit]
2Description=Respaldo diario de MariaDB
3
4[Timer]
5OnCalendar=*-*-* 02:30:00
6RandomizedDelaySec=15m
7Persistent=true
8
9[Install]
10WantedBy=timers.target
NiceeIOSchedulingClass=idlehacen que el respaldo ceda CPU y disco a la base de datos si hay carga.ProtectSystem=strictmonta todo el sistema de archivos como solo lectura para este servicio, exceptoReadWritePaths. Si un error en el script intentara escribir en otro lado, fallaría.RandomizedDelaySecevita que varios servidores con la misma configuración golpeen el almacenamiento compartido al mismo segundo.
Actívalo:
1sudo mkdir -p /var/backups/mariadb
2sudo systemctl daemon-reload
3sudo systemctl enable --now mariadb-backup.timer
Comprueba la próxima ejecución, lanza una manual y revisa el log:
1systemctl list-timers mariadb-backup.timer
2sudo systemctl start mariadb-backup.service
3journalctl -u mariadb-backup.service -n 20
Enterarte cuando falla
Un respaldo que falla en silencio es casi igual de malo que no tener respaldo. Como el script sale con código 1 ante cualquier error, systemd marca el servicio como failed, y puedes engancharle una notificación con OnFailure=:
1[Unit]
2OnFailure=notify-failure@%n.service
Donde notify-failure@.service es un servicio tuyo que manda un correo, un mensaje de Telegram o un webhook. Si ya tienes Zabbix o Prometheus, otra opción es monitorear la antigüedad del directorio más reciente en /var/backups/mariadb: alerta si supera 26 horas.
7. Restaurar
Un respaldo vale lo que su restauración. Estos son los casos habituales.
Una base en el mismo servidor
Los volcados no incluyen CREATE DATABASE (no usamos --databases), así que primero hay que crearla. Eso es a propósito: te deja restaurar con el nombre que quieras.
1cd /var/backups/mariadb/20260928-185715
2sha256sum -c SHA256SUMS
3
4sudo mariadb -e 'CREATE DATABASE tienda'
5gunzip -c tienda.sql.gz | sudo mariadb tienda
Una base con otro nombre, para inspeccionarla
El caso más común en la vida real: alguien borró registros ayer y hay que recuperarlos sin pisar la base de producción.
1sudo mariadb -e 'CREATE DATABASE tienda_ayer'
2gunzip -c tienda.sql.gz | sudo mariadb tienda_ayer
Ahora puedes comparar y copiar solo lo necesario con un INSERT ... SELECT entre tienda_ayer y tienda.
Un gotcha que encontré probando esto: si algún trigger, vista o procedimiento se creó con el nombre de la base escrito explícitamente (CREATE TRIGGER tienda.t ...), el volcado conserva ese texto tal cual, y al restaurar en tienda_ayer el objeto intenta crearse en tienda. La restauración falla con Trigger 'tienda.t' already exists. Los objetos definidos sin prefijo (lo normal cuando se crean con USE tienda) se restauran sin problema en cualquier nombre.
Solo una tabla
Como el volcado es texto, puedes extraer una sola tabla. El bloque de cada tabla empieza con un comentario -- Table structure for table y termina donde empieza el siguiente:
1gunzip -c tienda.sql.gz \
2 | sed -n '/^-- Table structure for table `productos`/,/^-- Table structure for table/p' \
3 > productos.sql
Revisa el archivo antes de aplicarlo: contiene un DROP TABLE IF EXISTS. Aplícalo sobre la base de pruebas, no sobre producción.
Usuarios y permisos en un servidor nuevo
_grants.sql contiene CREATE USER y GRANT de todas las cuentas, incluyendo root@localhost y el propio usuario backup, que en un servidor nuevo ya existen. Edita el archivo, deja solo las cuentas de aplicación y aplícalo:
1sudo mariadb < _grants.sql
Las contraseñas van como hash (IDENTIFIED BY PASSWORD '*...'), así que los usuarios conservan su contraseña sin que el archivo la contenga en claro. Aun así, esos hashes se pueden atacar por fuerza bruta: el archivo es sensible.
Prueba la restauración de verdad
Programa una prueba periódica — mensual está bien — en una máquina o contenedor aparte: toma el último respaldo, verifica checksums, restaura todo y corre un par de consultas que confirmen que los datos tienen sentido (conteo de filas de tablas grandes, fecha del registro más reciente). Es la única forma de saber que el procedimiento funciona antes de necesitarlo.
8. Sacar los respaldos del servidor
Un respaldo en el mismo disco que la base de datos protege contra un DELETE sin WHERE, pero no contra un disco muerto, un ransomware o un servidor comprometido. Necesitas al menos una copia en otro lugar.
Lo más simple es un segundo timer que corra después del respaldo y copie el directorio con rsync a otro servidor o con rclone a almacenamiento de objetos (S3, Backblaze B2, MinIO):
1rclone sync /var/backups/mariadb remoto:respaldos-mariadb --exclude .lock
Dos recomendaciones:
- Cifra antes de subir si el destino no es tuyo.
ageogpgcon una clave pública: el servidor puede cifrar, pero solo quien tiene la clave privada (que no vive en el servidor) puede descifrar. - Que el servidor no pueda borrar la copia remota. Si un atacante toma el servidor, lo primero que hará es borrar los respaldos que alcance. Usa credenciales de solo escritura, o activa versionado / object lock en el bucket.
Consideraciones de producción
--single-transaction solo es consistente para InnoDB. Las tablas MyISAM o Aria no son transaccionales: se vuelcan tal como estén en ese instante, y si cambian durante el respaldo, el resultado puede ser inconsistente con el resto. Revisa qué motores usas:
1SELECT table_schema, engine, COUNT(*)
2 FROM information_schema.tables
3 WHERE table_schema NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys')
4 GROUP BY table_schema, engine;
Si aparece algo distinto de InnoDB en una base importante, conviértelo con ALTER TABLE ... ENGINE=InnoDB.
Evita cambios de esquema durante el respaldo. Un ALTER TABLE, RENAME TABLE o TRUNCATE TABLE que ocurra mientras corre --single-transaction puede hacer que el volcado de esa tabla salga vacío o falle. Programa las migraciones lejos de la ventana de respaldo.
Punto en el tiempo. Un respaldo diario significa que puedes perder hasta 24 horas de datos. Si eso no es aceptable, activa el binary log (log_bin) y archívalo: con el último volcado más los binlogs posteriores, mariadb-binlog reproduce los cambios hasta el minuto anterior al desastre. Añade --master-data=2 a DUMP_OPTS para que el volcado registre la posición del binlog en la que fue tomado (requiere el privilegio RELOAD).
Cuándo pasar a mariadb-backup. Si una base pasa de unas decenas de GB, o restaurarla toma más tiempo del que el negocio tolera, cambia a respaldo físico con mariadb-backup. La estructura del script sirve igual: cambia la función de volcado, conserva el lock, los checksums, la rotación y el timer.
Espacio en disco. Con retención de 14 días, necesitas unas 15 veces el tamaño de un respaldo comprimido (14 más el que se está creando). Un SQL comprimido con gzip suele ocupar entre el 10% y el 25% del tamaño de los datos en disco, según qué tan repetitivos sean. Monitorea el espacio libre del volumen de respaldos igual que el de la base.