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é generaSQL: CREATE TABLE, INSERT…Copia de los archivos de datos de InnoDB
PortabilidadEntre versiones y arquitecturasMisma versión mayor de MariaDB
Restaurar una sola tablaFácil (es texto)Posible, pero laborioso
Velocidad de restauraciónLenta: re-ejecuta todo y reconstruye índicesRápida: copiar archivos y arrancar
Tamaño prácticoHasta decenas de GBCientos 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';
PrivilegioPara qué lo necesita mariadb-dump
SELECTLeer los datos, y mysql.proc para los procedimientos
SHOW VIEWVolcar la definición de las vistas
TRIGGERVolcar los triggers
LOCK TABLESBloquear tablas que no son InnoDB durante el volcado
EVENTVolcar los eventos programados
PROCESSLeer 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ónQué hace
--single-transactionAbre una transacción REPEATABLE READ y vuelca todo desde esa foto. Consistente para InnoDB sin bloquear escrituras.
--quickLee fila por fila en lugar de cargar cada tabla completa en memoria.
--routinesIncluye procedimientos y funciones almacenadas (no van por defecto).
--triggersIncluye triggers (van por defecto, pero mejor explícito).
--eventsIncluye eventos del event scheduler (no van por defecto).
--hex-blobEscribe columnas binarias en hexadecimal, inmune a problemas de codificación.
--default-character-set=utf8mb4Evita 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
  • Nice e IOSchedulingClass=idle hacen que el respaldo ceda CPU y disco a la base de datos si hay carga.
  • ProtectSystem=strict monta todo el sistema de archivos como solo lectura para este servicio, excepto ReadWritePaths. Si un error en el script intentara escribir en otro lado, fallaría.
  • RandomizedDelaySec evita 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. age o gpg con 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.