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
- Objetivo, requisitos previos y estado de partida
- Arquitectura de procesos y ficheros de PostgreSQL
- Ajuste de memoria con fórmulas razonadas
- Páginas enormes: la conexión con 07-03
- Conexiones: por qué un pool y no un número mayor
- Autenticación: pg_hba.conf campo a campo
- TLS en las conexiones
- Roles y permisos con mínimo privilegio
- Copias: lógica, física, WAL y recuperación a un punto en el tiempo
- Mantenimiento: vacuum, wraparound y reindexado
- Diagnóstico: dónde se va el tiempo
- Réplica en streaming
- Automatización con Ansible
- 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 | defaultTraducido: 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 | 512880Dos 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.dAjuste 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:
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 | fLa 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.42Antes 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] nevermadvise 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: 25huge_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
| 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, monitorEl 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 tramontanaY 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 | 0Sesenta 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.
| 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 encryptionTLS 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.pemPara 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.16El 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 || trueCopias: 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 | 0failed_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 verifiedY 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:
- 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.
recovery_target_action = 'pause'. Permite inspeccionar antes de comprometerse. Conpromote, si te pasaste de instante, hay que empezar de cero.- 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.
- Se extrae solo lo perdido. Sustituir la base entera perdería 19 minutos de reservas reales.
psql -1envuelve 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+02Un 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 lentoEl 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.6Un 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 eternamentestatement_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_idLa 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 msCó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 msDe 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 DESCEse 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 | 0Un í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
-------------------
tY 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.004retraso_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.confY 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: restartedEl 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_connectionsen 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_memglobalmente. 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_buffersal 80 %. Se duplica el almacenamiento con el caché del sistema y hay menos memoria útil que con el 25 %. - Dejar
random_page_cost = 4en un SSD. El planificador evita índices que debería usar, y las consultas se degradan sin causa aparente. - Editar
postgresql.confcuandopostgresql.auto.conftiene el mismo parámetro. Gana el segundo. Consultapg_settings.source. - Usar
trustenpg_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.confsin mirarpg_hba_file_rules. Puedes quedarte fuera de tu propia base de datos. - Creer que
sslmode=requireverifica el certificado. No lo hace. Soloverify-full. - Dar
SUPERUSERal 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
DELETEse replica en milisegundos. Para deshacerlo hace falta PITR. VACUUM FULLen producción. Bloqueo exclusivo: nadie lee ni escribe mientras dura. Usapg_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 llenapg_waly detiene el primario. Mejor perder la réplica. CREATE INDEXsinCONCURRENTLYen producción. Bloquea las escrituras de la tabla mientras construye.- Optimizar por
mean_exec_time. Ordena portotal_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
612pg_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 espacioEl 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 MBLa 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-cifradoY las tres lecciones de método, que van al cuaderno de guardia:
- Un fallo de archivado es un incidente de disponibilidad, aunque el síntoma sea de espacio: la cadena termina con PostgreSQL detenido.
failed_count > 0debe 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.- 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 ensrv-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 --allsrv-tramontana-pruebasactiva1.2 Misma versión mayor de PostgreSQL ssh ... psql --version16.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 -3Al menos 2 copias base 1.5 Frase de paso de restic disponible pass restic/tramontanaSe 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.50Anotar: 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 -fSalida 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 recovery4. 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=1023412,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.reservasCoherente 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 | 1859125. 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 anfitrionNunca 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 pointLa copia base es posterior al objetivo Usar una copia base anterior could not restore file ... from archiveFalta un segmento de WAL: cadena rota Incidente grave: revisar pg_stat_archivery la purgaLa recuperación no se detiene y llega al final recovery_target_timemal formateado o zona horariaRevisar el formato con +02explícitoTiempo > 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:
- Ya hecho, coste cero: las copias con rebobinado al segundo y el ensayo trimestral que las verifica. Esto era lo urgente y ya está.
- 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.
- 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.
- 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
- ¿Qué es Linux?
- Historia de Linux
- Distribuciones de Linux
- Instalando Linux
- Primer Contacto con el Sistema
- Estructura del Sistema de Archivos de Linux
Módulo 2: Comandos Básicos de Linux
- Introducción a la Línea de Comandos
- Obtener Ayuda y Documentación del Sistema
- Navegando el Sistema de Archivos
- Operaciones con Archivos y Directorios
- Visualización y Edición de Archivos
- Enlaces Duros y Simbólicos
- Permisos y Propiedad de Archivos
Módulo 3: Habilidades Avanzadas en la Línea de Comandos
- El Entorno del Shell: Variables, Alias e Historial
- Uso de Comodines y Expresiones Regulares
- Búsqueda de Archivos y Contenido: find, locate y grep
- Tuberías y Redirección
- Procesamiento de Texto: cut, sort, uniq, sed y awk
- Gestión de Procesos
- Programación de Tareas con Cron
- Comandos de Redes
Módulo 4: Scripting en Shell
- Introducción al Scripting en Shell
- Variables y Tipos de Datos
- Entrada, Salida y Argumentos de un Script
- Estructuras de Control
- Funciones y Librerías
- Depuración y Manejo de Errores
- Scripts de Producción: Buenas Prácticas
Módulo 5: Administración del Sistema
- Gestión de Usuarios y Grupos
- sudo y Permisos Especiales
- Gestión de Paquetes
- Gestión de Discos
- systemd y la Gestión de Servicios
- Registros del Sistema: journald y syslog
- Monitoreo del Sistema y Optimización del Rendimiento
- Respaldo y Restauración
Módulo 6: Redes y Seguridad
- Configuración de Redes
- SSH y Acceso Remoto
- Firewall y Seguridad Perimetral
- Sistemas de Detección de Intrusos
- Gestión de Secretos y Certificados TLS
- Asegurando Sistemas Linux
Módulo 7: Temas Avanzados
- El Proceso de Arranque y la Recuperación del Sistema
- Diagnóstico Avanzado: strace, perf y eBPF
- Optimización del Kernel de Linux
- Virtualización con Linux
- Contenedores de Linux y Docker
- Automatización con Ansible
- Alta Disponibilidad y Balanceo de Carga
