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
- La pregunta prèvia: ha de viure la base de dades a Kubernetes?
- Què té d'especial una càrrega amb estat
postgres-reservesamb CloudNativePG- Com es connecta
api-reserves: l'agrupador de connexions - Commutació per error: simular-la i mesurar-la
- Còpies de seguretat i recuperació a un instant concret
- Actualització de versió major de PostgreSQL
- Rèpliques de lectura per a
informes-ocupacio redis-cache: quan perdre la dada és acceptable- Llista de comprovació d'una càrrega amb estat en producció
- 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.
- 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, noDelete(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.
postgres-reserves amb CloudNativePG
postgres-reserves amb CloudNativePGCloudNativePG é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: 2Les decisions que convé entendre:
instances: 3i 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.walStorageseparat. 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. Ambpreferred, 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: barmanObjectStoreAmb 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 |
NAME AGE INSTANCES READY STATUS PRIMARY
postgres-reserves 214d 3 3 Cluster in healthy state postgres-reserves-1
- Com es connecta
api-reserves: l'agrupador de connexions
api-reserves: l'agrupador de connexionsPostgreSQL 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-rwMil 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.
- 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 state5.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.
- 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 WALkubectl apply -f restauracio.yaml
kubectl -n rutas-norte-pro get cluster postgres-reserves-restaurat -wNAME 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 stateVerificació 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;"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
ScheduledBackupestigui 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"
- 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: passwordSeqüència del dia del canvi:
- Setmanes abans: aixecar el clúster 17 a
rutas-norte-pre, executar el conjunt complet de proves d'api-reservescontra 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. - Dies abans: aixecar el 17 a
proi deixar-lo sincronitzant per replicació lògica fins que el retard sigui de mil·lisegons. - Finestra de tall (uns 90 s): posar
api-reservesen 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. - 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.
- Rèpliques de lectura per a
informes-ocupacio
informes-ocupacioinformes-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.
redis-cache: quan perdre la dada és acceptable
redis-cache: quan perdre la dada és acceptableRedis é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.
- 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
ScheduledBackupen 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
-rwsense 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-lruen un Redis que desa carretons. En omplir-se la memòria desallotja carretons actius.volatile-lruamb 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
ANALYZEdespré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-reserves té max_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 streamEnumera 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
- Què és Kubernetes?
- Arquitectura de Kubernetes
- Conceptes i Terminologia Clau
- Configuració d'un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objectes, Manifests YAML i el Model Declaratiu
- El Projecte del Curs: la Plataforma Rutas Norte
Mòdul 2: Components Principals de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualitzacions, Rollbacks i Estratègies de Desplegament
- Serveis
- Namespaces
- Etiquetes, Selectors i Anotacions
Mòdul 3: Gestió de Configuració i Secrets
- ConfigMaps
- Secrets
- Variables d'Entorn
- Quotes i Límits de Recursos
- LimitRanges i Classes de Qualitat de Servei (QoS)
- ServiceAccounts i Accés a l'API des dels Pods
Mòdul 4: Xarxes a Kubernetes
- Xarxes de Clúster
- Tipus de Serveis
- DNS Intern i Descobriment de Serveis
- Controladors d'Ingress
- TLS i Gestió de Certificats amb cert-manager
- Polítiques de Xarxa
Mòdul 5: Emmagatzematge a Kubernetes
- Volums
- Volums Persistents
- Reclamacions de Volums Persistents
- Classes d'Emmagatzematge
- Aprovisionament Dinàmic, Expansió i Snapshots
- Còpies de Seguretat i Restauració de Dades
Mòdul 6: Conceptes Avançats de Kubernetes
- StatefulSets
- DaemonSets
- Treballs i CronJobs
- Init Containers, Sidecars i Patrons Multicontenidor
- Planificació: Afinitat, Taints i Toleracions
- Definicions de Recursos Personalitzats (CRDs)
- Operadors i el Patró Controlador
Mòdul 7: Monitoratge i Registre
- Verificacions de Salut i Sondes
- Servidor de Mètriques i kubectl top
- Monitoratge amb Prometheus
- Visualització i Alertes amb Grafana i Alertmanager
- Registre Centralitzat amb Elasticsearch, Fluentd i Kibana (EFK)
- Depuració d'Aplicacions i Esdeveniments del Clúster
Mòdul 8: Seguretat a Kubernetes
- Control d'Accés Basat en Rols (RBAC)
- Contextos de Seguretat i Enduriment del Contenidor
- Polítiques de Seguretat de Pods i Pod Security Standards
- Seguretat de Xarxa
- Seguretat d'Imatges
- Auditoria, Escaneig i Gestió de Vulnerabilitats
Mòdul 9: Escalat i Rendiment
- Autoescalat Horitzontal de Pods
- Autoescalat Vertical de Pods
- Autoescalat de Clúster
- Escalat per Esdeveniments i Mètriques Personalitzades amb KEDA
- Alta Disponibilitat: PodDisruptionBudgets i Topologia
- Ajust de Rendiment
Mòdul 10: Ecosistema i Eines de Kubernetes
- Minikube i Entorns Locals amb kind
- Kubeadm
- Helm
- Kustomize
- GitOps amb Argo CD i Flux
- Kubernetes Gestionat: EKS, AKS i GKE
Mòdul 11: Estudis de Cas i Aplicacions del Món Real
- Desplegament d'una Aplicació Web
- Execució d'Aplicacions amb Estat
- CI/CD amb Kubernetes
- Estratègies de Desplegament: Blue-Green i Canary
- Gestió Multi-Clúster
- Operació en Producció: Incidències, Runbooks i Costos
