En la lección anterior llevamos tienda-web y api-reservas a producción. Fueron trece objetos y una lista de comprobaciones larga, pero el margen de error era generoso: si un pod se pierde, nace otro idéntico y nadie se entera. Aquí desaparece esa red de seguridad. postgres-reservas contiene los datos personales de los clientes de Rutas Norte, sus reservas y sus pagos. Un pod perdido puede significar minutos de servicio caído; un volumen perdido, un problema de existencia para la empresa.
Esta lección resuelve el escenario completo de una carga con estado en producción: desde la pregunta incómoda de si esa base de datos debería estar en Kubernetes, hasta el procedimiento de restauración cronometrado, pasando por la conmutación por error, la actualización de versión mayor y el caso muy distinto de redis-cache, donde perder el dato es aceptable.
Advertencia de cumplimiento normativo. Todo lo que se describe aquí afecta a datos personales de clientes (nombre, correo, historial de viajes, datos de pago tokenizados) y a la continuidad del negocio. Las decisiones sobre ubicación de las copias de seguridad, plazos de retención, cifrado, acceso del personal a los datos y transferencias entre regiones deben ser revisadas y aprobadas por el responsable de cumplimiento normativo de la organización antes de aplicarse. Los valores de este material son ilustrativos y no sustituyen esa revisión.
Contenido
- La pregunta previa: ¿debe la base de datos vivir en Kubernetes?
- Qué tiene de especial una carga con estado
postgres-reservascon CloudNativePG- Cómo se conecta
api-reservas: el agrupador de conexiones - Conmutación por error: simularla y medirla
- Copias de seguridad y recuperación a un instante concreto
- Actualización de versión mayor de PostgreSQL
- Réplicas de lectura para
informes-ocupacion redis-cache: cuando perder el dato es aceptable- Lista de comprobación de una carga con estado en producción
- La pregunta previa: ¿debe la base de datos vivir en Kubernetes?
Es tentador responder "sí" por coherencia: si todo lo demás está en el clúster, la base de datos también. Es una mala razón. La pregunta correcta es qué opción minimiza el riesgo total al coste que la empresa puede asumir.
Hay tres opciones reales.
| Criterio | Gestionada del proveedor (RDS, Cloud SQL) | Operador dentro del clúster (CloudNativePG) | StatefulSet artesanal |
|---|---|---|---|
| Coste de infraestructura | Alto: prima del 40-80 % sobre el cómputo equivalente | Medio: se paga cómputo y disco a precio de lista | Bajo, en apariencia |
| Coste de personal | Muy bajo | Medio: hay que conocer el operador y PostgreSQL | Muy alto: alguien debe ser experto en ambos |
| Esfuerzo operativo | Copias, parches y conmutación los hace el proveedor | El operador automatiza copias, conmutación y actualizaciones menores | Todo a mano o con scripts propios |
| Control y ajuste fino | Limitado: extensiones y parámetros restringidos | Total: cualquier extensión y parámetro | Total |
| Portabilidad entre nubes | Baja: es el punto de anclaje típico | Alta: el mismo manifiesto en cualquier clúster | Alta |
| Riesgo de fallo catastrófico | Bajo: alguien con guardia 24×7 responde | Medio: depende de la madurez del equipo | Alto: el fallo raro llega de madrugada |
| Tiempo hasta estar en producción | Días | Semanas | Meses, y nunca del todo |
| Latencia desde los pods | Un salto de red fuera del clúster (1-3 ms) | Dentro del clúster (< 1 ms) | Dentro del clúster |
El criterio de decisión, en una frase: usa la base de datos gestionada salvo que tengas una razón concreta para no hacerlo, y no montes nunca un StatefulSet artesanal para una base de datos de producción.
Razones concretas que justifican el operador dentro del clúster:
- La factura de la gestionada es desproporcionada para el tamaño de la empresa.
- Se necesitan extensiones o parámetros que el proveedor no permite.
- Hay un requisito de portabilidad entre nubes o de despliegue en un centro de datos propio.
- El equipo ya tiene madurez operativa demostrada en Kubernetes y alguien con conocimiento real de PostgreSQL.
Razones que no justifican nada: "queda más limpio", "así todo es Kubernetes", "el YAML es bonito".
La decisión de Rutas Norte. En el módulo 6 se presentó CloudNativePG como sustituto del StatefulSet artesanal, y en el módulo 10 se eligió EKS en eu-west-1. El equipo de plataforma ha decidido mantener postgres-reservas dentro del clúster con CloudNativePG, con dos condiciones explícitas escritas en el acta: que las copias vayan siempre a un almacén de objetos fuera del clúster, y que la restauración se pruebe trimestralmente. La razón de la decisión no es técnica sino económica y de portabilidad: el volumen de datos es modesto (unos 120 GB), la instancia gestionada equivalente con alta disponibilidad multi-zona costaría aproximadamente el triple, y la dirección quiere conservar la opción de mover la plataforma a otra nube en dos años.
Es una decisión legítima con una consecuencia clara: el equipo de plataforma asume la guardia de la base de datos. Si nadie está dispuesto a firmar eso, la respuesta correcta era la gestionada.
- Qué tiene de especial una carga con estado
Cuatro propiedades que rompen las suposiciones cómodas del módulo anterior.
2.1. Identidad
Un pod de api-reservas es intercambiable con cualquier otro. Un pod de PostgreSQL no: uno es el primario y acepta escrituras, los demás son réplicas y solo leen. La identidad no está en la etiqueta, está en el estado de la replicación en ese instante, y puede cambiar sin que nadie despliegue nada.
2.2. Orden
Al arrancar un clúster de PostgreSQL, la primera instancia debe inicializar el directorio de datos; las demás deben clonarse de ella. Al actualizar, hay que actualizar primero las réplicas y promocionar después. RollingUpdate no entiende de eso.
2.3. Datos que no se pueden perder
El volumen no es caché: es el activo. Esto cambia tres cosas de golpe:
- La política de reclamación del PV debe ser
Retain, noDelete(05-02). - Borrar el recurso no puede borrar el dato: hace falta protección explícita.
- Las copias no son opcionales ni son un detalle de operación: son parte del diseño.
2.4. Actualizaciones que no admiten RollingUpdate ingenuo
| Aspecto | Sin estado (api-reservas) |
Con estado (postgres-reservas) |
|---|---|---|
| Reemplazar un pod | Trivial, en segundos | Implica clonar o reconectar replicación |
| Dos versiones a la vez | Normal durante el despliegue | Peligroso: formatos de datos incompatibles |
| Reversión | rollout undo, segundos |
A veces imposible: el formato de datos ya cambió |
| Escalar a cero | Sin consecuencias | Corte total del servicio |
| Perder el volumen | Irrelevante | Catastrófico |
La conclusión práctica: para lo que tiene estado, no despliegas cargas, delegas en un operador que sabe de esa base de datos concreta. Eso es exactamente lo que vimos en 06-07 y lo que vamos a aplicar ahora.
postgres-reservas con CloudNativePG
postgres-reservas con CloudNativePGCloudNativePG es un operador que implementa un clúster de PostgreSQL con replicación en flujo, elección de primario, copias continuas y actualizaciones controladas. No usa StatefulSets: gestiona los pods directamente porque necesita un control más fino del orden.
graph TB
subgraph op["Namespace cnpg-system"]
OPR[Operador CloudNativePG]
end
subgraph pro["Namespace rutas-norte-pro"]
subgraph cl["Cluster postgres-reservas"]
P[(instancia-1<br/>PRIMARIO)]
R1[(instancia-2<br/>réplica)]
R2[(instancia-3<br/>réplica)]
end
RW[Service ...-rw<br/>escritura]
RO[Service ...-ro<br/>solo réplicas]
R[Service ...-r<br/>cualquiera]
POOL[Pooler PgBouncer<br/>...-pooler-rw]
API[api-reservas]
INF[informes-ocupacion]
end
S3[(Almacén de objetos<br/>copias + WAL)]
OPR -.reconcilia.-> cl
P -->|WAL streaming| R1
P -->|WAL streaming| R2
RW --> P
RO --> R1
RO --> R2
API --> POOL --> RW
INF --> RO
P -->|archivado continuo| S3
3.1. El recurso Cluster completo
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: postgres-reservas
namespace: rutas-norte-pro
spec:
# 3 instancias: 1 primario + 2 réplicas. Con 2 sobreviviríamos a un fallo,
# pero durante la reconstrucción de la réplica quedaríamos sin redundancia.
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:16.4
# Al perder el primario, esperamos como mucho 30 s antes de promocionar.
# Más alto = más indisponibilidad; más bajo = riesgo de promoción por un
# corte de red transitorio.
failoverDelay: 0
switchoverDelay: 60
primaryUpdateStrategy: unsupervised # el operador actualiza y conmuta solo
primaryUpdateMethod: switchover # conmuta ordenadamente, no reinicia el primario
bootstrap:
initdb:
database: reservas
owner: app_reservas
secret:
name: postgres-reservas-app # creado por External Secrets
encoding: UTF8
localeCollate: es_ES.UTF-8
localeCType: es_ES.UTF-8
postInitApplicationSQL:
- CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
- CREATE EXTENSION IF NOT EXISTS pgcrypto;
postgresql:
parameters:
max_connections: "200"
shared_buffers: "1GB" # ~25 % de la memoria del pod
effective_cache_size: "3GB"
work_mem: "16MB"
maintenance_work_mem: "256MB"
wal_compression: "on"
max_wal_size: "4GB"
checkpoint_completion_target: "0.9"
random_page_cost: "1.1" # disco SSD
log_min_duration_statement: "500" # registra consultas de más de 500 ms
log_checkpoints: "on"
shared_preload_libraries: "pg_stat_statements"
pg_hba:
# Solo TLS y solo desde la red de pods del clúster.
- hostssl reservas app_reservas 10.244.0.0/16 scram-sha-256
resources:
requests: { cpu: "1", memory: 4Gi }
limits: { memory: 4Gi } # QoS Guaranteed en memoria (03-05)
storage:
size: 200Gi
storageClass: rutasnorte-rapida # la clase con IOPS altas de 05-04
walStorage:
# WAL en volumen separado: evita que un pico de escritura de WAL
# deje sin espacio a los datos, y mejora el rendimiento.
size: 50Gi
storageClass: rutasnorte-rapida
# Reparto entre zonas: nunca dos instancias en el mismo nodo.
affinity:
enablePodAntiAffinity: true
topologyKey: kubernetes.io/hostname
podAntiAffinityType: required
monitoring:
enablePodMonitor: true # Prometheus lo descubre solo (07-03)
backup:
retentionPolicy: "30d"
barmanObjectStore:
destinationPath: s3://rutasnorte-copias-pro/postgres-reservas
s3Credentials:
inheritFromIAMRole: true # identidad federada, sin claves (10-06)
wal:
compression: gzip
maxParallel: 4
data:
compression: gzip
immediateCheckpoint: false
jobs: 2Las decisiones que conviene entender:
instances: 3y no 2. Con dos instancias, en cuanto una cae te quedas sin redundancia justo cuando más la necesitas, porque reconstruir una réplica de 120 GB tarda un rato. Tres es el mínimo defendible en producción.primaryUpdateMethod: switchover. Al aplicar una actualización menor, el operador actualiza primero las réplicas, luego promociona una réplica ya actualizada y por último actualiza el antiguo primario. El corte se mide en segundos, no en minutos.walStorageseparado. El WAL tiene un patrón de escritura muy distinto al de los datos y, sobre todo, si se llena el volumen de datos por culpa del WAL, PostgreSQL se detiene. Separarlos convierte un incidente de severidad alta en uno leve.podAntiAffinityType: required. Conpreferred, un clúster apretado puede colocar dos instancias en el mismo nodo y la alta disponibilidad se vuelve ficticia.inheritFromIAMRole: true. Ninguna clave de acceso guardada en ningún sitio: la ServiceAccount del pod tiene un rol de la nube asociado. Es la continuación directa de lo visto en 08-01 y 10-06.retentionPolicy: "30d". Este valor debe validarlo el responsable de cumplimiento normativo: retener demasiado poco incumple obligaciones contables y de continuidad; retener demasiado, con datos personales dentro, incumple el principio de limitación del plazo de conservación.
3.2. La copia programada
El backup del Cluster define dónde y cómo; hace falta además decir cuándo se toma la copia base.
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: postgres-reservas-diaria
namespace: rutas-norte-pro
spec:
# Formato con segundos: 03:15 cada día (hora del clúster, UTC).
schedule: "0 15 3 * * *"
backupOwnerReference: self
cluster:
name: postgres-reservas
method: barmanObjectStoreCon la copia base diaria y el archivado continuo del WAL, la ventana de pérdida de datos es de segundos, no de un día: se restaura la copia base más reciente y se reproducen los WAL hasta el instante deseado. Eso es la recuperación a un instante concreto del apartado 6.
3.3. Cómo elige el primario el operador y qué Services publica
El operador mantiene un pod como primario y anota su identidad en el estado del Cluster. Cuando el primario deja de responder a las comprobaciones durante más de failoverDelay, el operador elige la réplica con el WAL más avanzado, la promociona y reconfigura las demás para que sigan a la nueva. Todo eso sin que ningún manifiesto cambie.
Los Services que crea automáticamente:
| Service | A quién apunta | Uso en Rutas Norte |
|---|---|---|
postgres-reservas-rw |
Siempre al primario actual | Escrituras de api-reservas |
postgres-reservas-ro |
Solo a réplicas | Consultas de informes-ocupacion |
postgres-reservas-r |
A cualquier instancia | Diagnóstico; poco usado |
NAME AGE INSTANCES READY STATUS PRIMARY
postgres-reservas 214d 3 3 Cluster in healthy state postgres-reservas-1
- Cómo se conecta
api-reservas: el agrupador de conexiones
api-reservas: el agrupador de conexionesPostgreSQL crea un proceso por conexión. Con max_connections: 200 y un HPA que durante el puente de mayo lleva api-reservas a 40 réplicas con un pool de 20 conexiones cada una, la aritmética es demoledora: 800 conexiones solicitadas contra 200 disponibles. El resultado no es lentitud, es rechazo de conexiones y errores 500 en la venta de billetes.
La solución es un agrupador de conexiones (PgBouncer) delante de la base de datos, que CloudNativePG gestiona como recurso propio:
apiVersion: postgresql.cnpg.io/v1
kind: Pooler
metadata:
name: postgres-reservas-pooler-rw
namespace: rutas-norte-pro
spec:
cluster:
name: postgres-reservas
instances: 3 # el pooler también debe ser redundante
type: rw # apunta al primario actual, siguiéndolo en las conmutaciones
pgbouncer:
poolMode: transaction # devuelve la conexión al terminar cada transacción
parameters:
max_client_conn: "1000" # lo que aceptamos de los pods
default_pool_size: "40" # lo que abrimos realmente contra PostgreSQL
reserve_pool_size: "10"
server_idle_timeout: "120"
template:
spec:
containers: []
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
cnpg.io/poolerName: postgres-reservas-pooler-rwMil conexiones de aplicación se multiplexan sobre cuarenta reales. Por eso el ConfigMap de api-reservas de la lección anterior apunta a postgres-reservas-pooler-rw y no directamente al Service -rw.
Precio a pagar: poolMode: transaction es incompatible con funcionalidades ligadas a la sesión (sentencias preparadas con nombre en algunos controladores, LISTEN/NOTIFY, tablas temporales de sesión, SET persistente). En api-reservas se comprobó que ninguna se usa. Si se usara alguna, la alternativa es session, que reduce mucho la ventaja del agrupador.
- Conmutación por error: simularla y medirla
Un mecanismo de alta disponibilidad que nunca se ha probado es una hipótesis. Esta es la prueba que Rutas Norte ejecuta en rutas-norte-pre antes de cada temporada alta.
5.1. El experimento
# 1. Estado inicial: quién es el primario.
kubectl -n rutas-norte-pre get cluster postgres-reservas -o jsonpath='{.status.currentPrimary}{"\n"}'
# 2. Carga sostenida de escritura mientras dura el experimento.
kubectl -n rutas-norte-pre run carga-escritura --rm -it --restart=Never \
--image=registry.rutasnorte.example/utiles/pgbench:16 -- \
pgbench -h postgres-reservas-pooler-rw -U app_reservas -c 10 -T 180 -P 5 reservas &
# 3. Matamos el primario de golpe (no un borrado ordenado: simulamos pérdida de nodo).
kubectl -n rutas-norte-pre delete pod postgres-reservas-1 --grace-period=0 --force
# 4. Observamos la promoción segundo a segundo.
kubectl -n rutas-norte-pre get cluster postgres-reservas -w \
-o custom-columns='PRIMARIO:.status.currentPrimary,LISTAS:.status.readyInstances,ESTADO:.status.phase'PRIMARIO LISTAS ESTADO
postgres-reservas-1 3 Cluster in healthy state
postgres-reservas-1 2 Failing over
postgres-reservas-2 2 Failing over
postgres-reservas-2 2 Cluster in healthy state
postgres-reservas-2 3 Cluster in healthy state5.2. Los números medidos
Resultados del último simulacro en rutas-norte-pre (tres repeticiones, valores medios):
| Fase | Tiempo |
|---|---|
| Detección de la caída del primario | 4 s |
| Promoción de la réplica más avanzada | 6 s |
Actualización del Service -rw a la nueva IP |
2 s |
| Reconexión del pooler | 3 s |
| Corte total de escrituras percibido | ≈ 15 s |
| Reconstrucción de la instancia caída como réplica | 4 min |
| Pérdida de datos (transacciones confirmadas) | 0 |
Quince segundos sin poder escribir. Si api-reservas no hace nada al respecto, esos quince segundos son quince segundos de errores 500 en la compra de billetes, y en pleno puente de mayo eso son varios cientos de ventas perdidas.
5.3. Qué debe hacer api-reservas para sobrevivir a esos segundos
Tres mecanismos, y el orden importa.
Tiempos de espera acotados. Sin tiempo de espera, una conexión a un primario muerto se queda colgada hasta el tiempo de espera del sistema operativo (minutos). Con él, falla rápido y se puede reintentar.
const pool = new Pg.Pool({
host: process.env.PG_HOST,
max: Number(process.env.PG_POOL_MAX),
connectionTimeoutMillis: 3000, // conseguir conexión
idleTimeoutMillis: 30000,
statement_timeout: 5000, // ninguna consulta bloquea un hilo indefinidamente
keepAlive: true
});Reintentos con espera exponencial y aleatoriedad, solo para lo idempotente. Reintentar un SELECT es seguro. Reintentar INSERT INTO reservas sin más puede duplicar la reserva y cobrar dos veces al cliente: hace falta una clave de idempotencia.
const ERRORES_TRANSITORIOS = new Set([
'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT',
'57P01', // admin_shutdown: el primario se está apagando
'57P03', // cannot_connect_now: arrancando
'40001' // serialization_failure
]);
async function conReintentos(operacion, { intentos = 4, baseMs = 200 } = {}) {
let ultimo;
for (let i = 0; i < intentos; i++) {
try {
return await operacion();
} catch (e) {
ultimo = e;
const transitorio = ERRORES_TRANSITORIOS.has(e.code);
if (!transitorio || i === intentos - 1) throw e;
// Espera exponencial con aleatoriedad: evita que 40 réplicas
// reintenten todas en el mismo milisegundo.
const espera = baseMs * 2 ** i * (0.5 + Math.random());
log.warn({ intento: i + 1, codigo: e.code, espera }, 'reintentando');
await new Promise((r) => setTimeout(r, espera));
}
}
throw ultimo;
}Con cuatro intentos y base de 200 ms, la ventana cubierta ronda los 4-6 segundos. No cubre los quince del corte completo, y eso es deliberado: alargarla más haría que las peticiones se acumularan en el servidor hasta agotar la memoria.
Circuito para lo que no se puede reintentar. Cuando los fallos superan un umbral, el circuito se abre y la API deja de intentar durante unos segundos, devolviendo una respuesta degradada e inmediata en lugar de acumular peticiones colgadas.
// Degradación honesta: la consulta de horarios se sirve desde redis-cache,
// la compra devuelve 503 con Retry-After en lugar de colgarse.
app.post('/reservas', async (req, res) => {
if (circuito.abierto()) {
return res.status(503)
.set('Retry-After', '10')
.json({ error: 'servicio_temporalmente_no_disponible' });
}
...
});La combinación de los tres reduce el impacto real de la conmutación a unos pocos segundos de degradación parcial, con la venta recuperándose sola. Es la diferencia entre un incidente de severidad 1 y una nota en el registro.
- Copias de seguridad y recuperación a un instante concreto
6.1. Las tres preguntas que definen la política
| Pregunta | Concepto | Valor en Rutas Norte |
|---|---|---|
| ¿Cuántos datos podemos perder? | Objetivo de punto de recuperación (RPO) | 5 minutos |
| ¿Cuánto podemos estar caídos? | Objetivo de tiempo de recuperación (RTO) | 1 hora |
| ¿Cuánto tiempo guardamos las copias? | Retención | 30 días (pendiente de validación de cumplimiento) |
Con archivado continuo del WAL, el RPO real es de segundos. El RTO es el que hay que medir, y solo se mide restaurando.
6.2. Restauración a un instante concreto
CloudNativePG restaura creando un Cluster nuevo a partir del almacén de objetos. Nunca se restaura "encima" del clúster existente: se levanta uno paralelo, se verifica y luego se decide.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: postgres-reservas-restaurado
namespace: rutas-norte-pro
spec:
instances: 1 # para verificar basta una instancia
imageName: ghcr.io/cloudnative-pg/postgresql:16.4
storage:
size: 200Gi
storageClass: rutasnorte-rapida
bootstrap:
recovery:
source: copia-origen
recoveryTarget:
# Justo antes del borrado accidental de las 11:47.
targetTime: "2026-08-06 11:45:00.000000+00:00"
externalClusters:
- name: copia-origen
barmanObjectStore:
destinationPath: s3://rutasnorte-copias-pro/postgres-reservas
serverName: postgres-reservas
s3Credentials:
inheritFromIAMRole: true
wal:
maxParallel: 8 # paralelismo alto: acelera la reproducción del WALkubectl apply -f restauracion.yaml
kubectl -n rutas-norte-pro get cluster postgres-reservas-restaurado -wNAME INSTANCES READY STATUS
postgres-reservas-restaurado 1 0 Setting up primary
postgres-reservas-restaurado 1 0 Recovering from backup
postgres-reservas-restaurado 1 1 Cluster in healthy stateVerificación antes de dar por buena la restauración:
kubectl -n rutas-norte-pro exec -it postgres-reservas-restaurado-1 -- \
psql -U postgres reservas -c \
"SELECT count(*) AS reservas, max(creada_en) AS ultima FROM reservas;"6.3. El tiempo real, medido
Simulacro del 12 de julio de 2026 sobre una copia de 118 GB:
| Fase | Tiempo |
|---|---|
| Decidir el instante objetivo y redactar el manifiesto | 6 min |
| Aprovisionamiento del volumen y descarga de la copia base | 21 min |
| Reproducción de los WAL hasta el instante objetivo | 9 min |
| Verificación de integridad y recuento | 4 min |
Conmutar api-reservas al clúster restaurado |
3 min |
| Total | 43 min |
Cuarenta y tres minutos, dentro del RTO de una hora, pero con poco margen. Dos acciones salieron del simulacro: subir maxParallel de 4 a 8 en la recuperación (ya aplicado arriba) y tener el manifiesto de restauración escrito y versionado en el repositorio, con el instante objetivo como único parámetro a rellenar. Los seis minutos de "redactar el manifiesto" bajo presión son el peor sitio para improvisar.
Una copia que no se ha restaurado nunca no existe. Es una afirmación literal, no una figura retórica. Los modos de fallo reales son mundanos: la credencial del almacén caducó hace cuatro meses y nadie miró la alerta; el bucket tenía una política de ciclo de vida que movía los objetos a almacenamiento en frío con horas de latencia de recuperación; la copia se hacía pero de una base de datos que ya no era la de producción. Todos se detectan restaurando, y ninguno se detecta mirando que el
ScheduledBackupesté en verde.
La regla de Rutas Norte: restauración completa cronometrada cada trimestre, con acta y con el tiempo apuntado. Está en el calendario de mantenimiento de 11-06.
6.4. Vigilar que las copias se hacen
- alert: CopiaPostgresAntigua
expr: |
time() - cnpg_collector_last_available_backup_timestamp{cluster="postgres-reservas"} > 36 * 3600
for: 15m
labels: { severidad: critica, equipo: plataforma }
annotations:
resumen: "Sin copia válida de postgres-reservas en más de 36 horas"
runbook: "https://wiki.rutasnorte.example/runbooks/copia-postgres-fallida"
- Actualización de versión mayor de PostgreSQL
Las actualizaciones menores (16.4 → 16.6) las hace el operador solo: cambias imageName, actualiza réplicas, conmuta y actualiza el antiguo primario. Corte de segundos.
Las mayores (16 → 17) son otra cosa: cambia el formato del directorio de datos, la replicación en flujo no funciona entre versiones distintas y la reversión no es trivial. Hay tres caminos.
| Método | Corte | Riesgo | Reversión | Cuándo usarlo |
|---|---|---|---|---|
Volcado y restauración (pg_dump/pg_restore) |
Horas para 120 GB | Bajo | Fácil: el original sigue intacto | Bases pequeñas o ventana amplia |
pg_upgrade en el sitio |
5-15 min | Medio | Difícil una vez convertido | Ventana corta y equipo con experiencia |
| Replicación lógica a un clúster nuevo | 1-2 min | Bajo-medio | Fácil hasta el corte | Cuando el corte debe ser mínimo |
Rutas Norte eligió la replicación lógica. El procedimiento, resumido:
# Clúster destino en 17, poblado por importación con replicación lógica.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: postgres-reservas-17
namespace: rutas-norte-pro
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:17.2
storage: { size: 200Gi, storageClass: rutasnorte-rapida }
bootstrap:
initdb:
import:
type: microservice
databases: ["reservas"]
source:
externalCluster: origen-16
externalClusters:
- name: origen-16
connectionParameters:
host: postgres-reservas-rw.rutas-norte-pro.svc.cluster.local
user: postgres
dbname: reservas
password:
name: postgres-reservas-superuser
key: passwordSecuencia del día del cambio:
- Semanas antes: levantar el clúster 17 en
rutas-norte-pre, ejecutar el conjunto completo de pruebas deapi-reservascontra él y comparar planes de ejecución de las diez consultas más costosas. Un cambio de plan en el planificador es el riesgo real de una actualización mayor. - Días antes: levantar el 17 en
proy dejarlo sincronizando por replicación lógica hasta que el retraso sea de milisegundos. - Ventana de corte (unos 90 s): poner
api-reservasen modo de solo lectura mediante una bandera de funcionalidad, confirmar retraso cero, detener la suscripción, apuntar el ConfigMap al pooler del clúster 17, reiniciar el despliegue y quitar el modo de solo lectura. - Después: vigilar durante 48 horas. El clúster 16 se conserva una semana apagado pero intacto, como plan de reversión.
Un detalle que se olvida siempre: ANALYZE completo tras la migración. Las estadísticas del planificador no se transfieren, y sin ellas las consultas van lentas durante horas y parece que la actualización ha ido mal.
- Réplicas de lectura para
informes-ocupacion
informes-ocupacioninformes-ocupacion es el CronJob nocturno que recorre meses de reservas para calcular ocupación por línea y por franja. Son consultas pesadas que, ejecutadas contra el primario, compiten con la venta de billetes.
La solución es directa: apuntarlo al Service -ro, que solo enruta a réplicas.
apiVersion: batch/v1
kind: CronJob
metadata:
name: informes-ocupacion
namespace: rutas-norte-pro
spec:
schedule: "0 3 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: OnFailure
serviceAccountName: informes-ocupacion
containers:
- name: informes
image: registry.rutasnorte.example/rutasnorte/informes@sha256:c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071829304152
env:
- name: PG_HOST
value: postgres-reservas-ro # réplicas, nunca el primario
- name: PG_OPTIONS
# Tolera hasta 5 min de retraso de replicación en lugar de
# cancelar la consulta si el WAL entrante entra en conflicto.
value: "-c statement_timeout=1800000"
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { memory: 2Gi }Dos advertencias sobre las réplicas de lectura:
- Coherencia eventual. Una réplica va unos milisegundos por detrás. Para informes es irrelevante; para "acabo de reservar y no veo mi reserva" es un error visible para el usuario. Regla en Rutas Norte: lo que el usuario acaba de escribir se lee del primario; todo lo demás puede ir a réplica.
- Conflictos de recuperación. Una consulta larga en la réplica puede entrar en conflicto con el WAL que llega y ser cancelada. Se ajusta con
max_standby_streaming_delay, aceptando a cambio más retraso de replicación durante los informes.
redis-cache: cuando perder el dato es aceptable
redis-cache: cuando perder el dato es aceptableRedis es también una carga con estado, pero de una categoría distinta: su dato es reconstruible. Esa diferencia lo cambia todo.
En Rutas Norte, redis-cache guarda tres cosas:
| Dato | ¿Reconstruible? | Consecuencia de perderlo |
|---|---|---|
| Caché de horarios y precios | Sí, desde PostgreSQL | Pico de carga en la base de datos durante unos minutos |
| Carrito de la compra (TTL 15 min) | No | El usuario pierde la selección y tiene que repetirla |
| Contadores de limitación de peticiones | Sí, se rehacen solos | Ventana breve sin limitación efectiva |
El carrito es el caso incómodo: no es crítico como una reserva confirmada, pero perderlo es visible y molesto.
Decisión de Rutas Norte: persistencia AOF activada con appendfsync everysec, un solo nodo con volumen persistente y sin réplica. El razonamiento:
- Sin persistencia, cada reinicio de pod (una actualización de nodo, un cambio de configuración) vacía todos los carritos activos. Ocurre varias veces al mes.
- Con AOF cada segundo, un reinicio ordenado pierde como mucho un segundo de escrituras y los carritos sobreviven.
- Montar Redis Sentinel o un clúster para un dato con TTL de quince minutos es complejidad sin retorno.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis-cache
namespace: rutas-norte-pro
spec:
serviceName: redis-cache
replicas: 1
selector:
matchLabels: { app.kubernetes.io/name: redis-cache }
template:
metadata:
labels: { app.kubernetes.io/name: redis-cache }
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999
fsGroup: 999
terminationGracePeriodSeconds: 30
containers:
- name: redis
image: redis@sha256:d1e2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60
args:
- --appendonly
- "yes"
- --appendfsync
- everysec
- --maxmemory
- 900mb
- --maxmemory-policy
# allkeys-lru evictaría carritos al llenarse. volatile-lru solo
# desaloja claves con TTL, que son precisamente las de caché.
- volatile-lru
- --save
- ""
ports: [{ name: redis, containerPort: 6379 }]
resources:
requests: { cpu: 100m, memory: 1Gi }
limits: { memory: 1Gi }
readinessProbe:
exec: { command: ["redis-cli", "ping"] }
periodSeconds: 5
livenessProbe:
tcpSocket: { port: redis }
periodSeconds: 15
volumeMounts:
- { name: datos, mountPath: /data }
volumeClaimTemplates:
- metadata: { name: datos }
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: rutasnorte-estandar # no necesita IOPS altas
resources: { requests: { storage: 10Gi } }Y, sobre todo, la aplicación asume que Redis puede no estar: si la caché no responde, api-reservas va a PostgreSQL; si el carrito no está, se pide al usuario que repita la selección con un mensaje claro. Ninguna llamada a Redis puede tumbar una petición.
async function horariosDe(linea, fecha) {
try {
const cacheado = await redis.get(`horarios:${linea}:${fecha}`);
if (cacheado) return JSON.parse(cacheado);
} catch (e) {
log.warn({ err: e.message }, 'redis no disponible, se consulta la base de datos');
}
const filas = await consultarHorarios(linea, fecha);
redis.setex(`horarios:${linea}:${fecha}`, 300, JSON.stringify(filas)).catch(() => {});
return filas;
}Un cuidado adicional: si Redis cae durante el puente de mayo, toda la carga de lectura pasa de golpe a PostgreSQL. Hay que dimensionar la base de datos para sobrevivir a ese escenario, o el fallo de un componente prescindible se convierte en la caída de uno que no lo es.
- Lista de comprobación de una carga con estado en producción
| # | Comprobación | Estado en Rutas Norte |
|---|---|---|
| 1 | Decisión documentada de gestionada / operador / artesanal, con sus razones | ✔ Acta de plataforma |
| 2 | Se usa un operador maduro, no un StatefulSet propio | ✔ CloudNativePG |
| 3 | Mínimo 3 instancias, repartidas entre zonas con antiafinidad required |
✔ |
| 4 | Volúmenes con reclaimPolicy: Retain y clase adecuada |
✔ rutasnorte-rapida |
| 5 | WAL en volumen separado | ✔ 50 Gi |
| 6 | Copias automáticas a un almacén fuera del clúster | ✔ Almacén de objetos |
| 7 | Archivado continuo de WAL para recuperación a un instante | ✔ RPO real de segundos |
| 8 | Restauración completa probada y cronometrada este trimestre | ✔ 12/07/2026, 43 min |
| 9 | Alerta si no hay copia válida reciente | ✔ CopiaPostgresAntigua |
| 10 | Conmutación por error simulada y medida | ✔ ≈ 15 s, 0 pérdida |
| 11 | La aplicación tiene tiempos de espera, reintentos y circuito | ✔ |
| 12 | Agrupador de conexiones dimensionado para el pico del HPA | ✔ 1000 → 40 |
| 13 | Lecturas pesadas dirigidas a réplicas | ✔ informes-ocupacion |
| 14 | Procedimiento de actualización mayor escrito y probado en pre |
✔ Replicación lógica |
| 15 | Métricas y paneles específicos de la base de datos | ✔ PodMonitor + panel |
| 16 | Cifrado en reposo y en tránsito | ✔ Volúmenes cifrados, hostssl |
| 17 | Acceso al dato con RBAC mínimo y auditado | ✔ Revisión trimestral |
| 18 | Revisión de cumplimiento normativo sobre retención, ubicación y acceso | ⧗ Pendiente de renovación anual |
La fila 18 no es burocracia: en un clúster de PostgreSQL con datos personales, la retención de copias, la región del almacén de objetos, quién puede abrir una psql contra producción y qué queda registrado de ello son decisiones con consecuencias legales, no solo técnicas.
Errores Comunes y Consejos
- Montar la base de datos en Kubernetes por coherencia estética. Si nadie del equipo sabe hacer una recuperación a un instante concreto ni está dispuesto a estar de guardia, la respuesta correcta es la base de datos gestionada, aunque cueste más.
- Confiar en el
ScheduledBackupen verde. El indicador dice que el proceso se ejecutó, no que la copia sea restaurable. Solo la restauración cronometrada lo demuestra. - Copias en el mismo clúster o en la misma cuenta que produce el dato. Un borrado accidental con permisos amplios, o un compromiso de credenciales, se lleva el dato y la copia a la vez. Almacén separado y, si es posible, cuenta separada con inmutabilidad.
- Conectar la aplicación al Service
-rwsin agrupador. Funciona perfectamente hasta que el HPA escala, y entonces falla justo el día de más ventas. - Reintentar escrituras no idempotentes. Duplica reservas y cobros. Cada escritura reintentable necesita una clave de idempotencia.
- Usar
allkeys-lruen un Redis que guarda carritos. Al llenarse la memoria desaloja carritos activos.volatile-lrucon TTL solo en lo que es caché protege el dato que sí importa. - Consejo: escribe el manifiesto de restauración antes de necesitarlo y guárdalo en el repositorio con el instante objetivo como hueco a rellenar. Ahorra los minutos más caros del incidente.
- Consejo: haz
ANALYZEtras cualquier migración o actualización mayor. Sin estadísticas, el planificador toma decisiones malas y todo parece roto. - Consejo: dimensiona la base de datos para el escenario en que la caché no está. Si no, un fallo prescindible se convierte en uno crítico.
Ejercicios
Ejercicio 1: elegir la opción correcta
Rutas Norte va a lanzar un producto nuevo, rutas-carga, con su propia base de datos PostgreSQL de unos 8 GB, tres desarrolladores, sin equipo de plataforma dedicado y con lanzamiento previsto en seis semanas. Los datos incluyen información de contacto de empresas cliente. Recomienda una de las tres opciones y justifica la decisión con al menos cuatro criterios de la tabla del apartado 1. Indica también qué revisión adicional hace falta por los datos de contacto.
Ejercicio 2: dimensionar el agrupador de conexiones
api-reservas tiene un HPA con maxReplicas: 40 y cada réplica abre hasta 20 conexiones. informes-ocupacion abre 5 conexiones contra las réplicas. postgres-reservas tiene max_connections: 200, de las cuales PostgreSQL reserva 3 para superusuario. Calcula las conexiones máximas solicitadas sin agrupador, explica qué ocurre en el pico del puente de mayo y comprueba si max_client_conn: 1000 y default_pool_size: 40 son valores adecuados.
Ejercicio 3: diagnosticar un simulacro de restauración fallido
Durante el simulacro trimestral, el Cluster de restauración se queda así:
NAME INSTANCES READY STATUS
postgres-reservas-restaurado 1 0 Recovering from backup
$ kubectl -n rutas-norte-pro logs postgres-reservas-restaurado-1 | tail -3
ERROR: WAL segment 000000010000004A000000E1 not found in archive
FATAL: could not receive data from WAL streamEnumera tres causas posibles, di cómo distinguirlas y qué corrección aplicarías en cada caso. Indica además qué implicación tiene esto sobre el RPO real.
Soluciones
Solución 1. Recomendación: base de datos gestionada del proveedor. Criterios: (a) coste de personal, no hay equipo de plataforma y el operativo del operador recaería sobre tres desarrolladores que deben construir el producto; (b) tiempo hasta producción, seis semanas no dan margen para adquirir madurez operativa con CloudNativePG; (c) riesgo de fallo catastrófico, sin guardia establecida un fallo nocturno queda sin respuesta; (d) coste de infraestructura, la prima del proveedor sobre 8 GB es pequeña en términos absolutos, muy distinto del caso de postgres-reservas con 120 GB. El StatefulSet artesanal queda descartado de entrada. Revisión adicional: los datos de contacto de empresas cliente son datos personales de personas físicas de contacto, por lo que la elección de región, la retención de copias y las condiciones del encargado del tratamiento (el proveedor de la nube) deben ser revisadas por el responsable de cumplimiento normativo antes de contratar.
Solución 2. Sin agrupador: 40 × 20 = 800 conexiones de api-reservas, más 5 de informes = 805 solicitadas frente a 197 utilizables. En el pico del puente de mayo, en cuanto se superan las 197, PostgreSQL rechaza con FATAL: sorry, too many clients already; los pods afectados fallan sus sondas de preparación, salen del balanceo, el HPA ve más carga en los restantes y escala más, empeorando el problema: un ciclo de realimentación destructivo. Con el pooler: max_client_conn: 1000 cubre las 805 solicitadas con margen (adecuado); default_pool_size: 40 más reserve_pool_size: 10 abre como máximo 50 conexiones reales contra el primario, holgadamente dentro de 197, dejando sitio para informes, mantenimiento y superusuario. Ambos valores son correctos. Conviene vigilar la métrica de tiempo de espera en cola de PgBouncer: si crece durante el pico, el cuello de botella pasa a ser default_pool_size y habría que subirlo (con margen hasta unas 150 conexiones reales).
Solución 3. Causas posibles: (a) retención insuficiente, los WAL de ese periodo ya se eliminaron por la política de 30 días o por una regla de ciclo de vida del bucket; se distingue listando el prefijo wals/ en el almacén y comprobando si el segmento existe; corrección: elegir un instante objetivo dentro del rango disponible y revisar la política de retención y las reglas de ciclo de vida. (b) Fallo del archivado continuo, el primario dejó de archivar WAL en algún momento (credencial caducada, permisos, disco lleno) y hay un hueco; se distingue mirando cnpg_collector_last_failed_archive_time y los registros del primario; corrección: reparar el archivado y, muy importante, tomar inmediatamente una copia base nueva. (c) Permisos o ruta incorrectos en la restauración, el serverName o el destinationPath no coinciden con los del origen, o el rol federado no tiene lectura sobre ese prefijo; se distingue porque fallarían todos los segmentos, no uno concreto; corrección: ajustar serverName/destinationPath y los permisos del rol. Implicación sobre el RPO: si el archivado tiene huecos, el RPO declarado de 5 minutos es falso, y el real es la antigüedad de la última copia base completa, que puede ser de casi 24 horas. Es exactamente la razón por la que existe la alerta CopiaPostgresAntigua y por la que el simulacro trimestral es obligatorio.
Conclusión
Hemos resuelto de principio a fin el escenario de una carga con estado en producción. Empezamos por la pregunta que no conviene esquivar —si la base de datos debe estar en Kubernetes— y llegamos a una decisión razonada y con condiciones: en Rutas Norte se queda dentro del clúster con CloudNativePG, con copias fuera y restauración probada cada trimestre. Vimos qué hace especial al estado (identidad, orden, dato irreemplazable, actualizaciones que no admiten RollingUpdate), el recurso Cluster completo con sus decisiones justificadas, el agrupador de conexiones que evita que el éxito del HPA tumbe la base de datos, la conmutación por error medida en quince segundos y los tres mecanismos que permiten a api-reservas atravesarlos sin errores visibles, la recuperación a un instante concreto con su tiempo real de 43 minutos, la actualización de versión mayor por replicación lógica, las réplicas de lectura para los informes y redis-cache como el caso en que perder el dato es aceptable, siempre que la aplicación lo asuma.
La idea que resume la lección: en las cargas con estado, lo que te salva no son los manifiestos, sino los procedimientos probados. La copia que nunca se restauró, la conmutación que nunca se simuló y la actualización que nunca se ensayó en pre son deuda esperando a vencer en el peor momento.
Ya tenemos la aplicación sin estado y la base de datos en producción. Lo que no hemos contado todavía es cómo llega el código hasta ahí. En la siguiente lección, CI/CD con Kubernetes, seguimos el camino completo desde el git push de un desarrollador hasta el pod que atiende peticiones en rutas-norte-pro, pasando por la construcción de la imagen, su escaneo y firma, las pruebas contra un clúster efímero y la promoción entre entornos con Argo CD.
Curso de Kubernetes
Módulo 1: Introducción a Kubernetes
- ¿Qué es Kubernetes?
- Arquitectura de Kubernetes
- Conceptos y Terminología Clave
- Configuración de un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objetos, Manifiestos YAML y el Modelo Declarativo
- El Proyecto del Curso: la Plataforma Rutas Norte
Módulo 2: Componentes Principales de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualizaciones, Rollbacks y Estrategias de Despliegue
- Servicios
- Namespaces
- Etiquetas, Selectores y Anotaciones
Módulo 3: Gestión de Configuración y Secretos
- ConfigMaps
- Secrets
- Variables de Entorno
- Cuotas y Límites de Recursos
- LimitRanges y Clases de Calidad de Servicio (QoS)
- ServiceAccounts y Acceso a la API desde los Pods
Módulo 4: Redes en Kubernetes
- Redes de Clúster
- Tipos de Servicios
- DNS Interno y Descubrimiento de Servicios
- Controladores de Ingress
- TLS y Gestión de Certificados con cert-manager
- Políticas de Red
Módulo 5: Almacenamiento en Kubernetes
- Volúmenes
- Volúmenes Persistentes
- Reclamaciones de Volúmenes Persistentes
- Clases de Almacenamiento
- Aprovisionamiento Dinámico, Expansión y Snapshots
- Copias de Seguridad y Restauración de Datos
Módulo 6: Conceptos Avanzados de Kubernetes
- StatefulSets
- DaemonSets
- Trabajos y CronJobs
- Init Containers, Sidecars y Patrones Multi-Contenedor
- Planificación: Afinidad, Taints y Tolerations
- Definiciones de Recursos Personalizados (CRDs)
- Operadores y el Patrón Controlador
Módulo 7: Monitoreo y Registro
- Verificaciones de Salud y Sondas
- Servidor de Métricas y kubectl top
- Monitoreo con Prometheus
- Visualización y Alertas con Grafana y Alertmanager
- Registro Centralizado con Elasticsearch, Fluentd y Kibana (EFK)
- Depuración de Aplicaciones y Eventos del Clúster
Módulo 8: Seguridad en Kubernetes
- Control de Acceso Basado en Roles (RBAC)
- Contextos de Seguridad y Endurecimiento del Contenedor
- Políticas de Seguridad de Pods y Pod Security Standards
- Seguridad de Red
- Seguridad de Imágenes
- Auditoría, Escaneo y Gestión de Vulnerabilidades
Módulo 9: Escalado y Rendimiento
- Autoescalado Horizontal de Pods
- Autoescalado Vertical de Pods
- Autoescalado de Clúster
- Escalado por Eventos y Métricas Personalizadas con KEDA
- Alta Disponibilidad: PodDisruptionBudgets y Topología
- Ajuste de Rendimiento
Módulo 10: Ecosistema y Herramientas de Kubernetes
- Minikube y Entornos Locales con kind
- Kubeadm
- Helm
- Kustomize
- GitOps con Argo CD y Flux
- Kubernetes Gestionado: EKS, AKS y GKE
Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real
- Despliegue de una Aplicación Web
- Ejecución de Aplicaciones con Estado
- CI/CD con Kubernetes
- Estrategias de Despliegue: Blue-Green y Canary
- Gestión Multi-Clúster
- Operación en Producción: Incidencias, Runbooks y Costes
