Empieza como “la base de datos se siente un poco lenta”. Luego tus paneles muestran cosas raras: la CPU está baja, el disco parece bien, las consultas no son evidentemente peores, pero la aplicación igual hace timeout. Reinicias el contenedor y—milagro—todo vuelve a ser rápido. Por un día.
Normalmente no es un problema de consultas. Es presión de memoria más límites del contenedor, del tipo que no grita en los logs. Docker no “regula” la memoria como regula la CPU, pero sí puedes acabar con un colapso de rendimiento silencioso: tormentas de reclamación, thrashing de swap, bloqueos del asignador, amplificación de I/O y bases de datos adaptándose educadamente haciéndose más lentas.
Qué significa realmente la “estrangulación silenciosa” en Docker
Los límites de memoria en Docker no son como los límites de CPU. Con la CPU obtienes contadores claros de throttling. Con la memoria obtienes un muro duro (memory.max / --memory) y entonces ocurre una de dos cosas:
- El kernel reclama agresivamente (caché de archivos, memoria anónima, slabs), lo que parece “no pasa nada” hasta que la latencia se dispara y el I/O se dispara en vertical.
- El OOM killer termina tu proceso, lo cual al menos es honesto.
En medio de esos extremos está el miserable punto intermedio: la base de datos sigue viva, pero pelea con el kernel por la memoria y va perdiendo lentamente. Eso es tu “estrangulación silenciosa”.
La trampa de tres capas: caché de la base, caché del kernel, límite del contenedor
En un host normal, las bases de datos confían en dos niveles de caché:
- Caché gestionada por la base (InnoDB buffer pool de MySQL, shared buffers de PostgreSQL).
- Caché de páginas del kernel (caché del sistema de archivos).
Pónlos en un contenedor con un límite estricto de memoria y añadiste una tercera restricción: el cgroup. Ahora cada byte cuenta doble: una vez para la memoria de la base de datos y otra vez para “todo lo demás” (conexiones, work memory, ordenaciones/hash, buffers de replicación, procesos en segundo plano, bibliotecas compartidas, sobrecarga de malloc). Al kernel no le importa que “solo cambiaste una configuración”; cuenta el RSS y la caché en tu cgroup y reclama según la presión.
Por qué esto parece un fallo de red o almacenamiento
La presión de memoria se manifiesta como:
- picos aleatorios de latencia p99
- incremento de tiempos de fsync/flush
- más lectura de I/O a pesar de una mezcla de consultas estable
- CPU que parece “bien” porque los hilos están bloqueados en el kernel
- conexiones acumulándose y luego cascadas de timeouts
Broma #1: Las bases de datos bajo presión de memoria son como personas con abstinencia de cafeína—aún técnicamente funcionan, pero cada petición pequeña se vuelve personal.
Hechos e historia interesantes que sí importan
Algunos puntos de contexto que cambian cómo piensas sobre “simplemente establece un límite de memoria para el contenedor”.
- PostgreSQL ha dependido del page cache del SO desde siempre. Su arquitectura deja intencionalmente mucho caching al kernel;
shared_buffersno está pensado para ser “toda la memoria”. - El buffer pool de InnoDB de MySQL se convirtió en la herramienta por defecto cuando InnoDB reemplazó a MyISAM (era de MySQL 5.5). Eso arraigó la costumbre de “hacer el buffer pool enorme”.
- Linux cgroups v1 y v2 contabilizan la memoria de formas lo suficientemente diferentes como para confundir paneles. El mismo contenedor puede parecer “bien” en v1 y “misteriosamente limitado” en v2 si no revisas los archivos correctos.
- Docker añadió mejores valores por defecto con el tiempo, pero los archivos de Compose aún codifican folklore malo. Sigues viendo
mem_limitestablecido sin ningún ajuste de BD correspondiente, lo que es básicamente una ruleta de rendimiento. - El comportamiento del OOM killer en contenedores solía sorprender más a la gente. La adopción temprana de contenedores enseñó a los equipos que “el host tiene mucha RAM” no importa si el límite del cgroup es bajo.
- PostgreSQL introdujo soporte para huge pages hace mucho, pero rara vez se usa en contenedores porque es operacionalmente molesto y a veces incompatible con entornos restringidos.
- MySQL/InnoDB tiene múltiples consumidores de memoria fuera del buffer pool (adaptive hash index, buffers por conexión, performance_schema, replicación). Dimensionar solo el buffer pool es un clásico error de principiante.
- La memoria por consulta en PostgreSQL suele ser la culpable real. Un
work_memdemasiado generoso multiplicado por la concurrencia es cómo “accidentalmente” te asignas hasta el muro del cgroup.
MySQL vs PostgreSQL: modelos de memoria que chocan con contenedores
MySQL (InnoDB): un gran pool, más muerte por mil buffers
La cultura de tuning de MySQL está dominada por el InnoDB buffer pool. En bare metal, la regla general suele ser “60–80% de la RAM”. En contenedores, ese consejo se vuelve peligroso a menos que redefinas “RAM” como “límite del contenedor menos overhead”.
Cubos de memoria a tener en cuenta:
- InnoDB buffer pool: el número destacado. Si lo pones en 75% del límite del contenedor, ya estás perdido.
- InnoDB log buffer y memoria relacionada con redo log: normalmente pequeña, pero no finjas que es cero.
- Buffers por conexión: read buffer, sort buffer, join buffer, tmp tables. Multiplícalo por max connections. Luego multiplícalo por “el pico nunca es el promedio”.
- Performance Schema: puede ser sorprendentemente no trivial si está totalmente habilitado.
- Replicación: relay logs, binlog caches, buffers de red.
Bajo presión de memoria, InnoDB puede seguir funcionando pero con más lecturas de disco y más churn en segundo plano. Puede parecer que el almacenamiento se volvió más lento. El almacenamiento no empeoró. Escaseaste la caché y forzaste lecturas aleatorias.
PostgreSQL: shared_buffers es solo la mitad de la historia
Postgres usa memoria compartida (shared_buffers) para cachear páginas de datos, pero también depende mucho del page cache del kernel. Luego añade un montón de otros consumidores:
- work_mem por nodo de sort/hash, por consulta, por conexión (y por worker paralelo). Aquí es donde los presupuestos del contenedor mueren.
- maintenance_work_mem para vacuum/creación de índices. Tu tarea nocturna lenta puede ser tu incidente diurno.
- Workers de autovacuum y background writer: no solo usan CPU; generan I/O y pueden amplificar la presión de memoria indirectamente.
- Overhead de memoria compartida, catálogos, contextos de conexión, extensiones.
En Postgres, “la base de datos está lenta” bajo límites de contenedor a menudo significa que el kernel está reclamando page cache y Postgres está realizando más lecturas reales. Mientras tanto, un puñado de consultas concurrentes puede inflar memoria vía work_mem. Es una guerra en dos frentes.
Patrones de estrangulación silenciosa: MySQL vs Postgres
Patrón MySQL: buffer pool demasiado grande → poco margen → page cache comprimido → comportamiento de checkpoint/flush empeora → picos de I/O de lectura aleatoria → la latencia se incrementa sin un error obvio.
Patrón Postgres: shared_buffers moderado pero work_mem generoso → pico de concurrencia → crecimiento de memoria anónima → presión del cgroup → reclaim y/o swap → tiempos de consulta se disparan, a veces sin una “consulta mala” única.
Opinión operativa: si estás containerizando bases de datos, deja de pensar en “porcentaje de la RAM del host”. Piensa en “presupuesto duro con concurrencia en peor caso”. Serás menos popular en las revisiones de diseño. Serás más popular a las 3 a.m.
Una cita, porque sigue siendo cierta décadas después: paraphrased idea
— Jim Gray: “La mejor manera de mejorar el rendimiento es medirlo primero.”
Guía rápida de diagnóstico
Este es el orden que encuentra cuellos de botella rápidamente cuando una BD en Docker “se puso lenta”. Puedes hacerlo en 10–15 minutos si mantienes las manos firmes y tus suposiciones débiles.
1) Confirma el presupuesto real de memoria del contenedor (no lo que crees que configuraste)
- Revisa el cgroup
memory.max/ docker inspect. - Verifica si el swap está permitido (
memory.swap.maxo docker--memory-swap). - Comprueba si el orquestador sobrescribe valores de Compose.
2) Decide: ¿estamos reclamando, haciendo swap o OOMing?
- Busca kills por OOM en
dmesg/journalctl. - Revisa eventos de memoria del cgroup (
memory.eventsen v2). - Verifica fallos de página mayores, swap in/out.
3) Correlaciona con el comportamiento de memoria de la BD
- MySQL: tamaño del buffer pool, % de páginas sucias, longitud de history list, uso de tablas temporales, max connections.
- Postgres:
shared_buffers,work_mem, sorts/hashes activos, creación de archivos temporales, actividad de autovacuum, número de conexiones.
4) Confirma si el almacenamiento es la víctima o el culpable
- Mide IOPS de lectura y latencia a nivel del host.
- Verifica si las lecturas aumentaron después de que empezó la presión de memoria.
- Revisa comportamiento intensivo en fsync (checkpointing, WAL, redo flush).
5) Haz un cambio seguro
No “tunes todo”. Nunca sabrás qué lo arregló y probablemente romperás otra cosa. Elige un objetivo: o reduces el apetito de memoria de la BD o aumentas el presupuesto del contenedor. Luego verifica con los mismos contadores.
Tareas prácticas (comandos, salidas, decisiones)
Estas son tareas reales que puedes ejecutar hoy. Cada una incluye: comando, salida de muestra, qué significa y la decisión que tomas.
Task 1: Identify the container and its configured memory limit
cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
NAMES IMAGE STATUS
db-mysql mysql:8.0 Up 3 days
db-postgres postgres:16 Up 3 days
cr0x@server:~$ docker inspect -f '{{.Name}} mem={{.HostConfig.Memory}} swap={{.HostConfig.MemorySwap}}' db-mysql
/db-mysql mem=2147483648 swap=2147483648
Significado: la memoria es 2 GiB. El swap es igual a la memoria, así que el contenedor puede swappear hasta ~2 GiB (dependiendo de la configuración). Eso no es “gratis”. Es latencia.
Decisión: Si ves swap habilitado para bases de datos sensibles a latencia, o bien desactiva el swap para el contenedor o dimensiona la memoria para no necesitarlo.
Task 2: Verify cgroup v2 memory.max from inside the container
cr0x@server:~$ docker exec -it db-postgres bash -lc 'cat /sys/fs/cgroup/memory.max; cat /sys/fs/cgroup/memory.current'
2147483648
1967855616
Significado: límite 2 GiB, uso actual ~1.83 GiB. Eso está cerca del techo.
Decisión: Si memory.current se mantiene cerca de memory.max durante carga normal, no tienes un problema de “picos”. Tienes un problema de dimensionamiento.
Task 3: Check cgroup memory pressure events (v2)
cr0x@server:~$ docker exec -it db-postgres bash -lc 'cat /sys/fs/cgroup/memory.events'
low 0
high 214
max 0
oom 0
oom_kill 0
Significado: los incrementos en high indican presión de memoria sostenida. Aún no hay OOM.
Decisión: Si high sigue subiendo durante incidentes, trátalo como una señal de primera clase. Reduce consumo de memoria o aumenta el límite.
Task 4: Find OOM kills from the host
cr0x@server:~$ sudo journalctl -k --since "2 hours ago" | grep -i oom | tail -n 5
Dec 31 09:12:44 server kernel: oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=docker-8b1...,mems_allowed=0,oom_memcg=/docker/8b1...
Dec 31 09:12:44 server kernel: Killed process 27144 (mysqld) total-vm:3124280kB, anon-rss:1782400kB, file-rss:10240kB, shmem-rss:0kB
Significado: mysqld fue matado por el OOM killer del cgroup. Esto no es “MySQL se cayó”. Es “te quedaste sin presupuesto”.
Decisión: No reinicies en bucle. Arregla primero el desajuste entre presupuesto y configuración.
Task 5: Confirm swap activity on the host
cr0x@server:~$ vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
2 1 524288 31264 10240 88432 120 210 1820 2410 920 1640 12 8 61 19 0
1 2 526336 29812 10320 87020 140 180 2100 1980 980 1710 10 7 58 25 0
Significado: swap-in (si) y swap-out (so) son distintos de cero durante la carga. Eso es un impuesto de latencia.
Decisión: Si la latencia de la BD importa, evita el swapping. Aumenta el límite de memoria o reduce el uso de memoria de la BD; también considera ajustar --memory-swap igual a --memory para prevenir uso extra de swap (depende del entorno).
Task 6: See container-level memory usage and page cache via docker stats
cr0x@server:~$ docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.CPUPerc}}'
NAME MEM USAGE / LIMIT MEM % CPU %
db-mysql 1.95GiB / 2GiB 97.5% 35.2%
db-postgres 1.82GiB / 2GiB 91.0% 18.7%
Significado: ambos corren cerca de los límites. Espera presión de reclamación y/o OOM.
Decisión: Trata cualquier cosa >90% sostenida como “mal configurada”, no como “ocupada”.
Task 7: Inspect per-process RSS inside the container (who is eating memory?)
cr0x@server:~$ docker exec -it db-mysql bash -lc 'ps -eo pid,comm,rss --sort=-rss | head'
PID COMMAND RSS
1 mysqld 1789320
112 bash 17520
98 ps 4440
Significado: mysqld RSS es ~1.7 GiB. Eso es antes de page cache y otra overhead.
Decisión: Si mysqld RSS está cerca del límite, reduce InnoDB buffer pool y buffers por conexión, o sube el límite.
Task 8: MySQL—confirm InnoDB buffer pool size and other big knobs
cr0x@server:~$ docker exec -it db-mysql bash -lc 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -e "SHOW VARIABLES WHERE Variable_name IN (\"innodb_buffer_pool_size\",\"max_connections\",\"tmp_table_size\",\"max_heap_table_size\",\"performance_schema\");"'
+-------------------------+-----------+
| Variable_name | Value |
+-------------------------+-----------+
| innodb_buffer_pool_size | 1610612736|
| max_connections | 500 |
| tmp_table_size | 67108864 |
| max_heap_table_size | 67108864 |
| performance_schema | ON |
+-------------------------+-----------+
Significado: el buffer pool es 1.5 GiB dentro de un contenedor de 2 GiB. Max connections es 500, así que la memoria por conexión puede comerse el margen.
Decisión: Reduce el buffer pool a un número defendible (a menudo 40–60% del límite para contenedores pequeños) y reduce max_connections o añade pooling. A los contenedores no les gustan los límites de conexiones “por si acaso”.
Task 9: MySQL—check if you’re doing lots of temp table work (often memory→disk amplification)
cr0x@server:~$ docker exec -it db-mysql bash -lc 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -e "SHOW GLOBAL STATUS LIKE \"Created_tmp%tables\";"'
+-------------------------+--------+
| Variable_name | Value |
+-------------------------+--------+
| Created_tmp_disk_tables | 184220 |
| Created_tmp_tables | 912340 |
| Created_tmp_files | 2241 |
+-------------------------+--------+
Significado: una fracción significativa de tablas temporales está volcándose a disco. Bajo presión de memoria, esto empeora y parece una “regresión de almacenamiento”.
Decisión: Reduce el spill de consultas (índices, planes), ajusta con cuidado settings de temp y asegúrate de suficiente margen de memoria para que las operaciones temporales no se fuerce a disco con más frecuencia.
Task 10: PostgreSQL—confirm shared_buffers, work_mem, and max_connections
cr0x@server:~$ docker exec -it db-postgres bash -lc 'psql -U postgres -d postgres -c "SHOW shared_buffers; SHOW work_mem; SHOW maintenance_work_mem; SHOW max_connections;"'
shared_buffers
----------------
512MB
(1 row)
work_mem
----------
64MB
(1 row)
maintenance_work_mem
----------------------
1GB
(1 row)
max_connections
-----------------
300
(1 row)
Significado: work_mem 64MB multiplicado por la concurrencia es una trampa. maintenance_work_mem 1GB en un contenedor de 2GB es una bomba de tiempo cuando corren vacuum/index jobs.
Decisión: Baja work_mem y maintenance_work_mem, y confía en pooling de conexiones. Si necesitas work_mem grande, impón límites de concurrencia o usa colas/colas de recursos en la capa de aplicación.
Task 11: PostgreSQL—see temp files (a strong indicator of memory shortfalls or bad plans)
cr0x@server:~$ docker exec -it db-postgres bash -lc 'psql -U postgres -d postgres -c "SELECT datname, temp_files, temp_bytes FROM pg_stat_database ORDER BY temp_bytes DESC LIMIT 5;"'
datname | temp_files | temp_bytes
-----------+------------+-------------
appdb | 12402 | 9876543210
postgres | 2 | 819200
(2 rows)
Significado: temp_bytes grande sugiere spills por sorts/hashes. En contenedores, los spills más la reclamación equivalen a usuarios tristes.
Decisión: Encuentra las consultas que más spillan, ajusta índices y define work_mem basado en la concurrencia, no en la buena voluntad.
Task 12: PostgreSQL—spot autovacuum pressure (it can look like random I/O “mystery”)
cr0x@server:~$ docker exec -it db-postgres bash -lc 'psql -U postgres -d postgres -c "SELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 5;"'
relname | n_dead_tup | last_autovacuum
------------+------------+-------------------------------
events | 4821932 | 2025-12-31 08:44:12.12345+00
sessions | 1120044 | 2025-12-31 08:41:02.54321+00
(2 rows)
Significado: muchos dead tuples significan presión de vacuum. Vacuum genera I/O y puede agravar el churn de caché, especialmente con límites de memoria ajustados.
Decisión: Ajusta umbrales de autovacuum por tabla y arregla la amplificación de escritura. No “soluciones” dándole memoria ilimitada a vacuum en un contenedor pequeño.
Task 13: Measure host I/O latency (are we forcing disk reads because cache is gone?)
cr0x@server:~$ iostat -x 1 3
avg-cpu: %user %nice %system %iowait %steal %idle
11.22 0.00 6.11 18.33 0.00 64.34
Device r/s w/s rkB/s wkB/s await r_await w_await svctm %util
nvme0n1 820.0 210.0 50240.0 18432.0 18.2 21.4 5.6 1.2 98.0
Significado: alta utilización y alto read await sugiere que estás haciendo lecturas reales y esperando. Si esto coincide con presión de memoria, el disco suele ser solo el mensajero.
Decisión: No compres discos más rápidos de inmediato. Primero confirma la hambruna de caché y el comportamiento de reclaim.
Task 14: Check major page faults (a reclaim/swap smell)
cr0x@server:~$ pid=$(docker inspect -f '{{.State.Pid}}' db-postgres); sudo cat /proc/$pid/stat | awk '{print "majflt="$12, "minflt="$10}'
majflt=48219 minflt=12984321
Significado: los fallos de página mayores indican que el proceso tuvo que traer páginas desde disco (o swap). Bajo presión, esto sube junto con la latencia.
Decisión: Si los major faults aumentan durante incidentes, céntrate en el margen de memoria y el comportamiento de caché antes de culpar solo a los planes de consulta.
Task 15: Verify Compose vs runtime settings (the “I set mem_limit” lie)
cr0x@server:~$ docker compose config | sed -n '/db-mysql:/,/^[^ ]/p'
db-mysql:
image: mysql:8.0
mem_limit: 2g
environment:
MYSQL_DATABASE: appdb
cr0x@server:~$ docker inspect -f 'mem={{.HostConfig.Memory}}' db-mysql
2147483648
Significado: la configuración de Compose coincide con el runtime aquí. En muchos entornos no sucederá, porque Swarm/Kubernetes/otra herramienta puede sobrescribir.
Decisión: Confía siempre en la inspección en tiempo de ejecución más que en archivos de configuración. Los archivos de configuración son aspiraciones.
Tres mini-historias corporativas desde el frente
1) Incidente causado por una suposición errónea: “El host tiene 64 GB, estamos bien”
Un equipo SaaS mediano movió un primary MySQL a Docker para estandarizar el despliegue. El host tenía mucha RAM. Pusieron un límite de contenedor de 4 GB porque “el dataset es pequeño”, y copiaron su antiguo innodb_buffer_pool_size de una VM: 3 GB. Funcionó. Durante semanas.
Entonces llegó una campaña de marketing. Las conexiones se dispararon, se usaron buffers de ordenación y de pronto la latencia pasó de “bien” a “qué le pasó a nuestro producto”. La CPU no se saturó. El disco no estaba saturado al principio. Los servidores de aplicación hacían timeouts.
Los primeros respondedores miraron los logs de consultas, porque eso es lo que uno hace cuando está cansado. No encontraron nada dramático. Reiniciaron el contenedor y mejoró. Ese reinicio también limpió fragmentación de memoria acumulada y buffers a nivel de conexión. Fue un placebo con efectos secundarios.
Finalmente alguien revisó los logs del kernel y vio presión de reclamación del cgroup y kills ocasionales por OOM de procesos auxiliares. MySQL no siempre moría; simplemente vivía al borde y forzaba al kernel a robar constantemente caché. Las lecturas aumentaron, el checkpointing se volvió más feo y cada espera de I/O se convirtió en un pico de latencia visible para el usuario.
La solución fue dolorosamente aburrida: reducir el buffer pool para dejar margen real, limitar conexiones con un pooler y aumentar el límite del contenedor para ajustarlo al perfil real de concurrencia. El dataset no era el problema. La carga sí.
2) Optimización que salió mal: “Aumentemos work_mem, será más rápido”
Un servicio orientado a datos ejecutaba PostgreSQL en contenedores. Un ingeniero bienintencionado vio spills en disco en EXPLAIN (ANALYZE), y aumentó work_mem de 4MB a 128MB. Los benchmarks mejoraron. Todos celebraron y volvieron a Slack.
Dos semanas después, un incidente. No durante un deploy—peor. En un martes normal. Se lanzó un trabajo por lotes, corrieron varias consultas en paralelo y cada consulta usó múltiples nodos de sort/hash. Multiplícalo por el número de conexiones. Multiplícalo por workers paralelos. De pronto el contenedor alcanzó presión de memoria y empezó a reclamar agresivamente.
La base de datos no se cayó. Simplemente se puso lenta. Muy lenta. El job se ralentizó, retuvo locks más tiempo y bloqueó transacciones orientadas a usuarios. Eso provocó más reintentos desde la capa de aplicación, lo que aumentó la concurrencia. Bucle de realimentación positiva clásico, excepto que nadie estaba contento.
Revirtieron work_mem, pero el rendimiento siguió raro porque autovacuum había quedado atrasado durante el caos. Cuando vacuum recuperó, las cosas se normalizaron. La solución real fue dimensionar work_mem según la concurrencia en peor caso, no según benchmarks de consulta única, y aislar cargas por lotes con sus propios presupuestos de recursos.
Broma #2: Aumentar work_mem en un contenedor pequeño es como llevar una maleta más grande a la puerta de embarque—te sentirás preparado hasta que alguien la mida.
3) La práctica aburrida pero correcta que salvó el día: presupuestos, margen y alarmas
Un equipo distinto ejecutaba MySQL y PostgreSQL en Docker en distintos entornos. Tenían una regla: cada contenedor de BD tiene una “hoja de presupuesto de memoria”, un documento corto en el repo que lista el límite, conexiones pico esperadas y las asignaciones de memoria en peor caso calculadas.
También tenían alertas sobre eventos de presión de memoria del cgroup (v2) y sobre actividad de swap, no solo “porcentaje de memoria del contenedor”. La alerta no era “estás al 92%”. Era “memory.events high subiendo rápido”. Esa es la diferencia entre una advertencia y una alarma de incendio.
Un día, un lanzamiento de funcionalidad aumentó consultas concurrentes de reports. Sus paneles señalaron memory.events high subiendo mientras la latencia de usuario aún era aceptable. Moderaron la concurrencia de reports en la capa de aplicación y programaron un aumento de límite de contenedor para la próxima ventana de mantenimiento.
Sin outage. Sin drama. Al equipo lo acusaron de “sobreingeniería” exactamente una vez, que es como sabes que lo estás haciendo bien.
Errores comunes: síntoma → causa raíz → arreglo
1) Síntoma: picos de latencia p99, CPU parece bien
Causa raíz: presión de reclaim del kernel dentro del cgroup; hilos bloqueados en I/O o rutas del asignador.
Arreglo: revisa /sys/fs/cgroup/memory.events y memory.current. Reduce knobs de memoria de la BD o aumenta el límite. Confirma fallos de página mayores y I/O await.
2) Síntoma: picos aleatorios de lectura de disco después de “apretar” límites de memoria
Causa raíz: page cache exprimido; cache de la BD demasiado pequeña o demasiado grande respecto al contenedor; reclaim expulsa caché útil.
Arreglo: deja margen para page cache y uso no-buffer. Para MySQL, no pongas buffer pool cerca del límite. Para Postgres, no asumas que shared_buffers reemplaza al page cache.
3) Síntoma: la base de datos se reinicia sin error claro de BD
Causa raíz: OOM kill del cgroup. La BD no “se estrelló”, fue ejecutada por el kernel.
Arreglo: revisa journalctl -k. Arregla dimensionamiento de memoria, reduce concurrencia y evita dependencia de swap.
4) Síntoma: “va lento hasta que reiniciamos el contenedor”
Causa raíz: presión de memoria acumulada, bloat de conexiones, churn de caché, backlog de autovacuum o fragmentación; el reinicio resetea los síntomas.
Arreglo: mide presión de memoria e internos de la BD. Añade pooling, dimensiona buffers correctamente y programa vacuum/maintenance de manera adecuada.
5) Síntoma: archivos temporales de Postgres explotan y discos se llenan
Causa raíz: insufficient work_mem para la forma de la consulta o planes malos; bajo presión de memoria, los spills son más frecuentes.
Arreglo: identifica las consultas con más temp-bytes, ajusta índices y fija work_mem según concurrencia. Considera timeouts para analytics desbocados en OLTP.
6) Síntoma: MySQL usa “mucho más memoria que innodb_buffer_pool_size”
Causa raíz: buffers por conexión, performance_schema y overhead del asignador; también page cache del SO y metadatos de sistema de archivos contados en el cgroup.
Arreglo: limita conexiones, ajusta buffers por hilo, considera deshabilitar o recortar performance_schema si procede y deja margen.
7) Síntoma: el uso de memoria del contenedor parece limitado pero el swap del host crece
Causa raíz: el swap está permitido y el kernel está empujando páginas frías fuera; el contenedor “sigue vivo” pero se vuelve más lento.
Arreglo: desactiva swap para el contenedor o fija el límite de swap igual al de memoria; asegúrate de que la carga quepa en RAM.
8) Síntoma: “Pusimos mem_limit en Compose, pero no aplica en prod”
Causa raíz: el orquestador lo sobrescribe; campos de Compose varían por modo; la configuración de runtime diverge.
Arreglo: inspecciona ajustes en tiempo de ejecución y codifícalos en el mecanismo real de despliegue (Swarm/Kubernetes). Confía en docker inspect, no en el folklore del YAML.
Listas de verificación / plan paso a paso
Paso a paso: detener la estrangulación silenciosa para MySQL en Docker
- Confirma el límite real:
docker inspecty/sys/fs/cgroup/memory.max. - Reserva margen: apunta al menos 25–40% del límite para uso no-buffer + page cache en contenedores pequeños (sub-8GB). Sí, suena conservador. Lo es.
- Configura
innodb_buffer_pool_sizesegún un presupuesto, no una corazonada: para un contenedor de 2GB, 768MB–1.2GB suele ser sensato según la carga y el pooling de conexiones. - Limita conexiones: reduce
max_connectionsy añade pooling en la capa de aplicación si es posible. - Audita buffers por hilo: mantén modestos los sort/join/read buffers salvo que entiendas la concurrencia en peor caso.
- Vigila tablas temporales en disco: tablas temporales en aumento significan que estás pagando I/O por decisiones de memoria.
- Alerta por presión del cgroup: monitoriza
memory.eventshigh/max/oom_kill. - Re-test bajo concurrencia pico: benchmarks con 10 conexiones son bonitos; en producción hay 300 porque alguien olvidó cerrar sockets.
Paso a paso: detener la estrangulación silenciosa para PostgreSQL en Docker
- Confirma el límite: otra vez, no discutas con los cgroups.
- Fija
shared_bufferscon moderación: en contenedores, valores enormes pueden expulsar todo lo demás. Muchas cargas OLTP funcionan bien con 256MB–2GB según el presupuesto. - Haz que
work_memdependa de la concurrencia: comienza pequeño (4–16MB) y aumenta quirúrgicamente para roles/consultas específicas si es necesario. - No dejes que el mantenimiento se coma la máquina: dimensiona
maintenance_work_mempara que autovacuum y builds de índices no dejen sin recursos al resto. - Usa pooling: las conexiones de Postgres son costosas, y en contenedores la overhead se vuelve más dolorosa.
- Controla temp_bytes y retraso de autovacuum: spills y deuda de vacuum son señales tempranas.
- Alerta por eventos de presión de memoria: trata
memory.events highcomo una amenaza al SLO de rendimiento. - Separa OLTP de analytics: si no puedes, impón timeouts y límites de concurrencia.
Checklist de higiene a nivel contenedor (ambas bases)
- Define límites de memoria intencionalmente: evita “límites pequeñísimos por seguridad” sin tuning. Eso no es seguridad; son outages pospuestos.
- Decide una política de swap: para bases de datos, “swap como buffer de emergencia” suele volverse “swap como estilo de vida permanente”.
- Observa desde el host y desde dentro: swap del host, I/O del host y eventos del cgroup todos importan.
- Mantén los reinicios honestos: si un reinicio “lo arregla”, tienes una fuga, un backlog, un ciclo de presión de memoria o un desajuste de caché. Trátalo como una pista, no como cura.
Preguntas frecuentes (FAQ)
1) ¿Docker “está estrangulando” la memoria de mi base de datos?
No como con la CPU. Los límites de memoria crean presión y fallos duros (reclaim, swap, OOM). La “estrangulación” es que tu base de datos funciona más lento porque no puede mantener páginas calientes en memoria.
2) ¿Por qué mejora el rendimiento al reiniciar el contenedor de BD?
Los reinicios reinician cachés, liberan memoria por conexión acumulada, limpian fragmentación y a veces permiten al kernel reconstruir page cache. Es como apagar la radio para arreglar una rueda pinchada: el ruido cambia, el problema sigue.
3) Para MySQL, ¿puedo poner innodb_buffer_pool_size al 80% de la memoria del contenedor?
Usualmente no. En contenedores pequeños necesitas margen para buffers por conexión, hilos en segundo plano, performance_schema y algo de page cache. Comienza más bajo y demuestra que puedes permitir más.
4) Para Postgres, ¿debería shared_buffers ser enorme en contenedores?
No por defecto. Postgres se beneficia del page cache del SO, y los contenedores aún usan la caché del kernel dentro del mismo presupuesto de memoria. Sobredimensionar shared_buffers puede dejar sin memoria a work_mem, autovacuum y al page cache.
5) ¿Cuál es la señal más rápida de presión de memoria en cgroup v2?
/sys/fs/cgroup/memory.events. Si high sube durante problemas de latencia, estás reclamando. Si oom_kill incrementa, estás perdiendo procesos.
6) ¿Debería desactivar swap para contenedores de base de datos?
Si te importa la latencia consistente, sí—la mayor parte del tiempo. El swap puede prevenir caídas pero a menudo se convierte en outages a cámara lenta. Si mantienes swap, monitoriza el I/O de swap y fija límites realistas.
7) ¿Por qué el disco parece lento solo cuando la BD está “ocupada”?
Porque “ocupado” puede significar “sin caché”. Con menos caché, la BD hace más lecturas reales y escribe más datos temporales. El disco no empeoró; tú lo obligaste a trabajar más.
8) ¿Puedo arreglar esto solo aumentando el límite de memoria del contenedor?
A veces. Pero si la BD está configurada para expandirse hasta lo que obtiene (demasiadas conexiones, work_mem muy grande, buffers enormes), eventualmente alcanzará el nuevo techo también. Acompaña el aumento de límite con disciplina en la configuración.
9) ¿Cuál es más propensa a sorpresas por memoria en contenedores: MySQL o PostgreSQL?
Diferentes sorpresas. Los equipos de MySQL suelen sobredimensionar buffer pools y olvidar la overhead por conexión. Los equipos de Postgres suelen subestimar cómo work_mem se multiplica con la concurrencia. Ambas pueden “verse bien” hasta que no lo están.
10) ¿Y si uso Kubernetes en lugar de Docker puro?
Los principios son idénticos: cgroups, reclaim, OOM. Cambian las mecánicas (requests/limits, comportamiento de eviction). Tu trabajo sigue siendo el mismo: alinear los knobs de memoria de la BD con el límite aplicado y observar señales de presión.
Próximos pasos
Haz estos en orden. Están diseñados para convertir una queja vaga de “Docker es raro” en un sistema controlado.
- Mide el límite real del contenedor y si se permite swap. Anótalo donde la gente lo pueda encontrar.
- Revisa eventos de presión de memoria del cgroup durante un periodo de lentitud. Si
highsube, tienes el arma humeante. - Elige una BD y construye un presupuesto de memoria: buffer/cache + por-connection/por-query + mantenimiento + overhead. Sé pesimista.
- Reduce concurrencia antes de perseguir micro-optimizaciones: pooling de conexiones, colas, límites de tasa para analytics.
- Retunea los knobs de BD según el presupuesto, no al revés.
- Añade alertas sobre señales de presión (no solo porcentaje de uso). La presión es lo que sienten los usuarios.
- Haz pruebas de carga con concurrencia realista. Si tu prueba no dispara presión, es una prueba unitaria con disfraz de rendimiento.
Si haces todo eso, no solo detienes la estrangulación silenciosa—la previenes para que no vuelva disfrazada de “aleatorio” problema de almacenamiento o red. Y duermes mejor por la noche, que es el verdadero SLA.