SRE: Confiabilidad de Bases de Datos

2026-03-23 | Gabriel Garrido | 27 min de lectura
Share:

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:


  1. Detecta la falla: El operador verifica la salud de las instancias via health probes y estado de replicación
  2. Selecciona la mejor réplica: Elige la réplica con menos replication lag
  3. Promueve la réplica: Ejecuta pg_promote() para hacer que la réplica sea el nuevo primario
  4. Actualiza los servicios: El servicio myapp-db-rw ahora apunta al nuevo primario
  5. Reconfigura las réplicas restantes: Empiezan a replicar desde el nuevo primario
  6. 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_rewind para 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: 0

Por favor inicie sesión para poder escribir comentarios.

2026-03-23 | Gabriel Garrido