SRE: Confiabilidad de Bases de Datos
Apoya este blog
Si te resulta util este contenido, considera apoyar el blog.
Introducción
En los artículos anteriores cubrimos SLIs y SLOs, gestión de incidentes, observabilidad, chaos engineering, planificación de capacidad, GitOps, gestión de secretos, optimización de costos, y gestión de dependencias. Cubrimos un montón de terreno, pero hay una pieza crítica que todavía no tocamos: la base de datos.
Tu base de datos es probablemente el punto de falla único más difícil de manejar en todo tu stack. Podés escalar servicios stateless horizontalmente, podés reiniciar pods crasheados, incluso podés perder un nodo entero y recuperarte en segundos. Pero si tu base de datos se cae, todo se detiene. Si perdés datos, pueden estar perdidos para siempre. Y si tus migraciones lockean una tabla durante cinco minutos en hora pico, tus usuarios se van a dar cuenta.
En este artículo vamos a recorrer los patrones y herramientas que hacen que PostgreSQL sea confiable en Kubernetes. Vamos a cubrir connection pooling, read replicas, estrategias de backup, migraciones sin downtime, monitoreo, el operador CloudNativePG, y automatización de failover. Estos son los bloques fundamentales que te permiten dormir tranquilo incluso cuando tu app maneja tráfico y datos reales.
Vamos al tema.
Connection pooling con PgBouncer
PostgreSQL crea un nuevo proceso por cada conexión. Eso funciona bien cuando tenés 10 conexiones,
pero en Kubernetes donde podrías tener decenas de pods, cada uno corriendo múltiples procesos, podés
agotar fácilmente el límite de conexiones del servidor. El max_connections por defecto en PostgreSQL
es 100, y cada conexión consume alrededor de 5-10MB de RAM.
PgBouncer se sienta entre tu aplicación y PostgreSQL, multiplexando muchas conexiones de clientes en un número menor de conexiones al servidor. Es liviano (un solo proceso de PgBouncer puede manejar miles de conexiones de clientes) y está probado en producción a escala masiva.
Acá hay una configuración básica de PgBouncer:
# pgbouncer.ini
[databases]
myapp = host=postgresql-primary port=5432 dbname=myapp_production
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
# Modo de pool: transaction es el más común para apps web
pool_mode = transaction
# Tamaño del pool
default_pool_size = 20
min_pool_size = 5
max_client_conn = 1000
max_db_connections = 50
# Timeouts
server_idle_timeout = 300
client_idle_timeout = 0
query_timeout = 30
# Logging
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1
stats_period = 60
El setting pool_mode es crucial:
- transaction libera la conexión al servidor de vuelta al pool después de que cada transacción se completa. Esto es lo que querés para la mayoría de las aplicaciones web porque maximiza la reutilización de conexiones. Sin embargo, no soporta features a nivel de sesión como prepared statements, advisory locks, o LISTEN/NOTIFY.
- session mantiene la conexión al servidor asignada durante toda la sesión del cliente. Esto soporta todas las features de PostgreSQL pero provee menos multiplexación de conexiones. Usá esto si tu app depende de features a nivel de sesión.
- statement libera la conexión después de cada statement. Provee la mejor multiplexación pero solo funciona para queries simples de solo lectura sin transacciones de múltiples statements.
Para Ecto (la capa de base de datos en Phoenix/Elixir), el modo transaction funciona perfectamente porque Ecto envuelve cada request en una transacción explícita o usa queries simples.
Corriendo PgBouncer como sidecar en Kubernetes
El patrón sidecar pone un contenedor de PgBouncer en el mismo pod que tu aplicación. Esto significa que cada pod de la aplicación tiene su propia instancia de PgBouncer, y la conexión a PgBouncer es por localhost (cero latencia de red). Acá está el spec del pod:
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: tr-web
namespace: default
spec:
replicas: 3
selector:
matchLabels:
app: tr-web
template:
metadata:
labels:
app: tr-web
spec:
containers:
- name: app
image: kainlite/tr:latest
ports:
- containerPort: 4000
env:
# Apuntar la app a PgBouncer en localhost en vez de directamente a PostgreSQL
- name: DATABASE_URL
value: "ecto://myapp:password@localhost:6432/myapp_production"
resources:
requests:
memory: "256Mi"
cpu: "200m"
limits:
memory: "512Mi"
- name: pgbouncer
image: bitnami/pgbouncer:latest
ports:
- containerPort: 6432
env:
- name: POSTGRESQL_HOST
value: "postgresql-primary.database.svc.cluster.local"
- name: POSTGRESQL_PORT
value: "5432"
- name: PGBOUNCER_DATABASE
value: "myapp_production"
- name: PGBOUNCER_POOL_MODE
value: "transaction"
- name: PGBOUNCER_DEFAULT_POOL_SIZE
value: "20"
- name: PGBOUNCER_MAX_CLIENT_CONN
value: "500"
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
Cómo dimensionar tu pool de conexiones
Un error común es poner el pool demasiado grande. Más conexiones no significa mejor rendimiento. De hecho, demasiadas conexiones causan contención en locks y shared buffers, lo que perjudica el throughput.
Una buena fórmula inicial:
# Total de conexiones al servidor = cantidad de cores de CPU del servidor de DB * 2 + effective_spindle_count
# Para un servidor de 4 cores con SSD:
# max_useful_connections = 4 * 2 + 1 = 9 (pero redondeá a ~20 para tener margen)
# Después distribuí entre tus pods de aplicación:
# per_pod_pool_size = total_server_connections / number_of_pods
# Para 3 pods con 50 conexiones máximas a la DB:
# per_pod_pool_size = 50 / 3 ≈ 16
# En tu config de repo de Ecto:
config :myapp, MyApp.Repo,
pool_size: 16,
queue_target: 500, # Tiempo objetivo de cola en ms
queue_interval: 1000 # Cada cuánto verificar la salud de la cola
Monitoreá el uso real de conexiones con SHOW POOLS; en PgBouncer o con la vista pg_stat_activity
de PostgreSQL. Ajustá basándote en datos reales, no en suposiciones.
Read replicas y balanceo de carga
Para workloads pesados en lectura (que es lo que son la mayoría de las aplicaciones web), podés descargar queries de lectura a réplicas mientras mantenés las escrituras en el primario. La replicación por streaming de PostgreSQL hace que esto sea bastante directo.
Configurando replicación por streaming
En el primario, habilitá la replicación en postgresql.conf:
# postgresql.conf en el primario
wal_level = replica
max_wal_senders = 10
max_replication_slots = 10
hot_standby = on
# Archivar WAL para PITR (más sobre esto en la sección de backups)
archive_mode = on
archive_command = 'pgbackrest --stanza=myapp archive-push %p'
En la réplica, configurá la recuperación:
# postgresql.conf en la réplica
primary_conninfo = 'host=postgresql-primary port=5432 user=replicator password=secret'
primary_slot_name = 'replica_1'
hot_standby = on
hot_standby_feedback = on
Read/write splitting en Ecto
Ecto soporta múltiples repositorios, así que podés definir un repo de solo lectura que apunte a las réplicas:
# lib/myapp/repo.ex
defmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :myapp,
adapter: Ecto.Adapters.Postgres
end
# lib/myapp/read_repo.ex
defmodule MyApp.ReadRepo do
use Ecto.Repo,
otp_app: :myapp,
adapter: Ecto.Adapters.Postgres,
read_only: true
end
# config/runtime.exs
config :myapp, MyApp.Repo,
url: System.get_env("DATABASE_URL"),
pool_size: 16
config :myapp, MyApp.ReadRepo,
url: System.get_env("DATABASE_REPLICA_URL"),
pool_size: 20 # Las réplicas pueden manejar más conexiones ya que solo sirven lecturas
Después en el código de tu aplicación, usá el repo apropiado:
# Operaciones de escritura van al primario
MyApp.Repo.insert(%User{name: "Gabriel"})
MyApp.Repo.update(changeset)
MyApp.Repo.delete(user)
# Operaciones de lectura van a las réplicas
MyApp.ReadRepo.all(User)
MyApp.ReadRepo.get(User, 1)
# Para operaciones que necesitan leer sus propias escrituras (ej: después de un insert),
# usá el repo primario para evitar problemas de replication lag
def create_and_return_user(attrs) do
{:ok, user} = MyApp.Repo.insert(%User{} |> User.changeset(attrs))
# Leer del primario, no de la réplica, para evitar datos obsoletos
MyApp.Repo.get!(User, user.id)
end
Balanceo de carga entre réplicas con pgpool-II
Si tenés múltiples réplicas, pgpool-II puede distribuir queries de lectura entre ellas:
# pgpool.conf
backend_hostname0 = 'postgresql-primary'
backend_port0 = 5432
backend_weight0 = 0
backend_flag0 = 'ALWAYS_PRIMARY'
backend_hostname1 = 'postgresql-replica-1'
backend_port1 = 5432
backend_weight1 = 1
backend_hostname2 = 'postgresql-replica-2'
backend_port2 = 5432
backend_weight2 = 1
# Balanceo de carga
load_balance_mode = on
statement_level_load_balance = on
# Health check
health_check_period = 10
health_check_timeout = 5
health_check_max_retries = 3
Con este setup, pgpool-II envía todas las escrituras al primario y distribuye las lecturas entre las dos réplicas con peso igual. El health check asegura que las réplicas no saludables se remuevan del pool automáticamente.
Estrategias de backup
Hay tres tipos principales de backups de PostgreSQL, y una estrategia sólida usa al menos dos de ellos:
- Backups lógicos (pg_dump): Exportan la base de datos como statements SQL. Geniales para portabilidad, restauración selectiva, y bases de datos chicas a medianas. Lentos para bases de datos grandes.
- Archivado de WAL (PITR): Archiva continuamente archivos de Write-Ahead Log. Permite Point-in-Time Recovery a cualquier momento en el tiempo. Esencial para bases de datos de producción.
- Backups físicos (pgBackRest/pg_basebackup): Copian los archivos de datos crudos. Rápidos para bases de datos grandes, soportan backups incrementales, y funcionan con archivado de WAL para PITR.
Backups lógicos con pg_dump
El enfoque de backup más simple. Bueno para bases de datos chicas o cuando necesitás migrar entre versiones de PostgreSQL:
# Dump completo de la base en formato custom (comprimido, soporta restore paralelo)
pg_dump -h postgresql-primary -U myapp -Fc -f /backups/myapp_$(date +%Y%m%d_%H%M%S).dump myapp_production
# Restaurar desde un dump
pg_restore -h postgresql-primary -U myapp -d myapp_production --clean --if-exists /backups/myapp_20260318_030000.dump
# Para bases de datos muy grandes, usá dump/restore paralelo
pg_dump -h postgresql-primary -U myapp -Fd -j 4 -f /backups/myapp_parallel/ myapp_production
pg_restore -h postgresql-primary -U myapp -d myapp_production -j 4 /backups/myapp_parallel/
Archivado de WAL para Point-in-Time Recovery
El archivado de WAL captura cada cambio hecho a la base de datos. Combinado con un backup base, podés restaurar a cualquier punto en el tiempo. Así es como te recuperás de “ups, alguien ejecutó DELETE sin WHERE”:
# postgresql.conf
wal_level = replica
archive_mode = on
archive_command = 'test ! -f /wal_archive/%f && cp %p /wal_archive/%f'
# Para archivado basado en S3 (recomendado para producción):
archive_command = 'aws s3 cp %p s3://my-wal-archive/%f --sse AES256'
Para restaurar a un punto específico en el tiempo:
# recovery.conf (o postgresql.conf en PG12+)
restore_command = 'aws s3 cp s3://my-wal-archive/%f %p'
recovery_target_time = '2026-03-18 14:30:00 UTC'
recovery_target_action = 'promote'
Backups físicos con pgBackRest
pgBackRest es el estándar de oro para backups físicos de PostgreSQL. Soporta backups completos, incrementales y diferenciales, backup y restore paralelos, compresión, encriptación, y almacenamiento compatible con S3:
# /etc/pgbackrest/pgbackrest.conf
[global]
repo1-type=s3
repo1-s3-endpoint=s3.amazonaws.com
repo1-s3-bucket=myapp-pg-backups
repo1-s3-region=us-east-1
repo1-s3-key=AKIAIOSFODNN7EXAMPLE
repo1-s3-key-secret=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
repo1-retention-full=4
repo1-retention-diff=14
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=your-encryption-passphrase
process-max=4
compress-type=zst
compress-level=6
[myapp]
pg1-path=/var/lib/postgresql/16/main
pg1-port=5432
# Crear el stanza (setup inicial, una sola vez)
pgbackrest --stanza=myapp stanza-create
# Backup completo
pgbackrest --stanza=myapp --type=full backup
# Backup incremental (solo cambios desde el último full o incremental)
pgbackrest --stanza=myapp --type=incr backup
# Backup diferencial (solo cambios desde el último full)
pgbackrest --stanza=myapp --type=diff backup
# Listar backups
pgbackrest --stanza=myapp info
# Restaurar al último
pgbackrest --stanza=myapp --delta restore
# Restaurar a un punto específico en el tiempo
pgbackrest --stanza=myapp --delta --type=time --target="2026-03-18 14:30:00" restore
Programando backups con Kubernetes CronJobs
# backup-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: pg-backup-full
namespace: database
spec:
schedule: "0 2 * * 0" # Backup completo todos los domingos a las 2am
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
containers:
- name: backup
image: pgbackrest/pgbackrest:latest
command:
- /bin/sh
- -c
- |
pgbackrest --stanza=myapp --type=full backup
RESULT=$?
if [ $RESULT -ne 0 ]; then
echo "Backup falló con código de salida $RESULT"
# Enviar alerta a Slack o PagerDuty
curl -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
-d '{"text":"ALERTA: ¡Backup completo de PostgreSQL falló!"}'
fi
exit $RESULT
envFrom:
- secretRef:
name: pgbackrest-credentials
- secretRef:
name: slack-webhook
volumeMounts:
- name: pgbackrest-config
mountPath: /etc/pgbackrest
volumes:
- name: pgbackrest-config
configMap:
name: pgbackrest-config
restartPolicy: Never
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: pg-backup-incremental
namespace: database
spec:
schedule: "0 2 * * 1-6" # Backup incremental todos los demás días a las 2am
concurrencyPolicy: Forbid
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
containers:
- name: backup
image: pgbackrest/pgbackrest:latest
command:
- /bin/sh
- -c
- |
pgbackrest --stanza=myapp --type=incr backup
RESULT=$?
if [ $RESULT -ne 0 ]; then
curl -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
-d '{"text":"ALERTA: ¡Backup incremental de PostgreSQL falló!"}'
fi
exit $RESULT
envFrom:
- secretRef:
name: pgbackrest-credentials
- secretRef:
name: slack-webhook
volumeMounts:
- name: pgbackrest-config
mountPath: /etc/pgbackrest
volumes:
- name: pgbackrest-config
configMap:
name: pgbackrest-config
restartPolicy: Never
Validación de backups y pruebas de restauración
Acá va una verdad dura: un backup que nunca fue probado no es un backup. Es una esperanza. Y la esperanza no es una estrategia.
Necesitás restaurar tus backups regularmente a una base de datos temporal y verificar que los datos estén intactos. Esto debería estar automatizado, no ser algo que hacés manualmente una vez al año cuando alguien se acuerda.
Pruebas automatizadas de restauración con un CronJob
# restore-test-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: pg-restore-test
namespace: database
spec:
schedule: "0 6 * * 3" # Cada miércoles a las 6am
concurrencyPolicy: Forbid
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 3600 # Timeout después de 1 hora
template:
spec:
containers:
- name: restore-test
image: pgbackrest/pgbackrest:latest
command:
- /bin/sh
- -c
- |
set -e
echo "Iniciando prueba de restauración a $(date)"
# Inicializar un directorio temporal de datos de PostgreSQL
export PGDATA=/tmp/pg_restore_test
mkdir -p $PGDATA
# Restaurar el último backup al directorio temporal
pgbackrest --stanza=myapp --delta \
--pg1-path=$PGDATA \
--target-action=promote \
restore
# Arrancar PostgreSQL en un puerto no estándar
pg_ctl -D $PGDATA -o "-p 5433" -w start
# Ejecutar queries de validación
USERS_COUNT=$(psql -p 5433 -d myapp_production -tAc "SELECT count(*) FROM users;")
POSTS_COUNT=$(psql -p 5433 -d myapp_production -tAc "SELECT count(*) FROM posts;")
LATEST_RECORD=$(psql -p 5433 -d myapp_production -tAc \
"SELECT max(inserted_at) FROM users;")
echo "Resultados de validación:"
echo " Cantidad de usuarios: $USERS_COUNT"
echo " Cantidad de posts: $POSTS_COUNT"
echo " Último registro: $LATEST_RECORD"
# Verificar que los datos son recientes (no más viejos que 48 horas)
IS_RECENT=$(psql -p 5433 -d myapp_production -tAc \
"SELECT max(inserted_at) > now() - interval '48 hours' FROM users;")
# Parar el PostgreSQL temporal
pg_ctl -D $PGDATA -w stop
# Limpiar
rm -rf $PGDATA
if [ "$IS_RECENT" = "t" ]; then
echo "PRUEBA DE RESTAURACIÓN PASÓ: Los datos son recientes y válidos"
curl -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
-d "{\"text\":\"Prueba de restauración PASÓ. Usuarios: $USERS_COUNT, Posts: $POSTS_COUNT, Último: $LATEST_RECORD\"}"
else
echo "PRUEBA DE RESTAURACIÓN FALLÓ: Los datos son obsoletos o faltan"
curl -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
-d '{"text":"ALERTA: ¡Prueba de restauración FALLÓ! Los datos son obsoletos o faltan."}'
exit 1
fi
envFrom:
- secretRef:
name: pgbackrest-credentials
- secretRef:
name: slack-webhook
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "2Gi"
restartPolicy: Never
Las cosas clave que esta prueba de restauración valida:
- El backup es restaurable: Si pgBackRest no puede restaurar, te enterás inmediatamente
- Los datos son recientes: Si el último registro tiene más de 48 horas, algo anda mal con tu pipeline de backup
- Las tablas principales existen y tienen datos: Un chequeo básico de que el schema y los datos están intactos
- Notificación en éxito y falla: Querés saber tanto cuando funciona como cuando no
También deberías rastrear los resultados de las pruebas de restauración como una métrica y configurar un SLO para eso. Algo como “99% de las pruebas de restauración semanales deberían pasar” es un buen punto de partida.
Migraciones sin downtime
Las migraciones de base de datos son una de las causas más comunes de downtime. Una migración que lockea una tabla puede bloquear todas las queries a esa tabla, lo que significa que tu aplicación se cuelga hasta que la migración se completa. En PostgreSQL, incluso operaciones aparentemente inocentes como agregar una columna con un valor por defecto solían lockear toda la tabla (aunque esto se arregló en PostgreSQL 11).
Acá están los patrones de migración seguros para Ecto:
Seguro: Agregar una columna nullable sin default
# Esto siempre es seguro. Toma un lock ACCESS EXCLUSIVE breve pero se completa casi al instante.
defmodule MyApp.Repo.Migrations.AddAvatarToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :avatar_url, :string
end
end
end
Seguro: Agregar una columna con default (PostgreSQL 11+)
# En PostgreSQL 11+, esto es seguro porque el default se almacena en el catálogo,
# no se escribe en cada fila. El lock es breve.
defmodule MyApp.Repo.Migrations.AddRoleToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :role, :string, default: "user"
end
end
end
Peligroso: Agregar un índice en una tabla grande
Un CREATE INDEX regular lockea la tabla para escrituras. En una tabla con millones de filas, esto
puede tomar minutos. Usá CREATE INDEX CONCURRENTLY en su lugar:
# MAL: Esto lockea la tabla para escrituras
defmodule MyApp.Repo.Migrations.AddIndexToPostsTitle do
use Ecto.Migration
def change do
create index(:posts, [:title])
end
end
# BIEN: Usá concurrently para evitar el lock
defmodule MyApp.Repo.Migrations.AddIndexToPostsTitle do
use Ecto.Migration
# disable_ddl_transaction es requerido para creación de índices concurrent
@disable_ddl_transaction true
@disable_migration_lock true
def change do
create index(:posts, [:title], concurrently: true)
end
end
Patrón seguro: Backfilling de datos en lotes
Nunca hagas backfill de datos en una migración que corre dentro de una transacción. En su lugar, escribí una migración o tarea separada que procese filas en lotes:
# Paso 1: Agregar la nueva columna (rápido, seguro)
defmodule MyApp.Repo.Migrations.AddSlugToPosts do
use Ecto.Migration
def change do
alter table(:posts) do
add :slug, :string
end
end
end
# Paso 2: Backfill en lotes (migración separada o tarea mix)
defmodule MyApp.Repo.Migrations.BackfillPostSlugs do
use Ecto.Migration
import Ecto.Query
@disable_ddl_transaction true
@disable_migration_lock true
@batch_size 1000
def up do
backfill_batch(0)
end
defp backfill_batch(last_id) do
{count, _} =
repo().query!("""
UPDATE posts
SET slug = lower(replace(title, ' ', '-'))
WHERE id > $1
AND id <= $1 + $2
AND slug IS NULL
""", [last_id, @batch_size])
if count > 0 do
# Pequeña pausa para no sobrecargar la base de datos
Process.sleep(100)
backfill_batch(last_id + @batch_size)
end
end
def down do
# Nada que deshacer, el drop de la columna va a limpiar
:ok
end
end
# Paso 3: Agregar la constraint NOT NULL y el índice (después de que el backfill esté completo)
defmodule MyApp.Repo.Migrations.MakePostSlugRequired do
use Ecto.Migration
@disable_ddl_transaction true
@disable_migration_lock true
def up do
# Agregar una check constraint primero (no bloqueante en PG12+)
execute "ALTER TABLE posts ADD CONSTRAINT posts_slug_not_null CHECK (slug IS NOT NULL) NOT VALID"
# Después validarla (toma un lock breve pero no bloquea escrituras por mucho)
execute "ALTER TABLE posts VALIDATE CONSTRAINT posts_slug_not_null"
# Crear índice único de forma concurrent
create unique_index(:posts, [:slug], concurrently: true)
end
def down do
drop_if_exists index(:posts, [:slug])
execute "ALTER TABLE posts DROP CONSTRAINT IF EXISTS posts_slug_not_null"
end
end
Checklist de seguridad para migraciones
Antes de correr cualquier migración en producción:
- Chequeá el tipo de lock: ¿La migración va a tomar un lock ACCESS EXCLUSIVE? ¿Por cuánto tiempo?
- Probá en una copia de datos de producción: Nunca pruebes migraciones en una base de datos vacía. Una migración que corre instantáneamente en 100 filas puede lockear la tabla por minutos en 10 millones de filas.
- Usá statement_timeout: Configurá un statement timeout para que si una migración toma demasiado, falle en vez de lockear la tabla indefinidamente.
- Correla durante bajo tráfico: Incluso migraciones “seguras” son más seguras durante horas de poco tráfico.
- Tené un plan de rollback: Sabé cómo deshacer la migración antes de correrla.
# Configurar un statement timeout para migraciones para prevenir locks largos
# config/runtime.exs
config :myapp, MyApp.Repo,
migration_lock: nil,
migration_default_prefix: "public",
after_connect: {Postgrex, :query!, ["SET statement_timeout TO '5s'", []]}
Monitoreo de base de datos
No podés arreglar lo que no podés ver. PostgreSQL viene con excelentes vistas de monitoreo incorporadas, y combinarlas con Prometheus te da una imagen completa de la salud de tu base de datos.
pg_stat_statements: encontrando queries lentas
pg_stat_statements es la extensión más importante de PostgreSQL para monitoreo de performance.
Rastrea estadísticas de ejecución para cada query que corre en el servidor:
# Habilitar la extensión (una vez)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
# Top 10 queries por tiempo total de ejecución
SELECT
queryid,
calls,
round(total_exec_time::numeric, 2) AS total_time_ms,
round(mean_exec_time::numeric, 2) AS mean_time_ms,
round(max_exec_time::numeric, 2) AS max_time_ms,
rows,
round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 2) AS percent_total,
left(query, 100) AS query_preview
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
# Top 10 queries por tiempo promedio de ejecución (queries lentas)
SELECT
queryid,
calls,
round(mean_exec_time::numeric, 2) AS mean_time_ms,
round(max_exec_time::numeric, 2) AS max_time_ms,
rows / NULLIF(calls, 0) AS avg_rows,
left(query, 100) AS query_preview
FROM pg_stat_statements
WHERE calls > 10 -- Filtrar queries ejecutadas raramente
ORDER BY mean_exec_time DESC
LIMIT 10;
# Queries con más I/O
SELECT
queryid,
calls,
shared_blks_read + shared_blks_written AS total_blocks,
round(mean_exec_time::numeric, 2) AS mean_time_ms,
left(query, 100) AS query_preview
FROM pg_stat_statements
ORDER BY (shared_blks_read + shared_blks_written) DESC
LIMIT 10;
Monitoreo de conexiones
Saber cómo se están usando tus conexiones es crítico para dimensionar tu pool correctamente y detectar fugas de conexiones:
# Cantidad actual de conexiones por estado
SELECT
state,
count(*) AS connections,
max(now() - state_change) AS longest_in_state
FROM pg_stat_activity
WHERE pid != pg_backend_pid()
GROUP BY state
ORDER BY connections DESC;
# Conexiones por nombre de aplicación (útil para identificar qué servicio usa más)
SELECT
application_name,
state,
count(*) AS connections
FROM pg_stat_activity
WHERE pid != pg_backend_pid()
GROUP BY application_name, state
ORDER BY connections DESC;
# Encontrar queries de larga duración (problemas potenciales)
SELECT
pid,
now() - query_start AS duration,
state,
left(query, 80) AS query_preview,
wait_event_type,
wait_event
FROM pg_stat_activity
WHERE state = 'active'
AND now() - query_start > interval '30 seconds'
ORDER BY duration DESC;
# Encontrar queries bloqueadas (esperando locks)
SELECT
blocked_locks.pid AS blocked_pid,
blocked_activity.usename AS blocked_user,
blocking_locks.pid AS blocking_pid,
blocking_activity.usename AS blocking_user,
left(blocked_activity.query, 60) AS blocked_query,
left(blocking_activity.query, 60) AS blocking_query
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity
ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity
ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;
Monitoreo de replication lag
Si estás usando read replicas, monitorear el replication lag es esencial. Una réplica que está muy atrasada puede servir datos obsoletos:
# En el primario: verificar estado de replicación
SELECT
client_addr,
state,
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn,
pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replay_lag_bytes,
pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS replay_lag_pretty
FROM pg_stat_replication;
# En la réplica: verificar cuánto atrás está
SELECT
now() - pg_last_xact_replay_timestamp() AS replication_delay,
pg_is_in_recovery() AS is_replica,
pg_last_wal_receive_lsn() AS last_received,
pg_last_wal_replay_lsn() AS last_replayed;
Prometheus exporter para PostgreSQL
El postgres_exporter de Prometheus Community expone todas estas métricas en formato Prometheus.
Deployealo junto a tus instancias de PostgreSQL:
# postgres-exporter.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres-exporter
namespace: database
spec:
replicas: 1
selector:
matchLabels:
app: postgres-exporter
template:
metadata:
labels:
app: postgres-exporter
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9187"
spec:
containers:
- name: exporter
image: prometheuscommunity/postgres-exporter:latest
ports:
- containerPort: 9187
env:
- name: DATA_SOURCE_URI
value: "postgresql-primary.database.svc:5432/myapp_production?sslmode=disable"
- name: DATA_SOURCE_USER
valueFrom:
secretKeyRef:
name: postgres-exporter-credentials
key: username
- name: DATA_SOURCE_PASS
valueFrom:
secretKeyRef:
name: postgres-exporter-credentials
key: password
- name: PG_EXPORTER_EXTEND_QUERY_PATH
value: /etc/postgres-exporter/queries.yaml
volumeMounts:
- name: custom-queries
mountPath: /etc/postgres-exporter
volumes:
- name: custom-queries
configMap:
name: postgres-exporter-queries
---
# Queries custom para el exporter
apiVersion: v1
kind: ConfigMap
metadata:
name: postgres-exporter-queries
namespace: database
data:
queries.yaml: |
pg_slow_queries:
query: |
SELECT count(*) AS count
FROM pg_stat_activity
WHERE state = 'active'
AND now() - query_start > interval '30 seconds'
metrics:
- count:
usage: "GAUGE"
description: "Cantidad de queries corriendo más de 30 segundos"
pg_connection_count:
query: |
SELECT state, count(*) AS count
FROM pg_stat_activity
GROUP BY state
metrics:
- count:
usage: "GAUGE"
description: "Cantidad de conexiones por estado"
master: true
pg_database_size:
query: |
SELECT pg_database.datname,
pg_database_size(pg_database.datname) AS size_bytes
FROM pg_database
WHERE datistemplate = false
metrics:
- datname:
usage: "LABEL"
description: "Nombre de la base de datos"
- size_bytes:
usage: "GAUGE"
description: "Tamaño de la base de datos en bytes"
Con este setup, podés crear alertas de Prometheus para:
- Alto replication lag: Alertar cuando una réplica está más de 30 segundos atrasada
- Agotamiento de conexiones: Alertar cuando las conexiones están por encima del 80% de
max_connections- Queries lentas: Alertar cuando hay queries corriendo más de 60 segundos
- Crecimiento del tamaño de la base: Alertar cuando la base de datos está creciendo más rápido de lo esperado
Operador CloudNativePG
CloudNativePG (CNPG) es un operador de Kubernetes que gestiona el ciclo de vida completo de clusters de PostgreSQL. Maneja provisionamiento, escalado, failover, backups, y monitoreo. Si estás corriendo PostgreSQL en Kubernetes, este es el operador que deberías estar usando.
Instalación
# Instalar con Helm
helm repo add cnpg https://cloudnative-pg.github.io/charts
helm repo update
helm install cnpg cnpg/cloudnative-pg \
--namespace cnpg-system \
--create-namespace
Creando un cluster de PostgreSQL
Acá hay un CRD de Cluster listo para producción:
# postgresql-cluster.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: myapp-db
namespace: database
spec:
instances: 3 # 1 primario + 2 réplicas
imageName: ghcr.io/cloudnative-pg/postgresql:16.2
postgresql:
parameters:
max_connections: "200"
shared_buffers: "512MB"
effective_cache_size: "1536MB"
maintenance_work_mem: "128MB"
checkpoint_completion_target: "0.9"
wal_buffers: "16MB"
default_statistics_target: "100"
random_page_cost: "1.1" # Optimizado para SSD
effective_io_concurrency: "200"
work_mem: "4MB"
min_wal_size: "1GB"
max_wal_size: "4GB"
max_worker_processes: "4"
max_parallel_workers_per_gather: "2"
max_parallel_workers: "4"
max_parallel_maintenance_workers: "2"
# Habilitar pg_stat_statements
shared_preload_libraries: "pg_stat_statements"
pg_stat_statements.track: "all"
pg_stat_statements.max: "10000"
pg_hba:
- "host all all 10.0.0.0/8 scram-sha-256"
- "host replication streaming_replica 10.0.0.0/8 scram-sha-256"
bootstrap:
initdb:
database: myapp_production
owner: myapp
secret:
name: myapp-db-credentials
storage:
size: 50Gi
storageClass: longhorn # O tu storage class preferida
resources:
requests:
memory: "2Gi"
cpu: "1"
limits:
memory: "4Gi"
# Habilitar monitoreo
monitoring:
enablePodMonitor: true
customQueriesConfigMap:
- name: cnpg-default-monitoring
key: queries
# Anti-afinidad para distribuir instancias entre nodos
affinity:
enablePodAntiAffinity: true
topologyKey: kubernetes.io/hostname
# Configuración de backup a S3
backup:
barmanObjectStore:
destinationPath: "s3://myapp-pg-backups/cnpg/"
s3Credentials:
accessKeyId:
name: aws-s3-credentials
key: ACCESS_KEY_ID
secretAccessKey:
name: aws-s3-credentials
key: SECRET_ACCESS_KEY
wal:
compression: gzip
maxParallel: 4
data:
compression: gzip
immediateCheckpoint: true
retentionPolicy: "30d"
Esto crea un cluster de PostgreSQL de 3 instancias con:
- Replicación automática: CNPG maneja la replicación por streaming entre primario y réplicas
- Parámetros tuneados: Configuración de PostgreSQL optimizada para un workload web típico
- Anti-afinidad de pods: Las instancias se distribuyen entre diferentes nodos de Kubernetes para resiliencia
- Monitoreo: Pod monitors para integración con Prometheus
- Archivado de WAL a S3: Backup continuo de archivos WAL para PITR
Backups programados
# scheduled-backup.yaml
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: myapp-db-daily-backup
namespace: database
spec:
schedule: "0 2 * * *" # Todos los días a las 2am
backupOwnerReference: self
cluster:
name: myapp-db
immediate: false
target: prefer-standby # Tomar backup desde una réplica para no impactar al primario
---
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: myapp-db-weekly-full
namespace: database
spec:
schedule: "0 3 * * 0" # Todos los domingos a las 3am
backupOwnerReference: self
cluster:
name: myapp-db
immediate: false
target: prefer-standby
Conectando tu aplicación
CNPG crea servicios de Kubernetes para acceso de lectura-escritura y solo lectura:
# El operador crea estos servicios automáticamente:
# myapp-db-rw -> apunta al primario (lectura-escritura)
# myapp-db-ro -> apunta a las réplicas (solo lectura, balanceado)
# myapp-db-r -> apunta a cualquier instancia (para lecturas que toleran lag)
# En tu configuración de Ecto:
config :myapp, MyApp.Repo,
hostname: "myapp-db-rw.database.svc.cluster.local",
database: "myapp_production",
username: "myapp",
password: System.get_env("DB_PASSWORD"),
pool_size: 16
config :myapp, MyApp.ReadRepo,
hostname: "myapp-db-ro.database.svc.cluster.local",
database: "myapp_production",
username: "myapp",
password: System.get_env("DB_PASSWORD"),
pool_size: 20
Monitoreando el cluster de CNPG
CNPG expone un set rico de métricas. Acá hay algunas consultas PromQL útiles:
# Replication lag en segundos
cnpg_pg_replication_lag{cluster="myapp-db"}
# Cantidad de conexiones por estado
cnpg_pg_stat_activity_count{cluster="myapp-db"}
# Tasa de transacciones
rate(cnpg_pg_stat_database_xact_commit{cluster="myapp-db"}[5m])
+ rate(cnpg_pg_stat_database_xact_rollback{cluster="myapp-db"}[5m])
# Cache hit ratio (debería ser > 99%)
cnpg_pg_stat_database_blks_hit{cluster="myapp-db"}
/ (cnpg_pg_stat_database_blks_hit{cluster="myapp-db"}
+ cnpg_pg_stat_database_blks_read{cluster="myapp-db"}) * 100
# Tasa de generación de WAL
rate(cnpg_pg_stat_archiver_archived_count{cluster="myapp-db"}[5m])
# Tamaño de la base de datos
cnpg_pg_database_size_bytes{cluster="myapp-db", datname="myapp_production"}
Failover y alta disponibilidad
El punto principal de correr múltiples instancias es que cuando el primario falla, una réplica toma el control automáticamente. Acá es donde CloudNativePG realmente brilla.
Failover automático con CloudNativePG
CNPG monitorea la salud de todas las instancias continuamente. Cuando detecta que el primario no está saludable:
- Detecta la falla: El operador verifica la salud de las instancias via health probes y estado de replicación
- Selecciona la mejor réplica: Elige la réplica con menos replication lag
- Promueve la réplica: Ejecuta
pg_promote()para hacer que la réplica sea el nuevo primario- Actualiza los servicios: El servicio
myapp-db-rwahora apunta al nuevo primario- Reconfigura las réplicas restantes: Empiezan a replicar desde el nuevo primario
- Cerca al viejo primario: Previene que el viejo primario acepte escrituras (prevención de split-brain)
Todo este proceso típicamente se completa en 10-30 segundos. Tu aplicación podría ver un breve error de conexión durante el switchover, así que asegurate de que tu configuración de Ecto tenga lógica de reintentos apropiada:
# config/runtime.exs
config :myapp, MyApp.Repo,
hostname: "myapp-db-rw.database.svc.cluster.local",
database: "myapp_production",
pool_size: 16,
# Ecto/DBConnection va a reintentar checkouts fallidos
queue_target: 5000,
queue_interval: 5000,
# Configurar las opciones de socket para detección más rápida de fallas
socket_options: [
keepalive: true,
# Enviar sondas keepalive después de 10 segundos de inactividad
# (depende de la plataforma, funciona en Linux)
],
parameters: [
application_name: "tr-web"
]
Probando el failover
Deberías probar regularmente que el failover funciona. Con CNPG, podés disparar un switchover controlado:
# Disparar un switchover (failover controlado)
kubectl cnpg promote myapp-db myapp-db-2 --namespace database
# O usá el plugin para disparar un restart del primario (simula un crash)
kubectl cnpg restart myapp-db myapp-db-1 --namespace database
# Verificar el estado del cluster durante y después del failover
kubectl cnpg status myapp-db --namespace database
La salida te muestra qué instancia es el primario, el replication lag, y la salud general del cluster:
# Ejemplo de salida de kubectl cnpg status myapp-db
Cluster Summary
Name: myapp-db
Namespace: database
PostgreSQL Image: ghcr.io/cloudnative-pg/postgresql:16.2
Primary instance: myapp-db-2 # Este fue promovido
Status: Cluster in healthy state
Instances: 3
Certificates Status
...
Instances Status
Name Role Status Node Timeline LSN
---- ---- ------ ---- -------- ---
myapp-db-1 Replica OK worker-01 2 0/5000060
myapp-db-2 Primary OK worker-02 2 0/5000060
myapp-db-3 Replica OK worker-03 2 0/5000060
Patroni como alternativa
Si no estás usando CloudNativePG (tal vez estás corriendo PostgreSQL en VMs o usando un enfoque diferente para Kubernetes), Patroni es la solución de referencia para alta disponibilidad de PostgreSQL. Usa un store de consenso distribuido (etcd, Consul, o ZooKeeper) para manejar elección de líder y failover:
# patroni.yml
scope: myapp-cluster
name: postgresql-node-1
restapi:
listen: 0.0.0.0:8008
connect_address: postgresql-node-1:8008
etcd:
hosts: etcd-1:2379,etcd-2:2379,etcd-3:2379
bootstrap:
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576 # 1MB
postgresql:
use_pg_rewind: true
parameters:
max_connections: 200
shared_buffers: 512MB
wal_level: replica
hot_standby: on
max_wal_senders: 10
max_replication_slots: 10
initdb:
- encoding: UTF8
- data-checksums
postgresql:
listen: 0.0.0.0:5432
connect_address: postgresql-node-1:5432
data_dir: /var/lib/postgresql/16/main
authentication:
superuser:
username: postgres
password: secret
replication:
username: replicator
password: secret
La diferencia clave es que CNPG es nativo de Kubernetes (usa la API de Kubernetes para coordinación) mientras que Patroni requiere un store de consenso separado. Si ya estás corriendo en Kubernetes, CNPG es la opción más simple.
Prevención de split-brain
El split-brain es lo peor que puede pasar en un cluster de base de datos: dos instancias creen que son el primario y aceptan escrituras de forma independiente. Cuando se reconectan, los datos son inconsistentes y potencialmente irrecuperables.
Tanto CNPG como Patroni tienen prevención de split-brain incorporada:
- CNPG usa fencing. Cuando ocurre un failover, el viejo primario es cercado (su directorio de datos se marca como inválido) así que incluso si vuelve, no puede servir escrituras. Tiene que ser reinicializado como réplica.
- Patroni usa el store de consenso (etcd) como fuente de verdad. Solo el nodo que tiene la key de líder en etcd puede ser el primario. Si un nodo pierde contacto con etcd, se demota a sí mismo.
Salvaguardas adicionales que deberías tener:
- Network policies: Asegurate de que solo el operador o Patroni pueda modificar los endpoints de los servicios
- Monitoreo: Alertá sobre cualquier instancia que se reporte como primario cuando no debería serlo
- pg_rewind: Habilitá
pg_rewindpara que un ex-primario pueda ser resincronizado rápidamente como réplica sin un backup base completo
# PrometheusRule para detección de split-brain
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: pg-split-brain-alert
namespace: database
spec:
groups:
- name: postgresql.split-brain
rules:
- alert: PostgreSQLSplitBrain
expr: |
count(cnpg_pg_replication_is_replica{cluster="myapp-db"} == 0) > 1
for: 30s
labels:
severity: critical
annotations:
summary: "CRITICO: Múltiples instancias primarias detectadas en el cluster myapp-db"
description: "Hay {{ $value }} instancias reportándose como primario. Esta es una situación de split-brain que requiere atención inmediata."
Notas finales
La confiabilidad de la base de datos no es una sola cosa que configurás y te olvidás. Es una combinación de patrones que trabajan juntos: connection pooling mantiene tus conexiones saludables, las réplicas distribuyen la carga de lectura, los backups protegen tus datos, las migraciones seguras previenen outages autoinfligidos, el monitoreo te dice cuando algo anda mal, y el failover automatizado mantiene todo funcionando cuando el hardware falla.
La buena noticia es que herramientas como CloudNativePG hacen que la mayor parte de esto sea mucho más fácil de lo que solía ser. En vez de configurar manualmente la replicación, scripts de failover, y cron jobs de backup, declarás tu estado deseado en un manifiesto de Kubernetes y el operador se encarga del resto. Eso es una mejora enorme comparado con el enfoque de “PostgreSQL artesanal” con el que muchos de nosotros crecimos.
Empezá con lo básico: poné PgBouncer delante de tu base de datos, configurá backups automatizados con pruebas de restauración, y agregá pg_stat_statements para monitoreo de queries. Después, cuando estés listo, pasate a CloudNativePG para un cluster completamente gestionado con failover automático. Cada capa se construye sobre la anterior.
¡Espero que te haya resultado útil y lo hayas disfrutado! ¡Hasta la próxima!
Errata
Si encontrás algún error o tenés alguna sugerencia, por favor mandame un mensaje para que se corrija.
También podés revisar el código fuente y los cambios en las fuentes acá
$ Comentarios
Online: 0Por favor inicie sesión para poder escribir comentarios.