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

  1. La pregunta previa: ¿debe la base de datos vivir en Kubernetes?
  2. Qué tiene de especial una carga con estado
  3. postgres-reservas con CloudNativePG
  4. Cómo se conecta api-reservas: el agrupador de conexiones
  5. Conmutación por error: simularla y medirla
  6. Copias de seguridad y recuperación a un instante concreto
  7. Actualización de versión mayor de PostgreSQL
  8. Réplicas de lectura para informes-ocupacion
  9. redis-cache: cuando perder el dato es aceptable
  10. Lista de comprobación de una carga con estado en producción

  1. 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.

  1. 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, no Delete (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.

  1. postgres-reservas con CloudNativePG

CloudNativePG 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: 2

Las decisiones que conviene entender:

  • instances: 3 y 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.
  • walStorage separado. 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. Con preferred, 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: barmanObjectStore

Con 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
kubectl -n rutas-norte-pro get cluster postgres-reservas
NAME                AGE    INSTANCES   READY   STATUS                     PRIMARY
postgres-reservas   214d   3           3       Cluster in healthy state   postgres-reservas-1

  1. Cómo se conecta api-reservas: el agrupador de conexiones

PostgreSQL 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-rw

Mil 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.

  1. 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 state

5.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.

  1. 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 WAL
kubectl apply -f restauracion.yaml
kubectl -n rutas-norte-pro get cluster postgres-reservas-restaurado -w
NAME                            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 state

Verificació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;"
 reservas |            ultima
----------+-------------------------------
   418732 | 2026-08-06 11:44:58.221+00

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 ScheduledBackup esté 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"

  1. 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: password

Secuencia del día del cambio:

  1. Semanas antes: levantar el clúster 17 en rutas-norte-pre, ejecutar el conjunto completo de pruebas de api-reservas contra é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.
  2. Días antes: levantar el 17 en pro y dejarlo sincronizando por replicación lógica hasta que el retraso sea de milisegundos.
  3. Ventana de corte (unos 90 s): poner api-reservas en 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.
  4. 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.

  1. Réplicas de lectura para informes-ocupacion

informes-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.

  1. redis-cache: cuando perder el dato es aceptable

Redis 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.

  1. 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 ScheduledBackup en 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 -rw sin 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-lru en un Redis que guarda carritos. Al llenarse la memoria desaloja carritos activos. volatile-lru con 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 ANALYZE tras 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 stream

Enumera 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

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados