Base de datos en Docker o en el host: guia de produccion
Ejecutar Postgres, MySQL o Redis en contenedores es viable en produccion. Aprende a gestionar volúmenes, limites de memoria y copias de seguridad para evitar fallos operativos.
¿Debería ejecutarse la base de datos en Docker o en el host?
Ejecute la base de datos en Docker. Para una pila de aplicaciones en un solo VPS, un PostgreSQL, MySQL, MongoDB o Redis en contenedores es una opción normal en producción, y el debate que suele haber al respecto suele estar mal enfocado. Un contenedor es un proceso de Linux con namespaces y cgroups, no una máquina virtual, por lo que no hay un hipervisor entre la base de datos y el disco. Con un bind mount o un volumen nombrado local, las operaciones de lectura y escritura llegan al sistema de archivos del host, el mismo que utilizaría una instalación mediante paquetes.
El coste real es operativo. Cuatro factores determinan si esta configuración es adecuada o un desastre lento: dónde residen los datos, quién es el propietario de ese directorio, cómo se realiza una actualización de versión mayor y si alguna vez ha restaurado una copia de seguridad. Si gestiona estos puntos correctamente, el contenedor es solo un detalle. Si los gestiona mal, el contenedor será el culpable de los problemas.
Esta decisión es la misma para cualquier base de datos en el servidor. Los ejemplos a continuación utilizan PostgreSQL, MySQL, MongoDB y Redis, y las diferencias específicas de cada producto se señalan donde son relevantes.
Lo que cambia realmente un contenedor
No cambia la ruta de almacenamiento, siempre que monte un volumen. Se utiliza el mismo kernel, la misma caché de páginas y el mismo sistema de archivos.
Existe una trampa de rendimiento real, que ocurre cuando no se monta nada. Sin un volumen, el directorio de datos se ubica en la capa de escritura del contenedor, la cual es un sistema de archivos overlay apilado sobre la imagen. Las escrituras allí son más lentas y toda la capa se elimina cuando se borra el contenedor. De ahí proviene el problema de "mi base de datos estaba vacía esta mañana".
Lo que cambia genuinamente:
- El ciclo de vida.
docker compose downdestruye el contenedor. Todo lo que no estuviera en un volumen desaparece con él. - La versión. La etiqueta (tag) de la imagen es la versión. No existe un
apt upgradedentro de un contenedor de base de datos que sobreviva al siguientedocker compose pull. - Contabilidad de memoria. Un límite de cgroup es una barrera estricta impuesta por el kernel, y la base de datos no sabe que existe.
- El usuario. El proceso se ejecuta como un ID de usuario numérico dentro del contenedor, el cual puede no ser propietario de nada en su host.
Dónde reside la información lo decide todo
Existen dos opciones adecuadas y un error común.
- Un volumen con nombre:
pgdata:/var/lib/postgresql/data. Docker crea el directorio en/var/lib/docker/volumes/<project>_pgdata/_datay el entrypoint de la imagen establece la propiedad durante la primera ejecución. Esta es la respuesta predeterminada. - Un bind mount:
/srv/appname/pg:/var/lib/postgresql/data. Usted elige la ruta, por lo que usted es responsable de los problemas de permisos. - No utilizar ningún montaje. Véase lo anterior. Los datos permanecen dentro del contenedor.
El análisis completo de las ventajas y desventajas es un tema en sí mismo, y bind mounts frente a volúmenes con nombre lo cubre. Para una base de datos, la versión resumida es: utilice un volumen con nombre a menos que tenga una razón específica para conocer la ruta del host; si utiliza un bind mount, ubíquelo en un lugar estable como /srv/appname/pg en lugar de dentro del directorio del proyecto, donde un git clean podría acceder a él.
Un límite estricto: no coloque el directorio de datos de una base de datos en NFS (sistema de archivos de red) ni en ningún montaje de red cuyo comportamiento de bloqueo y fsync no haya probado. Las bases de datos asumen que un fsync exitoso significa que los bytes están en un almacenamiento estable. Cuando esa suposición es incorrecta, se produce una corrupción que se manifiesta semanas después.
Fije el nombre del volumen antes de que el volumen desaparezca
Compose asigna un nombre al volumen <project>_<volume>, y el nombre del proyecto toma por defecto el nombre del directorio. Por tanto, la identidad del volumen depende del nombre de un directorio, algo que los usuarios cambian sin pensarlo.
Mueva /srv/app a /srv/app-old, o cambie el nombre de la clave pgdata en el archivo compose, y el siguiente docker compose up -d creará un volumen nuevo y vacío. Postgres inicializa un clúster nuevo en él. El contenedor está en buen estado, la aplicación arranca y todas las tablas han desaparecido. La buena noticia es que el volumen antiguo sigue en el disco bajo el nombre anterior.
docker volume ls
docker volume inspect app_pgdataFije los nombres para que esto no pueda ocurrir. Establezca el nombre del proyecto y el nombre del volumen de forma explícita:
name: myapp
services:
db:
image: postgres:17
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
name: myapp_pgdataSi un volumen huérfano ya contiene sus datos, cópielos con la base de datos detenida:
docker compose stop db
docker run --rm -v app_pgdata:/from -v myapp_pgdata:/to alpine sh -c 'cp -a /from/. /to/'
docker compose start dbSi los copia mientras la base de datos está en ejecución, obtendrá una copia fragmentada de los archivos que se estaban escribiendo. Deténgala primero.
Quién es el propietario del directorio de datos
Las imágenes oficiales de Postgres, MySQL y MongoDB ejecutan su servidor como un usuario sin privilegios, habitualmente el 999. Cuando el contenedor arranca como root, el entrypoint cambia el propietario del directorio de datos a ese usuario y luego abandona los privilegios. Por eso, un bind mount vacío suele funcionar al primer intento.
Esto falla en el momento en que se define user: en el archivo compose, ya que entonces el entrypoint no tiene privilegios para corregir nada. Postgres lo indica directamente:
initdb: error: could not change permissions of directory "/var/lib/postgresql/data": Operation not permittedUn directorio de datos que existe con los permisos incorrectos genera un mensaje distinto, y vale la pena reconocerlo porque la solución es chmod, no chown:
FATAL: data directory "/var/lib/postgresql/data" has invalid permissions
DETAIL: Permissions should be u=rwx (0700) or u=rwx,g=rx (0750).MongoDB en un bind mount propiedad de root falla en el archivo de bloqueo:
Unable to create/open the lock file: /data/db/mongod.lock (Permission denied). Ensure the user executing mongod is the owner of the lock file and has the appropriate permissions.La solución es aplicar chown al directorio del host usando el ID numérico, no un nombre:
sudo chown -R 999:999 /srv/appname/pg
sudo chmod 700 /srv/appname/pg
ls -ldn /srv/appname/pgls -ldn muestra números en lugar de nombres, y debería mostrar 999 999. La cuenta llamada postgres en su host y la cuenta llamada postgres dentro de la imagen no tienen relación: el kernel compara números y los nombres se buscan por separado en cada lado. cómo PUID y PGID asignan usuarios del host a un contenedor explica ese mapeo correctamente. Bajo Docker rootless o el remapeo de espacios de nombres de usuario, los números cambian de nuevo, así que lea los IDs desde el contenedor en ejecución en lugar de asumir que es 999.
Los volúmenes con nombre hacen que toda esta sección sea irrelevante en la primera ejecución, ya que Docker crea un directorio vacío y el entrypoint le asigna la propiedad.
Actualizaciones: una actualización de paquete frente a un cambio de etiqueta de imagen
En el host, apt upgrade le permite avanzar a una versión menor. Su distribución no saltará una versión mayor de base de datos sin previo aviso y, cuando decida realizar el salto, ambos conjuntos de binarios pueden instalarse simultáneamente, que es exactamente lo que pg_upgrade necesita.
En un contenedor, la etiqueta es la versión, por lo que una actualización consiste en editar una línea. Esto hace que las actualizaciones menores sean triviales y las mayores, un procedimiento.
Cambie postgres:16 por postgres:17, ejecute docker compose up -d y el contenedor se detendrá inmediatamente:
PostgreSQL Database directory appears to contain a database; Skipping initialization
FATAL: database files are incompatible with server
DETAIL: The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 17.Nada resulta dañado. Los nuevos binarios rechazan leer el diseño del catálogo antiguo en disco, el cual cambia entre versiones mayores. Restaure la etiqueta a postgres:16 y volverá a iniciarse. Esa reversión es la única ventaja real de actualización que ofrecen los contenedores.
La ruta soportada es volcado y restauración. PostgreSQL prefiere que el volcado lo realice el cliente más nuevo, así que ejecútelo desde la nueva imagen contra el servidor antiguo que sigue ejecutándose en la red de compose:
docker run --rm --network myapp_default -e PGPASSWORD="$POSTGRES_PASSWORD" \
postgres:17 pg_dumpall -h db -U postgres > /srv/backups/all.sql
ls -lh /srv/backups/all.sql
tail -n 2 /srv/backups/all.sqlEl archivo debería tener al menos decenas de kilobytes y terminar con una línea que diga PostgreSQL database cluster dump complete. Un archivo de unos pocos cientos de bytes significa que el volcado falló y está a punto de eliminar un volumen sin necesidad. Solo después de esa comprobación:
docker compose down
docker volume rm myapp_pgdata
# edit the compose file: image: postgres:17
docker compose up -d db
docker compose exec -T db psql -U postgres -f /dev/stdin < /srv/backups/all.sqlLos otros motores difieren:
- MySQL 8 actualiza su propio diccionario de datos al iniciar, por lo que un cambio de etiqueta menor suele ser solo un reinicio. Lea las notas de la versión antes de un salto entre series de versiones y realice un volcado primero en cualquier caso.
- MariaDB espera que
mariadb-upgradese ejecute después de que el servidor se inicie en la nueva versión. - MongoDB debe actualizarse una versión mayor a la vez y, tras cada paso, debe establecer la versión de compatibilidad de funciones antes de continuar. Saltar una versión significa que
mongodse negará a iniciar y registrará una líneaUPGRADE PROBLEMmencionandofeatureCompatibilityVersion. A partir de MongoDB 7.0, el comando requiere una bandera de confirmación explícita:db.adminCommand({ setFeatureCompatibilityVersion: "8.0", confirm: true }). - Redis carga archivos de instantáneas antiguos sin problemas, pero no los más nuevos, por lo que una actualización es un reinicio y una degradación puede fallar al cargar los datos.
La regla general: un contenedor facilita la degradación y no simplifica la actualización.
¿Por qué mi contenedor de base de datos termina con el código 137?
Se debe a que el gestor de memoria (OOM killer) del kernel lo ha finalizado. El código 137 es el resultado de sumar 128 más la señal 9.
docker compose ps
docker inspect myapp-db-1 | grep -i oomkilled
journalctl -k | tail -n 20docker compose ps muestra Exited (137), la línea de inspección indica "OOMKilled": true, y el registro del kernel contiene una entrada coincidente:
Memory cgroup out of memory: Killed process 4711 (postgres) total-vm:2170416kBEste es el mecanismo, y suele sorprender a los usuarios. PostgreSQL y MySQL calculan el tamaño de sus búferes basándose en la memoria total que reporta el host. Un límite de cgroup no modifica ese valor para ellos. En un host de 16 GB con un límite de 2 GB, la base de datos planifica como si tuviera 16 GB, y el cgroup la finaliza mucho antes de que el host sufra presión de memoria. Por tanto, un límite de memoria por sí solo no es suficiente. También debe indicar a la base de datos la memoria disponible:
- PostgreSQL: configure
shared_buffersy preste atención awork_mem.work_memse asigna por cada operación de ordenación por conexión, por lo que un valor elevado multiplicado por cincuenta conexiones es la causa habitual de que un contenedor falle bajo carga en lugar de al iniciar. - MySQL y MariaDB: configure
innodb_buffer_pool_size, cuyo valor predeterminado es 128M. Mantengainnodb_dedicated_serverdesactivado en un contenedor, ya que su función es dimensionarse según la memoria detectada de la máquina. - MongoDB: establezca el tamaño de la caché de WiredTiger explícitamente en lugar de dejar que lo estime a partir de la memoria del host.
- Redis:
maxmemoryes ilimitado por defecto, por lo que Redis crece hasta que el cgroup lo detiene. Establezcamaxmemorycómodamente por debajo del límite del contenedor y elija unamaxmemory-policy.
Postgres también reporta el evento desde su propia perspectiva, y este par de líneas es lo que encontrará en el registro:
LOG: server process (PID 123) was terminated by signal 9: Killed
LOG: terminating any other active sessions due to crash of another server processLa finalización de un proceso backend obliga a reiniciar todos los demás, ya que la memoria compartida puede quedar en un estado inconsistente. Esto provoca una tormenta de conexiones para su aplicación, no un evento silencioso. configuración de límites de memoria en Docker Compose cubre la sintaxis y la diferencia entre mem_limit y el formato deploy.resources.
Nada de esto desaparece en el host. Simplemente se traslada. Sin un cgroup, la base de datos compite con todo lo demás en el sistema, y el OOM killer del host elige una víctima según una puntuación, que podría ser sshd. Un límite que finaliza la base de datos de forma predecible es más fácil de gestionar que un OOM del host que le bloquee el acceso.
Copias de seguridad: volcado interno, respaldo externo
No realice copias de seguridad de una base de datos en ejecución copiando su directorio de datos. Una copia a nivel de archivo realizada mientras el servidor escribe genera una copia inconsistente, y usted se dará cuenta en el momento de la restauración.
Existen dos métodos fiables: realizar un volcado con la herramienta propia de la base de datos mientras esta se ejecuta y respaldar dicho volcado, o detener el contenedor y copiar el volumen en frío.
docker compose exec -T db pg_dump -U postgres -Fc appdb > /srv/backups/appdb.dump
docker compose exec -T db mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction --all-databases > /srv/backups/mysql.sql
docker compose exec -T db mongodump --archive --gzip --db appdb > /srv/backups/appdb.archive.gz
docker compose exec -T redis redis-cli BGSAVEEl parámetro -T es fundamental. Sin él, docker compose exec puede adjuntar una terminal al comando, y la capa de terminal añade retornos de carro al flujo de salida. Un volcado de texto se restaura entonces con errores extraños y un volcado binario resulta simplemente corrupto. El proceso falla silenciosamente durante la copia y de forma crítica un mes después.
--single-transaction proporciona a mysqldump una instantánea consistente de las tablas InnoDB sin bloquear todo el servidor.
Estos comandos generan un archivo cada uno. No constituyen un sistema de copias de seguridad: no hay retención, ni copia fuera del servidor, ni verificación. Entregue el directorio de volcados a una herramienta que gestione estos tres aspectos, que es para lo que sirve restic backups from a VPS. Realice copias de /srv/backups, no de /var/lib/docker/volumes.
Luego, ejecute la restauración, porque una copia de seguridad que nunca ha restaurado no es una copia de seguridad:
docker compose exec -T db createdb -U postgres restore_test
docker compose exec -T db pg_restore -U postgres -d restore_test < /srv/backups/appdb.dump
docker compose exec -T db psql -U postgres -d restore_test -c '\dt'\dt debería listar las tablas de su aplicación. Un resultado vacío, o Did not find any relations., significa que el volcado no es lo que usted cree. Elimine restore_test cuando haya terminado.
El comando que elimina todo
docker compose down -v.
El comando down simple elimina los contenedores y la red. El comando -v también elimina todos los volúmenes con nombre declarados en ese archivo compose, además de todos los volúmenes anónimos asociados a esos contenedores. No hay confirmación ni posibilidad de deshacer la acción. Es la forma más común en la que se destruye una base de datos autohospedada, y suele ocurrir durante la resolución de problemas no relacionados, porque una respuesta en un foro sugirió ejecutarlo.
Cuatro medidas reducen el radio de impacto:
- Declare el volumen de la base de datos como
external: true. Compose no eliminará un volumen que no le pertenece, por lo que-vno podrá alcanzarlo. Usted lo crea una vez condocker volume create myapp_pgdata. - Use
docker compose stopydocker compose startpara reinicios rutinarios. down frente a stop en Compose detalla qué elimina cada uno. - Mantenga volcados de datos en una ruta del host fuera de cualquier volumen gestionado por compose.
- Nunca pegue
-vde una respuesta de resolución de problemas en una pila que contenga datos que le interesen.
No publique el puerto de la base de datos
Esta línea expone su base de datos a la internet pública:
ports:
- "5432:5432"Se vincula a todas las interfaces. Docker publica un puerto reescribiendo el destino del paquete antes de que las reglas de entrada de su firewall lo procesen; como las reglas de ufw residen en la cadena de entrada, ufw deny 5432 no tiene efecto alguno. por qué los puertos publicados por Docker omiten ufw muestra el recorrido de la cadena.
Una aplicación en el mismo proyecto de compose accede a la base de datos mediante el nombre del servicio en la red de compose, por lo que no requiere un puerto publicado. Elimine el bloque. Si necesita un cliente en el host, vincúlelo únicamente a la interfaz de loopback:
ports:
- "127.0.0.1:5432:5432"Compruebe qué está escuchando realmente:
sudo ss -ltnp | grep 5432127.0.0.1:5432 es lo que usted busca. 0.0.0.0:5432 significa que cualquier persona puede intentar adivinar su contraseña.
Qué ejecutar y dónde
Una aplicación en un VPS. Contenedor. Un volumen con nombre fijo, sin puertos publicados, un límite de memoria con los ajustes de base de datos correspondientes y un volcado nocturno a una ruta del host que restic recopila. Comience desde una instalación limpia de Docker en un VPS y mantenga la pila en un archivo compose que pueda confirmar en git. La ventaja es real: la versión de la base de datos se convierte en una línea revisable en git.
Un host que ejecuta varios servicios. Contenedores, una base de datos por aplicación, no un servidor compartido para todas ellas. Un servidor compartido vincula cada aplicación a un mismo calendario de actualizaciones, y una consulta fuera de control se convierte en una interrupción para todos. Asigne a cada contenedor su propio límite de memoria para que una consulta defectuosa quede contenida en la aplicación que la generó. Varias instancias pequeñas de Postgres consumen un poco más de disco, pero requieren mucha menos coordinación.
La base de datos es el producto. Ejecútela en el host desde el repositorio de paquetes del proveedor o pague por un servicio gestionado. pg_upgrade requiere tener instaladas ambas versiones principales de los binarios al mismo tiempo, algo que los paquetes ofrecen y una imagen de versión única no. La replicación y la recuperación en un punto en el tiempo con archivado de WAL (write ahead log) son más sencillas cuando la base de datos es dueña de la máquina y de sus discos. Elija el camino más predecible para el sistema que le enviará una alerta a las 03:00.
La aplicación es pequeña. Considere no ejecutar ninguna base de datos de servidor. Una aplicación web de escritura única en un VPS suele funcionar mejor con SQLite en producción en un VPS, donde la copia de seguridad es un solo archivo y la ruta de actualización es una versión de librería.
FAQ
¿Es seguro ejecutar una base de datos en producción dentro de Docker?
Sí, para una pila de aplicaciones en un solo servidor. Un contenedor es un proceso de Linux con espacios de nombres (namespaces) y cgroups; con un volumen montado, la base de datos escribe en el mismo sistema de archivos del host que usaría si se instalara desde un paquete. Los riesgos son operativos, no de rendimiento: un volumen cuyo nombre no está fijado, un montaje de tipo bind con un ID de usuario incorrecto, una restauración que nunca se ha probado y docker compose down -v. Si se controlan estos cuatro puntos, el contenedor funciona correctamente. Migre a una instalación en el host cuando la base de datos sea la carga de trabajo principal y necesite pg_upgrade, replicación o recuperación en un punto en el tiempo (PITR).
¿Debo usar un montaje de tipo bind o un volumen con nombre para los datos de la base de datos?
Use un volumen con nombre a menos que tenga una razón específica para conocer la ruta en el host. Docker crea el directorio y el punto de entrada (entrypoint) de la imagen establece la propiedad al iniciar por primera vez, por lo que el problema de permisos no aparece. Fije el volumen con un name: explícito o márquelo como external: true; de lo contrario, renombrar el directorio del proyecto creará silenciosamente un volumen nuevo y vacío, dejando la base de datos vacía. Un montaje de tipo bind es aceptable si cambia el propietario del directorio en el host al ID de usuario numérico con el que se ejecuta la imagen, que es 999 para las imágenes oficiales de Postgres, MySQL y MongoDB. Verifíquelo con ls -ldn, ya que ls -l muestra el nombre de su host para ese número y dicho nombre carece de sentido dentro del contenedor.
¿Qué borra docker compose down -v?
Elimina los contenedores y la red al igual que un down estándar, y el comando -v elimina adicionalmente todos los volúmenes con nombre declarados en ese archivo compose, junto con todos los volúmenes anónimos asociados a esos contenedores. Esto incluye la base de datos. No hay solicitud de confirmación ni posibilidad de recuperación. Los volúmenes marcados como external: true no se eliminan, razón principal para marcar un volumen de base de datos como externo. Para un reinicio rutinario, utilice docker compose stop y docker compose start en su lugar.
¿Cómo actualizo PostgreSQL a una nueva versión mayor en Docker?
Mediante volcado (dump) y restauración. Cambiar postgres:16 a postgres:17 y reiniciar provoca FATAL: database files are incompatible with server con una línea DETAIL que menciona ambas versiones, ya que los nuevos binarios no pueden leer el diseño del catálogo antiguo. No se daña nada: si vuelve a la etiqueta (tag) anterior, el servicio iniciará. Realice un pg_dumpall utilizando el cliente de la nueva versión contra el contenedor antiguo en ejecución, confirme que el archivo termina con PostgreSQL database cluster dump complete, luego inicie la nueva etiqueta con un volumen vacío y cargue el volcado. Las actualizaciones menores dentro de una misma versión mayor solo requieren un pull y un reinicio.
¿Por qué mi contenedor de base de datos se cierra con el código 137?
137 es 128 más la señal 9, lo que significa que algo terminó el proceso de forma abrupta. Ejecute docker inspect <container> | grep -i oomkilled; un valor de true indica que el contenedor alcanzó su límite de memoria en el cgroup. La causa habitual es que PostgreSQL y MySQL leen la memoria total del host y no detectan el límite del contenedor, por lo que planifican para 16 GB mientras residen en 2 GB. Ajuste shared_buffers y work_mem, o innodb_buffer_pool_size, para ajustarse al límite asignado al contenedor. Revise journalctl -k buscando la línea Memory cgroup out of memory correspondiente para confirmar qué proceso seleccionó el kernel.