PostgreSQL 16 lleva funcionando en srv-tramontana desde el Módulo 5. Está instalado, tiene su apt-mark hold y su pinning para que no salte de versión mayor sin decidirlo, escucha en 10.0.2.15:5432 y ufw solo lo permite desde 10.0.2.0/24. Todo eso está bien hecho.

Y sin embargo la configuración es la que trajo el paquete, que está calculada para arrancar en cualquier máquina, incluida una con 512 MB de RAM. En srv-tramontana significa que PostgreSQL usa 128 MB de memoria compartida de los 3,8 GB disponibles, que ordena en disco lo que cabría en memoria, y que cree que el sistema tiene un caché de disco mucho menor del que tiene — lo cual cambia los planes de ejecución que elige.

En 08-01 dejaste una pista concreta: el p99 de $upstream_response_time es de 1,18 s, y las dos rutas más lentas son informes. Cuando el tiempo de la aplicación sube y el de la red no, la culpable casi nunca es la aplicación: es una consulta. Esta lección va a por eso, y a por lo que hay debajo — porque la base de datos es donde vive el negocio real de Tramontana, y perderla es un problema de otra categoría que perder el servidor web.

Contenido

  1. Objetivo, requisitos previos y estado de partida
  2. Arquitectura de procesos y ficheros de PostgreSQL
  3. Ajuste de memoria con fórmulas razonadas
  4. Páginas enormes: la conexión con 07-03
  5. Conexiones: por qué un pool y no un número mayor
  6. Autenticación: pg_hba.conf campo a campo
  7. TLS en las conexiones
  8. Roles y permisos con mínimo privilegio
  9. Copias: lógica, física, WAL y recuperación a un punto en el tiempo
  10. Mantenimiento: vacuum, wraparound y reindexado
  11. Diagnóstico: dónde se va el tiempo
  12. Réplica en streaming
  13. Automatización con Ansible
  14. Operación diaria

Objetivo, requisitos previos y estado de partida

Objetivo. Dejar PostgreSQL ajustado a la máquina, con acceso restringido y cifrado, un rol de aplicación sin privilegios innecesarios, copias que permitan recuperar a un instante concreto dentro del RPO de 4 horas, mantenimiento automático verificado y diagnóstico instrumentado.

Requisitos previos: PostgreSQL 16 instalado (05-03), LVM y /srv/tramontana/backups cifrado con LUKS (05-04), restic con retención GFS (05-08), pass y systemd-creds (06-05), el cortafuegos de 06-03, y Ansible operativo (07-06).

Estado de partida, medido antes de tocar nada:

$ 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

Traducido: 128 MB de shared_buffers, 4 GB de effective_cache_size (que casualmente no está mal, pero por accidente), 4 MB de work_mem y source = default en todo, es decir, nadie ha ajustado nunca nada.

# Tamano actual de la base de datos y de sus tablas mayores
$ sudo -u postgres psql -d tramontana -c "
  SELECT relname, pg_size_pretty(pg_total_relation_size(c.oid)) AS total,
         n_live_tup AS filas
  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  | filas
-------------+---------+--------
 reservas    | 412 MB  | 186420
 disponibles | 168 MB  | 891200
 huespedes   |  54 MB  |  92310
 casas       |  96 kB  |      5
 auditoria   | 1204 MB | 512880

Dos datos relevantes: la base de datos completa ronda los 1,8 GB, es decir, cabe entera en memoria si se configura bien; y la tabla auditoria es la mayor de todas, lo que ya sugiere una política de retención pendiente.

Arquitectura de procesos y ficheros de PostgreSQL

PostgreSQL usa un modelo de proceso por conexión, no de hilos. Eso explica casi todo su comportamiento de memoria y es lo que hace importante el apartado del pool.

$ 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
Proceso Qué hace Por qué te importa
postmaster Proceso padre; acepta conexiones y lanza backends Si muere, todo cae
backend Uno por conexión; ejecuta las consultas Cada uno consume memoria propia: es el motivo del pool
checkpointer Vuelca páginas sucias a disco periódicamente Un checkpoint agresivo produce picos de E/S
background writer Va escribiendo páginas sucias poco a poco Suaviza los picos del checkpointer
walwriter Escribe el registro de escritura anticipada Es lo que garantiza la durabilidad
autovacuum launcher Lanza trabajadores de limpieza Sin él, la base de datos se degrada sola

Y dónde vive cada cosa en Ubuntu, que sigue el diseño de Debian con varios clusters posibles:

$ 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            # los datos: un subdirectorio por base de datos
global          # catalogos compartidos entre bases
pg_wal          # el registro de escritura anticipada
pg_stat         # estadisticas del planificador
pg_tblspc       # enlaces a tablespaces externos
postgresql.auto.conf   # lo que escribe ALTER SYSTEM
PG_VERSION
postmaster.pid
Ruta Qué es Cuidado
PGDATA = /var/lib/postgresql/16/main Todos los datos Nunca se toca a mano con el servidor arrancado
pg_wal/ Registro de escritura anticipada Si se llena, PostgreSQL se para. Nunca borrar ficheros a mano
/etc/postgresql/16/main/postgresql.conf Configuración En Debian/Ubuntu vive fuera de PGDATA
postgresql.auto.conf Lo que escribe ALTER SYSTEM Tiene prioridad sobre postgresql.conf: fuente de confusión
pg_hba.conf Quién puede conectarse y cómo Se aplica con reload, en orden de arriba abajo

Esa fila de postgresql.auto.conf merece un aviso: si alguien ejecutó alguna vez ALTER SYSTEM SET work_mem = '64MB', ese valor gana aunque edites postgresql.conf. Cuando un parámetro no toma el valor que esperas, pg_settings.source te dice de dónde viene.

Siguiendo la convención de drop-ins del curso, la configuración propia no se escribe editando el fichero principal, sino en un fichero aparte incluido 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

Ajuste de memoria con fórmulas razonadas

La máquina tiene 3,8 GB de RAM y 2 vCPU. Hay que repartir esa memoria entre PostgreSQL, la aplicación Tramontana, Nginx y el sistema. Estas son las fórmulas de partida —de partida, no dogmas— y su razonamiento.

Parámetro Fórmula habitual Valor para 3,8 GB Qué controla
shared_buffers 25 % de la RAM 960 MB Caché de páginas propia de PostgreSQL
effective_cache_size 50-75 % de la RAM 2560 MB Lo que el planificador cree que hay cacheado
work_mem RAM ÷ (conexiones × 3) 8 MB Memoria por operación de ordenación o hash
maintenance_work_mem 5-10 % de la RAM 256 MB Para VACUUM, CREATE INDEX, ALTER TABLE
wal_buffers 1/32 de shared_buffers, máx. 16 MB 16 MB Búfer del WAL antes de escribirlo

Y ahora el razonamiento de cada uno, que es lo que distingue ajustar de copiar números:

shared_buffers = 960 MB (25 %). PostgreSQL mantiene su propio caché de páginas además del caché del sistema operativo. Por eso no se pone al 80 % como en otros motores: se produciría un doble almacenamiento, con las mismas páginas en dos sitios y menos memoria total útil. El 25 % es el punto de equilibrio empíricamente contrastado. Con 1,8 GB de datos, casi la mitad de la base de datos vivirá permanentemente aquí.

effective_cache_size = 2560 MB (67 %). Este parámetro no reserva nada: es una pista para el planificador sobre cuánta memoria hay disponible entre shared_buffers y el caché del sistema. Si lo dejas bajo, el planificador cree que leer del disco es caro y evita los índices, prefiriendo recorridos secuenciales. Es uno de los ajustes con mayor impacto por unidad de esfuerzo, y no cuesta un byte de memoria.

work_mem = 8 MB, y por qué es el parámetro peligroso. Es memoria por operación, no por conexión. Una consulta con dos ordenaciones y un hash join puede usar tres veces work_mem. Con 100 conexiones y consultas de tres operaciones:

Peor caso teorico = work_mem x conexiones x operaciones por consulta
                  = 8 MB x 100 x 3 = 2400 MB

Es decir, más de la mitad de la RAM del servidor, encima de los 960 MB de shared_buffers. Esa aritmética es la razón por la que subir work_mem alegremente provoca que el OOM killer mate a PostgreSQL — el mismo mecanismo que viste en 07-02. Con PgBouncer limitando a 25 conexiones reales, el peor caso baja a 600 MB, que sí es asumible. Primero el pool, después work_mem.

Y lo elegante: work_mem se puede subir solo para la consulta que lo necesita, sin tocar el global.

-- En la sesion del informe mensual, no en todo el servidor
BEGIN;
SET LOCAL work_mem = '128MB';
SELECT casa, sum(importe) FROM reservas WHERE fecha >= '2026-01-01' GROUP BY casa;
COMMIT;

maintenance_work_mem = 256 MB. Solo lo usan operaciones de mantenimiento, y como máximo autovacuum_max_workers a la vez. Subirlo acelera enormemente el VACUUM y la creación de índices, con un riesgo mucho menor que work_mem.

El fichero completo:

# /etc/postgresql/16/main/conf.d/10-tramontana.conf
# Ajuste para srv-tramontana: 3,8 GB RAM, 2 vCPU, SSD, PostgreSQL 16
# Base de referencia tomada el 2026-08-18. Medir antes y despues.

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

# ---------- Conexiones ----------
# Se BAJA de 100 a 60. Con PgBouncer delante, 60 sobran, y cada
# conexion reservada cuesta memoria aunque este ociosa.
max_connections = 60
superuser_reserved_connections = 3

# ---------- Registro de escritura anticipada ----------
wal_level = replica              # necesario para replica y PITR
max_wal_size = 2GB               # menos checkpoints, picos mas suaves
min_wal_size = 256MB
checkpoint_completion_target = 0.9   # reparte la escritura en el tiempo
archive_mode = on
archive_command = '/usr/local/bin/archivar_wal.sh %p %f'
archive_timeout = 900s           # fuerza un segmento cada 15 min

# ---------- Planificador (SSD) ----------
# random_page_cost por defecto es 4.0, calibrado para discos giratorios
# donde un salto aleatorio costaba mucho mas que una lectura secuencial.
# En SSD la diferencia es minima: 1.1 refleja la realidad y hace que el
# planificador use indices donde debe.
random_page_cost = 1.1
effective_io_concurrency = 200   # el SSD atiende muchas peticiones a la vez

# ---------- Paralelismo (2 vCPU: contencion) ----------
max_worker_processes = 2
max_parallel_workers = 2
max_parallel_workers_per_gather = 1

# ---------- Registro ----------
log_destination = 'stderr'
logging_collector = off          # deja que systemd/journald lo recoja
log_line_prefix = '%m [%p] %q%u@%d '
log_min_duration_statement = 250ms   # registra las consultas lentas
log_checkpoints = on
log_connections = off            # ruidoso con un pool delante
log_lock_waits = on              # esperas de bloqueo: sintoma importante
log_temp_files = 0               # cualquier fichero temporal = work_mem corto
log_autovacuum_min_duration = 1s

# ---------- Estadisticas ----------
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 y shared_preload_libraries requieren REINICIO
$ 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 es la que te dice si un cambio está aplicado o solo escrito. source = configuration file confirma que el drop-in se está leyendo.

Medir el efecto, que es la mitad del trabajo:

# El indice de aciertos del cache compartido. Se mide ANTES y DESPUES,
# tras dejar pasar unas horas de trafico real.
$ sudo -u postgres psql -d tramontana -c "
  SELECT round(100.0 * sum(blks_hit) / nullif(sum(blks_hit + blks_read), 0), 2)
         AS acierto_pct
  FROM pg_stat_database WHERE datname = 'tramontana';"
 acierto_pct
-------------
       99.42

Antes del ajuste ese número era 91,80 %. Parece una mejora pequeña y no lo es: pasar del 91,8 % al 99,4 % significa que de cada 100 accesos a página, los que van al disco caen de 8,2 a 0,6, es decir, una reducción de casi el 93 % de las lecturas físicas. Por debajo del 95 % hay que sospechar; por debajo del 90 % hay un problema claro de memoria o de consultas que barren tablas enteras.

Páginas enormes: la conexión con 07-03

En 07-03 desactivaste las páginas enormes transparentes (THP) o las dejaste en madvise, y la lección dejó anotado que PostgreSQL era una de las razones. Aquí está el detalle.

El problema no son las páginas enormes en sí, que son útiles: con páginas de 2 MB en lugar de 4 KB, la TLB del procesador cubre 512 veces más memoria y los fallos de traducción se desploman. El problema es el «transparente»: el kernel intenta fusionar páginas y desfragmentar memoria de forma síncrona, dentro del contexto del proceso que pide memoria. Para PostgreSQL, con su shared_buffers de casi un gigabyte y sus procesos de vida corta, eso produce pausas impredecibles de decenas o cientos de milisegundos en consultas que deberían tardar 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 es exactamente la configuración correcta: las páginas enormes solo se usan donde el programa las pide explícitamente con madvise(MADV_HUGEPAGE), y no se imponen a todo el mundo.

Lo mejor de ambos mundos es usar páginas enormes explícitas para shared_buffers:

# 1. Cuantas necesita PostgreSQL (arranca, pregunta y sale)
$ sudo -u postgres /usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/16/main \
      -C shared_memory_size_in_huge_pages
495

# 2. Reservarlas con margen, de forma persistente
$ echo 'vm.nr_hugepages = 520' | \
      sudo tee /etc/sysctl.d/71-postgresql-hugepages.conf
$ sudo sysctl --system

# 3. Pedirle a PostgreSQL que las use
$ 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 y no on: con on, si las páginas reservadas no bastan, PostgreSQL no arranca. Con try arranca igualmente usando páginas normales, que es el comportamiento que quieres en un servidor que debe volver tras un reinicio sin supervisión.

Y el aviso importante: la memoria de vm.nr_hugepages queda reservada y no disponible para nada más. Reservar 520 páginas de 2 MB son 1040 MB que el resto del sistema ya no verá. Con 3,8 GB hay que ser preciso, y por eso el paso 1 no se estima: se pregunta.

Conexiones: por qué un pool y no un número mayor

En 07-02 hubo un incidente que conviene recordar con precisión: la aplicación tiene max_conexiones=80 en /etc/tramontana/app.conf y PostgreSQL tenía max_connections=100. Bajo carga, la aplicación abría sus 80, más las conexiones de informe_reservas.sh, más las de mantenimiento, más las tres reservadas para superusuario... y aparecía FATAL: sorry, too many clients already.

La reacción instintiva —subir max_connections a 300— es exactamente la equivocada, por tres motivos medibles:

Coste de cada conexión Detalle
Memoria Un proceso backend ronda 5-10 MB de memoria propia, ociosa o no
work_mem potencial Cada conexión puede reclamar varios work_mem a la vez
Contención Más procesos compitiendo por 2 vCPU: más cambios de contexto, más contención de bloqueos ligeros

Con 2 vCPU, el número de consultas que se pueden ejecutar realmente a la vez es 2. Trescientas conexiones no dan más trabajo hecho: dan más procesos esperando, más memoria consumida y un rendimiento que cae al subir la concurrencia. Es el mismo fenómeno de saturación del método USE de 05-07.

La regla de partida es conexiones ≈ (2 × núcleos) + husos de disco efectivos, que aquí da unas 5-10 conexiones activas. Necesitamos muchas más abiertas porque la aplicación las mantiene ociosas entre peticiones. Ahí es donde entra el pool.

PgBouncer en modo transacción

$ sudo apt install pgbouncer
$ pgbouncer --version
PgBouncer 1.21.0
Modo Cuándo devuelve la conexión al pool Multiplexación Restricciones
session Al desconectar el cliente Ninguna Ninguna
transaction Al terminar cada transacción Alta Sin SET de sesión, sin sentencias preparadas de sesión, sin LISTEN
statement Tras cada sentencia Máxima Prohíbe las transacciones multi-sentencia

El modo transacción es el correcto para una aplicación web: entre peticiones, la conexión vuelve al pool y otra petición la aprovecha. Cien clientes de aplicación pueden compartir veinticinco conexiones reales porque casi ninguno está ejecutando algo en un instante dado.

Las restricciones hay que verificarlas con Luis antes de activarlo, porque son reales: en modo transacción, un SET search_path fuera de una transacción se pierde, y las sentencias preparadas a nivel de sesión fallan salvo que se active max_prepared_statements.

; /etc/pgbouncer/pgbouncer.ini
[databases]
; La aplicacion se conecta a PgBouncer creyendo 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 de autenticacion delegada: evita duplicar contrasenas
auth_user = pgbouncer_auth

pool_mode = transaction

; --- El dimensionado, que es la decision central ---
; max_client_conn: cuantos clientes de aplicacion aceptamos (baratos)
max_client_conn = 200
; default_pool_size: conexiones REALES a PostgreSQL por par usuario/bd.
; Con 2 vCPU, 25 es holgado. Este es el numero que protege al servidor.
default_pool_size = 25
; Margen temporal para picos, con aviso en el registro
reserve_pool_size = 5
reserve_pool_timeout = 3

; Cerrar conexiones de servidor ociosas mucho tiempo
server_idle_timeout = 600
; Reciclar conexiones cada hora: evita fugas de memoria acumuladas
server_lifetime = 3600
; Si un cliente espera mas de esto por una conexion, error claro
query_wait_timeout = 20

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

El cambio en la aplicación es una línea de /etc/tramontana/app.conf — recordando que el fichero tiene chattr +i y hay que quitarlo antes:

$ 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

Y la comprobación de que el problema de 07-02 está resuelto:

$ 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

Sesenta y tres clientes de aplicación conectados, cuatro conexiones reales trabajando y ninguno esperando. maxwait = 0 es la métrica clave: en cuanto sea mayor que cero de forma sostenida, el pool se está quedando corto.

Antes Después
80 conexiones directas, memoria de 80 procesos 25 como máximo
too many clients already bajo carga maxwait visible y controlado
max_connections = 100 max_connections = 60, con holgura real

Autenticación: pg_hba.conf campo a campo

pg_hba.conf (host-based authentication) decide quién puede conectarse, a qué, desde dónde y cómo. Se evalúa de arriba abajo y gana la primera línea que coincide, lo que significa que una línea permisiva arriba anula todas las restrictivas de abajo.

TIPO      BASE_DE_DATOS   USUARIO          DIRECCION          METODO
Campo Valores Notas
TIPO local, host, hostssl, hostnossl local = socket Unix; hostssl exige TLS
BASE_DE_DATOS nombre, all, replication replication es una pseudo-base para réplicas
USUARIO nombre, all, +grupo + significa «miembro del rol»
DIRECCION CIDR, samenet, vacío en local Cuanto más estrecho, mejor
MÉTODO scram-sha-256, peer, cert, trust, reject Ver tabla siguiente
Método Cómo autentica Veredicto
scram-sha-256 Reto-respuesta; la contraseña no viaja El correcto para conexiones de red
md5 Obsoleto y débil Migrar a SCRAM
peer Comprueba el usuario Unix del socket local Ideal para tareas locales de mantenimiento
ident Consulta a un servidor ident remoto No usar: confía en la máquina remota
cert Certificado de cliente TLS Excelente para servicio-a-servicio
trust Acepta a cualquiera sin comprobar nada Nunca, ver abajo
reject Deniega explícitamente Útil para cortar antes de una regla general

Por qué trust nunca, ni siquiera «temporalmente» y ni siquiera en 127.0.0.1: significa que cualquier proceso que pueda abrir un socket al puerto entra como el usuario que declare ser, incluido postgres. En un servidor donde corre una aplicación web, basta una vulnerabilidad de tipo SSRF —conseguir que la aplicación haga una petición a 127.0.0.1:5432— para tener control total de la base de datos. Y lo que se pone «temporalmente» un martes sigue ahí dos años después.

# /etc/postgresql/16/main/pg_hba.conf
# El orden IMPORTA: gana la primera coincidencia.

# --- 1. Mantenimiento local por socket Unix ---
# 'peer' compara el usuario del sistema con el rol pedido: 'sudo -u postgres
# psql' funciona sin contrasena, y nadie mas puede hacerse pasar por el.
local   all             postgres                                peer
local   all             all                                     peer

# --- 2. PgBouncer y la aplicacion, desde el bucle local ---
# Solo el rol de aplicacion, solo su base de datos, con 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. Monitorizacion (08-06), solo lectura de estadisticas ---
host    postgres        monitor         127.0.0.1/32            scram-sha-256

# --- 4. Replicacion (apartado 12), con TLS OBLIGATORIO ---
# 'hostssl' rechaza la conexion si no va cifrada; el WAL lleva
# los datos completos y no puede viajar en claro.
hostssl replication     replicador      10.0.2.16/32            scram-sha-256

# --- 5. Acceso administrativo desde la red interna, cifrado ---
hostssl tramontana      operador        10.0.2.0/24             scram-sha-256

# --- 6. Denegacion explicita de todo lo demas ---
# Redundante (el comportamiento por defecto ya es denegar) pero deja
# constancia de la intencion y produce un mensaje claro en el registro.
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 filas)

pg_hba_file_rules es una vista que valida el fichero sin recargarlo: te dice si hay líneas con errores antes de que rompan el acceso. Es el equivalente de nginx -t para pg_hba.conf, y hay que usarla siempre — «nunca cierres la puerta por la que estás entrando» también aplica aquí.

Y la verificación de que las reglas hacen lo que crees:

# Debe 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 debe funcionar: rol de aplicacion contra otra base de datos
$ 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 en las conexiones

Con la aplicación y PgBouncer en la misma máquina, el tráfico va por el bucle local y el cifrado aporta poco. Pero la réplica de 10.0.2.16 y los accesos administrativos desde la red interna sí lo necesitan: el WAL contiene todos los datos en claro.

# Ubuntu genera un certificado autofirmado y lo enlaza al instalar
$ sudo ls -l /var/lib/postgresql/16/main/server.crt
lrwxrwxrwx 1 postgres postgres 36 -> /etc/ssl/certs/ssl-cert-snakeoil.pem

Para tráfico interno entre servidores propios, lo correcto no es un certificado de Let's Encrypt —que exige un nombre público y validación externa— sino una CA interna con las herramientas de openssl de 06-05:

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

# Certificado de servidor para la base de datos
$ openssl req -new -nodes -out bd.csr -keyout bd.key \
      -subj "/CN=srv-tramontana.interno"
$ 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 detalle que la mayoría de la gente pasa por alto: en el cliente, sslmode=require cifra pero no verifica el certificado, así que no protege de un intermediario. Solo verify-full comprueba la cadena y el nombre.

sslmode Cifra Verifica CA Verifica nombre
disable No — —
require Sí No No
verify-ca Sí Sí No
verify-full Sí Sí Sí

Roles y permisos con mínimo privilegio

Es el mismo principio de 05-01 y 05-02, aplicado dentro de la base de datos. Un rol de aplicación no debe poder crear tablas, alterar el esquema, leer otras bases de datos ni, por supuesto, ser SUPERUSER.

-- Ejecutado como postgres: sudo -u postgres psql -d tramontana

-- 1. Rol de aplicacion: solo puede conectarse y trabajar con datos
CREATE ROLE svc_tramontana WITH LOGIN
    PASSWORD 'se-inyecta-desde-pass'
    NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT
    CONNECTION LIMIT 30;

-- 2. Cerrar el esquema publico, que en PostgreSQL 15+ ya viene
--    restringido, pero conviene ser explicito. Antes de la 15,
--    CUALQUIER usuario podia crear objetos en 'public'.
REVOKE ALL ON SCHEMA public FROM PUBLIC;
REVOKE ALL ON DATABASE tramontana FROM PUBLIC;

-- 3. Esquema propio de la aplicacion
CREATE SCHEMA IF NOT EXISTS app AUTHORIZATION postgres;

-- 4. Permisos acotados: conectarse, usar el esquema, y DML sobre
--    las tablas existentes. NO CREATE: no puede alterar el 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. Y para las tablas FUTURAS, que es lo que casi todo el mundo olvida:
--    sin esto, la tabla que cree la proxima migracion sera inaccesible
--    para la aplicacion y el despliegue fallara en produccion.
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 solo lectura para informes (informe_reservas.sh) y para
--    la monitorizacion de 08-06
CREATE ROLE lector_informes WITH LOGIN PASSWORD 'otra-distinta'
    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 monitorizacion: rol predefinido, sin ser superusuario
CREATE ROLE monitor WITH LOGIN PASSWORD 'otra-mas';
GRANT pg_monitor TO monitor;

-- 8. Rol de replicacion (apartado 12)
CREATE ROLE replicador WITH LOGIN REPLICATION PASSWORD 'y-otra';

Verificar es tan importante como conceder — comprobar que lo prohibido está prohibido:

$ PGPASSWORD=$(pass tramontana/db) psql -h 127.0.0.1 -U svc_tramontana \
      -d tramontana -c "CREATE TABLE prueba(id int);"
ERROR:  permiso denegado para el esquema app

$ PGPASSWORD=$(pass tramontana/db) psql -h 127.0.0.1 -U svc_tramontana \
      -d tramontana -c "SELECT * FROM pg_shadow;"
ERROR:  permiso denegado para la tabla pg_shadow

$ sudo -u postgres psql -c "\du" | grep -E 'svc_tramontana|lector'
 lector_informes | 5 conexiones                       | {}
 svc_tramontana  | Sin herencia, 30 conexiones        | {}

La contraseña, por supuesto, no se escribe en el SQL: se inyecta desde pass como en 06-05.

$ sudo -u postgres psql -d tramontana <<SQL
ALTER ROLE svc_tramontana PASSWORD '$(pass tramontana/db)';
SQL
# Y se borra del historial de psql, que guarda TODO lo tecleado
$ shred -u ~/.psql_history 2>/dev/null || true

Copias: lógica, física, WAL y recuperación a un punto en el tiempo

respaldo_tramontana.sh hace hoy un pg_dump. Es correcto y no es suficiente, y aquí está por qué.

pg_dump (lógica) pg_basebackup (física)
Qué copia Sentencias SQL para reconstruir Los ficheros de PGDATA byte a byte
Portable entre versiones mayores Sí No
Restauración selectiva de una tabla Sí No, es todo o nada
Tiempo de restauración de 1,8 GB ~10 min (reconstruye índices) ~2 min (copia ficheros)
Permite PITR No Sí, con archivado de WAL
Granularidad del punto de recuperación El instante del volcado Cualquier instante
Coste en el servidor Alto: lee y serializa todo Moderado: E/S secuencial

Ambas son necesarias, y no son alternativas: la lógica te salva de una migración de versión mayor y permite recuperar una tabla concreta; la física con WAL es la única que cumple un RPO de 4 horas sin hacer volcados cada cuatro horas.

El archivado de WAL

El registro de escritura anticipada contiene todos los cambios, en orden. Si guardas una copia física y todos los segmentos de WAL desde entonces, puedes reproducir la historia hasta cualquier instante.

#!/usr/bin/env bash
#
# /usr/local/bin/archivar_wal.sh - Archiva un segmento de WAL
#
# PostgreSQL lo invoca como: archivar_wal.sh %p %f
#   %p = ruta relativa al segmento; %f = solo el nombre
#
# CONTRATO CRITICO:
#   - Debe devolver 0 SOLO si el segmento esta a salvo y verificado.
#   - Si devuelve != 0, PostgreSQL REINTENTA indefinidamente y NO borra
#     el segmento. Es lo correcto: mejor llenar pg_wal que perder datos.
#   - NUNCA debe sobrescribir un fichero existente con contenido distinto.
#
set -euo pipefail

readonly ORIGEN="$1"
readonly NOMBRE="$2"
readonly DESTINO="/srv/tramontana/backups/wal"

umask 077

# Ya archivado e identico: exito idempotente (07-06)
if [[ -f "${DESTINO}/${NOMBRE}" ]]; then
    if cmp -s "$ORIGEN" "${DESTINO}/${NOMBRE}"; then
        exit 0
    fi
    logger -t archivar_wal "ERROR: ${NOMBRE} ya existe con contenido DISTINTO"
    exit 1
fi

# Copia atomica: escribir a temporal y renombrar. Si el proceso muere a
# medias, no queda un segmento truncado que parezca valido.
tmp="${DESTINO}/.${NOMBRE}.$$"
trap 'rm -f "$tmp"' EXIT

cp "$ORIGEN" "$tmp"
sync -f "$tmp"                  # forzar a disco ANTES de renombrar
mv "$tmp" "${DESTINO}/${NOMBRE}"
sync "$DESTINO"

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

$ sudo -u postgres psql -c "SELECT pg_switch_wal();"   # forzar un segmento
$ 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 es la métrica que hay que vigilar sin descanso: si el archivado falla, pg_wal crece hasta llenar el disco y PostgreSQL se detiene. Va directa a la monitorización de 08-06.

La copia 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ón Qué hace
-Ft -z Formato tar comprimido: un fichero por tablespace
-Xs Incluye el WAL generado durante la copia (streaming)
-c fast Checkpoint inmediato: empieza ya, con un pico de E/S
--manifest-checksums Manifiesto verificable con pg_verifybackup
$ sudo -u postgres pg_verifybackup /srv/tramontana/backups/base/2026-08-18
backup successfully verified

Y la integración con restic de 05-08, que es lo que la lleva fuera del servidor:

# Fragmento anadido a respaldo_tramontana.sh
respaldar_postgresql() {
    local destino="${TRAMONTANA_BACKUP_DIR}/base/$(date +%F)"
    log "iniciando copia base de PostgreSQL"
    sudo -u postgres pg_basebackup -h /var/run/postgresql -U postgres \
        -D "$destino" -Ft -z -Xs -c fast --manifest-checksums=SHA256 \
        || morir 74 "pg_basebackup fallo"
    sudo -u postgres pg_verifybackup "$destino" \
        || morir 65 "la copia base NO verifica: no se sube"
    log "copia base verificada: $(formatear_bytes "$(du -sb "$destino" | cut -f1)")"

    restic backup "$destino" "${TRAMONTANA_BACKUP_DIR}/wal" \
        --tag postgresql --tag base \
        || morir 74 "restic fallo al subir la copia"
}

El pg_verifybackup antes de subir es deliberado: subir una copia corrupta consume espacio y, peor aún, da una falsa sensación de seguridad. La regla de 05-08 se cumple aquí: una copia no verificada no es una copia.

Recuperación a un punto en el tiempo, con el procedimiento completo

Este es el procedimiento que hay que tener escrito antes de necesitarlo, y probado. El escenario: a las 14:32 alguien ejecuta DELETE FROM app.reservas WHERE fecha < '2026-08-01' sin WHERE correcto y borra 40.000 filas. Se detecta a las 14:51.

# ===== SE HACE EN srv-tramontana-pruebas, NUNCA sobre produccion =====
# Restaurar sobre el servidor vivo destruye la unica copia de los datos
# posteriores al incidente. Se restaura aparte y se extrae lo perdido.

# 1. Parar PostgreSQL en la maquina de recuperacion y vaciar PGDATA
$ sudo systemctl stop postgresql@16-main
$ sudo -u postgres mv /var/lib/postgresql/16/main \
      /var/lib/postgresql/16/main.roto-$(date +%F)
$ sudo -u postgres mkdir -m 0700 /var/lib/postgresql/16/main

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

# 3. Decirle a PostgreSQL de donde saca el WAL y hasta donde reproducir
$ sudo -u postgres tee /var/lib/postgresql/16/main/postgresql.auto.conf <<'EOF'
restore_command = 'cp /srv/tramontana/backups/wal/%f %p'
# Reproducir hasta JUSTO ANTES del DELETE. Un segundo de margen.
recovery_target_time = '2026-08-18 14:31:55+02'
recovery_target_action = 'pause'
EOF

# 'pause' y no 'promote': el servidor se detiene en el instante objetivo
# y espera. Asi puedes MIRAR los datos antes de confirmar. Si te pasaste,
# reinicias con otro objetivo sin haber destruido nada.

# 4. La senal que activa el modo recuperacion (PostgreSQL >= 12)
$ sudo -u postgres touch /var/lib/postgresql/16/main/recovery.signal

# 5. Arrancar y 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 antes de confirmar nada
$ sudo -u postgres psql -d tramontana -c \
    "SELECT count(*) FROM app.reservas WHERE fecha < '2026-08-01';"
 count
-------
 40218

# 7. Confirmar: promover el servidor recuperado
$ 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. Extraer SOLO lo perdido y llevarlo a produccion
$ sudo -u postgres pg_dump -d tramontana -t app.reservas \
      --data-only --where="fecha < '2026-08-01'" > /tmp/recuperadas.sql
$ scp /tmp/recuperadas.sql [email protected]:/tmp/
$ ssh [email protected] \
    "sudo -u postgres psql -d tramontana -1 -f /tmp/recuperadas.sql"

Cinco decisiones de ese procedimiento que hay que entender:

  1. Se recupera en otra máquina. Restaurar encima de producción destruye las transacciones posteriores al incidente, que son legítimas y no están en ninguna otra parte.
  2. recovery_target_action = 'pause'. Permite inspeccionar antes de comprometerse. Con promote, si te pasaste de instante, hay que empezar de cero.
  3. El objetivo se fija un segundo antes, no en el instante exacto: la marca temporal del incidente rara vez se conoce con precisión de milisegundos.
  4. Se extrae solo lo perdido. Sustituir la base entera perdería 19 minutos de reservas reales.
  5. psql -1 envuelve la importación en una transacción: o entra todo o no entra nada.

Y el resultado que va al runbook, con números:

Métrica Valor medido
RPO real conseguido Segundos (con archive_timeout=900s, peor caso 15 min)
Tiempo de restauración de 1,8 GB 4 min 20 s
Tiempo total del procedimiento con verificación ~25 min
RTO acordado 2 h

El RPO pasa de 4 horas a minutos sin coste adicional, y eso es una noticia que merece un párrafo en el informe a Marta.

Mantenimiento: vacuum, wraparound y reindexado

PostgreSQL usa control de concurrencia multiversión (MVCC): un UPDATE no modifica la fila, escribe una versión nueva y marca la vieja como muerta. Un DELETE solo marca. Las versiones muertas siguen ocupando espacio hasta que alguien las limpia.

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

Un 31,7 % de tuplas muertas en disponibles es hinchazón (bloat): un tercio de esa tabla es basura que se lee en cada recorrido secuencial, ocupa shared_buffers y hace que los índices apunten a páginas casi vacías. Y el last_autovacuum de hace dos días indica que autovacuum no está siguiendo el ritmo de esa tabla.

Operación Qué hace Bloquea
VACUUM Marca el espacio muerto como reutilizable No: convive con la carga
VACUUM FULL Reescribe la tabla y devuelve espacio al SO Sí, ACCESS EXCLUSIVE: nadie lee ni escribe
ANALYZE Recalcula las estadísticas del planificador No
REINDEX Reconstruye índices hinchados Sí, salvo CONCURRENTLY

VACUUM FULL en producción es un error clásico: sobre una tabla de 400 MB tarda minutos, durante los cuales la aplicación no puede tocarla. Si hace falta recuperar espacio en caliente, la herramienta es pg_repack.

Ajustar autovacuum donde hace falta

Los valores por defecto disparan la limpieza cuando las tuplas muertas superan el 20 % de la tabla. En una tabla de 900.000 filas eso son 180.000 tuplas muertas antes de mover un dedo, y una limpieza cara cada vez.

-- Ajuste POR TABLA, que es como se hace: no se cambia el global por
-- una tabla problematica.
ALTER TABLE app.disponibles SET (
    autovacuum_vacuum_scale_factor = 0.02,   -- limpiar al 2 %, no al 20 %
    autovacuum_vacuum_threshold = 1000,
    autovacuum_analyze_scale_factor = 0.01,
    autovacuum_vacuum_cost_delay = 2         -- mas agresivo
);

-- La tabla de auditoria solo crece: no necesita el mismo tratamiento,
-- necesita una politica de retencion.
ALTER TABLE app.auditoria SET (autovacuum_vacuum_scale_factor = 0.1);
# conf.d/30-mantenimiento.conf — ajustes globales
autovacuum_max_workers = 2           # con 2 vCPU, no mas
autovacuum_naptime = 30s
autovacuum_vacuum_cost_limit = 1000  # por defecto 200: demasiado lento

El wraparound, y por qué es una emergencia

Este es el fallo más grave que puede sufrir una base de datos PostgreSQL mal mantenida, y el que menos gente conoce hasta que lo sufre.

Cada transacción recibe un identificador de 32 bits. Son unos 4.000 millones, y se agotan. PostgreSQL resuelve la circularidad tratando los identificadores como un círculo donde 2.000 millones quedan «en el pasado» y 2.000 millones «en el futuro». Para que eso funcione, las filas muy antiguas deben marcarse como congeladas (frozen): visibles para todos, sin importar el contador. De eso se encarga VACUUM.

Si VACUUM no llega a hacerlo —porque autovacuum está desactivado, o porque una transacción abierta desde hace días lo bloquea— PostgreSQL avisa, luego avisa más fuerte, y finalmente se niega a aceptar escrituras:

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 datos queda de solo lectura y la única salida es parar el servicio y limpiar en modo monousuario, lo que en una base grande puede llevar horas. Es una caída total, y por eso se vigila:

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

Un 2,4 % es una situación perfectamente sana. La regla operativa:

pct_hacia_limite Situación Acción
< 25 % Normal Ninguna
25-50 % Vigilar Revisar autovacuum y transacciones largas
50-75 % Aviso VACUUM FREEZE manual planificado
> 75 % Crítico Intervenir ya

Las dos causas casi siempre son las mismas, y ambas se detectan en una consulta:

$ sudo -u postgres psql -c "
  SELECT pid, state, age(backend_xid) AS edad,
         now()-xact_start AS duracion, 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        |  edad   |    duracion     |         consulta
------+---------------------+---------+-----------------+---------------------------
 4471 | idle in transaction | 8812044 | 3 days 04:12:09 | BEGIN; SELECT * FROM app...

idle in transaction durante tres días es el enemigo: una transacción abierta impide congelar todo lo posterior y bloquea la limpieza de toda la base de datos. Suele ser una aplicación que abrió una transacción y no la cerró. La defensa es preventiva:

# conf.d/30-mantenimiento.conf
idle_in_transaction_session_timeout = 300s   # matar tras 5 min ociosa
statement_timeout = 60s                      # ninguna consulta mas de 1 min
lock_timeout = 10s                           # no esperar bloqueos eternamente

statement_timeout = 60s es global; los informes que legítimamente tardan más lo suben en su sesión con SET LOCAL statement_timeout.

Diagnóstico: dónde se va el tiempo

Retomamos el hilo de 08-01: el p99 de $upstream_response_time era 1,18 s y las rutas lentas eran informes.

pg_stat_statements: la consulta que más tiempo consume

$ 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_media,
         round(100.0*shared_blks_hit/nullif(shared_blks_hit+shared_blks_read,0),1) AS acierto,
         left(query, 60) AS consulta
  FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 5;"
 ms_total  | calls | ms_media | acierto |                  consulta
-----------+-------+----------+---------+---------------------------------------------
   4128200 |  1088 |   3794.3 |    41.2 | SELECT casa, sum(importe) FROM app.reservas
    881400 | 92104 |      9.6 |    99.8 | SELECT * FROM app.disponibles WHERE casa =
    412900 |  1204 |    342.9 |    98.1 | SELECT * FROM app.reservas WHERE huesped_id

La primera fila es la culpable, y la columna acierto de 41,2 % lo confirma: esa consulta lee del disco más de la mitad de lo que necesita. Ordena por tiempo total, no por tiempo medio: una consulta de 10 ms ejecutada 92.000 veces consume más servidor que una de 4 segundos ejecutada 1.000, y es un error común optimizar la lenta y espectacular en lugar de la frecuente.

EXPLAIN (ANALYZE, BUFFERS)

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT casa, sum(importe) FROM app.reservas
WHERE fecha >= '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 reservas  (cost=0.00..47188.20 rows=186782 width=18)
                             (actual time=0.031..3402.118 rows=186420 loops=1)
         Filter: (fecha >= '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

Cómo se lee esto, que es una habilidad que se entrena:

Elemento Qué dice Aquí
cost= Estimación del planificador 48122 unidades arbitrarias
actual time= Tiempo real: primera fila..última 3,78 s
rows= estimado vs rows= actual Si difieren mucho, faltan estadísticas 186782 vs 186420: bien
Buffers: shared hit / read Páginas de caché / de disco 41208 de disco: el problema
Seq Scan Recorrido secuencial completo La tabla entera para filtrar nada
Rows Removed by Filter: 0 El filtro no descarta nada El WHERE es inútil aquí

Diagnóstico: el WHERE fecha >= '2026-01-01' no descarta ninguna fila porque todas las reservas son posteriores. La consulta lee 41.208 páginas de disco —322 MB— para agregar cinco grupos. Y sum(importe) obliga a leer las filas completas, así que un índice sobre fecha no ayudaría: el planificador seguiría prefiriendo el recorrido secuencial.

La solución correcta aquí no es un índice, sino un índice que cubra la consulta entera:

-- Indice de cobertura: contiene todo lo que la consulta necesita, asi
-- que se responde SIN tocar la tabla (Index Only Scan).
CREATE INDEX CONCURRENTLY idx_reservas_fecha_casa_importe
    ON app.reservas (fecha) INCLUDE (casa, importe);
ANALYZE app.reservas;
 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_reservas_fecha_casa_importe on reservas
         (actual time=0.048..96.221 rows=186420 loops=1)
         Index Cond: (fecha >= '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: veinte veces más rápido, y las páginas leídas de disco caen de 41.208 a 3.811. Heap Fetches: 0 confirma que no se toca la tabla en absoluto.

CREATE INDEX CONCURRENTLY es obligatorio en producción: la versión normal bloquea las escrituras de la tabla mientras construye. CONCURRENTLY tarda más y no bloquea, a cambio de que si falla deja un índice inválido que hay que eliminar.

Consultas lentas en el registro

Con log_min_duration_statement = 250ms, las lentas quedan en el 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 fecha DESC

Ese mensaje de fichero temporal es oro: significa que una ordenación no cupo en work_mem y se hizo en disco. 18 MB con work_mem = 8 MB. La respuesta es subir work_mem solo en esa sesión, no globalmente.

Índices que faltan y que sobran

# Tablas con muchos recorridos secuenciales sobre volumen grande
$ sudo -u postgres psql -d tramontana -c "
  SELECT relname, seq_scan, seq_tup_read, idx_scan,
         seq_tup_read/nullif(seq_scan,0) AS filas_por_recorrido
  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 | filas_por_recorrido
----------+----------+--------------+----------+---------------------
 reservas |     1088 |    202744960 |    91204 |              186346

# Indices que nadie usa: ocupan espacio y ralentizan cada INSERT
$ sudo -u postgres psql -d tramontana -c "
  SELECT indexrelname, pg_size_pretty(pg_relation_size(indexrelid)) AS tam, 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       |  tam  | idx_scan
-----------------------+-------+----------
 idx_reservas_telefono | 22 MB |        0

Un índice sin usar no es neutro: ocupa 22 MB de disco y de caché, y cada INSERT, UPDATE y DELETE tiene que actualizarlo. Antes de eliminarlo hay que confirmar que las estadísticas cubren un periodo representativo —un índice usado solo en el cierre mensual parecerá inútil el día 12.

Réplica en streaming

Retomamos 07-07, donde quedó decidido: replicación asíncrona (el RPO de 4 h la hace más que suficiente) y promoción manual (con dos nodos no hay quórum, y la automática produce datos divergentes).

# ===== En el PRIMARIO (10.0.2.15) =====
# wal_level = replica y archive_mode = on ya estan puestos.
$ sudo -u postgres psql -c "
  SELECT slot_name FROM pg_create_physical_replication_slot('replica_16');"

Una ranura de replicación (replication slot) hace que el primario conserve los segmentos de WAL que la réplica todavía no ha consumido, aunque la réplica lleve horas parada. Es lo que garantiza que una réplica que se cae por la noche pueda recuperarse por la mañana sin recrearla entera.

Y trae el riesgo simétrico, que hay que conocer: una ranura de una réplica que nunca vuelve llena pg_wal hasta detener el primario. Por eso se acota:

# conf.d/40-replicacion.conf en el PRIMARIO
max_wal_senders = 3
max_replication_slots = 3
wal_keep_size = 1GB
# Limite de seguridad: si la ranura acumula mas de 8 GB, se invalida.
# Mejor perder la replica que detener el primario.
max_slot_wal_keep_size = 8GB
# ===== En 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ón Qué hace
-R Escribe postgresql.auto.conf y standby.signal: la réplica queda lista
-S replica_16 Usa la ranura creada
-Xs Recibe el WAL mientras copia: no se pierde nada
sslmode=verify-full El WAL viaja cifrado y verificado
$ 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

Y la vigilancia, desde el primario:

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

retraso_bytes es cuántos datos se perderían si el primario cayera ahora: la métrica más importante de todo el apartado, y va a la monitorización de 08-06 junto a la edad de la última copia.

La réplica sirve además para algo inmediato y valioso: hot_standby = on permite consultas de solo lectura sobre ella, así que los informes pesados —los de 3,7 segundos— pueden ejecutarse ahí sin tocar el primario.

# En la replica, para que las consultas largas no se corten por conflicto
# con la reproduccion del WAL
$ echo "max_standby_streaming_delay = 300s" | \
      sudo tee -a /etc/postgresql/16/main/conf.d/40-replicacion.conf

Y el recordatorio que nunca sobra, ya enunciado en 07-07: la réplica no es una copia de seguridad. El DELETE del apartado 9 llega a la réplica en cuatro milisegundos. Para deshacerlo está el PITR; la réplica protege del fallo de hardware, no del error humano.

Automatización con Ansible

# ~/tramontana-infra/roles/basedatos/defaults/main.yml
---
bd_version: 16
bd_ram_mb: 3800
# Las formulas viven en las variables: cambia la RAM y todo se 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_redes_permitidas:
  - { red: '127.0.0.1/32', bd: tramontana, rol: svc_tramontana, tipo: host }
  - { red: '10.0.2.0/24',  bd: tramontana, rol: operador,       tipo: hostssl }
  - { red: '10.0.2.16/32', bd: replication, rol: replicador,    tipo: hostssl }
# ~/tramontana-infra/roles/basedatos/tasks/main.yml
---
- name: Comprobar que la RAM declarada coincide con la real
  ansible.builtin.assert:
    that: (ansible_memtotal_mb - bd_ram_mb) | abs < 400
    fail_msg: >-
      bd_ram_mb ({{ bd_ram_mb }}) no coincide con la RAM real
      ({{ ansible_memtotal_mb }} MB). Los calculos de memoria serian
      incorrectos y podrian dejar el servidor sin memoria.

- name: Instalar PostgreSQL y PgBouncer
  ansible.builtin.apt:
    name:
      - "postgresql-{{ bd_version }}"
      - "postgresql-contrib-{{ bd_version }}"
      - pgbouncer
      - python3-psycopg2        # necesario para los modulos postgresql_*
    state: present
  tags: [paquetes]

- name: Bloquear la version mayor (politica de 05-03)
  ansible.builtin.dpkg_selections:
    name: "postgresql-{{ bd_version }}"
    selection: hold

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

- name: Instalar el script de archivado de WAL
  ansible.builtin.copy:
    src: archivar_wal.sh
    dest: /usr/local/bin/archivar_wal.sh
    owner: root
    group: root
    mode: '0755'

- name: Activar include_dir en 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 el ajuste calculado
  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: Recargar postgresql

- name: Forzar los handlers antes de verificar
  ansible.builtin.meta: flush_handlers

# --- Verificacion: pg_hba invalido deja el servidor inaccesible ---
- name: Verificar que pg_hba.conf no tiene errores
  community.postgresql.postgresql_query:
    db: postgres
    login_unix_socket: /var/run/postgresql
    query: "SELECT count(*) AS errores FROM pg_hba_file_rules WHERE error IS NOT NULL"
  become: true
  become_user: postgres
  register: hba
  failed_when: hba.query_result[0].errores | int > 0

- name: Crear los roles con contrasena desde Vault
  community.postgresql.postgresql_user:
    name: "{{ item.nombre }}"
    password: "{{ item.password }}"
    role_attr_flags: "{{ item.flags }}"
    conn_limit: "{{ item.limite | default(omit) }}"
    state: present
  become: true
  become_user: postgres
  loop:
    - { nombre: svc_tramontana, password: "{{ vault_bd_app }}",
        flags: 'LOGIN,NOSUPERUSER,NOCREATEDB,NOCREATEROLE', limite: 30 }
    - { nombre: replicador, password: "{{ vault_bd_replicador }}",
        flags: 'LOGIN,REPLICATION' }
    - { nombre: monitor, password: "{{ vault_bd_monitor }}", flags: 'LOGIN' }
  no_log: true                     # las contrasenas no salen por pantalla
  tags: [roles]

- name: Conceder pg_monitor al rol de monitorizacion
  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 el archivado 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: archivador
  failed_when: archivador.query_result[0].failed_count | int > 0
  tags: [verificar]
# roles/basedatos/handlers/main.yml
---
- name: Recargar 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 reinicio corta las conexiones. Solo se dispara cuando cambia un
  # parametro que lo requiere, y en produccion se ejecuta en ventana.
  ansible.builtin.systemd:
    name: "postgresql@{{ bd_version }}-main"
    state: restarted

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

El assert inicial merece atención: sin él, aplicar el rol a una máquina con menos RAM de la declarada produciría un shared_buffers mayor que la memoria física y PostgreSQL no arrancaría. Es la clase de fallo que Ansible convierte en global si no se comprueba.

Operación diaria

#!/usr/bin/env bash
# Fragmento a integrar en revision_salud.sh: bloque de base de datos
comprobar_basedatos() {
    local estado=0

    # 1. Conexiones esperando en el pool
    local esperando
    esperando="$(psql -h 127.0.0.1 -p 6432 -U operador -d pgbouncer -tAc \
        "SHOW POOLS" | awk -F'|' '$1=="tramontana"{print $4}')"
    if (( esperando > 0 )); then
        error "hay $esperando clientes esperando conexion"; estado=1
    fi

    # 2. Retraso de la replica (bytes que se perderian ahora mismo)
    local retraso
    retraso="$(sudo -u postgres psql -tAc \
        "SELECT coalesce(max(pg_wal_lsn_diff(sent_lsn,replay_lsn)),0)::bigint
         FROM pg_stat_replication")"
    if (( retraso > 104857600 )); then          # 100 MB
        error "la replica va $(formatear_bytes "$retraso") por detras"; estado=1
    fi

    # 3. Fallos de archivado de WAL: si falla, pg_wal crece sin limite
    local fallos
    fallos="$(sudo -u postgres psql -tAc "SELECT failed_count FROM pg_stat_archiver")"
    if (( fallos > 0 )); then
        error "el archivado de WAL ha fallado $fallos veces"; estado=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"; estado=2
    elif (( pct > 50 )); then
        error "wraparound al ${pct}%"; estado=1
    fi

    # 5. Transacciones abiertas eternamente
    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 transacciones ociosas de mas de 10 min"; estado=1
    fi

    (( estado == 0 )) && log "base de datos correcta"
    return "$estado"
}

Qué se mira y cuándo:

Frecuencia Comprobación Umbral de alerta
Continuo (08-06) Retraso de la réplica > 100 MB
Continuo pg_stat_archiver.failed_count > 0
Continuo Clientes esperando en PgBouncer > 0 sostenido
Continuo Espacio libre en pg_wal < 20 %
Diario Índice de acierto de caché < 95 %
Diario Transacciones idle in transaction largas > 10 min
Semanal Tuplas muertas por tabla > 20 %
Semanal Top 5 de pg_stat_statements Cambios en el ranking
Mensual Edad del contador de transacciones > 50 %
Mensual Índices sin usar y tamaño de la BD Crecimiento inesperado
Trimestral Ensayo completo de PITR Debe completarse en < 30 min

Esa última fila es la más importante de la tabla y la que más se incumple. Una copia que nunca se ha restaurado no es una copia: es un fichero. El ensayo trimestral entra en el simulacro semestral que propusiste en 07-07.

Errores Comunes y Consejos

  • Subir max_connections en vez de poner un pool. Cada conexión cuesta memoria y contención. Con 2 vCPU, 300 conexiones dan menos trabajo hecho que 25.
  • Subir work_mem globalmente. Es memoria por operación y por conexión: multiplícalo antes de tocarlo, o el OOM killer se encargará de recordártelo.
  • Poner shared_buffers al 80 %. Se duplica el almacenamiento con el caché del sistema y hay menos memoria útil que con el 25 %.
  • Dejar random_page_cost = 4 en un SSD. El planificador evita índices que debería usar, y las consultas se degradan sin causa aparente.
  • Editar postgresql.conf cuando postgresql.auto.conf tiene el mismo parámetro. Gana el segundo. Consulta pg_settings.source.
  • Usar trust en pg_hba.conf, aunque sea «temporalmente» en localhost. Es control total de la base de datos para cualquier proceso local, y lo temporal dura años.
  • Recargar pg_hba.conf sin mirar pg_hba_file_rules. Puedes quedarte fuera de tu propia base de datos.
  • Creer que sslmode=require verifica el certificado. No lo hace. Solo verify-full.
  • Dar SUPERUSER al rol de la aplicación «para que no dé problemas». Una inyección SQL pasa de leer datos a ejecutar comandos en el servidor.
  • Olvidar ALTER DEFAULT PRIVILEGES. La aplicación funciona hasta que una migración crea una tabla nueva, y entonces falla en producción.
  • Confundir réplica con copia de seguridad. Un DELETE se replica en milisegundos. Para deshacerlo hace falta PITR.
  • VACUUM FULL en producción. Bloqueo exclusivo: nadie lee ni escribe mientras dura. Usa pg_repack.
  • Ignorar el wraparound hasta que la base deja de aceptar escrituras. Vigila age(datfrozenxid) y mata las transacciones ociosas.
  • No acotar max_slot_wal_keep_size. Una réplica caída llena pg_wal y detiene el primario. Mejor perder la réplica.
  • CREATE INDEX sin CONCURRENTLY en producción. Bloquea las escrituras de la tabla mientras construye.
  • Optimizar por mean_exec_time. Ordena por total_exec_time: la consulta de 10 ms ejecutada 92.000 veces cuesta más que la de 4 s ejecutada 1.000.
  • Consejo de método. Antes de cambiar un parámetro, apunta el valor actual y la métrica que esperas mover. Un ajuste sin medición previa es indistinguible de la superstición.

Ejercicios

Ejercicio 1

revision_salud.sh avisa a las 03:14 de que el espacio de /var/lib/postgresql está al 91 % y subiendo. Diagnostica la causa, explica el mecanismo, resuélvelo sin perder datos y propón la prevención definitiva.

Ejercicio 2

Diseña y documenta el procedimiento completo de ensayo trimestral de recuperación a un punto en el tiempo, en forma de runbook accionable por alguien que no seas tú, con sus criterios de éxito.

Ejercicio 3

Marta pregunta si merece la pena la réplica de PostgreSQL de la que se habló en 07-07, ahora que ya hay copias con recuperación al minuto. Redacta la respuesta.

Soluciones

Solución 1

Diagnóstico. Lo primero es saber qué crece, no cuánto:

$ 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 con 9,8 GB y 612 ficheros .ready en archive_status/. Un .ready es un segmento que PostgreSQL quiere archivar y no ha conseguido archivar. La causa es inmediata:

$ 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 archivar_wal --since "6 hours ago" | tail -2
archivar_wal[8812]: cp: no se puede crear el fichero regular
  '/srv/tramontana/backups/wal/.000000010000000000000A31.8812': Dispositivo sin espacio

El mecanismo, que es lo que hay que entender. El LV lv-backups de 15 GiB, cifrado con LUKS, se ha llenado. archivar_wal.sh devuelve un código distinto de cero, y aquí entra en juego el contrato del archivado: PostgreSQL no borra un segmento hasta que el comando de archivado confirma éxito. Es un comportamiento correcto y deliberado —perder un segmento rompería la cadena de PITR y con ella todas las copias posteriores— pero produce un efecto en cascada:

lv-backups lleno -> archivar_wal.sh falla -> segmentos no se borran
  -> pg_wal crece -> se llena /var/lib -> PostgreSQL SE DETIENE

Si /var/lib/postgresql se llena del todo, PostgreSQL entra en modo pánico y se para. Con el 91 % y subiendo, quedan horas, no días.

Y un segundo sospechoso que hay que descartar siempre:

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

La ranura está activa y solo retiene 12 MB: no es la causa. Si active = f con varios GB retenidos, la causa sería una réplica caída.

Resolución, en orden y sin perder datos:

# --- PASO 1: espacio inmediato en el destino de archivado ---
# Subir a restic lo que ya esta archivado y liberar lo mas antiguo
$ sudo restic backup /srv/tramontana/backups/wal --tag wal-urgente
$ sudo restic check --read-data-subset=5%      # verificar ANTES de borrar

# Solo entonces, borrar los WAL anteriores a la copia base mas antigua
# que queremos conservar. pg_archivecleanup calcula cuales sobran: NUNCA
# se borran a mano por fecha.
$ 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--datos-lv--backups  15G  4.2G  9.9G  30% /srv/tramontana/backups

# --- PASO 2: drenar la cola 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');"

Lo que NO se hace, y es la tentación del momento:

Acción tentadora Consecuencia
rm de ficheros de pg_wal Rompe la cadena de PITR y puede impedir el arranque. Nunca
archive_mode = off Alivia hoy y elimina la capacidad de PITR. Es rendirse
archive_command = '/bin/true' Descarta segmentos silenciosamente: PITR roto sin avisar
Ampliar el LV sin más Legítimo, pero sin arreglar la causa se repite en tres meses

Esa tercera fila es especialmente traicionera: todo parece funcionar, failed_count se queda en cero, y el problema solo aparece el día que hay que restaurar.

Prevención definitiva, en cuatro medidas:

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

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

main() {
    requiere_comando pg_archivecleanup
    requiere_comando restic

    # Nunca purgar por debajo de la copia base mas antigua que se conserva
    local base_mas_antigua
    base_mas_antigua="$(find "$BASE_DIR" -maxdepth 1 -type d -name '20*' | sort | head -1)" \
        || morir 75 "no hay ninguna copia base: NO se purga nada"
    [[ -n "$base_mas_antigua" ]] || morir 75 "no hay copia base; abortando"

    # Y solo si restic tiene el contenido a salvo
    restic check --read-data-subset=2% >/dev/null \
        || morir 65 "restic no verifica: no se purga nada"

    local wal_inicial
    wal_inicial="$(awk '/^START WAL LOCATION/{print $6}' \
        "${base_mas_antigua}/backup_label" | tr -d ')')"
    log "purgando WAL anterior a $wal_inicial"
    pg_archivecleanup -d "$WAL_DIR" "$wal_inicial"
}
main "$@"
# 2. Alerta ANTES del problema, no cuando ya no hay salida
# /etc/systemd/system/vigilar-wal.service (ejecutado cada 15 min)
[Service]
Type=oneshot
ExecStart=/home/operador/scripts/vigilar_wal.sh
# vigilar_wal.sh: dos umbrales, dos niveles
#   - .ready > 20         -> aviso: el archivado se esta atrasando
#   - uso de lv-backups > 75 % -> aviso; > 90 % -> critico
#   - failed_count > 0    -> critico inmediato
# 3. Red de seguridad en el propio PostgreSQL
# Limita cuanto WAL puede acumular una ranura antes de invalidarla.
max_slot_wal_keep_size = 8GB
# 4. Ampliar lv-backups con margen (05-04), ahora con datos
$ sudo lvextend -L +10G /dev/vg-datos/lv-backups
$ sudo cryptsetup resize backups-cifrado
$ sudo resize2fs /dev/mapper/backups-cifrado

Y las tres lecciones de método, que van al cuaderno de guardia:

  1. Un fallo de archivado es un incidente de disponibilidad, aunque el síntoma sea de espacio: la cadena termina con PostgreSQL detenido.
  2. failed_count > 0 debe ser una alerta crítica desde el primer fallo, no cuando el disco esté al 91 %. Es un ejemplo perfecto de alerta de causa (08-06) que sí merece existir, porque su síntoma tarda horas en aparecer y para entonces es tarde.
  3. La monitorización tenía que haber avisado a los 4.128 archivados y 1 fallo, no a los 2.044 fallos. Este incidente es justificación directa para la lección 08-06.

Solución 2

Runbook: Ensayo trimestral de recuperación a un punto en el tiempo

Documento: RB-BD-02 · Versión: 1.0 · Fecha: 2026-08-18 Responsable: Operaciones · Periodicidad: trimestral (marzo, junio, septiembre, diciembre) Duración estimada: 60 minutos · Riesgo para producción: ninguno si se siguen los pasos Ubicación: copia impresa en el archivador de operaciones y en ~/tramontana-infra/docs/. No se guarda únicamente en srv-tramontana.

0. Por qué existe este documento

Una copia que nunca se ha restaurado no es una copia: es un fichero del que suponemos cosas. Este ensayo verifica que la cadena completa —copia base, archivado de WAL, restic, y el procedimiento— funciona antes de necesitarla. También mide el tiempo real, que es el dato que sostiene el RTO acordado de 2 horas.

1. Requisitos previos (5 min)

# Comprobación Comando Criterio
1.1 Máquina de pruebas encendida virsh list --all srv-tramontana-pruebas activa
1.2 Misma versión mayor de PostgreSQL ssh ... psql --version 16.x en ambas
1.3 Espacio en pruebas df -h /var/lib/postgresql ≥ 3 × tamaño de la BD
1.4 Repositorio restic accesible restic snapshots --tag base | tail -3 Al menos 2 copias base
1.5 Frase de paso de restic disponible pass restic/tramontana Se recupera
1.6 Ventana avisada — Marta informada por correo

Si 1.4 o 1.5 fallan, el ensayo se detiene y se declara incidente. No poder acceder a las copias es exactamente el escenario que este ensayo debe descubrir.

2. Elegir el objetivo (5 min)

El ensayo debe recuperar a un instante arbitrario dentro de las últimas 24 horas, no al de la copia base — recuperar a la copia base no prueba el WAL, que es la mitad del mecanismo.

# Instante objetivo: ayer a las 15:00
$ OBJETIVO="$(date -d 'yesterday 15:00' '+%Y-%m-%d %H:%M:%S%:z')"

# Dato de control: una fila que exista en ese momento y que se pueda
# verificar despues. Se anota AQUI, antes de empezar.
$ sudo -u postgres psql -d tramontana -tAc \\
    "SELECT id, importe FROM app.reservas
     WHERE creado_en < '$OBJETIVO' ORDER BY creado_en DESC LIMIT 1;"
1023|412.50

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

3. Restauración (25 min)

# 3.1 Marcar el inicio: el cronometro empieza AQUI
$ INICIO=$(date +%s)

# 3.2 En srv-tramontana-pruebas
$ 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 Traer la copia base ANTERIOR al objetivo desde 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 Y los WAL
$ restic restore latest --tag wal --target /tmp/rest

# 3.5 Configurar la recuperacion
$ 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 = '$OBJETIVO'
recovery_target_action = 'pause'
EOF
$ sudo -u postgres touch /var/lib/postgresql/16/main/recovery.signal

# 3.6 Arrancar y seguir el proceso
$ sudo systemctl start postgresql@16-main
$ sudo journalctl -u postgresql@16-main -f

Salida esperada (si no aparece recovery stopping before..., el ensayo ha fallado):

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ón (10 min) — los criterios de éxito

# Criterio Comando Umbral
4.1 El servidor alcanzó el objetivo journalctl | grep 'recovery stopping' Aparece, con hora ≈ objetivo
4.2 El dato de control existe y coincide SELECT importe FROM app.reservas WHERE id=1023 412,50
4.3 No hay datos posteriores al objetivo SELECT count(*) FROM app.reservas WHERE creado_en > '$OBJETIVO' 0
4.4 Integridad estructural SELECT count(*) FROM app.reservas Coherente con producción
4.5 Sin errores de checksum journalctl | grep -ci 'checksum|corrupt' 0
4.6 Tiempo total echo $(( $(date +%s) - INICIO )) < 1800 s

El criterio 4.3 es el que valida de verdad el PITR: si hubiera datos posteriores al objetivo, la recuperación no se detuvo donde debía y el mecanismo no sirve para deshacer un borrado.

$ sudo -u postgres psql -d tramontana -c "
  SELECT (SELECT importe FROM app.reservas WHERE id=1023) AS control,
         (SELECT count(*) FROM app.reservas WHERE creado_en > '$OBJETIVO') AS posteriores,
         (SELECT count(*) FROM app.reservas) AS total;"
 control | posteriores | total
---------+-------------+--------
  412.50 |           0 | 185912

5. Limpieza (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-pruebas limpio   # desde el anfitrion

Nunca se deja la máquina de pruebas con una copia de los datos de producción: contiene datos personales reales de clientes y estaría fuera del alcance de las medidas de seguridad de producción. Es un requisito de RGPD, no una manía.

6. Registro del resultado

Se anota en ~/tramontana-infra/docs/ensayos-pitr.md, aunque el ensayo salga perfecto:

| Fecha      | Objetivo            | Tiempo | Criterios | Incidencias                    |
|------------|---------------------|--------|-----------|--------------------------------|
| 2026-08-18 | 2026-08-17 15:00+02 | 24m11s | 6/6 OK    | Ninguna                        |
| 2026-06-14 | 2026-06-13 11:00+02 | 41m02s | 5/6       | 4.6 fallo: restic lento por red|

7. Si algo falla

Fallo Causa probable Acción
requested recovery stop point is before consistent recovery point La copia base es posterior al objetivo Usar una copia base anterior
could not restore file ... from archive Falta un segmento de WAL: cadena rota Incidente grave: revisar pg_stat_archiver y la purga
La recuperación no se detiene y llega al final recovery_target_time mal formateado o zona horaria Revisar el formato con +02 explícito
Tiempo > 30 min Red, cifrado LUKS o descompresión Analizar y revisar el RTO con Marta
Errores de checksum Corrupción en la copia Incidente grave: probar otra copia y revisar el hardware

8. Escalado

Si el ensayo falla en 4.2, 4.3 o 4.5, se declara incidente de severidad alta el mismo día: significa que hoy no podríamos recuperar los datos. Se avisa a Marta y se detiene cualquier otro trabajo hasta resolverlo.

Dos notas de diseño del runbook: está escrito para que lo ejecute alguien que no lo redactó —cada comando es copiable y cada criterio tiene un umbral numérico—, y incluye qué hacer cuando falla, que es la parte que casi todos los runbooks omiten y la única que hace falta el día malo.

Solución 3

¿Merece la pena la réplica de la base de datos? Para: Marta Vidal · De: Operaciones de sistemas · 18 de agosto de 2026

Respuesta breve: sí, pero no por el motivo por el que se suele montar, y el orden importa. La recomiendo, con una inversión moderada, y sobre todo por un beneficio que no es el evidente.


Primero, una buena noticia. El trabajo de esta semana ha mejorado nuestra capacidad de recuperación mucho más de lo previsto:

Antes Ahora
Datos que podríamos perder en un desastre Hasta 4 horas Menos de 15 minutos
¿Podemos deshacer un borrado accidental? No: solo volver a la copia de la noche Sí, al segundo anterior
Tiempo de restauración completa ~2 horas estimadas 24 minutos, medidos

Ese salto no ha costado dinero: es una técnica que guarda continuamente el registro de cambios de la base de datos, de modo que podemos «rebobinar» a cualquier instante. Lo he ensayado y funciona.

Entonces, ¿para qué la réplica? Porque resuelve un problema distinto, y conviene no confundirlos:

Problema ¿Lo resuelven las copias? ¿Lo resuelve la réplica?
Alguien borra datos por error Sí, al segundo anterior No: el borrado se copia en milisegundos
Corrupción lógica de datos Sí No
Se estropea el disco del servidor Sí, en 24 minutos Sí, en 5-15 minutos
Los informes pesados ralentizan la web No Sí, y hoy mismo
Hay que actualizar el servidor No Sí: se trabaja sobre uno mientras el otro atiende
El proveedor tiene una caída general No Solo si está en otra ubicación

Y aquí está el argumento principal, que no es el de la avería. Hoy tenemos consultas de informes que tardan casi cuatro segundos y compiten con las reservas de los clientes por la misma máquina. Con una réplica, esos informes se ejecutan en la copia, y la web deja de notarlos. Es una mejora de rendimiento inmediata y perceptible, no un seguro para un día que quizá no llegue.

Qué protege y qué no protege la réplica, en una línea cada cosa:

  • Protege de que se estropee el hardware del servidor principal.
  • Protege el rendimiento de la web frente a los informes pesados.
  • Permite actualizar sin ventana de mantenimiento.
  • No protege de un borrado por error, ni de datos corruptos: eso se copia inmediatamente. Para eso están las copias, que siguen siendo igual de necesarias.
  • No protege si el problema es de la aplicación o de la red.
  • No se activa sola. Recomiendo expresamente que el cambio de servidor sea manual, y le explico por qué en el punto siguiente.

Por qué manual, aunque suene peor. Con solo dos servidores existe un riesgo que en el sector se llama «cerebro dividido»: si se corta la comunicación entre ambos pero los dos siguen vivos, cada uno cree que el otro ha caído y ambos empiezan a aceptar reservas. El resultado son dos bases de datos con información distinta e incompatible, y reconciliarlas puede ser imposible: reservas duplicadas sobre la misma casa y la misma noche. Evitarlo automáticamente exige un mínimo de cinco máquinas. Con dos, la decisión la toma una persona en cinco o diez minutos, y esos minutos son un precio pequeño frente a ese riesgo.

Lo que costaría:

Concepto Coste
Una máquina más Equivalente al servidor actual
Puesta en marcha 2-3 días, ya automatizada con el resto
Mantenimiento adicional ~1 h al mes
Cambio en la aplicación Ninguno para la avería; pequeño para dirigir los informes a la réplica

Mi recomendación, por orden de prioridad:

  1. Ya hecho, coste cero: las copias con rebobinado al segundo y el ensayo trimestral que las verifica. Esto era lo urgente y ya está.
  2. Este trimestre: la réplica, justificada sobre todo por el rendimiento de los informes y, de paso, por poder actualizar sin cortar el servicio. Con cambio manual.
  3. No recomiendo, hoy: el cambio automático de servidor. Exige cinco máquinas para ser seguro, y con dos crearía un riesgo mayor que el que evita.
  4. Pendiente, y lo traigo aparte: la tabla de auditoría ocupa ya más que todos los datos de reservas juntos. Necesitamos decidir cuánto tiempo guardamos ese histórico, y es una decisión tuya y no mía, porque tiene implicaciones legales de protección de datos.

Una última cosa que quiero dejar por escrito. Con la réplica seguiríamos necesitando exactamente las mismas copias de seguridad que hoy. Es la confusión más habitual en este terreno y la que provoca las pérdidas de datos más graves: tener una copia en vivo de los datos da una sensación de seguridad que no se corresponde con la realidad, porque copia fielmente también los errores. La réplica es para las averías; las copias son para los errores. Hacen falta las dos.

Conclusión

PostgreSQL ya no corre con la configuración que trajo el paquete. Has repartido los 3,8 GB con fórmulas que sabes justificar: shared_buffers al 25 % porque hay un segundo caché debajo, effective_cache_size al 67 % porque no reserva nada y cambia los planes que elige el optimizador, work_mem al valor que resiste multiplicarse por conexiones y por operaciones, y random_page_cost a 1,1 porque el disco es un SSD y el valor por defecto describe un mundo de discos giratorios. Y has cerrado el círculo con 07-03: las páginas enormes en madvise, con reserva explícita calculada preguntándole al propio servidor en lugar de estimarla.

Has resuelto el incidente que quedó abierto en 07-02, y de la forma correcta: no subiendo max_connections, sino bajándolo a 60 y poniendo PgBouncer en modo transacción delante, donde 63 clientes de aplicación comparten cuatro conexiones reales. Has cerrado pg_hba.conf línea a línea, sabiendo que gana la primera coincidencia y que trust no se pone nunca, ni en 127.0.0.1 ni «temporalmente». Has creado un rol de aplicación que no puede crear tablas, y lo has verificado intentándolo. Y sabes que sslmode=require cifra pero no verifica nada.

Sobre todo, la base de datos ya se puede recuperar. La copia física con pg_basebackup, el archivado de WAL con un script cuyo contrato entiendes —devolver cero solo si el segmento está a salvo—, y un procedimiento de recuperación a un punto en el tiempo que has ejecutado de principio a fin, con recovery_target_action = 'pause' para poder mirar antes de comprometerte. El RPO ha pasado de 4 horas a minutos sin gastar un euro, y el ensayo trimestral está escrito para que lo ejecute alguien que no seas tú. Conoces el VACUUM, la hinchazón, el wraparound del contador de transacciones y por qué una transacción idle in transaction de tres días es capaz de tumbar una base de datos entera. Y sabes leer un EXPLAIN (ANALYZE, BUFFERS), que es lo que convirtió una consulta de 3.781 ms en una de 182 ms.

En 08-03 el escenario cambia por completo, y a propósito. Vas a construir un servidor de medios para tu casa: Jellyfin sobre tu propio hardware, con almacenamiento redundante, aceleración por hardware para la transcodificación, comparticiones para los dispositivos de la familia y acceso desde fuera. Es el proyecto donde compruebas que nada de lo aprendido era «cosa de servidores de empresa»: los mismos UUID en fstab, la misma unidad de systemd endurecida, las mismas claves en keyrings, las mismas copias verificadas y el mismo smartctl vigilando discos. Con dos diferencias que en casa importan y en el trabajo no: el consumo eléctrico y el ruido. Y con una advertencia que conviene leer antes de empezar, sobre qué contenido es legítimo tener ahí.

Curso de Linux: De Principiante a Administrador de Sistemas

Módulo 1: Introducción a Linux

Módulo 2: Comandos Básicos de Linux

Módulo 3: Habilidades Avanzadas en la Línea de Comandos

Módulo 4: Scripting en Shell

Módulo 5: Administración del Sistema

Módulo 6: Redes y Seguridad

Módulo 7: Temas Avanzados

Módulo 8: Proyectos Prácticos

© Copyright 2026. Todos los derechos reservados