Saltar al contenido

058 — Respaldo y restauración: solo cuenta lo que se ha restaurado

🗂️ parte 🎚️ nivel ⏱️ duración 📗 clase

Programa · Parte 11 · ← Anterior · Siguiente →

Parte 11 — Operación, seguridad y gobierno · Intermedio · 4 horas estimadas · motores postgresql, sqlite, mongodb · laboratorio labs/08-recovery · 3 fuentes.

Conceptos centrales: RPO · RTO · recuperación a un punto en el tiempo · prueba de restauración

En este caso se comparan 7 motores: 5 lo resuelven (5 con el resultado comprobado por máquina) y 2 no, con el motivo escrito.

De qué trata esta clase

La clase que convierte una intención en una garantía medida. RPO y RTO se declaran en números, la recuperación a un punto en el tiempo se demuestra restaurando de verdad, y la conclusión es incómoda a propósito: un respaldo que nunca se ha restaurado no es un respaldo, es un fichero con nombre esperanzador.

flowchart LR
    C["🗄️ Clase 058"]
    C --> K1["RPO"]
    C --> K2["RTO"]
    C --> K3["recuperación a un punto en el tiempo"]
    C --> K4["prueba de restauración"]
    classDef raiz fill:#0b3d2e,stroke:#3fb950,color:#fff
    class C raiz

Antes de empezar

Esta clase supone que ya trabajaste lo siguiente. Si algo de la última columna no te suena, vuelve a esa clase antes de seguir: aquí se usa sin volver a explicarlo.

# Clase previa Lo que se da por sabido
046 Registro anticipado y recuperación: WAL y ARIES WAL · punto de control · rehacer · deshacer · LSN

Vocabulario de la clase

Los términos que siguen se usan más adelante con este significado exacto. La definición completa, con sus términos relacionados, está en el glosario del programa.

Término Qué significa Procedencia
RPO Objetivo de punto de recuperación: cuántos datos se acepta perder, medido en tiempo. Un RPO de cinco minutos obliga a archivar el registro al menos cada cinco minutos; si no está escrito y probado, el RPO real es «el que salga». se introduce aquí
RTO Objetivo de tiempo de recuperación: cuánto se acepta estar caído. Se mide restaurando de verdad y cronometrando, no estimando; casi siempre resulta ser varias veces mayor de lo que el equipo suponía. se introduce aquí
recuperación a un punto en el tiempo Restaurar una copia base y reaplicar el registro archivado hasta un instante concreto, justo antes del DELETE sin WHERE. Exige que el archivado del registro esté activo y verificado desde antes del incidente. se introduce aquí
prueba de restauración Restaurar la copia en un entorno limpio, comprobar la integridad de los datos y medir cuánto tardó. Un respaldo que nunca se ha restaurado no es un respaldo: es un fichero con nombre esperanzador. se introduce aquí

Propósito

Convertir la copia de seguridad en una garantía verificada. Una copia que nunca se ha restaurado no es una copia: es una carpeta que ocupa espacio.

Resultados de aprendizaje

Al terminar podrás:

  1. Definir RPO y RTO y derivarlos de una exigencia de negocio.
  2. Distinguir copia lógica, física y recuperación a un punto en el tiempo.
  3. Diseñar un plan que cubra los cinco modos de pérdida.
  4. Ejecutar y cronometrar una restauración completa.
  5. Explicar por qué una réplica no es una copia de seguridad.

Fundamentos

RPO y RTO

Ambos salen de una conversación con el negocio, no de una preferencia técnica. La pregunta útil es concreta: «si perdiéramos los datos de las últimas cuatro horas, ¿qué habría que rehacer y cuánto costaría?».

RPO Mecanismo necesario
24 h Copia diaria
1 h Copia diaria + archivado de WAL cada hora
5 min Archivado continuo de WAL
~0 Réplica síncrona más copias
RTO Mecanismo necesario
24 h Restaurar copia lógica
1 h Copia física + WAL
5 min Réplica en caliente con conmutación
~0 Multimaestro o activo-activo

Los cinco modos de pérdida

Un plan solo está completo si cubre los cinco:

Modo Cubre
Fallo de hardware Réplica, RAID
Corrupción de datos Copia + WAL desde antes de la corrupción
Error humano (DROP TABLE) Recuperación a un punto en el tiempo
Ataque (cifrado o borrado) Copia inmutable, fuera de línea o con retención bloqueada
Desastre regional Copia en otra región

Los dos marcados son los que la réplica no cubre, y son los más frecuentes. Un DELETE erróneo se replica en milisegundos a todas las réplicas. Un atacante con credenciales de administrador borra el primario y las réplicas.

Una réplica no es una copia de seguridad. Protege contra fallo de hardware y nada más.

Tipos de copia

Tipo Qué es Restauración Verificación
Lógica (pg_dump) SQL o formato propio Lenta; reconstruye índices Fácil: se puede leer
Física (pg_basebackup) Archivos del clúster Rápida Requiere el mismo motor y versión mayor
PITR Copia física + WAL archivado A cualquier instante La más completa
Instantánea de volumen Copia del almacenamiento Muy rápida Debe ser atómica entre volúmenes

La copia lógica tiene una ventaja subestimada: es legible y portable entre versiones mayores. La física es mucho más rápida de restaurar pero está atada al motor.

La regla 3-2-1

Tres copias, en dos medios distintos, una fuera del sitio. En la práctica actual: primario + copia local + copia en almacenamiento de objetos de otra región, esta última con retención bloqueada para que ni siquiera un administrador comprometido pueda borrarla.

flowchart TD
    P[("Primario")] --> R[("Réplica<br/>fallo de hardware")]
    P --> B["Copia base<br/>diaria"]
    P --> W["Archivado continuo<br/>de WAL"]
    B --> L["Almacenamiento local<br/>RTO bajo"]
    B --> O["Objetos, otra región<br/>desastre regional"]
    O --> I["Retención bloqueada<br/>ataque"]
    W --> PITR["PITR<br/>error humano"]
    L --> T["PRUEBA MENSUAL<br/>restaurar y verificar"]
    O --> T
    T --> M["Registrar RTO real<br/>y filas verificadas"]

Ejemplo trabajado

Requisito de negocio: «no podemos perder más de 15 minutos de inscripciones y debemos volver en menos de 2 horas». → RPO = 15 min, RTO = 2 h.

Configuración:

# postgresql.conf
archive_mode = on
archive_command = 'test ! -f /archivo/%f && cp %p /archivo/%f'
archive_timeout = 300          # fuerza cierre de segmento cada 5 min → RPO ≤ 5 min

# copia base diaria
pg_basebackup -D /copias/base-$(date +%F) -Ft -z -X stream -c fast

Escenario: a las 14:37 alguien ejecuta DELETE FROM enrollments; sin WHERE.

Lo que no sirve:

Lo que sirve:

# 1. Detener el servicio y preservar el estado actual (por si acaso)
systemctl stop postgresql
mv /var/lib/postgresql/16/main /var/lib/postgresql/16/main.roto

# 2. Restaurar la copia base más reciente
tar xzf /copias/base-2026-08-19/base.tar.gz -C /var/lib/postgresql/16/main

# 3. Reproducir el WAL hasta JUSTO ANTES del error
cat > /var/lib/postgresql/16/main/postgresql.auto.conf <<'EOF'
restore_command = 'cp /archivo/%f %p'
recovery_target_time = '2026-08-19 14:36:30'
recovery_target_action = 'pause'
EOF
touch /var/lib/postgresql/16/main/recovery.signal

# 4. Arrancar; PostgreSQL reproduce y se detiene en el objetivo
systemctl start postgresql

Verificación antes de promover —este paso es el que casi nadie hace y el que evita restaurar sobre un estado equivocado—:

SELECT count(*) FROM enrollments;                       -- ¿coincide con lo esperado?
SELECT max(registrada_en) FROM enrollments;             -- ¿llega hasta 14:36?
SELECT * FROM enrollments ORDER BY registrada_en DESC LIMIT 5;

Solo si cuadra:

SELECT pg_wal_replay_resume();   -- o promover

Cronometraje real de una prueba sobre 500 GB:

Descarga de la copia desde otra región    38 min
Descompresión y colocación                12 min
Reproducción de 11 h de WAL               24 min
Verificación de integridad y conteos       8 min
                                        --------
RTO real medido                           82 min      ✔ por debajo de las 2 h
RPO real medido                       ≤ 5 min         ✔ por debajo de 15 min

Sin esta medición, el RTO es una suposición. Y las suposiciones se descubren falsas el día del incidente, que es el peor día.

La prueba mensual, automatizada:

#!/usr/bin/env bash
set -euo pipefail
INICIO=$(date +%s)

restaurar_en_entorno_aislado /copias/base-mas-reciente
esperar_a_que_acepte_conexiones

# Comprobar contenido, no solo que el proceso arranque: una base
# restaurada vacía arranca perfectamente.
FILAS=$(psql -tAc "SELECT count(*) FROM enrollments")
test "$FILAS" -gt 1000000 || { echo "::error::restauración vacía: $FILAS filas"; exit 1; }
psql -tAc "SELECT count(*) FROM (
             SELECT e.student_id FROM enrollments e
             LEFT JOIN courses c ON c.id = e.course_id
             WHERE c.id IS NULL) t" | grep -qx 0 || { echo "::error::integridad"; exit 1; }

echo "RTO medido: $(( ($(date +%s) - INICIO) / 60 )) min · filas: $FILAS"

El punto crítico está en el comentario: una base restaurada vacía arranca sin errores. Comprobar que el servicio responde no demuestra nada; hay que contar el contenido.

Comparación

Amenaza Réplica Copia diaria PITR Copia inmutable externa
Fallo de disco
Corrupción lógica No Parcial
DROP TABLE No Parcial
Cifrado por atacante No No No
Desastre regional Si es remota Si es remota Si es remota

Errores frecuentes

  1. No probar nunca la restauración. El fallo más grave y el más común.
  2. Confundir réplica con copia. No cubre error humano ni ataque.
  3. Copias en la misma cuenta y con los mismos permisos que el primario. Un atacante las borra también.
  4. Verificar que el servicio arranca y no el contenido. Una base vacía arranca.
  5. No cronometrar. El RTO declarado no coincide con el real.
  6. Copias sin cifrar en almacenamiento de objetos. La fuga es tan grave como la pérdida.
  7. Retener el WAL sin límite o sin vigilancia. Llena el disco del primario.

De la clase a la operación

La métrica que resume la salud del plan es la fecha de la última restauración verificada. Si es de hace más de un mes, el plan es una hipótesis. Publicarla en el panel de operación cambia la conducta del equipo más que cualquier documento.

Reto de transferencia

  1. Deriva RPO y RTO de una conversación real de negocio, con la pregunta concreta.
  2. Configura archivado continuo y realiza una recuperación a un punto en el tiempo.
  3. Cronometra la restauración completa y compara con el RTO declarado.
  4. Automatiza la prueba mensual con verificación de contenido, no solo de arranque.

Preguntas de evaluación

  1. ¿Por qué una réplica no protege de un DELETE sin WHERE?
  2. Calcula el RPO real de tu configuración actual, en minutos.
  3. ¿Qué comprobarías tras restaurar, antes de promover a producción?
  4. Diseña la protección de tus copias frente a un atacante con credenciales de administrador.

🌐 El mismo problema en cada motor

Caso: Un respaldo que nadie ha restaurado no es un respaldo, es un archivo

La única propiedad que importa de una copia de seguridad no es que exista: es que se pueda restaurar y comprobar. Y comprobar no es mirar si el archivo pesa lo esperado: es contar lo restaurado y compararlo con el origen.

El caso reduce esa disciplina a su forma mínima: seis notas, una copia, y la pareja de cifras que las compara —cuántas filas y cuánto suman—. En producción la copia la hace pg_dump, mysqldump, mongodump o una instantánea del volumen; lo que aquí se compara no es la herramienta, es el método de verificación, que es el mismo en todas partes y el que casi nadie ejecuta hasta el día del incidente.

Salida esperada, idéntica en todos los motores que lo resuelven:

copia filas suma
origen 6 402
restaurado 6 402

El contrato vive en motores.yaml y lo comprueba python scripts/verificar_equivalencia.py --clase 058: 5 de las 5 implementaciones se ejecutan de verdad y su resultado se compara con esa tabla; el resto se declara como material revisado, no ejecutado.

Motor ¿Resuelve el caso? Nivel de prueba Código Fuente
PostgreSQL servicio código doc oficial
MySQL servicio código doc oficial
SQLite núcleo código doc oficial
DuckDB núcleo código doc oficial
MongoDB servicio código doc oficial
Redis no doc oficial
Apache Cassandra no doc oficial

Los que resuelven el caso

PostgreSQL · implementaciones/postgresql/consulta.sql

verificado — se ejecuta contra el motor real levantado con docker compose

-- motor: postgresql
-- doc: https://www.postgresql.org/docs/current/continuous-archiving.html
-- nota: las dos familias, y hacen falta las dos:
--         pg_dump -Fc base > copia.dump        copia logica, portable
--         pg_basebackup + archive_command      punto en el tiempo
--       La segunda es la unica que salva de un DELETE sin WHERE a las once de
--       la noche: permite restaurar al segundo anterior.

-- === preparacion ===
DROP TABLE IF EXISTS notas_restauradas, notas;

CREATE TABLE notas (
    id         integer PRIMARY KEY,
    estudiante text NOT NULL,
    nota       integer NOT NULL
);
INSERT INTO notas (id, estudiante, nota) VALUES
    (1, 'Ada', 90), (2, 'Ada', 58), (3, 'Linus', 78),
    (4, 'Linus', 66), (5, 'Grace', 55), (6, 'Grace', 55);

-- La copia. En produccion seria pg_dump, mysqldump, mongodump o una
-- instantanea del volumen; aqui es una tabla, para que lo que se compare sea
-- el METODO de verificacion y no la herramienta.
CREATE TABLE notas_restauradas AS SELECT * FROM notas;

-- === consulta ===
-- La unica prueba que cuenta. Un respaldo que nadie ha restaurado no es un
-- respaldo: es un archivo. Y restaurarlo sin comparar tampoco prueba nada, asi
-- que la comprobacion es siempre la misma pareja: cuantas filas y que suman.
SELECT 'origen' AS copia, COUNT(*) AS filas, SUM(nota) AS suma FROM notas
UNION ALL
SELECT 'restaurado', COUNT(*), SUM(nota) FROM notas_restauradas
ORDER BY copia;

MySQL · implementaciones/mysql/consulta.sql

verificado — se ejecuta contra el motor real levantado con docker compose

-- motor: mysql
-- doc: https://dev.mysql.com/doc/refman/8.4/en/backup-and-recovery.html
-- nota: mysqldump SIN --single-transaction bloquea las tablas y puede producir
--       una copia incoherente. Y la recuperacion a un punto en el tiempo exige
--       que log_bin este activado y que los registros se conserven: comprobarlo
--       forma parte del plan de respaldo, no del de rendimiento.

-- === preparacion ===
DROP TABLE IF EXISTS notas_restauradas;
DROP TABLE IF EXISTS notas;

CREATE TABLE notas (
    id         INT PRIMARY KEY,
    estudiante VARCHAR(50) NOT NULL,
    nota       INT NOT NULL
);
INSERT INTO notas (id, estudiante, nota) VALUES
    (1, 'Ada', 90), (2, 'Ada', 58), (3, 'Linus', 78),
    (4, 'Linus', 66), (5, 'Grace', 55), (6, 'Grace', 55);

-- La copia. En produccion seria pg_dump, mysqldump, mongodump o una
-- instantanea del volumen; aqui es una tabla, para que lo que se compare sea
-- el METODO de verificacion y no la herramienta.
CREATE TABLE notas_restauradas AS SELECT * FROM notas;

-- === consulta ===
-- La unica prueba que cuenta. Un respaldo que nadie ha restaurado no es un
-- respaldo: es un archivo. Y restaurarlo sin comparar tampoco prueba nada, asi
-- que la comprobacion es siempre la misma pareja: cuantas filas y que suman.
SELECT 'origen' AS copia, COUNT(*) AS filas, SUM(nota) AS suma FROM notas
UNION ALL
SELECT 'restaurado', COUNT(*), SUM(nota) FROM notas_restauradas
ORDER BY copia;

SQLite · implementaciones/sqlite/consulta.sql

verificado — se ejecuta en CI sin servicios

-- motor: sqlite
-- doc: https://sqlite.org/backup.html
-- nota: en produccion la copia se hace SIN detener la base con
--         VACUUM INTO 'copia-2026-08-19.sqlite';
--       Lo que NO hay que hacer nunca es `cp base.sqlite copia.sqlite` mientras
--       alguien escribe: produce un archivo que parece valido y no lo es.

-- === preparacion ===
CREATE TABLE notas (
    id         INTEGER PRIMARY KEY,
    estudiante TEXT NOT NULL,
    nota       INTEGER NOT NULL
);
INSERT INTO notas (id, estudiante, nota) VALUES
    (1, 'Ada', 90), (2, 'Ada', 58), (3, 'Linus', 78),
    (4, 'Linus', 66), (5, 'Grace', 55), (6, 'Grace', 55);

-- La copia. En produccion seria pg_dump, mysqldump, mongodump o una
-- instantanea del volumen; aqui es una tabla, para que lo que se compare sea
-- el METODO de verificacion y no la herramienta.
CREATE TABLE notas_restauradas AS SELECT * FROM notas;

-- === consulta ===
-- La unica prueba que cuenta. Un respaldo que nadie ha restaurado no es un
-- respaldo: es un archivo. Y restaurarlo sin comparar tampoco prueba nada, asi
-- que la comprobacion es siempre la misma pareja: cuantas filas y que suman.
SELECT 'origen' AS copia, COUNT(*) AS filas, SUM(nota) AS suma FROM notas
UNION ALL
SELECT 'restaurado', COUNT(*), SUM(nota) FROM notas_restauradas
ORDER BY copia;

DuckDB · implementaciones/duckdb/consulta.sql

verificado — se ejecuta en CI sin servicios

-- motor: duckdb
-- doc: https://duckdb.org/docs/stable/sql/statements/export
-- nota: aqui el respaldo es exportar a un formato ABIERTO:
--         EXPORT DATABASE 'copia' (FORMAT PARQUET);
--       La copia sobrevive a la desaparicion del motor que la creo, cosa que
--       ningun volcado propietario garantiza. A cambio: no hay punto en el
--       tiempo ni copia incremental.

-- === preparacion ===
CREATE TABLE notas (
    id         INTEGER PRIMARY KEY,
    estudiante VARCHAR NOT NULL,
    nota       INTEGER NOT NULL
);
INSERT INTO notas (id, estudiante, nota) VALUES
    (1, 'Ada', 90), (2, 'Ada', 58), (3, 'Linus', 78),
    (4, 'Linus', 66), (5, 'Grace', 55), (6, 'Grace', 55);

-- La copia. En produccion seria pg_dump, mysqldump, mongodump o una
-- instantanea del volumen; aqui es una tabla, para que lo que se compare sea
-- el METODO de verificacion y no la herramienta.
CREATE TABLE notas_restauradas AS SELECT * FROM notas;

-- === consulta ===
-- La unica prueba que cuenta. Un respaldo que nadie ha restaurado no es un
-- respaldo: es un archivo. Y restaurarlo sin comparar tampoco prueba nada, asi
-- que la comprobacion es siempre la misma pareja: cuantas filas y que suman.
SELECT 'origen' AS copia, COUNT(*) AS filas, SUM(nota) AS suma FROM notas
UNION ALL
SELECT 'restaurado', COUNT(*), SUM(nota) FROM notas_restauradas
ORDER BY copia;

MongoDB · implementaciones/mongodb/consulta.js

verificado — se ejecuta contra el motor real levantado con docker compose

// motor: mongodb
// doc: https://www.mongodb.com/docs/database-tools/mongodump/
// nota: mongodump sobre un conjunto de replicas NO da una copia coherente entre
//       colecciones salvo que se use --oplog, y sobre un cluster fragmentado
//       hay que detener el balanceador. Son detalles que solo se descubren
//       restaurando, y por eso hay que restaurar antes del incidente.

// === preparacion ===
db.notas.drop();
db.notas_restauradas.drop();

db.notas.insertMany([
  { _id: 1, estudiante: "Ada", nota: 90 },
  { _id: 2, estudiante: "Ada", nota: 58 },
  { _id: 3, estudiante: "Linus", nota: 78 },
  { _id: 4, estudiante: "Linus", nota: 66 },
  { _id: 5, estudiante: "Grace", nota: 55 },
  { _id: 6, estudiante: "Grace", nota: 55 },
]);

// La «restauracion»: aqui, una copia de la coleccion.
db.notas.aggregate([{ $out: "notas_restauradas" }]).toArray();

// === consulta ===
for (const [nombre, coleccion] of [["origen", db.notas],
                                   ["restaurado", db.notas_restauradas]]) {
  const r = coleccion
    .aggregate([{ $group: { _id: null, filas: { $sum: 1 }, suma: { $sum: "$nota" } } }])
    .toArray()[0];
  print(nombre + "|" + r.filas + "|" + r.suma);
}

Los que no resuelven este caso — y qué se hace en su lugar

Descartar un motor con un argumento es tan formativo como usarlo. Ninguna de estas filas dice que el motor sea peor: dice que este problema no es el suyo.

Motor Por qué no Qué se hace en su lugar Fuente
Redis El archivo RDB es una instantánea de un momento, y el AOF un registro de órdenes: los dos se pueden copiar, pero Redis casi nunca es la fuente de la verdad, así que restaurarlo no restaura nada que importe. Tratar Redis como reconstruible: el plan de recuperación no es restaurarlo, es volver a llenarlo desde el sistema que sí guarda la verdad, y medir cuánto tarda ese llenado, porque ese tiempo es el de la caída. doc
Apache Cassandra Las instantáneas son por nodo y consisten en enlaces duros a las SSTables: la copia del clúster es la unión de las de todos los nodos, y no están tomadas en el mismo instante. No hay una copia coherente del conjunto. Instantáneas por nodo más el archivado incremental, y aceptar que la restauración devuelve un estado aproximadamente coherente que hay que reparar después con nodetool repair. doc

Laboratorio

python scripts/validate_repository.py
python labs/08-recovery/run_recovery_lab.py

Guarda como evidencia la salida completa, la versión del motor y la semilla o los parámetros usados. Una captura sin comando no es evidencia: no se puede repetir.

Evaluación

Criterio Peso Qué se comprueba
Comprensión conceptual 25 % Explica el mecanismo, no solo el resultado
Ejecución reproducible 25 % Otra persona obtiene lo mismo con las instrucciones dadas
Interpretación basada en evidencia 25 % Cada conclusión se apoya en una salida o una medición
Límites y riesgos declarados 25 % Dice qué no demuestra el ejercicio y qué faltaría en producción

La clase se da por superada cuando la respuesta explica el mecanismo, muestra la salida que la respalda y declara al menos un límite del ejercicio.

Fuentes de esta clase

Todo lo afirmado arriba procede de estas obras. Los identificadores viven en catalog/sources.json y el estado de los enlaces se comprueba con python scripts/check_external_links.py.


Programa · Parte 11 · ← Anterior · Siguiente →