Cuando Linux dice que tu CPU está “inactiva” pero tu máquina se siente como si estuviera hundiéndose, normalmente te enfrentas al mismo villano: la espera de E/S. Tus núcleos no están ocupados computando; están haciendo fila, esperando a que el almacenamiento responda. Mientras tanto, un contenedor Docker mastica escrituras con entusiasmo como si le pagaran por cada fsync, y todo el host se convierte en una tragedia en cámara lenta.
Esta es la parte incómoda de la densidad de contenedores: un vecino ruidoso puede retrasar a todos. La buena noticia es que Linux te da las herramientas para identificar quién es el responsable y luego limitarlo de forma quirúrgica—sin reiniciar, sin adivinar y sin “vamos a añadir más discos” como primera respuesta.
Un modelo mental: qué significa realmente la espera de E/S en un host con contenedores
En Linux, la espera de E/S es el tiempo que la CPU pasa inactiva mientras existe al menos una petición de E/S pendiente en el sistema. No es una medida de la utilización del disco por sí sola. Es una medida de “la CPU quería ejecutar algo, pero las tareas están bloqueadas por E/S, así que el planificador las pone en espera.”
En un host Docker, ese “bloqueo por E/S” a menudo significa:
- Escrituras en el sistema de archivos overlay que se amplifican en múltiples escrituras reales.
- Spam de logs que fuerza escrituras síncronas en un sistema de archivos caliente.
- Una base de datos haciendo fsync intensivo en un bucle cerrado.
- Un trabajo de backup transmitiendo lecturas que dejan sin recursos a las escrituras (o viceversa) según el planificador y el dispositivo.
- Tormentas de metadatos: millones de archivos pequeños, búsquedas de directorios, actualizaciones de inodos, presión del journal.
Los contenedores no tienen almacenamiento mágico. Comparten los mismos dispositivos de bloques del host, la misma cola y a menudo el mismo journal del sistema de archivos. Si una carga de trabajo ataca la cola con I/O profundo y sin controles de equidad, todas las demás cargas empiezan a hacer cola también. El kernel intentará ser justo, pero justo no es lo mismo que “tu latencia p99 se mantiene razonable”.
Las dos variantes de “espera de E/S infernal”
Infierno de latencia: las IOPS no son muy altas, pero la latencia se dispara. Piensa en: fallos del firmware NVMe, vaciados de caché del controlador RAID, un journal del sistema de archivos atragantado o escrituras con muchas sincronizaciones. Los usuarios sienten esto como “todo está atascado” aunque las gráficas de throughput parezcan modestos.
Infierno de profundidad de cola: un contenedor mantiene el dispositivo saturado con muchas peticiones en vuelo. El dispositivo está “ocupado” y el rendimiento medio parece impresionante. Mientras tanto, las tareas interactivas y otros contenedores esperan detrás de una montaña de peticiones.
Una cita para llevar en el bolsillo
Idea parafraseada: “La esperanza no es una estrategia; necesitas un plan y bucles de retroalimentación.”
— Gene Kranz (mentalidad de operaciones de misión, parafraseado)
Esta es la actitud que quieres aquí. No minimices el problema. Mide, atribuye y luego aplica un control.
Guía de diagnóstico rápido (verifica esto primero)
Si tu host se está derritiendo, no tienes tiempo para danzas interpretativas. Haz esto en orden.
1) Confirma que es latencia/cola de almacenamiento, no CPU o memoria
- Verifica la carga del sistema y la espera de E/S.
- Verifica la latencia del disco y la profundidad de cola.
- Verifica la presión de memoria (las tormentas de swap pueden parecer tormentas de I/O porque lo son).
2) Identifica los procesos con más I/O en el host
- Usa
iotop(tasa de lectura/escritura por proceso). - Usa
pidstat -d(estadísticas de I/O por proceso a lo largo del tiempo). - Usa
lsofpara ver qué archivos están siendo golpeados (logs? archivos de base de datos? diff de overlay?).
3) Mapea esos procesos a contenedores
- Encuentra el ID del contenedor vía cgroups (
/proc/<pid>/cgroup). - O mapea rutas de archivos a los directorios diff en
/var/lib/docker/overlay2.
4) Aplica la limitación menos mala
- Si estás en cgroup v2:
io.maxyio.weight. - Si estás en cgroup v1: limitación blkio y pesos (funciona mejor en dispositivos de bloque directos, menos mágica en sistemas de archivos en capas).
- También reduce el daño: limita logs, mueve rutas calientes a discos separados y arregla el comportamiento de la aplicación (frecuencia de flush, batching, etc.).
Broma #1: Si tu espera de E/S es del 60%, tus CPUs no son “perezosas”—simplemente están atrapadas en la fila de caja más lenta del mundo.
Datos interesantes y contexto (por qué sigue ocurriendo este problema)
- La “espera de E/S” de Linux no es tiempo pasado por el disco. Es tiempo de CPU inactiva mientras hay I/O pendiente, que puede subir incluso en dispositivos rápidos si las tareas se bloquean de forma síncrona.
- Los cgroups para control de I/O llegaron después que los de CPU/memoria. Las primeras configuraciones de contenedores eran buenas para cuotas de CPU y límites de memoria, y malas para la equidad en almacenamiento.
- La limitación blkio históricamente funcionaba mejor con acceso directo a dispositivos de bloque. Cuando todo pasa por una capa de sistema de archivos (como overlay2), la atribución y el control se vuelven más difusos.
- El planificador Completely Fair Queuing (CFQ) solía ser el predeterminado de “equidad” para medios rotacionales. Los kernels modernos se inclinan por planificadores como BFQ o mq-deadline según la clase del dispositivo y los objetivos.
- La amplificación de escrituras de OverlayFS es real. Una “pequeña escritura” dentro de un contenedor puede desencadenar operaciones de copia hacia arriba y escrituras de metadatos en el sistema de archivos del host.
- El journaling de Ext4 puede convertirse en un cuello de botella bajo cargas con muchos metadatos. Muchas creaciones/borrados de archivos pueden estresar el journal aun cuando el throughput de datos es bajo.
- Los logs han sido un repetido culpable desde la era de los daemons. Lo único más infinito que el optimismo humano es un archivo de log sin límites en un disco compartido.
- NVMe no está inmune a picos de latencia. El firmware, la gestión térmica, la gestión de energía y la recolección de basura a nivel de dispositivo aún pueden producir latencias dolorosas en la cola final.
- El “load average” incluye tareas en sueño ininterrumpible (estado D). Los bloqueos de almacenamiento pueden inflar la carga incluso cuando el uso de CPU parece normal.
Tareas prácticas: comandos, salidas y decisiones (12+)
Estas son herramientas de campo. Cada tarea incluye: un comando, una salida típica, qué significa y la decisión que tomas a continuación. Ejecuta como root o con privilegios suficientes cuando sea necesario.
Task 1: Confirmar síntomas de espera de E/S y carga
cr0x@server:~$ uptime
14:22:05 up 21 days, 6:11, 2 users, load average: 28.12, 24.77, 19.03
cr0x@server:~$ mpstat -P ALL 1 3
Linux 6.5.0 (server) 01/02/2026 _x86_64_ (32 CPU)
14:22:07 CPU %usr %nice %sys %iowait %irq %soft %steal %idle
14:22:08 all 3.21 0.00 1.18 62.44 0.00 0.09 0.00 33.08
14:22:09 all 2.97 0.00 1.11 64.02 0.00 0.07 0.00 31.83
14:22:10 all 3.45 0.00 1.29 61.88 0.00 0.08 0.00 33.30
Qué significa: la carga promedio es enorme, pero la CPU en modo usuario+sistema es baja y la espera de E/S ronda el 60%. Muchas tareas están bloqueadas por almacenamiento.
Decisión: trata esto como un problema de latencia/cola de almacenamiento. Pasa a métricas a nivel de disco; no pierdas tiempo afinando la CPU.
Task 2: Verificar latencia de disco y profundidad de cola con iostat
cr0x@server:~$ iostat -x 1 3
Linux 6.5.0 (server) 01/02/2026 _x86_64_ (32 CPU)
Device r/s w/s rkB/s wkB/s await r_await w_await aqu-sz %util
nvme0n1 120.0 980.0 5120.0 82400.0 86.32 14.22 95.01 22.41 99.50
Qué significa: el dispositivo está básicamente al máximo (%util ~99.5). La latencia media de escritura es terrible (~95ms) y la profundidad de cola (aqu-sz) es alta.
Decisión: necesitas atribución. Encuentra qué procesos generan el I/O.
Task 3: Comprobar si las tareas están en estado D (espera de almacenamiento)
cr0x@server:~$ ps -eo state,pid,comm,wchan:32 --sort=state | head -n 15
D 18342 postgres io_schedule
D 19011 containerd-shim io_schedule
D 19102 python3 ext4_sync_file
D 24410 java io_schedule
R 1322 ksoftirqd/3 -
R 31201 iostat -
S 1 systemd ep_poll
Qué significa: los procesos en estado D están bloqueados en rutas de I/O del kernel (io_schedule, ext4_sync_file).
Decisión: encuentra cuáles de estos pertenecen al/los contenedor(es ruidosos).
Task 4: Obtener tasas de I/O por proceso (lista rápida de culpables)
cr0x@server:~$ iotop -oPa -d 1 -n 3
Total DISK READ: 5.00 M/s | Total DISK WRITE: 90.00 M/s
PID PRIO USER DISK READ DISK WRITE SWAPIN IO> COMMAND
19102 be/4 root 0.00 B/s 45.00 M/s 0.00 % 95.00 % python3 /app/worker.py
18342 be/4 postgres 0.00 B/s 20.00 M/s 0.00 % 80.00 % postgres: checkpointer
24410 be/4 app 0.00 B/s 12.00 M/s 0.00 % 60.00 % java -jar service.jar
Qué significa: un trabajador Python domina las escrituras; el checkpointer de Postgres también está activo (posible comportamiento normal o empujado por otro proceso).
Decisión: mapea el PID 19102 a un contenedor e inspecciona qué está escribiendo.
Task 5: Mapear un PID a un contenedor Docker vía cgroups
cr0x@server:~$ cat /proc/19102/cgroup
0::/docker/4c3f1f8f2a7b6c7f2c7a1d3b8ad9b9d2f2b2a9c8e1d2a3b4c5d6e7f8a9b0c1d
Qué significa: el proceso está dentro del contenedor 4c3f1f8f2a7b....
Decisión: identifica el nombre humano, la imagen y el propósito de ese contenedor.
Task 6: Resolver ID de contenedor a nombre e inspeccionar configuraciones
cr0x@server:~$ docker ps --no-trunc --filter id=4c3f1f8f2a7b6c7f2c7a1d3b8ad9b9d2f2b2a9c8e1d2a3b4c5d6e7f8a9b0c1d
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
4c3f1f8f2a7b6c7f2c7a1d3b8ad9b9d2f2b2a9c8e1d2a3b4c5d6e7f8a9b0c1d acme/etl:2.8.1 "python3 /app/wo..." 3 hours ago Up 3 hours etl-worker-07
cr0x@server:~$ docker inspect -f '{{.HostConfig.LogConfig.Type}} {{json .HostConfig.LogConfig.Config}}' etl-worker-07
json-file {"max-file":"0","max-size":"0"}
Qué significa: el contenedor es un trabajador ETL y usa el logging por defecto json-file sin rotación (max-size 0, max-file 0).
Decisión: sospecha de escrituras inducidas por logs y/o rutas de datos calientes. Comprueba qué archivos están ocupados.
Task 7: Ver qué archivos está escribiendo el PID ruidoso
cr0x@server:~$ lsof -p 19102 | awk '{print $4, $9}' | head -n 15
1u /dev/null
2u /dev/null
3w /var/lib/docker/containers/4c3f1f8f2a7b6c7f2c7a1d3b8ad9b9d2f2b2a9c8e1d2a3b4c5d6e7f8a9b0c1d/4c3f1f8f2a7b6c7f2c7a1d3b8ad9b9d2f2b2a9c8e1d2a3b4c5d6e7f8a9b0c1d-json.log
4w /data/spool/outgoing/events.ndjson
5w /data/spool/outgoing/events.ndjson
Qué significa: está escribiendo en el log json de Docker y también en una ruta montada en el host /data/spool/outgoing/events.ndjson.
Decisión: verifica el uso del sistema de archivos, las opciones de montaje y si esa ruta comparte el mismo dispositivo que el resto.
Task 8: Determinar qué sistema de archivos/dispositivo respalda la ruta caliente
cr0x@server:~$ df -T /var/lib/docker /data/spool/outgoing
Filesystem Type 1K-blocks Used Available Use% Mounted on
/dev/nvme0n1p2 ext4 960379012 712883120 198472344 79% /
/dev/nvme0n1p2 ext4 960379012 712883120 198472344 79% /
Qué significa: tanto el almacenamiento de Docker como el directorio spool comparten el mismo sistema de archivos ext4 en el mismo dispositivo.
Decisión: cualquier tormenta de escrituras castigará a todo el host. Si no puedes mover rutas a un dispositivo distinto de inmediato, la limitación se convierte en tu freno de emergencia.
Task 9: Comprobar el driver de almacenamiento de Docker (el comportamiento de overlay2 importa)
cr0x@server:~$ docker info | sed -n '1,40p'
Client:
Version: 25.0.3
Server:
Containers: 47
Running: 39
Storage Driver: overlay2
Backing Filesystem: extfs
Logging Driver: json-file
Cgroup Driver: systemd
Cgroup Version: 2
Qué significa: overlay2 sobre ext4, logging json-file, cgroup v2 con driver systemd. Esto es una buena base para usar io.max/io.weight en cgroup v2.
Decisión: limitar vía cgroup v2 (preferido) y arreglar el logging.
Task 10: Verificar presión de memoria y swap (no ignores lo obvio)
cr0x@server:~$ free -h
total used free shared buff/cache available
Mem: 125Gi 96Gi 2.1Gi 1.2Gi 27Gi 18Gi
Swap: 16Gi 9.5Gi 6.5Gi
cr0x@server:~$ vmstat 1 3
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
2 18 9961472 2200000 210000 22400000 80 150 1200 92000 1200 3100 3 1 34 62 0
1 20 9961600 2180000 210000 22390000 60 120 1100 89000 1180 3000 3 1 32 64 0
Qué significa: el swap está en uso y hay I/O de swap (si/so). Eso puede amplificar el dolor del disco, pero el problema dominante es la enorme salida de bloques (bo) y el alto wa.
Decisión: mantén el swap en mente, pero procede con la atribución de I/O. Si el swap está haciendo thrashing, considera también limitar la memoria del culpable.
Task 11: Identificar la ruta de cgroup del contenedor (driver systemd, cgroup v2)
cr0x@server:~$ systemctl status docker | sed -n '1,12p'
● docker.service - Docker Application Container Engine
Loaded: loaded (/lib/systemd/system/docker.service; enabled; preset: enabled)
Active: active (running) since Tue 2025-12-10 08:14:10 UTC; 3 weeks 2 days ago
Docs: man:docker(1)
Main PID: 1420 (dockerd)
cr0x@server:~$ cat /proc/19102/cgroup
0::/system.slice/docker-4c3f1f8f2a7b6c7f2c7a1d3b8ad9b9d2f2b2a9c8e1d2a3b4c5d6e7f8a9b0c1d.scope
Qué significa: con systemd + cgroup v2, el contenedor vive en una unidad de scope de systemd bajo /sys/fs/cgroup/system.slice/.
Decisión: aplica límites de I/O a esa unidad de scope vía archivos de cgroup (o propiedades systemd), no a ciegas.
Task 12: Localizar el major:minor del dispositivo de bloques para el sistema de archivos
cr0x@server:~$ findmnt -no SOURCE,TARGET,FSTYPE /var/lib/docker
/dev/nvme0n1p2 / ext4
cr0x@server:~$ lsblk -o NAME,MAJ:MIN,SIZE,TYPE,MOUNTPOINT /dev/nvme0n1p2
NAME MAJ:MIN SIZE TYPE MOUNTPOINT
nvme0n1p2 259:2 915G part /
Qué significa: tu dispositivo objetivo es major:minor 259:2. Los controles de I/O de cgroup v2 requieren ese identificador.
Decisión: establece io.max y/o io.weight para ese dispositivo en el cgroup del contenedor.
Task 13: Aplicar un límite de ancho de banda con cgroup v2 (contención inmediata)
cr0x@server:~$ CG=/sys/fs/cgroup/system.slice/docker-4c3f1f8f2a7b6c7f2c7a1d3b8ad9b9d2f2b2a9c8e1d2a3b4c5d6e7f8a9b0c1d.scope
cr0x@server:~$ echo "259:2 wbps=20971520 rbps=10485760" | sudo tee $CG/io.max
259:2 wbps=20971520 rbps=10485760
cr0x@server:~$ cat $CG/io.max
259:2 rbps=10485760 wbps=20971520
Qué significa: lecturas limitadas a 10 MiB/s, escrituras limitadas a 20 MiB/s para ese contenedor contra el dispositivo raíz.
Decisión: observa latencia y capacidad de respuesta del sistema. Si el host se recupera, has confirmado al “vecino ruidoso” y comprado tiempo para una solución real.
Task 14: Verificar mejora inmediatamente
cr0x@server:~$ iostat -x 1 3
Device r/s w/s rkB/s wkB/s await aqu-sz %util
nvme0n1 110.0 260.0 4800.0 21000.0 12.40 2.10 78.00
cr0x@server:~$ mpstat 1 3 | tail -n 4
14:25:08 all 6.01 0.00 2.10 8.33 0.00 0.12 0.00 83.44
Qué significa: la latencia del dispositivo bajó (await ~12ms) y la espera de E/S cayó notablemente (~8%). El host está respirando de nuevo.
Decisión: mantén el límite como salvaguarda temporal y luego arregla la causa raíz: logging, batching, ubicación de datos o diseño de la app.
Task 15: Implementar rotación de logs (detener la hemorragia)
cr0x@server:~$ docker update --log-opt max-size=50m --log-opt max-file=3 etl-worker-07
etl-worker-07
cr0x@server:~$ docker inspect -f '{{json .HostConfig.LogConfig}}' etl-worker-07
{"Type":"json-file","Config":{"max-file":"3","max-size":"50m"}}
Qué significa: los logs rotarán y no crecerán sin límites.
Decisión: si los logs fueron un contribuyente importante, puedes reducir el límite de I/O o eliminarlo después de confirmar estabilidad.
Task 16: Encontrar qué contenedores generan más churn en la capa escribible
cr0x@server:~$ docker ps -q | while read c; do
> name=$(docker inspect -f '{{.Name}}' $c | sed 's#^/##')
> rw=$(docker inspect -f '{{.GraphDriver.Data.UpperDir}}' $c 2>/dev/null)
> if [ -n "$rw" ]; then
> sz=$(sudo du -sh "$rw" 2>/dev/null | awk '{print $1}')
> echo "$sz $name"
> fi
> done | sort -h | tail -n 8
3.1G etl-worker-07
4.8G api-gateway
6.2G report-builder
9.7G search-indexer
Qué significa: las capas escribibles (UpperDir) muestran churn pesado. No todo churn es malo, pero es una señal.
Decisión: mueve las rutas con muchas escrituras a volúmenes/mounts enlazados y reduce las escrituras en la capa escribible del contenedor.
Relacionar el dolor de I/O con el contenedor culpable
La parte más difícil de “Docker causó espera de E/S” es que el disco no sabe lo que es un contenedor. El kernel conoce PIDs, inodos, dispositivos de bloque y cgroups. Tu trabajo es conectar esos puntos.
Comienza desde el disco y sube
Si iostat muestra un único dispositivo con await horrible y alto %util, estás viendo saturación o latencia a nivel de dispositivo. Desde ahí:
- Usa
iotoppara identificar los PIDs que más escriben. - Mapea PID → cgroup → ID de contenedor.
- Usa
lsofpara entender la ruta: logs, volumen, UpperDir de overlay, archivos de base de datos.
Comienza desde el contenedor y baja
A veces ya sospechas del culpable (“es el job por lotes, ¿no?”). Valida sin autoengaños:
- Inspecciona la configuración de logs del contenedor y los montajes de volúmenes.
- Verifica el tamaño de su archivo de log json y de su capa escribible.
- Comprueba si es una app con muchas sincronizaciones (bases de datos, brokers, cualquier cosa que valore durabilidad).
Entender la marca especial de caos de overlay2
overlay2 es eficiente para muchas cargas, pero los patrones intensivos en escrituras pueden volverse costosos. Las escrituras a archivos que existen en la capa baja (imagen) desencadenan un copy-up a la capa superior. Las operaciones de metadatos también pueden explotar, especialmente para cargas que tocan muchos archivos pequeños.
Si ves escrituras intensas bajo /var/lib/docker/overlay2, no intentes “optimizar overlay2.” Lo correcto suele ser dejar de escribir ahí. Coloca datos mutables en volúmenes o bind mounts donde puedas controlar sistemas de archivos, opciones de montaje y aislamiento de I/O.
Broma #2: Los sistemas de archivos overlay son como la política de oficina—todo parece simple hasta que intentas cambiar una pequeña cosa y de repente están involucrados tres departamentos.
Opciones de limitación que realmente funcionan (y qué evitar)
Tienes tres clases de controles: a nivel de contenedor (cgroups), a nivel de host (planificador de I/O y elecciones de sistema de archivos) y a nivel de aplicación (cómo escribe la carga). Si solo haces una cosa, haz cgroups primero para detener el radio de impacto.
Opción A: cgroup v2 io.max (topes duros)
En distros modernas y configuraciones Docker que usan cgroup v2, io.max es tu palanca más directa: fija máximo de ancho de banda de lectura/escritura (y límites de IOPS en algunas configuraciones) por dispositivo de bloque.
Cuándo usar: el host está poco receptivo y necesitas contención inmediata; un job por lotes puede ralentizarse sin romper la corrección.
Compensaciones: es tosca. Si impones un tope demasiado estricto, puedes provocar timeouts en el contenedor limitado. Eso aun así puede ser mejor que tumbar todo lo demás.
Opción B: cgroup v2 io.weight (equidad relativa)
io.weight es más amable que un tope duro: le dice al kernel “este cgroup importa menos/más.” Si tienes múltiples cargas y quieres que se compartan de forma justa, los pesos pueden ser mejores que límites estrictos.
Cuándo usar: quieres proteger servicios sensibles a la latencia dejando que jobs por lotes usen la capacidad sobrante.
Compensaciones: si el dispositivo está saturado por un job y los demás son ligeros, el peso puede no salvarte lo suficiente; puede que aún necesites un tope.
Opción C: flags legacy blkio de Docker (era cgroup v1)
Los --blkio-weight, --device-read-bps, --device-write-bps y similares de Docker fueron diseñados para cgroup v1. Aún pueden ayudar según tu kernel, dispositivo y driver, pero son menos previsibles cuando hay sistemas de archivos en capas y subsistemas de bloque multi-cola modernos.
Guía opinada: si estás en cgroup v2, prefiere io.max/io.weight en el sistema de archivos de cgroup. Trata los flags blkio de Docker como “funcionan en laboratorio” a menos que lo hayas validado en tu pila de almacenamiento y kernel.
Opción D: propiedades systemd para scopes Docker (automatización más ordenada)
Si los contenedores aparecen como unidades scope de systemd, puedes establecer propiedades vía systemctl set-property. Esto evita escribir a mano en archivos de cgroup y sobrevive mejor a algunos flujos operativos.
Opción E: arreglar la causa raíz (la única victoria permanente)
La limitación sirve para contención. Las soluciones de causa raíz lucen así:
- Mover rutas con muchas escrituras a volúmenes dedicados en dispositivos separados.
- Limitar y encaminar logs (rotar, comprimir o enviar fuera del host).
- Reducir frecuencia de sync: agrupar escrituras, usar ajustes de group commit (con cuidado) o cambiar la postura de durabilidad explícitamente.
- Elegir sistemas de archivos más adecuados para la carga (o al menos opciones de montaje).
- Dejar de escribir millones de archivos pequeños si puedes almacenarlos como segmentos más grandes.
Qué evitar
- No “arregles” la espera de E/S añadiendo CPU. Es como comprar un coche más rápido para quedarte atascado en un tráfico peor.
- No desactives journaling ni características de durabilidad a la ligera. Puedes mejorar rendimiento—hasta que descubres lo que se siente perder energía y datos.
- No culpes a Docker como concepto. Docker es solo el mensajero. El verdadero enemigo es el almacenamiento compartido sin gobernanza.
Tres mini-historias corporativas desde las minas de I/O
1) Incidente causado por una suposición errónea: “Está en un contenedor, así que está aislado”
El equipo tenía un host ocupado: servicios API, algunos jobs en background y un importador “temporal” que tiraba datos de socios cada noche. El importador se desplegó sin límites explícitos. Nadie se preocupó. Estaba en Docker, después de todo.
La primera noche que corrió en el host de producción, las alertas de latencia afectaron a todo. No solo al importador. Las APIs se ralentizaron, la canalización de métricas se retrasó, las sesiones SSH se congelaron a mitad de comandos. Las gráficas de CPU parecían “bien”, lo que es exactamente lo que hace a este modo de falla tan desorientador: las CPUs esperaban educadamente a que el almacenamiento respondiera.
El ingeniero de guardia persiguió logs de aplicaciones y dependencias upstream durante veinte minutos porque los síntomas parecían una caída distribuida. Solo al revisar iostat la imagen encajó: un dispositivo NVMe pegado al ~100% de utilización, con latencia de escritura disparada. Luego iotop apuntó a un único proceso que churneaba escrituras a ritmo constante.
La suposición errónea fue sutil: “los contenedores aíslan recursos por defecto.” No lo hacen. CPU y memoria pueden limitarse fácilmente, pero el almacenamiento se comparte a menos que implementes controles o separes los dispositivos subyacentes. La solución esa noche fue un tope de cgroup de emergencia. La solución a largo plazo fue menos emocionante: el importador obtuvo su propio volumen en un dispositivo separado, más un límite de I/O como cinturón de seguridad.
2) Optimización que salió mal: “Más concurrencia significa imports más rápidos”
Un equipo de plataforma de datos intentó acelerar un job de transformación. Duplicaron la concurrencia de trabajadores y cambiaron a trozos de archivo más pequeños para aumentar el paralelismo. En papel debía ser más rápido: más trabajadores, más throughput, menos tiempo ocioso.
En producción se convirtió en una granada de latencia. El job produjo miles de archivos pequeños e hizo fsyncs frecuentes para “ser seguro”. El journal del sistema de archivos empezó a dominar. El throughput no se duplicó; se hundió. Peor, el resto del host sufrió porque el job mantuvo la cola de almacenamiento saturada con operaciones pequeñas que son veneno para la latencia tail.
El fracaso no fue solo “mucha carga”. Fue la forma de la carga. Muchas operaciones pequeñas y síncronas son otra bestia distinta a escrituras grandes y secuenciales. En almacenamiento moderno aún puedes ahogarte en actualizaciones de metadatos y flushes de barrera. Tu NVMe rápido puede convertirse en un disco giratorio muy caro si lo tratas como saco de golpes aleatorios.
Se recuperaron reduciendo la concurrencia, agrupando salidas en segmentos más grandes y escribiendo en un volumen en un sistema de archivos separado y afinado para la carga. Luego añadieron un tope de I/O para que futuras “optimizaciónes” no pudieran secuestrar el host. La nota final del postmortem fue directa: optimizar un trabajo sin definir daño colateral aceptable no es optimizar; es apostar.
3) Práctica aburrida pero correcta que salvó el día: QoS por servicio y discos separados
Otra organización corría una flota mixta: servicios web, caches, un par de bases de datos stateful y analytics periódicos. Ya habían sido quemados antes, así que construyeron un manual aburrido.
Los servicios stateful obtuvieron volúmenes dedicados en dispositivos separados. Sin excepciones. Las cargas por lotes corrían en contenedores con pesos y topes de I/O predefinidos. El logging se limitaba por defecto. El equipo de plataforma mantenía incluso un runbook simple que empezaba con iostat, iotop y “mapear PID a cgroup.” Nada sofisticado.
Una tarde, una nueva imagen de contenedor se lanzó con logging de depuración activado. Empezó a escribir agresivamente, pero el host no se derritió. El contenedor de logging alcanzó su tope, se ralentizó y el resto de la flota siguió sirviendo. El de guardia todavía tuvo que arreglar la mala configuración, pero fue un incidente contenido, no una caída.
La práctica no era glamorosa: asignar almacenamiento intencionalmente, aplicar QoS y hacer cumplir defaults. Pero así es la confiabilidad—menos heroísmos, más barandillas.
Errores comunes: síntoma → causa raíz → solución
1) Síntoma: la carga promedio es enorme, uso de CPU bajo
Causa raíz: tareas bloqueadas en estado D por almacenamiento; la carga incluye sueño ininterrumpible.
Solución: confirma con ps/vmstat; identifica PIDs top de I/O; mapea a contenedor; aplica io.max o pesos; luego arregla el comportamiento de escritura.
2) Síntoma: disco 100% utilizado pero el throughput no impresiona
Causa raíz: I/O aleatorio pequeño, tormentas de metadatos, contención del journal o flushes frecuentes causando alto overhead por operación.
Solución: reduce churn de archivos; agrupa escrituras; mueve rutas calientes a un volumen afinado; considera BFQ para equidad en algunos dispositivos; limita al culpable.
3) Síntoma: un único contenedor “parece bien” en CPU/mem pero el host es inutilizable
Causa raíz: sin aislamiento de I/O; el contenedor golpea el dispositivo compartido (logs, temp, checkpoints de BD).
Solución: aplica QoS de I/O vía cgroups; limita logs; coloca rutas de mucho escrito en volúmenes separados.
4) Síntoma: docker logs es lento y /var/lib/docker crece rápido
Causa raíz: logging json-file sin rotación; archivo de log enorme provocando escrituras y actualizaciones de metadatos adicionales.
Solución: establece max-size y max-file; envía logs fuera; desactiva logging de depuración en producción por defecto.
5) Síntoma: después de “limitar”, la app empieza a hacer timeouts
Causa raíz: topes duros demasiado bajos para los requisitos de latencia/durabilidad de la carga, o la carga espera ráfagas de I/O.
Solución: sube límites hasta que los SLOs se recuperen; prefiere pesos sobre topes estrictos para cargas mixtas; arregla batching y backpressure en la app.
6) Síntoma: espera de E/S alta pero iostat parece normal
Causa raíz: la I/O puede estar en otro dispositivo (loopback, almacenamiento en red), o el cuello de botella está en locks/metadata del sistema de archivos no mostrado claramente en estadísticas de dispositivo simples.
Solución: verifica mounts con findmnt; revisa otros dispositivos con iostat -x en todos; usa pidstat -d y lsof para localizar rutas; verifica volúmenes en red por separado.
7) Síntoma: un contenedor lee mucho y las escrituras se quedan sin recursos (o viceversa)
Causa raíz: comportamiento del planificador y contención de cola; patrones mixtos de lectura/escritura pueden causar injusticias y picos tail.
Solución: aplica topes separados para lectura/escritura en io.max; aísla cargas a dispositivos separados; programa lecturas por lotes fuera de pico.
Listas de verificación / plan paso a paso
Contención de emergencia (15 minutos)
- Confirma espera de E/S y latencia de dispositivo:
mpstat,iostat -x. - Encuentra PIDs top de I/O:
iotop -oPa,pidstat -d 1. - Mapea PID → contenedor vía
/proc/<pid>/cgroupydocker ps. - Aplica un tope
io.maxde cgroup v2 para el contenedor en el dispositivo ofensivo. - Verifica la mejora: iowait abajo, await abajo, respuesta del host recuperada.
- Comunica claramente: “Limitamos el contenedor X para estabilizar el host; la carga Y puede ir más lenta.”
Estabilización (el mismo día)
- Arregla logging: limita json-file logs o muévelos a un driver/agente que no castigue el disco raíz.
- Mueve datos con muchas escrituras fuera de la capa escribible overlay a un volumen o bind mount.
- Verifica el llenado del sistema de archivos: espacio libre y uso de inodos. Discos llenos generan problemas de rendimiento extraños.
- Confirma comportamiento de swap; si swap es intenso, añade límites de memoria al culpable o ajusta la carga.
- Documenta los ajustes de cgroup aplicados exactamente para poder reproducirlos y revertirlos.
Endurecimiento (esta iteración)
- Establece límites de logging por defecto para todos los contenedores (política, no sugerencia).
- Define clases de I/O: servicios sensibles a latencia vs batch/ETL; aplica pesos/topes en consecuencia.
- Separa almacenamiento para servicios stateful; evita co-localizar archivos de BD con almacenamiento del runtime cuando sea posible.
- Construye un runbook: “mapear PID a contenedor” con los comandos exactos para tu modo de cgroup.
- Añade alertas sobre latencia de disco (
await) y profundidad de cola, no solo throughput y %util.
Preguntas frecuentes
1) ¿Por qué la alta espera de E/S hace que SSH y comandos simples se cuelguen?
Porque esos comandos también necesitan disco: leer binarios, escribir el historial del shell, actualizar logs, tocar archivos, paginar memoria. Cuando la cola del disco es profunda o la latencia se dispara, todo lo que necesita I/O se bloquea.
2) ¿La espera de E/S siempre es señal de que el disco es “lento”?
No. Puede ser señal de comportamiento síncrono de la aplicación, contención del sistema de archivos, actividad de swap o saturación de la cola del dispositivo. Confírmalo con métricas de latencia/cola como await y aqu-sz.
3) Limitó un contenedor y el host mejoró. ¿Eso prueba que el contenedor es la causa raíz?
Prueba que es un contribuidor importante a la cola del dispositivo. La causa raíz puede seguir siendo arquitectónica: discos compartidos, defaults de logging o diseño que convierte escrituras pequeñas en tormentas de flush.
4) ¿Debería usar límites de IOPS o de ancho de banda?
Los límites de ancho de banda son una buena herramienta tosca para cargas de streaming. Los límites de IOPS pueden ser mejores para I/O aleatorio y cargas con muchos metadatos. Usa lo que encaje con el dolor: si la latencia se dispara en operaciones pequeñas, los límites de IOPS pueden ayudar más.
5) ¿Los flags blkio de Docker funcionan con overlay2?
Algunas veces, pero no lo suficientemente fiable como para apostar tu presupuesto de outages. Con cgroup v2, prefiere io.max/io.weight en el scope del contenedor. Valida en tu entorno.
6) Si muevo datos a un volumen, ¿por qué ayuda?
Evitas la amplificación de escrituras de overlay y ganas control: puedes colocar el volumen en otro dispositivo, elegir opciones de sistema de archivos y aislar I/O de forma más limpia.
7) ¿Puedo arreglar esto cambiando el planificador de I/O?
En ocasiones puedes mejorar equidad o colas tail, pero los ajustes del planificador no te salvarán de un escritor sin límites en un disco compartido. Limita primero; ajusta después.
8) ¿Por qué los logs causan tanto daño?
Porque son escrituras que no presupuestaste, a menudo casi síncronas, a menudo infinitas y por defecto viven en el mismo disco que todo lo demás. Rótalos y envíalos fuera.
9) ¿Cómo sé si el cuello de botella es el journal del sistema de archivos?
Verás alta latencia con muchas escrituras pequeñas, muchas tareas en canales de espera ext4_sync_file o similares y mucha actividad de metadatos. La solución suele ser la forma de la carga (batching) y la ubicación de datos, no banderas mágicas del kernel.
10) ¿Es “seguro” limitar la I/O para bases de datos?
Depende. Para una base de datos que sirve tráfico de producción, los topes duros pueden causar timeouts y fallos en cascada. Prefiere pesos y un aislamiento de almacenamiento adecuado. Si debes imponer un tope, hazlo con cuidado y monitoriza.
Próximos pasos (qué haces el lunes)
Cuando un contenedor arrastra tu host al purgatorio de espera de E/S, la jugada ganadora no es adivinar. Es atribución y control: mide la latencia del dispositivo, identifica los PIDs top de I/O, mapea a contenedores y aplica límites de I/O en cgroup que mantengan el host vivo.
Luego haz la parte madura: deja de escribir en capas overlay, limita logging por defecto y separa almacenamiento para cargas que no deben compartir una cola. Mantén los throttles como barandillas, no como sustituto de la arquitectura. Tu yo de guardia futuro te lo agradecerá aburridamente.