Si alguna vez reiniciaste un contenedor Postgres “sin estado” y encontraste una base de datos “fresca” esperándote como un pez dorado amnésico, te has topado con
la mayor mentira del mundo de los contenedores: que la base de datos se preocupa más por tu YAML que por el sistema de archivos.
Postgres es aburrido en el mejor sentido, hasta que lo ejecutas en Docker con decisiones de almacenamiento equivocadas, copias de seguridad descuidadas o actualizaciones casuales. Entonces se convierte en
una historia de detectives donde el culpable suele ser tú, de ayer.
Qué falla realmente (y por qué Docker lo facilita)
La mayoría de los incidentes de “pérdida de datos de Postgres en Docker” no son místicos. Son la colisión de dos sistemas totalmente razonables:
Docker asume que tu contenedor es descartable; Postgres asume que su directorio de datos es sagrado.
Docker te da primitivas simples: imágenes, contenedores, volúmenes, bind mounts, redes. Postgres te da reglas estrictas de durabilidad: WAL,
fsync, checkpoints, y una aversión violenta a archivos corruptos. Cuando la gente sale mal parada, suele ser por una suposición equivocada:
“El contenedor es la base de datos.” No. El contenedor es un envoltorio para el proceso. La base de datos es el estado en disco más WAL más copias de seguridad y las reglas que sigues.
Puedes ejecutar Postgres en Docker sin problema. Muchas pilas de producción lo hacen. Pero debes ser explícito sobre:
- Dónde vive el directorio de datos y cómo está montado.
- Cómo funcionan las copias de seguridad—y cómo has probado que se restauran.
- Cómo realizas las actualizaciones sin re-inicializar accidentalmente el clúster.
- Qué compromisos de durabilidad has tomado (a veces sin darte cuenta).
- Cómo detectas presión de almacenamiento y WAL antes de que se convierta en downtime.
El patrón de la trampa es consistente: una pequeña elección de conveniencia en el día 1 se convierte en un corte de servicio en la semana 12 porque nadie la revisó una vez que “funcionó”.
Hechos interesantes y contexto histórico
- Postgres empezó a mediados de los años 80 (como POSTGRES en Berkeley) y mantuvo su cultura de “hacer lo correcto con los datos” incluso cuando las herramientas evolucionaron.
- Write-Ahead Logging (WAL) es el corazón de la durabilidad de Postgres. No es opcional en espíritu, aunque las configuraciones intenten fingir lo contrario.
- Los volúmenes Docker son gestionados por Docker y viven por defecto bajo el directorio de datos de Docker; esto los hace portables entre reemplazos de contenedores, pero no frente a la pérdida del host.
- Los bind mounts preceden a Docker como concepto Unix. Son poderosos y transparentes, por eso también son la forma de cometer errores por permisos y rutas equivocadas.
- “docker system prune” existe desde hace años y sigue siendo una de las formas más rápidas de borrar lo equivocado si tratas volúmenes como “cache”.
- Las actualizaciones mayores de Postgres no son in-place por defecto; normalmente requieren dump/restore o pg_upgrade, y ambos tienen aristas dentro de contenedores.
- Los sistemas de ficheros overlay se popularizaron con los contenedores; son geniales para imágenes y capas, y un lugar terrible para almacenar bases de datos a menos que disfrutes sorpresas de I/O.
- Kubernetes popularizó la consigna “mascotas vs ganado”, que funciona muy bien hasta que aplicas la lógica de “ganado” al almacenamiento de la base de datos en sí.
- Postgres tiene replicación lógica sólida desde hace años (publication/subscription); puede ser una ruta de migración práctica fuera de una mala configuración Docker sin downtime.
Tu modelo mental: container lifecycle vs. database lifecycle
Los contenedores son reemplazables. El estado de la base de datos no lo es.
Un contenedor es una instancia en ejecución de una imagen. Mátalo, recréalo, reubícalo—bien. Ese es el punto. A Postgres no le importan los contenedores.
A Postgres le importa PGDATA (el directorio de datos), y asume:
- Los archivos permanecen en su lugar.
- La propiedad y los permisos se mantienen consistentes.
- fsync significa fsync.
- WAL llega al almacenamiento estable cuando declara que lo hace.
Tu trabajo es asegurar que la plomería de almacenamiento de Docker no viole esas suposiciones. La mayoría de los problemas se reducen a:
- Ruta de montaje equivocada: Postgres escribe en las capas del sistema de archivos del contenedor, no en almacenamiento persistente.
- Origen de montaje equivocado: montaste un directorio vacío y desencadenaste initdb accidentalmente.
- Permisos incorrectos: Postgres no puede escribir, por lo que falla o se comporta de forma extraña bajo la lógica del entrypoint.
- Método de actualización incorrecto: creaste un clúster nuevo y apuntaste las apps a él.
- Configuración de durabilidad equivocada: “ganancias” de rendimiento que aceptan silenciosamente pérdida de datos en un crash.
Por qué “funcionó en mi laptop” es una trampa
En un portátil, puede que no notes que guardaste Postgres dentro de la capa del contenedor. Reinicias el contenedor unas cuantas veces; los datos permanecen allí.
Luego alguien ejecuta docker rm o CI recrea contenedores, y los datos se evaporan porque nunca estuvieron en un volumen.
Los contenedores son excelentes en hacer que lo incorrecto parezca estable. Conservarán felizmente tus errores hasta el día en que no lo hagan.
Escenarios de pérdida de datos que puedes reproducir (y prevenir)
Escenario 1: Sin montaje de volumen → los datos viven en la capa del contenedor
El clásico. Ejecutas Postgres sin un montaje persistente. Postgres escribe en /var/lib/postgresql/data dentro del sistema de archivos del contenedor.
Reiniciar el contenedor mantiene los datos. Borrar/recrear el contenedor los destruye.
Prevención: siempre monta un volumen Docker o un bind mount al directorio PGDATA real, y demuestra que está montado con docker inspect.
Escenario 2: Montar la ruta equivocada → los datos van a otro lado
La imagen oficial usa /var/lib/postgresql/data. Gente monta /var/lib/postgres o /data porque lo ha hecho en otros sitios.
Postgres sigue escribiendo en la ruta por defecto. Tu almacenamiento montado queda sin usar, maravillosamente vacío, como un paracaídas extra olvidado en el avión.
Prevención: inspecciona los montajes del contenedor y confirma que la base de datos está escribiendo en el sistema de archivos montado. Comprueba SHOW data_directory; dentro de Postgres.
Escenario 3: Bind mount de un directorio vacío → initdb se ejecuta y “crea” un clúster nuevo
Muchos entrypoints inicializan un clúster nuevo cuando PGDATA parece vacío. Si montas por error un directorio nuevo del host sobre el directorio real de datos,
el contenedor ve vacío y ejecuta initdb. Has creado ahora una base de datos nueva al lado de la real, y tu aplicación se conecta a lo incorrecto.
Así es como la “pérdida de datos” comienza como “¿eh, por qué falta mi tabla?” y termina como “¿por qué durante seis horas escribimos datos en el clúster equivocado?”
Escenario 4: “docker compose down -v” y amigos → le pediste a Docker que borre tu base de datos
Docker facilita la limpieza. Eso incluye volúmenes. Si tu almacenamiento de Postgres es un volumen con nombre en Compose, down -v lo elimina.
Si es un volumen anónimo, puedes eliminarlo con prune sin darte cuenta.
Prevención: trata el volumen de Postgres como estado de producción. Protégelo con convenciones de nombres, etiquetas y procesos. Evita volúmenes anónimos para bases de datos.
Escenario 5: Ejecutar Postgres sobre almacenamiento overlay → rarezas de rendimiento y mayor riesgo de corrupción
La capa escribible de Docker suele ser overlay2 (o similar). Está bien para logs de aplicaciones. No es donde quieres I/O aleatorio de base de datos y fsync intensivo.
El rendimiento se vuelve inconsistente; aparecen picos de latencia. Bajo un crash o presión de disco, los incidentes de corrupción se vuelven más plausibles.
Prevención: usa un volumen o bind mount respaldado por un sistema de archivos real en el host, no la capa escribible del contenedor.
Escenario 6: “Ajuste” de durabilidad que en realidad es modo pérdida de datos
Desactivar fsync o poner synchronous_commit=off puede hacer que los benchmarks parezcan heroicos.
Luego el host se cae y la base de datos pierde transacciones recientes. Eso no fue “inesperado”. Ese fue el trato.
Hay razones válidas para relajar la durabilidad (por ejemplo, desarrollo efímero, ciertos pipelines analíticos donde perder segundos está bien).
Pero para cualquier cosa orientada al usuario: no lo hagas. Postgres es lo bastante rápido si lo colocas en almacenamiento sensato y lo configuras correctamente.
Broma #1: Desactivar fsync para “acelerar Postgres” es como quitar los frenos para “mejorar el tiempo de viaje”. Llegarás más rápido, brevemente.
Escenario 7: WAL llena el disco → Postgres se detiene y la recuperación se complica
WAL es append-heavy. Cuando el disco se llena, Postgres no puede escribir WAL y se detendrá. Si además tienes malas políticas de retención o sin monitorización,
puedes acabar con una instancia bloqueada y sin una ruta de recuperación limpia.
Prevención: monitoriza el uso de disco donde viven PGDATA y WAL. Ajusta un max_wal_size sensato, configura archivado si necesitas PITR, y mantén margen libre.
Escenario 8: Desajuste de zona horaria/locale del contenedor y errores de codificación → “pérdida de datos” por mala interpretación
No toda “pérdida” es borrado. Si inicializas un clúster con locale/encoding diferente y luego comparas dumps, puedes ver ordenaciones corruptas,
problemas de collation o texto roto. No faltan bytes, pero puede parecer corrupción.
Prevención: establece explícitamente locale/encoding en la inicialización, documéntalo y mantenlo estable entre reconstrucciones.
Escenario 9: Actualización de versión mayor cambiando tags de imagen → creaste un clúster incompatible
Cambiar postgres:14 a postgres:16 y reiniciar con el mismo volumen no “actualiza” Postgres.
Postgres se negará a arrancar porque el formato del directorio de datos difiere. Bajo presión, la gente “arregla” esto borrando el volumen.
Eso no es una actualización. Es incendiar.
Prevención: usa pg_upgrade (a menudo más fácil con dos contenedores y una estrategia de volúmenes compartidos), o replicación lógica, o dump/restore—según tamaño y tolerancia a downtime.
Escenario 10: Deriva de permisos (rootless Docker, cambios UID/GID en el host) → Postgres no arranca y alguien “recrea” la BD
Los bind mounts heredan permisos del host. Cambia el mapeo de usuarios del host, mueve directorios, pasa a rootless Docker, o restaura desde backup con una propiedad distinta,
y de repente Postgres no puede acceder a sus propios archivos. En pánico, los equipos suelen eliminar el montaje y “comenzar de nuevo”.
Prevención: estandariza la propiedad y usa volúmenes con nombre cuando sea posible. Si debes usar bind mounts, fija expectativas de UID/GID y prueba en la misma familia de SO.
Guion de diagnóstico rápido
Cuando Postgres en Docker está comportándose mal, no divagues. Comienza con las tres preguntas que lo deciden todo: “¿Dónde están los datos, puede escribir y es durable?”
Primero: confirma que estás mirando el clúster correcto
- Comprueba el montaje PGDATA: ¿el directorio de datos está respaldado por un volumen/bind mount?
- Comprueba la ruta del directorio de datos: ¿qué reporta Postgres como
data_directory? - Comprueba la identidad del clúster: mira
system_identifiery la timeline; compáralos con lo que esperas.
Segundo: busca presión de almacenamiento obvia
- Disco lleno: uso del filesystem del host donde vive el volumen.
- Inflado de WAL: tamaño de
pg_waly replication slots. - Bloqueos de I/O: latencia y tiempos de fsync; la CPU del contenedor puede parecer bien mientras el almacenamiento muere.
Tercero: verifica durabilidad y estado de crash-recovery
- Sanidad de configuración: asegúrate de que
fsyncyfull_page_writesno estén desactivados en producción. - Logs: bucles de crash recovery, “invalid checkpoint record” o errores de permisos.
- Eventos del kernel/filesystem: dmesg por errores de I/O; esto suele explicar la “corrupción aleatoria”.
Cómo encontrar el cuello de botella rápidamente
Si el síntoma es “lento”, decide si es CPU, memoria, contención de locks o I/O de almacenamiento. Con Docker, el almacenamiento suele ser el sospechoso habitual, y los logs a menudo te lo indican pronto.
Si el síntoma es “datos faltantes”, deja de escribir inmediatamente y verifica que no hayas arrancado un clúster nuevo por error.
Tareas prácticas: comandos, salidas y decisiones
Estas son las tareas que realmente ejecuto cuando algo huele a raro. Cada una incluye (1) el comando, (2) qué significa la salida y (3) la decisión que tomas.
Supón que el contenedor se llama pg y tienes acceso shell al host.
Task 1: Listar contenedores y confirmar qué Postgres está en ejecución
cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
NAMES IMAGE STATUS PORTS
pg postgres:16 Up 2 hours 0.0.0.0:5432->5432/tcp
Significado: Confirma qué tag de imagen está vivo y si se reinició recientemente. Un sospechoso “Up 3 minutes” suele correlacionar con problemas en el directorio de datos.
Decisión: Si el tag de imagen cambió recientemente, trata las actualizaciones como sospecha principal. Si se reinició inesperadamente, ve directo a logs y comprobaciones de montaje.
Task 2: Inspeccionar montajes y verificar que PGDATA esté persistido
cr0x@server:~$ docker inspect pg --format '{{json .Mounts}}'
[{"Type":"volume","Name":"pgdata","Source":"/var/lib/docker/volumes/pgdata/_data","Destination":"/var/lib/postgresql/data","Driver":"local","Mode":"z","RW":true,"Propagation":""}]
Significado: Quieres un montaje que apunte al directorio de datos real. Si no ves un montaje a /var/lib/postgresql/data, tus datos están en la capa del contenedor.
Decisión: Si falta el montaje o apunta a otro lugar, para y arregla el almacenamiento antes de hacer cualquier otra cosa. No “reinicies hasta que funcione”.
Task 3: Confirmar que Postgres piensa que su directorio de datos es donde lo montaste
cr0x@server:~$ docker exec -it pg psql -U postgres -Atc "SHOW data_directory;"
/var/lib/postgresql/data
Significado: Esto debería coincidir con el destino del montaje. Si no coincide, estás escribiendo donde no pretendías.
Decisión: Si hay desajuste, corrige variables de entorno o flags de línea de comando, y asegúrate de usar consistentemente la ruta PGDATA que espera la imagen oficial.
Task 4: Comprobar si accidentalmente inicializaste un clúster nuevo (system identifier)
cr0x@server:~$ docker exec -it pg psql -U postgres -Atc "SELECT system_identifier FROM pg_control_system();"
7264851093812409912
Significado: El system identifier es efectivamente la identidad del clúster. Si cambió tras un redeploy, no estás en el mismo clúster.
Decisión: Si el identificador es inesperado, detén las escrituras de la aplicación, localiza el volumen/bind mount original y restaura la conectividad al directorio de datos correcto.
Task 5: Revisar logs del contenedor por initdb o errores de permisos
cr0x@server:~$ docker logs --since=2h pg | tail -n 30
PostgreSQL Database directory appears to contain a database; Skipping initialization
2026-01-03 10:41:07.123 UTC [1] LOG: starting PostgreSQL 16.1 on x86_64-pc-linux-gnu
2026-01-03 10:41:07.124 UTC [1] LOG: listening on IPv4 address "0.0.0.0", port 5432
Significado: “Skipping initialization” es bueno. Si ves “initdb: warning” o “Database directory appears to be empty” inesperadamente, montaste un directorio vacío.
Si ves “permission denied”, es un problema de propiedad en el bind mount.
Decisión: Initdb cuando no lo esperabas es una alarma roja. No continúes hasta explicar por qué Postgres creyó que el directorio estaba vacío.
Task 6: Identificar si tu volumen es con nombre o anónimo
cr0x@server:~$ docker volume ls
DRIVER VOLUME NAME
local pgdata
local 3f4c9b6a7c1b0b3e8b8d8af2c2e1d2f9d8e7c6b5a4f3e2d1c0b9a8f7e6d5
Significado: Los volúmenes con nombre (pgdata) son más fáciles de proteger y referenciar. Los volúmenes anónimos son fáciles de perder durante la limpieza.
Decisión: Para bases de datos, usa volúmenes con nombre o bind mounts explícitos. Si ves volúmenes anónimos adjuntos a Postgres, migra antes del “día de limpieza”.
Task 7: Ver qué contenedores usan el volumen (evita borrar el equivocado)
cr0x@server:~$ docker ps -a --filter volume=pgdata --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}'
NAMES STATUS IMAGE
pg Up 2 hours postgres:16
Significado: Si más de un contenedor usa el mismo volumen, podrías tener acceso multi-writer accidental. Eso es un riesgo de corrupción.
Decisión: Asegúrate de que solo una instancia de Postgres escriba en un directorio de datos dado. Si necesitas HA, usa replicación, no almacenamiento compartido multi-writer.
Task 8: Comprobar espacio libre en el filesystem del host que respalda Docker
cr0x@server:~$ df -h /var/lib/docker
Filesystem Size Used Avail Use% Mounted on
/dev/nvme0n1p2 450G 410G 40G 92% /
Significado: 92% es territorio peligroso para crecimiento de WAL y picos de vacuum. Las bases de datos no fallan educadamente cuando los discos se llenan.
Decisión: Si estás por encima de ~85–90% en producción, planifica limpieza o ampliación inmediata. Luego configura alertas y objetivos de margen.
Task 9: Ver tamaño del directorio WAL dentro del contenedor
cr0x@server:~$ docker exec -it pg bash -lc 'du -sh /var/lib/postgresql/data/pg_wal'
18G /var/lib/postgresql/data/pg_wal
Significado: WAL grande puede ser normal bajo carga de escritura, pero un crecimiento repentino a menudo significa replication slots atascados, max_wal_size demasiado grande,
o archivado que no se está drenando.
Decisión: Si WAL está creciendo, revisa los replication slots y el estado del archiver inmediatamente antes de que el disco se llene.
Task 10: Comprobar replication slots (causa común de WAL sin límites)
cr0x@server:~$ docker exec -it pg psql -U postgres -x -c "SELECT slot_name, active, restart_lsn FROM pg_replication_slots;"
-[ RECORD 1 ]----------------------------
slot_name | analytics_consumer
active | f
restart_lsn | 0/2A3F120
Significado: Un slot inactivo puede retener WAL para siempre si ningún consumidor lo avanza.
Decisión: Si el slot no se usa, elimínalo. Si se necesita, arregla el consumidor y confirma que avanza. No te limites a “aumentar el disco”.
Task 11: Revisar salud del archiver si usas archivado de WAL
cr0x@server:~$ docker exec -it pg psql -U postgres -x -c "SELECT archived_count, failed_count, last_archived_wal, last_failed_wal FROM pg_stat_archiver;"
-[ RECORD 1 ]-----------------------
archived_count | 18241
failed_count | 12
last_archived_wal | 0000000100000000000001A3
last_failed_wal | 0000000100000000000001A1
Significado: Los fallos implican que tu cadena PITR puede tener huecos. También significa que WAL podría acumularse si el archivado forma parte de tu plan de retención.
Decisión: Investiga el comando de archivado y el almacenamiento. Si los fallos son recientes, asume que las opciones de recuperación están comprometidas hasta demostrar lo contrario.
Task 12: Verificar configuraciones de durabilidad (atrapa el “modo benchmark” accidental)
cr0x@server:~$ docker exec -it pg psql -U postgres -Atc "SHOW fsync; SHOW synchronous_commit; SHOW full_page_writes;"
on
on
on
Significado: Para OLTP en producción, esto es la base que quieres. Si fsync=off, has aceptado explícitamente el riesgo de corrupción en un crash.
Decisión: Si estas opciones están desactivadas, actívalas y planifica un reinicio controlado. Luego explica a los interesados por qué las configuraciones previas eran inseguras.
Task 13: Confirmar que el contenedor no carece de memoria (los OOM kills parecen crashes aleatorios)
cr0x@server:~$ docker stats --no-stream pg
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
a1b2c3d4e5f6 pg 85.3% 1.9GiB / 2.0GiB 95.0% 1.2GB/1.1GB 35GB/22GB 78
Significado: Uso de memoria al 95% con CPU alta sugiere presión. Si el OOM killer del host interviene, Postgres se reinicia y corres el riesgo de una recuperación más larga.
Decisión: Aumenta límites de memoria, ajusta configuraciones de memoria de Postgres (shared_buffers, work_mem) y verifica que el host no esté sobresuscrito.
Task 14: Buscar errores de I/O en el filesystem del host (la verdad poco glamorosa)
cr0x@server:~$ sudo dmesg -T | tail -n 20
[Sat Jan 3 10:22:11 2026] nvme0n1: I/O 128 QID 7 timeout, aborting
[Sat Jan 3 10:22:11 2026] EXT4-fs error (device nvme0n1p2): ext4_find_entry:1459: inode #262401: comm postgres: reading directory lblock 0
Significado: Si el kernel informa timeouts de I/O o errores del filesystem, deja de culpar a Docker. Tu almacenamiento está fallando o comportándose mal.
Decisión: Trátalo como un incidente. Reduce la carga de escritura, toma una copia de seguridad si es posible, y planifica failover o reemplazo de almacenamiento.
Task 15: Confirmar que existen backups y que son restaurables (no confundas “archivos” con “backups”)
cr0x@server:~$ docker exec -it pg bash -lc 'ls -lh /backups | tail -n 5'
-rw-r--r-- 1 root root 1.2G Jan 3 02:00 pg_dumpall_2026-01-03.sql.gz
-rw-r--r-- 1 root root 1.1G Jan 2 02:00 pg_dumpall_2026-01-02.sql.gz
Significado: Tienes artefactos. Eso no es lo mismo que una restauración verificada.
Decisión: Si no puedes probar la restauración, programa una prueba de restore. Producción no es el momento para descubrir que tus dumps están vacíos o truncados.
Task 16: Ejecutar una prueba rápida de restauración en una base de datos descartable
cr0x@server:~$ docker exec -it pg bash -lc 'createdb -U postgres restore_smoke && gunzip -c /backups/pg_dumpall_2026-01-03.sql.gz | psql -U postgres -d restore_smoke -v ON_ERROR_STOP=1'
SET
SET
CREATE TABLE
ALTER TABLE
Significado: Si se ejecuta sin errores y crea objetos, tu dump es al menos sintácticamente utilizable. No es una validación completa, pero es mejor que confiar en sensaciones.
Decisión: Si ocurren errores, deja de asumir que tienes backups. Arregla la canalización y vuelve a ejecutar hasta que sea aburrido.
Tres mini-historias del mundo corporativo
Mini-historia #1: La suposición equivocada (el incidente “los contenedores son persistencia”)
Un equipo SaaS mediano movió una app legacy a Docker Compose para que el dev local coincidiera con staging. Postgres entró en el mismo Compose.
El ingeniero que hizo la migración tenía buenas intenciones y una fecha límite, que es la combinación que genera los outages más interesantes.
Probaron reinicios con docker restart. Los datos seguían allí. Chocaron manos con el universo y desplegaron el mismo Compose en una pequeña VM de producción.
El servicio de Postgres no tenía volumen configurado—solo el filesystem por defecto del contenedor.
Semanas después rotaron el host por un parche de seguridad. El nuevo host arrancó el stack Compose, y Postgres apareció limpio… porque estaba vacío.
La aplicación también apareció limpia… y empezó a recrear tablas, porque la herramienta de migración vio “sin esquema” y procedió como un niño con marcadores permanentes.
La respuesta al incidente fue desordenada porque el equipo inicialmente lo trató como corrupción. Buscaron ajustes de WAL y versiones de kernel.
La causa real era más simple: nunca habían persistido el clúster. No había “restaurar desde disco”. Solo “restaurar desde backup”, y los backups eran parciales.
La acción correctiva que quedó: añadieron un volumen con nombre, fijaron PGDATA explícitamente y escribieron un script de preflight que se negaba a arrancar producción si el directorio de datos parecía recién inicializado.
Ese script molestó a la gente exactamente una vez, y luego les salvó de repetir el mismo error en un redeploy posterior.
Mini-historia #2: La optimización que salió mal (el “disco rápido” que mentía)
Otra organización tenía un contenedor Postgres ruidoso y mucho tráfico de escritura. Surgieron picos de latencia en hora pico, y los sospechosos habituales fueron señalados:
autovacuum, locks, planes de consulta. Hicieron algunos ajustes, obtuvieron ganancias modestas y aún veían stalls ocasionales.
Un ingeniero de infra sugirió mover el directorio de datos de Docker a un filesystem en red “más rápido” usado en otros sitios para artefactos.
En benchmarks rindió bien para escrituras secuenciales y archivos grandes. Postgres usa patrones intensivos en fsync, I/O aleatorio pequeño y churn de metadata.
El movimiento se vio bien en pruebas sintéticas e incluso en los primeros días de producción.
Luego vino el primer tropiezo real del host. Una breve pausa de almacenamiento causó que Postgres registrara avisos de I/O y luego se cayera. Al reiniciar entró en crash recovery.
La recuperación tomó mucho más tiempo de lo esperado y los timeouts de la app se convirtieron en un outage visible para usuarios.
El postmortem fue franco: el filesystem “rápido” estaba optimizado para throughput, no para consistencia de latencia y semánticas de durabilidad.
Peor, su comportamiento bajo carga no coincidía con lo que Postgres espera al llamar fsync. Habían cambiado rendimiento a corto plazo por una recuperación frágil.
Revirtieron a almacenamiento local en SSD y se centraron en arreglos aburridos: dimensionamiento correcto de la instancia, ajuste de checkpoints y añadir réplicas.
El rendimiento mejoró, pero la verdadera ganancia fue estabilidad—la recuperación volvió a ser predecible.
Mini-historia #3: La práctica aburrida pero correcta que salvó el día (simulacros de restauración)
Un gran equipo de plataforma interna ejecutaba Postgres en contenedores para docenas de servicios pequeños. Nada exótico: volúmenes, versiones fijadas, monitorización decente.
La parte que a los recién llegados les parecía excesiva era el simulacro trimestral de restauración. Cada trimestre restauraban un subconjunto de bases en un entorno aislado.
Verificaban esquema, conteos de filas en tablas clave y tests de humo de la aplicación.
Un día, un host sufrió fallo de almacenamiento. Un primario de Postgres murió duro. La réplica iba retrasada más de lo que querían, y había incertidumbre sobre la disponibilidad de WAL.
Nadie entró en pánico, lo que no es un rasgo de personalidad—es un procedimiento.
Hicieron failover donde pudieron. Para un servicio tuvieron que restaurar desde backup en un volumen nuevo porque el estado de replicación era dudoso.
La restauración fue más lenta que un failover pero funcionó exactamente como se había ensayado. El servicio volvió con una frescura de datos aceptable, ya acordada con negocio.
En el debrief, la “salsa secreta” del equipo no era una herramienta ingeniosa. Era la repetición. Habían practicado restaurar tantas veces que el incidente real se sintió como un simulacro un poco más molesto.
Errores comunes: síntomas → causa raíz → solución
1) “Mi base de datos se reinició tras redeploy”
- Síntomas: Esquema vacío, solo usuarios por defecto, migraciones de la app se ejecutan desde cero.
- Causa raíz: No hay volumen persistente, o montaste un directorio vacío sobre PGDATA causando initdb.
- Solución: Detén las escrituras, localiza el volumen/bind mount original y vuelve a conectarlo. Añade un volumen con nombre y un guard de arranque que verifique la identidad del clúster esperada.
2) “Los datos están, pero la app no los encuentra”
- Síntomas: psql muestra datos correctos; la app ve filas/tablas faltantes; o la app se conecta pero devuelve “relation does not exist”.
- Causa raíz: La app se conecta a otra instancia/puerto de Postgres, nombre de base de datos equivocado, red equivocada o volumen equivocado adjunto a un contenedor con nombre similar.
- Solución: Confirma connection string, resolución de nombres de contenedores, mapeos de puertos y
system_identifier. Etiqueta volúmenes y contenedores claramente.
3) “Postgres no arranca tras la actualización”
- Síntomas: Error fatal sobre archivos de base de datos incompatibles con el servidor.
- Causa raíz: Cambio de versión mayor sin pg_upgrade o dump/restore.
- Solución: Revierte al tag de imagen anterior para restaurar el servicio. Planifica una actualización real: pg_upgrade en flujo controlado o migración por replicación lógica.
4) “WAL sigue creciendo hasta llenar disco”
- Síntomas:
pg_walenorme; uso de disco en ascenso; Postgres termina deteniéndose. - Causa raíz: Replication slot inactivo, fallos de archivado o replicación retrasada con restricciones de retención.
- Solución: Identifica slots, elimina los no usados, arregla consumidores, repara archivado y añade alertas sobre crecimiento del directorio WAL y margen de disco.
5) “Reinicios aleatorios, a veces bajo carga”
- Síntomas: Contenedor se reinicia; logs muestran terminación abrupta; consultas fallan intermitentemente.
- Causa raíz: OOM kills por límites de memoria ajustados en el contenedor, o presión de memoria en el host.
- Solución: Aumenta memoria del contenedor, ajusta configuración de memoria de Postgres y evita overcommit en el host. Confirma con
dmesgy estadísticas del contenedor.
6) “Restauramos el volumen desde snapshot y ahora Postgres se queja”
- Síntomas: Crash recovery falla, faltan segmentos WAL, errores de estado inconsistente.
- Causa raíz: Snapshot a nivel de almacenamiento tomado sin coordinación con filesystem/aplicación; el snapshot capturó un punto inconsistente respecto a WAL.
- Solución: Prefiere backups lógicos o backups físicos coordinados (pg_basebackup, archivado). Si haces snapshot, congela I/O o usa funciones de filesystem diseñadas para snapshots crash-consistentes y prueba la restauración.
7) “Permisos denegados al arrancar”
- Síntomas: Logs de Postgres mencionan permission denied en PGDATA; el contenedor sale inmediatamente.
- Causa raíz: Bind mount propiedadada por UID/GID incorrecto; problemas de etiquetado SELinux; mismatch con rootless Docker.
- Solución: Corrige la propiedad al usuario Postgres, ajusta opciones de montaje, considera volúmenes con nombre para evitar deriva de permisos en el FS del host y estandariza UID/GID.
8) “El rendimiento es impredecible: genial y luego horrible”
- Síntomas: Picos de latencia, checkpoints lentos, autovacuum atascado, esperas aleatorias de I/O.
- Causa raíz: Almacenamiento overlay, filesystems en red, vecinos ruidosos, checkpoints mal dimensionados o WAL en almacenamiento lento.
- Solución: Coloca PGDATA en almacenamiento local estable, afina parámetros de checkpoint, monitoriza tiempos de fsync/checkpoint y aísla la base de datos de cargas de disco competidoras.
Listas de verificación / plan paso a paso
Checklist A: El plan “voy a ejecutar Postgres en Docker en serio”
- Elige almacenamiento a propósito. Usa un volumen con nombre o un bind mount a almacenamiento dedicado del host. No confíes en la capa escribible del contenedor.
- Fija tags de imagen. Usa
postgres:16.1(ejemplo) en lugar depostgres:latest. “Latest” no es una estrategia. - Bloquea nombres de volúmenes. Nómbralo como un activo de producción. Añade etiquetas que indiquen entorno y servicio.
- Establece PGDATA explícito. Manténlo consistente entre entornos y scripts.
- Configura backups desde el día uno. Decide: dumps lógicos, backups físicos + archivado WAL, o ambos.
- Prueba restauraciones. Ejecuta una prueba de restore regularmente, no cuando ya estás en problemas.
- Alerta sobre disco y crecimiento de WAL. Quieres saberlo al 70–80%, no al 99%.
- Mantén defaults de durabilidad salvo que puedas defender los cambios. Si cambias ajustes relacionados con fsync, documenta explícitamente el presupuesto de pérdida de datos.
- Planifica actualizaciones como migraciones. Las actualizaciones mayores requieren un procedimiento, no un cambio de tag.
Checklist B: Pasos en incidente “sospechamos pérdida de datos”
- Detén las escrituras. Si la app escribe en un clúster equivocado o fresco, cada minuto aumenta el daño.
- Captura evidencia. Logs del contenedor, salida de
docker inspect,system_identifierde Postgres y detalles de montajes. - Identifica el directorio de datos correcto. Encuentra el volumen/bind mount que contiene el clúster esperado (busca
PG_VERSION, archivos de relaciones y identificador coincidente). - Confirma estado de backups. ¿Cuál es el backup restaurable más reciente? ¿Están intactos los archivos WAL archivados si necesitas PITR?
- Recupera el servicio de forma segura. Prefiere volver a conectar el volumen correcto. Si debes restaurar, hazlo en un volumen nuevo y valida antes de cambiar.
- Evita la recurrencia. Añade guards de arranque, elimina volúmenes anónimos y protege volúmenes de producción de workflows de prune.
Checklist C: Ruta de actualización que no arruine tu fin de semana
- Inventaría extensiones. Asegura que las extensiones existen y son compatibles con la versión objetivo de Postgres.
- Elige un enfoque de actualización: pg_upgrade (rápido, necesita coordinación) vs dump/restore (simple, puede ser lento) vs replicación lógica (downtime cero/bajo, más componentes).
- Clona datos de producción. Usa un entorno staging con datos realistas para ensayar la actualización.
- Programa el corte. Ten un plan de rollback explícito: imagen vieja + volumen viejo sin tocar.
- Valida. Ejecuta tests de humo de la app, compara conteos de filas en tablas críticas y compara rendimiento de consultas clave.
Preguntas frecuentes
1) ¿Es “seguro” ejecutar Postgres en Docker en producción?
Sí, si tratas almacenamiento, backups y actualizaciones como primera clase. Docker no elimina responsabilidades de base de datos; añade nuevas formas de mal configurarlas.
2) ¿Volumen Docker o bind mount—qué debo usar?
Los volúmenes Docker con nombre suelen ser más seguros operativamente: menos sorpresas de permisos, más fáciles de referenciar y menos acoplamiento a rutas del host.
Los bind mounts pueden ser geniales cuando necesitas controlar el filesystem y snapshots, pero exigen disciplina en propiedad y gestión de rutas.
3) ¿Por qué mi base de datos “se reinició” cuando cambié un archivo Compose?
A menudo porque cambió el nombre del servicio, el nombre del volumen o la ruta de montaje, provocando que Docker creara un volumen nuevo, o porque montaste un nuevo directorio vacío del host.
Postgres entonces inicializó un clúster fresco.
4) ¿Puedo simplemente actualizar cambiando el tag de la imagen?
Versiones menores: usualmente sí. Versiones mayores: no. Las mayores necesitan pg_upgrade, dump/restore o migración por replicación lógica. El flip de tag es cómo descubres incompatibilidades en tiempo de ejecución.
5) ¿Cuál es la forma más rápida de confirmar que estoy en los datos correctos?
Consulta system_identifier, verifica data_directory y confirma montajes con docker inspect. Si eso no coincide, no confíes en nada más.
6) ¿Por qué WAL está enorme aunque el tráfico sea normal?
Causas comunes: un replication slot inactivo, una réplica retrasada o fallos en el archivado de WAL. La retención de WAL no es “limpieza”. Es un contrato con consumidores.
7) ¿Es “docker system prune” seguro en un host con contenedores Postgres?
Puede serlo, pero solo si tus reglas operativas son estrictas y entiendes qué va a eliminar. Si tu base de datos usa volúmenes anónimos o volúmenes “sin usar”,
prune puede borrar lo equivocado. Trata prune como una motosierra: útil, no sutil.
8) ¿Qué ajustes de durabilidad nunca debo cambiar en producción?
No desactives fsync o full_page_writes para sistemas OLTP. Ten precaución con synchronous_commit.
Si relajas la durabilidad, anota la ventana exacta de pérdida de datos aceptada y consigue aprobación.
9) ¿Cómo evito un initdb accidental al montar almacenamiento?
Usa un guard de arranque: comprueba un archivo marcador esperado, PG_VERSION esperado y (si es posible) el system_identifier esperado.
Rechaza el arranque si el directorio parece nuevo en un entorno donde no debería.
10) ¿Por qué el rendimiento empeora tras mover a un almacenamiento “mejor”?
Muchos sistemas de almacenamiento optimizan throughput, no consistencia de latencia ni semánticas de fsync. Las bases de datos castigan la latencia inconsistente. Mide tiempos de fsync y checkpoints,
y elige almacenamiento que se comporte bien bajo presión.
Cita (idea parafraseada) de Richard Cook: “En sistemas complejos, las fallas son normales; el éxito requiere adaptación continua.” — Richard Cook, investigador en operaciones y seguridad.
Broma #2: Lo único más persistente que un volumen Docker es un ingeniero insistiendo en que no necesita backups—justo hasta que los necesita.
Conclusión: próximos pasos que puedes hacer hoy
Si ejecutas Postgres en Docker, tu trabajo no es hacerlo “nativo de contenedores.” Tu trabajo es hacerlo aburrido. Almacenamiento aburrido. Backups aburridos. Actualizaciones aburridas.
El camino emocionante es el que termina con un esquema vacío y una noche larga.
- Audita montajes: verifica que PGDATA esté en un volumen con nombre o en un bind mount deliberado, y que Postgres reporte el
data_directoryesperado. - Protege volúmenes: elimina volúmenes anónimos para bases de datos y deja de usar
down -ven cualquier flujo que toque producción. - Verifica backups restaurando: ejecuta una prueba de restauración esta semana y luego prográmala regularmente.
- Revisa riesgos de WAL: inspecciona replication slots y estado del archiver, y añade alertas para crecimiento de WAL y margen de disco.
- Escribe el runbook de actualización: fija versiones y elige un método real para actualizaciones mayores antes de necesitarlo.