SRE: Recuperación ante Desastres y Continuidad del Negocio
Apoya este blog
Si te resulta util este contenido, considera apoyar el blog.
Introducción
A lo largo de esta serie de SRE fuimos construyendo un conjunto completo de herramientas para correr sistemas confiables. Cubrimos SLIs y SLOs, gestión de incidentes, observabilidad, chaos engineering, planificación de capacidad, GitOps, gestión de secretos, optimización de costos, gestión de dependencias, confiabilidad de bases de datos, ingeniería de releases, y seguridad como código. Tenemos métricas, alertas, respuesta a incidentes y experimentos de caos funcionando. Pero hay una pregunta que todavía no abordamos del todo: ¿qué pasa cuando todo se cae al mismo tiempo?
“La esperanza no es una estrategia” es una frase que se escucha seguido en círculos de SRE, y en ningún
lado aplica más que en recuperación ante desastres. Una sola zona de disponibilidad que se apaga, un
upgrade de cluster que sale mal, un ataque de ransomware, o incluso un
kubectl delete namespace production accidental puede borrar toda tu carga de trabajo. La pregunta no es
si un desastre va a pasar, sino cuándo, y si vas a estar preparado.
En este artículo vamos a cubrir todo lo que necesitás para armar un plan sólido de recuperación ante desastres (DR) y continuidad del negocio para ambientes Kubernetes. Vamos desde definir objetivos de RPO y RTO hasta backups con Velero, recuperación de etcd, estrategias multi-región, simulacros de DR, planes de comunicación, y runbooks paso a paso para recuperación completa del cluster.
Vamos al tema.
RPO y RTO: definiendo tus objetivos de recuperación
Antes de poder planificar la recuperación ante desastres, necesitás responder dos preguntas fundamentales:
- RPO (Recovery Point Objective): ¿Cuántos datos podés permitirte perder? Si tu RPO es 1 hora, necesitás backups al menos cada hora. Si tu RPO es cero, necesitás replicación sincrónica.
- RTO (Recovery Time Objective): ¿Cuánto tiempo puede estar caído tu servicio? Si tu RTO es 15 minutos, necesitás failover automatizado. Si tu RTO es 4 horas, la recuperación manual puede ser aceptable.
Estos objetivos no son decisiones técnicas, son decisiones de negocio. Necesitás sentarte con los stakeholders y entender el costo real del downtime y la pérdida de datos para cada servicio. Un sistema de procesamiento de pagos tiene requerimientos muy distintos a una wiki interna.
Acá hay un template simple de análisis de impacto al negocio para guiar esas conversaciones:
# dr-plan/business-impact-analysis.yaml
services:
- name: payment-api
tier: critical
rpo: "0 minutos" # Cero pérdida de datos
rto: "5 minutos" # Failover automatizado requerido
data_classification: pci
revenue_impact_per_hour: "$50,000"
dependencies:
- postgresql-primary
- redis-sessions
- stripe-api
backup_strategy: synchronous-replication
failover_strategy: active-active
- name: user-api
tier: high
rpo: "15 minutos"
rto: "30 minutos"
data_classification: pii
revenue_impact_per_hour: "$10,000"
dependencies:
- postgresql-primary
- redis-cache
backup_strategy: streaming-replication
failover_strategy: active-passive
- name: blog
tier: medium
rpo: "24 horas"
rto: "4 horas"
data_classification: public
revenue_impact_per_hour: "$0"
dependencies:
- postgresql-primary
backup_strategy: daily-snapshots
failover_strategy: rebuild-from-backup
- name: internal-tools
tier: low
rpo: "24 horas"
rto: "24 horas"
data_classification: internal
revenue_impact_per_hour: "$500"
dependencies:
- postgresql-primary
backup_strategy: daily-snapshots
failover_strategy: rebuild-from-backup
Lo clave acá es que no todos los servicios necesitan el mismo nivel de protección. Sobre-ingeniar el DR para un servicio de bajo nivel desperdicia plata, mientras que sub-ingeniarlo para un servicio crítico crea riesgo real. Clasificá tus servicios en niveles y planificá en consecuencia.
Template del plan de DR
Toda organización necesita un plan de DR documentado, probado y actualizado regularmente. Acá hay un template estructurado que cubre lo esencial:
# dr-plan/disaster-recovery-plan.yaml
metadata:
version: "2.1"
last_updated: "2026-03-15"
next_review: "2026-06-15"
owner: "platform-team"
approver: "vp-engineering"
scope:
environments:
- production
- staging
regions:
- primary: us-east-1
- secondary: eu-west-1
clusters:
- prod-primary (us-east-1)
- prod-secondary (eu-west-1)
roles_and_responsibilities:
incident_commander:
name: "Líder de guardia rotativo"
responsibilities:
- Declarar desastre
- Coordinar recuperación
- Autorizar decisiones de failover
- Comunicar con liderazgo
dr_lead:
name: "SRE senior de guardia"
responsibilities:
- Ejecutar runbooks de recuperación
- Verificar integridad de backups
- Coordinar recuperación de infraestructura
- Correr validación post-recuperación
communications_lead:
name: "Engineering manager de guardia"
responsibilities:
- Actualizar la página de estado
- Notificar a los clientes
- Coordinar con el equipo de soporte
- Enviar actualizaciones internas
database_lead:
name: "DBA de guardia"
responsibilities:
- Verificar backups de base de datos
- Ejecutar recuperación de base de datos
- Validar integridad de datos
- Monitorear lag de replicación
activation_criteria:
- "Pérdida completa de disponibilidad de la región primaria"
- "Cluster de Kubernetes primario irrecuperable"
- "Corrupción de datos que afecta servicios críticos"
- "Brecha de seguridad que requiere reconstruir infraestructura"
- "Caída del proveedor cloud por más de 30 minutos"
communication_channels:
primary: "Slack #incident-war-room"
secondary: "PagerDuty conference bridge"
tertiary: "Números de teléfono personales (ver doc de contactos de emergencia)"
status_page: "https://status.example.com"
recovery_priority:
- tier: 1
services: [payment-api, auth-service]
target_rto: "5 minutos"
action: "Failover DNS automatizado a región secundaria"
- tier: 2
services: [user-api, notification-service]
target_rto: "30 minutos"
action: "Restaurar desde réplica en región secundaria"
- tier: 3
services: [blog, docs, internal-tools]
target_rto: "4 horas"
action: "Reconstruir desde backups y repo de GitOps"
Notá que el plan tiene versión, dueño, y una fecha de revisión programada. Un plan de DR que se escribió hace dos años y nunca se actualizó es peor que no tener plan porque te da falsa confianza. Revisá tu plan de DR trimestralmente y actualizalo cada vez que tu infraestructura cambie.
Velero para backup de Kubernetes
Velero es la herramienta estándar para hacer backup de recursos de Kubernetes y volúmenes persistentes. Puede hacer backup de todo el estado de tu cluster (o namespaces específicos) y restaurarlo en el mismo cluster o en uno diferente.
Instalá Velero con el plugin de AWS (funciona con storage compatible con S3, incluyendo MinIO):
# Instalar el CLI de Velero
wget https://github.com/vmware-tanzu/velero/releases/download/v1.13.0/velero-v1.13.0-linux-arm64.tar.gz
tar -xvf velero-v1.13.0-linux-arm64.tar.gz
sudo mv velero-v1.13.0-linux-arm64/velero /usr/local/bin/
# Instalar Velero en el cluster
velero install \
--provider aws \
--plugins velero/velero-plugin-for-aws:v1.9.0 \
--bucket velero-backups \
--secret-file ./credentials-velero \
--backup-location-config region=us-east-1,s3ForcePathStyle=true,s3Url=https://s3.us-east-1.amazonaws.com \
--snapshot-location-config region=us-east-1 \
--use-node-agent \
--default-volumes-to-fs-backup
Ahora configurá backups programados. Lo clave es tener diferentes programaciones de backup para diferentes niveles de servicios:
# velero/backup-schedule-critical.yaml
apiVersion: velero.io/v1
kind: Schedule
metadata:
name: critical-services-hourly
namespace: velero
spec:
schedule: "0 * * * *" # Cada hora
template:
includedNamespaces:
- payment-system
- auth-system
includedResources:
- deployments
- services
- configmaps
- secrets
- persistentvolumeclaims
- persistentvolumes
- ingresses
- horizontalpodautoscalers
defaultVolumesToFsBackup: true
storageLocation: default
ttl: 168h # Mantener por 7 días
metadata:
labels:
tier: critical
backup-type: scheduled
# velero/backup-schedule-standard.yaml
apiVersion: velero.io/v1
kind: Schedule
metadata:
name: standard-services-daily
namespace: velero
spec:
schedule: "0 2 * * *" # Diario a las 2 AM
template:
includedNamespaces:
- default
- blog
- monitoring
- ingress-nginx
excludedResources:
- events
- pods
defaultVolumesToFsBackup: true
storageLocation: default
ttl: 720h # Mantener por 30 días
metadata:
labels:
tier: standard
backup-type: scheduled
# velero/backup-schedule-full.yaml
apiVersion: velero.io/v1
kind: Schedule
metadata:
name: full-cluster-weekly
namespace: velero
spec:
schedule: "0 3 * * 0" # Cada domingo a las 3 AM
template:
includedNamespaces:
- "*"
excludedNamespaces:
- velero
- kube-system
excludedResources:
- events
- pods
defaultVolumesToFsBackup: true
storageLocation: default
ttl: 2160h # Mantener por 90 días
metadata:
labels:
backup-type: full-cluster
Para restaurar desde un backup de Velero, primero chequeá qué backups están disponibles:
# Listar backups disponibles
velero backup get
# Describir un backup específico para ver qué contiene
velero backup describe critical-services-hourly-20260328120000
# Restaurar a un namespace nuevo (para testing)
velero restore create test-restore \
--from-backup critical-services-hourly-20260328120000 \
--namespace-mappings payment-system:payment-system-restored
# Restaurar al namespace original (para DR real)
velero restore create dr-restore \
--from-backup critical-services-hourly-20260328120000
# Verificar estado de la restauración
velero restore describe dr-restore
velero restore logs dr-restore
Algo crítico que mucha gente se pierde: necesitás probar regularmente tus backups restaurándolos. Un backup que nunca se probó no es un backup, es una esperanza. Configurá un job semanal que restaure tu último backup a un namespace de prueba y valide los recursos:
# velero/backup-validation-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: validate-velero-backups
namespace: velero
spec:
schedule: "0 6 * * 1" # Cada lunes a las 6 AM
jobTemplate:
spec:
template:
spec:
serviceAccountName: velero-validator
containers:
- name: validator
image: bitnami/kubectl:1.29
command:
- /bin/bash
- -c
- |
set -euo pipefail
echo "=== Validación de Backup de Velero ==="
LATEST_BACKUP=$(velero backup get -o json | \
jq -r '.items | sort_by(.metadata.creationTimestamp) | last | .metadata.name')
echo "Último backup: ${LATEST_BACKUP}"
# Crear una restauración de prueba
velero restore create validation-${LATEST_BACKUP} \
--from-backup ${LATEST_BACKUP} \
--namespace-mappings default:validation-test
# Esperar a que complete la restauración
sleep 120
# Verificar estado de restauración
RESTORE_STATUS=$(velero restore get validation-${LATEST_BACKUP} -o json | \
jq -r '.status.phase')
if [ "$RESTORE_STATUS" = "Completed" ]; then
echo "PASS: Restauración completada exitosamente"
else
echo "FAIL: Estado de restauración es ${RESTORE_STATUS}"
# Enviar alerta a PagerDuty o Slack
curl -X POST "$SLACK_WEBHOOK" \
-H 'Content-Type: application/json' \
-d "{\"text\": \"Validación de backup de Velero FALLÓ para ${LATEST_BACKUP}\"}"
fi
# Limpiar el namespace de prueba
kubectl delete namespace validation-test --ignore-not-found=true
env:
- name: SLACK_WEBHOOK
valueFrom:
secretKeyRef:
name: slack-webhook
key: url
restartPolicy: OnFailure
Backup y restauración de etcd
etcd es el cerebro de tu cluster de Kubernetes. Almacena todo el estado del cluster, incluyendo deployments, services, secrets, configmaps y políticas RBAC. Si perdés etcd y no tenés un backup, perdés todo tu cluster. Todo lo demás se puede reconstruir desde GitOps, pero etcd es la pieza que contiene el estado en vivo.
Acá hay un script para snapshots automatizados de etcd:
#!/bin/bash
# etcd-backup.sh - Backup automatizado de snapshots de etcd
# Correr como CronJob en uno de los nodos del control plane
set -euo pipefail
BACKUP_DIR="/var/backups/etcd"
S3_BUCKET="s3://etcd-backups-prod"
RETENTION_DAYS=30
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
SNAPSHOT_FILE="${BACKUP_DIR}/etcd-snapshot-${TIMESTAMP}.db"
# Crear directorio de backup
mkdir -p "${BACKUP_DIR}"
echo "[$(date)] Iniciando backup de etcd..."
# Tomar el snapshot
ETCDCTL_API=3 etcdctl snapshot save "${SNAPSHOT_FILE}" \
--endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key
# Verificar el snapshot
ETCDCTL_API=3 etcdctl snapshot status "${SNAPSHOT_FILE}" \
--write-out=table
# Obtener tamaño del snapshot para logging
SNAPSHOT_SIZE=$(du -h "${SNAPSHOT_FILE}" | cut -f1)
echo "[$(date)] Snapshot creado: ${SNAPSHOT_FILE} (${SNAPSHOT_SIZE})"
# Subir a S3
aws s3 cp "${SNAPSHOT_FILE}" \
"${S3_BUCKET}/etcd-snapshot-${TIMESTAMP}.db" \
--storage-class STANDARD_IA
echo "[$(date)] Snapshot subido a ${S3_BUCKET}"
# Calcular checksum y subirlo junto al snapshot
sha256sum "${SNAPSHOT_FILE}" > "${SNAPSHOT_FILE}.sha256"
aws s3 cp "${SNAPSHOT_FILE}.sha256" \
"${S3_BUCKET}/etcd-snapshot-${TIMESTAMP}.db.sha256"
# Limpiar backups locales viejos
find "${BACKUP_DIR}" -name "etcd-snapshot-*.db" -mtime +${RETENTION_DAYS} -delete
find "${BACKUP_DIR}" -name "etcd-snapshot-*.sha256" -mtime +${RETENTION_DAYS} -delete
echo "[$(date)] Backup de etcd completado exitosamente"
Programá esto como un CronJob en tu control plane:
# etcd/backup-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: etcd-backup
namespace: kube-system
spec:
schedule: "0 */6 * * *" # Cada 6 horas
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
template:
spec:
nodeName: control-plane-1 # Fijar a un nodo del control plane
hostNetwork: true
containers:
- name: etcd-backup
image: registry.k8s.io/etcd:3.5.12-0
command:
- /bin/sh
- /scripts/etcd-backup.sh
volumeMounts:
- name: etcd-certs
mountPath: /etc/kubernetes/pki/etcd
readOnly: true
- name: backup-scripts
mountPath: /scripts
- name: backup-storage
mountPath: /var/backups/etcd
volumes:
- name: etcd-certs
hostPath:
path: /etc/kubernetes/pki/etcd
- name: backup-scripts
configMap:
name: etcd-backup-scripts
defaultMode: 0755
- name: backup-storage
hostPath:
path: /var/backups/etcd
restartPolicy: OnFailure
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
Ahora la parte crítica, restaurar etcd. Este es el procedimiento que seguís cuando tu cluster se fue por completo:
#!/bin/bash
# etcd-restore.sh - Restaurar etcd desde un snapshot
# ADVERTENCIA: Esto reemplaza TODO el estado del cluster. Solo usar durante DR.
set -euo pipefail
SNAPSHOT_FILE="$1"
DATA_DIR="/var/lib/etcd-restored"
if [ -z "${SNAPSHOT_FILE}" ]; then
echo "Uso: $0 <archivo-snapshot>"
exit 1
fi
echo "ADVERTENCIA: Esto va a reemplazar TODOS los datos de etcd!"
echo "Snapshot: ${SNAPSHOT_FILE}"
echo "Presioná Ctrl+C para abortar, o esperá 10 segundos para continuar..."
sleep 10
# Parar el kubelet (que maneja etcd como pod estático)
systemctl stop kubelet
# Parar etcd si está corriendo
crictl ps | grep etcd && crictl stop $(crictl ps -q --name etcd)
# Verificar la integridad del snapshot
echo "Verificando integridad del snapshot..."
ETCDCTL_API=3 etcdctl snapshot status "${SNAPSHOT_FILE}" \
--write-out=table
# Restaurar el snapshot a un nuevo directorio de datos
ETCDCTL_API=3 etcdctl snapshot restore "${SNAPSHOT_FILE}" \
--data-dir="${DATA_DIR}" \
--name=control-plane-1 \
--initial-cluster=control-plane-1=https://10.0.1.10:2380 \
--initial-cluster-token=etcd-cluster-1 \
--initial-advertise-peer-urls=https://10.0.1.10:2380
# Hacer backup del directorio de datos viejo
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
if [ -d /var/lib/etcd ]; then
mv /var/lib/etcd "/var/lib/etcd-old-${TIMESTAMP}"
fi
# Mover los datos restaurados a su lugar
mv "${DATA_DIR}" /var/lib/etcd
# Arreglar ownership
chown -R etcd:etcd /var/lib/etcd 2>/dev/null || true
# Iniciar kubelet (que va a iniciar etcd como pod estático)
systemctl start kubelet
echo "Esperando a que etcd esté saludable..."
for i in $(seq 1 60); do
if ETCDCTL_API=3 etcdctl endpoint health \
--endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key 2>/dev/null; then
echo "etcd está saludable!"
break
fi
echo "Esperando... ($i/60)"
sleep 5
done
echo "Restauración de etcd completada. Verificá el estado del cluster con: kubectl get nodes"
Una nota importante sobre restauraciones de etcd: cuando restaurás desde un snapshot, obtenés el estado del cluster en el momento en que se tomó el snapshot. Cualquier recurso creado después del snapshot se habrá perdido. Por eso tu RPO para el estado del cluster está determinado por la frecuencia de snapshots de etcd. Si hacés snapshots cada 6 horas, tu peor caso de pérdida de datos para estado del cluster es 6 horas de cambios. Sin embargo, si estás usando GitOps (y deberías), podés re-aplicar todos tus manifiestos desde el repositorio de Git para traer el cluster al estado actual.
Estrategias multi-región y multi-cluster
Para servicios que necesitan un RTO muy bajo, necesitás tus workloads corriendo en múltiples regiones o clusters simultáneamente. Hay dos enfoques principales:
Active-Active: Ambas regiones sirven tráfico simultáneamente. Si una se cae, la otra absorbe todo el tráfico. Esto te da el RTO más bajo posible (solo el tiempo para que los health checks de DNS o del load balancer detecten la falla) pero también es lo más complejo de configurar y operar.
Active-Passive: Una región sirve todo el tráfico, la otra está en espera. Cuando la región activa falla, hacés failover a la región pasiva. Esto es más simple pero tiene un RTO más largo porque necesitás detectar la falla, tomar la decisión de failover, y potencialmente calentar la región pasiva.
Acá hay una configuración de failover basada en DNS usando external-dns y health checks:
# multi-region/dns-failover.yaml
# Health check de la región primaria
apiVersion: externaldns.k8s.io/v1alpha1
kind: DNSEndpoint
metadata:
name: app-primary
namespace: default
annotations:
external-dns.alpha.kubernetes.io/aws-region: us-east-1
spec:
endpoints:
- dnsName: app.example.com
recordTTL: 60
recordType: A
targets:
- 10.0.1.100 # LB de la región primaria
setIdentifier: primary
providerSpecific:
- name: aws/failover
value: PRIMARY
- name: aws/health-check-id
value: "hc-primary-12345"
---
# Región secundaria (destino de failover)
apiVersion: externaldns.k8s.io/v1alpha1
kind: DNSEndpoint
metadata:
name: app-secondary
namespace: default
annotations:
external-dns.alpha.kubernetes.io/aws-region: eu-west-1
spec:
endpoints:
- dnsName: app.example.com
recordTTL: 60
recordType: A
targets:
- 10.1.1.100 # LB de la región secundaria
setIdentifier: secondary
providerSpecific:
- name: aws/failover
value: SECONDARY
- name: aws/health-check-id
value: "hc-secondary-67890"
Para gestión multi-cluster, acá hay una configuración de sync usando ApplicationSets de ArgoCD:
# multi-region/argocd-multi-cluster.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: critical-services
namespace: argocd
spec:
generators:
- clusters:
selector:
matchLabels:
environment: production
values:
region: "{{metadata.labels.region}}"
template:
metadata:
name: "critical-services-{{name}}"
spec:
project: production
source:
repoURL: https://github.com/example/k8s-manifests
targetRevision: main
path: "clusters/{{values.region}}/critical-services"
destination:
server: "{{server}}"
namespace: production
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
Con este setup, ArgoCD automáticamente despliega tus servicios críticos en cada cluster de producción. Cuando agregás un nuevo cluster, los servicios se despliegan automáticamente. Acá es donde GitOps realmente brilla para DR: todo tu estado deseado está en Git, y ArgoCD se asegura de que cada cluster lo cumpla.
DR de base de datos: replicación cross-region de PostgreSQL
Las bases de datos son generalmente la parte más difícil de la recuperación ante desastres porque contienen estado. Para PostgreSQL, acá hay un setup usando streaming replication con pgBackRest para backups cross-region:
# database/postgresql-dr.yaml
# Configuración del PostgreSQL primario para DR
apiVersion: v1
kind: ConfigMap
metadata:
name: postgresql-dr-config
namespace: database
data:
postgresql.conf: |
# Configuración de replicación para DR
wal_level = replica
max_wal_senders = 10
wal_keep_size = 1024 # Mantener 1GB de WAL para tolerancia de lag
synchronous_commit = on
synchronous_standby_names = 'standby_eu_west'
# Configuración de archivado para point-in-time recovery
archive_mode = on
archive_command = 'pgbackrest --stanza=main archive-push %p'
archive_timeout = 60 # Archivar al menos cada 60 segundos
pg_hba.conf: |
# Acceso de replicación desde la región secundaria
hostssl replication replicator 10.1.0.0/16 scram-sha-256
hostssl replication replicator 10.0.0.0/16 scram-sha-256
hostssl all all 10.0.0.0/8 scram-sha-256
pgbackrest.conf: |
[global]
repo1-type=s3
repo1-s3-bucket=pg-backups-primary
repo1-s3-region=us-east-1
repo1-s3-endpoint=s3.us-east-1.amazonaws.com
repo1-retention-full=4
repo1-retention-diff=14
# Backup cross-region para DR
repo2-type=s3
repo2-s3-bucket=pg-backups-dr
repo2-s3-region=eu-west-1
repo2-s3-endpoint=s3.eu-west-1.amazonaws.com
repo2-retention-full=4
repo2-retention-diff=14
[main]
pg1-path=/var/lib/postgresql/data
Y la programación de backups:
# database/pgbackrest-backup-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: pgbackrest-full-backup
namespace: database
spec:
schedule: "0 1 * * 0" # Backup completo cada domingo a la 1 AM
jobTemplate:
spec:
template:
spec:
containers:
- name: backup
image: pgbackrest/pgbackrest:2.50
command:
- /bin/bash
- -c
- |
echo "Iniciando backup completo a ambos repos..."
# Backup a la región primaria
pgbackrest --stanza=main --type=full --repo=1 backup
echo "Backup de región primaria completo"
# Backup a la región de DR
pgbackrest --stanza=main --type=full --repo=2 backup
echo "Backup de región DR completo"
# Verificar ambos backups
pgbackrest --stanza=main --repo=1 info
pgbackrest --stanza=main --repo=2 info
restartPolicy: OnFailure
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: pgbackrest-diff-backup
namespace: database
spec:
schedule: "0 */4 * * *" # Backup diferencial cada 4 horas
jobTemplate:
spec:
template:
spec:
containers:
- name: backup
image: pgbackrest/pgbackrest:2.50
command:
- /bin/bash
- -c
- |
pgbackrest --stanza=main --type=diff --repo=1 backup
pgbackrest --stanza=main --type=diff --repo=2 backup
echo "Backups diferenciales completados"
restartPolicy: OnFailure
El procedimiento de restauración para PostgreSQL cuando tu primario se fue:
#!/bin/bash
# database/pg-dr-restore.sh
# Restaurar PostgreSQL desde backup de pgBackRest en la región de DR
set -euo pipefail
DR_REPO=2 # Usar el repositorio de la región de DR
TARGET_TIME="${1:-}" # Opcional: objetivo de point-in-time recovery
echo "=== Restauración DR de PostgreSQL ==="
echo "Usando repositorio: repo${DR_REPO} (región de DR)"
# Listar backups disponibles
echo "Backups disponibles:"
pgbackrest --stanza=main --repo=${DR_REPO} info
# Parar PostgreSQL si está corriendo
pg_ctl stop -D /var/lib/postgresql/data -m fast 2>/dev/null || true
# Limpiar el directorio de datos
rm -rf /var/lib/postgresql/data/*
if [ -n "${TARGET_TIME}" ]; then
echo "Restaurando a point-in-time: ${TARGET_TIME}"
pgbackrest --stanza=main --repo=${DR_REPO} \
--type=time \
--target="${TARGET_TIME}" \
--target-action=promote \
restore
else
echo "Restaurando último backup..."
pgbackrest --stanza=main --repo=${DR_REPO} \
--type=default \
restore
fi
# Iniciar PostgreSQL
pg_ctl start -D /var/lib/postgresql/data
# Esperar a que se complete la recuperación
echo "Esperando recuperación..."
until pg_isready; do
sleep 2
done
echo "PostgreSQL restaurado y listo"
# Verificar integridad de datos
psql -c "SELECT count(*) as total_tables FROM information_schema.tables WHERE table_schema = 'public';"
psql -c "SELECT pg_size_pretty(pg_database_size(current_database())) as db_size;"
Simulacros y pruebas de DR
El mejor plan de DR del mundo no sirve de nada si nunca lo probaste. Los simulacros de DR son la forma en que convertís un plan teórico en una capacidad comprobada. Hay tres niveles de pruebas de DR:
- Ejercicios de mesa (tabletop): El equipo recorre el plan de DR en papel. No se afectan sistemas reales. Esto es bueno para encontrar brechas en documentación y planes de comunicación.
- Pruebas de componentes: Probás componentes individuales del plan, como restaurar un backup de Velero o hacer failover de DNS. Esto valida que las herramientas y procedimientos funcionan.
- Simulación completa de DR: Simulás un desastre completo y ejecutás el plan de recuperación completo. Este es el estándar de oro, y da miedo, que es exactamente por lo que necesitás hacerlo.
Acá hay un template de ejercicio de mesa:
# dr-drills/tabletop-exercise.yaml
exercise:
name: "Ejercicio de Mesa DR Q1 2026"
date: "2026-03-20"
duration: "2 horas"
facilitator: "SRE Senior"
participants:
- equipo-plataforma
- equipo-base-de-datos
- equipo-aplicaciones
- gerencia-ingeniería
scenario:
description: |
A las 2:30 AM de un martes, la región primaria del cloud (us-east-1)
experimenta una caída completa. Todos los servicios en la región están
inaccesibles. El proveedor cloud estima 4-6 horas para la recuperación.
Tu payment-api está procesando $5,000 por hora en transacciones.
timeline:
- time: "T+0"
event: "PagerDuty dispara alertas para todos los servicios en us-east-1"
question: "¿A quién se le envía la alerta? ¿Cuál es el path de escalación?"
- time: "T+5min"
event: "El ingeniero de guardia confirma que la región está caída"
question: "¿Cuál es la primera acción? ¿Quién toma la decisión de failover?"
- time: "T+10min"
event: "El incident commander declara desastre, inicia el plan de DR"
question: "¿Qué comunicación se envía? ¿A quién? ¿Por qué canales?"
- time: "T+15min"
event: "El líder de DR comienza el procedimiento de failover"
question: "¿Cuáles son los pasos exactos? Recorré el runbook."
- time: "T+30min"
event: "Failover de DNS completo para servicios de tier-1"
question: "¿Cómo verificás que los servicios están saludables en la región de DR?"
- time: "T+1hr"
event: "Servicios de tier-2 restaurados desde réplicas"
question: "¿Qué datos se perdieron? ¿Cómo reconciliás?"
- time: "T+4hr"
event: "La región primaria vuelve en línea"
question: "¿Hacés failback inmediatamente? ¿Cuál es el procedimiento de failback?"
discussion_questions:
- "¿Dónde están las brechas en nuestro plan de DR actual?"
- "¿Tenemos todos los accesos y credenciales necesarios para DR?"
- "¿Qué pasaría si la persona que sabe hacer X no está disponible?"
- "¿Nuestros backups son realmente restaurables? ¿Cuándo fue la última vez que probamos?"
- "¿Cuál es nuestro plan de comunicación para los clientes?"
Para simulacros de DR en vivo, acá hay un enfoque estructurado:
# dr-drills/live-drill-plan.yaml
drill:
name: "Simulacro DR en Vivo Q1 2026"
date: "2026-03-25"
time: "10:00 AM - 2:00 PM"
type: "component" # Opciones: tabletop, component, full
environment: "staging" # Siempre empezar con staging
pre_drill_checklist:
- "Todos los participantes confirmados y disponibles"
- "Stakeholders notificados sobre potencial impacto en staging"
- "Dashboards de monitoreo abiertos para ambiente de staging"
- "Procedimientos de rollback revisados y listos"
- "Región/cluster de DR verificado accesible"
- "Últimos backups verificados disponibles"
- "Canales de comunicación probados"
scenarios:
- name: "Restauración de backup de Velero"
objective: "Verificar que podemos restaurar un namespace desde un backup de Velero"
steps:
- "Borrar el namespace test-app en staging"
- "Restaurar desde el último backup de Velero"
- "Verificar que todos los recursos se recrearon"
- "Verificar que la aplicación es funcional"
success_criteria:
- "Todos los deployments corriendo con la cantidad correcta de réplicas"
- "Todos los services e ingresses recreados"
- "La aplicación responde a health checks"
- "Los datos persistentes están presentes y correctos"
max_duration: "30 minutos"
- name: "Restauración de snapshot de etcd"
objective: "Verificar que podemos restaurar etcd desde un snapshot"
steps:
- "Tomar un snapshot fresco de etcd"
- "Crear algunos recursos de prueba (deployment, service, configmap)"
- "Restaurar desde el snapshot (antes de los recursos de prueba)"
- "Verificar que los recursos de prueba no están (probando que la restauración funcionó)"
- "Verificar que los recursos pre-existentes están intactos"
success_criteria:
- "Restauración de etcd completa sin errores"
- "Cluster funcional después de la restauración"
- "Recursos de prueba ausentes (probando restauración point-in-time)"
max_duration: "45 minutos"
- name: "Failover de base de datos"
objective: "Verificar failover de PostgreSQL a read replica"
steps:
- "Verificar que el lag de replicación es cero"
- "Simular falla del primario (parar pod primario)"
- "Promover read replica a primario"
- "Actualizar connection strings de la aplicación"
- "Verificar que las escrituras de la aplicación funcionan en el nuevo primario"
success_criteria:
- "Failover completo dentro del objetivo de RTO"
- "Sin pérdida de datos (objetivo de RPO cumplido)"
- "La aplicación funciona normalmente en el nuevo primario"
max_duration: "30 minutos"
post_drill:
- "Restaurar staging al estado normal"
- "Documentar todos los hallazgos"
- "Crear issues para cualquier falla o brecha encontrada"
- "Actualizar el plan de DR basado en los hallazgos"
- "Compartir resultados con el equipo más amplio"
- "Programar el próximo simulacro"
También deberías vincular los simulacros de DR con tu práctica de chaos engineering. Un experimento de caos que simula una falla de zona es esencialmente un simulacro de DR liviano. Si ya estás corriendo experimentos de caos regularmente (como discutimos en el artículo de chaos engineering), estás construyendo la memoria muscular que tu equipo necesita para desastres reales.
Runbook para recuperación completa del cluster
Este es el grande: tu cluster se fue y necesitás reconstruir desde cero. Acá hay un runbook paso a paso que cubre el proceso completo de recuperación:
# runbooks/full-cluster-recovery.yaml
runbook:
name: "Recuperación Completa del Cluster de Kubernetes"
version: "1.3"
last_tested: "2026-03-15"
estimated_time: "2-4 horas"
prerequisites:
- "Acceso a la consola/CLI del proveedor cloud"
- "Acceso al storage de backups de etcd (S3)"
- "Acceso al storage de backups de Velero (S3)"
- "Acceso al repositorio de GitOps"
- "Acceso al registry de containers"
- "Acceso a gestión de DNS"
- "Certificados TLS o configuración de cert-manager"
phases:
- phase: 1
name: "Provisión de infraestructura"
estimated_time: "30-60 minutos"
steps:
- step: 1.1
action: "Provisionar nuevos nodos de cómputo"
command: |
# Usando Terraform (asumiendo estado en backend remoto)
cd infrastructure/terraform/kubernetes
terraform plan -var="cluster_name=prod-recovery"
terraform apply -auto-approve
verification: |
# Verificar que los nodos están provisionados
kubectl get nodes
# Esperado: todos los nodos en estado Ready
- step: 1.2
action: "Verificar networking"
command: |
# Verificar que el CNI es funcional
kubectl run nettest --image=busybox --rm -it -- nslookup kubernetes.default
# Verificar conectividad externa
kubectl run nettest --image=busybox --rm -it -- wget -qO- https://hub.docker.com
verification: "Resolución DNS y conectividad externa funcionando"
- step: 1.3
action: "Verificar provisioner de storage"
command: |
kubectl get storageclass
# Crear un PVC de prueba
kubectl apply -f - <<EOF
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: test-pvc
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi
EOF
kubectl get pvc test-pvc
verification: "PVC transiciona a estado Bound"
- phase: 2
name: "Recuperación de infraestructura core"
estimated_time: "20-30 minutos"
steps:
- step: 2.1
action: "Restaurar etcd desde backup (si aplica)"
command: |
# Descargar último snapshot desde S3
aws s3 cp s3://etcd-backups-prod/latest/etcd-snapshot.db /tmp/
# Verificar snapshot
ETCDCTL_API=3 etcdctl snapshot status /tmp/etcd-snapshot.db
# Restaurar (ver etcd-restore.sh)
bash /scripts/etcd-restore.sh /tmp/etcd-snapshot.db
verification: "kubectl get nodes devuelve la lista esperada de nodos"
- step: 2.2
action: "Instalar ArgoCD"
command: |
kubectl create namespace argocd
kubectl apply -n argocd -f \
https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
# Esperar a que ArgoCD esté listo
kubectl wait --for=condition=available deployment/argocd-server \
-n argocd --timeout=300s
# Configurar el repositorio de GitOps
argocd repo add https://github.com/example/k8s-manifests \
--username git --password "${GIT_TOKEN}"
verification: "UI de ArgoCD accesible, repositorio conectado"
- step: 2.3
action: "Desplegar cert-manager"
command: |
helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager --create-namespace \
--set installCRDs=true
# Aplicar ClusterIssuer
kubectl apply -f manifests/cert-manager/cluster-issuer.yaml
verification: "Pods de cert-manager corriendo, ClusterIssuer listo"
- step: 2.4
action: "Desplegar ingress controller"
command: |
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx --create-namespace \
--values manifests/ingress-nginx/values.yaml
verification: "Ingress controller tiene IP externa asignada"
- phase: 3
name: "Recuperación de datos"
estimated_time: "30-60 minutos"
steps:
- step: 3.1
action: "Restaurar bases de datos desde backup"
command: |
# Desplegar operador de PostgreSQL
kubectl apply -f manifests/database/operator.yaml
# Esperar al operador
kubectl wait --for=condition=available deployment/postgres-operator \
--timeout=300s
# Restaurar desde backup de pgBackRest
bash /scripts/pg-dr-restore.sh
verification: |
psql -c "SELECT count(*) FROM users;"
# Comparar con el conteo esperado del manifiesto de backup
- step: 3.2
action: "Restaurar Velero y recuperar volúmenes persistentes"
command: |
# Instalar Velero
velero install --provider aws ...
# Restaurar namespaces críticos
velero restore create dr-critical \
--from-backup critical-services-hourly-latest
# Verificar restauración
velero restore describe dr-critical
verification: "Todos los PVCs vinculados, datos verificados"
- phase: 4
name: "Recuperación de aplicaciones"
estimated_time: "30-45 minutos"
steps:
- step: 4.1
action: "Sincronizar todas las aplicaciones de ArgoCD"
command: |
# Aplicar el patrón app-of-apps
kubectl apply -f manifests/argocd/app-of-apps.yaml
# Forzar sync de todas las aplicaciones
argocd app sync --all --prune
# Esperar a que todas las apps estén saludables
argocd app wait --all --health --timeout 600
verification: "Todas las aplicaciones de ArgoCD en estado Synced y Healthy"
- step: 4.2
action: "Verificar servicios de tier-1"
command: |
# Verificar payment-api
curl -f https://payment-api.example.com/health
# Verificar auth-service
curl -f https://auth.example.com/health
# Correr tests de integración contra servicios recuperados
./scripts/integration-tests.sh --target=production
verification: "Todos los health checks pasando, tests de integración verdes"
- step: 4.3
action: "Verificar servicios de tier-2 y tier-3"
command: |
# Verificar todos los servicios restantes
for svc in user-api notifications blog docs; do
curl -f "https://${svc}.example.com/health" || echo "WARN: ${svc} no está listo"
done
verification: "Todos los servicios respondiendo"
- phase: 5
name: "DNS y cutover de tráfico"
estimated_time: "10-15 minutos"
steps:
- step: 5.1
action: "Actualizar DNS para apuntar al cluster recuperado"
command: |
# Actualizar registros de Route53
aws route53 change-resource-record-sets \
--hosted-zone-id Z1234567890 \
--change-batch file://dns-changes.json
# Verificar propagación DNS
for domain in app auth payment-api; do
dig +short ${domain}.example.com
done
verification: "DNS resolviendo a las nuevas IPs del cluster"
- step: 5.2
action: "Aumentar tráfico gradualmente"
command: |
# Si usás weighted routing, mover tráfico gradualmente
# Empezar con 10%, luego 50%, luego 100%
aws route53 change-resource-record-sets \
--hosted-zone-id Z1234567890 \
--change-batch '{"Changes":[{"Action":"UPSERT","ResourceRecordSet":{"Name":"app.example.com","Type":"A","SetIdentifier":"recovered","Weight":10,"TTL":60,"ResourceRecords":[{"Value":"NEW_IP"}]}}]}'
verification: "Tráfico fluyendo al cluster recuperado, sin errores"
- phase: 6
name: "Validación post-recuperación"
estimated_time: "30 minutos"
steps:
- step: 6.1
action: "Correr suite completa de smoke tests"
command: |
./scripts/smoke-tests.sh --environment=production
verification: "Todos los smoke tests pasando"
- step: 6.2
action: "Verificar monitoreo y alertas"
command: |
# Verificar que Prometheus está scrapeando
curl -s http://prometheus:9090/api/v1/targets | jq '.data.activeTargets | length'
# Verificar dashboards de Grafana
curl -f http://grafana:3000/api/health
# Verificar que las reglas de alertas están cargadas
curl -s http://prometheus:9090/api/v1/rules | jq '.data.groups | length'
verification: "Stack de monitoreo completamente operativo"
- step: 6.3
action: "Documentar resultados de la recuperación"
command: |
# Crear reporte post-recuperación
echo "Recuperación completada a las: $(date)"
echo "Tiempo total de recuperación: X horas Y minutos"
echo "Ventana de pérdida de datos: edad del snapshot de etcd + gap de WAL"
echo "Servicios recuperados: todos / parcial"
echo "Problemas encontrados: ..."
verification: "Reporte compartido con stakeholders"
El runbook es largo, y debería serlo. Cada paso tiene un paso de verificación porque durante un desastre, no podés permitirte saltear pasos y esperar que las cosas funcionen. Cada paso debe confirmarse antes de pasar al siguiente.
Comunicación durante desastres
La comunicación es frecuentemente el eslabón más débil durante un desastre. La gente está estresada, múltiples equipos están involucrados, y los clientes están impactados. Tener templates de comunicación pre-escritos ahorra tiempo valioso y asegura que no se pierda nada importante.
Acá hay un conjunto de templates de comunicación:
# communication/disaster-templates.yaml
templates:
internal_declaration:
channel: "#incident-war-room"
template: |
@here DESASTRE DECLARADO - Plan de DR Activado
Qué pasó: [Descripción breve de la falla]
Impacto: [Qué servicios están afectados]
Severidad: [SEV-1]
Incident Commander: [Nombre]
Líder de DR: [Nombre]
Líder de Comunicaciones: [Nombre]
Estado actual: Ejecutando plan de DR fase 1 (provisión de infraestructura)
Tiempo estimado de recuperación: [X horas basado en objetivos de RTO]
War room: [Link a videollamada]
Página de estado: https://status.example.com
Runbook de DR: [Link al runbook]
Se publicarán actualizaciones cada 15 minutos en este canal.
customer_initial:
channel: "página de estado"
template: |
Título: Interrupción del Servicio - [Servicios Afectados]
Estado: Investigando
Actualmente estamos experimentando una interrupción que afecta a
[listar servicios afectados]. Nuestro equipo fue convocado y está
trabajando activamente en la recuperación.
Proporcionaremos una actualización dentro de 30 minutos.
Servicios afectados:
- [Servicio 1]: [Estado]
- [Servicio 2]: [Estado]
customer_update:
channel: "página de estado"
template: |
Título: Interrupción del Servicio - Actualización
Estado: Identificado / Recuperando
Actualización: Identificamos el problema como [descripción breve,
no técnica]. Nuestro equipo está ejecutando nuestro plan de
recuperación ante desastres.
Progreso actual:
- Infraestructura: [Restaurada / En progreso]
- Servicios críticos: [Restaurados / En progreso]
- Todos los servicios: [Restaurados / En progreso]
Tiempo estimado para recuperación completa: [X horas]
Próxima actualización: [Hora]
customer_resolved:
channel: "página de estado"
template: |
Título: Interrupción del Servicio - Resuelta
Estado: Resuelto
La interrupción del servicio que comenzó a las [hora de inicio]
fue completamente resuelta a las [hora de resolución].
Causa raíz: [Descripción breve, no técnica]
Duración: [X horas Y minutos]
Impacto en datos: [Ninguno / Las transacciones entre X e Y pueden
necesitar revisión]
Publicaremos un reporte post-incidente detallado dentro de
5 días hábiles. Pedimos disculpas por la interrupción y estamos
tomando medidas para prevenir problemas similares en el futuro.
internal_update_cadence:
description: "Cada cuánto publicar actualizaciones durante DR"
schedule:
- phase: "Primera hora"
frequency: "Cada 15 minutos"
- phase: "Horas 2-4"
frequency: "Cada 30 minutos"
- phase: "Después de la hora 4"
frequency: "Cada hora"
- phase: "Post-recuperación"
frequency: "Resumen final dentro de 1 hora de la resolución"
Algunos puntos clave sobre la comunicación en desastres:
- No esperes a tener todas las respuestas para comunicar. “Estamos al tanto del problema e investigando” es infinitamente mejor que el silencio.
- Usá templates pre-escritos. Durante un desastre, tu cerebro no está en su mejor momento. Los templates previenen que te olvides de detalles importantes o digas algo incorrecto.
- Separá la comunicación interna de la externa. Los mensajes internos pueden ser técnicos y detallados. Los mensajes externos deben ser claros, no técnicos y empáticos.
- Establecé una cadencia y respetala. Decir “próxima actualización en 30 minutos” y después quedar en silencio por 2 horas destruye la confianza. Si no tenés nada nuevo que decir, publicá “Sin cambios significativos, seguimos trabajando en la recuperación.”
- Asigná una persona dedicada a comunicaciones. Las personas haciendo la recuperación no deberían también estar escribiendo actualizaciones de la página de estado. Separá esas responsabilidades.
Juntando todo: un modelo de madurez de DR
Al igual que discutimos niveles de madurez de chaos engineering en el artículo de chaos engineering, acá hay un modelo de madurez para recuperación ante desastres:
- Nivel 0 - Esperanza: Sin plan de DR, sin backups, sin idea de qué pasaría. (Sorprendentemente común)
- Nivel 1 - Documentado: El plan de DR existe en papel pero nunca se probó. Los backups existen pero nunca se restauraron.
- Nivel 2 - Componentes probados: Componentes individuales de DR (restauración de backup, failover de DNS) fueron probados. Ejercicios de mesa completados.
- Nivel 3 - Simulado: Se corrieron simulaciones completas de DR. El equipo practicó todo el proceso de recuperación. Los objetivos de RTO y RPO fueron validados.
- Nivel 4 - Automatizado: El failover de DR está automatizado y se puede disparar con un solo comando. Tests automatizados regulares de DR validan el plan continuamente.
La mayoría de los equipos están en Nivel 1 o Nivel 2. Llegar al Nivel 3 es donde viene la confianza real. No necesitás automatización completa (Nivel 4) para estar preparado, pero absolutamente necesitás haber practicado el proceso al menos una vez.
Notas finales
La recuperación ante desastres no es un trabajo glamoroso. A nadie le emociona escribir scripts de backup y templates de comunicación. Pero cuando el desastre llega, y eventualmente va a llegar, la diferencia entre un equipo que practicó la recuperación y uno que no es la diferencia entre unas pocas horas de downtime y un evento catastrófico que amenaza a la empresa.
Los puntos clave de este artículo son:
- Definí objetivos de RPO y RTO basados en el impacto al negocio, no en la conveniencia técnica.
- Hacé backup de todo y almacená los backups en una región diferente a tu infraestructura primaria.
- Probá tus backups regularmente. Un backup que nunca se restauró no es un backup.
- Escribí runbooks detallados con pasos de verificación para cada acción.
- Practicá, practicá, practicá. Corré simulacros de DR al menos trimestralmente.
- Prepará templates de comunicación antes de necesitarlos.
Empezá de a poco. Si hoy no tenés un plan de DR, empezá configurando backups de Velero y snapshots de etcd. Después escribí un runbook básico. Después probalo. Después iterá. Cada paso te hace más preparado de lo que estabas antes, y estar ligeramente preparado es infinitamente mejor que no estar preparado en absoluto.
¡Espero que te haya resultado útil y lo hayas disfrutado! ¡Hasta la próxima!
Errata
Si encontrás algún error o tenés alguna sugerencia, por favor mandame un mensaje para que se corrija.
También podés revisar el código fuente y los cambios en las fuentes acá
$ Comentarios
Online: 0Por favor inicie sesión para poder escribir comentarios.