StackPractices
intermediate Por Mathias Paulenko

Runbook de Failover de Base de Datos

Un runbook paso a paso para ejecutar procedimientos de failover de base de datos de forma segura con tiempo de inactividad y pérdida de datos mínimos.

Visión General

Un failover promueve una réplica de lectura para que asuma el rol de primaria cuando la original falla o debe desconectarse; la operación inversa — devolver el tráfico al nodo original una vez reconstruido — es el failback que se cubre más adelante en este runbook. Los failovers de base de datos son eventos de alto riesgo donde minutos de retraso significan pérdida de ingresos y erosión de confianza. Un runbook manual reduce los errores impulsados por el pánico al proporcionar pasos exactos, comandos de verificación y procedimientos de rollback. Este runbook cubre la promoción de primaria a réplica, la reconfiguración de aplicaciones y la validación post-failover.

El procedimiento siguiente asume que ya ejecutas replicación en streaming; si todavía la estás configurando, empieza por la guía completa de replicación en PostgreSQL. Para los trade-offs arquitectónicos detrás de este procedimiento, consulta el patrón failover.

Cuándo Usarlo

Usa este runbook cuando:

  • La base de datos primaria no responde o está severamente degradada
  • Un mantenimiento planificado requiere cambiar a una réplica
  • El centro de datos primario experimenta una interrupción
  • El failover automático ha fallado y se requiere intervención manual

No uses este runbook cuando:

  • RDS Multi-AZ o un orquestador como Patroni o Stolon ya está ejecutando un failover automático saludable — deja que la automatización termine y verifica el resultado
  • La primaria está degradada pero es recuperable sin una promoción (mata las consultas bloqueantes, reinicia la instancia, añade capacidad)
  • No hay réplica que promover — restaura desde backup y trátalo como un escenario de recuperación ante desastres

Antes de Empezar

Antes de comenzar:

  • Acceso a dashboards de monitoreo de base de datos (lag, conexiones, estado de replicación)
  • Acceso a gestión de configuración de aplicaciones (variables de entorno, archivos de config, service mesh)
  • Acceso a consola de DNS o balanceador de carga
  • Equipo de guardia notificado y canal de incidente abierto
  • Réplica de lectura confirmada saludable y lag < 5 segundos

Plantilla

# Runbook de Failover de Base de Datos: `<Nombre del Servicio>`

## 1. Verificar la Falla (2 minutos)

### Verificar Salud del Primario
```bash
psql -h primary.db.internal -U monitor -c "SELECT pg_is_in_recovery();"

## MySQL
mysql -h primary.db.internal -u monitor -e "SHOW STATUS LIKE 'Threads_connected';"
```

| Verificación | Esperado | Acción si Falla |
|--------------|----------|-----------------|
| Ping al primario | Respuesta < 10ms | Proceder al failover |
| Conteo de conexiones | < max_connections | Verificar tormenta de conexiones |
| Lag de replicación | N/A (primario) | Confirmar que primario es fuente |
| Espacio en disco | > 10% libre | Si está lleno, failover es la única opción |

### Confirmar que la Réplica está Lista
```bash
## PostgreSQL
psql -h replica.db.internal -U monitor -c "SELECT pg_last_xact_replay_timestamp();"

## MySQL 8.0.22+ (usa SHOW SLAVE STATUS / Seconds_Behind_Master en versiones anteriores)
mysql -h replica.db.internal -u monitor -e "SHOW REPLICA STATUS\G" | grep Seconds_Behind_Source
```

**Puerta de Decisión:** Solo proceder si el lag de réplica < 5 segundos y el disco de réplica es saludable.

## 2. Detener Escrituras al Primario (1 minuto)

```bash
## Configurar aplicación a modo solo lectura (si disponible)
curl -X POST http://app.internal/admin/read-only

## O bloquear en balanceador de carga
## Bloquear puerto 5432/3306 en grupo de seguridad del primario
```

## 3. Promover Réplica a Primario (3 minutos)

### PostgreSQL
```bash
## En la réplica
sudo -u postgres pg_ctl promote -D /var/lib/postgresql/data

## Verificar promoción
psql -h replica.db.internal -U monitor -c "SELECT pg_is_in_recovery();"  # Debe retornar false
```

### MySQL
```bash
## En la réplica (MySQL 8.0.22+; usa STOP SLAVE / RESET SLAVE ALL en versiones anteriores)
mysql -u root -e "STOP REPLICA; RESET REPLICA ALL;"

## Verificar
mysql -u root -e "SHOW REPLICA STATUS\G"    # Debe retornar conjunto vacío
mysql -u root -e "SHOW BINARY LOG STATUS;"  # Debe mostrar posición del binlog (SHOW MASTER STATUS en MySQL < 8.4)
```

### AWS RDS
```bash
aws rds promote-read-replica \
  --db-instance-identifier replica-01 \
  --region us-east-1
```

## 4. Actualizar Configuración de la Aplicación (2 minutos)

```bash
## Actualizar variable de entorno o config map
export DB_HOST=replica.db.internal

## Recargar aplicación (sin tiempo de inactividad si usa pool de conexiones)
sudo systemctl reload app

## O para Kubernetes
kubectl set env deployment/app DB_HOST=replica.db.internal
kubectl rollout status deployment/app
```

## 5. Cambio de DNS / Balanceador de Carga (2 minutos)

| Método | Comando | RTO |
|--------|---------|-----|
| Registro DNS A | Actualizar a IP de réplica | 5-60 segundos (depende del TTL) |
| Balanceador de carga | Cambiar target group | 10-30 segundos |
| Service mesh (Consul) | Actualizar `consul catalog services` | 5-10 segundos |
| Servicio de Kubernetes | Actualizar endpoint o selector de servicio | Inmediato |

```bash
## Ejemplo: AWS Route53
aws route53 change-resource-record-sets \
  --hosted-zone-id Z123456789 \
  --change-batch file://failover-dns.json
```

## 6. Verificar Funcionalidad de la Aplicación (3 minutos)

```bash
## Health check
curl -f http://app.internal/health

## Prueba de escritura
curl -X POST http://app.internal/api/test \
  -H "Content-Type: application/json" \
  -d '{"test": "failover-write-2026-06-26"}'

## Verificación de lectura — reemplaza <id> con el ID devuelto por la prueba de escritura
curl http://app.internal/api/test/<id>
```

| Verificación | Estado | Tiempo |
|--------------|--------|--------|
| Health checks pasando | [ ] | ___ |
| Escritura exitosa | [ ] | ___ |
| Lectura correcta | [ ] | ___ |
| Lag de replicación (nueva réplica) | < 1s | ___ |
| Tasa de error < 0.1% | [ ] | ___ |

## 7. Establecer Nueva Replicación (5 minutos)

### Opción A: Reparar el Viejo Primario (si recuperable)
```bash
## Reconfigurar viejo primario como réplica
## PostgreSQL — el directorio de datos de destino debe estar vacío (mueve o borra el viejo primero)
pg_basebackup -h new-primary.db.internal -D /var/lib/postgresql/data -Fp -Xs -P
## Editar recovery.conf o postgresql.auto.conf con primary_conninfo
sudo -u postgres pg_ctl start
```

### Opción B: Crear Nueva Réplica
```bash
## Desde snapshot o backup base
aws rds create-db-instance-read-replica \
  --db-instance-identifier new-replica-01 \
  --source-db-instance-identifier new-primary-01
```

## 8. Acciones Post-Incidente

- [ ] Actualizar línea de tiempo del incidente con tiempos exactos de cada paso
- [ ] Capturar logs del viejo primario para análisis de causa raíz
- [ ] Documentar pérdida de datos (si hay) con IDs de transacción exactos
- [ ] Programar postmortem dentro de 24 horas
- [ ] Actualizar este runbook con lecciones aprendidas

Explicación

El runbook separa la verificación (confirmar falla, confirmar salud de réplica) de la ejecución (promoción, cambio) y la validación (pruebas de escritura/lectura). La puerta de decisión en el paso 1 previene failovers hacia una réplica no saludable. El cambio de DNS se prefiere sobre reinicios de aplicación porque minimiza el RTO y evita retrasos por calentamiento de pools de conexión.

Dos números deciden qué tan mal puede salir un failover: el RPO, la cantidad de datos confirmados que puedes permitirte perder, y el RTO, cuánto tiempo puede estar caída la base de datos. La puerta de lag de 5 segundos en el paso 1 es el control de RPO — cada transacción que la réplica no haya replicado al momento de la promoción es un dato que la aplicación nunca volverá a ver. El método de cambio es el control de RTO: DNS es barato pero está limitado por el TTL, un balanceador de carga o service mesh converge en segundos, y un pooler de conexiones como PgBouncer puede pausar y reanudar el tráfico de clientes sin provocar una tormenta de reconexiones.

El orden de los pasos también protege contra el split-brain, el peor resultado de un failover mal ejecutado. Las escrituras se detienen (paso 2) antes de promover la réplica (paso 3) precisamente para que ambos nodos nunca acepten escrituras al mismo tiempo. Si el viejo primario vuelve inesperadamente después del cambio, no debe servir tráfico otra vez hasta que sea reconstruido como réplica — por eso existen el paso 7 y el procedimiento de failback siguiente.

Failback: Regreso al Primario Original

Un failover es solo la mitad de la operación. Una vez que el viejo primario vuelve a estar saludable, decide si mantener la réplica promovida como primaria permanente o hacer failback al original.

Mantén la réplica promovida como primaria cuando:

  • Tiene capacidad igual o superior al viejo primario
  • La revisión del incidente no encontró ventajas de hardware o ubicación en el nodo original
  • Quieres evitar una segunda ventana de interrupción — cada cambio conlleva riesgo

Haz failback al primario original cuando:

  • La réplica era capacidad de emergencia (instancia más pequeña, menos IOPS aprovisionadas)
  • Las licencias, las herramientas de backup o la ubicación regional favorecen al nodo original
  • El viejo primario contiene configuración fijada que la réplica no tiene

Procedimiento de failback

  1. Reconstruye el viejo primario como réplica del nuevo primario (paso 7 del runbook). En PostgreSQL, pg_rewind es más rápido que un pg_basebackup completo cuando ambos nodos comparten un historial común de checkpoints:
## En el viejo primario, detenido y con un directorio de datos válido (no borrado)
pg_rewind --target-pgdata=/var/lib/postgresql/data \
  --source-server="host=new-primary.db.internal user=replicator"

pg_rewind requiere wal_log_hints = on o checksums de datos activados — verifica SHOW data_checksums; antes de confiar en él. Si falla, vuelve a pg_basebackup sobre un directorio de datos vacío.

  1. Espera hasta que el lag de replicación en el nodo reconstruido sea inferior a 1 segundo.
  2. Programa una ventana de mantenimiento — un failback es un failover planificado, no una emergencia.
  3. Ejecuta el mismo runbook a la inversa: detén escrituras, promueve el primario original, revierte el DNS o balanceador de carga, verifica las escrituras.
  4. Restablece la replicación en la dirección final y actualiza dashboards y alertas para que coincidan con la nueva topología.

No omitas la tabla de verificación del paso 6 durante un failback. Una promoción que funciona en una dirección puede fallar en la otra por valores obsoletos de primary_conninfo o slots de replicación residuales.

Variantes

ContextoEnfoqueNotas
Replicación en streaming de PostgreSQLpg_ctl promoteMás rápido, requiere streaming WAL saludable
MySQL GTIDSTOP REPLICA; RESET REPLICA ALL;GTID simplifica encontrar la posición correcta
AWS RDS Multi-AZFailover automáticoSolo usar este runbook para cross-region o promoción manual
Kubernetes StatefulSetOrquestador Patroni / StolonEl operador maneja la promoción; runbook para falla del operador

Adaptar Este Runbook

La plantilla está escrita para un setup genérico de dos nodos, así que reemplaza cada hostname *.db.internal, puerto y rol de credencial (monitor, replicator) con tus valores reales antes de que ocurra un incidente. Ajusta la puerta de lag de 5 segundos a tu objetivo de RPO real — una base de datos de pagos puede necesitar replicación síncrona y una puerta de lag cero, mientras que una réplica de analítica tolera más. Mapea cada paso a tus herramientas: Cloud SQL y Aurora tienen sus propios comandos de promoción, y los proxies gestionados reemplazan el paso de DNS en algunos stacks.

Dos detalles que los equipos olvidan hasta el primer simulacro: la deriva de versiones y los permisos. La réplica debe ejecutar la misma versión mayor que la primaria o la promoción fallará, y el ingeniero de guardia necesita las credenciales de cloud o SSH para promover antes del incidente — anota dónde viven. Por último, mantén una copia impresa de una página o fijada con los pasos 1-6 en el canal de incidentes — durante una interrupción real nadie hace scroll. Las versiones descargables (la plantilla del runbook, el script de automatización, la config de Patroni y el verificador de consistencia) están en el repositorio complementario.

Lo que funciona

  1. Prueba este runbook mensualmente en un entorno de staging — no durante el incidente; un plan de pruebas de recuperación ante desastres le da al simulacro una estructura de aprobado/fallido
  2. Automatiza los health checks en los pasos 1 y 6 con scripts, no consultas manuales
  3. Usa pooling de conexiones (PgBouncer, ProxySQL) para evitar retrasos de TTL DNS
  4. Monitorea el lag de replicación continuamente — lag > 30s debe alertar al equipo de guardia
  5. Documenta el ID de transacción exacto en la promoción para cálculo de pérdida de datos
  6. Comunica durante el evento — publica actualizaciones de estado con la plantilla de comunicación de caídas para que los stakeholders no tengan que adivinar

Errores Comunes

  1. Hacer failover a una réplica con lag — resulta en pérdida de datos y errores de aplicación
  2. No detener escrituras antes de la promoción — cerebro dividido, conjuntos de datos divergentes
  3. Olvidar actualizar la config de la aplicación — apps reconectan al viejo primario fallido
  4. No verificar escrituras post-failover — fallas silenciosas pasan desapercibidas por horas
  5. Omitir configurar nueva réplica — ejecutando sin redundancia después del failover

Soluciones Avanzadas

Script de failover automatizado con verificaciones pre-flight

Combina todos los pasos del runbook en un solo script ejecutable con puertas de seguridad:

#!/bin/bash
# failover.sh - Failover de base de datos automatizado con verificaciones pre-flight
# Uso: ./failover.sh [--force] [--dry-run]

set -euo pipefail

FORCE=false
DRY_RUN=false

for arg in "$@"; do
  case $arg in
    --force) FORCE=true ;;
    --dry-run) DRY_RUN=true ;;
  esac
done

PRIMARY_HOST="primary.db.internal"
REPLICA_HOST="replica.db.internal"
DB_USER="monitor"
MAX_LAG_SECONDS=5

log() { echo "[$(date -u +%H:%M:%S)] $1"; }

# Paso 1: Verificaciones pre-flight
log "Running pre-flight checks..."

# Comprobar que el primario realmente está caído
if ping -c 1 -W 2 "$PRIMARY_HOST" &>/dev/null && ! $FORCE; then
  log "ERROR: Primary is reachable. Use --force to override."
  exit 1
fi

# Comprobar lag de la réplica
log "Checking replica lag..."
PG_LAG=$(psql -h "$REPLICA_HOST" -U "$DB_USER" -t -c \
  "SELECT EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()));" 2>/dev/null | xargs)

if (( $(echo "$PG_LAG > $MAX_LAG_SECONDS" | bc -l) )) && ! $FORCE; then
  log "ERROR: Replica lag is ${PG_LAG}s (max: ${MAX_LAG_SECONDS}s). Use --force to override."
  exit 1
fi

log "Pre-flight checks passed. Replica lag: ${PG_LAG}s"

if $DRY_RUN; then
  log "DRY RUN: Would proceed with failover."
  exit 0
fi

# Paso 2: Activar modo solo lectura
log "Enabling read-only mode..."
curl -sS -X POST http://app.internal/admin/read-only || log "WARN: Could not enable read-only mode"

# Paso 3: Promover réplica
log "Promoting replica to primary..."
sudo -u postgres pg_ctl promote -D /var/lib/postgresql/data

# Verificar promoción
IS_RECOVERY=$(psql -h "$REPLICA_HOST" -U "$DB_USER" -t -c "SELECT pg_is_in_recovery();" | xargs)
if [ "$IS_RECOVERY" != "f" ]; then
  log "ERROR: Promotion failed. pg_is_in_recovery returned: $IS_RECOVERY"
  exit 1
fi
log "Promotion successful."

# Paso 4: Actualizar config de la aplicación
log "Updating application configuration..."
kubectl set env deployment/app DB_HOST="$REPLICA_HOST"
kubectl rollout status deployment/app --timeout=120s

# Paso 5: Verificar
log "Running post-failover verification..."
sleep 5
HTTP_CODE=$(curl -sS -o /dev/null -w "%{http_code}" http://app.internal/health)
if [ "$HTTP_CODE" != "200" ]; then
  log "ERROR: Health check failed with HTTP $HTTP_CODE"
  exit 1
fi

WRITE_RESULT=$(curl -sS -X POST http://app.internal/api/test \
  -H "Content-Type: application/json" \
  -d "{\"test\": \"failover-$(date +%s)\"}" -w "\n%{http_code}")

WRITE_CODE=$(echo "$WRITE_RESULT" | tail -1)
if [ "$WRITE_CODE" != "200" ] && [ "$WRITE_CODE" != "201" ]; then
  log "ERROR: Write test failed with HTTP $WRITE_CODE"
  exit 1
fi

log "Failover complete. All verifications passed."
log "Next steps:"
log "  1. Establish new replication (Step 7 of runbook)"
log "  2. Update incident timeline"
log "  3. Schedule postmortem"

Configuración de failover automatizado con Patroni

Para PostgreSQL, Patroni proporciona failover automatizado con health checks y gestión de cluster:

# patroni.yml
name: postgres-cluster
scope: pgsql
restapi:
  listen: 0.0.0.0:8008
  connect_address: $(HOSTIP):8008

etcd:
  hosts: etcd1.internal:2379,etcd2.internal:2379,etcd3.internal:2379

bootstrap:
  dcs:
    ttl: 30
    loop_wait: 10
    retry_timeout: 20
    maximum_lag_on_failover: 1048576  # 1MB
    maximum_lag_on_syncnode: 1048576
    synchronous_mode: true
    synchronous_mode_strict: false
    postgresql:
      use_pg_rewind: true
      use_slots: true
      parameters:
        wal_level: replica
        hot_standby: "on"
        max_wal_senders: 10
        max_replication_slots: 10
        wal_keep_size: 128MB  # wal_keep_segments en PostgreSQL < 13
        archive_mode: "on"
        archive_timeout: 1800s
      recovery_conf:
        restore_command: "wal-g wal-fetch %f %p"

postgresql:
  listen: 0.0.0.0:5432
  connect_address: $(HOSTIP):5432
  data_dir: /var/lib/postgresql/data
  bin_dir: /usr/lib/postgresql/bin
  pg_hba:
    - "replication replicator 0.0.0.0/0 md5"
    - "host all all 0.0.0.0/0 md5"
  replication:
    username: replicator
    password: "${REPLICATION_PASSWORD}"
    network: 0.0.0.0/0
  superuser:
    username: postgres
    password: "${POSTGRES_PASSWORD}"

tags:
  nofailover: false
  noloadbalance: false
  clonefrom: false
  nosync: false

Verificación de consistencia de datos post-failover

Verifica la integridad de datos después del failover comparando logs de transacciones:

import psycopg2
from dataclasses import dataclass

@dataclass
class FailoverVerification:
    promoted_node: str
    old_primary_lsn: str  # LSN (log sequence number) en el momento de la falla
    new_primary_lsn: str  # LSN en el momento de la promoción
    transactions_lost: int
    consistency_ok: bool

def verify_failover_consistency(
    new_primary_host: str,
    old_primary_lsn: str,
    db_user: str = "monitor",
) -> FailoverVerification:
    """Verifica la consistencia de datos después de un failover de base de datos."""
    conn = psycopg2.connect(host=new_primary_host, user=db_user, dbname="postgres")
    cur = conn.cursor()

    # Obtener el LSN actual en el nuevo primario
    cur.execute("SELECT pg_current_wal_lsn();")
    new_lsn = cur.fetchone()[0]

    # Contar transacciones desde el punto de promoción
    cur.execute("""
        SELECT count(*) FROM pg_stat_activity
        WHERE state = 'active' AND xact_start > now() - interval '5 minutes';
    """)
    active_txns = cur.fetchone()[0]

    # Comprobar salud de los slots de replicación
    cur.execute("""
        SELECT slot_name, active, restart_lsn
        FROM pg_replication_slots;
    """)
    slots = cur.fetchall()

    # Verificar que todos los slots están activos
    all_active = all(slot[1] for slot in slots) if slots else True

    # Estimar transacciones perdidas (simplificado)
    lost = 0 if old_primary_lsn == "unknown" else estimate_lost(old_primary_lsn, new_lsn)

    result = FailoverVerification(
        promoted_node=new_primary_host,
        old_primary_lsn=old_primary_lsn,
        new_primary_lsn=new_lsn,
        transactions_lost=lost,
        consistency_ok=all_active,
    )

    cur.close()
    conn.close()
    return result

def estimate_lost(old_lsn: str, new_lsn: str) -> int:
    """Estima las transacciones perdidas entre dos LSN."""
    # Parsear formato LSN (p. ej., '0/17000058')
    try:
        old_parts = [int(x, 16) for x in old_lsn.split("/")]
        new_parts = [int(x, 16) for x in new_lsn.split("/")]
        old_bytes = old_parts[0] * 0x100000000 + old_parts[1]
        new_bytes = new_parts[0] * 0x100000000 + new_parts[1]
        diff = new_bytes - old_bytes
        # Estimación aproximada: 1 transacción ~ 200 bytes de media
        return max(0, diff // 200)
    except (ValueError, IndexError):
        return 0

# Ejemplo de uso
result = verify_failover_consistency(
    new_primary_host="replica.db.internal",
    old_primary_lsn="0/17000058",
)
print(f"Promoted node: {result.promoted_node}")
print(f"Transactions lost (est.): {result.transactions_lost}")
print(f"Replication slots healthy: {result.consistency_ok}")

Árbol de Decisión de Failover

flowchart diagram: ¿Primario caído o degradado?

Lectura Adicional

Preguntas frecuentes

¿Cómo sé si la réplica está al día?

PostgreSQL: pg_last_xact_replay_timestamp() debe estar dentro de 5 segundos de now(). MySQL: Seconds_Behind_Source debe ser 0 (Seconds_Behind_Master en versiones anteriores a 8.0.22). Verifica siempre antes de promover.

¿Qué pasa si el viejo primario vuelve online después del failover?

Apágalo inmediatamente o configúralo como réplica. Un viejo primario que acepta escrituras crea un escenario de cerebro dividido. El enfoque más seguro: apagarlo hasta poder reconfigurarlo.

¿Cómo minimizo el RTO durante un failover?

Usa un balanceador de carga o service mesh en lugar de DNS. Pre-configura el endpoint de réplica en la aplicación con un pooler de conexiones. Automatiza el paso de promoción con un script que retorne en menos de 10 segundos.

¿Cuál es la diferencia entre failover y failback?

El failover promueve una réplica cuando la primaria falla — normalmente no es planificado. El failback es la operación inversa: una vez que el primario original está reconstruido como réplica saludable, ejecutas un failover planificado para devolverle el rol de primaria. El failback siempre se programa; el failover a menudo no.

¿Cómo se aplican el RTO y el RPO a un failover de base de datos?

El RTO es cuánto tiempo permanece caída la base de datos — los pasos cronometrados de este runbook apuntan a unos 15-20 minutos de inactividad. El RPO es cuántos datos confirmados puedes permitirte perder: con replicación asíncrona, el RPO equivale al lag de la réplica en el momento de la promoción, razón por la que existe la puerta de lag de 5 segundos. La replicación síncrona puede llevar el RPO a cero a costa de latencia de escritura.