PostgreSQL 16 fa temps que funciona a srv-tramontana, des del Mòdul 5. Està instal·lat, té el seu apt-mark hold i la seva fixació de versions perquè no salti de versió major sense decidir-ho, escolta a 10.0.2.15:5432 i ufw només ho permet des de 10.0.2.0/24. Tot això està ben fet.

I tanmateix la configuració és la que va portar el paquet, que està calculada per arrencar en qualsevol màquina, inclosa una amb 512 MB de RAM. A srv-tramontana significa que PostgreSQL fa servir 128 MB de memòria compartida dels 3,8 GB disponibles, que ordena en disc el que cabria en memòria, i que es pensa que el sistema té una memòria cau de disc molt menor de la que té — cosa que canvia els plans d'execució que tria.

A 08-01 vas deixar una pista concreta: el p99 de $upstream_response_time és d'1,18 s, i les dues rutes més lentes són informes. Quan el temps de l'aplicació puja i el de la xarxa no, la culpable gairebé mai no és l'aplicació: és una consulta. Aquesta lliçó va a buscar això, i el que hi ha a sota — perquè la base de dades és on viu el negoci real de Tramontana, i perdre-la és un problema d'una altra categoria que perdre el servidor web.

Contingut

  1. Objectiu, requisits previs i estat de partida
  2. Arquitectura de processos i fitxers de PostgreSQL
  3. Ajust de memòria amb fórmules raonades
  4. Pàgines enormes: la connexió amb 07-03
  5. Connexions: per què una agrupació i no un número més gran
  6. Autenticació: pg_hba.conf camp a camp
  7. TLS a les connexions
  8. Rols i permisos amb mínim privilegi
  9. Còpies: lògica, física, WAL i recuperació a un punt en el temps
  10. Manteniment: buidatge, wraparound i reindexació
  11. Diagnòstic: on se'n va el temps
  12. Rèplica en flux
  13. Automatització amb Ansible
  14. Operació diària

Objectiu, requisits previs i estat de partida

Objectiu. Deixar PostgreSQL ajustat a la màquina, amb accés restringit i xifrat, un rol d'aplicació sense privilegis innecessaris, còpies que permetin recuperar a un instant concret dins del RPO de 4 hores, manteniment automàtic verificat i diagnòstic instrumentat.

Requisits previs: PostgreSQL 16 instal·lat (05-03), LVM i /srv/tramontana/backups xifrat amb LUKS (05-04), restic amb retenció GFS (05-08), pass i systemd-creds (06-05), el tallafocs de 06-03, i Ansible operatiu (07-06).

Estat de partida, mesurat abans de tocar res:

$ psql --version
psql (PostgreSQL) 16.3 (Ubuntu 16.3-0ubuntu0.24.04.1)

$ sudo -u postgres psql -c "SELECT name, setting, unit, source FROM pg_settings
   WHERE name IN ('shared_buffers','effective_cache_size','work_mem',
                  'maintenance_work_mem','max_connections','wal_buffers');"
         name         | setting | unit |  source
----------------------+---------+------+----------
 effective_cache_size | 524288  | 8kB  | default
 maintenance_work_mem | 65536   | kB   | default
 max_connections      | 100     |      | default
 shared_buffers       | 16384   | 8kB  | default
 wal_buffers          | 512     | 8kB  | default
 work_mem             | 4096    | kB   | default

Traduït: 128 MB de shared_buffers, 4 GB d'effective_cache_size (que casualment no està malament, però per accident), 4 MB de work_mem i source = default a tot, és a dir, ningú no ha ajustat mai res.

# Mida actual de la base de dades i de les seves taules mes grans
$ sudo -u postgres psql -d tramontana -c "
  SELECT relname, pg_size_pretty(pg_total_relation_size(c.oid)) AS total,
         n_live_tup AS files
  FROM pg_class c JOIN pg_stat_user_tables s ON s.relid = c.oid
  ORDER BY pg_total_relation_size(c.oid) DESC LIMIT 5;"
   relname   |  total  | files
-------------+---------+--------
 reserves    | 412 MB  | 186420
 disponibles | 168 MB  | 891200
 hostes      |  54 MB  |  92310
 cases       |  96 kB  |      5
 auditoria   | 1204 MB | 512880

Dues dades rellevants: la base de dades completa ronda els 1,8 GB, és a dir, hi cap sencera en memòria si es configura bé; i la taula auditoria és la més gran de totes, cosa que ja suggereix una política de retenció pendent.

Arquitectura de processos i fitxers de PostgreSQL

PostgreSQL fa servir un model de procés per connexió, no de fils. Això explica gairebé tot el seu comportament de memòria i és el que fa important l'apartat de l'agrupació de connexions.

$ ps -eo pid,ppid,user,comm --forest | grep -A9 'postgres$' | head -12
   1121       1 postgres postgres
   1210    1121 postgres  \_ postgres: checkpointer
   1211    1121 postgres  \_ postgres: background writer
   1213    1121 postgres  \_ postgres: walwriter
   1214    1121 postgres  \_ postgres: autovacuum launcher
   1215    1121 postgres  \_ postgres: logical replication launcher
   3402    1121 postgres  \_ postgres: svc_tramontana tramontana 127.0.0.1(51422) idle
   3403    1121 postgres  \_ postgres: svc_tramontana tramontana 127.0.0.1(51424) SELECT
Procés Què fa Per què t'importa
postmaster Procés pare; accepta connexions i llança backends Si mor, tot cau
backend Un per connexió; executa les consultes Cadascun consumeix memòria pròpia: és el motiu de l'agrupació
checkpointer Aboca pàgines brutes a disc periòdicament Un checkpoint agressiu produeix pics d'E/S
background writer Va escrivint pàgines brutes a poc a poc Suavitza els pics del checkpointer
walwriter Escriu el registre d'escriptura anticipada És el que garanteix la durabilitat
autovacuum launcher Llança treballadors de neteja Sense ell, la base de dades es degrada sola

I on viu cada cosa a Ubuntu, que segueix el disseny de Debian amb diversos clusters possibles:

$ sudo -u postgres psql -c "SHOW data_directory; SHOW config_file; SHOW hba_file;"
      data_directory
---------------------------
 /var/lib/postgresql/16/main
        config_file
-------------------------------------------
 /etc/postgresql/16/main/postgresql.conf
         hba_file
-------------------------------------------
 /etc/postgresql/16/main/pg_hba.conf

$ sudo ls -1 /var/lib/postgresql/16/main/ | head -8
base            # les dades: un subdirectori per base de dades
global          # cataleg compartit entre bases
pg_wal          # el registre d escriptura anticipada
pg_stat         # estadistiques del planificador
pg_tblspc       # enllacos a tablespaces externs
postgresql.auto.conf   # el que escriu ALTER SYSTEM
PG_VERSION
postmaster.pid
Ruta Què és Compte
PGDATA = /var/lib/postgresql/16/main Totes les dades Mai no es toca a mà amb el servidor arrencat
pg_wal/ Registre d'escriptura anticipada Si s'omple, PostgreSQL s'atura. No esborrar mai fitxers a mà
/etc/postgresql/16/main/postgresql.conf Configuració A Debian/Ubuntu viu fora de PGDATA
postgresql.auto.conf El que escriu ALTER SYSTEM Té prioritat sobre postgresql.conf: font de confusió
pg_hba.conf Qui es pot connectar i com S'aplica amb reload, en ordre de dalt a baix

Aquella fila de postgresql.auto.conf mereix un advertiment: si algú va executar alguna vegada ALTER SYSTEM SET work_mem = '64MB', aquell valor guanya encara que editis postgresql.conf. Quan un paràmetre no pren el valor que esperes, pg_settings.source et diu d'on ve.

Seguint la convenció de drop-ins del curs, la configuració pròpia no s'escriu editant el fitxer principal, sinó en un fitxer a part inclòs al final:

$ sudo cp /etc/postgresql/16/main/postgresql.conf \
          /etc/postgresql/16/main/postgresql.conf.bak-$(date +%F)
$ echo "include_dir = 'conf.d'" | sudo tee -a /etc/postgresql/16/main/postgresql.conf
$ sudo -u postgres mkdir -p /etc/postgresql/16/main/conf.d

Ajust de memòria amb fórmules raonades

La màquina té 3,8 GB de RAM i 2 vCPU. Cal repartir aquella memòria entre PostgreSQL, l'aplicació Tramontana, Nginx i el sistema. Aquestes són les fórmules de partida —de partida, no dogmes— i el seu raonament.

Paràmetre Fórmula habitual Valor per a 3,8 GB Què controla
shared_buffers 25 % de la RAM 960 MB Memòria cau de pàgines pròpia de PostgreSQL
effective_cache_size 50-75 % de la RAM 2560 MB El que el planificador es pensa que hi ha a la memòria cau
work_mem RAM ÷ (connexions × 3) 8 MB Memòria per operació d'ordenació o hash
maintenance_work_mem 5-10 % de la RAM 256 MB Per a VACUUM, CREATE INDEX, ALTER TABLE
wal_buffers 1/32 de shared_buffers, màx. 16 MB 16 MB Memòria intermèdia del WAL abans d'escriure'l

I ara el raonament de cadascun, que és el que distingeix ajustar de copiar números:

shared_buffers = 960 MB (25 %). PostgreSQL manté la seva pròpia memòria cau de pàgines a més de la del sistema operatiu. Per això no es posa al 80 % com en altres motors: es produiria un doble emmagatzematge, amb les mateixes pàgines en dos llocs i menys memòria total útil. El 25 % és el punt d'equilibri contrastat empíricament. Amb 1,8 GB de dades, gairebé la meitat de la base de dades viurà permanentment aquí.

effective_cache_size = 2560 MB (67 %). Aquest paràmetre no reserva res: és una pista per al planificador sobre quanta memòria hi ha disponible entre shared_buffers i la memòria cau del sistema. Si el deixes baix, el planificador es pensa que llegir del disc és car i evita els índexs, preferint recorreguts seqüencials. És un dels ajustos amb major impacte per unitat d'esforç, i no costa ni un byte de memòria.

work_mem = 8 MB, i per què és el paràmetre perillós. És memòria per operació, no per connexió. Una consulta amb dues ordenacions i un hash join pot fer servir tres vegades work_mem. Amb 100 connexions i consultes de tres operacions:

Pitjor cas teoric = work_mem x connexions x operacions per consulta
                  = 8 MB x 100 x 3 = 2400 MB

És a dir, més de la meitat de la RAM del servidor, per sobre dels 960 MB de shared_buffers. Aquella aritmètica és la raó per la qual pujar work_mem alegrement provoca que l'OOM killer mati PostgreSQL — el mateix mecanisme que vas veure a 07-02. Amb PgBouncer limitant a 25 connexions reals, el pitjor cas baixa a 600 MB, que sí que és assumible. Primer l'agrupació, després work_mem.

I l'elegant: work_mem es pot pujar només per a la consulta que ho necessita, sense tocar el global.

-- A la sessio de l informe mensual, no a tot el servidor
BEGIN;
SET LOCAL work_mem = '128MB';
SELECT casa, sum(import) FROM reserves WHERE data >= '2026-01-01' GROUP BY casa;
COMMIT;

maintenance_work_mem = 256 MB. Només el fan servir operacions de manteniment, i com a màxim autovacuum_max_workers alhora. Pujar-lo accelera enormement el VACUUM i la creació d'índexs, amb un risc molt menor que work_mem.

El fitxer complet:

# /etc/postgresql/16/main/conf.d/10-tramontana.conf
# Ajust per a srv-tramontana: 3,8 GB RAM, 2 vCPU, SSD, PostgreSQL 16
# Base de referencia presa el 2026-08-18. Mesurar abans i despres.

# ---------- Memoria ----------
shared_buffers = 960MB
effective_cache_size = 2560MB
work_mem = 8MB
maintenance_work_mem = 256MB
wal_buffers = 16MB

# ---------- Connexions ----------
# Es BAIXA de 100 a 60. Amb PgBouncer al davant, 60 sobren, i cada
# connexio reservada costa memoria encara que estigui ociosa.
max_connections = 60
superuser_reserved_connections = 3

# ---------- Registre d escriptura anticipada ----------
wal_level = replica              # necessari per a replica i PITR
max_wal_size = 2GB               # menys checkpoints, pics mes suaus
min_wal_size = 256MB
checkpoint_completion_target = 0.9   # reparteix l escriptura en el temps
archive_mode = on
archive_command = '/usr/local/bin/arxivar_wal.sh %p %f'
archive_timeout = 900s           # forca un segment cada 15 min

# ---------- Planificador (SSD) ----------
# random_page_cost per defecte es 4.0, calibrat per a discos giratoris
# on un salt aleatori costava molt mes que una lectura sequencial.
# En SSD la diferencia es minima: 1.1 reflecteix la realitat i fa que el
# planificador faci servir indexs on toca.
random_page_cost = 1.1
effective_io_concurrency = 200   # el SSD aten moltes peticions alhora

# ---------- Paral·lelisme (2 vCPU: contencio) ----------
max_worker_processes = 2
max_parallel_workers = 2
max_parallel_workers_per_gather = 1

# ---------- Registre ----------
log_destination = 'stderr'
logging_collector = off          # deixa que systemd/journald ho reculli
log_line_prefix = '%m [%p] %q%u@%d '
log_min_duration_statement = 250ms   # registra les consultes lentes
log_checkpoints = on
log_connections = off            # sorollos amb una agrupacio al davant
log_lock_waits = on              # esperes de blocatge: simptoma important
log_temp_files = 0               # qualsevol fitxer temporal = work_mem curt
log_autovacuum_min_duration = 1s

# ---------- Estadistiques ----------
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 5000
pg_stat_statements.track = top
$ sudo -u postgres psql -c "SELECT pg_reload_conf();"
# shared_buffers i shared_preload_libraries requereixen REINICI
$ sudo systemctl restart postgresql@16-main

$ sudo -u postgres psql -c "SELECT name, setting, unit, source, pending_restart
   FROM pg_settings WHERE name IN ('shared_buffers','work_mem','random_page_cost');"
      name       | setting | unit |               source                | pending_restart
-----------------+---------+------+-------------------------------------+-----------------
 random_page_cost| 1.1     |      | configuration file                  | f
 shared_buffers  | 122880  | 8kB  | configuration file                  | f
 work_mem        | 8192    | kB   | configuration file                  | f

La columna pending_restart és la que et diu si un canvi està aplicat o només escrit. source = configuration file confirma que el drop-in s'està llegint.

Mesurar l'efecte, que és la meitat de la feina:

# L index d encerts de la memoria cau compartida. Es mesura ABANS i
# DESPRES, deixant passar unes hores de transit real.
$ sudo -u postgres psql -d tramontana -c "
  SELECT round(100.0 * sum(blks_hit) / nullif(sum(blks_hit + blks_read), 0), 2)
         AS encert_pct
  FROM pg_stat_database WHERE datname = 'tramontana';"
 encert_pct
------------
      99.42

Abans de l'ajust aquell número era 91,80 %. Sembla una millora petita i no ho és: passar del 91,8 % al 99,4 % significa que de cada 100 accessos a pàgina, els que van al disc cauen de 8,2 a 0,6, és a dir, una reducció de gairebé el 93 % de les lectures físiques. Per sota del 95 % cal sospitar; per sota del 90 % hi ha un problema clar de memòria o de consultes que escombren taules senceres.

Pàgines enormes: la connexió amb 07-03

A 07-03 vas desactivar les pàgines enormes transparents (THP) o les vas deixar en madvise, i la lliçó va deixar anotat que PostgreSQL n'era una de les raons. Aquí tens el detall.

El problema no són les pàgines enormes en si, que són útils: amb pàgines de 2 MB en lloc de 4 KB, la TLB del processador cobreix 512 vegades més memòria i les fallades de traducció s'esfondren. El problema és el «transparent»: el nucli intenta fusionar pàgines i desfragmentar memòria de forma síncrona, dins del context del procés que demana memòria. Per a PostgreSQL, amb el seu shared_buffers de gairebé un gigabyte i els seus processos de vida curta, això produeix pauses impredictibles de desenes o centenars de mil·lisegons en consultes que haurien de trigar dos.

$ cat /sys/kernel/mm/transparent_hugepage/enabled
always [madvise] never

$ cat /sys/kernel/mm/transparent_hugepage/defrag
always defer defer+madvise [madvise] never

madvise és exactament la configuració correcta: les pàgines enormes només es fan servir on el programa les demana explícitament amb madvise(MADV_HUGEPAGE), i no s'imposen a tothom.

El millor de tots dos mons és fer servir pàgines enormes explícites per a shared_buffers:

# 1. Quantes en necessita PostgreSQL (arrenca, pregunta i surt)
$ sudo -u postgres /usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/16/main \
      -C shared_memory_size_in_huge_pages
495

# 2. Reservar-les amb marge, de forma persistent
$ echo 'vm.nr_hugepages = 520' | \
      sudo tee /etc/sysctl.d/71-postgresql-hugepages.conf
$ sudo sysctl --system

# 3. Demanar a PostgreSQL que les faci servir
$ echo "huge_pages = try" | \
      sudo tee -a /etc/postgresql/16/main/conf.d/10-tramontana.conf
$ sudo systemctl restart postgresql@16-main

# 4. Verificar
$ grep -E 'HugePages_Total|HugePages_Free' /proc/meminfo
HugePages_Total:     520
HugePages_Free:       25

huge_pages = try i no on: amb on, si les pàgines reservades no basten, PostgreSQL no arrenca. Amb try arrenca igualment fent servir pàgines normals, que és el comportament que vols en un servidor que ha de tornar després d'un reinici sense supervisió.

I l'advertiment important: la memòria de vm.nr_hugepages queda reservada i no disponible per a res més. Reservar 520 pàgines de 2 MB són 1040 MB que la resta del sistema ja no veurà. Amb 3,8 GB cal ser precís, i per això el pas 1 no s'estima: es pregunta.

Connexions: per què una agrupació i no un número més gran

A 07-02 hi va haver un incident que convé recordar amb precisió: l'aplicació té max_connexions=80 a /etc/tramontana/app.conf i PostgreSQL tenia max_connections=100. Sota càrrega, l'aplicació obria les seves 80, més les connexions d'informe_reserves.sh, més les de manteniment, més les tres reservades per a superusuari... i apareixia FATAL: sorry, too many clients already.

La reacció instintiva —pujar max_connections a 300— és exactament l'equivocada, per tres motius mesurables:

Cost de cada connexió Detall
Memòria Un procés backend ronda 5-10 MB de memòria pròpia, ociós o no
work_mem potencial Cada connexió pot reclamar diversos work_mem alhora
Contenció Més processos competint per 2 vCPU: més canvis de context, més contenció de blocatges lleugers

Amb 2 vCPU, el nombre de consultes que es poden executar realment alhora és 2. Tres-centes connexions no donen més feina feta: donen més processos esperant, més memòria consumida i un rendiment que cau en pujar la concurrència. És el mateix fenomen de saturació del mètode USE de 05-07.

La regla de partida és connexions ≈ (2 × nuclis) + fusos de disc efectius, que aquí dona unes 5-10 connexions actives. En necessitem moltes més obertes perquè l'aplicació les manté ocioses entre peticions. Aquí és on entra l'agrupació de connexions (pool).

PgBouncer en mode transacció

$ sudo apt install pgbouncer
$ pgbouncer --version
PgBouncer 1.21.0
Mode Quan torna la connexió a l'agrupació Multiplexació Restriccions
session En desconnectar el client Cap Cap
transaction En acabar cada transacció Alta Sense SET de sessió, sense sentències preparades de sessió, sense LISTEN
statement Després de cada sentència Màxima Prohibeix les transaccions multisentència

El mode transacció és el correcte per a una aplicació web: entre peticions, la connexió torna a l'agrupació i una altra petició l'aprofita. Cent clients d'aplicació poden compartir vint-i-cinc connexions reals perquè gairebé cap no està executant res en un instant donat.

Les restriccions cal verificar-les amb el Luis abans d'activar-lo, perquè són reals: en mode transacció, un SET search_path fora d'una transacció es perd, i les sentències preparades a nivell de sessió fallen llevat que s'activi max_prepared_statements.

; /etc/pgbouncer/pgbouncer.ini
[databases]
; L aplicacio es connecta a PgBouncer creient-se que es PostgreSQL
tramontana = host=127.0.0.1 port=5432 dbname=tramontana

[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
unix_socket_dir = /var/run/postgresql

auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
; Consulta d autenticacio delegada: evita duplicar contrasenyes
auth_user = pgbouncer_auth

pool_mode = transaction

; --- El dimensionament, que es la decisio central ---
; max_client_conn: quants clients d aplicacio acceptem (barats)
max_client_conn = 200
; default_pool_size: connexions REALS a PostgreSQL per parell usuari/bd.
; Amb 2 vCPU, 25 es folgat. Aquest es el numero que protegeix el servidor.
default_pool_size = 25
; Marge temporal per a pics, amb avis al registre
reserve_pool_size = 5
reserve_pool_timeout = 3

; Tancar connexions de servidor ocioses molt de temps
server_idle_timeout = 600
; Reciclar connexions cada hora: evita fuites de memoria acumulades
server_lifetime = 3600
; Si un client espera mes d aixo per una connexio, error clar
query_wait_timeout = 20

logfile = /var/log/postgresql/pgbouncer.log
pidfile = /var/run/postgresql/pgbouncer.pid
admin_users = operador
stats_users = operador, monitor

El canvi a l'aplicació és una línia de /etc/tramontana/app.conf — recordant que el fitxer té chattr +i i cal treure'l abans:

$ sudo chattr -i /etc/tramontana/app.conf
$ sudo cp /etc/tramontana/app.conf /etc/tramontana/app.conf.bak-$(date +%F)
$ sudo sed -i 's/^db_port=5432/db_port=6432/' /etc/tramontana/app.conf
$ sudo diff -u /etc/tramontana/app.conf.bak-$(date +%F) /etc/tramontana/app.conf
--- /etc/tramontana/app.conf.bak-2026-08-18
+++ /etc/tramontana/app.conf
@@ -2,7 +2,7 @@
 db_host=127.0.0.1
-db_port=5432
+db_port=6432
 db_name=tramontana
$ sudo chattr +i /etc/tramontana/app.conf
$ sudo systemctl restart tramontana

I la comprovació que el problema de 07-02 està resolt:

$ psql -h 127.0.0.1 -p 6432 -U operador -d pgbouncer -c "SHOW POOLS;"
  database   |     user      | cl_active | cl_waiting | sv_active | sv_idle | maxwait
-------------+---------------+-----------+------------+-----------+---------+---------
 tramontana  | svc_tramontana|        63 |          0 |         4 |      21 |       0

Seixanta-tres clients d'aplicació connectats, quatre connexions reals treballant i cap esperant. maxwait = 0 és la mètrica clau: així que sigui més gran que zero de forma sostinguda, l'agrupació s'està quedant curta.

Abans Després
80 connexions directes, memòria de 80 processos 25 com a màxim
too many clients already sota càrrega maxwait visible i controlat
max_connections = 100 max_connections = 60, amb folgança real

Autenticació: pg_hba.conf camp a camp

pg_hba.conf (host-based authentication) decideix qui es pot connectar, a què, des d'on i com. S'avalua de dalt a baix i guanya la primera línia que coincideix, cosa que significa que una línia permissiva a dalt anul·la totes les restrictives de sota.

TIPUS     BASE_DE_DADES   USUARI           ADRECA             METODE
Camp Valors Notes
TIPUS local, host, hostssl, hostnossl local = sòcol Unix; hostssl exigeix TLS
BASE_DE_DADES nom, all, replication replication és una pseudobase per a rèpliques
USUARI nom, all, +grup + significa «membre del rol»
ADREÇA CIDR, samenet, buit a local Com més estret, millor
MÈTODE scram-sha-256, peer, cert, trust, reject Veure taula següent
Mètode Com autentica Veredicte
scram-sha-256 Repte-resposta; la contrasenya no viatja El correcte per a connexions de xarxa
md5 Obsolet i feble Migrar a SCRAM
peer Comprova l'usuari Unix del sòcol local Ideal per a tasques locals de manteniment
ident Consulta a un servidor ident remot No fer-lo servir: confia en la màquina remota
cert Certificat de client TLS Excel·lent per a servei a servei
trust Accepta qualsevol sense comprovar res Mai, veure a sota
reject Denega explícitament Útil per tallar abans d'una regla general

Per què trust mai, ni tan sols «temporalment» i ni tan sols a 127.0.0.1: significa que qualsevol procés que pugui obrir un sòcol al port entra com l'usuari que declari ser, inclòs postgres. En un servidor on corre una aplicació web, n'hi ha prou amb una vulnerabilitat de tipus SSRF —aconseguir que l'aplicació faci una petició a 127.0.0.1:5432— per tenir control total de la base de dades. I el que es posa «temporalment» un dimarts continua allà dos anys després.

# /etc/postgresql/16/main/pg_hba.conf
# L ordre IMPORTA: guanya la primera coincidencia.

# --- 1. Manteniment local per socol Unix ---
# 'peer' compara l usuari del sistema amb el rol demanat: 'sudo -u postgres
# psql' funciona sense contrasenya, i ningu mes no pot fer-se passar per ell.
local   all             postgres                                peer
local   all             all                                     peer

# --- 2. PgBouncer i l aplicacio, des del bucle local ---
# Nomes el rol d aplicacio, nomes la seva base de dades, amb SCRAM.
host    tramontana      svc_tramontana  127.0.0.1/32            scram-sha-256
host    tramontana      pgbouncer_auth  127.0.0.1/32            scram-sha-256

# --- 3. Monitoracio (08-06), nomes lectura d estadistiques ---
host    postgres        monitor         127.0.0.1/32            scram-sha-256

# --- 4. Replicacio (apartat 12), amb TLS OBLIGATORI ---
# 'hostssl' rebutja la connexio si no va xifrada; el WAL porta
# les dades completes i no pot viatjar en clar.
hostssl replication     replicador      10.0.2.16/32            scram-sha-256

# --- 5. Acces administratiu des de la xarxa interna, xifrat ---
hostssl tramontana      operador        10.0.2.0/24             scram-sha-256

# --- 6. Denegacio explicita de tota la resta ---
# Redundant (el comportament per defecte ja es denegar) pero deixa
# constancia de la intencio i produeix un missatge clar al registre.
host    all             all             0.0.0.0/0               reject
host    all             all             ::/0                    reject
$ sudo -u postgres psql -c "SELECT pg_reload_conf();"
$ sudo -u postgres psql -c "SELECT line_number, type, database, user_name,
    address, auth_method, error FROM pg_hba_file_rules WHERE error IS NOT NULL;"
(0 rows)

pg_hba_file_rules és una vista que valida el fitxer sense recarregar-lo: et diu si hi ha línies amb errors abans que trenquin l'accés. És l'equivalent de nginx -t per a pg_hba.conf, i cal fer-la servir sempre — «no tanquis mai la porta per la qual estàs entrant» també s'aplica aquí.

I la verificació que les regles fan el que et penses:

# Ha de funcionar
$ PGPASSWORD=$(pass tramontana/db) psql -h 127.0.0.1 -p 5432 \
      -U svc_tramontana -d tramontana -c 'SELECT 1;' >/dev/null && echo OK
OK

# NO ha de funcionar: rol d aplicacio contra una altra base de dades
$ PGPASSWORD=$(pass tramontana/db) psql -h 127.0.0.1 -U svc_tramontana \
      -d postgres -c 'SELECT 1;'
psql: error: FATAL:  no pg_hba.conf entry for host "127.0.0.1", user
"svc_tramontana", database "postgres", no encryption

TLS a les connexions

Amb l'aplicació i PgBouncer a la mateixa màquina, el trànsit va pel bucle local i el xifratge aporta poc. Però la rèplica de 10.0.2.16 i els accessos administratius des de la xarxa interna sí que ho necessiten: el WAL conté totes les dades en clar.

# Ubuntu genera un certificat autosignat i l enllaca en instal·lar
$ sudo ls -l /var/lib/postgresql/16/main/server.crt
lrwxrwxrwx 1 postgres postgres 36 -> /etc/ssl/certs/ssl-cert-snakeoil.pem

Per a trànsit intern entre servidors propis, el correcte no és un certificat de Let's Encrypt —que exigeix un nom públic i validació externa— sinó una CA interna amb les eines d'openssl de 06-05:

# CA interna (es genera una vegada, en una maquina segura, NO al servidor)
$ openssl req -new -x509 -days 3650 -nodes -out ca-tramontana.crt \
      -keyout ca-tramontana.key -subj "/CN=CA interna Tramontana"

# Certificat de servidor per a la base de dades
$ openssl req -new -nodes -out bd.csr -keyout bd.key \
      -subj "/CN=srv-tramontana.intern"
$ openssl x509 -req -in bd.csr -days 825 -CA ca-tramontana.crt \
      -CAkey ca-tramontana.key -CAcreateserial -out bd.crt

$ sudo install -o postgres -g postgres -m 0600 bd.key /etc/postgresql/16/main/
$ sudo install -o postgres -g postgres -m 0644 bd.crt /etc/postgresql/16/main/
# conf.d/20-tls.conf
ssl = on
ssl_cert_file = '/etc/postgresql/16/main/bd.crt'
ssl_key_file  = '/etc/postgresql/16/main/bd.key'
ssl_ca_file   = '/etc/postgresql/16/main/ca-tramontana.crt'
ssl_min_protocol_version = 'TLSv1.2'
ssl_prefer_server_ciphers = on
$ sudo -u postgres psql -c "SELECT pid, ssl, version, cipher, client_addr
    FROM pg_stat_ssl JOIN pg_stat_activity USING (pid) WHERE ssl;"
 pid  | ssl | version |         cipher         | client_addr
------+-----+---------+------------------------+-------------
 8812 | t   | TLSv1.3 | TLS_AES_256_GCM_SHA384 | 10.0.2.16

El detall que la majoria de la gent passa per alt: al client, sslmode=require xifra però no verifica el certificat, així que no protegeix d'un intermediari. Només verify-full comprova la cadena i el nom.

sslmode Xifra Verifica CA Verifica nom
disable No — —
require Sí No No
verify-ca Sí Sí No
verify-full Sí Sí Sí

Rols i permisos amb mínim privilegi

És el mateix principi de 05-01 i 05-02, aplicat dins de la base de dades. Un rol d'aplicació no ha de poder crear taules, alterar l'esquema, llegir altres bases de dades ni, per descomptat, ser SUPERUSER.

-- Executat com a postgres: sudo -u postgres psql -d tramontana

-- 1. Rol d aplicacio: nomes es pot connectar i treballar amb dades
CREATE ROLE svc_tramontana WITH LOGIN
    PASSWORD 's-injecta-des-de-pass'
    NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT
    CONNECTION LIMIT 30;

-- 2. Tancar l esquema public, que a PostgreSQL 15+ ja ve restringit,
--    pero conve ser explicit. Abans de la 15, QUALSEVOL usuari podia
--    crear objectes a 'public'.
REVOKE ALL ON SCHEMA public FROM PUBLIC;
REVOKE ALL ON DATABASE tramontana FROM PUBLIC;

-- 3. Esquema propi de l aplicacio
CREATE SCHEMA IF NOT EXISTS app AUTHORIZATION postgres;

-- 4. Permisos acotats: connectar-se, fer servir l esquema, i DML sobre
--    les taules existents. NO CREATE: no pot alterar l esquema.
GRANT CONNECT ON DATABASE tramontana TO svc_tramontana;
GRANT USAGE ON SCHEMA app TO svc_tramontana;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app
    TO svc_tramontana;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA app TO svc_tramontana;

-- 5. I per a les taules FUTURES, que es el que gairebe tothom oblida:
--    sense aixo, la taula que crei la proxima migracio sera inaccessible
--    per a l aplicacio i el desplegament fallara en produccio.
ALTER DEFAULT PRIVILEGES IN SCHEMA app
    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO svc_tramontana;
ALTER DEFAULT PRIVILEGES IN SCHEMA app
    GRANT USAGE ON SEQUENCES TO svc_tramontana;

-- 6. Rol de nomes lectura per a informes (informe_reserves.sh) i per a
--    la monitoracio de 08-06
CREATE ROLE lector_informes WITH LOGIN PASSWORD 'una-altra-de-diferent'
    NOSUPERUSER NOCREATEDB NOCREATEROLE CONNECTION LIMIT 5;
GRANT CONNECT ON DATABASE tramontana TO lector_informes;
GRANT USAGE ON SCHEMA app TO lector_informes;
GRANT SELECT ON ALL TABLES IN SCHEMA app TO lector_informes;
ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO lector_informes;

-- 7. Rol de monitoracio: rol predefinit, sense ser superusuari
CREATE ROLE monitor WITH LOGIN PASSWORD 'una-altra-mes';
GRANT pg_monitor TO monitor;

-- 8. Rol de replicacio (apartat 12)
CREATE ROLE replicador WITH LOGIN REPLICATION PASSWORD 'i-una-altra';

Verificar és tan important com concedir — comprovar que el prohibit està prohibit:

$ PGPASSWORD=$(pass tramontana/db) psql -h 127.0.0.1 -U svc_tramontana \
      -d tramontana -c "CREATE TABLE prova(id int);"
ERROR:  permission denied for schema app

$ PGPASSWORD=$(pass tramontana/db) psql -h 127.0.0.1 -U svc_tramontana \
      -d tramontana -c "SELECT * FROM pg_shadow;"
ERROR:  permission denied for table pg_shadow

$ sudo -u postgres psql -c "\du" | grep -E 'svc_tramontana|lector'
 lector_informes | 5 connections                      | {}
 svc_tramontana  | No inheritance, 30 connections     | {}

La contrasenya, per descomptat, no s'escriu al SQL: s'injecta des de pass com a 06-05.

$ sudo -u postgres psql -d tramontana <<SQL
ALTER ROLE svc_tramontana PASSWORD '$(pass tramontana/db)';
SQL
# I s esborra de l historial de psql, que guarda TOT el que s ha teclejat
$ shred -u ~/.psql_history 2>/dev/null || true

Còpies: lògica, física, WAL i recuperació a un punt en el temps

copia_tramontana.sh fa avui un pg_dump. És correcte i no és suficient, i aquí tens per què.

pg_dump (lògica) pg_basebackup (física)
Què copia Sentències SQL per reconstruir Els fitxers de PGDATA byte a byte
Portable entre versions majors Sí No
Restauració selectiva d'una taula Sí No, és tot o res
Temps de restauració d'1,8 GB ~10 min (reconstrueix índexs) ~2 min (copia fitxers)
Permet PITR No Sí, amb arxivat de WAL
Granularitat del punt de recuperació L'instant de l'abocament Qualsevol instant
Cost al servidor Alt: llegeix i serialitza tot Moderat: E/S seqüencial

Totes dues són necessàries, i no són alternatives: la lògica et salva d'una migració de versió major i permet recuperar una taula concreta; la física amb WAL és l'única que compleix un RPO de 4 hores sense fer abocaments cada quatre hores.

L'arxivat de WAL

El registre d'escriptura anticipada conté tots els canvis, en ordre. Si guardes una còpia física i tots els segments de WAL des d'aleshores, pots reproduir la història fins a qualsevol instant.

#!/usr/bin/env bash
#
# /usr/local/bin/arxivar_wal.sh - Arxiva un segment de WAL
#
# PostgreSQL l invoca com: arxivar_wal.sh %p %f
#   %p = ruta relativa al segment; %f = nomes el nom
#
# CONTRACTE CRITIC:
#   - Ha de retornar 0 NOMES si el segment esta segur i verificat.
#   - Si retorna != 0, PostgreSQL HO REINTENTA indefinidament i NO esborra
#     el segment. Es el correcte: millor omplir pg_wal que perdre dades.
#   - MAI no ha de sobreescriure un fitxer existent amb contingut diferent.
#
set -euo pipefail

readonly ORIGEN="$1"
readonly NOM="$2"
readonly DESTI="/srv/tramontana/backups/wal"

umask 077

# Ja arxivat i identic: exit idempotent (07-06)
if [[ -f "${DESTI}/${NOM}" ]]; then
    if cmp -s "$ORIGEN" "${DESTI}/${NOM}"; then
        exit 0
    fi
    logger -t arxivar_wal "ERROR: ${NOM} ja existeix amb contingut DIFERENT"
    exit 1
fi

# Copia atomica: escriure a temporal i reanomenar. Si el proces mor a
# mitges, no queda un segment truncat que sembli valid.
tmp="${DESTI}/.${NOM}.$$"
trap 'rm -f "$tmp"' EXIT

cp "$ORIGEN" "$tmp"
sync -f "$tmp"                  # forcar a disc ABANS de reanomenar
mv "$tmp" "${DESTI}/${NOM}"
sync "$DESTI"

exit 0
$ sudo install -o root -g root -m 0755 arxivar_wal.sh /usr/local/bin/
$ sudo -u postgres mkdir -p /srv/tramontana/backups/wal

$ sudo -u postgres psql -c "SELECT pg_switch_wal();"   # forcar un segment
$ sudo -u postgres psql -c "SELECT archived_count, last_archived_wal,
    last_archived_time, failed_count, last_failed_wal FROM pg_stat_archiver;"
 archived_count |    last_archived_wal     |      last_archived_time       | failed_count
----------------+--------------------------+-------------------------------+--------------
             47 | 000000010000000000000031 | 2026-08-18 12:41:08.221+02    |            0

failed_count és la mètrica que cal vigilar sense descans: si l'arxivat falla, pg_wal creix fins a omplir el disc i PostgreSQL s'atura. Va directa a la monitoració de 08-06.

La còpia física

$ sudo -u postgres pg_basebackup \
      -h /var/run/postgresql -U postgres \
      -D /srv/tramontana/backups/base/$(date +%F) \
      -Ft -z -Xs -P -c fast --manifest-checksums=SHA256
 1843712/1843712 kB (100%), 1/1 tablespace
Opció Què fa
-Ft -z Format tar comprimit: un fitxer per tablespace
-Xs Inclou el WAL generat durant la còpia (flux)
-c fast Checkpoint immediat: comença ja, amb un pic d'E/S
--manifest-checksums Manifest verificable amb pg_verifybackup
$ sudo -u postgres pg_verifybackup /srv/tramontana/backups/base/2026-08-18
backup successfully verified

I la integració amb restic de 05-08, que és el que la porta fora del servidor:

# Fragment afegit a copia_tramontana.sh
copiar_postgresql() {
    local desti="${TRAMONTANA_BACKUP_DIR}/base/$(date +%F)"
    log "iniciant copia base de PostgreSQL"
    sudo -u postgres pg_basebackup -h /var/run/postgresql -U postgres \
        -D "$desti" -Ft -z -Xs -c fast --manifest-checksums=SHA256 \
        || morir 74 "pg_basebackup ha fallat"
    sudo -u postgres pg_verifybackup "$desti" \
        || morir 65 "la copia base NO verifica: no es puja"
    log "copia base verificada: $(formatar_bytes "$(du -sb "$desti" | cut -f1)")"

    restic backup "$desti" "${TRAMONTANA_BACKUP_DIR}/wal" \
        --tag postgresql --tag base \
        || morir 74 "restic ha fallat en pujar la copia"
}

El pg_verifybackup abans de pujar és deliberat: pujar una còpia corrupta consumeix espai i, pitjor encara, dona una falsa sensació de seguretat. La regla de 05-08 es compleix aquí: una còpia no verificada no és una còpia.

Recuperació a un punt en el temps, amb el procediment complet

Aquest és el procediment que cal tenir escrit abans de necessitar-lo, i provat. L'escenari: a les 14:32 algú executa DELETE FROM app.reserves WHERE data < '2026-08-01' sense el WHERE correcte i esborra 40.000 files. Es detecta a les 14:51.

# ===== ES FA A srv-tramontana-proves, MAI sobre produccio =====
# Restaurar sobre el servidor viu destrueix l unica copia de les dades
# posteriors a l incident. Es restaura a part i s extreu el que s ha perdut.

# 1. Aturar PostgreSQL a la maquina de recuperacio i buidar PGDATA
$ sudo systemctl stop postgresql@16-main
$ sudo -u postgres mv /var/lib/postgresql/16/main \
      /var/lib/postgresql/16/main.trencat-$(date +%F)
$ sudo -u postgres mkdir -m 0700 /var/lib/postgresql/16/main

# 2. Descomprimir l ultima copia base ANTERIOR a l incident
$ sudo -u postgres tar -xzf /srv/tramontana/backups/base/2026-08-18/base.tar.gz \
      -C /var/lib/postgresql/16/main

# 3. Dir a PostgreSQL d on treu el WAL i fins on reproduir
$ sudo -u postgres tee /var/lib/postgresql/16/main/postgresql.auto.conf <<'EOF'
restore_command = 'cp /srv/tramontana/backups/wal/%f %p'
# Reproduir fins JUST ABANS del DELETE. Un segon de marge.
recovery_target_time = '2026-08-18 14:31:55+02'
recovery_target_action = 'pause'
EOF

# 'pause' i no 'promote': el servidor s atura a l instant objectiu i
# espera. Aixi pots MIRAR les dades abans de confirmar. Si t has passat,
# reinicies amb un altre objectiu sense haver destruit res.

# 4. El senyal que activa el mode recuperacio (PostgreSQL >= 12)
$ sudo -u postgres touch /var/lib/postgresql/16/main/recovery.signal

# 5. Arrencar i observar
$ sudo systemctl start postgresql@16-main
$ sudo journalctl -u postgresql@16-main -f
LOG:  starting point-in-time recovery to 2026-08-18 14:31:55+02
LOG:  restored log file "000000010000000000000031" from archive
LOG:  restored log file "000000010000000000000032" from archive
LOG:  recovery stopping before commit of transaction 84412, time 2026-08-18 14:32:04+02
LOG:  pausing at the end of recovery
HINT:  Execute pg_wal_replay_resume() to promote.

# 6. VERIFICAR abans de confirmar res
$ sudo -u postgres psql -d tramontana -c \
    "SELECT count(*) FROM app.reserves WHERE data < '2026-08-01';"
 count
-------
 40218

# 7. Confirmar: promoure el servidor recuperat
$ sudo -u postgres psql -c "SELECT pg_wal_replay_resume();"
$ sudo -u postgres psql -c "SELECT pg_is_in_recovery();"
 pg_is_in_recovery
-------------------
 f

# 8. Extreure NOMES el que s ha perdut i portar-ho a produccio
$ sudo -u postgres pg_dump -d tramontana -t app.reserves \
      --data-only --where="data < '2026-08-01'" > /tmp/recuperades.sql
$ scp /tmp/recuperades.sql [email protected]:/tmp/
$ ssh [email protected] \
    "sudo -u postgres psql -d tramontana -1 -f /tmp/recuperades.sql"

Cinc decisions d'aquell procediment que cal entendre:

  1. Es recupera en una altra màquina. Restaurar a sobre de producció destrueix les transaccions posteriors a l'incident, que són legítimes i no són en cap altra banda.
  2. recovery_target_action = 'pause'. Permet inspeccionar abans de comprometre's. Amb promote, si t'has passat d'instant, cal començar de zero.
  3. L'objectiu es fixa un segon abans, no a l'instant exacte: la marca temporal de l'incident poques vegades es coneix amb precisió de mil·lisegons.
  4. S'extreu només el que s'ha perdut. Substituir la base sencera perdria 19 minuts de reserves reals.
  5. psql -1 embolcalla la importació en una transacció: o entra tot o no entra res.

I el resultat que va al manual d'operació, amb números:

Mètrica Valor mesurat
RPO real aconseguit Segons (amb archive_timeout=900s, pitjor cas 15 min)
Temps de restauració d'1,8 GB 4 min 20 s
Temps total del procediment amb verificació ~25 min
RTO acordat 2 h

El RPO passa de 4 hores a minuts sense cost addicional, i això és una notícia que mereix un paràgraf a l'informe per a la Marta.

Manteniment: buidatge, wraparound i reindexació

PostgreSQL fa servir control de concurrència multiversió (MVCC): un UPDATE no modifica la fila, escriu una versió nova i marca la vella com a morta. Un DELETE només marca. Les versions mortes continuen ocupant espai fins que algú les neteja.

$ sudo -u postgres psql -d tramontana -c "
  SELECT relname, n_live_tup AS vives, n_dead_tup AS mortes,
         round(100.0*n_dead_tup/nullif(n_live_tup+n_dead_tup,0),1) AS pct_mortes,
         last_autovacuum
  FROM pg_stat_user_tables WHERE n_dead_tup > 1000
  ORDER BY n_dead_tup DESC;"
   relname   | vives  | mortes | pct_mortes |        last_autovacuum
-------------+--------+--------+------------+-------------------------------
 disponibles | 891200 | 412880 |       31.7 | 2026-08-16 03:12:41+02
 reserves    | 186420 |  18122 |        8.9 | 2026-08-18 04:02:11+02

Un 31,7 % de tuples mortes a disponibles és inflament (bloat): un terç d'aquella taula és brossa que es llegeix a cada recorregut seqüencial, ocupa shared_buffers i fa que els índexs apuntin a pàgines gairebé buides. I el last_autovacuum de fa dos dies indica que autovacuum no està seguint el ritme d'aquella taula.

Operació Què fa Bloqueja
VACUUM Marca l'espai mort com a reutilitzable No: conviu amb la càrrega
VACUUM FULL Reescriu la taula i retorna espai al SO Sí, ACCESS EXCLUSIVE: ningú no llegeix ni escriu
ANALYZE Recalcula les estadístiques del planificador No
REINDEX Reconstrueix índexs inflats Sí, llevat de CONCURRENTLY

VACUUM FULL en producció és un error clàssic: sobre una taula de 400 MB triga minuts, durant els quals l'aplicació no la pot tocar. Si cal recuperar espai en calent, l'eina és pg_repack.

Ajustar autovacuum on cal

Els valors per defecte disparen la neteja quan les tuples mortes superen el 20 % de la taula. En una taula de 900.000 files això són 180.000 tuples mortes abans de moure un dit, i una neteja cara cada vegada.

-- Ajust PER TAULA, que es com es fa: no es canvia el global per una
-- taula problematica.
ALTER TABLE app.disponibles SET (
    autovacuum_vacuum_scale_factor = 0.02,   -- netejar al 2 %, no al 20 %
    autovacuum_vacuum_threshold = 1000,
    autovacuum_analyze_scale_factor = 0.01,
    autovacuum_vacuum_cost_delay = 2         -- mes agressiu
);

-- La taula d auditoria nomes creix: no necessita el mateix tractament,
-- necessita una politica de retencio.
ALTER TABLE app.auditoria SET (autovacuum_vacuum_scale_factor = 0.1);
# conf.d/30-manteniment.conf — ajustos globals
autovacuum_max_workers = 2           # amb 2 vCPU, no mes
autovacuum_naptime = 30s
autovacuum_vacuum_cost_limit = 1000  # per defecte 200: massa lent

El wraparound, i per què és una emergència

Aquesta és la fallada més greu que pot patir una base de dades PostgreSQL mal mantinguda, i la que menys gent coneix fins que la pateix.

Cada transacció rep un identificador de 32 bits. Són uns 4.000 milions, i s'esgoten. PostgreSQL resol la circularitat tractant els identificadors com un cercle on 2.000 milions queden «al passat» i 2.000 milions «al futur». Perquè allò funcioni, les files molt antigues s'han de marcar com a congelades (frozen): visibles per a tothom, sense importar el comptador. D'això se n'encarrega VACUUM.

Si VACUUM no arriba a fer-ho —perquè autovacuum està desactivat, o perquè una transacció oberta des de fa dies el bloqueja— PostgreSQL avisa, després avisa més fort, i finalment es nega a acceptar escriptures:

ERROR:  database is not accepting commands to avoid wraparound data loss
in database "tramontana"
HINT:  Stop the postmaster and vacuum that database in single-user mode.

La base de dades queda de només lectura i l'única sortida és aturar el servei i netejar en mode monousuari, cosa que en una base gran pot durar hores. És una caiguda total, i per això es vigila:

$ sudo -u postgres psql -c "
  SELECT datname, age(datfrozenxid) AS edat_xid,
         round(100.0*age(datfrozenxid)/2000000000, 1) AS pct_cap_al_limit
  FROM pg_database ORDER BY age(datfrozenxid) DESC LIMIT 3;"
  datname   | edat_xid | pct_cap_al_limit
------------+----------+------------------
 tramontana | 48212104 |              2.4
 postgres   | 12088311 |              0.6

Un 2,4 % és una situació perfectament sana. La regla operativa:

pct_cap_al_limit Situació Acció
< 25 % Normal Cap
25-50 % Vigilar Revisar autovacuum i transaccions llargues
50-75 % Avís VACUUM FREEZE manual planificat
> 75 % Crític Intervenir ja

Les dues causes gairebé sempre són les mateixes, i totes dues es detecten en una consulta:

$ sudo -u postgres psql -c "
  SELECT pid, state, age(backend_xid) AS edat,
         now()-xact_start AS duracio, left(query,50) AS consulta
  FROM pg_stat_activity
  WHERE backend_xid IS NOT NULL ORDER BY age(backend_xid) DESC LIMIT 3;"
 pid  |        state        |  edat   |     duracio     |         consulta
------+---------------------+---------+-----------------+---------------------------
 4471 | idle in transaction | 8812044 | 3 days 04:12:09 | BEGIN; SELECT * FROM app...

idle in transaction durant tres dies és l'enemic: una transacció oberta impedeix congelar tot el que ve després i bloqueja la neteja de tota la base de dades. Sol ser una aplicació que va obrir una transacció i no la va tancar. La defensa és preventiva:

# conf.d/30-manteniment.conf
idle_in_transaction_session_timeout = 300s   # matar despres de 5 min ociosa
statement_timeout = 60s                      # cap consulta mes d 1 min
lock_timeout = 10s                           # no esperar blocatges eternament

statement_timeout = 60s és global; els informes que legítimament triguen més el pugen a la seva sessió amb SET LOCAL statement_timeout.

Diagnòstic: on se'n va el temps

Reprenem el fil de 08-01: el p99 de $upstream_response_time era 1,18 s i les rutes lentes eren informes.

pg_stat_statements: la consulta que més temps consumeix

$ sudo -u postgres psql -d tramontana -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;"
$ sudo -u postgres psql -d tramontana -c "
  SELECT round(total_exec_time::numeric,0) AS ms_total, calls,
         round(mean_exec_time::numeric,1) AS ms_mitjana,
         round(100.0*shared_blks_hit/nullif(shared_blks_hit+shared_blks_read,0),1) AS encert,
         left(query, 60) AS consulta
  FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 5;"
 ms_total  | calls | ms_mitjana | encert |                 consulta
-----------+-------+------------+--------+--------------------------------------------
   4128200 |  1088 |     3794.3 |   41.2 | SELECT casa, sum(import) FROM app.reserves
    881400 | 92104 |        9.6 |   99.8 | SELECT * FROM app.disponibles WHERE casa =
    412900 |  1204 |      342.9 |   98.1 | SELECT * FROM app.reserves WHERE hoste_id

La primera fila és la culpable, i la columna encert de 41,2 % ho confirma: aquella consulta llegeix del disc més de la meitat del que necessita. Ordena per temps total, no per temps mitjà: una consulta de 10 ms executada 92.000 vegades consumeix més servidor que una de 4 segons executada 1.000, i és un error comú optimitzar la lenta i espectacular en lloc de la freqüent.

EXPLAIN (ANALYZE, BUFFERS)

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT casa, sum(import) FROM app.reserves
WHERE data >= '2026-01-01' GROUP BY casa;
 HashAggregate  (cost=48122.11..48122.16 rows=5 width=40)
                (actual time=3781.442..3781.449 rows=5 loops=1)
   Group Key: casa
   Buffers: shared hit=812 read=41208
   ->  Seq Scan on reserves  (cost=0.00..47188.20 rows=186782 width=18)
                             (actual time=0.031..3402.118 rows=186420 loops=1)
         Filter: (data >= '2026-01-01'::date)
         Rows Removed by Filter: 0
         Buffers: shared hit=812 read=41208
 Planning Time: 0.184 ms
 Execution Time: 3781.512 ms

Com es llegeix això, que és una habilitat que s'entrena:

Element Què diu Aquí
cost= Estimació del planificador 48122 unitats arbitràries
actual time= Temps real: primera fila..última 3,78 s
rows= estimat vs rows= actual Si difereixen molt, falten estadístiques 186782 vs 186420: bé
Buffers: shared hit / read Pàgines de memòria cau / de disc 41208 de disc: el problema
Seq Scan Recorregut seqüencial complet La taula sencera per no filtrar res
Rows Removed by Filter: 0 El filtre no descarta res El WHERE és inútil aquí

Diagnòstic: el WHERE data >= '2026-01-01' no descarta cap fila perquè totes les reserves són posteriors. La consulta llegeix 41.208 pàgines de disc —322 MB— per agregar cinc grups. I sum(import) obliga a llegir les files completes, així que un índex sobre data no ajudaria: el planificador continuaria preferint el recorregut seqüencial.

La solució correcta aquí no és un índex, sinó un índex que cobreixi la consulta sencera:

-- Index de cobertura: conte tot el que la consulta necessita, aixi
-- que es respon SENSE tocar la taula (Index Only Scan).
CREATE INDEX CONCURRENTLY idx_reserves_data_casa_import
    ON app.reserves (data) INCLUDE (casa, import);
ANALYZE app.reserves;
 HashAggregate  (actual time=182.401..182.409 rows=5 loops=1)
   Group Key: casa
   Buffers: shared hit=1204 read=3811
   ->  Index Only Scan using idx_reserves_data_casa_import on reserves
         (actual time=0.048..96.221 rows=186420 loops=1)
         Index Cond: (data >= '2026-01-01'::date)
         Heap Fetches: 0
         Buffers: shared hit=1204 read=3811
 Execution Time: 182.478 ms

De 3.781 ms a 182 ms: vint vegades més ràpid, i les pàgines llegides de disc cauen de 41.208 a 3.811. Heap Fetches: 0 confirma que no es toca la taula en absolut.

CREATE INDEX CONCURRENTLY és obligatori en producció: la versió normal bloqueja les escriptures de la taula mentre construeix. CONCURRENTLY triga més i no bloqueja, a canvi que si falla deixa un índex invàlid que cal eliminar.

Consultes lentes al registre

Amb log_min_duration_statement = 250ms, les lentes queden al journal:

$ sudo journalctl -u postgresql@16-main --since today | grep -oP 'duration: \K[0-9.]+' | \
      sort -rn | head -3
3812.402
1204.118
890.331

$ sudo journalctl -u postgresql@16-main --since today | grep 'temporary file'
LOG:  temporary file: path "base/pgsql_tmp/pgsql_tmp4471.0", size 18874368
STATEMENT:  SELECT ... ORDER BY data DESC

Aquell missatge de fitxer temporal és or: significa que una ordenació no va cabre a work_mem i es va fer en disc. 18 MB amb work_mem = 8 MB. La resposta és pujar work_mem només en aquella sessió, no globalment.

Índexs que falten i que sobren

# Taules amb molts recorreguts sequencials sobre volum gran
$ sudo -u postgres psql -d tramontana -c "
  SELECT relname, seq_scan, seq_tup_read, idx_scan,
         seq_tup_read/nullif(seq_scan,0) AS files_per_recorregut
  FROM pg_stat_user_tables
  WHERE seq_scan > 100 AND seq_tup_read/nullif(seq_scan,0) > 10000
  ORDER BY seq_tup_read DESC;"
 relname  | seq_scan | seq_tup_read | idx_scan | files_per_recorregut
----------+----------+--------------+----------+----------------------
 reserves |     1088 |    202744960 |    91204 |               186346

# Indexs que ningu no fa servir: ocupen espai i alenteixen cada INSERT
$ sudo -u postgres psql -d tramontana -c "
  SELECT indexrelname, pg_size_pretty(pg_relation_size(indexrelid)) AS mida, idx_scan
  FROM pg_stat_user_indexes WHERE idx_scan < 50
    AND indexrelid NOT IN (SELECT conindid FROM pg_constraint)
  ORDER BY pg_relation_size(indexrelid) DESC;"
     indexrelname     | mida  | idx_scan
----------------------+-------+----------
 idx_reserves_telefon | 22 MB |        0

Un índex sense fer servir no és neutre: ocupa 22 MB de disc i de memòria cau, i cada INSERT, UPDATE i DELETE l'ha d'actualitzar. Abans d'eliminar-lo cal confirmar que les estadístiques cobreixen un període representatiu —un índex fet servir només al tancament mensual semblarà inútil el dia 12.

Rèplica en flux

Reprenem 07-07, on va quedar decidit: replicació asíncrona (el RPO de 4 h la fa més que suficient) i promoció manual (amb dos nodes no hi ha quòrum, i l'automàtica produeix dades divergents).

# ===== Al PRIMARI (10.0.2.15) =====
# wal_level = replica i archive_mode = on ja hi son.
$ sudo -u postgres psql -c "
  SELECT slot_name FROM pg_create_physical_replication_slot('replica_16');"

Una ranura de replicació (replication slot) fa que el primari conservi els segments de WAL que la rèplica encara no ha consumit, encara que la rèplica porti hores aturada. És el que garanteix que una rèplica que cau de nit pugui recuperar-se al matí sense recrear-la sencera.

I porta el risc simètric, que cal conèixer: una ranura d'una rèplica que no torna mai omple pg_wal fins a aturar el primari. Per això s'acota:

# conf.d/40-replicacio.conf al PRIMARI
max_wal_senders = 3
max_replication_slots = 3
wal_keep_size = 1GB
# Limit de seguretat: si la ranura acumula mes de 8 GB, s invalida.
# Millor perdre la replica que aturar el primari.
max_slot_wal_keep_size = 8GB
# ===== A la REPLICA (10.0.2.16) =====
$ sudo systemctl stop postgresql@16-main
$ sudo -u postgres rm -rf /var/lib/postgresql/16/main/*

$ sudo -u postgres PGPASSWORD=$(pass tramontana/replicador) pg_basebackup \
      -h 10.0.2.15 -U replicador -D /var/lib/postgresql/16/main \
      -R -P -Xs -C -S replica_16 \
      -d "sslmode=verify-full sslrootcert=/etc/postgresql/ca-tramontana.crt"
 1843712/1843712 kB (100%), 1/1 tablespace
Opció Què fa
-R Escriu postgresql.auto.conf i standby.signal: la rèplica queda llesta
-S replica_16 Fa servir la ranura creada
-Xs Rep el WAL mentre copia: no es perd res
sslmode=verify-full El WAL viatja xifrat i verificat
$ sudo -u postgres cat /var/lib/postgresql/16/main/postgresql.auto.conf
primary_conninfo = 'user=replicador passfile=''/var/lib/postgresql/.pgpass''
  host=10.0.2.15 port=5432 sslmode=verify-full ...'
primary_slot_name = 'replica_16'

$ sudo systemctl start postgresql@16-main
$ sudo -u postgres psql -c "SELECT pg_is_in_recovery();"
 pg_is_in_recovery
-------------------
 t

I la vigilància, des del primari:

$ sudo -u postgres psql -x -c "
  SELECT client_addr, state, sync_state,
         pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS retard_bytes,
         write_lag, flush_lag, replay_lag FROM pg_stat_replication;"
-[ RECORD 1 ]+----------------
client_addr  | 10.0.2.16
state        | streaming
sync_state   | async
retard_bytes | 0 bytes
write_lag    | 00:00:00.002
replay_lag   | 00:00:00.004

retard_bytes és quantes dades es perdrien si el primari caigués ara: la mètrica més important de tot l'apartat, i va a la monitoració de 08-06 al costat de l'antiguitat de l'última còpia.

La rèplica serveix a més per a una cosa immediata i valuosa: hot_standby = on permet consultes de només lectura sobre ella, així que els informes pesants —els de 3,7 segons— es poden executar allà sense tocar el primari.

# A la replica, perque les consultes llargues no es tallin per conflicte
# amb la reproduccio del WAL
$ echo "max_standby_streaming_delay = 300s" | \
      sudo tee -a /etc/postgresql/16/main/conf.d/40-replicacio.conf

I el recordatori que mai no sobra, ja enunciat a 07-07: la rèplica no és una còpia de seguretat. El DELETE de l'apartat 9 arriba a la rèplica en quatre mil·lisegons. Per desfer-lo hi ha el PITR; la rèplica protegeix de la fallada de maquinari, no de l'error humà.

Automatització amb Ansible

# ~/tramontana-infra/roles/bd/defaults/main.yml
---
bd_version: 16
bd_ram_mb: 3800
# Les formules viuen a les variables: canvia la RAM i tot es recalcula
bd_shared_buffers_mb: "{{ (bd_ram_mb * 0.25) | int }}"
bd_effective_cache_mb: "{{ (bd_ram_mb * 0.67) | int }}"
bd_work_mem_mb: 8
bd_maintenance_work_mem_mb: "{{ (bd_ram_mb * 0.07) | int }}"
bd_max_connections: 60
bd_random_page_cost: 1.1          # SSD
bd_log_min_duration_ms: 250
bd_archive_dir: /srv/tramontana/backups/wal
bd_pgbouncer_pool_size: 25
bd_pgbouncer_max_clients: 200
bd_xarxes_permeses:
  - { xarxa: '127.0.0.1/32', bd: tramontana, rol: svc_tramontana, tipus: host }
  - { xarxa: '10.0.2.0/24',  bd: tramontana, rol: operador,       tipus: hostssl }
  - { xarxa: '10.0.2.16/32', bd: replication, rol: replicador,    tipus: hostssl }
# ~/tramontana-infra/roles/bd/tasks/main.yml
---
- name: Comprovar que la RAM declarada coincideix amb la real
  ansible.builtin.assert:
    that: (ansible_memtotal_mb - bd_ram_mb) | abs < 400
    fail_msg: >-
      bd_ram_mb ({{ bd_ram_mb }}) no coincideix amb la RAM real
      ({{ ansible_memtotal_mb }} MB). Els calculs de memoria serien
      incorrectes i podrien deixar el servidor sense memoria.

- name: Instal·lar PostgreSQL i PgBouncer
  ansible.builtin.apt:
    name:
      - "postgresql-{{ bd_version }}"
      - "postgresql-contrib-{{ bd_version }}"
      - pgbouncer
      - python3-psycopg2        # necessari per als moduls postgresql_*
    state: present
  tags: [paquets]

- name: Bloquejar la versio major (politica de 05-03)
  ansible.builtin.dpkg_selections:
    name: "postgresql-{{ bd_version }}"
    selection: hold

- name: Directori d arxivat de WAL
  ansible.builtin.file:
    path: "{{ bd_archive_dir }}"
    state: directory
    owner: postgres
    group: postgres
    mode: '0700'

- name: Instal·lar l script d arxivat de WAL
  ansible.builtin.copy:
    src: arxivar_wal.sh
    dest: /usr/local/bin/arxivar_wal.sh
    owner: root
    group: root
    mode: '0755'

- name: Activar include_dir a postgresql.conf
  ansible.builtin.lineinfile:
    path: "/etc/postgresql/{{ bd_version }}/main/postgresql.conf"
    line: "include_dir = 'conf.d'"
    regexp: '^#?\s*include_dir\s*='
    backup: true
  notify: Reiniciar postgresql

- name: Desplegar l ajust calculat
  ansible.builtin.template:
    src: 10-tramontana.conf.j2
    dest: "/etc/postgresql/{{ bd_version }}/main/conf.d/10-tramontana.conf"
    owner: postgres
    group: postgres
    mode: '0640'
    backup: true
  notify: Reiniciar postgresql

- name: Desplegar pg_hba.conf
  ansible.builtin.template:
    src: pg_hba.conf.j2
    dest: "/etc/postgresql/{{ bd_version }}/main/pg_hba.conf"
    owner: postgres
    group: postgres
    mode: '0640'
    backup: true
  notify: Recarregar postgresql

- name: Forcar els handlers abans de verificar
  ansible.builtin.meta: flush_handlers

# --- Verificacio: un pg_hba invalid deixa el servidor inaccessible ---
- name: Verificar que pg_hba.conf no te errors
  community.postgresql.postgresql_query:
    db: postgres
    login_unix_socket: /var/run/postgresql
    query: "SELECT count(*) AS errors FROM pg_hba_file_rules WHERE error IS NOT NULL"
  become: true
  become_user: postgres
  register: hba
  failed_when: hba.query_result[0].errors | int > 0

- name: Crear els rols amb contrasenya des de Vault
  community.postgresql.postgresql_user:
    name: "{{ item.nom }}"
    password: "{{ item.password }}"
    role_attr_flags: "{{ item.flags }}"
    conn_limit: "{{ item.limit | default(omit) }}"
    state: present
  become: true
  become_user: postgres
  loop:
    - { nom: svc_tramontana, password: "{{ vault_bd_app }}",
        flags: 'LOGIN,NOSUPERUSER,NOCREATEDB,NOCREATEROLE', limit: 30 }
    - { nom: replicador, password: "{{ vault_bd_replicador }}",
        flags: 'LOGIN,REPLICATION' }
    - { nom: monitor, password: "{{ vault_bd_monitor }}", flags: 'LOGIN' }
  no_log: true                     # les contrasenyes no surten per pantalla
  tags: [rols]

- name: Concedir pg_monitor al rol de monitoracio
  community.postgresql.postgresql_membership:
    group: pg_monitor
    target_roles: monitor
    state: present
  become: true
  become_user: postgres

- name: Configurar PgBouncer
  ansible.builtin.template:
    src: pgbouncer.ini.j2
    dest: /etc/pgbouncer/pgbouncer.ini
    owner: postgres
    group: postgres
    mode: '0640'
    backup: true
  notify: Reiniciar pgbouncer

- name: Verificar que l arxivat de WAL funciona
  community.postgresql.postgresql_query:
    db: postgres
    login_unix_socket: /var/run/postgresql
    query: "SELECT failed_count, last_archived_time FROM pg_stat_archiver"
  become: true
  become_user: postgres
  register: arxivador
  failed_when: arxivador.query_result[0].failed_count | int > 0
  tags: [verificar]
# roles/bd/handlers/main.yml
---
- name: Recarregar postgresql
  community.postgresql.postgresql_query:
    db: postgres
    login_unix_socket: /var/run/postgresql
    query: "SELECT pg_reload_conf()"
  become: true
  become_user: postgres

- name: Reiniciar postgresql
  # Un reinici talla les connexions. Nomes es dispara quan canvia un
  # parametre que ho requereix, i en produccio s executa en finestra.
  ansible.builtin.systemd:
    name: "postgresql@{{ bd_version }}-main"
    state: restarted

- name: Reiniciar pgbouncer
  ansible.builtin.systemd:
    name: pgbouncer
    state: restarted

L'assert inicial mereix atenció: sense ell, aplicar el rol a una màquina amb menys RAM de la declarada produiria un shared_buffers més gran que la memòria física i PostgreSQL no arrencaria. És la mena de fallada que Ansible converteix en global si no es comprova.

Operació diària

#!/usr/bin/env bash
# Fragment per integrar a revisio_salut.sh: bloc de base de dades
comprovar_base_dades() {
    local estat=0

    # 1. Connexions esperant a l agrupacio
    local esperant
    esperant="$(psql -h 127.0.0.1 -p 6432 -U operador -d pgbouncer -tAc \
        "SHOW POOLS" | awk -F'|' '$1=="tramontana"{print $4}')"
    if (( esperant > 0 )); then
        error "hi ha $esperant clients esperant connexio"; estat=1
    fi

    # 2. Retard de la replica (bytes que es perdrien ara mateix)
    local retard
    retard="$(sudo -u postgres psql -tAc \
        "SELECT coalesce(max(pg_wal_lsn_diff(sent_lsn,replay_lsn)),0)::bigint
         FROM pg_stat_replication")"
    if (( retard > 104857600 )); then          # 100 MB
        error "la replica va $(formatar_bytes "$retard") per darrere"; estat=1
    fi

    # 3. Fallades d arxivat de WAL: si falla, pg_wal creix sense limit
    local fallades
    fallades="$(sudo -u postgres psql -tAc "SELECT failed_count FROM pg_stat_archiver")"
    if (( fallades > 0 )); then
        error "l arxivat de WAL ha fallat $fallades vegades"; estat=2
    fi

    # 4. Wraparound
    local pct
    pct="$(sudo -u postgres psql -tAc \
        "SELECT round(100.0*max(age(datfrozenxid))/2000000000) FROM pg_database")"
    if (( pct > 75 )); then
        error "wraparound al ${pct}%: EMERGENCIA"; estat=2
    elif (( pct > 50 )); then
        error "wraparound al ${pct}%"; estat=1
    fi

    # 5. Transaccions obertes eternament
    local zombis
    zombis="$(sudo -u postgres psql -tAc \
        "SELECT count(*) FROM pg_stat_activity
         WHERE state='idle in transaction' AND now()-xact_start > interval '10 min'")"
    if (( zombis > 0 )); then
        error "$zombis transaccions ocioses de mes de 10 min"; estat=1
    fi

    (( estat == 0 )) && log "base de dades correcta"
    return "$estat"
}

Què es mira i quan:

Freqüència Comprovació Llindar d'alerta
Continu (08-06) Retard de la rèplica > 100 MB
Continu pg_stat_archiver.failed_count > 0
Continu Clients esperant a PgBouncer > 0 sostingut
Continu Espai lliure a pg_wal < 20 %
Diari Índex d'encert de la memòria cau < 95 %
Diari Transaccions idle in transaction llargues > 10 min
Setmanal Tuples mortes per taula > 20 %
Setmanal Top 5 de pg_stat_statements Canvis a la classificació
Mensual Edat del comptador de transaccions > 50 %
Mensual Índexs sense fer servir i mida de la BD Creixement inesperat
Trimestral Assaig complet de PITR S'ha de completar en < 30 min

Aquella última fila és la més important de la taula i la que més s'incompleix. Una còpia que no s'ha restaurat mai no és una còpia: és un fitxer. L'assaig trimestral entra al simulacre semestral que vas proposar a 07-07.

Errors Comuns i Consells

  • Pujar max_connections en lloc de posar una agrupació. Cada connexió costa memòria i contenció. Amb 2 vCPU, 300 connexions donen menys feina feta que 25.
  • Pujar work_mem globalment. És memòria per operació i per connexió: multiplica-ho abans de tocar-ho, o l'OOM killer s'encarregarà de recordar-t'ho.
  • Posar shared_buffers al 80 %. Es duplica l'emmagatzematge amb la memòria cau del sistema i hi ha menys memòria útil que amb el 25 %.
  • Deixar random_page_cost = 4 en un SSD. El planificador evita índexs que hauria de fer servir, i les consultes es degraden sense causa aparent.
  • Editar postgresql.conf quan postgresql.auto.conf té el mateix paràmetre. Guanya el segon. Consulta pg_settings.source.
  • Fer servir trust a pg_hba.conf, encara que sigui «temporalment» a localhost. És control total de la base de dades per a qualsevol procés local, i el temporal dura anys.
  • Recarregar pg_hba.conf sense mirar pg_hba_file_rules. Pots quedar-te fora de la teva pròpia base de dades.
  • Creure que sslmode=require verifica el certificat. No ho fa. Només verify-full.
  • Donar SUPERUSER al rol de l'aplicació «perquè no doni problemes». Una injecció SQL passa de llegir dades a executar ordres al servidor.
  • Oblidar ALTER DEFAULT PRIVILEGES. L'aplicació funciona fins que una migració crea una taula nova, i aleshores falla en producció.
  • Confondre rèplica amb còpia de seguretat. Un DELETE es replica en mil·lisegons. Per desfer-lo cal PITR.
  • VACUUM FULL en producció. Blocatge exclusiu: ningú no llegeix ni escriu mentre dura. Fes servir pg_repack.
  • Ignorar el wraparound fins que la base deixa d'acceptar escriptures. Vigila age(datfrozenxid) i mata les transaccions ocioses.
  • No acotar max_slot_wal_keep_size. Una rèplica caiguda omple pg_wal i atura el primari. Millor perdre la rèplica.
  • CREATE INDEX sense CONCURRENTLY en producció. Bloqueja les escriptures de la taula mentre construeix.
  • Optimitzar per mean_exec_time. Ordena per total_exec_time: la consulta de 10 ms executada 92.000 vegades costa més que la de 4 s executada 1.000.
  • Consell de mètode. Abans de canviar un paràmetre, apunta el valor actual i la mètrica que esperes moure. Un ajust sense mesura prèvia és indistingible de la superstició.

Exercicis

Exercici 1

revisio_salut.sh avisa a les 03:14 que l'espai de /var/lib/postgresql està al 91 % i pujant. Diagnostica la causa, explica el mecanisme, resol-ho sense perdre dades i proposa la prevenció definitiva.

Exercici 2

Dissenya i documenta el procediment complet d'assaig trimestral de recuperació a un punt en el temps, en forma de manual d'operació accionable per algú que no siguis tu, amb els seus criteris d'èxit.

Exercici 3

La Marta pregunta si val la pena la rèplica de PostgreSQL de la qual es va parlar a 07-07, ara que ja hi ha còpies amb recuperació al minut. Redacta la resposta.

Solucions

Solució 1

Diagnòstic. El primer és saber què creix, no quant:

$ sudo du -sh /var/lib/postgresql/16/main/* | sort -rh | head -4
9.8G	/var/lib/postgresql/16/main/pg_wal
1.6G	/var/lib/postgresql/16/main/base
2.1M	/var/lib/postgresql/16/main/global

$ ls /var/lib/postgresql/16/main/pg_wal/*.ready 2>/dev/null | wc -l
612

pg_wal amb 9,8 GB i 612 fitxers .ready a archive_status/. Un .ready és un segment que PostgreSQL vol arxivar i no ha aconseguit arxivar. La causa és immediata:

$ sudo -u postgres psql -c "SELECT archived_count, failed_count,
    last_failed_wal, last_failed_time FROM pg_stat_archiver;"
 archived_count | failed_count |     last_failed_wal      |       last_failed_time
----------------+--------------+--------------------------+-------------------------------
           4128 |         2044 | 000000010000000000000A31 | 2026-08-18 03:12:55.118+02

$ sudo journalctl -t arxivar_wal --since "6 hours ago" | tail -2
arxivar_wal[8812]: cp: cannot create regular file
  '/srv/tramontana/backups/wal/.000000010000000000000A31.8812': No space left on device

El mecanisme, que és el que cal entendre. El LV lv-backups de 15 GiB, xifrat amb LUKS, s'ha omplert. arxivar_wal.sh retorna un codi diferent de zero, i aquí entra en joc el contracte de l'arxivat: PostgreSQL no esborra un segment fins que l'ordre d'arxivat confirma l'èxit. És un comportament correcte i deliberat —perdre un segment trencaria la cadena de PITR i amb ella totes les còpies posteriors— però produeix un efecte en cascada:

lv-backups ple -> arxivar_wal.sh falla -> els segments no s esborren
  -> pg_wal creix -> s omple /var/lib -> PostgreSQL S ATURA

Si /var/lib/postgresql s'omple del tot, PostgreSQL entra en mode pànic i s'atura. Amb el 91 % i pujant, queden hores, no dies.

I un segon sospitós que cal descartar sempre:

$ sudo -u postgres psql -c "SELECT slot_name, active,
    pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retingut
  FROM pg_replication_slots;"
 slot_name  | active | retingut
------------+--------+----------
 replica_16 | t      | 12 MB

La ranura està activa i només reté 12 MB: no és la causa. Si active = f amb diversos GB retinguts, la causa seria una rèplica caiguda.

Resolució, en ordre i sense perdre dades:

# --- PAS 1: espai immediat al desti d arxivat ---
# Pujar a restic el que ja esta arxivat i alliberar el mes antic
$ sudo restic backup /srv/tramontana/backups/wal --tag wal-urgent
$ sudo restic check --read-data-subset=5%      # verificar ABANS d esborrar

# Nomes aleshores, esborrar els WAL anteriors a la copia base mes antiga
# que volem conservar. pg_archivecleanup calcula quins sobren: MAI no
# s esborren a ma per data.
$ sudo -u postgres pg_archivecleanup -d /srv/tramontana/backups/wal \
      000000010000000000000900
pg_archivecleanup: removing file "000000010000000000000412"
...
pg_archivecleanup: 1288 files removed

$ df -h /srv/tramontana/backups
Filesystem                  Size  Used Avail Use% Mounted on
/dev/mapper/vg--dades-lv--backups  15G  4.2G  9.9G  30% /srv/tramontana/backups

# --- PAS 2: drenar la cua de .ready ---
$ sudo -u postgres psql -c "SELECT pg_switch_wal();"
$ sleep 60
$ ls /var/lib/postgresql/16/main/archive_status/*.ready | wc -l
0
$ sudo du -sh /var/lib/postgresql/16/main/pg_wal
412M	/var/lib/postgresql/16/main/pg_wal

$ sudo -u postgres psql -c "SELECT pg_stat_reset_shared('archiver');"

El que NO es fa, i és la temptació del moment:

Acció temptadora Conseqüència
rm de fitxers de pg_wal Trenca la cadena de PITR i pot impedir l'arrencada. Mai
archive_mode = off Alleuja avui i elimina la capacitat de PITR. És rendir-se
archive_command = '/bin/true' Descarta segments silenciosament: PITR trencat sense avisar
Ampliar el LV sense més Legítim, però sense arreglar la causa es repeteix en tres mesos

Aquella tercera fila és especialment traïdora: tot sembla funcionar, failed_count es queda a zero, i el problema només apareix el dia que cal restaurar.

Prevenció definitiva, en quatre mesures:

# 1. Retencio automatica de WAL, setmanal, despres de verificar restic
$ cat /home/operador/scripts/purgar_wal.sh
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/lib/comuns.sh"

readonly WAL_DIR=/srv/tramontana/backups/wal
readonly BASE_DIR=/srv/tramontana/backups/base

main() {
    requereix_comanda pg_archivecleanup
    requereix_comanda restic

    # No purgar mai per sota de la copia base mes antiga que es conserva
    local base_mes_antiga
    base_mes_antiga="$(find "$BASE_DIR" -maxdepth 1 -type d -name '20*' | sort | head -1)" \
        || morir 75 "no hi ha cap copia base: NO es purga res"
    [[ -n "$base_mes_antiga" ]] || morir 75 "no hi ha copia base; avortant"

    # I nomes si restic te el contingut segur
    restic check --read-data-subset=2% >/dev/null \
        || morir 65 "restic no verifica: no es purga res"

    local wal_inicial
    wal_inicial="$(awk '/^START WAL LOCATION/{print $6}' \
        "${base_mes_antiga}/backup_label" | tr -d ')')"
    log "purgant WAL anterior a $wal_inicial"
    pg_archivecleanup -d "$WAL_DIR" "$wal_inicial"
}
main "$@"
# 2. Alerta ABANS del problema, no quan ja no hi ha sortida
# /etc/systemd/system/vigilar-wal.service (executat cada 15 min)
[Service]
Type=oneshot
ExecStart=/home/operador/scripts/vigilar_wal.sh
# vigilar_wal.sh: dos llindars, dos nivells
#   - .ready > 20         -> avis: l arxivat s esta endarrerint
#   - us de lv-backups > 75 % -> avis; > 90 % -> critic
#   - failed_count > 0    -> critic immediat
# 3. Xarxa de seguretat al propi PostgreSQL
# Limita quant WAL pot acumular una ranura abans d invalidar-la.
max_slot_wal_keep_size = 8GB
# 4. Ampliar lv-backups amb marge (05-04), ara amb dades
$ sudo lvextend -L +10G /dev/vg-dades/lv-backups
$ sudo cryptsetup resize backups-xifrat
$ sudo resize2fs /dev/mapper/backups-xifrat

I les tres lliçons de mètode, que van al quadern de guàrdia:

  1. Una fallada d'arxivat és un incident de disponibilitat, encara que el símptoma sigui d'espai: la cadena acaba amb PostgreSQL aturat.
  2. failed_count > 0 ha de ser una alerta crítica des de la primera fallada, no quan el disc estigui al 91 %. És un exemple perfecte d'alerta de causa (08-06) que sí que mereix existir, perquè el seu símptoma triga hores a aparèixer i aleshores ja és tard.
  3. La monitoració havia d'haver avisat als 4.128 arxivats i 1 fallada, no a les 2.044 fallades. Aquest incident és justificació directa per a la lliçó 08-06.

Solució 2

Manual d'operació: Assaig trimestral de recuperació a un punt en el temps

Document: RB-BD-02 · Versió: 1.0 · Data: 2026-08-18 Responsable: Operacions · Periodicitat: trimestral (març, juny, setembre, desembre) Durada estimada: 60 minuts · Risc per a producció: cap si se segueixen els passos Ubicació: còpia impresa a l'arxivador d'operacions i a ~/tramontana-infra/docs/. No es guarda únicament a srv-tramontana.

0. Per què existeix aquest document

Una còpia que no s'ha restaurat mai no és una còpia: és un fitxer del qual suposem coses. Aquest assaig verifica que la cadena completa —còpia base, arxivat de WAL, restic, i el procediment— funciona abans de necessitar-la. També mesura el temps real, que és la dada que sosté el RTO acordat de 2 hores.

1. Requisits previs (5 min)

# Comprovació Ordre Criteri
1.1 Màquina de proves engegada virsh list --all srv-tramontana-proves activa
1.2 Mateixa versió major de PostgreSQL ssh ... psql --version 16.x a totes dues
1.3 Espai a proves df -h /var/lib/postgresql ≥ 3 × mida de la BD
1.4 Repositori restic accessible restic snapshots --tag base | tail -3 Almenys 2 còpies base
1.5 Frase de pas de restic disponible pass restic/tramontana Es recupera
1.6 Finestra avisada — Marta informada per correu

Si 1.4 o 1.5 fallen, l'assaig s'atura i es declara incident. No poder accedir a les còpies és exactament l'escenari que aquest assaig ha de descobrir.

2. Triar l'objectiu (5 min)

L'assaig ha de recuperar a un instant arbitrari dins de les últimes 24 hores, no al de la còpia base — recuperar a la còpia base no prova el WAL, que és la meitat del mecanisme.

# Instant objectiu: ahir a les 15:00
$ OBJECTIU="$(date -d 'yesterday 15:00' '+%Y-%m-%d %H:%M:%S%:z')"

# Dada de control: una fila que existeixi en aquell moment i que es
# pugui verificar despres. S anota AQUI, abans de comencar.
$ sudo -u postgres psql -d tramontana -tAc \\
    "SELECT id, import FROM app.reserves
     WHERE creat_el < '$OBJECTIU' ORDER BY creat_el DESC LIMIT 1;"
1023|412.50

Anotar: objectiu 2026-08-17 15:00:00+02, control: reserva 1023, import 412,50 €.

3. Restauració (25 min)

# 3.1 Marcar l inici: el cronometre comenca AQUI
$ INICI=$(date +%s)

# 3.2 A srv-tramontana-proves
$ ssh [email protected]
$ sudo systemctl stop postgresql@16-main
$ sudo -u postgres rm -rf /var/lib/postgresql/16/main
$ sudo -u postgres mkdir -m 0700 /var/lib/postgresql/16/main

# 3.3 Portar la copia base ANTERIOR a l objectiu des de restic
$ restic restore latest --tag base --target /tmp/rest --host srv-tramontana
$ sudo -u postgres tar -xzf /tmp/rest/srv/tramontana/backups/base/*/base.tar.gz \\
      -C /var/lib/postgresql/16/main

# 3.4 I els WAL
$ restic restore latest --tag wal --target /tmp/rest

# 3.5 Configurar la recuperacio
$ sudo -u postgres tee /var/lib/postgresql/16/main/postgresql.auto.conf <<EOF
restore_command = 'cp /tmp/rest/srv/tramontana/backups/wal/%f %p'
recovery_target_time = '$OBJECTIU'
recovery_target_action = 'pause'
EOF
$ sudo -u postgres touch /var/lib/postgresql/16/main/recovery.signal

# 3.6 Arrencar i seguir el proces
$ sudo systemctl start postgresql@16-main
$ sudo journalctl -u postgresql@16-main -f

Sortida esperada (si no apareix recovery stopping before..., l'assaig ha fallat):

LOG:  starting point-in-time recovery to 2026-08-17 15:00:00+02
LOG:  restored log file "0000000100000000000009F1" from archive
LOG:  recovery stopping before commit of transaction 91204, time 2026-08-17 15:00:03+02
LOG:  pausing at the end of recovery

4. Verificació (10 min) — els criteris d'èxit

# Criteri Ordre Llindar
4.1 El servidor va arribar a l'objectiu journalctl | grep 'recovery stopping' Apareix, amb hora ≈ objectiu
4.2 La dada de control existeix i coincideix SELECT import FROM app.reserves WHERE id=1023 412,50
4.3 No hi ha dades posteriors a l'objectiu SELECT count(*) FROM app.reserves WHERE creat_el > '$OBJECTIU' 0
4.4 Integritat estructural SELECT count(*) FROM app.reserves Coherent amb producció
4.5 Sense errors de suma de verificació journalctl | grep -ci 'checksum|corrupt' 0
4.6 Temps total echo $(( $(date +%s) - INICI )) < 1800 s

El criteri 4.3 és el que valida de debò el PITR: si hi hagués dades posteriors a l'objectiu, la recuperació no es va aturar on tocava i el mecanisme no serveix per desfer un esborrat.

$ sudo -u postgres psql -d tramontana -c "
  SELECT (SELECT import FROM app.reserves WHERE id=1023) AS control,
         (SELECT count(*) FROM app.reserves WHERE creat_el > '$OBJECTIU') AS posteriors,
         (SELECT count(*) FROM app.reserves) AS total;"
 control | posteriors | total
---------+------------+--------
  412.50 |          0 | 185912

5. Neteja (5 min)

$ sudo systemctl stop postgresql@16-main
$ sudo -u postgres rm -rf /var/lib/postgresql/16/main /tmp/rest
$ sudo virsh snapshot-revert srv-tramontana-proves neta   # des de l amfitrio

Mai no es deixa la màquina de proves amb una còpia de les dades de producció: conté dades personals reals de clients i quedaria fora de l'abast de les mesures de seguretat de producció. És un requisit del RGPD, no una mania.

6. Registre del resultat

S'anota a ~/tramontana-infra/docs/assajos-pitr.md, encara que l'assaig surti perfecte:

| Data       | Objectiu            | Temps  | Criteris  | Incidencies                    |
|------------|---------------------|--------|-----------|--------------------------------|
| 2026-08-18 | 2026-08-17 15:00+02 | 24m11s | 6/6 OK    | Cap                            |
| 2026-06-14 | 2026-06-13 11:00+02 | 41m02s | 5/6       | 4.6 fallada: restic lent per xarxa|

7. Si alguna cosa falla

Fallada Causa probable Acció
requested recovery stop point is before consistent recovery point La còpia base és posterior a l'objectiu Fer servir una còpia base anterior
could not restore file ... from archive Falta un segment de WAL: cadena trencada Incident greu: revisar pg_stat_archiver i la purga
La recuperació no s'atura i arriba al final recovery_target_time mal formatat o zona horària Revisar el format amb +02 explícit
Temps > 30 min Xarxa, xifratge LUKS o descompressió Analitzar i revisar el RTO amb la Marta
Errors de suma de verificació Corrupció a la còpia Incident greu: provar una altra còpia i revisar el maquinari

8. Escalat

Si l'assaig falla a 4.2, 4.3 o 4.5, es declara incident de severitat alta el mateix dia: significa que avui no podríem recuperar les dades. S'avisa la Marta i s'atura qualsevol altra feina fins a resoldre-ho.

Dues notes de disseny del manual d'operació: està escrit perquè l'executi algú que no el va redactar —cada ordre és copiable i cada criteri té un llindar numèric—, i inclou què fer quan falla, que és la part que gairebé tots els manuals d'operació ometen i l'única que cal el dia dolent.

Solució 3

Val la pena la rèplica de la base de dades? Per a: Marta Vidal · De: Operacions de sistemes · 18 d'agost de 2026

Resposta breu: sí, però no pel motiu pel qual se sol muntar, i l'ordre importa. La recomano, amb una inversió moderada, i sobretot per un benefici que no és l'evident.


Primer, una bona notícia. La feina d'aquesta setmana ha millorat la nostra capacitat de recuperació molt més del previst:

Abans Ara
Dades que podríem perdre en un desastre Fins a 4 hores Menys de 15 minuts
Podem desfer un esborrat accidental? No: només tornar a la còpia de la nit Sí, al segon anterior
Temps de restauració completa ~2 hores estimades 24 minuts, mesurats

Aquell salt no ha costat diners: és una tècnica que guarda contínuament el registre de canvis de la base de dades, de manera que podem «rebobinar» a qualsevol instant. Ho he assajat i funciona.

Aleshores, per a què la rèplica? Perquè resol un problema diferent, i convé no confondre'ls:

Problema Ho resolen les còpies? Ho resol la rèplica?
Algú esborra dades per error Sí, al segon anterior No: l'esborrat es copia en mil·lisegons
Corrupció lògica de dades Sí No
S'espatlla el disc del servidor Sí, en 24 minuts Sí, en 5-15 minuts
Els informes pesants alenteixen el web No Sí, i avui mateix
Cal actualitzar el servidor No Sí: es treballa sobre un mentre l'altre atén
El proveïdor té una caiguda general No Només si està en una altra ubicació

I aquí està l'argument principal, que no és el de l'avaria. Avui tenim consultes d'informes que triguen gairebé quatre segons i competeixen amb les reserves dels clients per la mateixa màquina. Amb una rèplica, aquells informes s'executen a la còpia, i el web deixa de notar-los. És una millora de rendiment immediata i perceptible, no una assegurança per a un dia que potser no arribarà.

Què protegeix i què no protegeix la rèplica, en una línia cada cosa:

  • Protegeix que s'espatlli el maquinari del servidor principal.
  • Protegeix el rendiment del web davant dels informes pesants.
  • Permet actualitzar sense finestra de manteniment.
  • No protegeix d'un esborrat per error, ni de dades corruptes: això es copia immediatament. Per a això hi ha les còpies, que continuen sent igual de necessàries.
  • No protegeix si el problema és de l'aplicació o de la xarxa.
  • No s'activa sola. Recomano expressament que el canvi de servidor sigui manual, i t'explico per què al punt següent.

Per què manual, encara que soni pitjor. Amb només dos servidors existeix un risc que al sector s'anomena «cervell dividit»: si es talla la comunicació entre tots dos però tots dos continuen vius, cadascun es pensa que l'altre ha caigut i tots dos comencen a acceptar reserves. El resultat són dues bases de dades amb informació diferent i incompatible, i reconciliar-les pot ser impossible: reserves duplicades sobre la mateixa casa i la mateixa nit. Evitar-ho automàticament exigeix un mínim de cinc màquines. Amb dues, la decisió la pren una persona en cinc o deu minuts, i aquells minuts són un preu petit davant d'aquell risc.

El que costaria:

Concepte Cost
Una màquina més Equivalent al servidor actual
Posada en marxa 2-3 dies, ja automatitzada amb la resta
Manteniment addicional ~1 h al mes
Canvi a l'aplicació Cap per a l'avaria; petit per dirigir els informes a la rèplica

La meva recomanació, per ordre de prioritat:

  1. Ja fet, cost zero: les còpies amb rebobinat al segon i l'assaig trimestral que les verifica. Això era l'urgent i ja està.
  2. Aquest trimestre: la rèplica, justificada sobretot pel rendiment dels informes i, de passada, per poder actualitzar sense tallar el servei. Amb canvi manual.
  3. No recomano, avui: el canvi automàtic de servidor. Exigeix cinc màquines per ser segur, i amb dues crearia un risc més gran que el que evita.
  4. Pendent, i ho porto a part: la taula d'auditoria ocupa ja més que totes les dades de reserves juntes. Necessitem decidir quant temps guardem aquell històric, i és una decisió teva i no meva, perquè té implicacions legals de protecció de dades.

Una última cosa que vull deixar per escrit. Amb la rèplica continuaríem necessitant exactament les mateixes còpies de seguretat que avui. És la confusió més habitual en aquest terreny i la que provoca les pèrdues de dades més greus: tenir una còpia en viu de les dades dona una sensació de seguretat que no es correspon amb la realitat, perquè copia fidelment també els errors. La rèplica és per a les avaries; les còpies són per als errors. Calen totes dues.

Conclusió

PostgreSQL ja no corre amb la configuració que va portar el paquet. Has repartit els 3,8 GB amb fórmules que saps justificar: shared_buffers al 25 % perquè hi ha una segona memòria cau a sota, effective_cache_size al 67 % perquè no reserva res i canvia els plans que tria l'optimitzador, work_mem al valor que resisteix multiplicar-se per connexions i per operacions, i random_page_cost a 1,1 perquè el disc és un SSD i el valor per defecte descriu un món de discos giratoris. I has tancat el cercle amb 07-03: les pàgines enormes en madvise, amb reserva explícita calculada preguntant-ho al mateix servidor en lloc d'estimar-la.

Has resolt l'incident que va quedar obert a 07-02, i de la manera correcta: no pujant max_connections, sinó baixant-lo a 60 i posant PgBouncer en mode transacció al davant, on 63 clients d'aplicació comparteixen quatre connexions reals. Has tancat pg_hba.conf línia a línia, sabent que guanya la primera coincidència i que trust no es posa mai, ni a 127.0.0.1 ni «temporalment». Has creat un rol d'aplicació que no pot crear taules, i ho has verificat intentant-ho. I saps que sslmode=require xifra però no verifica res.

Sobretot, la base de dades ja es pot recuperar. La còpia física amb pg_basebackup, l'arxivat de WAL amb un script el contracte del qual entens —retornar zero només si el segment està segur—, i un procediment de recuperació a un punt en el temps que has executat de principi a fi, amb recovery_target_action = 'pause' per poder mirar abans de comprometre't. El RPO ha passat de 4 hores a minuts sense gastar un euro, i l'assaig trimestral està escrit perquè l'executi algú que no siguis tu. Coneixes el VACUUM, l'inflament, el wraparound del comptador de transaccions i per què una transacció idle in transaction de tres dies és capaç de tombar una base de dades sencera. I saps llegir un EXPLAIN (ANALYZE, BUFFERS), que és el que va convertir una consulta de 3.781 ms en una de 182 ms.

A 08-03 l'escenari canvia completament, i a propòsit. Construiràs un servidor de mitjans per a casa teva: Jellyfin sobre el teu propi maquinari, amb emmagatzematge redundant, acceleració per maquinari per a la transcodificació, comparticions per als dispositius de la família i accés des de fora. És el projecte on comproves que res del que has après era «cosa de servidors d'empresa»: els mateixos UUID a fstab, la mateixa unitat de systemd endurida, les mateixes claus a keyrings, les mateixes còpies verificades i el mateix smartctl vigilant discos. Amb dues diferències que a casa importen i a la feina no: el consum elèctric i el soroll. I amb un advertiment que convé llegir abans de començar, sobre quin contingut és legítim tenir-hi.

Curs de Linux: De Principiant a Administrador de Sistemes

Mòdul 1: Introducció a Linux

Mòdul 2: Comandes Bàsiques de Linux

Mòdul 3: Habilitats Avançades en la Línia de Comandes

Mòdul 4: Scripting en Shell

Mòdul 5: Administració del Sistema

Mòdul 6: Xarxes i Seguretat

Mòdul 7: Temes Avançats

Mòdul 8: Projectes Pràctics

© Copyright 2026. Tots els drets reservats