Automatización Linux, scripting Bash y herramientas para sysadmins

Backup incremental con rsync y systemd: copias diarias que no llenan el disco

#bash#linux#backup#systemd#scripting

El día que cron me dejó tirado

Hace unos años mantenía un server que hacía backups nocturnos con cron. Un día el disco se corrompió y fui a restaurar. El último backup era de hacía tres meses.

¿Qué pasó? Un apt upgrade había movido el binario de rsync y el path absoluto en el crontab dejó de funcionar. Sin errores, sin logs, sin nada. Silencio cómplice durante 90 días.

Desde entonces uso systemd timers. Si el servicio falla, queda registrado en journalctl. Si la máquina estaba apagada a la hora del backup, el timer se dispara al bootear. Y no dependo de paths absolutos en un archivo de texto que nadie revisa.

Lo que hace el script

Cada noche:

  1. rsync copia /srv/data a /backups/data/2026-06-02_030000/
  2. Si un archivo no cambió desde ayer, no se copia: se crea un hard link al backup anterior
  3. Resultado: 30 backups diarios que ocupan ~el tamaño de 1 (solo crecen los archivos modificados)
  4. Se borran automáticamente los backups de más de 30 días

El truco es --link-dest: rsync compara contra el backup de ayer y solo escribe lo que cambió. Lo que no cambió es un hard link al mismo inodo. El sistema de archivos ve 30 copias, el disco ve 1.

El script

#!/usr/bin/env bash
set -euo pipefail

SOURCE_DIR="${SOURCE_DIR:-/srv/data}"
BACKUP_ROOT="${BACKUP_ROOT:-/backups/data}"
RETENTION_DAYS="${RETENTION_DAYS:-30}"

TIMESTAMP=$(date +%Y-%m-%d_%H%M%S)
LATEST_LINK="${BACKUP_ROOT}/latest"
DEST_DIR="${BACKUP_ROOT}/${TIMESTAMP}"

mkdir -p "${BACKUP_ROOT}"

rsync -avh --delete \
    --link-dest="${LATEST_LINK}" \
    "${SOURCE_DIR}/" \
    "${DEST_DIR}/"

rm -f "${LATEST_LINK}"
ln -s "${DEST_DIR}" "${LATEST_LINK}"

find "${BACKUP_ROOT}" -maxdepth 1 -type d -name '20*' \
    -mtime "+${RETENTION_DAYS}" -exec rm -rf {} \; 2>/dev/null || true

echo "Backup: ${DEST_DIR}"

Guárdalo en /usr/local/bin/backup-incremental.sh y dale permisos:

chmod +x /usr/local/bin/backup-incremental.sh

Por qué cada línea está así

set -euo pipefail: sin esto, el script puede fallar a la mitad y decir que terminó bien. Con esto, cualquier comando que falle detiene todo. pipefail además detecta errores en medio de un pipeline.

Variables de entorno para la configuración: SOURCE_DIR, BACKUP_ROOT y RETENTION_DAYS toman valores por defecto pero puedes sobrescribirlos desde el servicio de systemd. Nada hardcodeado.

--link-dest: la clave de todo. Le dice a rsync “si este archivo ya existe en el backup de ayer y no cambió, no lo copies, hacele un hard link”. El backup de hoy comparte inodos con el de ayer. Si borrás el backup de ayer, el de hoy no se rompe — los hard links mantienen vivo el inodo mientras al menos uno lo referencie.

rm -f "${LATEST_LINK}" antes de ln -s: ln -sf no es atómico y puede dejar un enlace roto si falla a medio camino. Borrar primero y crear después es más seguro.

find ... -mtime: usa fecha de modificación del directorio, no de creación. Si moviste backups entre discos, -ctime se rompe. -mtime es más confiable para esto.

2>/dev/null || true: si find no encuentra nada para borrar, no queremos que falle el script.

El timer de systemd

Dos archivos en /etc/systemd/system/.

El servicio:

[Unit]
Description=Backup incremental con rsync
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-incremental.sh
Environment="SOURCE_DIR=/srv/produccion"
Environment="BACKUP_ROOT=/backups/produccion"
Environment="RETENTION_DAYS=30"
User=root
Nice=19
IOSchedulingClass=idle

El timer:

[Unit]
Description=Backup incremental diario a las 03:00
Requires=backup-incremental.service

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=1800

[Install]
WantedBy=timers.target

Type=oneshot: systemd considera el servicio “activo” mientras se ejecuta. Sabe exactamente cuándo termina y si falló.

Nice=19 + IOSchedulingClass=idle: el backup corre con la prioridad más baja posible. Si el servidor está bajo carga, cede el paso.

Persistent=true: si la máquina estaba apagada a las 03:00, el timer se dispara al bootear. Lo que cron no puede hacer.

RandomizedDelaySec=1800: añade hasta 30 minutos aleatorios. Si tienes 20 servidores haciendo backup al mismo NAS, no lo golpean todos a la vez.

Actívalo:

systemctl daemon-reload
systemctl enable --now backup-incremental.timer

Cómo saber que funciona

# ¿Está activo?
systemctl status backup-incremental.timer

# ¿Cuándo se dispara la próxima vez?
systemctl list-timers backup-incremental.timer

# ¿Falló algo anoche?
journalctl -u backup-incremental.service --since "1 day ago"

# ¿Cuánto espacio ocupan los backups?
du -sh /backups/produccion/

Con 30 días de retención, el du debería mostrar apenas más que el tamaño de tus datos reales. Si ves 30× el tamaño, --link-dest no está funcionando — probablemente porque latest no apunta al backup correcto.


Si ya tienes systemd (cualquier distro posterior a 2016), no hay razón para seguir usando cron en backups nuevos.