Hasta ahora has visto contenedores que funcionan y contenedores que fallan de forma evidente. En el trabajo real, la situación más frecuente es la tercera: un contenedor que no funciona y no dice por qué. Está Up 3 hours pero no responde. Sale con código 1 y desaparece antes de que puedas mirarlo. Se marca unhealthy sin más explicación. Y tú, con veinte pestañas abiertas, probando cosas al azar.
Esta lección te da un método y las cinco herramientas que lo sostienen: docker logs, docker exec, docker inspect, docker stats y docker events. Vas a aprender a leerlas a fondo, a entrar en imágenes mínimas que no tienen ni ping, y a extraer del JSON de inspect exactamente el dato que necesitas. Y lo vas a aplicar a tres averías reales de Aurora Libros, entre ellas la que lleva tres lecciones esperándote: por qué aurora-api no encuentra a aurora-db aunque los dos estén corriendo en la misma máquina. Al final de esta lección sabrás la respuesta exacta.
Contenido
- Un método antes que un comando
docker logsa fondo- La regla de oro: stdout y stderr
docker exec: entrar en el contenedor- Depurar imágenes mínimas sin herramientas
docker inspect: el JSON completo del contenedordocker top: los procesos de dentrodocker stats: consumo en tiempo realdocker events: el flujo del demonio- Caso A: el contenedor sale con código 1
- Caso B:
ECONNREFUSEDen/libros - Caso C: el healthcheck en
unhealthy
- Un método antes que un comando
La diferencia entre alguien que depura rápido y alguien que da palos de ciego no son los comandos, sino el orden en que se hacen las preguntas:
flowchart TD
A["Algo no funciona"] --> B{"¿Está corriendo?<br/>docker ps -a"}
B -- "No aparece" --> B1["Nunca se creó:<br/>revisa el docker run (código 125)"]
B -- "Created" --> B2["El ejecutable no existe<br/>o no se pudo ejecutar (126/127)"]
B -- "Exited" --> C{"¿Qué código<br/>de salida?"}
B -- "Restarting" --> R["Bucle de reinicios:<br/>logs + política (03-07)"]
B -- "Up" --> D{"¿Está healthy?"}
C -- "1-124" --> L["docker logs:<br/>falló TU aplicación"]
C -- "137 / 139 / 143" --> S["docker inspect:<br/>señal, OOMKilled, Error"]
D -- "unhealthy" --> H["docker inspect<br/>.State.Health.Log"]
D -- "healthy / sin check" --> E{"¿Los logs<br/>dicen algo?"}
E -- "Sí" --> L
E -- "No" --> F{"¿Cómo está<br/>configurado?"}
F --> G["docker inspect:<br/>entorno, red, montajes, puertos"]
G --> I["docker exec:<br/>mirar desde dentro"]
I --> J["Contenedor efímero con netshoot<br/>si faltan herramientas"]
Las cuatro preguntas, en este orden y sin saltarse ninguna:
| # | Pregunta | Herramienta | Qué descarta |
|---|---|---|---|
| 1 | ¿Está corriendo? | docker ps -a |
Distingue "no arrancó" de "arrancó y falló" |
| 2 | ¿Qué dicen los logs? | docker logs |
Resuelve la mayoría de los casos en 10 segundos |
| 3 | ¿Cómo está configurado? | docker inspect |
Variables, red, montajes, puertos: lo que creías haber puesto |
| 4 | ¿Qué pasa dentro? | docker exec, top, stats |
Lo que solo se ve desde dentro |
El error más común es empezar por la 4 —entrar al contenedor a mirar— cuando la respuesta estaba en la 2. Y el segundo error más común es no hacer nunca la 3, quedándose horas convencido de haber pasado una variable de entorno que en realidad tenía una errata.
docker logs a fondo
docker logs a fondoPostgreSQL init process complete; ready for start up.
2026-08-04 19:28:43.117 UTC [1] LOG: starting PostgreSQL 16.4 on x86_64-pc-linux-musl
2026-08-04 19:28:43.121 UTC [1] LOG: listening on IPv4 address "0.0.0.0", port 5432
2026-08-04 19:28:43.198 UTC [1] LOG: database system is ready to accept connectionsLas opciones que se usan de verdad:
| Opción | Qué hace | Ejemplo |
|---|---|---|
-f, --follow |
Sigue la salida en directo | docker logs -f aurora-api |
--tail N |
Solo las últimas N líneas | docker logs --tail 50 aurora-db |
-t, --timestamps |
Añade marca de tiempo a cada línea | docker logs -t aurora-db |
--since |
Desde un momento dado | --since 10m, --since 2026-08-04T19:30:00 |
--until |
Hasta un momento dado | --until 5m |
--details |
Muestra metadatos extra | Poco usado |
Combinaciones que resuelven problemas reales:
# Las 20 últimas líneas y seguir en directo: lo más usado del día a día
docker logs -f --tail 20 aurora-db
# Qué pasó en los últimos 5 minutos, con hora exacta
docker logs -t --since 5m aurora-db
# Acotar una ventana concreta alrededor de un incidente
docker logs -t --since 2026-08-04T19:28:00 --until 2026-08-04T19:29:00 aurora-db
# Buscar errores en todo el histórico
docker logs aurora-db 2>&1 | grep -i "error\|fatal"Tres detalles importantes:
docker logsfunciona con el contenedor parado. Los logs viven en el host y sobreviven adocker stop. Solo desaparecen condocker rm. Es la razón por la que--rmes tu enemigo mientras depuras.--tailsin-fes la forma más rápida de ver el final de un log de 100 000 líneas sin volcarlo entero a la terminal.- Con
-f,Ctrl+Csolo interrumpe el visor: no toca el contenedor. A diferencia dedocker attach, aquíCtrl+Ces completamente seguro.
- La regla de oro: stdout y stderr
docker logs muestra exactamente dos cosas: la salida estándar y la salida de error del PID 1. Ni una más.
docker run --rm --name demo-flujos alpine:3.20 \
sh -c 'echo "esto va a stdout"; echo "esto va a stderr" >&2; echo "esto va a un fichero" > /tmp/oculto.log'La tercera línea no aparece por ningún sitio, porque se escribió en un fichero dentro del contenedor. Puedes separar los dos flujos con la redirección estándar del shell:
docker run --name demo-flujos2 alpine:3.20 \
sh -c 'echo "salida normal"; echo "un error" >&2'
docker logs demo-flujos2 2>/dev/null # solo stdout
docker logs demo-flujos2 1>/dev/null # solo stderr
docker rm demo-flujos2De ahí sale la regla que gobierna toda la operación de contenedores:
Una aplicación en un contenedor escribe sus logs en la salida estándar y en la salida de error. Nunca en un fichero.
Los motivos:
| Si logea a stdout/stderr | Si logea a un fichero |
|---|---|
docker logs funciona |
docker logs está vacío y parece que la app no hace nada |
| Los logs se recogen automáticamente | Hay que entrar con exec o sacarlos con docker cp |
| El fichero no crece dentro de la capa de escritura | El contenedor engorda hasta llenar el disco del host |
| Cualquier agregador (Loki, ELK, CloudWatch) los recoge sin configurar nada | Hay que montar volúmenes y desplegar agentes |
| Se borran solos con el contenedor | Quedan huérfanos |
Tu server.js ya lo hace bien desde la lección 01-07: usa console.log y console.error, que en Node escriben en stdout y stderr respectivamente.
Y una comprobación práctica cuando docker logs está sospechosamente vacío:
lrwx------ 1 root root 64 Aug 4 19:28 /proc/1/fd/1 -> /dev/pts/0
lrwx------ 1 root root 64 Aug 4 19:28 /proc/1/fd/2 -> /dev/pts/0Si esos descriptores apuntaran a un fichero en vez de a la consola, ahí tendrías tu explicación.
Un apunte de alcance: dónde se guardan realmente esos logs, cómo cambiar el driver de logging, cómo rotarlos y cómo enviarlos a un sistema centralizado es el contenido de la lección 05-06. Aquí nos quedamos en leerlos.
docker exec: entrar en el contenedor
docker exec: entrar en el contenedor/ # psql -U aurora -d aurora_libros -c "SELECT COUNT(*) FROM libros;"
count
-------
8
(1 row)
/ # exitYa sabes de la lección 03-01 que exec lanza un proceso nuevo dentro de los namespaces del contenedor, y que por eso salir de él no afecta al servicio. Sus opciones:
| Opción | Para qué |
|---|---|
-it |
Sesión interactiva con terminal |
-u, --user |
Ejecutar como otro usuario, típicamente -u root |
-w, --workdir |
Empezar en otro directorio |
-e |
Añadir una variable solo para este proceso |
-d |
Lanzarlo en segundo plano dentro del contenedor |
--privileged |
Con capacidades extendidas (último recurso) |
Qué shell usar
| Imagen base | Shell disponible | Comando |
|---|---|---|
Alpine (alpine, node:22-alpine, redis:7-alpine) |
sh (BusyBox ash). No hay bash |
docker exec -it X sh |
Debian/Ubuntu (node:22, postgres:16, ubuntu) |
bash y sh |
docker exec -it X bash |
distroless, scratch |
Ninguno | Ver el apartado 5 |
Si te equivocas, el error es inconfundible:
OCI runtime exec failed: exec failed: unable to start container process:
exec: "bash": executable file not found in $PATH: unknownUn truco que funciona casi siempre:
Entrar como root en una imagen sin privilegios
Tu aurora-api corre con USER node desde la lección 02-04. Eso está muy bien para producción y es un fastidio para depurar:
docker run -d --name api-depurar --env-file ~/aurora-libros/aurora.env auroralibros/aurora-api:1.2.0
docker exec -it api-depurar id
docker exec -it -u root api-depurar id
docker exec -it -u root api-depurar sh -c 'apk add --no-cache curl && curl -s localhost:3000/salud'uid=1000(node) gid=1000(node) groups=1000(node)
uid=0(root) gid=0(root) groups=0(root),1(bin),...
{"servicio":"aurora-api","version":"1.0.0","db":"ko","cache":"ko","errorDb":"getaddrinfo ENOTFOUND aurora-db","errorCache":"The client is closed"}Tres cosas que aprender de esta salida:
-u rootte da privilegios dentro del contenedor aunque la imagen declare otro usuario. La restricción deUSERprotege del código que corre dentro, no de quien controla el demonio de Docker.- Instalar herramientas con
apk adddentro de un contenedor en marcha es legítimo para depurar y absolutamente prohibido como forma de "arreglar" nada: en cuanto recrees el contenedor, esecurldesaparece. Lo que se arregla, se arregla en el Dockerfile. - Y ahí está el diagnóstico servido en bandeja:
errorDb: getaddrinfo ENOTFOUND aurora-db. El endpoint/saludque escribiste en la lección 01-07 está haciendo justo el trabajo para el que lo diseñaste.
Comandos de diagnóstico dentro del contenedor
docker exec api-depurar printenv | sort | head -8 # ¿qué configuración ve de verdad?
docker exec api-depurar ps -o pid,comm # ¿qué procesos hay?
docker exec api-depurar cat /etc/hosts # ¿qué nombres conoce?
docker exec api-depurar cat /etc/resolv.conf # ¿a qué DNS pregunta?
docker exec api-depurar getent hosts aurora-db # ¿resuelve este nombre?
docker exec api-depurar df -h / # ¿queda espacio?
docker exec api-depurar ls -la /app # ¿está el código donde creo?DB_HOST=aurora-db
DB_NAME=aurora_libros
DB_PASSWORD=aurora_secreta
DB_PORT=5432
DB_USER=aurora
HOME=/home/node
HOSTNAME=4c8f2a1e9b73
NODE_ENV=production
PID COMMAND
1 node
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
172.17.0.4 4c8f2a1e9b73
nameserver 127.0.0.11
options ndots:0Fíjate en /etc/hosts: hay tres entradas y ninguna se llama aurora-db. El comando getent hosts aurora-db ni siquiera imprimió nada (devolvió código 2, "no encontrado"). Guarda ese dato: es la mitad de la respuesta al caso B.
- Depurar imágenes mínimas sin herramientas
Una imagen bien optimizada no trae curl, ni ping, ni netstat, ni dig. Y las imágenes distroless o construidas FROM scratch no traen ni siquiera un shell. Eso es excelente para la seguridad y el tamaño (lección 05-04) y desesperante cuando algo falla.
La solución moderna: un contenedor efímero cargado de herramientas que comparte los namespaces del contenedor enfermo.
docker run --rm -it \
--network container:api-depurar \
--pid container:api-depurar \
nicolaka/netshootLo que hace cada opción:
| Opción | Efecto |
|---|---|
--network container:X |
El contenedor nuevo comparte la pila de red de X: mismo localhost, misma IP, mismas interfaces |
--pid container:X |
Comparte el espacio de procesos: ves los procesos de X y puedes examinarlos |
nicolaka/netshoot |
Una imagen con curl, dig, nmap, tcpdump, netstat, ss, iperf, jq y decenas más |
Y ahora, desde dentro de ese contenedor, diagnosticas la red de api-depurar como si estuvieras en él:
~ ❯ ss -tlnp
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 511 0.0.0.0:3000 0.0.0.0:*
~ ❯ ps -ef
PID USER TIME COMMAND
1 1000 0:00 node server.js
28 root 0:00 zsh
~ ❯ nslookup aurora-db
Server: 127.0.0.11
Address: 127.0.0.11:53
** server can't find aurora-db: NXDOMAIN
~ ❯ curl -s localhost:3000/salud | jq -r .errorDb
getaddrinfo ENOTFOUND aurora-dbCuatro conclusiones en cuatro comandos, sin instalar nada en la imagen de producción:
ss -tlnpconfirma que la API sí escucha en el 3000, en0.0.0.0(bien: no en127.0.0.1, que sería inalcanzable desde fuera).ps -efmuestra elnode server.jsdel contenedor vecino gracias a--pid container:, y además que corre con UID 1000.nslookup aurora-dbresponde NXDOMAIN: el servidor DNS interno de Docker (127.0.0.11) existe y responde, pero no conoce ese nombre.curlfunciona aunque la imagen de la API no tengacurl, porque lo aporta netshoot.
Ese NXDOMAIN es la prueba definitiva. Volveremos a ella en el caso B.
docker debug
Docker Desktop incluye una versión integrada de esta idea:
Abre una shell con un conjunto de herramientas montado sobre el contenedor sin modificarlo: funciona incluso en imágenes distroless sin shell, y al salir no queda ni rastro. Requiere una suscripción Pro o superior; el truco de netshoot es gratuito, funciona en cualquier Docker y conviene conocerlo igualmente.
docker inspect: el JSON completo del contenedor
docker inspect: el JSON completo del contenedordocker inspect devuelve todo lo que Docker sabe de un contenedor: unas 200 líneas de JSON.
[
"AppArmorProfile", "Args", "Config", "Created", "Driver", "ExecIDs",
"GraphDriver", "HostConfig", "HostnamePath", "HostsPath", "Id", "Image",
"LogPath", "MountLabel", "Mounts", "Name", "NetworkSettings", "Path",
"Platform", "ProcessLabel", "ResolvConfPath", "RestartCount", "State"
]Los seis bloques que importan:
| Bloque | Contiene |
|---|---|
.State |
Estado, PID, códigos de salida, OOMKilled, salud |
.Config |
Lo que viene de la imagen: Env, Cmd, Entrypoint, Labels, Healthcheck, User |
.HostConfig |
Lo que pusiste tú en docker run: puertos, memoria, CPU, política de reinicio |
.NetworkSettings |
IPs, redes, puertos publicados, gateway |
.Mounts |
Volúmenes y bind mounts activos |
.RestartCount |
Cuántas veces lo ha reiniciado Docker (lección 03-07) |
Leer 200 líneas de JSON no es depurar. Lo que se hace es extraer el dato concreto, con --format o con jq:
| Necesitas saber | Comando |
|---|---|
| Estado y código de salida | docker inspect -f '{{.State.Status}} ({{.State.ExitCode}})' X |
| La IP del contenedor | docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' X |
| A qué redes está conectado | docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' X |
| Puertos publicados | docker inspect -f '{{json .NetworkSettings.Ports}}' X |
| Variables de entorno | docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' X |
| Una variable concreta | docker inspect -f '{{json .Config.Env}}' X | jq -r '.[]|select(startswith("DB_HOST"))' |
| Montajes | docker inspect -f '{{range .Mounts}}{{.Type}}: {{.Source}} -> {{.Destination}}{{println}}{{end}}' X |
| Comando efectivo | docker inspect -f '{{.Path}} {{.Args}}' X |
| Estado de salud | docker inspect -f '{{.State.Health.Status}}' X |
| Último fallo del healthcheck | docker inspect -f '{{(index .State.Health.Log 0).Output}}' X |
| ¿Lo mató el OOM killer? | docker inspect -f '{{.State.OOMKilled}}' X |
| Número de reinicios | docker inspect -f '{{.RestartCount}}' X |
| Límite de memoria | docker inspect -f '{{.HostConfig.Memory}}' X |
| Etiquetas | docker inspect -f '{{json .Config.Labels}}' X | jq |
Un ejemplo completo sobre la flota:
docker inspect -f '{{.Name}} | {{.State.Status}} | {{range $k,$v := .NetworkSettings.Networks}}{{$k}}={{$v.IPAddress}} {{end}}' aurora-db aurora-cacheDos observaciones que serán decisivas dentro de cinco minutos: ambos están en la red bridge y cada uno tiene su propia IP.
Con jq puedes hacer consultas más ricas sobre el JSON crudo:
docker inspect aurora-db | jq -r '.[0].Config.Env[] | select(startswith("POSTGRES"))'
docker inspect aurora-db | jq -r '.[0].Mounts[] | "\(.Type): \(.Destination)"'POSTGRES_USER=aurora
POSTGRES_PASSWORD=aurora_secreta
POSTGRES_DB=aurora_libros
volume: /var/lib/postgresql/dataNota de seguridad, y es seria: docker inspect muestra las contraseñas en claro. Cualquiera con acceso al demonio de Docker puede leer todas las variables de entorno de todos los contenedores. Por eso las variables de entorno no son un mecanismo de secretos; lo son los gestores de secretos, que se ven en la lección 05-03.
docker top: los procesos de dentro
docker top: los procesos de dentroUID PID PPID C STIME TTY TIME CMD
70 24817 24795 0 19:28 ? 00:00:00 postgres
70 24893 24817 0 19:28 ? 00:00:00 postgres: checkpointer
70 24894 24817 0 19:28 ? 00:00:00 postgres: background writer
70 24896 24817 0 19:28 ? 00:00:00 postgres: walwriter
70 24897 24817 0 19:28 ? 00:00:00 postgres: autovacuum launcherDos particularidades que lo hacen especial:
- Los PID son los del host, no los de dentro del contenedor.
docker exec aurora-db psmostraría el mismopostgrescomo PID 1; aquí es el 24817. Es la doble numeración de los namespaces de PID. - No necesita que la imagen tenga
ps. El comando lo ejecuta el demonio en el host. Por eso funciona incluso en imágenesdistroless.
Sirve para responder rápido a: ¿cuántos procesos hay?, ¿hay procesos zombis?, ¿está la aplicación lanzando hijos que no debería?
docker stats: consumo en tiempo real
docker stats: consumo en tiempo realCONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
3f8a1c9e7b2d aurora-db 0.02% 38.41MiB / 7.628GiB 0.49% 1.24kB / 0B 12.3MB / 8.19MB 7
9d4b7e2f1a6c aurora-cache 0.15% 9.83MiB / 7.628GiB 0.13% 1.86kB / 1.02kB 0B / 0B 6Sin --no-stream, la vista se refresca continuamente como un top. Columna a columna:
| Columna | Qué mide | Cómo interpretarla |
|---|---|---|
| CPU % | Porcentaje de CPU | Puede superar el 100 %: 400 % = cuatro núcleos saturados |
| MEM USAGE / LIMIT | Memoria usada / límite | Si no pusiste límite, LIMIT es toda la RAM del host. Cuidado |
| MEM % | Uso respecto al límite | Sostenido cerca del 100 % = candidato a que lo mate el OOM killer |
| NET I/O | Recibido / enviado por la red | Un 0B en un servicio web indica que nadie le está hablando |
| BLOCK I/O | Leído / escrito en disco | Un valor que crece sin parar puede ser un log descontrolado |
| PIDS | Número de procesos e hilos | Si crece sin parar, hay una fuga de procesos |
Ese LIMIT de 7,628 GiB en las dos filas es la señal de alarma que resolverás en la lección 03-07: ninguno de los dos contenedores tiene límite de memoria, así que cualquiera de ellos puede consumir toda la RAM de la máquina.
Formatos útiles:
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}"
docker stats --no-stream $(docker ps -q --filter label=proyecto=aurora-libros)
docker events: el flujo del demonio
docker events: el flujo del demonio2026-08-04T19:28:41.882 container create 3f8a1c9e... (image=postgres:16-alpine, name=aurora-db)
2026-08-04T19:28:41.913 network connect 6b2f... (container=3f8a1c9e..., name=bridge, type=bridge)
2026-08-04T19:28:42.104 container start 3f8a1c9e... (image=postgres:16-alpine, name=aurora-db)
2026-08-04T19:41:07.221 container exec_create: psql -U aurora... 3f8a1c9e...
2026-08-04T19:41:07.238 container exec_start: psql -U aurora... 3f8a1c9e...docker events es el registro de todo lo que hace el demonio. Sin --since, se queda escuchando en directo. Es la herramienta que responde a preguntas que ninguna otra puede:
# ¿Quién ha matado mi contenedor y cuándo?
docker events --since 1h --filter event=die --filter event=kill
# Ver en directo lo que pasa mientras reproduces el fallo en otra terminal
docker events --filter label=proyecto=aurora-libros
# Solo eventos de salud
docker events --since 30m --filter event=health_statusLos eventos más útiles: create, start, die (con su exitCode), kill, oom, health_status, restart, destroy, y los de red connect/disconnect. Un oom en esta lista es la confirmación de que fue el kernel y no tú (lección 03-07).
- Caso A: el contenedor sale con código 1
Síntoma: un compañero te dice que la API "no arranca en su máquina". Aplicas el método.
Pregunta 1: ¿está corriendo?
docker run -d --name aurora-api \
--env-file ~/aurora-libros/aurora.env \
-p 3000:3000 \
auroralibros/aurora-api:1.2.0 serve.js
sleep 2
docker ps -a --filter name=aurora-api --format "table {{.Names}}\t{{.Status}}"
docker inspect -f '{{.State.Status}} / código {{.State.ExitCode}}' aurora-apiCódigo 1: de la tabla de la lección 03-02, eso significa que la aplicación arrancó y falló ella misma. No es Docker (sería 125), ni un ejecutable inexistente (127), ni una señal (>128). Así que hay que ir a los logs.
Pregunta 2: ¿qué dicen los logs?
node:internal/modules/cjs/loader:1215
throw err;
^
Error: Cannot find module '/app/serve.js'
at Module._resolveFilename (node:internal/modules/cjs/loader:1212:15)
...
code: 'MODULE_NOT_FOUND'Resuelto en dos comandos y quince segundos. El fichero se llama server.js, no serve.js. ¿De dónde sale ese nombre?
Pregunta 3: ¿cómo está configurado?
docker inspect -f 'Path: {{.Path}} | Args: {{.Args}} | Cmd de la imagen: {{.Config.Cmd}}' aurora-apiAhí está: el CMD de la imagen (["server.js"]) fue sobrescrito desde la línea de comandos por el argumento suelto serve.js, tal y como aprendiste en la lección 03-01. La imagen está perfecta; el error estaba en el docker run.
docker rm aurora-api
docker run -d --name aurora-api --env-file ~/aurora-libros/aurora.env \
-p 3000:3000 auroralibros/aurora-api:1.2.0Una variante del mismo síntoma que conviene reconocer, porque el diagnóstico es completamente distinto:
docker run -d --name prueba-125 --env-file ~/aurora-libros/no-existe.env alpine:3.20
echo "código: $?"
docker ps -a --filter name=prueba-125 -qEl comando docker ps -a no devuelve nada: el contenedor no llegó ni a crearse. Con un 125 no hay logs que mirar ni contenedor que inspeccionar; el error está en tu línea de comandos. Es la primera bifurcación del árbol de decisión y ahorra mucho tiempo perdido.
- Caso B:
ECONNREFUSED en /libros
ECONNREFUSED en /librosAquí está el caso que arrastras desde el módulo 2. Vamos a resolverlo con el método completo.
Pregunta 1: ¿está corriendo?
Los tres están corriendo. No es un problema de arranque.
Pregunta 2: ¿qué dicen los logs?
[cache] no disponible al arrancar: getaddrinfo ENOTFOUND aurora-cache
[aurora-api] escuchando en el puerto 3000
[aurora-api] base de datos: aurora-db:5432/aurora_libros
[aurora-api] caché: aurora-cache:6379
[/libros] error: getaddrinfo ENOTFOUND aurora-db
{"error":"No se pudo obtener el catálogo","detalle":"getaddrinfo ENOTFOUND aurora-db"}El mensaje es claro: getaddrinfo es la función de resolución de nombres, y ENOTFOUND significa que el nombre aurora-db no se pudo traducir a ninguna dirección IP. No es un rechazo de conexión: es que la API no sabe a dónde conectarse.
Pregunta 3: ¿cómo está configurado?
docker inspect -f '{{.Name}}: {{range $k,$v := .NetworkSettings.Networks}}red={{$k}} ip={{$v.IPAddress}}{{end}}' \
aurora-api aurora-db aurora-cache/aurora-api: red=bridge ip=172.17.0.4
/aurora-db: red=bridge ip=172.17.0.2
/aurora-cache: red=bridge ip=172.17.0.3Y aquí llega la sorpresa que hace interesante el caso: los tres están en la misma red, la bridge por defecto, con IPs del mismo rango 172.17.0.0/16. No es un problema de aislamiento.
Verifica las variables, por descartar:
Correctas. La configuración es la que querías.
Pregunta 4: ¿qué pasa dentro?
docker exec aurora-api getent hosts aurora-db
echo "código de getent: $?"
docker exec aurora-api cat /etc/resolv.confEl DNS interno de Docker (127.0.0.11) está configurado, pero no resuelve aurora-db. Confírmalo con herramientas de verdad, usando el truco del apartado 5:
docker run --rm --network container:aurora-api nicolaka/netshoot \
sh -c 'nslookup aurora-db; echo "---"; nc -zv 172.17.0.2 5432'Server: 127.0.0.11
Address: 127.0.0.11:53
** server can't find aurora-db: NXDOMAIN
---
Connection to 172.17.0.2 5432 port [tcp/*] succeeded!Este es el diagnóstico definitivo, y son dos hechos opuestos en la misma salida:
| Prueba | Resultado | Qué demuestra |
|---|---|---|
nslookup aurora-db |
NXDOMAIN | El nombre no se resuelve |
nc -zv 172.17.0.2 5432 |
succeeded | La conectividad de red existe y funciona perfectamente |
O sea: aurora-api puede hablar con aurora-db; simplemente no sabe cómo se llama. El problema nunca fue de firewall, ni de puertos, ni de PostgreSQL: es de resolución de nombres.
Y la causa es una característica muy concreta de Docker: la red bridge por defecto no tiene DNS interno entre contenedores. Solo las redes definidas por el usuario lo tienen. Nadie te lo había dicho hasta ahora porque es exactamente el tema de la próxima lección.
¿Y no podrías poner la IP directamente y acabar antes?
# Funcionaría... hoy
docker run -d --name api-por-ip -e DB_HOST=172.17.0.2 -e REDIS_HOST=172.17.0.3 ...Podrías, y sería un error. Esas IPs las asigna Docker en el orden de arranque: reinicia la máquina, cambia el orden de los contenedores y 172.17.0.2 será otro. Escribir IPs a mano es construir un castillo sobre arena. La solución correcta —una red propia donde los nombres funcionen— es la primera práctica de la lección 03-05, y ahora ya sabes exactamente por qué la necesitas.
La incógnita del módulo queda cerrada. Solo falta aplicar la solución.
- Caso C: el healthcheck en
unhealthy
unhealthySíntoma: docker ps muestra Up 40 seconds (unhealthy) en aurora-api. ¿Qué significa exactamente y de dónde sale ese veredicto?
docker inspect -f '{{.State.Health.Status}} | {{len .State.Health.Log}} intentos | {{.State.Health.FailingStreak}} fallos seguidos' aurora-apiEl histórico completo está en .State.Health.Log, un array con los últimos cinco intentos:
{
"Start": "2026-08-04T20:31:12.114Z",
"End": "2026-08-04T20:31:12.287Z",
"ExitCode": 1,
"Output": "Connecting to localhost:3000 (127.0.0.1:3000)\nwget: server returned error: HTTP/1.1 503\n"
}Léelo con calma, porque cuenta toda la historia:
| Campo | Valor | Interpretación |
|---|---|---|
Start / End |
0,173 s de diferencia | La comprobación no expiró: responde rápido, pero mal |
ExitCode |
1 | El comando del HEALTHCHECK devolvió error. Cualquier valor distinto de 0 es un fallo |
Output |
HTTP/1.1 503 |
La API contestó, con un 503 |
Ese 503 no es casual: lo escribiste tú en la lección 01-07. El endpoint /salud devuelve 200 solo si la base de datos y la caché responden, y 503 en cualquier otro caso:
{"servicio":"aurora-api","version":"1.0.0","db":"ko","cache":"ko","errorDb":"getaddrinfo ENOTFOUND aurora-db","errorCache":"The client is closed"}
HTTP 503Y ahora el matiz que separa un unhealthy real de un falso positivo:
El contenedor está sano; el servicio no. El proceso
nodevive, escucha en el 3000 y responde en 173 milisegundos. Lo que está roto son sus dependencias. ElHEALTHCHECKestá haciendo exactamente lo que le pediste: informar de que este contenedor no está en condiciones de atender tráfico.
Los cuatro estados de salud posibles:
| Estado | Cuándo aparece |
|---|---|
starting |
Durante el --start-period (10 s en tu Dockerfile). Los fallos aquí no cuentan |
healthy |
La última comprobación devolvió 0 |
unhealthy |
Ha fallado --retries veces seguidas (3 en tu caso) |
| (ninguno) | La imagen no define HEALTHCHECK — el caso de postgres:16-alpine |
Y el detalle que sorprende a todo el mundo: unhealthy no hace nada por sí solo. Docker Engine no reinicia el contenedor, no lo saca de rotación ni avisa a nadie; se limita a marcarlo y a emitir un evento health_status. Quien actúa sobre esa información es un orquestador (Swarm en la lección 06-03, Kubernetes en la 06-05) o tú, mirando docker ps. Ni siquiera la política --restart reacciona a un unhealthy, y ese matiz se explica en la lección 03-07.
Deja aurora-api como está: en la próxima lección se pondrá healthy solo.
Errores Comunes y Consejos
- Empezar por entrar al contenedor. El 70 % de las averías se ven en
docker logsen diez segundos. Sigue el orden: estado → logs → configuración → dentro. - Depurar con
--rm. Si el contenedor se borra al morir, no hay logs, niinspect, ni autopsia. Durante la investigación, sin--rm. - Buscar logs de una aplicación que escribe en un fichero.
docker logsvacío no significa "no pasa nada": comprueba adónde escribe realmente el proceso. - Instalar herramientas en el contenedor "para arreglarlo". Se pierden al recrearlo. Para diagnosticar, un contenedor efímero con
netshoot; para arreglar, el Dockerfile. - Confundir
ECONNREFUSEDconENOTFOUND. El primero dice "el nombre se resolvió pero nadie escucha ahí"; el segundo, "no sé quién es ese". Son dos averías distintas y llevan a sitios distintos. - Leer
docker statssin límites configurados. La columna LIMIT muestra toda la RAM del host y elMEM %resulta engañosamente tranquilizador. - Creer que
unhealthyreinicia algo. Docker Engine solo lo marca. Sin orquestador, nadie actúa. - Volcar
docker inspectentero en la terminal. Son 200 líneas de JSON. Usa--formatojqy ve directo al dato. - Consejo: guarda alias para las consultas de
inspectque repitas. Por ejemplodip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' "$1"; }. - Consejo:
docker eventsen una segunda terminal mientras reproduces el fallo. Ver eldiecon suexitCodeen el instante exacto vale más que diez conjeturas.
Ejercicios
Ejercicio 1: autopsia de un contenedor muerto
Lanza este contenedor, que fallará a propósito, y realiza la investigación completa sin volver a ejecutarlo:
docker run -d --name autopsia -e MODO=produccion alpine:3.20 \
sh -c 'echo "[init] arrancando en modo $MODO"; echo "[init] falta la variable API_KEY" >&2; sleep 2; exit 78'Responde con comandos concretos: ¿en qué estado está y con qué código salió?, ¿ese código apunta a Docker o a la aplicación?, ¿qué escribió en stdout y qué en stderr, por separado?, ¿qué variables de entorno tenía configuradas?, ¿cuál era su comando efectivo?, ¿cuánto tiempo estuvo vivo (calcúlalo con StartedAt y FinishedAt)?
Ejercicio 2: diagnostica una red sin herramientas
Arranca un contenedor web-mudo con nginx:alpine sin publicar ningún puerto. Después, sin instalar nada dentro de él y sin recrearlo:
- Averigua su dirección IP.
- Comprueba desde un contenedor efímero de
netshootque Nginx responde en su puerto 80. - Demuestra que desde el host, con
curl localhost:80, no responde, y explica por qué eso no contradice el punto anterior. - Averigua qué procesos corren dentro sin usar
docker exec.
Ejercicio 3: el healthcheck que miente
Crea una imagen aurora-api:falso-sano a partir de auroralibros/aurora-api:1.2.0 que sustituya el HEALTHCHECK por uno que compruebe simplemente que el proceso existe (CMD pgrep node || exit 1). Arráncala sin base de datos ni caché y compara, con comandos, el estado de salud que reporta frente al de auroralibros/aurora-api:1.2.0 en las mismas condiciones. Después responde: ¿cuál de los dos healthchecks es "mejor"?, ¿en qué situación concreta el segundo te habría evitado un incidente y en cuál te habría dado una falsa alarma?
Soluciones
Solución al ejercicio 1
docker ps -a --filter name=autopsia --format "table {{.Names}}\t{{.Status}}"
docker inspect -f '{{.State.Status}} / código {{.State.ExitCode}}' autopsiaEl código 78 está en el rango 1–124, así que es de la aplicación, no de Docker. Un 125 habría significado un error del propio docker run, un 127 un ejecutable inexistente y un 137 una señal. Aquí, el programa decidió salir con ese número, así que la explicación está en su código y en sus logs.
echo "--- stdout ---"; docker logs autopsia 2>/dev/null
echo "--- stderr ---"; docker logs autopsia 1>/dev/nullLa separación de flujos es lo que da el diagnóstico: en stdout hay ruido informativo y en stderr está la causa real. En un log entremezclado de 500 líneas, ese filtro es la diferencia entre encontrarlo y no encontrarlo.
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' autopsia
docker inspect -f 'Path: {{.Path}} | Args: {{.Args}}' autopsiaMODO=produccion
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Path: sh | Args: [-c echo "[init] arrancando en modo $MODO"; echo "[init] falta la variable API_KEY" >&2; sleep 2; exit 78]Confirmado: MODO estaba definida y API_KEY no aparece por ninguna parte, que es justo lo que denunciaba el stderr.
docker inspect -f 'Inicio: {{.State.StartedAt}}{{println}}Fin: {{.State.FinishedAt}}' autopsia
docker rm autopsiaVivió 2,17 segundos, coherentes con el sleep 2 del comando. Ese cálculo es más útil de lo que parece: un contenedor que vive milisegundos suele fallar al arrancar; uno que vive horas y luego muere apunta a una fuga de memoria o a un evento externo.
Solución al ejercicio 2
docker run -d --name web-mudo nginx:alpine
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' web-mudo2. Comprobar que responde, desde netshoot:
Nginx funciona perfectamente. Otra forma, compartiendo directamente su pila de red:
docker run --rm --network container:web-mudo nicolaka/netshoot \
sh -c 'ss -tlnp; curl -s -o /dev/null -w "HTTP %{http_code}\n" localhost'3. Desde el host:
No hay contradicción alguna: el contenedor escucha en el puerto 80 de su propia pila de red, pero como no se publicó ningún puerto con -p, no existe ninguna regla que reenvíe el puerto 80 del host hacia él. La conectividad existe dentro de la red de Docker (donde vive netshoot) y no existe desde el host. Es justo la distinción entre "publicar un puerto" y "que dos contenedores se hablen" que se formaliza en la lección 03-05.
4. Procesos sin docker exec:
UID PID PPID C STIME TTY TIME CMD
root 26104 26082 0 21:03 ? 00:00:00 nginx: master process nginx -g daemon off;
101 26155 26104 0 21:03 ? 00:00:00 nginx: worker processdocker top lo ejecuta el demonio en el host, así que no necesita entrar en el contenedor ni que la imagen tenga ps. Y de propina se ve la buena práctica de Nginx: el master corre como root y los workers como el usuario 101.
Solución al ejercicio 3
# ~/aurora-libros/api/Dockerfile.falso-sano
FROM auroralibros/aurora-api:1.2.0
# pgrep viene con BusyBox, así que no hay que instalar nada
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD pgrep node || exit 1docker build -f ~/aurora-libros/api/Dockerfile.falso-sano -t aurora-api:falso-sano ~/aurora-libros/api
docker run -d --name api-falso-sano --env-file ~/aurora-libros/aurora.env aurora-api:falso-sano
sleep 45
docker ps --filter name=api --format "table {{.Names}}\t{{.Status}}"Dos contenedores idénticos en todo salvo en su comprobación de salud, y veredictos opuestos. El de la izquierda dice estar sano; ninguno de los dos puede servir un solo libro.
docker inspect -f '{{json .State.Health}}' api-falso-sano | jq '.Log[-1] | {ExitCode, Output}'
curl -s -o /dev/null -w "HTTP %{http_code}\n" localhost:3000/saludEl healthcheck falso responde 0 porque pgrep node encuentra el proceso. Y tiene toda la razón: el proceso está vivo. Simplemente está midiendo lo que no importa.
Las respuestas:
- El de
auroralibros/aurora-api:1.2.0es mejor en este escenario, porque comprueba la capacidad real de dar servicio (una petición HTTP de extremo a extremo, que a su vez consulta la base de datos y la caché) en lugar de la mera existencia de un proceso. - Cuándo te habría salvado el segundo (
pgrep): cuando la caída es de una dependencia y no quieres que todas tus réplicas se declaren enfermas a la vez. Si PostgreSQL se cae 30 segundos, con el healthcheck estricto las diez réplicas de la API pasan aunhealthysimultáneamente, el orquestador las saca de rotación y te quedas con un corte total en lugar de un servicio degradado que aún sirve lo cacheado. Es el conocido efecto dominó de los healthchecks demasiado profundos. - Cuándo te habría dado una falsa alarma... o peor, ninguna: cuando el proceso de Node está vivo pero su bucle de eventos está bloqueado, el pool de conexiones agotado o respondiendo 500 a todo.
pgrepdiría "sano" indefinidamente mientras los usuarios ven errores. Ese escenario es literalmente el que motivóHEALTHCHECKen la lección 02-04.
La solución profesional, que apuntabas ya en el ejercicio 3 de la lección 02-04, es separar dos endpoints: uno de vivacidad (/salud/vivo, superficial, para decidir reinicios) y otro de disponibilidad (/salud, profundo, para decidir si recibe tráfico). Es la distinción entre liveness y readiness que se desarrolla en la lección 06-05.
Conclusión
Tienes un método, y eso vale más que la lista de comandos: ¿está corriendo? → ¿qué dicen los logs? → ¿cómo está configurado? → ¿qué pasa dentro?, en ese orden y sin saltarse pasos. Sabes que un Created señala un ejecutable inexistente, que un 125 significa que el contenedor ni se creó y no hay nada que inspeccionar, y que un código entre 1 y 124 manda directamente a docker logs.
Manejas docker logs con sus ventanas de tiempo, su --tail y su -f que puedes cortar con Ctrl+C sin miedo, y tienes grabada la regla de oro: los contenedores logean a stdout y stderr, nunca a un fichero, y sabes separar los dos flujos con una redirección para que el error aparezca solo. Entras con docker exec eligiendo el shell correcto según la base, sabes que -u root te da privilegios aunque la imagen declare USER node, y —lo más valioso— sabes depurar imágenes mínimas sin herramientas lanzando un contenedor efímero de netshoot que comparte los namespaces de red y de procesos del enfermo, sin ensuciar la imagen de producción.
Del JSON de docker inspect extraes el dato exacto con plantillas Go y jq, con una tabla de consultas para IP, redes, puertos, montajes, entorno, salud, código de salida y reinicios, y sabes que ese mismo comando muestra las contraseñas en claro, razón por la que las variables de entorno no son un mecanismo de secretos. Con docker top ves los procesos desde el host aunque la imagen no tenga ps, con docker stats interpretas las siete columnas —y has descubierto que tus contenedores no tienen ningún límite de memoria— y con docker events reconstruyes qué pasó y cuándo.
Y has cerrado la investigación que arrastrabas desde el módulo 2. aurora-api, aurora-db y aurora-cache están en la misma red y con conectividad plena entre ellos: nc -zv 172.17.0.2 5432 responde succeeded. Lo que falla es una sola cosa, y ahora la sabes nombrar con precisión: nslookup aurora-db devuelve NXDOMAIN, porque la red bridge por defecto no tiene DNS interno entre contenedores. No es un cortafuegos, ni un puerto, ni PostgreSQL: es que la API no sabe cómo se llama su base de datos.
Solo falta aplicar la solución, y es más corta que el diagnóstico. En la siguiente lección, Redes en Docker, verás por qué cada contenedor tiene su propia pila de red y su propio localhost, compararás los drivers bridge, host y none, y entenderás la diferencia decisiva entre la bridge por defecto y una red definida por el usuario, donde el DNS interno resuelve nombres de contenedor automáticamente. Crearás aurora-net, recrearás los tres servicios dentro de ella, y ejecutarás por fin ese curl http://localhost:3000/libros que lleva seis lecciones esperando para devolverte El jardín de senderos que se bifurcan, Rayuela y los otros seis títulos. Después añadirás aurora-web como proxy inverso, y la plataforma de Aurora Libros estará viva de verdad.
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
