En 07-03 nació respaldo.sh: copiaba /srv/veloz/datos con rsync --link-dest, comprimía, verificaba con sha256sum y aplicaba una retención sencilla. Era suficiente para no perder los datos del día, pero no para dormir tranquilo. Este proyecto lo lleva a su versión definitiva y digna de confianza: perfiles configurables sin tocar el código, manifiesto verificable, retención con promoción a semanal y mensual, copia remota, cifrado opcional, borrado seguro y —lo más importante— una prueba de restauración automática que se ejecuta sola cada semana. El principio que ordena todo el proyecto: un respaldo no verificado no existe.
Contenido
- Inventario: qué se respalda, con qué RPO y RTO
- La regla 3-2-1 aplicada a Veloz Envíos
- Diseño: perfiles en ficheros, no en el código
- Fase 1: la copia incremental que se restaura como completa
- Fase 2: manifiesto y verificación
- Fase 3: retención con promoción
- Fase 4: restauración y su prueba automática
- Copia remota y cifrado
- Espacio en disco: comprobar antes de empezar
- Observabilidad y códigos de salida
- Puesta en producción y runbook
- Inventario: qué se respalda, con qué RPO y RTO
El inventario se escribe antes que el código, y se revisa cada trimestre:
| Conjunto | Ruta | Tamaño | RPO | RTO | Por qué |
|---|---|---|---|---|---|
| Datos operativos | /srv/veloz/datos |
6 GB | 1 día | 1 h | Reconstruirlos exigiría pedir los CSV a los clientes |
| Histórico | /srv/veloz/datos/historico |
40 GB | 1 mes | 1 día | Inmutable; solo cambia al cerrar el mes |
| Configuración | ~/veloz-ops/etc, /etc/veloz |
2 MB | 1 día | 15 min | Pequeño y crítico: sin él nada arranca |
| Sistema operativo | — | — | — | — | No se respalda: se reinstala desde la receta |
RPO es cuántos datos aceptamos perder; RTO, cuánto tardamos en volver. Las dos columnas son las que deciden la frecuencia y el diseño, y la última fila es la más importante: respaldar el sistema entero multiplica el coste y no mejora ninguno de los dos números.
- La regla 3-2-1 aplicada a Veloz Envíos
Tres copias, dos medios, una fuera (07-03):
| Copia | Dónde | Cómo | Frecuencia |
|---|---|---|---|
| 1 (los datos vivos) | srv-veloz-01:/srv/veloz/datos |
— | — |
| 2 | srv-veloz-01:/respaldos |
rsync --link-dest |
Diaria, 03:15 |
| 3 | srv-veloz-03:/respaldos/srv-veloz-01 |
rsync -e ssh |
Diaria, tras la copia 2 |
| Fuera | Almacenamiento externo | tar + gpg -c mensual |
Mensual, día 1 |
La copia 2 en el mismo disco que los datos no cumple la regla por sí sola —un disco que muere se lleva las dos— pero es la que da un RTO de una hora, porque restaurar de local es instantáneo. La 3 cubre la muerte del servidor; la de fuera, el incendio y el borrado malicioso.
- Diseño: perfiles en ficheros, no en el código
El error de 07-03 era tener las rutas dentro del script: añadir un conjunto exigía editar código, y editar código exige revisión y despliegue. La versión definitiva lee perfiles de etc/respaldo.d/*.conf:
# etc/respaldo.d/10-datos.conf
NOMBRE=datos
ORIGEN=/srv/veloz/datos/
DESTINO=/respaldos
EXCLUSIONES=/tmp/*:*.swp:historico/
RETENCION_DIARIAS=7; RETENCION_SEMANALES=4; RETENCION_MENSUALES=6
VERIFICA_GLOB='*.csv'El prefijo numérico fija el orden. Se cargan en bucle, cada uno en una subshell para que las variables de un perfil no contaminen al siguiente (01-04):
for perfil in "$ETC/respaldo.d"/*.conf; do
[[ -r $perfil ]] || continue
( set -a; . "$perfil"; set +a
respalda_perfil ) || fallos+=("$(basename "$perfil")")
doneset -a marca para exportación todo lo que se defina a continuación, y la subshell aísla. La alternativa —cargar todo en el mismo ámbito— provoca el bug clásico: el perfil 20 hereda la RETENCION_MENSUALES del 10 porque olvidó definirla. Con la subshell, cada perfil arranca de los valores por defecto del script, que es la precedencia de 05-06: defecto < fichero < entorno < opciones.
- Fase 1: la copia incremental que se restaura como completa
respalda_perfil() {
local hoy previa enlace=() excl
hoy=$(date +%F)
previa=$(ultima_correcta "$DESTINO/$NOMBRE")
[[ -n $previa ]] && enlace=(--link-dest="$DESTINO/$NOMBRE/$previa")
excl=$(mktemp); trap 'rm -f "$excl"' RETURN
printf '%s\n' ${EXCLUSIONES//:/ } > "$excl"
rsync -a --delete --exclude-from="$excl" "${enlace[@]}" ${SIMULACION:+--dry-run} \
"$ORIGEN" "$DESTINO/$NOMBRE/$hoy.parcial/" || return 1
mv "$DESTINO/$NOMBRE/$hoy.parcial" "$DESTINO/$NOMBRE/$hoy"
}Cuatro decisiones que valen por todo el proyecto. --link-dest (07-03) enlaza duro lo que no ha cambiado: el respaldo de hoy ocupa lo que ocupan los ficheros nuevos, pero contiene el árbol completo, así que restaurar es copiar un directorio y no reconstruir una cadena —con el requisito de que origen y destino de los enlaces estén en el mismo sistema de ficheros—. Y enlace=() es un array (04-03), no una cadena: el primer día no hay copia previa, el array queda vacío y la opción desaparece de la orden; si fuera una cadena vacía entre comillas, rsync recibiría un argumento vacío y fallaría.
${SIMULACION:+--dry-run} (03-06) añade la opción solo si la variable tiene valor. El modo simulación es obligatorio aquí porque hay un --delete de por medio: antes de la primera ejecución real de un perfil nuevo se ejecuta siempre con --simular. Y el sufijo .parcial con mv final es la escritura atómica de 08-03: mientras rsync trabaja, el directorio tiene un nombre que ultima_correcta ignora; solo el mv —atómico dentro del mismo sistema de ficheros— lo convierte en respaldo válido. Si el servidor se apaga a mitad, queda basura identificable, nunca un respaldo incompleto que parezca bueno.
- Fase 2: manifiesto y verificación
Un directorio con ficheros no demuestra nada. El manifiesto es la prueba:
escribe_manifiesto() {
local dir=$1 m="$1/MANIFIESTO"
{
printf '# respaldo %s de %s\norigen\t%s\n' "$NOMBRE" "$(date -Is)" "$ORIGEN"
printf 'ficheros\t%s\n' "$(find "$dir" -type f ! -name MANIFIESTO | wc -l)"
printf 'bytes\t%s\n' "$(du -sb "$dir" | cut -f1)"
(cd "$dir" && find . -name "$VERIFICA_GLOB" -print0 | xargs -0 -r sha256sum)
} > "$m.parcial" && mv "$m.parcial" "$m"
}
verifica() {
local dir=$1 esperados reales
[[ -r $dir/MANIFIESTO ]] || { veloz_log_error "sin manifiesto: $dir"; return 1; }
esperados=$(awk -F'\t' '$1=="ficheros" {print $2}' "$dir/MANIFIESTO")
reales=$(find "$dir" -type f ! -name MANIFIESTO | wc -l)
(( esperados == reales )) || { veloz_log_error "recuento: $esperados vs $reales"; return 1; }
(cd "$dir" && grep -E '^[0-9a-f]{64} ' MANIFIESTO | sha256sum -c --quiet) || return 1
veloz_log_info "verificado: $dir ($reales ficheros)"
}Se verifican tres cosas, de la más barata a la más cara: que el manifiesto exista, que el recuento coincida y que las sumas de los ficheros clave cuadren. Solo se calculan sumas de VERIFICA_GLOB (los CSV) y no de los 40 GB del histórico: verificarlo todo cada noche costaría media hora y no aporta, porque los ficheros enlazados con --link-dest son los mismos datos ya verificados ayer. El sha256sum -c --quiet devuelve un código distinto de cero si algo no cuadra, y eso basta para marcar el perfil como fallido.
- Fase 3: retención con promoción
La retención no es «borrar lo viejo», es decidir qué copia se convierte en semanal y cuál en mensual antes de que caduque como diaria.
aplica_retencion() {
local base=$1 d
# Promocion: la copia del lunes pasa a semanales; la del dia 1, a mensuales.
for d in "$base/diarias"/*; do
[[ -d $d ]] || continue
local f=${d##*/}
[[ $(date -d "$f" +%u) == 1 ]] && enlaza_si_falta "$d" "$base/semanales/$f"
[[ $(date -d "$f" +%d) == 01 ]] && enlaza_si_falta "$d" "$base/mensuales/$f"
done
poda "$base/diarias" "$RETENCION_DIARIAS"
poda "$base/semanales" "$RETENCION_SEMANALES"
poda "$base/mensuales" "$RETENCION_MENSUALES"
}
poda() { # $1 = directorio, $2 = cuantas conservar
local dir=$1 conservar=$2 copias=() sobran
mapfile -t copias < <(find "$dir" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
(( ${#copias[@]} > conservar + 3 )) && { veloz_log_error "$dir: demasiadas copias, revisa"; return 1; }
sobran=$(( ${#copias[@]} - conservar ))
for ((i = 0; i < sobran; i++)); do
veloz_log_info "borrando ${copias[i]}"
[[ $SIMULACION == si ]] || rm -rf "${dir:?dir vacio}/${copias[i]:?copia vacia}"
done
}La promoción usa enlaces duros (cp -al, dentro de enlaza_si_falta), no copias: la semanal del 3 de agosto comparte datos con la diaria del mismo día, así que promocionar cuesta cero bytes, y cuando la diaria se borre, la semanal seguirá conteniendo los datos completos. Es la misma propiedad de --link-dest aplicada a la retención. Hay además dos salvaguardas de 08-03. La primera, ${dir:?} y ${copias[i]:?}: si por un error de configuración dir quedara vacía, rm -rf "/${copias[i]}" borraría desde la raíz; con :? el script muere antes de ejecutar nada. La segunda, el aviso cuando hay muchas más copias de las esperadas: significa que el script lleva días sin podar y algo va mal; borrar quince directorios de golpe sin avisar es justo lo que no queremos que haga solo.
- Fase 4: restauración y su prueba automática
El subcomando restaurar nunca escribe sobre el original:
restaurar() { # $1 = perfil, $2 = fecha o "ultima", $3 = destino
local base=$RAIZ/$1 fecha=$2 destino=${3:-$(mktemp -d /tmp/restaura-XXXXXX)}
[[ $fecha == ultima ]] && fecha=$(ultima_correcta "$base/diarias")
local origen=$base/diarias/$fecha
[[ -d $origen ]] || veloz_morir 3 "no existe el respaldo $1/$fecha"
verifica "$origen" || veloz_morir 4 "respaldo corrupto, no se restaura"
rsync -a "$origen/" "$destino/"
printf 'Restaurado %s (%s) en %s\n' "$1" "$fecha" "$destino"
}Se verifica antes de restaurar: restaurar una copia corrupta sobre datos buenos convierte un incidente en un desastre. Y el destino por defecto es un temporal, de modo que hace falta un acto deliberado para escribir en /srv. La prueba de restauración automática es la pieza que separa este proyecto de un simple script de copia:
prueba_restauracion() {
local tmp; tmp=$(mktemp -d); trap 'rm -rf "$tmp"' RETURN
restaurar datos ultima "$tmp" >/dev/null || return 1
local orig=/srv/veloz/datos/envios.csv rest=$tmp/envios.csv
[[ -f $rest ]] || { veloz_log_error "prueba: falta envios.csv"; return 1; }
cmp -s "$orig" "$rest" || { veloz_log_error "prueba FALLIDA: el CSV difiere"; return 1; }
veloz_log_info "prueba de restauracion OK ($(wc -l < "$rest") lineas)"
}Corre cada domingo con su propio timer y compara el CSV recuperado con el original mediante cmp -s. Puede dar un falso negativo si el fichero cambió entre el respaldo y la prueba; por eso se compara contra el fichero tal como estaba —o se acepta el falso positivo y se investiga— pero jamás se desactiva la prueba para que deje de avisar. Un respaldo no verificado no existe; uno verificado pero nunca restaurado, tampoco.
- Copia remota y cifrado
sincroniza_remoto() {
rsync -a --delete -e 'ssh -o BatchMode=yes' \
"$RAIZ/" "respaldo@srv-veloz-03:/respaldos/$(hostname -s)/"
}
cifra_mensual() { # $1 = tar mensual
gpg --batch --yes --passphrase-file "$CLAVE" -c --cipher-algo AES256 "$1" && shred -u "$1"
}BatchMode=yes (07-06) hace que SSH falle en lugar de esperar una contraseña que nadie va a teclear a las tres de la mañana. En el remoto se usa una clave restringida con command= en authorized_keys, el mínimo privilegio de 08-03: esa clave solo puede recibir un rsync, no abrir una sesión. La clave de cifrado, por su parte, vive en un fichero con permisos 600 fuera del repositorio y fuera del propio respaldo. Cifrar el respaldo y guardar la clave dentro de él es el chiste más viejo del oficio; y una clave que solo está en el servidor incendiado tampoco sirve: la copia de la frase de paso va en el gestor de contraseñas de la empresa, y el runbook dice quién puede recuperarla.
- Espacio en disco: comprobar antes de empezar
Un respaldo que llena el disco tira abajo el servicio que pretendía proteger.
hay_espacio() { # $1 = origen, $2 = destino
local necesita disponible
necesita=$(du -sb --exclude=historico "$1" | cut -f1)
disponible=$(df -PB1 "$2" | awk 'NR==2 {print $4}')
(( disponible > necesita * 12 / 10 )) # 20 % de margen
}Se pide un 20 % de margen porque --link-dest hace que el consumo real sea mucho menor que el tamaño del origen, pero el margen cubre el día en que alguien reescribe el CSV entero. Si no cabe: no se ejecuta el perfil, se registra un error, se avisa y se sigue con el siguiente perfil. Lo que no se hace nunca es borrar respaldos antiguos para hacer sitio —eso convierte un problema de espacio en una pérdida de historial—.
- Observabilidad y códigos de salida
Cada perfil produce su propio código, y el script agrega:
| Código | Significado | Qué hace el operador |
|---|---|---|
| 0 | Todos los perfiles bien y verificados | Nada |
| 1 | Un perfil falló, el resto bien | Revisar ese perfil |
| 3 / 4 | Respaldo no encontrado / verificación fallida | Comprobar la fecha; si es corrupción, restaurar de srv-veloz-03 |
| 5 | Sin espacio | Ampliar disco o revisar retención |
El bucle de perfiles no aborta al primer fallo: acumula en el array fallos y sigue, porque que el perfil de logs falle no es razón para quedarse sin copia de los datos. Al final, un resumen (07-04):
resumen() {
veloz_log_info "respaldo terminado: ${#perfiles[@]} perfiles, ${#fallos[@]} fallos, ${SECONDS}s"
(( ${#fallos[@]} == 0 )) || avisa_webhook "Respaldo con fallos en $(hostname -s): ${fallos[*]}"
}SECONDS (04-06) da la duración sin restar marcas de tiempo, y su tendencia es un dato valioso: si el respaldo pasa de tres a treinta minutos, algo cambió aunque el código de salida sea 0.
- Puesta en producción y runbook
[Timer] # systemd/veloz-respaldo.timer
OnCalendar=*-*-* 03:15
RandomizedDelaySec=300
Persistent=truePersistent=true (07-05) recupera la ejecución si el servidor estaba apagado a las 03:15, que es justo cuando más falta hace la copia. El RandomizedDelaySec evita que los tres servidores golpeen srv-veloz-03 en el mismo segundo. El servicio ejecuta respaldo.sh --todos con flock (07-02) para que dos ejecuciones nunca se solapen.
El runbook (docs/runbook.md) contiene la única secuencia que importa a las cuatro de la mañana, con órdenes copiables: comprobar el último respaldo correcto, verificarlo, restaurar a un temporal, comparar, y solo entonces mover a producción. Un runbook que no se ha ejecutado nunca es ficción: se prueba en el simulacro trimestral.
Errores Comunes y Consejos
- Confundir copia con respaldo. Un
rsync --deletea un disco montado en el mismo servidor replica elrm -rfaccidental en segundos. Hace falta retención, y la retención es la que convierte la copia en respaldo. --link-destentre sistemas de ficheros distintos. No falla: hace copias completas en silencio hasta llenar el disco. Y sin.parcial+mv, un corte de luz deja un directorio incompleto que la retención cuenta como bueno y que el día de la restauración descubres vacío.rm -rf "$dir/$copia"sin:?. El día que una variable quede vacía, el script borra desde la raíz. Es la línea más peligrosa de todo el toolkit.- Verificar el respaldo con el propio respaldo. Guardar el manifiesto solo dentro de la copia detecta corrupción de datos, no un borrado del conjunto: por eso el recuento y el tamaño también se registran en el log central.
- Consejo: apunta en el calendario una restauración real cada trimestre, con cronómetro. Si el RTO medido supera el RTO prometido, el diseño está mal, no el operador.
Ejercicios
- Respaldo de base de datos. Añade un perfil
postgresque usepg_dump -Fca un fichero antes de la copia, con la contraseña en~/.pgpass(600) y verificación conpg_restore -l. - Informe semanal del estado de las copias. Un subcomando
informeque recorra todos los perfiles y produzca una tabla con: última copia correcta, antigüedad en días, tamaño, número de copias por nivel y resultado de la última verificación, en texto y JSON. - Restauración parcial. Extiende
restaurarcon--fichero RUTApara recuperar un solo fichero, buscando en qué copias existe y en cuál cambió por última vez.
Soluciones
1. La clave es que el volcado se hace antes del rsync y se verifica en el sitio:
pre_respaldo_postgres() {
local out=$ORIGEN/dump-$(date +%F).dump
pg_dump -Fc -f "$out.parcial" veloz || return 1
pg_restore -l "$out.parcial" >/dev/null || { rm -f "$out.parcial"; return 1; }
mv "$out.parcial" "$out"
find "$ORIGEN" -name 'dump-*.dump' -mtime +7 -delete
}pg_dump -Fc da una copia consistente aunque la base esté en uso; copiar sus ficheros en caliente daría una foto inconsistente (07-03). pg_restore -l lista el contenido sin restaurar: es la verificación más barata posible de que el volcado no está truncado. Se engancha con un PRE_COMANDO en el .conf del perfil, invocado si está definido.
2. El informe se apoya en los manifiestos ya escritos, así que no hay que recalcular nada:
informe() {
printf '%-12s %-12s %5s %8s %s\n' PERFIL ULTIMA DIAS TAMANO ESTADO
for base in "$RAIZ"/*; do
local n=${base##*/} u; u=$(ultima_correcta "$base/diarias")
printf '%-12s %-12s %5d %8s %s\n' "$n" "${u:-ninguna}" \
"$(( ( $(date +%s) - $(date -d "$u" +%s) ) / 86400 ))" "$(du -sh "$base" | cut -f1)" \
"$(verifica "$base/diarias/$u" >/dev/null 2>&1 && echo OK || echo FALLO)"
done
}La columna de días es la que de verdad se mira: un respaldo «correcto» de hace nueve días es una alerta, aunque verifique bien.
3. Recorrer las copias de más nueva a más antigua con find "$base" -path "*/$RUTA", y como los ficheros no modificados comparten inodo gracias a --link-dest, comparar el número de inodo (stat -c %i) entre copias consecutivas identifica exactamente en cuál cambió, sin leer un solo byte del contenido.
Conclusión
respaldo.sh ya no es un script de copia: es un sistema. Los perfiles en etc/respaldo.d/*.conf permiten añadir un conjunto sin tocar código; rsync --link-dest da copias que se generan como incrementales y se restauran como completas; el patrón .parcial + mv garantiza que nunca exista un respaldo a medias que parezca bueno; el manifiesto con recuento, tamaño y sumas convierte «hay ficheros» en «hay estos ficheros y son estos»; la retención promociona con enlaces duros antes de podar y borra con ${var:?} como red de seguridad; y la prueba de restauración semanal responde sola, cada domingo, a la única pregunta que importa: ¿se puede recuperar? Alrededor, lo aprendido antes: subshells para aislar perfiles (01-04), arrays para opciones opcionales (04-03), mktemp y trap (05-02), escritura atómica y mínimo privilegio (08-03), BatchMode en SSH (07-06), timer con Persistent=true (07-05) y aviso solo cuando hay fallos (07-04).
En 09-04 pasamos de proteger los datos a vigilar el servicio. Construiremos monitor-red.sh: definir de forma medible qué significa «la red va bien», un catálogo de objetivos en fichero, comprobaciones que devuelven 0/1/2 al estilo de los plugins clásicos, ejecución en paralelo porque todo es espera de red, reintentos con retroceso, alertas solo en las transiciones y un histórico TSV del que sale la disponibilidad porcentual.
Curso de Programación en Bash
Módulo 1: Introducción a Bash
- ¿Qué es Bash?
- Configurando tu Entorno
- Navegación Básica en la Línea de Comandos
- Entendiendo el Shell
- Encontrar Ayuda: man, help y --help
Módulo 2: Comandos Básicos de Bash
- Operaciones con Archivos y Directorios
- Comandos de Procesamiento de Texto
- Permisos y Propiedad de Archivos
- Redirección y Tuberías
- Comodines y Expansión de Rutas
- Historial y Atajos de Teclado
Módulo 3: Fundamentos de Scripting
- Creando y Ejecutando un Script
- Variables y Constantes
- Operadores Básicos
- Sentencias Condicionales
- Argumentos y Entrada del Usuario
- Comillas, Expansión y Sustitución
Módulo 4: Scripting Intermedio
- Bucles en Bash
- Funciones en Bash
- Arrays y Arrays Asociativos
- Manipulación de Cadenas
- La Sentencia case y los Menús Interactivos
- Aritmética y Cálculos Numéricos
Módulo 5: Técnicas Avanzadas de Scripting
- Operaciones Avanzadas con Archivos
- Gestión de Procesos
- Manejo de Errores y Depuración
- Expresiones Regulares
- Entrada/Salida Avanzada: Descriptores y Here-Documents
- Scripts Modulares y Librerías Reutilizables
Módulo 6: Trabajando con Herramientas Externas
Módulo 7: Automatización y Programación
- Trabajos Cron
- Automatizando Tareas
- Scripts de Respaldo y Restauración
- Monitoreo y Registro
- Servicios y Temporizadores con systemd
- Automatización Remota con SSH
Módulo 8: Mejores Prácticas y Optimización
- Escribiendo Código Legible
- Optimizando Scripts en Bash
- Consideraciones de Seguridad
- Control de Versiones con Git
- Análisis Estático con ShellCheck y shfmt
- Pruebas Automatizadas con Bats
- Portabilidad: POSIX sh frente a Bashismos
