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

  1. Un método antes que un comando
  2. docker logs a fondo
  3. La regla de oro: stdout y stderr
  4. docker exec: entrar en el contenedor
  5. Depurar imágenes mínimas sin herramientas
  6. docker inspect: el JSON completo del contenedor
  7. docker top: los procesos de dentro
  8. docker stats: consumo en tiempo real
  9. docker events: el flujo del demonio
  10. Caso A: el contenedor sale con código 1
  11. Caso B: ECONNREFUSED en /libros
  12. Caso C: el healthcheck en unhealthy

  1. 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.

  1. docker logs a fondo

docker logs aurora-db
PostgreSQL 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 connections

Las 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 logs funciona con el contenedor parado. Los logs viven en el host y sobreviven a docker stop. Solo desaparecen con docker rm. Es la razón por la que --rm es tu enemigo mientras depuras.
  • --tail sin -f es 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+C solo interrumpe el visor: no toca el contenedor. A diferencia de docker attach, aquí Ctrl+C es completamente seguro.

  1. 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'
esto va a stdout
esto va a stderr

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-flujos2
salida normal
un error

De 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:

docker exec aurora-db ls -la /proc/1/fd/1 /proc/1/fd/2
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/0

Si 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.

  1. docker exec: entrar en el contenedor

docker exec -it aurora-db sh
/ # psql -U aurora -d aurora_libros -c "SELECT COUNT(*) FROM libros;"
 count
-------
     8
(1 row)
/ # exit

Ya 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:

docker exec -it aurora-cache bash
OCI runtime exec failed: exec failed: unable to start container process:
exec: "bash": executable file not found in $PATH: unknown

Un truco que funciona casi siempre:

docker exec -it aurora-cache sh -c 'command -v bash || command -v sh'

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:

  1. -u root te da privilegios dentro del contenedor aunque la imagen declare otro usuario. La restricción de USER protege del código que corre dentro, no de quien controla el demonio de Docker.
  2. Instalar herramientas con apk add dentro de un contenedor en marcha es legítimo para depurar y absolutamente prohibido como forma de "arreglar" nada: en cuanto recrees el contenedor, ese curl desaparece. Lo que se arregla, se arregla en el Dockerfile.
  3. Y ahí está el diagnóstico servido en bandeja: errorDb: getaddrinfo ENOTFOUND aurora-db. El endpoint /salud que 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:0

Fí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.

  1. 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/netshoot
                    dP            dP                           dP
                    88            88                           88
88d888b. .d8888b. d8888P .d8888b. 88d888b. .d8888b. .d8888b. d8888P
...

Lo 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-db

Cuatro conclusiones en cuatro comandos, sin instalar nada en la imagen de producción:

  1. ss -tlnp confirma que la API sí escucha en el 3000, en 0.0.0.0 (bien: no en 127.0.0.1, que sería inalcanzable desde fuera).
  2. ps -ef muestra el node server.js del contenedor vecino gracias a --pid container:, y además que corre con UID 1000.
  3. nslookup aurora-db responde NXDOMAIN: el servidor DNS interno de Docker (127.0.0.11) existe y responde, pero no conoce ese nombre.
  4. curl funciona aunque la imagen de la API no tenga curl, 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:

docker debug api-depurar

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 rm -f api-depurar

  1. docker inspect: el JSON completo del contenedor

docker inspect devuelve todo lo que Docker sabe de un contenedor: unas 200 líneas de JSON.

docker inspect aurora-db | head -20
docker inspect aurora-db | jq 'keys'
[
  "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 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-cache
/aurora-db | running | bridge=172.17.0.2
/aurora-cache | running | bridge=172.17.0.3

Dos 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/data

Nota 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.

  1. docker top: los procesos de dentro

docker top aurora-db
UID     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 launcher

Dos particularidades que lo hacen especial:

  • Los PID son los del host, no los de dentro del contenedor. docker exec aurora-db ps mostraría el mismo postgres como 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ágenes distroless.

Sirve para responder rápido a: ¿cuántos procesos hay?, ¿hay procesos zombis?, ¿está la aplicación lanzando hijos que no debería?

  1. docker stats: consumo en tiempo real

docker stats --no-stream
CONTAINER 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           6

Sin --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)

  1. docker events: el flujo del demonio

docker events --since 10m --filter container=aurora-db
2026-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_status

Los 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).

  1. 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-api
NAMES        STATUS
aurora-api   Exited (1) 1 second ago
exited / código 1

exited / código 1

Có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?

docker logs aurora-api
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-api
Path: node | Args: [serve.js] | Cmd de la imagen: [serve.js]

Ahí 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.0

Una 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 -q
docker: open /home/junior/aurora-libros/no-existe.env: no such file or directory
código: 125

El 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.

  1. Caso B: ECONNREFUSED en /libros

Aquí está el caso que arrastras desde el módulo 2. Vamos a resolverlo con el método completo.

Pregunta 1: ¿está corriendo?

docker ps --filter name=aurora --format "table {{.Names}}\t{{.Status}}"
NAMES          STATUS
aurora-api     Up 40 seconds (unhealthy)
aurora-db      Up 1 hour
aurora-cache   Up 1 hour

Los tres están corriendo. No es un problema de arranque.

Pregunta 2: ¿qué dicen los logs?

docker logs aurora-api
curl -s localhost:3000/libros | head -c 200
[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.3

Y 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:

docker exec aurora-api printenv | grep -E "DB_HOST|REDIS_HOST"
DB_HOST=aurora-db
REDIS_HOST=aurora-cache

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.conf
código de getent: 2

nameserver 127.0.0.11
options ndots:0

El 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.

  1. Caso C: el healthcheck en unhealthy

Sí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-api
unhealthy | 5 intentos | 5 fallos seguidos

El histórico completo está en .State.Health.Log, un array con los últimos cinco intentos:

docker inspect -f '{{json .State.Health}}' aurora-api | jq '.Log[-1]'
{
  "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:

curl -s -w "\nHTTP %{http_code}\n" localhost:3000/salud
{"servicio":"aurora-api","version":"1.0.0","db":"ko","cache":"ko","errorDb":"getaddrinfo ENOTFOUND aurora-db","errorCache":"The client is closed"}
HTTP 503

Y ahora el matiz que separa un unhealthy real de un falso positivo:

El contenedor está sano; el servicio no. El proceso node vive, escucha en el 3000 y responde en 173 milisegundos. Lo que está roto son sus dependencias. El HEALTHCHECK está 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.

docker events --since 5m --filter event=health_status --filter container=aurora-api
2026-08-04T20:31:12.311 container health_status: unhealthy 4c8f2a1e9b73 (name=aurora-api)

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 logs en diez segundos. Sigue el orden: estado → logs → configuración → dentro.
  • Depurar con --rm. Si el contenedor se borra al morir, no hay logs, ni inspect, ni autopsia. Durante la investigación, sin --rm.
  • Buscar logs de una aplicación que escribe en un fichero. docker logs vací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 ECONNREFUSED con ENOTFOUND. 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 stats sin límites configurados. La columna LIMIT muestra toda la RAM del host y el MEM % resulta engañosamente tranquilizador.
  • Creer que unhealthy reinicia algo. Docker Engine solo lo marca. Sin orquestador, nadie actúa.
  • Volcar docker inspect entero en la terminal. Son 200 líneas de JSON. Usa --format o jq y ve directo al dato.
  • Consejo: guarda alias para las consultas de inspect que repitas. Por ejemplo dip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' "$1"; }.
  • Consejo: docker events en una segunda terminal mientras reproduces el fallo. Ver el die con su exitCode en 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:

  1. Averigua su dirección IP.
  2. Comprueba desde un contenedor efímero de netshoot que Nginx responde en su puerto 80.
  3. Demuestra que desde el host, con curl localhost:80, no responde, y explica por qué eso no contradice el punto anterior.
  4. 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}}' autopsia
NAMES      STATUS
autopsia   Exited (78) 30 seconds ago
exited / código 78

El 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/null
--- stdout ---
[init] arrancando en modo produccion
--- stderr ---
[init] falta la variable API_KEY

La 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}}' autopsia
MODO=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 autopsia
Inicio: 2026-08-04T20:52:03.417218Z
Fin:    2026-08-04T20:52:05.583904Z

Vivió 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-mudo
172.17.0.5

2. Comprobar que responde, desde netshoot:

docker run --rm nicolaka/netshoot curl -s -o /dev/null -w "HTTP %{http_code}\n" http://172.17.0.5
HTTP 200

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'
State  Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0      511          0.0.0.0:80        0.0.0.0:*
HTTP 200

3. Desde el host:

curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:80
HTTP 000

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:

docker top web-mudo
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 process

docker 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.

docker rm -f web-mudo

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 1
docker 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}}"
NAMES             STATUS
api-falso-sano    Up 45 seconds (healthy)
aurora-api        Up 15 minutes (unhealthy)

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/salud
{ "ExitCode": 0, "Output": "1\n" }

El 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.0 es 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 a unhealthy simultá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. pgrep diría "sano" indefinidamente mientras los usuarios ven errores. Ese escenario es literalmente el que motivó HEALTHCHECK en 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.

docker rm -f api-falso-sano
docker image rm aurora-api:falso-sano

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

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