Aurora Libros funciona: cuatro contenedores en una red propia, el catálogo a salvo en un volumen con nombre, la web sirviéndose a través de un proxy inverso. Pero quedan dos riesgos que ya asomaron en lecciones anteriores y que en un servidor real acaban en llamada de madrugada.

El primero lo viste en docker stats: la columna LIMIT mostraba 7,628 GiB en todos los contenedores, es decir, toda la RAM de la máquina. Una consulta desbocada en PostgreSQL o una fuga de memoria en Node pueden dejar sin memoria al host entero y arrastrar consigo a los otros tres servicios. El segundo es más simple: si un contenedor muere a las tres de la mañana, ahí se queda hasta que alguien lo levante.

Esta lección cierra ambos frentes. Vas a poner límites de memoria, CPU, disco y número de procesos, vas a provocar un OOM a propósito para verlo con tus ojos, y vas a configurar políticas de reinicio con sus diferencias sutiles. Y al terminar, tendrás delante la lista completa de comandos que hace falta para levantar la plataforma desde cero... y entenderás perfectamente por qué existe el módulo 4.

Contenido

  1. Por qué un contenedor sin límites es un problema
  2. Límites de memoria
  3. El OOM killer, provocado y diagnosticado
  4. Límites de CPU
  5. Límites de E/S de disco
  6. --pids-limit: defensa ante una fork bomb
  7. Verificar y modificar en caliente
  8. Políticas de reinicio
  9. always frente a unless-stopped
  10. Por qué una política de reinicio no sustituye a un healthcheck
  11. Práctica final: el cuadro de límites de Aurora Libros
  12. El guion completo, y por qué no puede seguir así

  1. Por qué un contenedor sin límites es un problema

docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}\t{{.PIDs}}"
NAME           MEM USAGE / LIMIT     CPU %   PIDS
aurora-web     3.44MiB / 7.628GiB    0.00%   3
aurora-api     52.17MiB / 7.628GiB   0.13%   11
aurora-cache   9.91MiB / 7.628GiB    0.18%   6
aurora-db      41.28MiB / 7.628GiB   0.02%   7

Los cuatro tienen el mismo límite: toda la memoria del host. Por defecto, un contenedor puede consumir tanta RAM y tanta CPU como haya disponible. Recuerda de la lección 01-01 que un contenedor no es una máquina virtual con recursos preasignados, sino un proceso del host aislado con namespaces; lo que reparte los recursos son los cgroups, y por defecto están abiertos de par en par.

Las consecuencias, todas reales:

Escenario Qué pasa sin límites
Fuga de memoria en la API Node crece hasta agotar la RAM. El kernel empieza a matar procesos, y no necesariamente el culpable
Consulta pesada en PostgreSQL Un ORDER BY sobre una tabla enorme se come varios GB y expulsa de memoria a los demás servicios
Pico de tráfico Un contenedor acapara toda la CPU y los demás dejan de responder
Contenedor comprometido Un minero de criptomonedas usa el 100 % de todos los núcleos
Bug con fork() Miles de procesos agotan la tabla de procesos del host, no solo la del contenedor

La idea que sostiene todo el apartado: los límites no son una optimización, son un mecanismo de contención. Sirven para que el fallo de un servicio no se convierta en el fallo de la máquina entera.

  1. Límites de memoria

Opción Qué hace
-m, --memory Límite duro. Al superarlo, el kernel mata el proceso
--memory-swap Límite de memoria + swap combinados
--memory-reservation Límite blando: bajo presión, el kernel intenta reducirlo hasta este valor
--memory-swappiness De 0 a 100: cuánta tendencia a usar swap
--oom-kill-disable No matar el contenedor al agotar la memoria

Las unidades son b, k, m y g: -m 512m, -m 1g. El mínimo aceptado es 6 MB.

La relación entre --memory y --memory-swap

Esta es la parte que confunde a todo el mundo, porque --memory-swap no es la cantidad de swap: es el total de memoria más swap.

--memory --memory-swap RAM disponible Swap disponible Total
512m (sin especificar) 512 MB 512 MB (igual que la RAM) 1 GB
512m 1g 512 MB 512 MB 1 GB
512m 512m 512 MB 0 (swap desactivado) 512 MB
512m -1 512 MB Ilimitado Ilimitado
(sin especificar) 1g Ilimitada Error: --memory-swap requiere --memory

La fila más importante es la tercera: --memory y --memory-swap con el mismo valor desactiva el swap. Y esa suele ser la configuración correcta en servidores, porque un proceso que empieza a intercambiar a disco no está funcionando, está agonizando: los tiempos de respuesta se disparan hasta hacer el servicio inútil, pero como técnicamente sigue vivo, los healthchecks pueden no enterarse. Es preferible un fallo limpio y rápido a una degradación lenta y silenciosa.

docker run -d --name limite-demo -m 256m --memory-swap 256m alpine:3.20 sleep 300
docker inspect -f 'Memory: {{.HostConfig.Memory}} bytes | MemorySwap: {{.HostConfig.MemorySwap}} bytes' limite-demo
docker stats --no-stream limite-demo --format "{{.Name}}: {{.MemUsage}}"
docker rm -f limite-demo
Memory: 268435456 bytes | MemorySwap: 268435456 bytes
limite-demo: 1.02MiB / 256MiB

Ahora la columna LIMIT dice 256 MiB en lugar de los 7,628 GiB del host.

--memory-reservation: el límite blando

docker run -d --name blando-demo -m 512m --memory-reservation 256m redis:7-alpine
docker inspect -f '{{.HostConfig.MemoryReservation}}' blando-demo
docker rm -f blando-demo
268435456

La diferencia entre los dos:

--memory (duro) --memory-reservation (blando)
¿Se puede superar? No, nunca Sí, si hay memoria libre en el host
Qué pasa al superarlo El contenedor muere (OOM) El kernel presiona para que baje
Para qué sirve Contención absoluta Priorizar quién cede memoria cuando escasea

El patrón habitual es usar los dos: reserva el consumo normal esperado y limita en el punto donde algo va claramente mal.

--oom-kill-disable

docker run -d --name sin-oom -m 128m --oom-kill-disable alpine:3.20 sleep 300

Con esta opción, al agotar la memoria el kernel no mata el proceso: lo congela indefinidamente. Suena mejor y casi siempre es peor: acabas con un contenedor Up que no responde a nada, sin logs nuevos y sin código de salida que investigar. Un proceso muerto se reinicia; uno congelado hay que diagnosticarlo a mano. Úsalo solo si sabes exactamente por qué, y nunca sin un límite de memoria (sin -m, congelarías la máquina entera).

docker rm -f sin-oom

  1. El OOM killer, provocado y diagnosticado

Cuando un contenedor supera su límite de memoria, actúa el OOM killer (Out Of Memory killer) del kernel de Linux. No es Docker: es el kernel, y no negocia.

Vamos a provocarlo. Este contenedor tiene 64 MB y va a intentar escribir 200 MB en memoria compartida, que se contabiliza contra el mismo cgroup:

docker run -d --name oom-demo \
  -m 64m --memory-swap 64m --shm-size 512m \
  alpine:3.20 sh -c 'echo "empiezo a llenar memoria"; dd if=/dev/zero of=/dev/shm/relleno bs=1M count=200; echo "he terminado"'

sleep 5
docker ps -a --filter name=oom-demo --format "table {{.Names}}\t{{.Status}}"
NAMES       STATUS
oom-demo    Exited (137) 2 seconds ago

Código 137. De la tabla de la lección 03-02: 137 = 128 + 9 = SIGKILL. Pero ese mismo 137 lo produce también un docker kill o un docker stop que agota su plazo, así que hace falta una prueba más:

docker inspect -f 'OOMKilled: {{.State.OOMKilled}} | ExitCode: {{.State.ExitCode}} | Error: {{.State.Error}}' oom-demo
docker logs oom-demo
OOMKilled: true | ExitCode: 137 | Error:
empiezo a llenar memoria

OOMKilled: true es la confirmación inequívoca. Y fíjate en los logs: se ve el "empiezo a llenar memoria" pero nunca el "he terminado". El proceso fue eliminado a media faena, sin oportunidad de escribir nada más, sin excepción capturable y sin apagado ordenado. SIGKILL no se puede interceptar.

El evento también queda registrado en el demonio:

docker events --since 5m --filter container=oom-demo --filter event=oom
2026-08-04T23:14:07.442 container oom 8b2f1c9e... (image=alpine:3.20, name=oom-demo)

Y en el host, el kernel deja su propia constancia:

sudo dmesg | grep -i "killed process" | tail -2
[189234.117] Memory cgroup out of memory: Killed process 41207 (dd) total-vm:213504kB, anon-rss:65112kB

Cómo diagnosticar un 137 sospechoso, en orden:

Comprobación Si es afirmativa
docker inspect -f '{{.State.OOMKilled}}'true Fue el OOM killer: sube el límite o arregla la fuga
docker events --filter event=oom muestra el evento Confirmación desde el demonio, con hora exacta
Alguien ejecutó docker kill o docker stop Fue una acción humana o un script
El proceso ignoró SIGTERM durante 10 s Es el caso de la forma shell de la lección 03-02
docker rm oom-demo

  1. Límites de CPU

Tres opciones que hacen cosas distintas y se confunden constantemente:

Opción Qué es Tipo Cuándo usarla
--cpus Cuántos núcleos puede usar como máximo (admite decimales) Límite duro La opción por defecto: predecible y fácil de razonar
--cpu-shares Peso relativo frente a otros contenedores (1024 por defecto) Prioridad Repartir CPU en contención sin desperdiciarla cuando sobra
--cpuset-cpus Qué núcleos concretos puede usar Fijación Aislar cargas sensibles a la latencia o respetar NUMA
docker run -d --name cpu-limitado --cpus 0.5 alpine:3.20 sh -c 'while true; do :; done'
sleep 3
docker stats --no-stream cpu-limitado --format "{{.Name}}: {{.CPUPerc}}"
docker rm -f cpu-limitado
cpu-limitado: 49.87%

Un bucle infinito que devoraría un núcleo entero se queda en el 50 % de uno, que es justo lo que pedía --cpus 0.5. Ten presente que en docker stats el 100 % equivale a un núcleo: un contenedor con --cpus 2 puede llegar a marcar 200 %.

--cpu-shares no es un límite

docker run -d --name peso-alto  --cpu-shares 1024 alpine:3.20 sh -c 'while true; do :; done'
docker run -d --name peso-bajo  --cpu-shares 256  alpine:3.20 sh -c 'while true; do :; done'
sleep 5
docker stats --no-stream peso-alto peso-bajo --format "{{.Name}}: {{.CPUPerc}}"
docker rm -f peso-alto peso-bajo
peso-alto: 318.42%
peso-bajo: 79.61%

La proporción es aproximadamente 4:1, exactamente la relación entre 1024 y 256. Pero la clave está en otra parte: si peso-bajo estuviera solo en la máquina, usaría toda la CPU disponible. Los shares solo entran en juego cuando hay competencia; no desperdician capacidad ociosa.

--cpus 1 --cpu-shares 1024
Con la máquina libre Usa como máximo 1 núcleo Usa todos los que haya
Con la máquina saturada Usa 1 núcleo Usa su parte proporcional
Predecible No, depende de los vecinos
Uso típico Producción, facturación por recursos Prioridades relativas dentro de un host

--cpuset-cpus

docker run -d --name fijado --cpuset-cpus "0,1" alpine:3.20 sh -c 'while true; do :; done'
docker inspect -f '{{.HostConfig.CpusetCpus}}' fijado
docker rm -f fijado
0,1

El contenedor solo se ejecutará en los núcleos 0 y 1, sea cual sea la carga del resto. Se usa poco, pero es valioso para cargas sensibles a la latencia (evita que el planificador mueva el proceso entre núcleos y se pierdan las cachés) y para reservar núcleos a procesos críticos del sistema.

  1. Límites de E/S de disco

Menos habituales, pero imprescindibles cuando un contenedor satura el disco y arrastra a los demás:

Opción Qué limita
--blkio-weight Peso relativo de E/S, de 10 a 1000 (500 por defecto). Como --cpu-shares, pero para disco
--device-read-bps Bytes por segundo de lectura sobre un dispositivo
--device-write-bps Bytes por segundo de escritura
--device-read-iops / --device-write-iops Operaciones por segundo
docker run --rm --device-write-bps /dev/sda:1mb alpine:3.20 \
  dd if=/dev/zero of=/tmp/prueba bs=1M count=10 oflag=direct
10+0 records in
10+0 records out
10485760 bytes (10.0MB) copied, 10.031459 seconds, 1.0MB/s

Exactamente 1 MB/s, diez segundos para diez megas. Dos advertencias: hay que indicar el dispositivo real del host (/dev/sda, /dev/nvme0n1; míralo con lsblk), y estas opciones solo funcionan con el driver de almacenamiento y el sistema de ficheros adecuados —en muchas configuraciones con overlay2 sobre ext4 requieren oflag=direct para notarse, como en el ejemplo—.

  1. --pids-limit: defensa ante una fork bomb

Limita el número de procesos e hilos que puede crear un contenedor. Es la protección más barata y de las más efectivas:

docker run --rm --pids-limit 20 alpine:3.20 sh -c \
  'for i in $(seq 1 50); do sleep 30 & done; echo "lanzados los que se pudo"'
sh: can't fork: Resource temporarily unavailable
sh: can't fork: Resource temporarily unavailable
...
lanzados los que se pudo

A partir del proceso número 20, el kernel deniega el fork(). Sin este límite, una fork bomb —un bug o un ataque que crea procesos sin parar— agota la tabla de procesos del host, y a partir de ese momento no se puede lanzar ni un ps para diagnosticar: hay que reiniciar la máquina.

docker run -d --name pids-demo --pids-limit 100 nginx:alpine
docker inspect -f '{{.HostConfig.PidsLimit}}' pids-demo
docker stats --no-stream pids-demo --format "{{.Name}}: {{.PIDs}} procesos"
docker rm -f pids-demo
100
pids-demo: 3 procesos

Valores razonables: 100 para un servicio web sencillo, 200–500 para una base de datos, y siempre midiendo antes con docker stats cuál es el consumo normal, con un margen holgado.

  1. Verificar y modificar en caliente

docker inspect -f 'Memoria: {{.HostConfig.Memory}} | Swap: {{.HostConfig.MemorySwap}} | NanoCPUs: {{.HostConfig.NanoCpus}} | PIDs: {{.HostConfig.PidsLimit}} | Política: {{.HostConfig.RestartPolicy.Name}}' aurora-api
Memoria: 0 | Swap: 0 | NanoCPUs: 0 | PIDs: 0 | Política: no

Un 0 significa "sin límite", y NanoCPUs expresa --cpus en milmillonésimas: --cpus 1.5 se guarda como 1500000000.

La gran ventaja de estas opciones, frente a puertos, redes y montajes, es que sí se pueden cambiar sin recrear el contenedor, porque viven en los cgroups y no en los namespaces:

docker update --memory 512m --memory-swap 512m --cpus 1.0 --pids-limit 200 aurora-db
docker inspect -f 'Memoria: {{.HostConfig.Memory}} | NanoCPUs: {{.HostConfig.NanoCpus}}' aurora-db
docker stats --no-stream aurora-db --format "{{.Name}}: {{.MemUsage}}"
aurora-db
Memoria: 536870912 | NanoCPUs: 1000000000
aurora-db: 41.83MiB / 512MiB

Sin parar PostgreSQL, sin recrear el contenedor y sin perder ninguna conexión. La columna LIMIT ya dice 512 MiB. Es la excepción de la regla "todo se fija al crear", y por eso docker update existe.

Dos límites de docker update: no puede bajar la memoria por debajo de lo que ya está en uso (fallaría), y algunas opciones como --memory-swap requieren indicar también --memory en la misma llamada.

  1. Políticas de reinicio

Un contenedor que muere no vuelve solo. La opción --restart cambia eso:

Política Reinicia si el proceso sale con error Reinicia si sale con 0 Reinicia al arrancar el demonio Tras un docker stop manual
no (por defecto) No No No
on-failure , indefinidamente No Sí, si había fallado No
on-failure:N Sí, hasta N veces No No
always Sí, siempre Sí, al reiniciar el demonio
unless-stopped Sí, salvo si lo paraste tú No
docker run -d --name reinicio-demo --restart on-failure:3 \
  alpine:3.20 sh -c 'echo "intento"; sleep 2; exit 1'
sleep 15
docker ps -a --filter name=reinicio-demo --format "table {{.Names}}\t{{.Status}}"
docker inspect -f 'Reinicios: {{.RestartCount}} | Estado: {{.State.Status}} | Código: {{.State.ExitCode}}' reinicio-demo
docker logs reinicio-demo
NAMES            STATUS
reinicio-demo    Exited (1) 3 seconds ago
Reinicios: 3 | Estado: exited | Código: 1
intento
intento
intento
intento

Cuatro "intento" en los logs: el arranque original más tres reinicios, y después Docker se rinde. RestartCount es el contador que lo confirma y es de los primeros campos que mirar cuando un servicio "va y viene".

El backoff exponencial

Docker no reinicia en bucle cerrado: espera cada vez más entre intentos.

Intento Espera aproximada
1.º 100 ms
2.º 200 ms
3.º 400 ms
4.º 800 ms
Doblando hasta un máximo de 1 minuto

El contador se reinicia si el contenedor consigue mantenerse en marcha al menos 10 segundos. Ese detalle explica un comportamiento desconcertante: un servicio que arranca, funciona 30 segundos y muere, con --restart always puede quedarse reiniciándose eternamente cada pocos segundos sin que el backoff llegue nunca a crecer. Se detecta así:

docker ps --filter status=restarting
docker inspect -f '{{.RestartCount}}' <contenedor>

Un RestartCount de 847 es un servicio que lleva horas fallando y nadie se ha enterado.

  1. always frente a unless-stopped

Las dos parecen iguales y se diferencian en un único escenario, que además es el más importante: cuando se reinicia el demonio de Docker o la máquina.

flowchart TD
    A["docker stop aurora-api<br/>(parada manual deliberada)"] --> B["Contenedor exited"]
    B --> C["Se reinicia el host<br/>o el servicio docker"]
    C --> D{"¿Qué política<br/>tenía?"}
    D -- "always" --> E["Docker lo ARRANCA<br/>Tu parada manual se ignora"]
    D -- "unless-stopped" --> F["Docker lo DEJA parado<br/>Se respeta tu decisión"]
Situación always unless-stopped
El proceso falla Reinicia Reinicia
El proceso sale con código 0 Reinicia Reinicia
docker stop manual No reinicia (en ese momento) No reinicia
Reinicio del demonio tras un docker stop manual Lo arranca Lo deja parado
Reinicio del demonio estando en marcha Lo arranca Lo arranca

Por qué importa: imagina que paras aurora-api para investigar un problema y te vas a comer. Con always, un reinicio del demonio —una actualización automática, un reinicio del servidor— la vuelve a levantar en el estado roto que estabas investigando. Con unless-stopped, tu decisión se respeta.

Recomendación general: unless-stopped para servicios de larga duración y on-failure:N para procesos que deben terminar. always solo si de verdad quieres que nada pueda dejar el contenedor parado.

Y dos matices operativos:

  • La política se puede cambiar en caliente: docker update --restart unless-stopped aurora-db.
  • Con --rm, --restart es incompatible: Docker lo rechaza, porque no puede a la vez borrar el contenedor y reiniciarlo.
docker rm -f reinicio-demo

  1. Por qué una política de reinicio no sustituye a un healthcheck

Este es el matiz que separa una configuración que parece robusta de una que lo es.

docker run -d --name colgado --restart unless-stopped nginx:alpine
docker exec colgado nginx -s stop
sleep 5
docker ps --filter name=colgado --format "{{.Names}}: {{.Status}}"
docker rm -f colgado
colgado: Up 12 seconds

Nginx está parado dentro del contenedor y Docker dice Up, tan tranquilo, porque el PID 1 sigue vivo (el master de Nginx aún no ha terminado del todo). Generalizando el problema:

Tipo de fallo ¿Lo detecta --restart? ¿Lo detecta HEALTHCHECK?
El proceso muere
El proceso sale con error
El proceso vive pero está bloqueado No
Pool de conexiones agotado No
Devuelve 500 a todas las peticiones No
Ha perdido la base de datos No

Y ahora el detalle que sorprende: Docker Engine no reinicia un contenedor por estar unhealthy. La política de reinicio reacciona a la muerte del proceso, no al veredicto del healthcheck. Puedes tener un contenedor Up 3 days (unhealthy) con --restart always durante tres días sin que nadie haga nada.

¿Quién actúa entonces sobre la salud?

Entorno Qué hace con unhealthy
Docker Engine solo Marca el estado y emite un evento health_status. Nada más
Docker Swarm Reemplaza la tarea por una nueva (lección 06-03)
Kubernetes La liveness probe reinicia el contenedor; la readiness lo saca de balanceo (lección 06-05)
Tú, con un script Lo que programes, vigilando docker events --filter event=health_status

La conclusión, que conviene tener clara al salir de este módulo: la política de reinicio y el healthcheck resuelven problemas distintos y se complementan. La primera te devuelve un proceso muerto; el segundo detecta un proceso vivo pero inútil. Y para que ese diagnóstico se traduzca en acción hace falta un orquestador, que es el destino del módulo 6.

  1. Práctica final: el cuadro de límites de Aurora Libros

Toca decidir números concretos, y decidirlos con criterio. Parte de lo que consumen de verdad:

docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}\t{{.PIDs}}"
NAME           MEM USAGE / LIMIT     CPU %   PIDS
aurora-web     3.44MiB / 7.628GiB    0.00%   3
aurora-api     52.17MiB / 7.628GiB   0.13%   11
aurora-cache   9.91MiB / 7.628GiB    0.18%   6
aurora-db      41.83MiB / 512MiB     0.02%   7

El cuadro, con su justificación:

Servicio Memoria Reserva CPU PIDs Política Por qué
aurora-db 512m 256m 1.0 200 unless-stopped Consume 42 MB en reposo, pero PostgreSQL necesita margen para work_mem, caché y conexiones. Es el servicio con estado: debe volver siempre, y si lo paras tú, debe quedarse parado
aurora-cache 256m 0.5 100 unless-stopped Con --maxmemory 200mb de Redis alineado por debajo del límite del contenedor. No necesita CPU: casi todo es E/S de red
aurora-api 256m 128m 1.0 100 on-failure:3 Node con 52 MB de base; 256 MB dan aire al recolector de basura. on-failure:3 porque si falla tres veces seguidas, el problema es de configuración y reiniciar en bucle solo lo oculta
aurora-web 128m 0.5 50 unless-stopped Nginx sirviendo estáticos y haciendo de proxy: 3,4 MB reales. 128 MB es generosísimo

Dos decisiones merecen explicación aparte:

Por qué aurora-cache lleva --maxmemory además del límite del contenedor. Si solo pusieras -m 256m, Redis no sabría nada de ese límite: seguiría aceptando claves hasta que el OOM killer lo matara de golpe, perdiendo toda la caché. Con --maxmemory 200mb --maxmemory-policy allkeys-lru, Redis gestiona el problema él mismo: al llegar a 200 MB empieza a expulsar las claves menos usadas y sigue funcionando. El límite del contenedor pasa a ser la red de seguridad, no el mecanismo habitual. Cuando un servicio tiene su propio control de memoria, configúralo por debajo del límite del contenedor. Lo mismo aplica a --max-old-space-size en Node y a shared_buffers en PostgreSQL.

Por qué aurora-api lleva on-failure:3 y no unless-stopped. Es un servicio sin estado y reemplazable, pero si falla tres veces seguidas casi nunca es algo transitorio: es una variable de entorno mal puesta, una migración pendiente o un fallo de dependencia. Reiniciar infinitamente convierte un error visible en un bucle silencioso que llena los logs y oculta el diagnóstico.

Aplica el cuadro. aurora-db ya está actualizado, así que basta con la política:

docker update --restart unless-stopped --memory-reservation 256m aurora-db

Los otros tres hay que recrearlos, porque aurora-cache cambia también su comando y aurora-api y aurora-web no tenían límites:

docker rm -f aurora-cache aurora-api aurora-web

docker run -d --name aurora-cache --network aurora-net \
  --label proyecto=aurora-libros --label componente=cache \
  --memory 256m --memory-swap 256m --cpus 0.5 --pids-limit 100 \
  --restart unless-stopped \
  redis:7-alpine redis-server --maxmemory 200mb --maxmemory-policy allkeys-lru

docker run -d --name aurora-api --network aurora-net \
  --label proyecto=aurora-libros --label componente=api \
  --env-file ~/aurora-libros/aurora.env \
  -p 3000:3000 \
  --memory 256m --memory-swap 256m --memory-reservation 128m \
  --cpus 1.0 --pids-limit 100 \
  --restart on-failure:3 \
  auroralibros/aurora-api:1.2.0

docker run -d --name aurora-web --network aurora-net \
  --label proyecto=aurora-libros --label componente=web \
  -p 8080:80 \
  --mount type=bind,src=$HOME/aurora-libros/web/index.html,dst=/usr/share/nginx/html/index.html,readonly \
  --mount type=bind,src=$HOME/aurora-libros/web/nginx.conf,dst=/etc/nginx/conf.d/default.conf,readonly \
  --memory 128m --memory-swap 128m --cpus 0.5 --pids-limit 50 \
  --restart unless-stopped --stop-signal SIGQUIT \
  nginx:alpine

Verifica:

sleep 8
docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.PIDs}}"
NAME           MEM USAGE / LIMIT    MEM %   PIDS
aurora-web     3.51MiB / 128MiB     2.74%   3
aurora-api     53.02MiB / 256MiB    20.71%  11
aurora-cache   10.14MiB / 256MiB    3.96%   6
aurora-db      42.11MiB / 512MiB    8.22%   7

Cuatro límites reales en lugar de cuatro veces 7,628 GiB. Y los porcentajes son sanos: entre el 3 % y el 21 %, con margen de sobra para picos.

docker inspect -f '{{.Name}}: mem={{.HostConfig.Memory}} cpus={{.HostConfig.NanoCpus}} pol={{.HostConfig.RestartPolicy.Name}}{{if .HostConfig.RestartPolicy.MaximumRetryCount}}:{{.HostConfig.RestartPolicy.MaximumRetryCount}}{{end}}' \
  aurora-db aurora-cache aurora-api aurora-web
curl -s http://localhost:8080/api/libros | jq -r '.libros | length'
/aurora-db: mem=536870912 cpus=1000000000 pol=unless-stopped
/aurora-cache: mem=268435456 cpus=500000000 pol=unless-stopped
/aurora-api: mem=268435456 cpus=1000000000 pol=on-failure:3
/aurora-web: mem=134217728 cpus=500000000 pol=unless-stopped
9

Los cuatro con sus límites, sus políticas, y los nueve libros seguían ahí después de destruir y recrear tres contenedores. El volumen aurora-datos hizo su trabajo.

Prueba final de resiliencia: mata la API a lo bruto y mira qué pasa.

docker kill aurora-api
sleep 3
docker ps --filter name=aurora-api --format "{{.Names}}: {{.Status}}"
docker inspect -f 'Reinicios: {{.RestartCount}}' aurora-api
curl -s http://localhost:8080/api/libros | jq -r '.libros | length'
aurora-api: Up 2 seconds
Reinicios: 1
9

Se ha levantado sola en menos de tres segundos y la web volvió a funcionar sin que nadie tocara nada. Esa es exactamente la diferencia entre un montón de contenedores y una plataforma.

  1. El guion completo, y por qué no puede seguir así

Recapitula. Esto es todo lo que hace falta escribir, en el orden correcto, para levantar Aurora Libros en una máquina limpia:

#!/usr/bin/env bash
# levantar-aurora.sh — Aurora Libros S.L., plataforma completa
set -euo pipefail

# ---------- 1. Red ----------
docker network create --label proyecto=aurora-libros aurora-net

# ---------- 2. Volumen ----------
docker volume create --label proyecto=aurora-libros aurora-datos

# ---------- 3. Base de datos ----------
docker run -d --name aurora-db --network aurora-net \
  --label proyecto=aurora-libros --label componente=base-de-datos \
  -e POSTGRES_USER=aurora \
  -e POSTGRES_PASSWORD=aurora_secreta \
  -e POSTGRES_DB=aurora_libros \
  -p 127.0.0.1:5432:5432 \
  --mount type=volume,src=aurora-datos,dst=/var/lib/postgresql/data \
  --mount type=bind,src=$HOME/aurora-libros/db/init.sql,dst=/docker-entrypoint-initdb.d/init.sql,readonly \
  --memory 512m --memory-swap 512m --memory-reservation 256m \
  --cpus 1.0 --pids-limit 200 \
  --restart unless-stopped --stop-timeout 30 \
  postgres:16-alpine

# ---------- 4. Esperar a que la base de datos acepte conexiones ----------
until docker exec aurora-db pg_isready -U aurora -q; do sleep 1; done

# ---------- 5. Caché ----------
docker run -d --name aurora-cache --network aurora-net \
  --label proyecto=aurora-libros --label componente=cache \
  --memory 256m --memory-swap 256m --cpus 0.5 --pids-limit 100 \
  --restart unless-stopped \
  redis:7-alpine redis-server --maxmemory 200mb --maxmemory-policy allkeys-lru

# ---------- 6. API ----------
docker run -d --name aurora-api --network aurora-net \
  --label proyecto=aurora-libros --label componente=api \
  --env-file "$HOME/aurora-libros/aurora.env" \
  -p 3000:3000 \
  --memory 256m --memory-swap 256m --memory-reservation 128m \
  --cpus 1.0 --pids-limit 100 \
  --restart on-failure:3 \
  auroralibros/aurora-api:1.2.0

# ---------- 7. Web y proxy inverso ----------
docker run -d --name aurora-web --network aurora-net \
  --label proyecto=aurora-libros --label componente=web \
  -p 8080:80 \
  --mount type=bind,src=$HOME/aurora-libros/web/index.html,dst=/usr/share/nginx/html/index.html,readonly \
  --mount type=bind,src=$HOME/aurora-libros/web/nginx.conf,dst=/etc/nginx/conf.d/default.conf,readonly \
  --memory 128m --memory-swap 128m --cpus 0.5 --pids-limit 50 \
  --restart unless-stopped --stop-signal SIGQUIT \
  nginx:alpine

echo "Aurora Libros levantada: http://localhost:8080"

Mira ese guion con ojos críticos. Funciona, es correcto y contiene todo lo que has aprendido en siete lecciones. Y es un problema:

Defecto Por qué duele
Cincuenta líneas para cuatro servicios Y esto es una aplicación pequeña. Con diez servicios son ciento treinta
El orden es responsabilidad tuya Ese until pg_isready lo has escrito a mano. Si te lo saltas, la API arranca antes que la base de datos
No hay forma de "parar todo" Necesitas otro guion, con los nombres repetidos y el orden inverso
Repetición constante --network aurora-net y las etiquetas aparecen cuatro veces. Cambiar el nombre de la red significa cuatro ediciones
Frágil ante los cambios Añadir una variable a la API obliga a borrar el contenedor y volver a pegar sus catorce líneas exactas, sin equivocarse en el volumen
No es declarativo Describe cómo llegar al estado deseado, no cuál es. Si alguien cambió algo a mano, el guion no lo detecta
Difícil de compartir y revisar ¿Cómo revisas esto en una pull request? ¿Cómo sabes qué cambió respecto a la semana pasada?
Nada garantiza que se ejecutó entero Si falla el paso 5, te quedas con media plataforma en marcha

Y ahora imagina la escena real: entra alguien nuevo en el equipo de Aurora Libros. Le pasas este fichero por chat, y espera que su $HOME tenga la misma estructura, que la imagen 1.2.0 sea la buena, que nadie haya cambiado un valor sin decirlo. Es la versión moderna de aquellos quince pasos manuales de onboarding de la lección 01-07: has reducido el problema muchísimo, pero no lo has eliminado. Lo has convertido en un guion de bash que nadie quiere mantener.

Existe una respuesta a exactamente esto, y es el módulo que viene.

Errores Comunes y Consejos

  • No poner ningún límite "porque la máquina tiene RAM de sobra". La tiene hasta que un contenedor se la come entera y el kernel empieza a matar procesos al azar, incluidos los del sistema.
  • Confundir --memory-swap con "cantidad de swap". Es el total de memoria más swap. -m 512m --memory-swap 512m es lo que desactiva el swap.
  • Interpretar todo 137 como un docker kill. Comprueba .State.OOMKilled antes de sacar conclusiones.
  • Usar --cpu-shares esperando un límite duro. Solo actúa en contención. Para un tope real, --cpus.
  • Poner un límite de memoria sin ajustar el del servicio. Redis sin --maxmemory, Node sin --max-old-space-size o la JVM sin -Xmx chocarán contra el límite del contenedor y morirán de golpe en vez de gestionarlo.
  • Confiar en --restart always como si fuera alta disponibilidad. No detecta un proceso vivo pero colgado, y no arregla un fallo de configuración: solo lo repite más rápido.
  • Esperar que Docker reinicie un contenedor unhealthy. Docker Engine solo lo marca. Actuar es cosa de un orquestador.
  • Usar always en lugar de unless-stopped. Con always, un reinicio del demonio te levanta contenedores que habías parado a propósito.
  • Consejo: mide antes de limitar. Deja el servicio corriendo bajo carga real, mira docker stats, y pon el límite con un margen del doble o el triple del pico observado.
  • Consejo: docker update es tu amigo para ajustar límites y políticas en producción sin recrear nada ni cortar conexiones.

Ejercicios

Ejercicio 1: provoca y diagnostica un OOM

Crea un contenedor comilon con 100 MB de memoria y sin swap que intente ocupar 300 MB en /dev/shm. Después:

  1. Comprueba el estado, el código de salida y el valor de OOMKilled.
  2. Localiza el evento correspondiente con docker events.
  3. Repite el experimento con un límite de 512 MB y comprueba que ahora termina bien.
  4. Repite el primer caso pero con --memory-swap 1g en lugar de sin swap. Explica qué cambia y por qué eso rara vez es una buena solución.

Ejercicio 2: compara --cpus con --cpu-shares

Lanza dos contenedores que consuman CPU con un bucle infinito y mide su reparto con docker stats en tres escenarios:

  1. contenedor-a con --cpus 0.5 y contenedor-b sin ningún límite.
  2. Ambos con --cpu-shares, uno con 1024 y otro con 512.
  3. Solo el de --cpu-shares 512, corriendo en solitario.

Explica los tres resultados y responde: ¿cuál de las dos opciones usarías para garantizar a un cliente que su contenedor nunca superará medio núcleo?, ¿y para repartir una máquina entre un servicio crítico y uno de pruebas aprovechando toda la CPU disponible?

Ejercicio 3: pon a prueba las políticas de reinicio

Diseña un experimento que demuestre la diferencia entre always y unless-stopped sin reiniciar tu máquina (pista: sudo systemctl restart docker reinicia el demonio; en Docker Desktop, usa "Restart" desde el menú). Crea dos contenedores idénticos, uno con cada política, para ambos manualmente y después reinicia el demonio. Anota qué contenedores están corriendo al volver. Finalmente:

  1. Crea un tercer contenedor con --restart on-failure:2 que falle siempre y comprueba RestartCount cuando se rinda.
  2. Con la plataforma de Aurora Libros, provoca que aurora-api quede unhealthy (para aurora-db) y demuestra con comandos que la política de reinicio no hace nada al respecto. Después arregla la situación.

Soluciones

Solución al ejercicio 1

docker run -d --name comilon -m 100m --memory-swap 100m --shm-size 512m alpine:3.20 \
  sh -c 'dd if=/dev/zero of=/dev/shm/relleno bs=1M count=300; echo TERMINADO'
sleep 5
docker inspect -f 'Estado: {{.State.Status}} | Código: {{.State.ExitCode}} | OOMKilled: {{.State.OOMKilled}}' comilon
docker logs comilon
Estado: exited | Código: 137 | OOMKilled: true

Los logs están vacíos: ni siquiera llegó a imprimir "TERMINADO". SIGKILL no da margen para nada.

docker events --since 10m --filter container=comilon
2026-08-04T23:41:02.118 container create 6d2a...  (name=comilon)
2026-08-04T23:41:02.309 container start  6d2a...  (name=comilon)
2026-08-04T23:41:04.771 container oom    6d2a...  (name=comilon)
2026-08-04T23:41:04.883 container die    6d2a...  (exitCode=137, name=comilon)

La secuencia completa en cuatro líneas: creación, arranque, oom y muerte con 137. Ese evento oom es la prueba desde el lado del demonio.

3. Con margen suficiente:

docker rm comilon
docker run --name comilon -m 512m --memory-swap 512m --shm-size 512m alpine:3.20 \
  sh -c 'dd if=/dev/zero of=/dev/shm/relleno bs=1M count=300; echo TERMINADO'
docker inspect -f 'Código: {{.State.ExitCode}} | OOMKilled: {{.State.OOMKilled}}' comilon
300+0 records in
300+0 records out
TERMINADO
Código: 0 | OOMKilled: false

4. Con swap:

docker rm comilon
docker run --name comilon -m 100m --memory-swap 1g --shm-size 512m alpine:3.20 \
  sh -c 'time dd if=/dev/zero of=/dev/shm/relleno bs=1M count=300; echo TERMINADO'
docker inspect -f 'Código: {{.State.ExitCode}} | OOMKilled: {{.State.OOMKilled}}' comilon
docker rm comilon
300+0 records in
300+0 records out
real    0m 11.84s
TERMINADO
Código: 0 | OOMKilled: false

Ahora sobrevive, porque tiene 100 MB de RAM más 924 MB de swap. Pero mira el tiempo: 11,84 segundos frente a menos de uno con memoria real. Ese es el motivo por el que el swap rara vez es la respuesta: el contenedor no muere, pero funciona entre diez y cien veces más lento. En un servicio con usuarios esperando, eso es indistinguible de una caída, con el agravante de que ninguna alarma se dispara: no hay OOM, no hay reinicio, no hay código 137. Solo un servicio insoportablemente lento que nadie sabe explicar. Es preferible un fallo rápido y ruidoso.

Solución al ejercicio 2

docker run -d --name cpu-a --cpus 0.5 alpine:3.20 sh -c 'while true; do :; done'
docker run -d --name cpu-b alpine:3.20 sh -c 'while true; do :; done'
sleep 5
docker stats --no-stream cpu-a cpu-b --format "{{.Name}}: {{.CPUPerc}}"
docker rm -f cpu-a cpu-b
cpu-a: 49.94%
cpu-b: 99.87%

1. cpu-a respeta su medio núcleo aunque la máquina tenga capacidad de sobra; cpu-b, sin límite, ocupa un núcleo entero. El límite duro no cede aunque sobre CPU.

docker run -d --name cpu-alto --cpu-shares 1024 alpine:3.20 sh -c 'while true; do :; done'
docker run -d --name cpu-bajo --cpu-shares 512  alpine:3.20 sh -c 'while true; do :; done'
sleep 5
docker stats --no-stream cpu-alto cpu-bajo --format "{{.Name}}: {{.CPUPerc}}"
docker rm -f cpu-alto
sleep 5
docker stats --no-stream cpu-bajo --format "{{.Name}}: {{.CPUPerc}}"
docker rm -f cpu-bajo
cpu-alto: 265.31%
cpu-bajo: 132.44%
cpu-bajo: 398.72%

2. La proporción es 2:1, exactamente 1024 frente a 512. 3. Y en cuanto cpu-alto desaparece, cpu-bajo salta al 398 %: casi cuatro núcleos. Los shares nunca desperdician capacidad ociosa.

Las dos respuestas:

  • Para garantizar a un cliente que nunca superará medio núcleo: --cpus 0.5. Es un techo absoluto, verificable e independiente de lo que hagan los demás. Es lo que se factura y lo que se puede prometer por contrato.
  • Para repartir una máquina entre un servicio crítico y uno de pruebas: --cpu-shares, por ejemplo 1024 y 256. Cuando ambos compiten, el crítico se lleva cuatro veces más; cuando el crítico está ocioso, el de pruebas aprovecha toda la máquina en vez de dejarla parada. Es un reparto de prioridades, no un techo.

Solución al ejercicio 3

docker run -d --name pol-always        --restart always         alpine:3.20 sleep 3600
docker run -d --name pol-unless-stopped --restart unless-stopped alpine:3.20 sleep 3600

docker stop pol-always pol-unless-stopped
docker ps -a --filter name=pol- --format "{{.Names}}: {{.Status}}"
pol-always: Exited (137) 2 seconds ago
pol-unless-stopped: Exited (137) 2 seconds ago

Los dos parados por decisión tuya. Ahora reinicia el demonio:

sudo systemctl restart docker
sleep 10
docker ps -a --filter name=pol- --format "table {{.Names}}\t{{.Status}}"
NAMES                 STATUS
pol-always            Up 8 seconds
pol-unless-stopped    Exited (137) 45 seconds ago

La diferencia, en dos líneas. always ignoró tu parada manual y volvió a arrancar el contenedor; unless-stopped respetó tu decisión y lo dejó parado. Es exactamente el escenario del apartado 9: si hubieras parado aurora-api para investigar un incidente, con always te la habrías encontrado corriendo otra vez al volver.

docker rm -f pol-always pol-unless-stopped

1. El contador de reintentos:

docker run -d --name pol-fallo --restart on-failure:2 alpine:3.20 sh -c 'sleep 1; exit 7'
sleep 12
docker inspect -f 'Estado: {{.State.Status}} | Código: {{.State.ExitCode}} | RestartCount: {{.RestartCount}}' pol-fallo
docker rm -f pol-fallo
Estado: exited | Código: 7 | RestartCount: 2

RestartCount: 2: el arranque original más dos reintentos, y Docker se rinde conservando el código real del fallo (7), no un genérico. Ese código es lo que te permite diagnosticar.

2. La política no reacciona a la salud:

docker stop aurora-db
sleep 45
docker ps --filter name=aurora-api --format "{{.Names}}: {{.Status}}"
docker inspect -f 'Salud: {{.State.Health.Status}} | Reinicios: {{.RestartCount}} | Política: {{.HostConfig.RestartPolicy.Name}}' aurora-api
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:8080/api/libros
aurora-api: Up 12 minutes (unhealthy)
Salud: unhealthy | Reinicios: 1 | Política: on-failure:3
HTTP 500

La demostración es rotunda: el contenedor lleva doce minutos unhealthy, la API devuelve 500 a los usuarios, y RestartCount sigue valiendo 1 —el reinicio del docker kill de la práctica, no uno nuevo—. La política de reinicio no ha movido un dedo, porque el proceso node nunca murió. Solo perdió su base de datos.

docker start aurora-db
sleep 40
docker ps --filter name=aurora-api --format "{{.Names}}: {{.Status}}"
curl -s http://localhost:8080/api/libros | jq -r '.libros | length'
aurora-api: Up 25 minutes (healthy)
9

Y en cuanto vuelve aurora-db, el healthcheck se recupera solo: /salud vuelve a devolver 200, el estado pasa a healthy y los nueve libros regresan. Sin reiniciar la API, porque nunca hizo falta. Fíjate en dos cosas: la API sobrevivió a la caída de su base de datos gracias al cambio que le hiciste en la lección 03-02, y el diagnóstico correcto —unhealthy sostenido con RestartCount congelado— es la firma inconfundible de "el proceso está bien, sus dependencias no".

Conclusión

El módulo está cerrado y la plataforma es, por fin, algo que se puede defender. Sabes que por defecto un contenedor puede consumir toda la RAM y toda la CPU del host, y sabes ponerle cerco: -m como límite duro, --memory-reservation como límite blando que decide quién cede memoria bajo presión, y --memory-swap con la trampa que confunde a todo el mundo —es el total, y con el mismo valor que --memory se desactiva el swap, que es casi siempre lo correcto—. Has provocado un OOM a propósito y lo has diagnosticado con la firma que no admite dudas: OOMKilled: true, código 137 y un evento oom en el demonio, con los logs cortados a media frase porque SIGKILL no se puede capturar.

Distingues las tres formas de repartir CPU: --cpus como techo absoluto y predecible, --cpu-shares como peso relativo que solo actúa en contención y nunca desperdicia capacidad ociosa, y --cpuset-cpus para fijar núcleos concretos. Sabes frenar la E/S de disco con --device-write-bps y, sobre todo, sabes que --pids-limit es la defensa más barata que existe contra una fork bomb que, sin él, se lleva por delante la tabla de procesos del host entero. Y sabes verificarlo todo con docker stats e inspect, y cambiarlo en caliente con docker update sin recrear ni un contenedor, porque los recursos viven en los cgroups y no en los namespaces.

Conoces las cuatro políticas de reinicio y la diferencia que solo se aprecia al reiniciar el demonio: always ignora tu parada manual y unless-stopped la respeta. Sabes leer RestartCount, reconocer el backoff exponencial y detectar un contenedor que lleva 847 reinicios sin que nadie se haya enterado. Y te llevas la distinción que más confusión evita: una política de reinicio te devuelve un proceso muerto, un healthcheck detecta un proceso vivo pero inútil, y Docker Engine no reinicia nada por estar unhealthy; para eso hace falta un orquestador. Aurora Libros lo tiene todo aplicado con criterio: aurora-db con 512 MB y unless-stopped, aurora-cache con 256 MB y un --maxmemory 200mb de Redis alineado por debajo para que expulse claves en vez de morir, aurora-api con 256 MB y on-failure:3 para que un error de configuración no se esconda en un bucle infinito, y aurora-web con 128 MB de sobra. Un docker kill aurora-api y el servicio volvió solo en menos de tres segundos.

Y sin embargo, mira otra vez ese guion del apartado 12. Cincuenta líneas de bash para cuatro contenedores: una red, un volumen, cuatro docker run de catorce líneas cada uno, un bucle de espera escrito a mano para que la API no arranque antes que PostgreSQL, --network aurora-net repetido cuatro veces, ningún comando para parar la plataforma entera y ninguna forma de saber si lo que está corriendo se corresponde con lo que dice el fichero. Añadir una variable de entorno a la API significa borrar el contenedor y volver a pegar sus catorce líneas exactas sin equivocarse en el volumen. Es correcto, es frágil, y es imposible de revisar en una pull request. Has recorrido un camino enorme desde los quince pasos manuales de la lección 01-07, pero el último tramo sigue siendo un guion que nadie quiere mantener.

Ese es exactamente el problema que resuelve el módulo 4, Docker Compose. Esas cincuenta líneas de comandos imperativos se convierten en un único fichero compose.yaml de unas cuarenta líneas declarativas, versionado en Git junto al código, que describe cuál es el estado deseado de la plataforma —cuatro servicios con sus imágenes, variables, redes, volúmenes, límites, dependencias y políticas— en lugar de los pasos para llegar a él. La red y el volumen se crean solos; el orden de arranque se declara con depends_on y su condición de salud en vez de con un bucle until; y toda la plataforma se levanta con docker compose up -d y se para con docker compose down. Un solo comando, un solo fichero, y un compañero nuevo que clona el repositorio y tiene Aurora Libros funcionando en su máquina en menos de un minuto. Los quince pasos de onboarding pasarán a ser uno.

Docker: De Principiante a Avanzado

Módulo 1: Introducción a Docker

Módulo 2: Trabajando con Imágenes Docker

Módulo 3: Contenedores Docker

Módulo 4: Docker Compose

Módulo 5: Conceptos Avanzados de Docker

Módulo 6: Docker en Producción

Módulo 7: Ecosistema y Herramientas de Docker

© Copyright 2026. Todos los derechos reservados