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
- Por qué un contenedor sin límites es un problema
- Límites de memoria
- El OOM killer, provocado y diagnosticado
- Límites de CPU
- Límites de E/S de disco
--pids-limit: defensa ante una fork bomb- Verificar y modificar en caliente
- Políticas de reinicio
alwaysfrente aunless-stopped- Por qué una política de reinicio no sustituye a un healthcheck
- Práctica final: el cuadro de límites de Aurora Libros
- El guion completo, y por qué no puede seguir así
- Por qué un contenedor sin límites es un problema
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% 7Los 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.
- 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-demoAhora 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-demoLa 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
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).
- 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}}"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-demoOOMKilled: 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:
Y en el host, el kernel deja su propia constancia:
[189234.117] Memory cgroup out of memory: Killed process 41207 (dd) total-vm:213504kB, anon-rss:65112kBCó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 |
- 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-limitadoUn 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-bajoLa 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 | Sí | 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 fijadoEl 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.
- 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=directExactamente 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—.
--pids-limit: defensa ante una fork bomb
--pids-limit: defensa ante una fork bombLimita 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 pudoA 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-demoValores 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.
- 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-apiUn 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}}"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.
- 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 |
Sí, indefinidamente | No | Sí, si había fallado | No |
on-failure:N |
Sí, hasta N veces | No | Sí | No |
always |
Sí | Sí | Sí, siempre | Sí, al reiniciar el demonio |
unless-stopped |
Sí | Sí | 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-demoNAMES STATUS
reinicio-demo Exited (1) 3 seconds ago
Reinicios: 3 | Estado: exited | Código: 1
intento
intento
intento
intentoCuatro "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í:
Un RestartCount de 847 es un servicio que lleva horas fallando y nadie se ha enterado.
always frente a unless-stopped
always frente a unless-stoppedLas 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-stoppedpara servicios de larga duración yon-failure:Npara procesos que deben terminar.alwayssolo 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,--restartes incompatible: Docker lo rechaza, porque no puede a la vez borrar el contenedor y reiniciarlo.
- 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 colgadoNginx 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 | Sí | Sí |
| El proceso sale con error | Sí | Sí |
| El proceso vive pero está bloqueado | No | Sí |
| Pool de conexiones agotado | No | Sí |
| Devuelve 500 a todas las peticiones | No | Sí |
| Ha perdido la base de datos | No | Sí |
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.
- 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:
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% 7El 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:
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:alpineVerifica:
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% 7Cuatro 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
9Los 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'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.
- 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-swapcon "cantidad de swap". Es el total de memoria más swap.-m 512m --memory-swap 512mes lo que desactiva el swap. - Interpretar todo 137 como un
docker kill. Comprueba.State.OOMKilledantes de sacar conclusiones. - Usar
--cpu-sharesesperando 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-sizeo la JVM sin-Xmxchocarán contra el límite del contenedor y morirán de golpe en vez de gestionarlo. - Confiar en
--restart alwayscomo 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
alwaysen lugar deunless-stopped. Conalways, 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 updatees 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:
- Comprueba el estado, el código de salida y el valor de
OOMKilled. - Localiza el evento correspondiente con
docker events. - Repite el experimento con un límite de 512 MB y comprueba que ahora termina bien.
- Repite el primer caso pero con
--memory-swap 1gen 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:
contenedor-acon--cpus 0.5ycontenedor-bsin ningún límite.- Ambos con
--cpu-shares, uno con 1024 y otro con 512. - 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:
- Crea un tercer contenedor con
--restart on-failure:2que falle siempre y compruebaRestartCountcuando se rinda. - Con la plataforma de Aurora Libros, provoca que
aurora-apiquedeunhealthy(paraaurora-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 comilonLos logs están vacíos: ni siquiera llegó a imprimir "TERMINADO". SIGKILL no da margen para nada.
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}}' comilon4. 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 comilonAhora 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-b1. 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-bajo2. 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}}"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}}"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.
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-falloRestartCount: 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/librosaurora-api: Up 12 minutes (unhealthy)
Salud: unhealthy | Reinicios: 1 | Política: on-failure:3
HTTP 500La 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'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
- ¿Qué es Docker?
- Instalando Docker
- Arquitectura de Docker
- Comandos Básicos de Docker
- Entendiendo las Imágenes de Docker
- Creando tu Primer Contenedor Docker
- El Proyecto del Curso: la Plataforma Aurora Libros
Módulo 2: Trabajando con Imágenes Docker
- Docker Hub y Repositorios
- Construyendo Imágenes Docker
- Conceptos Básicos de Dockerfile
- Instrucciones Avanzadas del Dockerfile
- Gestionando Imágenes Docker
- Etiquetado y Publicación de Imágenes
Módulo 3: Contenedores Docker
- Ejecutando Contenedores
- Ciclo de Vida del Contenedor
- Gestionando Contenedores
- Inspección y Depuración de Contenedores
- Redes en Docker
- Persistencia de Datos con Volúmenes
- Límites de Recursos y Políticas de Reinicio
Módulo 4: Docker Compose
- Introducción a Docker Compose
- Definiendo Servicios en Docker Compose
- Comandos de Docker Compose
- Aplicaciones Multi-Contenedor
- Variables de Entorno en Docker Compose
- Perfiles, Overrides y Múltiples Entornos
- Desarrollo Local con Docker Compose
Módulo 5: Conceptos Avanzados de Docker
- Profundización en Redes Docker
- Opciones de Almacenamiento Docker
- Mejores Prácticas de Seguridad en Docker
- Optimizando Imágenes Docker
- Builds Avanzadas con BuildKit y Buildx
- Registro y Monitoreo en Docker
- El Runtime por Dentro: Namespaces, Cgroups y Capas
Módulo 6: Docker en Producción
- Preparar una Imagen para Producción
- CI/CD con Docker
- Orquestando Contenedores con Docker Swarm
- Introducción a Kubernetes
- Desplegando Contenedores Docker en Kubernetes
- Escalado y Balanceo de Carga
- Estrategias de Despliegue y Rollback
