Por defecto, un contenedor Docker puede usar toda la CPU y toda la memoria del servidor. Si una aplicación tiene una fuga de memoria o entra en un bucle, puede dejar sin recursos al resto de contenedores e incluso al sistema. En este tutorial aprenderás a limitar la memoria, el swap, la CPU y el número de procesos de un contenedor en Ubuntu 24.04, tanto con docker run como con Docker Compose, y a comprobar en cgroups v2 que el kernel aplica esos límites.

Requisitos previos

Para seguir esta guía necesitas:

  • Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con al menos 2 vCPU para ver bien el efecto de los límites de CPU.
  • Un usuario no root con privilegios sudo que pertenezca al grupo docker.
  • Docker Engine y el plugin Docker Compose instalados desde el repositorio oficial de Docker.

Cómo aplica Docker los límites

Docker no controla los recursos por sí mismo: crea un grupo de control (cgroup) del kernel para cada contenedor y escribe en él los límites. Ubuntu 24.04 usa cgroups v2, y dentro de un contenedor los ficheros relevantes están en /sys/fs/cgroup/:

Opción de DockerFichero de cgroups v2Efecto
--memorymemory.maxLímite duro de RAM; si se supera, el kernel mata procesos del contenedor
--memory-reservationmemory.lowMínimo que el kernel intenta respetar cuando falta memoria
--memory-swapmemory.swap.maxSwap adicional permitido
--cpuscpu.maxTope de CPU (por ejemplo, 1,5 núcleos)
--cpu-sharescpu.weightPeso relativo cuando varios contenedores compiten por la CPU
--cpuset-cpuscpuset.cpusNúcleos concretos en los que puede ejecutarse
--pids-limitpids.maxNúmero máximo de procesos e hilos

Comprueba que tu sistema usa cgroups v2:

docker info --format '{{.CgroupVersion}} {{.CgroupDriver}}'
2 systemd

Paso 1: Limitar la memoria

La opción --memory (o -m) fija el máximo de RAM. Acepta los sufijos k, m y g. Arranca un Nginx con 256 MB:

docker run -d --name web-limitado --memory 256m nginx:alpine

Comprueba el límite de tres formas distintas. Primero, en la configuración del contenedor (el valor está en bytes):

docker inspect web-limitado --format '{{.HostConfig.Memory}}'
268435456

Después, en el cgroup que ve el propio contenedor:

docker exec web-limitado cat /sys/fs/cgroup/memory.max
268435456

Por último, en docker stats, que muestra el consumo actual frente al límite:

docker stats --no-stream web-limitado
CONTAINER ID   NAME           CPU %     MEM USAGE / LIMIT   MEM %     NET I/O       BLOCK I/O   PIDS
4b1e9c2a7d3f   web-limitado   0.00%     3.1MiB / 256MiB     1.21%     1.2kB / 0B    0B / 8kB    3

Qué ocurre al superar el límite

Cuando el contenedor intenta usar más memoria de la permitida, el OOM killer del kernel mata el proceso. Puedes provocarlo con tail /dev/zero, que acumula datos en memoria sin parar. Limita también el swap al mismo valor para que no pueda desbordarse al disco:

docker run --name oom-test --memory 64m --memory-swap 64m alpine tail /dev/zero
echo "Código de salida: $?"
Código de salida: 137

El código 137 significa que el proceso recibió SIGKILL. Confirma que la causa fue la memoria:

docker inspect oom-test --format 'OOMKilled={{.State.OOMKilled}}'
OOMKilled=true

Borra el contenedor de prueba:

docker rm oom-test

Si un servicio real muestra OOMKilled=true, no basta con subir el límite a ciegas: revisa con docker stats cuánto consume en carga normal y fija el límite con margen por encima.

Paso 2: Controlar el swap y la reserva de memoria

La opción --memory-swap indica el total de memoria más swap, no el swap por separado. Las combinaciones posibles son:

--memory--memory-swapResultado
512msin indicar512 MB de RAM y hasta 512 MB de swap
512m512m512 MB de RAM y sin swap
512m1g512 MB de RAM y hasta 512 MB de swap
512m-1512 MB de RAM y swap ilimitado

Para servicios sensibles a la latencia, como bases de datos, lo habitual es desactivar el swap del contenedor igualando ambos valores.

La reserva (--memory-reservation) es un límite blando: el contenedor puede superarla, pero cuando el servidor se queda sin memoria el kernel intenta no recortarle por debajo de ese valor. Debe ser menor que --memory:

docker run -d --name db-test \
  --memory 1g --memory-swap 1g --memory-reservation 512m \
  -e POSTGRES_PASSWORD=tu_contraseña_segura \
  postgres:17

Comprueba los tres valores en el cgroup:

docker exec db-test sh -c 'cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/memory.low /sys/fs/cgroup/memory.swap.max'
1073741824
536870912
0

Borra el contenedor de prueba:

docker rm -f db-test

Paso 3: Limitar la CPU

Tope de CPU con --cpus

--cpus es la forma más sencilla de limitar la CPU: --cpus 0.5 permite usar como máximo medio núcleo, y --cpus 1.5, núcleo y medio, repartido entre los núcleos disponibles. Arranca un contenedor que consume CPU sin parar, limitado a medio núcleo:

docker run -d --name cpu-test --cpus 0.5 alpine sh -c 'while :; do :; done'

Mide su consumo:

docker stats --no-stream cpu-test
CONTAINER ID   NAME       CPU %     MEM USAGE / LIMIT   MEM %     NET I/O       BLOCK I/O   PIDS
9a3d7e1f5b2c   cpu-test   50.12%    412KiB / 3.8GiB     0.01%     0B / 0B       0B / 0B     1

En docker stats, el 100 % equivale a un núcleo completo, así que el contenedor se queda en torno al 50 %. En el cgroup, el límite aparece como cuota y periodo en microsegundos (50 ms de cada 100 ms):

docker exec cpu-test cat /sys/fs/cgroup/cpu.max
50000 100000

Cambiar límites sin recrear el contenedor

docker update modifica los límites de un contenedor en marcha. Sube el límite a un núcleo y comprueba el cambio:

docker update --cpus 1 cpu-test
docker exec cpu-test cat /sys/fs/cgroup/cpu.max
cpu-test
100000 100000

Los cambios hechos con docker update se pierden si el contenedor se recrea (por ejemplo, con docker compose up -d), así que trasládalos después a tu configuración.

Fijar núcleos con --cpuset-cpus

--cpuset-cpus restringe el contenedor a núcleos concretos, numerados desde 0 (acepta listas como 0,2 y rangos como 0-1). Es útil para aislar una carga pesada en un núcleo y dejar el resto libre. nproc dentro del contenedor muestra cuántos núcleos puede usar:

docker run --rm --cpuset-cpus 0 alpine nproc
1

Prioridad relativa con --cpu-shares

--cpu-shares no es un límite: solo reparte la CPU cuando hay competencia. El valor por defecto es 1024; un contenedor con 2048 recibirá el doble de tiempo de CPU que uno con 1024 cuando ambos quieran la CPU completa. Si el servidor está desocupado, cualquier contenedor puede usar toda la CPU.

Detén el contenedor de prueba:

docker rm -f cpu-test

Paso 4: Limitar el número de procesos

Un fallo que crea procesos sin control (o una bomba fork) puede agotar la tabla de procesos del servidor aunque la CPU y la memoria estén limitadas. --pids-limit lo evita:

docker run --rm --pids-limit 50 alpine cat /sys/fs/cgroup/pids.max
50

Un valor entre 100 y 500 suele bastar para servicios web; ajústalo según los hilos que cree tu aplicación.

Paso 5: Definir límites en Docker Compose

En Docker Compose, los límites se declaran en deploy.resources, que Compose aplica también fuera de Swarm. Crea un proyecto de ejemplo:

mkdir -p ~/limites && cd ~/limites
nano compose.yaml
services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    pids_limit: 200
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          memory: 128M

  worker:
    image: alpine
    command: sleep infinity
    mem_limit: 256m
    memswap_limit: 256m
    cpus: 0.5

El servicio web usa la sintaxis deploy.resources y el servicio worker, las claves equivalentes a nivel de servicio (mem_limit, memswap_limit, cpus). Ambas funcionan con docker compose; elige una y úsala de forma coherente en todo el proyecto. memswap_limit no tiene equivalente en deploy.resources, por lo que es la forma de controlar el swap en Compose.

Arranca los servicios y comprueba los límites:

docker compose up -d
docker stats --no-stream
CONTAINER ID   NAME               CPU %     MEM USAGE / LIMIT   MEM %     NET I/O       BLOCK I/O   PIDS
1f2e3d4c5b6a   limites-web-1      0.00%     3.4MiB / 512MiB     0.66%     1.1kB / 0B    0B / 8kB    3
6a5b4c3d2e1f   limites-worker-1   0.00%     328KiB / 256MiB     0.12%     1.1kB / 0B    0B / 0B     1

Verifica también los límites de CPU y de procesos del servicio web:

docker compose exec web cat /sys/fs/cgroup/cpu.max /sys/fs/cgroup/pids.max
100000 100000
200

Cuando termines, elimina el proyecto de prueba:

docker compose down

Cómo elegir los valores

  • Mide antes de limitar: observa el consumo con docker stats durante un periodo de carga real y fija el límite de memoria entre un 25 % y un 50 % por encima del pico.
  • Deja memoria para el sistema: la suma de los límites de memoria de todos los contenedores no debería superar la RAM del servidor menos unos cientos de MB para el sistema operativo y Docker.
  • Ajusta la aplicación al límite: algunos entornos no leen el cgroup y se configuran según la RAM total del servidor. Por ejemplo, fija shared_buffers en PostgreSQL o -Xmx en Java por debajo del límite del contenedor.
  • Limita todo lo que no controlas: contenedores de terceros, tareas por lotes y procesos de compilación son los primeros candidatos.

Solución de problemas

  • Minimum memory limit allowed is 6MB: el límite es demasiado bajo. Usa valores realistas; ningún servicio útil funciona con unos pocos MB.
  • Memory limit should be smaller than already set memoryswap limit: al subir --memory con docker update, sube también --memory-swap en el mismo comando, ya que la memoria nunca puede superar el total de memoria más swap.
  • El contenedor se reinicia en bucle con código 137: revisa docker inspect nombre --format '{{.State.OOMKilled}}'. Si es true, el límite de memoria es insuficiente para su carga; si es false, otro proceso o una sonda de salud lo está matando.
  • Range of CPUs is from 0.01 to N.00: has pedido más CPU de la que tiene el servidor. Comprueba los núcleos disponibles con nproc.

Conclusión

Has limitado la memoria, el swap, la CPU y los procesos de tus contenedores con docker run, docker update y Docker Compose, y has comprobado en cgroups v2 que el kernel aplica cada límite. Como siguientes pasos, añade límites a todos los servicios de tus ficheros de Compose, recoge las métricas de docker stats en un sistema de monitorización para ajustar los valores con datos reales y configura healthcheck para detectar contenedores que se quedan sin recursos.