A la lliçó anterior vam portar botiga-web i api-reserves a producció. Van ser tretze objectes i una llista de comprovacions llarga, però el marge d'error era generós: si un pod es perd, en neix un altre d'idèntic i ningú no se n'assabenta. Aquí desapareix aquesta xarxa de seguretat. postgres-reserves conté les dades personals dels clients de Rutas Norte, les seves reserves i els seus pagaments. Un pod perdut pot significar minuts de servei caigut; un volum perdut, un problema d'existència per a l'empresa.

Aquesta lliçó resol l'escenari complet d'una càrrega amb estat en producció: des de la pregunta incòmoda de si aquesta base de dades hauria d'estar a Kubernetes, fins al procediment de restauració cronometrat, passant per la commutació per error, l'actualització de versió major i el cas molt diferent de redis-cache, on perdre la dada és acceptable.

Advertiment de compliment normatiu. Tot el que es descriu aquí afecta dades personals de clients (nom, correu, historial de viatges, dades de pagament tokenitzades) i la continuïtat del negoci. Les decisions sobre ubicació de les còpies de seguretat, terminis de retenció, xifratge, accés del personal a les dades i transferències entre regions han de ser revisades i aprovades pel responsable de compliment normatiu de l'organització abans d'aplicar-se. Els valors d'aquest material són il·lustratius i no substitueixen aquesta revisió.

Contingut

  1. La pregunta prèvia: ha de viure la base de dades a Kubernetes?
  2. Què té d'especial una càrrega amb estat
  3. postgres-reserves amb CloudNativePG
  4. Com es connecta api-reserves: l'agrupador de connexions
  5. Commutació per error: simular-la i mesurar-la
  6. Còpies de seguretat i recuperació a un instant concret
  7. Actualització de versió major de PostgreSQL
  8. Rèpliques de lectura per a informes-ocupacio
  9. redis-cache: quan perdre la dada és acceptable
  10. Llista de comprovació d'una càrrega amb estat en producció

  1. La pregunta prèvia: ha de viure la base de dades a Kubernetes?

És temptador respondre "sí" per coherència: si tota la resta és al clúster, la base de dades també. És una mala raó. La pregunta correcta és quina opció minimitza el risc total al cost que l'empresa pot assumir.

Hi ha tres opcions reals.

Criteri Gestionada del proveïdor (RDS, Cloud SQL) Operador dins del clúster (CloudNativePG) StatefulSet artesanal
Cost d'infraestructura Alt: prima del 40-80 % sobre el còmput equivalent Mitjà: es paga còmput i disc a preu de llista Baix, en aparença
Cost de personal Molt baix Mitjà: cal conèixer l'operador i PostgreSQL Molt alt: algú ha de ser expert en tots dos
Esforç operatiu Còpies, pedaços i commutació els fa el proveïdor L'operador automatitza còpies, commutació i actualitzacions menors Tot a mà o amb scripts propis
Control i ajust fi Limitat: extensions i paràmetres restringits Total: qualsevol extensió i paràmetre Total
Portabilitat entre núvols Baixa: és el punt d'ancoratge típic Alta: el mateix manifest en qualsevol clúster Alta
Risc de fallada catastròfica Baix: algú amb guàrdia 24×7 respon Mitjà: depèn de la maduresa de l'equip Alt: la fallada rara arriba de matinada
Temps fins a ser en producció Dies Setmanes Mesos, i mai del tot
Latència des dels pods Un salt de xarxa fora del clúster (1-3 ms) Dins del clúster (< 1 ms) Dins del clúster

El criteri de decisió, en una frase: fes servir la base de dades gestionada tret que tinguis una raó concreta per no fer-ho, i no muntis mai un StatefulSet artesanal per a una base de dades de producció.

Raons concretes que justifiquen l'operador dins del clúster:

  • La factura de la gestionada és desproporcionada per a la mida de l'empresa.
  • Calen extensions o paràmetres que el proveïdor no permet.
  • Hi ha un requisit de portabilitat entre núvols o de desplegament en un centre de dades propi.
  • L'equip ja té maduresa operativa demostrada a Kubernetes i algú amb coneixement real de PostgreSQL.

Raons que no justifiquen res: "queda més net", "així tot és Kubernetes", "el YAML és bonic".

La decisió de Rutas Norte. Al mòdul 6 es va presentar CloudNativePG com a substitut del StatefulSet artesanal, i al mòdul 10 es va triar EKS a eu-west-1. L'equip de plataforma ha decidit mantenir postgres-reserves dins del clúster amb CloudNativePG, amb dues condicions explícites escrites a l'acta: que les còpies vagin sempre a un magatzem d'objectes fora del clúster, i que la restauració es provi trimestralment. La raó de la decisió no és tècnica sinó econòmica i de portabilitat: el volum de dades és modest (uns 120 GB), la instància gestionada equivalent amb alta disponibilitat multizona costaria aproximadament el triple, i la direcció vol conservar l'opció de moure la plataforma a un altre núvol d'aquí a dos anys.

És una decisió legítima amb una conseqüència clara: l'equip de plataforma assumeix la guàrdia de la base de dades. Si ningú no està disposat a signar això, la resposta correcta era la gestionada.

  1. Què té d'especial una càrrega amb estat

Quatre propietats que trenquen les suposicions còmodes del mòdul anterior.

2.1. Identitat

Un pod d'api-reserves és intercanviable amb qualsevol altre. Un pod de PostgreSQL no: un és el primari i accepta escriptures, els altres són rèpliques i només llegeixen. La identitat no és a l'etiqueta, és a l'estat de la replicació en aquell instant, i pot canviar sense que ningú desplegui res.

2.2. Ordre

En arrencar un clúster de PostgreSQL, la primera instància ha d'inicialitzar el directori de dades; les altres s'han de clonar d'ella. En actualitzar, cal actualitzar primer les rèpliques i promocionar després. RollingUpdate no entén d'això.

2.3. Dades que no es poden perdre

El volum no és memòria cau: és l'actiu. Això canvia tres coses de cop:

  • La política de reclamació del PV ha de ser Retain, no Delete (05-02).
  • Esborrar el recurs no pot esborrar la dada: cal protecció explícita.
  • Les còpies no són opcionals ni són un detall d'operació: són part del disseny.

2.4. Actualitzacions que no admeten RollingUpdate ingenu

Aspecte Sense estat (api-reserves) Amb estat (postgres-reserves)
Reemplaçar un pod Trivial, en segons Implica clonar o reconnectar replicació
Dues versions alhora Normal durant el desplegament Perillós: formats de dades incompatibles
Reversió rollout undo, segons De vegades impossible: el format de dades ja ha canviat
Escalar a zero Sense conseqüències Tall total del servei
Perdre el volum Irrellevant Catastròfic

La conclusió pràctica: per al que té estat, no desplegues càrregues, delegues en un operador que sap d'aquella base de dades concreta. Això és exactament el que vam veure a 06-07 i el que aplicarem ara.

  1. postgres-reserves amb CloudNativePG

CloudNativePG és un operador que implementa un clúster de PostgreSQL amb replicació en flux, elecció de primari, còpies contínues i actualitzacions controlades. No fa servir StatefulSets: gestiona els pods directament perquè necessita un control més fi de l'ordre.

graph TB
  subgraph op["Namespace cnpg-system"]
    OPR[Operador CloudNativePG]
  end
  subgraph pro["Namespace rutas-norte-pro"]
    subgraph cl["Cluster postgres-reserves"]
      P[(instancia-1<br/>PRIMARI)]
      R1[(instancia-2<br/>rèplica)]
      R2[(instancia-3<br/>rèplica)]
    end
    RW[Service ...-rw<br/>escriptura]
    RO[Service ...-ro<br/>només rèpliques]
    R[Service ...-r<br/>qualsevol]
    POOL[Pooler PgBouncer<br/>...-pooler-rw]
    API[api-reserves]
    INF[informes-ocupacio]
  end
  S3[(Magatzem d'objectes<br/>còpies + WAL)]
  OPR -.reconcilia.-> cl
  P -->|WAL streaming| R1
  P -->|WAL streaming| R2
  RW --> P
  RO --> R1
  RO --> R2
  API --> POOL --> RW
  INF --> RO
  P -->|arxivat continu| S3

3.1. El recurs Cluster complet

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: postgres-reserves
  namespace: rutas-norte-pro
spec:
  # 3 instàncies: 1 primari + 2 rèpliques. Amb 2 sobreviuríem a una fallada,
  # però durant la reconstrucció de la rèplica quedaríem sense redundància.
  instances: 3

  imageName: ghcr.io/cloudnative-pg/postgresql:16.4

  # En perdre el primari, esperem com a màxim 30 s abans de promocionar.
  # Més alt = més indisponibilitat; més baix = risc de promoció per un
  # tall de xarxa transitori.
  failoverDelay: 0
  switchoverDelay: 60

  primaryUpdateStrategy: unsupervised   # l'operador actualitza i commuta sol
  primaryUpdateMethod: switchover       # commuta ordenadament, no reinicia el primari

  bootstrap:
    initdb:
      database: reserves
      owner: app_reserves
      secret:
        name: postgres-reserves-app     # creat per 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 memòria 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"            # disc SSD
      log_min_duration_statement: "500"  # registra consultes de més de 500 ms
      log_checkpoints: "on"
      shared_preload_libraries: "pg_stat_statements"
    pg_hba:
      # Només TLS i només des de la xarxa de pods del clúster.
      - hostssl reserves app_reserves 10.244.0.0/16 scram-sha-256

  resources:
    requests: { cpu: "1", memory: 4Gi }
    limits:   { memory: 4Gi }            # QoS Guaranteed en memòria (03-05)

  storage:
    size: 200Gi
    storageClass: rutasnorte-rapida      # la classe amb IOPS altes de 05-04
  walStorage:
    # WAL en volum separat: evita que un pic d'escriptura de WAL
    # deixi sense espai les dades, i millora el rendiment.
    size: 50Gi
    storageClass: rutasnorte-rapida

  # Repartiment entre zones: mai dues instàncies al mateix node.
  affinity:
    enablePodAntiAffinity: true
    topologyKey: kubernetes.io/hostname
    podAntiAffinityType: required

  monitoring:
    enablePodMonitor: true               # Prometheus el descobreix sol (07-03)

  backup:
    retentionPolicy: "30d"
    barmanObjectStore:
      destinationPath: s3://rutasnorte-copies-pro/postgres-reserves
      s3Credentials:
        inheritFromIAMRole: true          # identitat federada, sense claus (10-06)
      wal:
        compression: gzip
        maxParallel: 4
      data:
        compression: gzip
        immediateCheckpoint: false
        jobs: 2

Les decisions que convé entendre:

  • instances: 3 i no 2. Amb dues instàncies, tan bon punt en cau una et quedes sense redundància justament quan més la necessites, perquè reconstruir una rèplica de 120 GB triga una estona. Tres és el mínim defensable en producció.
  • primaryUpdateMethod: switchover. En aplicar una actualització menor, l'operador actualitza primer les rèpliques, després promociona una rèplica ja actualitzada i finalment actualitza l'antic primari. El tall es mesura en segons, no en minuts.
  • walStorage separat. El WAL té un patró d'escriptura molt diferent del de les dades i, sobretot, si s'omple el volum de dades per culpa del WAL, PostgreSQL s'atura. Separar-los converteix un incident de severitat alta en un de lleu.
  • podAntiAffinityType: required. Amb preferred, un clúster atapeït pot col·locar dues instàncies al mateix node i l'alta disponibilitat esdevé fictícia.
  • inheritFromIAMRole: true. Cap clau d'accés desada enlloc: la ServiceAccount del pod té un rol del núvol associat. És la continuació directa del que vam veure a 08-01 i 10-06.
  • retentionPolicy: "30d". Aquest valor l'ha de validar el responsable de compliment normatiu: retenir massa poc incompleix obligacions comptables i de continuïtat; retenir massa, amb dades personals a dins, incompleix el principi de limitació del termini de conservació.

3.2. La còpia programada

El backup del Cluster defineix on i com; cal a més dir quan es pren la còpia base.

apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
  name: postgres-reserves-diaria
  namespace: rutas-norte-pro
spec:
  # Format amb segons: 03:15 cada dia (hora del clúster, UTC).
  schedule: "0 15 3 * * *"
  backupOwnerReference: self
  cluster:
    name: postgres-reserves
  method: barmanObjectStore

Amb la còpia base diària i l'arxivat continu del WAL, la finestra de pèrdua de dades és de segons, no d'un dia: es restaura la còpia base més recent i es reprodueixen els WAL fins a l'instant desitjat. Això és la recuperació a un instant concret de l'apartat 6.

3.3. Com tria el primari l'operador i quins Services publica

L'operador manté un pod com a primari i n'anota la identitat a l'estat del Cluster. Quan el primari deixa de respondre a les comprovacions durant més de failoverDelay, l'operador tria la rèplica amb el WAL més avançat, la promociona i reconfigura les altres perquè segueixin la nova. Tot això sense que cap manifest canviï.

Els Services que crea automàticament:

Service A qui apunta Ús a Rutas Norte
postgres-reserves-rw Sempre al primari actual Escriptures d'api-reserves
postgres-reserves-ro Només a rèpliques Consultes d'informes-ocupacio
postgres-reserves-r A qualsevol instància Diagnòstic; poc utilitzat
kubectl -n rutas-norte-pro get cluster postgres-reserves
NAME                AGE    INSTANCES   READY   STATUS                     PRIMARY
postgres-reserves   214d   3           3       Cluster in healthy state   postgres-reserves-1

  1. Com es connecta api-reserves: l'agrupador de connexions

PostgreSQL crea un procés per connexió. Amb max_connections: 200 i un HPA que durant el pont de maig porta api-reserves a 40 rèpliques amb un pool de 20 connexions cadascuna, l'aritmètica és demolidora: 800 connexions sol·licitades contra 200 de disponibles. El resultat no és lentitud, és rebuig de connexions i errors 500 en la venda de bitllets.

La solució és un agrupador de connexions (PgBouncer) davant de la base de dades, que CloudNativePG gestiona com a recurs propi:

apiVersion: postgresql.cnpg.io/v1
kind: Pooler
metadata:
  name: postgres-reserves-pooler-rw
  namespace: rutas-norte-pro
spec:
  cluster:
    name: postgres-reserves
  instances: 3          # el pooler també ha de ser redundant
  type: rw              # apunta al primari actual, i el segueix en les commutacions
  pgbouncer:
    poolMode: transaction    # retorna la connexió en acabar cada transacció
    parameters:
      max_client_conn: "1000"     # el que acceptem dels pods
      default_pool_size: "40"     # el que obrim realment 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-reserves-pooler-rw

Mil connexions d'aplicació es multiplexen sobre quaranta de reals. Per això el ConfigMap d'api-reserves de la lliçó anterior apunta a postgres-reserves-pooler-rw i no directament al Service -rw.

Preu a pagar: poolMode: transaction és incompatible amb funcionalitats lligades a la sessió (sentències preparades amb nom en alguns controladors, LISTEN/NOTIFY, taules temporals de sessió, SET persistent). A api-reserves es va comprovar que no se'n fa servir cap. Si se'n fes servir alguna, l'alternativa és session, que redueix molt l'avantatge de l'agrupador.

  1. Commutació per error: simular-la i mesurar-la

Un mecanisme d'alta disponibilitat que mai no s'ha provat és una hipòtesi. Aquesta és la prova que Rutas Norte executa a rutas-norte-pre abans de cada temporada alta.

5.1. L'experiment

# 1. Estat inicial: qui és el primari.
kubectl -n rutas-norte-pre get cluster postgres-reserves -o jsonpath='{.status.currentPrimary}{"\n"}'

# 2. Càrrega sostinguda d'escriptura mentre dura l'experiment.
kubectl -n rutas-norte-pre run carrega-escriptura --rm -it --restart=Never \
  --image=registry.rutasnorte.example/utils/pgbench:16 -- \
  pgbench -h postgres-reserves-pooler-rw -U app_reserves -c 10 -T 180 -P 5 reserves &

# 3. Matem el primari de cop (no un esborrat ordenat: simulem pèrdua de node).
kubectl -n rutas-norte-pre delete pod postgres-reserves-1 --grace-period=0 --force

# 4. Observem la promoció segon a segon.
kubectl -n rutas-norte-pre get cluster postgres-reserves -w \
  -o custom-columns='PRIMARI:.status.currentPrimary,LLESTES:.status.readyInstances,ESTAT:.status.phase'
PRIMARI               LLESTES  ESTAT
postgres-reserves-1   3        Cluster in healthy state
postgres-reserves-1   2        Failing over
postgres-reserves-2   2        Failing over
postgres-reserves-2   2        Cluster in healthy state
postgres-reserves-2   3        Cluster in healthy state

5.2. Els números mesurats

Resultats de l'últim simulacre a rutas-norte-pre (tres repeticions, valors mitjans):

Fase Temps
Detecció de la caiguda del primari 4 s
Promoció de la rèplica més avançada 6 s
Actualització del Service -rw a la nova IP 2 s
Reconnexió del pooler 3 s
Tall total d'escriptures percebut ≈ 15 s
Reconstrucció de la instància caiguda com a rèplica 4 min
Pèrdua de dades (transaccions confirmades) 0

Quinze segons sense poder escriure. Si api-reserves no fa res al respecte, aquests quinze segons són quinze segons d'errors 500 en la compra de bitllets, i en ple pont de maig això són uns quants centenars de vendes perdudes.

5.3. Què ha de fer api-reserves per sobreviure a aquests segons

Tres mecanismes, i l'ordre importa.

Temps d'espera acotats. Sense temps d'espera, una connexió a un primari mort es queda penjada fins al temps d'espera del sistema operatiu (minuts). Amb ell, falla ràpid i es pot reintentar.

const pool = new Pg.Pool({
  host: process.env.PG_HOST,
  max: Number(process.env.PG_POOL_MAX),
  connectionTimeoutMillis: 3000,   // aconseguir connexió
  idleTimeoutMillis: 30000,
  statement_timeout: 5000,         // cap consulta no bloqueja un fil indefinidament
  keepAlive: true
});

Reintents amb espera exponencial i aleatorietat, només per al que és idempotent. Reintentar un SELECT és segur. Reintentar INSERT INTO reserves sense més pot duplicar la reserva i cobrar dues vegades al client: cal una clau d'idempotència.

const ERRORS_TRANSITORIS = new Set([
  'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT',
  '57P01', // admin_shutdown: el primari s'està apagant
  '57P03', // cannot_connect_now: arrencant
  '40001'  // serialization_failure
]);

async function ambReintents(operacio, { intents = 4, baseMs = 200 } = {}) {
  let ultim;
  for (let i = 0; i < intents; i++) {
    try {
      return await operacio();
    } catch (e) {
      ultim = e;
      const transitori = ERRORS_TRANSITORIS.has(e.code);
      if (!transitori || i === intents - 1) throw e;
      // Espera exponencial amb aleatorietat: evita que 40 rèpliques
      // reintentin totes al mateix mil·lisegon.
      const espera = baseMs * 2 ** i * (0.5 + Math.random());
      log.warn({ intent: i + 1, codi: e.code, espera }, 'reintentant');
      await new Promise((r) => setTimeout(r, espera));
    }
  }
  throw ultim;
}

Amb quatre intents i base de 200 ms, la finestra coberta ronda els 4-6 segons. No cobreix els quinze del tall complet, i això és deliberat: allargar-la més faria que les peticions s'acumulessin al servidor fins a esgotar la memòria.

Circuit per al que no es pot reintentar. Quan les fallades superen un llindar, el circuit s'obre i l'API deixa d'intentar-ho durant uns segons, i retorna una resposta degradada i immediata en lloc d'acumular peticions penjades.

// Degradació honesta: la consulta d'horaris se serveix des de redis-cache,
// la compra retorna 503 amb Retry-After en lloc de penjar-se.
app.post('/reserves', async (req, res) => {
  if (circuit.obert()) {
    return res.status(503)
      .set('Retry-After', '10')
      .json({ error: 'servei_temporalment_no_disponible' });
  }
  ...
});

La combinació dels tres redueix l'impacte real de la commutació a uns pocs segons de degradació parcial, amb la venda recuperant-se sola. És la diferència entre un incident de severitat 1 i una nota al registre.

  1. Còpies de seguretat i recuperació a un instant concret

6.1. Les tres preguntes que defineixen la política

Pregunta Concepte Valor a Rutas Norte
Quantes dades podem perdre? Objectiu de punt de recuperació (RPO) 5 minuts
Quant de temps podem estar caiguts? Objectiu de temps de recuperació (RTO) 1 hora
Quant de temps guardem les còpies? Retenció 30 dies (pendent de validació de compliment)

Amb arxivat continu del WAL, l'RPO real és de segons. L'RTO és el que cal mesurar, i només es mesura restaurant.

6.2. Restauració a un instant concret

CloudNativePG restaura creant un Cluster nou a partir del magatzem d'objectes. Mai no es restaura "a sobre" del clúster existent: se n'aixeca un de paral·lel, es verifica i després es decideix.

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: postgres-reserves-restaurat
  namespace: rutas-norte-pro
spec:
  instances: 1                   # per verificar n'hi ha prou amb una instància
  imageName: ghcr.io/cloudnative-pg/postgresql:16.4
  storage:
    size: 200Gi
    storageClass: rutasnorte-rapida
  bootstrap:
    recovery:
      source: copia-origen
      recoveryTarget:
        # Just abans de l'esborrat accidental de les 11:47.
        targetTime: "2026-08-06 11:45:00.000000+00:00"
  externalClusters:
    - name: copia-origen
      barmanObjectStore:
        destinationPath: s3://rutasnorte-copies-pro/postgres-reserves
        serverName: postgres-reserves
        s3Credentials:
          inheritFromIAMRole: true
        wal:
          maxParallel: 8         # paral·lelisme alt: accelera la reproducció del WAL
kubectl apply -f restauracio.yaml
kubectl -n rutas-norte-pro get cluster postgres-reserves-restaurat -w
NAME                           INSTANCES   READY   STATUS
postgres-reserves-restaurat    1           0       Setting up primary
postgres-reserves-restaurat    1           0       Recovering from backup
postgres-reserves-restaurat    1           1       Cluster in healthy state

Verificació abans de donar per bona la restauració:

kubectl -n rutas-norte-pro exec -it postgres-reserves-restaurat-1 -- \
  psql -U postgres reserves -c \
  "SELECT count(*) AS reserves, max(creada_el) AS ultima FROM reserves;"
 reserves |            ultima
----------+-------------------------------
   418732 | 2026-08-06 11:44:58.221+00

6.3. El temps real, mesurat

Simulacre del 12 de juliol de 2026 sobre una còpia de 118 GB:

Fase Temps
Decidir l'instant objectiu i redactar el manifest 6 min
Aprovisionament del volum i descàrrega de la còpia base 21 min
Reproducció dels WAL fins a l'instant objectiu 9 min
Verificació d'integritat i recompte 4 min
Commutar api-reserves al clúster restaurat 3 min
Total 43 min

Quaranta-tres minuts, dins de l'RTO d'una hora, però amb poc marge. Dues accions van sortir del simulacre: pujar maxParallel de 4 a 8 en la recuperació (ja aplicat més amunt) i tenir el manifest de restauració escrit i versionat al repositori, amb l'instant objectiu com a únic paràmetre a emplenar. Els sis minuts de "redactar el manifest" sota pressió són el pitjor lloc per improvisar.

Una còpia que no s'ha restaurat mai no existeix. És una afirmació literal, no una figura retòrica. Els modes de fallada reals són mundans: la credencial del magatzem va caducar fa quatre mesos i ningú no va mirar l'alerta; el bucket tenia una política de cicle de vida que movia els objectes a emmagatzematge en fred amb hores de latència de recuperació; la còpia es feia però d'una base de dades que ja no era la de producció. Tots es detecten restaurant, i cap no es detecta mirant que el ScheduledBackup estigui en verd.

La regla de Rutas Norte: restauració completa cronometrada cada trimestre, amb acta i amb el temps apuntat. És al calendari de manteniment d'11-06.

6.4. Vigilar que les còpies es fan

- alert: CopiaPostgresAntiga
  expr: |
    time() - cnpg_collector_last_available_backup_timestamp{cluster="postgres-reserves"} > 36 * 3600
  for: 15m
  labels: { severitat: critica, equip: plataforma }
  annotations:
    resum: "Sense còpia vàlida de postgres-reserves en més de 36 hores"
    runbook: "https://wiki.rutasnorte.example/runbooks/copia-postgres-fallida"

  1. Actualització de versió major de PostgreSQL

Les actualitzacions menors (16.4 → 16.6) les fa l'operador tot sol: canvies imageName, actualitza rèpliques, commuta i actualitza l'antic primari. Tall de segons.

Les majors (16 → 17) són una altra cosa: canvia el format del directori de dades, la replicació en flux no funciona entre versions diferents i la reversió no és trivial. Hi ha tres camins.

Mètode Tall Risc Reversió Quan fer-lo servir
Bolcat i restauració (pg_dump/pg_restore) Hores per a 120 GB Baix Fàcil: l'original continua intacte Bases petites o finestra àmplia
pg_upgrade al mateix lloc 5-15 min Mitjà Difícil un cop convertit Finestra curta i equip amb experiència
Replicació lògica a un clúster nou 1-2 min Baix-mitjà Fàcil fins al tall Quan el tall ha de ser mínim

Rutas Norte va triar la replicació lògica. El procediment, resumit:

# Clúster destí en 17, poblat per importació amb replicació lògica.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: postgres-reserves-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: ["reserves"]
        source:
          externalCluster: origen-16
  externalClusters:
    - name: origen-16
      connectionParameters:
        host: postgres-reserves-rw.rutas-norte-pro.svc.cluster.local
        user: postgres
        dbname: reserves
      password:
        name: postgres-reserves-superuser
        key: password

Seqüència del dia del canvi:

  1. Setmanes abans: aixecar el clúster 17 a rutas-norte-pre, executar el conjunt complet de proves d'api-reserves contra ell i comparar plans d'execució de les deu consultes més costoses. Un canvi de pla al planificador és el risc real d'una actualització major.
  2. Dies abans: aixecar el 17 a pro i deixar-lo sincronitzant per replicació lògica fins que el retard sigui de mil·lisegons.
  3. Finestra de tall (uns 90 s): posar api-reserves en mode de només lectura mitjançant una bandera de funcionalitat, confirmar retard zero, aturar la subscripció, apuntar el ConfigMap al pooler del clúster 17, reiniciar el desplegament i treure el mode de només lectura.
  4. Després: vigilar durant 48 hores. El clúster 16 es conserva una setmana apagat però intacte, com a pla de reversió.

Un detall que s'oblida sempre: ANALYZE complet després de la migració. Les estadístiques del planificador no es transfereixen, i sense elles les consultes van lentes durant hores i sembla que l'actualització ha anat malament.

  1. Rèpliques de lectura per a informes-ocupacio

informes-ocupacio és el CronJob nocturn que recorre mesos de reserves per calcular ocupació per línia i per franja. Són consultes pesades que, executades contra el primari, competeixen amb la venda de bitllets.

La solució és directa: apuntar-lo al Service -ro, que només enruta a rèpliques.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: informes-ocupacio
  namespace: rutas-norte-pro
spec:
  schedule: "0 3 * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          restartPolicy: OnFailure
          serviceAccountName: informes-ocupacio
          containers:
            - name: informes
              image: registry.rutasnorte.example/rutasnorte/informes@sha256:c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071829304152
              env:
                - name: PG_HOST
                  value: postgres-reserves-ro    # rèpliques, mai el primari
                - name: PG_OPTIONS
                  # Tolera fins a 5 min de retard de replicació en lloc de
                  # cancel·lar la consulta si el WAL entrant entra en conflicte.
                  value: "-c statement_timeout=1800000"
              resources:
                requests: { cpu: 500m, memory: 1Gi }
                limits: { memory: 2Gi }

Dos advertiments sobre les rèpliques de lectura:

  • Coherència eventual. Una rèplica va uns mil·lisegons per darrere. Per a informes és irrellevant; per a "acabo de reservar i no veig la meva reserva" és un error visible per a l'usuari. Regla a Rutas Norte: el que l'usuari acaba d'escriure es llegeix del primari; tota la resta pot anar a rèplica.
  • Conflictes de recuperació. Una consulta llarga a la rèplica pot entrar en conflicte amb el WAL que arriba i ser cancel·lada. S'ajusta amb max_standby_streaming_delay, acceptant a canvi més retard de replicació durant els informes.

  1. redis-cache: quan perdre la dada és acceptable

Redis és també una càrrega amb estat, però d'una categoria diferent: la seva dada és reconstruïble. Aquesta diferència ho canvia tot.

A Rutas Norte, redis-cache desa tres coses:

Dada És reconstruïble? Conseqüència de perdre-la
Memòria cau d'horaris i preus Sí, des de PostgreSQL Pic de càrrega a la base de dades durant uns minuts
Carretó de la compra (TTL 15 min) No L'usuari perd la selecció i l'ha de repetir
Comptadors de limitació de peticions Sí, es refan sols Finestra breu sense limitació efectiva

El carretó és el cas incòmode: no és crític com una reserva confirmada, però perdre'l és visible i molest.

Decisió de Rutas Norte: persistència AOF activada amb appendfsync everysec, un sol node amb volum persistent i sense rèplica. El raonament:

  • Sense persistència, cada reinici de pod (una actualització de node, un canvi de configuració) buida tots els carretons actius. Passa diverses vegades al mes.
  • Amb AOF cada segon, un reinici ordenat perd com a màxim un segon d'escriptures i els carretons sobreviuen.
  • Muntar Redis Sentinel o un clúster per a una dada amb TTL de quinze minuts és complexitat sense retorn.
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 desallotjaria carretons en omplir-se. volatile-lru només
            # desallotja claus amb TTL, que són precisament les de memòria cau.
            - 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: dades, mountPath: /data }
  volumeClaimTemplates:
    - metadata: { name: dades }
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: rutasnorte-estandar   # no necessita IOPS altes
        resources: { requests: { storage: 10Gi } }

I, sobretot, l'aplicació assumeix que Redis pot no ser-hi: si la memòria cau no respon, api-reserves va a PostgreSQL; si el carretó no hi és, es demana a l'usuari que repeteixi la selecció amb un missatge clar. Cap crida a Redis no pot tombar una petició.

async function horarisDe(linia, data) {
  try {
    const cacheat = await redis.get(`horaris:${linia}:${data}`);
    if (cacheat) return JSON.parse(cacheat);
  } catch (e) {
    log.warn({ err: e.message }, 'redis no disponible, es consulta la base de dades');
  }
  const files = await consultarHoraris(linia, data);
  redis.setex(`horaris:${linia}:${data}`, 300, JSON.stringify(files)).catch(() => {});
  return files;
}

Una cura addicional: si Redis cau durant el pont de maig, tota la càrrega de lectura passa de cop a PostgreSQL. Cal dimensionar la base de dades per sobreviure a aquest escenari, o la fallada d'un component prescindible es converteix en la caiguda d'un que no ho és.

  1. Llista de comprovació d'una càrrega amb estat en producció

# Comprovació Estat a Rutas Norte
1 Decisió documentada de gestionada / operador / artesanal, amb les seves raons ✔ Acta de plataforma
2 Es fa servir un operador madur, no un StatefulSet propi ✔ CloudNativePG
3 Mínim 3 instàncies, repartides entre zones amb antiafinitat required
4 Volums amb reclaimPolicy: Retain i classe adequada rutasnorte-rapida
5 WAL en volum separat ✔ 50 Gi
6 Còpies automàtiques a un magatzem fora del clúster ✔ Magatzem d'objectes
7 Arxivat continu de WAL per a recuperació a un instant ✔ RPO real de segons
8 Restauració completa provada i cronometrada aquest trimestre ✔ 12/07/2026, 43 min
9 Alerta si no hi ha còpia vàlida recent CopiaPostgresAntiga
10 Commutació per error simulada i mesurada ✔ ≈ 15 s, 0 pèrdua
11 L'aplicació té temps d'espera, reintents i circuit
12 Agrupador de connexions dimensionat per al pic de l'HPA ✔ 1000 → 40
13 Lectures pesades dirigides a rèpliques informes-ocupacio
14 Procediment d'actualització major escrit i provat a pre ✔ Replicació lògica
15 Mètriques i panells específics de la base de dades ✔ PodMonitor + panell
16 Xifratge en repòs i en trànsit ✔ Volums xifrats, hostssl
17 Accés a la dada amb RBAC mínim i auditat ✔ Revisió trimestral
18 Revisió de compliment normatiu sobre retenció, ubicació i accés ⧗ Pendent de renovació anual

La fila 18 no és burocràcia: en un clúster de PostgreSQL amb dades personals, la retenció de còpies, la regió del magatzem d'objectes, qui pot obrir una psql contra producció i què en queda registrat són decisions amb conseqüències legals, no només tècniques.

Errors Comuns i Consells

  • Muntar la base de dades a Kubernetes per coherència estètica. Si ningú de l'equip no sap fer una recuperació a un instant concret ni està disposat a estar de guàrdia, la resposta correcta és la base de dades gestionada, encara que costi més.
  • Confiar en el ScheduledBackup en verd. L'indicador diu que el procés s'ha executat, no que la còpia sigui restaurable. Només la restauració cronometrada ho demostra.
  • Còpies al mateix clúster o al mateix compte que produeix la dada. Un esborrat accidental amb permisos amplis, o un compromís de credencials, s'endú la dada i la còpia alhora. Magatzem separat i, si és possible, compte separat amb immutabilitat.
  • Connectar l'aplicació al Service -rw sense agrupador. Funciona perfectament fins que l'HPA escala, i llavors falla justament el dia de més vendes.
  • Reintentar escriptures no idempotents. Duplica reserves i cobraments. Cada escriptura reintentable necessita una clau d'idempotència.
  • Fer servir allkeys-lru en un Redis que desa carretons. En omplir-se la memòria desallotja carretons actius. volatile-lru amb TTL només en el que és memòria cau protegeix la dada que sí que importa.
  • Consell: escriu el manifest de restauració abans de necessitar-lo i desa'l al repositori amb l'instant objectiu com a buit per emplenar. Estalvia els minuts més cars de l'incident.
  • Consell: fes ANALYZE després de qualsevol migració o actualització major. Sense estadístiques, el planificador pren decisions dolentes i tot sembla trencat.
  • Consell: dimensiona la base de dades per a l'escenari en què la memòria cau no hi és. Si no, una fallada prescindible es converteix en una de crítica.

Exercicis

Exercici 1: triar l'opció correcta

Rutas Norte llançarà un producte nou, rutas-carga, amb la seva pròpia base de dades PostgreSQL d'uns 8 GB, tres desenvolupadors, sense equip de plataforma dedicat i amb llançament previst en sis setmanes. Les dades inclouen informació de contacte d'empreses client. Recomana una de les tres opcions i justifica la decisió amb almenys quatre criteris de la taula de l'apartat 1. Indica també quina revisió addicional cal per les dades de contacte.

Exercici 2: dimensionar l'agrupador de connexions

api-reserves té un HPA amb maxReplicas: 40 i cada rèplica obre fins a 20 connexions. informes-ocupacio obre 5 connexions contra les rèpliques. postgres-reservesmax_connections: 200, de les quals PostgreSQL en reserva 3 per a superusuari. Calcula les connexions màximes sol·licitades sense agrupador, explica què passa al pic del pont de maig i comprova si max_client_conn: 1000 i default_pool_size: 40 són valors adequats.

Exercici 3: diagnosticar un simulacre de restauració fallit

Durant el simulacre trimestral, el Cluster de restauració es queda així:

NAME                          INSTANCES   READY   STATUS
postgres-reserves-restaurat   1           0       Recovering from backup

$ kubectl -n rutas-norte-pro logs postgres-reserves-restaurat-1 | tail -3
ERROR: WAL segment 000000010000004A000000E1 not found in archive
FATAL: could not receive data from WAL stream

Enumera tres causes possibles, digues com distingir-les i quina correcció aplicaries en cada cas. Indica a més quina implicació té això sobre l'RPO real.

Solucions

Solució 1. Recomanació: base de dades gestionada del proveïdor. Criteris: (a) cost de personal, no hi ha equip de plataforma i l'operatiu de l'operador requeriria tres desenvolupadors que han de construir el producte; (b) temps fins a producció, sis setmanes no donen marge per adquirir maduresa operativa amb CloudNativePG; (c) risc de fallada catastròfica, sense guàrdia establerta una fallada nocturna queda sense resposta; (d) cost d'infraestructura, la prima del proveïdor sobre 8 GB és petita en termes absoluts, molt diferent del cas de postgres-reserves amb 120 GB. El StatefulSet artesanal queda descartat d'entrada. Revisió addicional: les dades de contacte d'empreses client són dades personals de persones físiques de contacte, per la qual cosa l'elecció de regió, la retenció de còpies i les condicions de l'encarregat del tractament (el proveïdor del núvol) han de ser revisades pel responsable de compliment normatiu abans de contractar.

Solució 2. Sense agrupador: 40 × 20 = 800 connexions d'api-reserves, més 5 d'informes = 805 de sol·licitades davant de 197 d'utilitzables. Al pic del pont de maig, tan bon punt se superen les 197, PostgreSQL rebutja amb FATAL: sorry, too many clients already; els pods afectats fallen les seves sondes de preparació, surten del balanceig, l'HPA veu més càrrega als restants i escala més, i empitjora el problema: un cicle de realimentació destructiu. Amb el pooler: max_client_conn: 1000 cobreix les 805 sol·licitades amb marge (adequat); default_pool_size: 40 més reserve_pool_size: 10 obre com a màxim 50 connexions reals contra el primari, folgadament dins de 197, i deixa lloc per a informes, manteniment i superusuari. Tots dos valors són correctes. Convé vigilar la mètrica de temps d'espera en cua de PgBouncer: si creix durant el pic, el coll d'ampolla passa a ser default_pool_size i caldria pujar-lo (amb marge fins a unes 150 connexions reals).

Solució 3. Causes possibles: (a) retenció insuficient, els WAL d'aquell període ja s'han eliminat per la política de 30 dies o per una regla de cicle de vida del bucket; es distingeix llistant el prefix wals/ al magatzem i comprovant si el segment existeix; correcció: triar un instant objectiu dins del rang disponible i revisar la política de retenció i les regles de cicle de vida. (b) Fallada de l'arxivat continu, el primari va deixar d'arxivar WAL en algun moment (credencial caducada, permisos, disc ple) i hi ha un buit; es distingeix mirant cnpg_collector_last_failed_archive_time i els registres del primari; correcció: reparar l'arxivat i, molt important, prendre immediatament una còpia base nova. (c) Permisos o ruta incorrectes a la restauració, el serverName o el destinationPath no coincideixen amb els de l'origen, o el rol federat no té lectura sobre aquest prefix; es distingeix perquè fallarien tots els segments, no un de concret; correcció: ajustar serverName/destinationPath i els permisos del rol. Implicació sobre l'RPO: si l'arxivat té buits, l'RPO declarat de 5 minuts és fals, i el real és l'antiguitat de l'última còpia base completa, que pot ser de gairebé 24 hores. És exactament la raó per la qual existeix l'alerta CopiaPostgresAntiga i per la qual el simulacre trimestral és obligatori.

Conclusió

Hem resolt de principi a fi l'escenari d'una càrrega amb estat en producció. Vam començar per la pregunta que no convé esquivar —si la base de dades ha d'estar a Kubernetes— i vam arribar a una decisió raonada i amb condicions: a Rutas Norte es queda dins del clúster amb CloudNativePG, amb còpies fora i restauració provada cada trimestre. Vam veure què fa especial l'estat (identitat, ordre, dada irreemplaçable, actualitzacions que no admeten RollingUpdate), el recurs Cluster complet amb les seves decisions justificades, l'agrupador de connexions que evita que l'èxit de l'HPA tombi la base de dades, la commutació per error mesurada en quinze segons i els tres mecanismes que permeten a api-reserves travessar-los sense errors visibles, la recuperació a un instant concret amb el seu temps real de 43 minuts, l'actualització de versió major per replicació lògica, les rèpliques de lectura per als informes i redis-cache com el cas en què perdre la dada és acceptable, sempre que l'aplicació ho assumeixi.

La idea que resumeix la lliçó: a les càrregues amb estat, el que et salva no són els manifests, sinó els procediments provats. La còpia que mai no es va restaurar, la commutació que mai no es va simular i l'actualització que mai no es va assajar a pre són deute esperant a vèncer en el pitjor moment.

Ja tenim l'aplicació sense estat i la base de dades en producció. El que no hem explicat encara és com hi arriba el codi. A la lliçó següent, CI/CD amb Kubernetes, seguim el camí complet des del git push d'un desenvolupador fins al pod que atén peticions a rutas-norte-pro, passant per la construcció de la imatge, el seu escaneig i signatura, les proves contra un clúster efímer i la promoció entre entorns amb Argo CD.

Curs de Kubernetes

Mòdul 1: Introducció a Kubernetes

Mòdul 2: Components Principals de Kubernetes

Mòdul 3: Gestió de Configuració i Secrets

Mòdul 4: Xarxes a Kubernetes

Mòdul 5: Emmagatzematge a Kubernetes

Mòdul 6: Conceptes Avançats de Kubernetes

Mòdul 7: Monitoratge i Registre

Mòdul 8: Seguretat a Kubernetes

Mòdul 9: Escalat i Rendiment

Mòdul 10: Ecosistema i Eines de Kubernetes

Mòdul 11: Estudis de Cas i Aplicacions del Món Real

Mòdul 12: Preparació per a la Certificació de Kubernetes

© Copyright 2026. Tots els drets reservats