So far you have seen containers that work and containers that fail obviously. In real work, the most frequent situation is a third one: a container that does not work and does not say why. It is Up 3 hours but it does not respond. It exits with code 1 and disappears before you can look at it. It gets marked unhealthy with no further explanation. And there you are, with twenty tabs open, trying things at random.
This lesson gives you a method and the five tools that support it: docker logs, docker exec, docker inspect, docker stats and docker events. You are going to learn to read them thoroughly, to get into minimal images that do not have so much as ping, and to extract exactly the piece of data you need from inspect's JSON. And you are going to apply it to three real Aurora Libros breakdowns, among them the one that has been waiting for you for three lessons: why aurora-api cannot find aurora-db even though both are running on the same machine. By the end of this lesson you will know the exact answer.
Contents
- A method before a command
docker logsin depth- The golden rule: stdout and stderr
docker exec: getting into the container- Debugging minimal images with no tools
docker inspect: the container's complete JSONdocker top: the processes insidedocker stats: real-time consumptiondocker events: the daemon's stream- Case A: the container exits with code 1
- Case B:
ECONNREFUSEDon/books - Case C: the healthcheck stuck on
unhealthy
- A method before a command
The difference between someone who debugs fast and someone who flails around is not the commands, but the order in which the questions get asked:
flowchart TD
A["Something is not working"] --> B{"Is it running?<br/>docker ps -a"}
B -- "Not listed" --> B1["It was never created:<br/>check the docker run (code 125)"]
B -- "Created" --> B2["The executable does not exist<br/>or could not be run (126/127)"]
B -- "Exited" --> C{"Which exit<br/>code?"}
B -- "Restarting" --> R["Restart loop:<br/>logs + policy (03-07)"]
B -- "Up" --> D{"Is it healthy?"}
C -- "1-124" --> L["docker logs:<br/>YOUR application failed"]
C -- "137 / 139 / 143" --> S["docker inspect:<br/>signal, OOMKilled, Error"]
D -- "unhealthy" --> H["docker inspect<br/>.State.Health.Log"]
D -- "healthy / no check" --> E{"Do the logs<br/>say anything?"}
E -- "Yes" --> L
E -- "No" --> F{"How is it<br/>configured?"}
F --> G["docker inspect:<br/>environment, network, mounts, ports"]
G --> I["docker exec:<br/>look from inside"]
I --> J["Ephemeral container with netshoot<br/>if tools are missing"]
The four questions, in this order and without skipping any:
| # | Question | Tool | What it rules out |
|---|---|---|---|
| 1 | Is it running? | docker ps -a |
Distinguishes "it did not start" from "it started and failed" |
| 2 | What do the logs say? | docker logs |
Solves most cases in 10 seconds |
| 3 | How is it configured? | docker inspect |
Variables, network, mounts, ports: what you thought you had set |
| 4 | What is happening inside? | docker exec, top, stats |
What can only be seen from inside |
The most common mistake is to start at 4 —getting into the container to look around— when the answer was in 2. And the second most common mistake is never doing 3, spending hours convinced you passed an environment variable that in fact had a typo.
docker logs in depth
docker logs in depthPostgreSQL 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 connectionsThe options that actually get used:
| Option | What it does | Example |
|---|---|---|
-f, --follow |
Follows the output live | docker logs -f aurora-api |
--tail N |
Only the last N lines | docker logs --tail 50 aurora-db |
-t, --timestamps |
Adds a timestamp to each line | docker logs -t aurora-db |
--since |
From a given moment | --since 10m, --since 2026-08-04T19:30:00 |
--until |
Up to a given moment | --until 5m |
--details |
Shows extra metadata | Rarely used |
Combinations that solve real problems:
# The last 20 lines and then follow live: the most used of the day to day
docker logs -f --tail 20 aurora-db
# What happened in the last 5 minutes, with exact times
docker logs -t --since 5m aurora-db
# Narrowing to a specific window around an incident
docker logs -t --since 2026-08-04T19:28:00 --until 2026-08-04T19:29:00 aurora-db
# Searching for errors across the whole history
docker logs aurora-db 2>&1 | grep -i "error\|fatal"Three important details:
docker logsworks with the container stopped. The logs live on the host and survivedocker stop. They only disappear withdocker rm. That is why--rmis your enemy while debugging.--tailwithout-fis the fastest way to see the end of a 100,000-line log without dumping the whole thing to your terminal.- With
-f,Ctrl+Conly interrupts the viewer: it does not touch the container. Unlikedocker attach, hereCtrl+Cis completely safe.
- The golden rule: stdout and stderr
docker logs shows exactly two things: the standard output and the standard error of PID 1. Not one thing more.
docker run --rm --name demo-streams alpine:3.20 \
sh -c 'echo "this goes to stdout"; echo "this goes to stderr" >&2; echo "this goes to a file" > /tmp/hidden.log'The third line does not appear anywhere, because it was written to a file inside the container. You can separate the two streams with the shell's standard redirection:
docker run --name demo-streams2 alpine:3.20 \
sh -c 'echo "normal output"; echo "an error" >&2'
docker logs demo-streams2 2>/dev/null # stdout only
docker logs demo-streams2 1>/dev/null # stderr only
docker rm demo-streams2And from that comes the rule that governs all container operations:
An application in a container writes its logs to standard output and standard error. Never to a file.
The reasons:
| If it logs to stdout/stderr | If it logs to a file |
|---|---|
docker logs works |
docker logs is empty and it looks as if the app does nothing |
| Logs get collected automatically | You have to get in with exec or pull them out with docker cp |
| The file does not grow inside the writable layer | The container swells until it fills the host's disk |
| Any aggregator (Loki, ELK, CloudWatch) collects them with no configuration | You have to mount volumes and deploy agents |
| They are deleted along with the container | They are left orphaned |
Your server.js has been doing this right since lesson 01-07: it uses console.log and console.error, which in Node write to stdout and stderr respectively.
And a practical check for when docker logs is suspiciously empty:
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/0If those descriptors pointed to a file instead of to the console, there would be your explanation.
A note on scope: where those logs are really stored, how to change the logging driver, how to rotate them and how to ship them to a centralized system is the content of lesson 05-06. Here we stop at reading them.
docker exec: getting into the container
docker exec: getting into the container/ # psql -U aurora -d aurora_books -c "SELECT COUNT(*) FROM books;"
count
-------
8
(1 row)
/ # exitYou already know from lesson 03-01 that exec launches a new process inside the container's namespaces, and that this is why leaving it does not affect the service. Its options:
| Option | What for |
|---|---|
-it |
Interactive session with a terminal |
-u, --user |
Run as another user, typically -u root |
-w, --workdir |
Start in a different directory |
-e |
Add a variable for this process only |
-d |
Launch it in the background inside the container |
--privileged |
With extended capabilities (last resort) |
Which shell to use
| Base image | Available shell | Command |
|---|---|---|
Alpine (alpine, node:22-alpine, redis:7-alpine) |
sh (BusyBox ash). There is no bash |
docker exec -it X sh |
Debian/Ubuntu (node:22, postgres:16, ubuntu) |
bash and sh |
docker exec -it X bash |
distroless, scratch |
None | See section 5 |
If you get it wrong, the error is unmistakable:
OCI runtime exec failed: exec failed: unable to start container process:
exec: "bash": executable file not found in $PATH: unknownA trick that almost always works:
Getting in as root on an unprivileged image
Your aurora-api runs with USER node since lesson 02-04. That is excellent for production and a nuisance for debugging:
docker run -d --name api-debug --env-file ~/aurora-libros/aurora.env auroralibros/aurora-api:1.2.0
docker exec -it api-debug id
docker exec -it -u root api-debug id
docker exec -it -u root api-debug sh -c 'apk add --no-cache curl && curl -s localhost:3000/health'uid=1000(node) gid=1000(node) groups=1000(node)
uid=0(root) gid=0(root) groups=0(root),1(bin),...
{"service":"aurora-api","version":"1.0.0","db":"ko","cache":"ko","errorDb":"getaddrinfo ENOTFOUND aurora-db","errorCache":"The client is closed"}Three things to learn from this output:
-u rootgives you privileges inside the container even if the image declares another user. TheUSERrestriction protects you from the code running inside, not from whoever controls the Docker daemon.- Installing tools with
apk addinside a running container is legitimate for debugging and absolutely forbidden as a way of "fixing" anything: the moment you recreate the container, thatcurlis gone. What gets fixed, gets fixed in the Dockerfile. - And there is the diagnosis handed to you on a plate:
errorDb: getaddrinfo ENOTFOUND aurora-db. The/healthendpoint you wrote in lesson 01-07 is doing exactly the job you designed it for.
Diagnostic commands inside the container
docker exec api-debug printenv | sort | head -8 # what configuration does it really see?
docker exec api-debug ps -o pid,comm # what processes are there?
docker exec api-debug cat /etc/hosts # what names does it know?
docker exec api-debug cat /etc/resolv.conf # which DNS does it ask?
docker exec api-debug getent hosts aurora-db # does it resolve this name?
docker exec api-debug df -h / # is there space left?
docker exec api-debug ls -la /app # is the code where I think it is?DB_HOST=aurora-db
DB_NAME=aurora_books
DB_PASSWORD=aurora_secret
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:0Look at /etc/hosts: there are three entries and none of them is called aurora-db. The command getent hosts aurora-db did not even print anything (it returned code 2, "not found"). Keep that fact: it is half the answer to case B.
- Debugging minimal images with no tools
A well-optimized image ships no curl, no ping, no netstat, no dig. And distroless images or ones built FROM scratch do not even ship a shell. That is excellent for security and size (lesson 05-04) and maddening when something fails.
The modern solution: an ephemeral container loaded with tools that shares the sick container's namespaces.
What each option does:
| Option | Effect |
|---|---|
--network container:X |
The new container shares X's network stack: same localhost, same IP, same interfaces |
--pid container:X |
Shares the process space: you see X's processes and can examine them |
nicolaka/netshoot |
An image with curl, dig, nmap, tcpdump, netstat, ss, iperf, jq and dozens more |
And now, from inside that container, you diagnose api-debug's network as if you were in it:
~ ❯ 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/health | jq -r .errorDb
getaddrinfo ENOTFOUND aurora-dbFour conclusions in four commands, without installing anything in the production image:
ss -tlnpconfirms the API is listening on 3000, on0.0.0.0(good: not on127.0.0.1, which would be unreachable from outside).ps -efshows the neighboring container'snode server.jsthanks to--pid container:, and also that it runs as UID 1000.nslookup aurora-dbanswers NXDOMAIN: Docker's internal DNS server (127.0.0.11) exists and responds, but it does not know that name.curlworks even though the API image has nocurl, because netshoot provides it.
That NXDOMAIN is the definitive proof. We will come back to it in case B.
docker debug
Docker Desktop includes a built-in version of this idea:
It opens a shell with a set of tools mounted over the container without modifying it: it works even on distroless images with no shell, and when you leave, no trace is left. It requires a Pro subscription or above; the netshoot trick is free, works on any Docker and is worth knowing anyway.
docker inspect: the container's complete JSON
docker inspect: the container's complete JSONdocker inspect returns everything Docker knows about a container: around 200 lines of JSON.
[
"AppArmorProfile", "Args", "Config", "Created", "Driver", "ExecIDs",
"GraphDriver", "HostConfig", "HostnamePath", "HostsPath", "Id", "Image",
"LogPath", "MountLabel", "Mounts", "Name", "NetworkSettings", "Path",
"Platform", "ProcessLabel", "ResolvConfPath", "RestartCount", "State"
]The six blocks that matter:
| Block | Contains |
|---|---|
.State |
State, PID, exit codes, OOMKilled, health |
.Config |
What comes from the image: Env, Cmd, Entrypoint, Labels, Healthcheck, User |
.HostConfig |
What you set on docker run: ports, memory, CPU, restart policy |
.NetworkSettings |
IPs, networks, published ports, gateway |
.Mounts |
Active volumes and bind mounts |
.RestartCount |
How many times Docker has restarted it (lesson 03-07) |
Reading 200 lines of JSON is not debugging. What you do is extract the specific piece of data, with --format or with jq:
| What you need to know | Command |
|---|---|
| State and exit code | docker inspect -f '{{.State.Status}} ({{.State.ExitCode}})' X |
| The container's IP | docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' X |
| Which networks it is connected to | docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' X |
| Published ports | docker inspect -f '{{json .NetworkSettings.Ports}}' X |
| Environment variables | docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' X |
| One specific variable | docker inspect -f '{{json .Config.Env}}' X | jq -r '.[]|select(startswith("DB_HOST"))' |
| Mounts | docker inspect -f '{{range .Mounts}}{{.Type}}: {{.Source}} -> {{.Destination}}{{println}}{{end}}' X |
| Effective command | docker inspect -f '{{.Path}} {{.Args}}' X |
| Health state | docker inspect -f '{{.State.Health.Status}}' X |
| Last healthcheck failure | docker inspect -f '{{(index .State.Health.Log 0).Output}}' X |
| Did the OOM killer get it? | docker inspect -f '{{.State.OOMKilled}}' X |
| Number of restarts | docker inspect -f '{{.RestartCount}}' X |
| Memory limit | docker inspect -f '{{.HostConfig.Memory}}' X |
| Labels | docker inspect -f '{{json .Config.Labels}}' X | jq |
A complete example on the fleet:
docker inspect -f '{{.Name}} | {{.State.Status}} | {{range $k,$v := .NetworkSettings.Networks}}{{$k}}={{$v.IPAddress}} {{end}}' aurora-db aurora-cacheTwo observations that will be decisive in five minutes: both are on the bridge network and each has its own IP.
With jq you can run richer queries over the raw JSON:
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_secret
POSTGRES_DB=aurora_books
volume: /var/lib/postgresql/dataA security note, and it is a serious one: docker inspect shows passwords in the clear. Anyone with access to the Docker daemon can read every environment variable of every container. That is why environment variables are not a secrets mechanism; secret managers are, and they are covered in lesson 05-03.
docker top: the processes inside
docker top: the processes insideUID 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 launcherTwo peculiarities make it special:
- The PIDs are the host's, not the ones inside the container.
docker exec aurora-db pswould show that samepostgresas PID 1; here it is 24817. It is the double numbering of PID namespaces. - It does not require the image to have
ps. The command is run by the daemon on the host. That is why it works even ondistrolessimages.
It is good for quickly answering: how many processes are there? Are there zombie processes? Is the application spawning children it should not?
docker stats: real-time consumption
docker stats: real-time consumptionCONTAINER 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 6Without --no-stream, the view refreshes continuously like a top. Column by column:
| Column | What it measures | How to read it |
|---|---|---|
| CPU % | CPU percentage | It can exceed 100%: 400% = four saturated cores |
| MEM USAGE / LIMIT | Memory used / limit | If you set no limit, LIMIT is all of the host's RAM. Careful |
| MEM % | Usage relative to the limit | Sustained near 100% = a candidate for the OOM killer |
| NET I/O | Received / sent over the network | A 0B on a web service means nobody is talking to it |
| BLOCK I/O | Read / written to disk | A value that grows non-stop may be a runaway log |
| PIDS | Number of processes and threads | If it grows non-stop, there is a process leak |
That LIMIT of 7.628 GiB on both rows is the alarm bell you will deal with in lesson 03-07: neither container has a memory limit, so either of them can consume all the machine's RAM.
Useful formats:
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}"
docker stats --no-stream $(docker ps -q --filter label=project=aurora-libros)
docker events: the daemon's stream
docker events: the daemon's stream2026-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 is the record of everything the daemon does. Without --since, it stays listening live. It is the tool that answers questions no other one can:
# Who killed my container, and when?
docker events --since 1h --filter event=die --filter event=kill
# Watch live what happens while you reproduce the failure in another terminal
docker events --filter label=project=aurora-libros
# Health events only
docker events --since 30m --filter event=health_statusThe most useful events: create, start, die (with its exitCode), kill, oom, health_status, restart, destroy, and the network ones connect/disconnect. An oom in this list is confirmation that it was the kernel and not you (lesson 03-07).
- Case A: the container exits with code 1
Symptom: a colleague tells you the API "does not start on his machine". You apply the method.
Question 1: is it running?
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}} / exit code {{.State.ExitCode}}' aurora-apiCode 1: from lesson 03-02's table, that means the application started and failed on its own. It is not Docker (that would be 125), nor a non-existent executable (127), nor a signal (>128). So we go to the logs.
Question 2: what do the logs say?
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'Solved in two commands and fifteen seconds. The file is called server.js, not serve.js. Where does that name come from?
Question 3: how is it configured?
There it is: the image's CMD (["server.js"]) was overridden from the command line by the loose argument serve.js, exactly as you learned in lesson 03-01. The image is perfect; the mistake was in the 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.0A variant of the same symptom worth recognizing, because the diagnosis is completely different:
docker run -d --name test-125 --env-file ~/aurora-libros/does-not-exist.env alpine:3.20
echo "code: $?"
docker ps -a --filter name=test-125 -qThe docker ps -a command returns nothing: the container was never even created. With a 125 there are no logs to look at and no container to inspect; the error is in your command line. It is the first fork in the decision tree and it saves a lot of wasted time.
- Case B:
ECONNREFUSED on /books
ECONNREFUSED on /booksHere is the case you have been dragging along since module 2. Let's solve it with the complete method.
Question 1: is it running?
All three are running. It is not a startup problem.
Question 2: what do the logs say?
[cache] not available at startup: getaddrinfo ENOTFOUND aurora-cache
[aurora-api] listening on port 3000
[aurora-api] database: aurora-db:5432/aurora_books
[aurora-api] cache: aurora-cache:6379
[/books] error: getaddrinfo ENOTFOUND aurora-db
{"error":"Could not fetch the catalog","detail":"getaddrinfo ENOTFOUND aurora-db"}The message is clear: getaddrinfo is the name resolution function, and ENOTFOUND means the name aurora-db could not be translated into any IP address. It is not a refused connection: it is that the API does not know where to connect.
Question 3: how is it configured?
docker inspect -f '{{.Name}}: {{range $k,$v := .NetworkSettings.Networks}}network={{$k}} ip={{$v.IPAddress}}{{end}}' \
aurora-api aurora-db aurora-cache/aurora-api: network=bridge ip=172.17.0.4
/aurora-db: network=bridge ip=172.17.0.2
/aurora-cache: network=bridge ip=172.17.0.3And here comes the surprise that makes the case interesting: all three are on the same network, the default bridge, with IPs from the same 172.17.0.0/16 range. It is not an isolation problem.
Check the variables, to rule them out:
Correct. The configuration is the one you wanted.
Question 4: what is happening inside?
docker exec aurora-api getent hosts aurora-db
echo "getent exit code: $?"
docker exec aurora-api cat /etc/resolv.confDocker's internal DNS (127.0.0.11) is configured, but it does not resolve aurora-db. Confirm it with real tools, using the trick from section 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!This is the definitive diagnosis, and it is two opposite facts in the same output:
| Test | Result | What it proves |
|---|---|---|
nslookup aurora-db |
NXDOMAIN | The name does not resolve |
nc -zv 172.17.0.2 5432 |
succeeded | The network connectivity exists and works perfectly |
In other words: aurora-api can talk to aurora-db; it simply does not know its name. The problem was never a firewall, or ports, or PostgreSQL: it is name resolution.
And the cause is a very specific Docker characteristic: the default bridge network has no internal DNS between containers. Only user-defined networks have it. Nobody had told you until now because it is exactly the subject of the next lesson.
Could you not just put the IP in directly and be done sooner?
# It would work... today
docker run -d --name api-by-ip -e DB_HOST=172.17.0.2 -e REDIS_HOST=172.17.0.3 ...You could, and it would be a mistake. Those IPs are assigned by Docker in start-up order: reboot the machine, change the order of the containers and 172.17.0.2 will be a different one. Writing IPs by hand is building a castle on sand. The correct solution —a network of your own where names work— is the first hands-on part of lesson 03-05, and now you know exactly why you need it.
The module's mystery is closed. All that is left is to apply the solution.
- Case C: the healthcheck stuck on
unhealthy
unhealthySymptom: docker ps shows Up 40 seconds (unhealthy) on aurora-api. What does that mean exactly, and where does that verdict come from?
docker inspect -f '{{.State.Health.Status}} | {{len .State.Health.Log}} attempts | {{.State.Health.FailingStreak}} consecutive failures' aurora-apiThe complete history is in .State.Health.Log, an array with the last five attempts:
{
"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"
}Read it carefully, because it tells the whole story:
| Field | Value | Interpretation |
|---|---|---|
Start / End |
0.173 s apart | The check did not time out: it responds fast, but badly |
ExitCode |
1 | The HEALTHCHECK command returned an error. Any value other than 0 is a failure |
Output |
HTTP/1.1 503 |
The API did answer, with a 503 |
That 503 is no accident: you wrote it yourself in lesson 01-07. The /health endpoint returns 200 only if the database and the cache respond, and 503 in any other case:
{"service":"aurora-api","version":"1.0.0","db":"ko","cache":"ko","errorDb":"getaddrinfo ENOTFOUND aurora-db","errorCache":"The client is closed"}
HTTP 503And now the nuance that separates a real unhealthy from a false positive:
The container is healthy; the service is not. The
nodeprocess is alive, listening on 3000 and responding in 173 milliseconds. What is broken are its dependencies. TheHEALTHCHECKis doing exactly what you asked it to: reporting that this container is not in a fit state to serve traffic.
The four possible health states:
| State | When it appears |
|---|---|
starting |
During the --start-period (10 s in your Dockerfile). Failures here do not count |
healthy |
The last check returned 0 |
unhealthy |
It has failed --retries times in a row (3 in your case) |
| (none) | The image defines no HEALTHCHECK — the case of postgres:16-alpine |
And the detail that surprises everyone: unhealthy does nothing on its own. Docker Engine does not restart the container, does not take it out of rotation and does not notify anyone; it just marks it and emits a health_status event. Who acts on that information is an orchestrator (Swarm in lesson 06-03, Kubernetes in 06-05) or you, looking at docker ps. Not even the --restart policy reacts to an unhealthy, and that nuance is explained in lesson 03-07.
Leave aurora-api as it is: in the next lesson it will turn healthy on its own.
Common Mistakes and Tips
- Starting by getting into the container. 70% of breakdowns show up in
docker logsin ten seconds. Follow the order: state → logs → configuration → inside. - Debugging with
--rm. If the container is deleted when it dies, there are no logs, noinspectand no autopsy. During an investigation, no--rm. - Looking for the logs of an application that writes to a file. An empty
docker logsdoes not mean "nothing is happening": check where the process is really writing. - Installing tools in the container "to fix it". They are lost when you recreate it. To diagnose, an ephemeral container with
netshoot; to fix, the Dockerfile. - Confusing
ECONNREFUSEDwithENOTFOUND. The first says "the name resolved but nobody is listening there"; the second, "I do not know who that is". They are two different breakdowns and they lead to different places. - Reading
docker statswith no limits configured. The LIMIT column shows all the host's RAM and theMEM %is deceptively reassuring. - Believing
unhealthyrestarts anything. Docker Engine only marks it. Without an orchestrator, nobody acts. - Dumping the whole
docker inspectinto the terminal. That is 200 lines of JSON. Use--formatorjqand go straight to the fact. - Tip: keep aliases for the
inspectqueries you repeat. For exampledip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' "$1"; }. - Tip:
docker eventsin a second terminal while you reproduce the failure. Seeing thediewith itsexitCodeat the exact instant is worth more than ten guesses.
Exercises
Exercise 1: autopsy of a dead container
Launch this container, which will fail on purpose, and carry out the complete investigation without running it again:
docker run -d --name autopsy -e MODE=production alpine:3.20 \
sh -c 'echo "[init] starting in $MODE mode"; echo "[init] API_KEY variable is missing" >&2; sleep 2; exit 78'Answer with specific commands: what state is it in and what code did it exit with? Does that code point at Docker or at the application? What did it write to stdout and what to stderr, separately? What environment variables did it have configured? What was its effective command? How long was it alive (work it out with StartedAt and FinishedAt)?
Exercise 2: diagnose a network with no tools
Start a web-silent container with nginx:alpine without publishing any port. Then, without installing anything inside it and without recreating it:
- Find out its IP address.
- Check from an ephemeral
netshootcontainer that Nginx responds on its port 80. - Prove that from the host, with
curl localhost:80, it does not respond, and explain why that does not contradict the previous point. - Find out what processes run inside without using
docker exec.
Exercise 3: the healthcheck that lies
Create an aurora-api:fake-healthy image from auroralibros/aurora-api:1.2.0 that replaces the HEALTHCHECK with one that simply checks the process exists (CMD pgrep node || exit 1). Start it with no database and no cache and compare, with commands, the health state it reports against that of auroralibros/aurora-api:1.2.0 under the same conditions. Then answer: which of the two healthchecks is "better"? In what specific situation would the second one have saved you from an incident, and in which one would it have given you a false alarm?
Solutions
Solution to exercise 1
docker ps -a --filter name=autopsy --format "table {{.Names}}\t{{.Status}}"
docker inspect -f '{{.State.Status}} / exit code {{.State.ExitCode}}' autopsyCode 78 is in the 1–124 range, so it is the application's, not Docker's. A 125 would have meant an error in docker run itself, a 127 a non-existent executable and a 137 a signal. Here, the program decided to exit with that number, so the explanation is in its code and in its logs.
echo "--- stdout ---"; docker logs autopsy 2>/dev/null
echo "--- stderr ---"; docker logs autopsy 1>/dev/nullSeparating the streams is what gives the diagnosis: stdout has informational noise and stderr has the real cause. In a 500-line mixed-up log, that filter is the difference between finding it and not finding it.
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' autopsy
docker inspect -f 'Path: {{.Path}} | Args: {{.Args}}' autopsyMODE=production
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Path: sh | Args: [-c echo "[init] starting in $MODE mode"; echo "[init] API_KEY variable is missing" >&2; sleep 2; exit 78]Confirmed: MODE was defined and API_KEY is nowhere to be seen, which is exactly what stderr was complaining about.
docker inspect -f 'Started: {{.State.StartedAt}}{{println}}Finished: {{.State.FinishedAt}}' autopsy
docker rm autopsyIt lived 2.17 seconds, consistent with the sleep 2 in the command. That calculation is more useful than it looks: a container that lives for milliseconds usually fails at startup; one that lives for hours and then dies points to a memory leak or an external event.
Solution to exercise 2
docker run -d --name web-silent nginx:alpine
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' web-silent2. Checking that it responds, from netshoot:
Nginx works perfectly. Another way, sharing its network stack directly:
docker run --rm --network container:web-silent nicolaka/netshoot \
sh -c 'ss -tlnp; curl -s -o /dev/null -w "HTTP %{http_code}\n" localhost'3. From the host:
There is no contradiction at all: the container listens on port 80 of its own network stack, but since no port was published with -p, there is no rule forwarding the host's port 80 to it. The connectivity exists inside Docker's network (where netshoot lives) and does not exist from the host. It is exactly the distinction between "publishing a port" and "two containers talking to each other" that gets formalized in lesson 03-05.
4. Processes without 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 is run by the daemon on the host, so it does not need to get into the container or for the image to have ps. And as a bonus you can see Nginx's good practice: the master runs as root and the workers as user 101.
Solution to exercise 3
# ~/aurora-libros/api/Dockerfile.fake-healthy
FROM auroralibros/aurora-api:1.2.0
# pgrep ships with BusyBox, so nothing needs installing
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD pgrep node || exit 1docker build -f ~/aurora-libros/api/Dockerfile.fake-healthy -t aurora-api:fake-healthy ~/aurora-libros/api
docker run -d --name api-fake-healthy --env-file ~/aurora-libros/aurora.env aurora-api:fake-healthy
sleep 45
docker ps --filter name=api --format "table {{.Names}}\t{{.Status}}"Two containers identical in everything except their health check, and opposite verdicts. The one on the left says it is healthy; neither of the two can serve a single book.
docker inspect -f '{{json .State.Health}}' api-fake-healthy | jq '.Log[-1] | {ExitCode, Output}'
curl -s -o /dev/null -w "HTTP %{http_code}\n" localhost:3000/healthThe fake healthcheck answers 0 because pgrep node finds the process. And it is entirely right: the process is alive. It is simply measuring what does not matter.
The answers:
- The one in
auroralibros/aurora-api:1.2.0is better in this scenario, because it checks the real ability to provide service (an end-to-end HTTP request, which in turn queries the database and the cache) instead of the mere existence of a process. - When the second one (
pgrep) would have saved you: when the outage is in a dependency and you do not want all your replicas to declare themselves sick at once. If PostgreSQL goes down for 30 seconds, with the strict healthcheck the API's ten replicas all switch tounhealthysimultaneously, the orchestrator takes them out of rotation and you end up with a total outage instead of a degraded service still serving cached content. It is the well-known domino effect of healthchecks that are too deep. - When it would have given you a false alarm... or worse, none at all: when the Node process is alive but its event loop is blocked, its connection pool exhausted or it answers 500 to everything.
pgrepwould say "healthy" indefinitely while users see errors. That scenario is literally the one that motivatedHEALTHCHECKin lesson 02-04.
The professional solution, which you were already sketching in exercise 3 of lesson 02-04, is to separate two endpoints: one for liveness (/health/live, shallow, for deciding on restarts) and another for readiness (/health, deep, for deciding whether it receives traffic). It is the liveness/readiness distinction developed in lesson 06-05.
Conclusion
You have a method, and that is worth more than the list of commands: is it running? → what do the logs say? → how is it configured? → what is happening inside?, in that order and without skipping steps. You know that a Created points to a non-existent executable, that a 125 means the container was never created and there is nothing to inspect, and that a code between 1 and 124 sends you straight to docker logs.
You handle docker logs with its time windows, its --tail and its -f that you can cut with Ctrl+C without fear, and you have the golden rule engraved: containers log to stdout and stderr, never to a file, and you know how to separate the two streams with a redirection so the error appears on its own. You get in with docker exec choosing the right shell for the base, you know -u root gives you privileges even if the image declares USER node, and —most valuable of all— you know how to debug minimal images with no tools by launching an ephemeral netshoot container that shares the sick one's network and process namespaces, without dirtying the production image.
From docker inspect's JSON you extract the exact fact with Go templates and jq, with a table of queries for IP, networks, ports, mounts, environment, health, exit code and restarts, and you know that same command shows passwords in the clear, which is why environment variables are not a secrets mechanism. With docker top you see the processes from the host even if the image has no ps, with docker stats you read the seven columns —and you have discovered your containers have no memory limit at all— and with docker events you reconstruct what happened and when.
And you have closed the investigation you had been dragging along since module 2. aurora-api, aurora-db and aurora-cache are on the same network and with full connectivity between them: nc -zv 172.17.0.2 5432 answers succeeded. Only one thing fails, and now you can name it precisely: nslookup aurora-db returns NXDOMAIN, because the default bridge network has no internal DNS between containers. It is not a firewall, or a port, or PostgreSQL: it is that the API does not know its database's name.
All that is left is to apply the solution, and it is shorter than the diagnosis. In the next lesson, Docker Networking, you will see why each container has its own network stack and its own localhost, you will compare the bridge, host and none drivers, and you will understand the decisive difference between the default bridge and a user-defined network, where internal DNS resolves container names automatically. You will create aurora-net, recreate the three services inside it, and finally run that curl http://localhost:3000/books that has been waiting six lessons to give you back El jardín de senderos que se bifurcan, Rayuela and the other six titles. Then you will add aurora-web as a reverse proxy, and the Aurora Libros platform will be truly alive.
Docker: From Beginner to Advanced
Module 1: Introduction to Docker
- What Is Docker?
- Installing Docker
- Docker Architecture
- Basic Docker Commands
- Understanding Docker Images
- Creating Your First Docker Container
- The Course Project: The Aurora Libros Platform
Module 2: Working with Docker Images
- Docker Hub and Repositories
- Building Docker Images
- Dockerfile Basics
- Advanced Dockerfile Instructions
- Managing Docker Images
- Tagging and Publishing Images
Module 3: Docker Containers
- Running Containers
- Container Lifecycle
- Managing Containers
- Inspecting and Debugging Containers
- Docker Networking
- Data Persistence with Volumes
- Resource Limits and Restart Policies
Module 4: Docker Compose
- Introduction to Docker Compose
- Defining Services in Docker Compose
- Docker Compose Commands
- Multi-Container Applications
- Environment Variables in Docker Compose
- Profiles, Overrides and Multiple Environments
- Local Development with Docker Compose
Module 5: Advanced Docker Concepts
- Docker Networking Deep Dive
- Docker Storage Options
- Docker Security Best Practices
- Optimizing Docker Images
- Advanced Builds with BuildKit and Buildx
- Logging and Monitoring in Docker
- The Runtime Inside: Namespaces, Cgroups and Layers
Module 6: Docker in Production
- Preparing an Image for Production
- CI/CD with Docker
- Orchestrating Containers with Docker Swarm
- Introduction to Kubernetes
- Deploying Docker Containers in Kubernetes
- Scaling and Load Balancing
- Deployment Strategies and Rollback
