"Este artículo detalla cómo crear un sistema de copias de seguridad ZFS profesional entre dos servidores FreeBSD (cliente y servidor), resolviendo los errores más comunes de desincronización de GUIDs y bloqueos de datasets, y automatizando el proceso con cron."
ZFS Replicación Incremental Cliente-Servidor
Entorno: FreeBSD 14.5
Topología: Cliente solaris (192.168.88.51) → Servidor tormenta (192.168.88.160)
Dataset: zroot/home/carlos → zbackup/solaris/home/carlos
Método: Transferencia por red (SSH) con ZFS nativo.
1. Filosofía de Diseño
Este script (zfsrep.sh) está diseñado para ser extremadamente resiliente. No asume que la red funcione siempre, ni que el cliente esté encendido todos los días. Sus principios son:
- Uso de
-Ien lugar de-i: Envía todos los snapshots intermedios. Si el equipo ha estado apagado 5 días, ZFS rellena el hueco automáticamente en lugar de fallar. - Cero uso de
-F: El flag destructivo de ZFS está prohibido. Si el destino se desincroniza (por ejemplo, por un cambio de GUID), el script fallará, pero nunca borrará datos del destino silenciosamente. - Auto-saneamiento: Utiliza
zfs receive -Aantes de empezar para abortar cualquier recepción que se haya quedado colgada (evitando el temido error "dataset is busy"). - SSH Keepalive: Utiliza opciones de SSH para evitar que la conexión se quede congelada en redes lentas.
2. El Script Completo (v1.2.1)
Ubicación
/home/carlos/bin/zfsrep.sh
Contenido completo
#!/bin/sh
### BEGIN INFO
# NOMBRE: Replicación Incremental ZFS cliente - servidor
# DESC: Replica zroot/home/carlos (solaris) -> zbackup/solaris/home/carlos
# VERSION: 1.2.1
### END INFO
# Detectar fallos en cualquier punto de la tubería (zfs send | ssh).
set -o pipefail
NOMBRE="Replicacion Incremental ZFS cliente - servidor"
VERSION="1.2.1"
snap_prefix=snap
retention=30
# Patrón estricto: solo acepta snap-YYYYMMDD (8 dígitos).
# Evita que nombres manuales (snap-14.4-RELEASE) rompan el orden.
snap_pat="${snap_prefix}-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]"
# Rutas absolutas para cron.
date=/bin/date
cut=/usr/bin/cut
grep=/usr/bin/grep
sed=/usr/bin/sed
sort=/usr/bin/sort
xargs=/usr/bin/xargs
zfs=/sbin/zfs
src_0="zroot/home/carlos"
dst_0="zbackup/solaris/home/carlos"
host="root@192.168.88.160"
# Keepalive SSH
SSH_OPTS="-o ConnectTimeout=10 -o ServerAliveInterval=30 \
-o ServerAliveCountMax=10 -o TCPKeepAlive=yes \
-o BatchMode=yes"
today="$snap_prefix-`$date +%Y%m%d`"
snap_today="$src_0@$today"
log=/home/carlos/cronlog
echo >> $log
echo "$NOMBRE $VERSION" >> $log
echo "(ejecucion manual)" >> $log
$date >> $log
echo >> $log
# Comprobar conectividad SSH antes de nada.
if ! ssh $SSH_OPTS $host true 2>/dev/null; then
echo "ERROR: no hay conexion SSH con $host. Abortando." >> $log
echo "**********" >> $log
exit 1
fi
# Limpia el estado del destino: desmonta y aborta receives colgados.
# El uso de sh -c evita que csh/tcsh en el remoto malinterprete el 2>.
clean_dst() {
ssh $SSH_OPTS $host "sh -c 'zfs unmount \"$dst_0\" 2>/dev/null; zfs receive -A \"$dst_0\" 2>/dev/null; true'"
}
# --- Snapshots locales antiguas (para purga) ---
snap_old=`$zfs list -t snapshot -o name \
| $grep "${src_0}@${snap_pat}" \
| $sort -r | $sed 1,${retention}d | $sort | $xargs -n 1`
# --- Snapshot base en destino remoto ---
snap_base_name=`ssh $SSH_OPTS $host "zfs list -t snapshot -o name \
| grep '${dst_0}@${snap_pat}' \
| sort -r | sed 1q | cut -d@ -f2"`
snap_base="${src_0}@$snap_base_name"
# --- Snapshots remotas antiguas (para purga) ---
snap_old_dst_0=`ssh $SSH_OPTS $host "zfs list -t snapshot -o name \
| grep '${dst_0}@${snap_pat}' \
| sort -r | sed 1,${retention}d | sort"`
# --- Snapshot de hoy en origen ---
if $zfs list -H -o name -t snapshot | $grep "${snap_today}$" > /dev/null
then
echo "La instantánea de hoy '$snap_today' ya existe." >> $log
else
echo "Tomando la instantánea de hoy: $snap_today" >> $log
if ! $zfs snapshot "$snap_today" >> $log 2>&1; then
echo "ERROR: no se pudo crear la instantánea $snap_today." >> $log
echo "**********" >> $log
exit 1
fi
fi
echo >> $log
# --- Comparar la PUNTA de origen y destino ---
snap_last_src=`$zfs list -t snapshot -o name \
| $grep "${src_0}@${snap_pat}" | $sort -r | sed 1q`
snap_last_dst=`ssh $SSH_OPTS $host "zfs list -t snapshot -o name \
| grep '${dst_0}@${snap_pat}' | sort -r | sed 1q"`
src_suffix=`echo "$snap_last_src" | $cut -d@ -f2`
dst_suffix=`echo "$snap_last_dst" | $cut -d@ -f2`
if [ -n "$src_suffix" ] && [ "$src_suffix" = "$dst_suffix" ]; then
echo "Origen y destino ya sincronizados en @$src_suffix. Nada que enviar." >> $log
else
echo "Destino desfasado (@${dst_suffix:-vacio} vs @${src_suffix:-vacio}). Replicando..." >> $log
if [ -n "$snap_base_name" ] && $zfs list -H -o name -t snapshot | $grep "${snap_base}$" > /dev/null
then
echo "Usando '$snap_base' como base del incremental (-I)..." >> $log
clean_dst
if $zfs send -I "$snap_base" "$snap_today" \
| ssh $SSH_OPTS $host "zfs receive -vu -x mountpoint '$dst_0'" >> $log 2>&1
then
echo "Replicacion completada." >> $log
else
echo "ERROR: fallo la replicacion incremental." >> $log
echo "**********" >> $log
exit 1
fi
else
echo "No se encontró snapshot común. Realizando envío completo..." >> $log
clean_dst
if $zfs send "$snap_today" \
| ssh $SSH_OPTS $host "zfs receive -vu -x mountpoint '$dst_0'" >> $log 2>&1
then
echo "Envío completo finalizado." >> $log
else
echo "ERROR: fallo el envio completo." >> $log
echo "**********" >> $log
exit 1
fi
fi
fi
echo >> $log
# --- Purga local ---
echo "Intentando destruir instantáneas antiguas $src_0..." >> $log
if [ -n "$snap_old" ]; then
echo "Destruyendo las siguientes instantáneas antiguas:" >> $log
echo "$snap_old" >> $log
$zfs list -t snapshot -o name | $grep "${src_0}@${snap_pat}" \
| $sort -r | $sed 1,${retention}d | $sort | $xargs -n 1 $zfs destroy >> $log 2>&1
else
echo "No se pudo encontrar ninguna instantánea local para destruir." >> $log
fi
echo >> $log
# --- Purga remota ---
echo "Intentando destruir instantáneas antiguas $dst_0..." >> $log
if [ -n "$snap_old_dst_0" ]; then
echo "Destruyendo las siguientes instantáneas antiguas en $host:" >> $log
echo "$snap_old_dst_0" >> $log
ssh $SSH_OPTS $host "zfs list -t snapshot -o name | grep '${dst_0}@${snap_pat}' \
| sort -r | sed 1,${retention}d | sort | xargs -n 1 zfs destroy" >> $log 2>&1
else
echo "No se pudo encontrar ninguna instantánea remota para destruir." >> $log
fi
echo "**********" >> $log
exit 0
3. Solución de Problemas (Lecciones Aprendidas)
3.1. El error "most recent snapshot does not match incremental source"
Causa: Aunque las snapshots tengan el mismo nombre en origen y destino (ej. snap-20261020), sus identificadores únicos (GUIDs) son distintos. Esto ocurre tras restauraciones o movimientos de discos que rompen el "ADN" de ZFS.
Solución: El origen es el maestro, el destino es el esclavo. Nunca se destruyen snapshots del origen para igualar. Hay que destruir el dataset en el destino y forzar un envío completo:
# En el servidor destino (tormenta)
zfs destroy -r zbackup/solaris/home/carlos
El script detectará que no hay base y hará un envío completo automático (Full Send).
3.2. El error "dataset is busy" al destruir
cannot destroy ... dataset is busy, incluso sin procesos visibles.
Causa: Un intento de zfs receive fallido o interrumpido deja un candado a nivel de kernel.
Solución paso a paso:
# 1. Abortar recepciones colgadas
zfs receive -A zbackup/solaris/home/carlos
# 2. Forzar desmontaje
zfs unmount -f zbackup/solaris/home/carlos
# 3. Si sigue bloqueado, reiniciar el servidor (limpia los candados del kernel)
shutdown -r now
3.3. El problema del shell `csh` en SSH
root suele ser csh o tcsh. Al enviar comandos por SSH con redirecciones como 2>, csh lo interpreta como un argumento y el comando falla silenciosamente.
Solución: Envolver el comando remoto en sh -c '...', tal y como hace la función clean_dst del script.
4. Automatización (Cron)
Para que el backup se ejecute todos los días a las 3:00 AM sin intervención, añadir al crontab del usuario:
0 3 * * * /home/carlos/bin/zfsrep.sh > /dev/null 2>&1
/home/carlos/cronlog.
-I.



