Skip to content

English · Español

Teoría 08 — Copia de seguridad y restauración

🇪🇸 El portal usa la API de copia en línea de SQLite (Connection.backup) para producir snapshots íntegros mientras el proceso sigue en marcha. Objetivo de recuperación: RPO ≤ 24 h, RTO ≤ 15 min en una sola máquina, con verificación trimestral del procedimiento. La replicación off-machine queda fuera de alcance — se difiere a una fase futura de producción.

Por qué online backup de SQLite (no cp)

El portal corre SQLite en modo WAL (el predeterminado para cualquier despliegue FastAPI + SQLModel que quiera lectores concurrentes durante escrituras). En modo WAL, una escritura comprometida va primero al archivo lateral *.db-wal; solo se reincorpora al archivo principal *.db en el siguiente checkpoint. Dos consecuencias:

  1. Un cp portal.db snapshot.db ingenuo captura el archivo principal sin el WAL. Cualquier transacción comprometida desde el último checkpoint falta en la copia. En el momento de la restauración perderías silenciosamente los minutos más recientes de escrituras — exactamente los datos que querías respaldar.
  2. Si un checkpoint corre mientras cp está en mitad del stream, la copia puede contener páginas de dos estados distintos. El archivo resultante puede pasar un sqlite3 .open pero fallará un PRAGMA integrity_check (y peor, puede tener éxito al principio y corromperse después bajo presión de escritura).

La API de online backup de SQLite (Connection.backup en el módulo sqlite3 de Python) recorre la base de datos página a página manteniendo los locks apropiados. El resultado es un único archivo que representa un snapshot consistente del momento en que se completó el backup, incluidos los datos pendientes del WAL ya plegados. Esto es seguro de ejecutar mientras el portal sirve tráfico.

Tras cada escritura, el script de backup reabre el snapshot en solo lectura y ejecuta PRAGMA integrity_check. Esto es barato (milisegundos de un solo dígito para DBs del tamaño del portal) y atrapa los casos raros en los que el disco de destino devolvió éxito en una escritura parcial.

Qué vive en un snapshot

var/snapshots/
├── 2026-05-23-031500-portal.db     ← portal DB online backup
├── 2026-05-23-031500-vault.db      ← vault DB online backup
├── 2026-05-22-031500-portal.db
├── 2026-05-22-031500-vault.db
├── .last_ok                        ← sentinel: { "label": ..., "ts": ... }
└── pre-restore-2026-05-23-040000-portal.db   ← created by restore (never rotated)

Los snapshots del portal y del vault comparten un prefijo de timestamp. El script de rotación los trata como un par: mantener N pares mantiene ambos archivos del mismo momento, así el vault nunca se desfasa por delante del portal que protege.

El sentinel .last_ok es un archivo JSON con dos campos:

{"label": "cron", "ts": "2026-05-23T03:15:00+00:00"}

El gauge de Prometheus last_snapshot_age_seconds lee este archivo en cada scrape de /metrics. Si el archivo falta o es más antiguo que el intervalo del cron, el dashboard se pone rojo.

Calendario

Cron recomendado:

# /etc/cron.d/miniportal-snapshot
15 3 * * *   portaluser   cd /opt/lynx-cortex && \
    uv run python scripts/portal_backup.py --label cron && \
    uv run python scripts/portal_snapshot_rotate.py --keep 14

Cada noche a las 03:15 UTC — lejos del tráfico pico del portal, que es diurno para la cohorte de learners. El paso de rotación corre inmediatamente después del backup, así la huella en disco se mantiene acotada a 14 pares ≈ 14 × (portal_size + vault_size). Para el portal del tutor de gramática §A13 eso es una fracción de megabyte; el margen a 14 días es generoso.

Los snapshots manuales están disponibles para los admins a través del dashboard /admin/obs (botón "snapshot now" → POST /admin/obs/snapshot-now). Los snapshots manuales se marcan con --label manual y se cuentan en la rotación junto a los snapshots de cron.

Procedimiento de restauración

  1. Para el portal. El script de restauración rehúsa sobreescribir una DB que otro proceso mantiene abierta (adquiere un flock exclusivo). Esto es un chequeo de seguridad, no de corrección — sobreescribir por la fuerza una DB en vivo corrompería las escrituras en vuelo.
  2. Elige un snapshot. Lista archivos en var/snapshots/; elige el par más reciente cuyo timestamp sea anterior al incidente.
  3. Primero dry-run.
uv run python scripts/portal_restore.py \
    --snapshot var/snapshots/2026-05-22-031500-portal.db \
    --target portal --dry-run

El dry-run verifica el PRAGMA integrity_check del snapshot e imprime qué se haría. No toca el disco. 4. Restauración real.

uv run python scripts/portal_restore.py \
    --snapshot var/snapshots/2026-05-22-031500-portal.db \
    --target portal --i-know-what-im-doing

Antes de sobreescribir, el script escribe un backup pre-restauración de la DB en vivo en var/snapshots/pre-restore-{ts}-portal.db. Estos nunca se rotan automáticamente — son tu "undo". Si tras restaurar te das cuenta de que elegiste el snapshot equivocado, puedes restaurar desde el backup pre-restauración. 5. Reinicia el portal. Verifica que el dashboard de obs del admin muestra el estado esperado (la edad del snapshot solo se resetea en el siguiente backup exitoso; restaurar no resetea el sentinel).

Objetivos RPO / RTO

Métrica Objetivo Cómo se logra
RPO (Recovery Point Objective) ≤ 24 h Snapshot diario por cron. Peor caso: el incidente ocurre a las 03:14 UTC, justo antes del cron de las 03:15 — perdemos 23h 59m de escrituras.
RTO (Recovery Time Objective) ≤ 15 min Parar portal (1 min), dry-run + restaurar (2 min), reiniciar y smoke-check (5 min); los 7 min restantes son "el operador lee las notas del incidente".

Estos son números de una sola máquina. Recuperarse de una pérdida a nivel de hardware (fallo de disco, máquina comprometida) está fuera de alcance para la Fase 41 — eso requiere replicación de snapshots off-machine (S3, restic a un remoto, etc.), que se difiere a una fase de despliegue de producción.

Lo que NO está en alcance

  • Replicación off-machine. Véase arriba; diferida.
  • Cifrado en reposo para los snapshots. El snapshot del vault hereda el cifrado a nivel de columna del vault; el snapshot del portal no. En la Fase 41 la DB del portal no contiene contraseñas en plaintext ni claves de sesión (esas viven en el vault) — así que un snapshot filtrado filtra metadatos por estudiante pero no credenciales. Si el modelo de amenazas más adelante requiere snapshots cifrados, eso es una fase de seguimiento.
  • Recuperación a un punto en el tiempo (PITR). SQLite no tiene archivado WAL incorporado. La granularidad diaria es el contrato.

Probar la restauración cada trimestre

Un snapshot que nunca has restaurado no es un backup; es un archivo esperanzador. Programa un recordatorio en el calendario:

  1. El primer lunes de cada trimestre, copia el par de snapshots de ayer a un directorio temporal.
  2. Ejecuta portal_restore.py --dry-run contra él.
  3. Levanta una instancia desechable del portal apuntando a las DBs restauradas.
  4. Inicia sesión como el admin seedeado; verifica que tres entradas aleatorias del journal del estudiante renderizan; verifica que el audit log contiene filas.
  5. Escribe una entrada de una línea en experiments/41-portal-dr-drill/<date>.md confirmando que el simulacro se ejecutó.

El simulacro trimestral es el artefacto que te permite reclamar un RTO de 15 minutos de buena fe — sin él, el número es aspiracional.

Referencias cruzadas

  • docs/phase-41-learner-portal/theory/07-observability.md — el gauge last_snapshot_age_seconds saca a la luz la frescura de estos snapshots en el dashboard.
  • src/miniportal/obs_extended/service.py — las sondas que actualizan ese gauge en cada scrape de /metrics.
  • scripts/portal_backup.py, scripts/portal_restore.py, scripts/portal_snapshot_rotate.py — los tres puntos de entrada orientados al operador.

Trampas comunes

  • Usar shutil.copy sobre un archivo SQLite vivo. Descrito arriba. Usa la API de online backup.
  • Olvidar el vault. Una restauración del portal sin una restauración del vault emparejada puede dejar tokens de set-password no canjeables. Restaura el par.
  • Confiar en un snapshot no verificado. Siempre ejecuta PRAGMA integrity_check sobre el snapshot antes de tratarlo como fuente de verdad. Tanto portal_backup.py como portal_restore.py lo hacen por ti; no te lo saltes cuando restaures manualmente.
  • Rotar fuera tu único buen snapshot. Pon --keep al menos a 14 en un calendario diario, así una semana de backups malos todavía te deja una semana de buenos a los que recurrir.