Docker Deep Dive · Prerequisites · P6 of 7
Ports, DNS & Packages
Who is listening, on which address, and how names become numbers —
the three mechanics that lessons 4 and 6 rode on. Plus: why every Dockerfile chants
apt-get update && apt-get install in one breath.
Before you start
cd docker-deep-dive
git pull
cd p6-ports-dns-and-packages
This chapter ships a tiny Dockerfile; you'll build it in section 2.
If you're doing the prerequisites before Lesson 3, just paste the
docker build / docker run lines as-is — the course explains
every flag later. The port lab publishes host port 8080; if something on
your machine already uses it, swap in 18080 everywhere.
1.Packages first — because the room is bare
Every tool this lesson needs is missing from the stock Ubuntu image. That's not an accident — minimal images are the point (lesson 8). See for yourself:
docker run --rm ubuntu:24.04 bash -c 'for c in curl ip ss python3; do command -v "$c" || echo "$c: not found"; done'
curl: not found
ip: not found
ss: not found
python3: not found
A package manager fixes that. It keeps a local copy of an index — a catalog of every installable package in Ubuntu's online repositories — and when you ask for one thing, it computes everything that thing needs (dependency resolution) and unpacks the files into their FHS homes from P2.¹ Watch all three moves inside a throwaway container:
ls /var/lib/apt/lists/ # the index's home — empty in a fresh image!
apt-get update # move 1: download a fresh index (that's ALL this does)
apt-get install -y curl # move 2: resolve dependencies, download, unpack
dpkg -L curl | head # where did the files land? (P2's tree again)
curl --version | head -1
# apt-get update
Get:1 http://ports.ubuntu.com/ubuntu-ports noble InRelease [256 kB]
Get:5 http://ports.ubuntu.com/ubuntu-ports noble/universe arm64 Packages [19.0 MB]
...
# apt-get install -y curl
0 upgraded, 21 newly installed, 0 to remove and 7 not upgraded.
...
# dpkg -L curl
/usr/bin/curl
/usr/share/doc/curl/README.Debian
...
# curl --version
curl 8.5.0 (aarch64-unknown-linux-gnu) libcurl/8.5.0 OpenSSL/3.0.13 ...
Read those numbers back: you asked for one package and got
21 — curl needs OpenSSL, OpenSSL needs certificates, and apt walked
the whole chain for you. update upgraded nothing; it only
refreshed the catalog. And dpkg -L shows the binary landing in
/usr/bin, docs in /usr/share/doc — the standard tree,
every time.
The Dockerfile incantation, decoded
You've typed this since lesson 3:
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
Now each piece has a reason. One RUN, not three: if
update lived in its own layer, Docker's cache could reuse a
months-old index while re-running a new install line — package-not-found
errors from nowhere (lesson 3's cache rule).²
The rm at the end: that 19 MB index is useless at
runtime, so it's deleted inside the same layer before the layer is sealed
(lesson 8's weight rule). Alpine folds all three into one flag:
apk add --no-cache curl.
2.Build the lab, meet the interfaces
This chapter's Dockerfile bakes the tools in once so every lab
container starts equipped — exactly why real images install their dependencies at
build time, not at run time:
docker build -t netlab .
docker run --rm netlab ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 ...
inet 127.0.0.1/8 scope host lo
225: eth0@if226: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 ...
inet 172.17.0.3/16 brd 172.17.255.255 scope global eth0
Two network doors, and the whole lesson hangs on the difference:
lo, the loopback —127.0.0.1. A private hallway that never leaves the machine. And since a container has its own network namespace (P5), it has its own private hallway — the container's127.0.0.1and your machine's127.0.0.1are two different places.³eth0—172.17.0.3here. The door to the outside: Docker's bridge network (lesson 6). Addresses like10.x.x.x,172.16–31.x.x, and192.168.x.xare private ranges — valid only inside a local network, which is why Docker hands them out freely.
3.Ports — 65,536 numbered parking spots
An IP address gets a process to the right machine; a port
gets it to the right process. A TCP socket is the pair
address:port, and the kernel enforces one iron rule: one
listener per address:port. Ports are 16-bit numbers
(0–65535), and binding below 1024 needs root — or, in container land, the
CAP_NET_BIND_SERVICE capability you'll strip in lesson
11.⁴
Start a listener and catch it in the act — all inside one netlab container:
docker run -it --rm netlab bash
python3 -m http.server 8000 >/dev/null 2>&1 & # a real web server, backgrounded (P4)
ss -tlnp # t=tcp l=listening n=numeric p=process
curl -s localhost:8000 | head -4
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0 5 0.0.0.0:8000 0.0.0.0:* users:(("python3",pid=7,fd=3))
<!DOCTYPE HTML>
<html lang="en">
<head>
<meta charset="utf-8">
ss -tlnp is the "is it even up?" command you'll use for the rest of
your career: there sits python3, PID 7, holding
0.0.0.0:8000 in LISTEN state — and note the server answered through a
plain file descriptor, fd=3: sockets are just streams once connected
(P3). Now break it, twice, on purpose:
python3 -m http.server 8000 # failure 1: the spot is taken
curl localhost:9999 # failure 2: nobody's listening there
# second server on 8000:
OSError: [Errno 98] Address already in use
# curl to an empty port (instant, exit code 7):
curl: (7) Failed to connect to localhost port 9999 after 0 ms: Couldn't connect to server
Learn both faces now and you'll never debug them blind again.
"Address already in use" = one-listener rule; find the squatter with
ss -tlnp (this is also why two containers can't publish the same host
port, lesson 4). "Couldn't connect" after 0 ms = the network is
fine; the kernel checked its socket table, found no listener, and said so
(ECONNREFUSED). Instant failure is a clear answer, not a mystery.
ss -tlnp is how you read the table yourself.4.The bind rule — 127.0.0.1 vs 0.0.0.0
When a server binds, it picks which door to listen at.
127.0.0.1 means "loopback only — locals only". 0.0.0.0
means "every interface I have — loopback and eth0". Inside a container
that difference decides whether -p works at all. Run the experiment —
this one command pair explains half of all "my container is up but nothing
connects" bugs:
docker run -d --name p6-loop -p 8080:8000 netlab python3 -m http.server 8000 --bind 127.0.0.1
curl --max-time 3 localhost:8080 # through the published port... nothing
docker rm -f p6-loop
docker run -d --name p6-open -p 8080:8000 netlab python3 -m http.server 8000 --bind 0.0.0.0
curl -s localhost:8080 | head -4 # same server, one flag changed
docker rm -f p6-open
# bound to 127.0.0.1 — the published port is a dead end:
curl: (52) Empty reply from server
# bound to 0.0.0.0 — same image, same -p, now it answers:
<!DOCTYPE HTML>
<html lang="en">
<head>
<meta charset="utf-8">
docker run -d --name p6-loop -p 8080:8000 netlab python3 -m http.server 8000 --bind 127.0.0.1
curl.exe --max-time 3 localhost:8080
docker rm -f p6-loop
docker run -d --name p6-open -p 8080:8000 netlab python3 -m http.server 8000 --bind 0.0.0.0
curl.exe -s localhost:8080 | Select-Object -First 4
docker rm -f p6-open
Why: -p 8080:8000 forwards host traffic to the container's
eth0, port 8000. A server bound to 127.0.0.1 is only listening at
the container's private loopback — a different door in a different network namespace
(P5) — so the forwarded connection finds nobody.³
This is why Harbor Log's Flask app has said host="0.0.0.0" since lesson
4, and why 12-factor apps "export services via port binding" on all
interfaces.⁵
(python3 -m http.server binds 0.0.0.0 by default — the
default run in section 3 was already container-correct.⁶)
Why "empty reply" and not "refused"?
On a Linux host you'd typically see connection refused. On macOS and
Windows, Docker Desktop's port proxy accepts your connection first, then
discovers nobody answers inside the container and hangs up — so curl reports
(52) Empty reply from server. Same root cause, proxy-flavored symptom.
File the pattern: published port + connects-then-dies = check the app's bind
address first.
-p always delivers to the container's eth0. Bound to
127.0.0.1, the server watches only the private loopback door — the
delivery bounces. Bound to 0.0.0.0, it watches every door it has.5.Names — /etc/hosts, then DNS
Nobody curls 172.17.0.3 by choice. When a program asks for a
name, the resolver checks two places, in order: the local override file
/etc/hosts first, then the DNS server listed in
/etc/resolv.conf.⁷
Both are plain files (P2's motto pays off again) — so you can watch, and even edit,
name resolution:
python3 -m http.server 8000 >/dev/null 2>&1 &
echo "127.0.0.1 myapp" >> /etc/hosts # invent a name, point it at loopback
curl -s myapp:8000 | head -2 # the made-up name now resolves!
<!DOCTYPE HTML>
<html lang="en">
Miss in /etc/hosts? Then it's a DNS query. Here's the payoff for
lesson 6's "containers find each other by name" — put two containers on a
user-defined network and look at who answers DNS:
docker network create p6-net
docker run -d --name p6-db --network p6-net netlab sleep 600
docker run -it --rm --network p6-net netlab bash
cat /etc/resolv.conf # who answers name lookups here?
getent hosts p6-db # resolve a name exactly the way the OS does
dig +short example.com # and real internet DNS still works too
# /etc/resolv.conf
nameserver 127.0.0.11
options ndots:0
# getent hosts p6-db
192.168.0.2 p6-db
# dig +short example.com
104.20.23.154
172.66.147.243
There's lesson 6's magic with the cover off: on a user-defined network, Docker
plants an embedded DNS server at 127.0.0.11 inside
every container, and it knows container names.³
p6-db isn't special syntax — it's an ordinary DNS lookup answered by
Docker instead of the internet. Names are lookups all the way up: hosts file →
local resolver → the world.
docker rm -f p6-db
docker network rm p6-net
6.The whole journey, animated
Everything in this lesson in one trip — what actually happens in the instant
after a container runs curl http://p6-db:8000/. Step through it:
- Resolve the name: /etc/hosts first — miss — then Docker's embedded DNS at 127.0.0.11 answers: p6-db = 192.168.0.2.
- curl asks the kernel through syscalls — socket(), then connect() — P5's only doorway, in the wild.
- The connection request (SYN) leaves through web's eth0 and crosses the bridge to 192.168.0.2, port 8000.
- The receiving kernel checks its socket table: 0.0.0.0:8000 is in LISTEN — python3 accepts.
- Connected. From here it's plain reads and writes on file descriptors — sockets are streams (P3).
- The failure timeline: had the server bound 127.0.0.1, the eth0 delivery would find no listener — connection refused. The bind rule decides everything.
cat /etc/hosts,
cat /etc/resolv.conf, ip addr, ss -tlnp. When
a connection fails, walk this exact chain.Run the chapter's ./check.sh when you're done — it verifies your
netlab image and the server loop end to end. (It's a bash script that drives docker
from the host: bash check.sh from the chapter folder on macOS / Linux /
WSL; on Windows, run it inside WSL or Git Bash.)
You can now
- Install anything in a Debian/Ubuntu container — and explain every clause of the
update && install && rmDockerfile incantation. - Read the kernel's listener table with
ss -tlnp, and decode "address already in use" and "connection refused" on sight. - State the bind rule —
127.0.0.1is namespace-private,0.0.0.0is every door — and fix the "published port answers nothing" bug it causes. - Trace any name to an address:
/etc/hosts→ resolver in/etc/resolv.conf→ Docker's 127.0.0.11 for container names.
7.Check yourself
A container app listens on 127.0.0.1:8000 and you
ran it with -p 8080:8000. What happens when your machine curls
localhost:8080?
- the request reaches the app normally
- the proxy connects but nobody answers
- docker rewrites the bind address automatically
- the kernel blocks the published port
What does apt-get update actually do?
- upgrades every installed package immediately
- installs pending security updates only
- downloads a fresh package index
- rebuilds the docker layer cache
curl localhost:9999 fails instantly with
"Couldn't connect to server". What does that prove?
- no process is listening on that port
- the network stack itself is broken here
- a firewall silently dropped the request packets
- the port number is reserved for root
Inside a container on a user-defined network, in what order is
the name db turned into an address?
/etc/hosts is checked first; on a miss, the query goes to the nameserver in /etc/resolv.conf — which Docker sets to its embedded DNS at 127.0.0.11, and that server knows the names of containers on the same network.
Why must apt-get update and
apt-get install share one RUN line in a Dockerfile?
Layers cache independently: in separate RUNs, a months-old cached index layer can pair with a new install line and fail on missing or stale packages. One RUN refreshes the index, installs, and deletes the index inside a single layer — correct and small.
8.Go deeper
Primary sources: Docker's networking overview (bridges, published ports, the embedded DNS) and Ubuntu's package management guide (apt from the source). For the name-resolution big picture, Cloudflare's What is DNS? is the clearest short read.
Next: you've been typing commands for six lessons —
P7 teaches you to put them in
files that survive you: real bash scripts with a safety net, and the
exec "$@" entrypoint pattern every production image uses. After that,
the main course: Lesson 1 — Your First Container, published here
as the course progresses — watch the index.
Stuck? Curious?
Bring questions to class, or open an issue on the course repo — include the command you ran and the output you got. The quizzes above are for self-checking: commit to an answer before revealing it, and re-try anything you missed tomorrow.
Sources
- Ubuntu Server docs — Package management (apt index, install, dependency resolution)
- Docker Docs — Building best practices (apt-get update && install in one RUN; cleaning lists)
- Docker Docs — Networking (bridge networks, port publishing, embedded DNS at 127.0.0.11)
- man7 — capabilities(7) (CAP_NET_BIND_SERVICE for ports below 1024)
- The Twelve-Factor App — Port binding
- Python docs — http.server (default bind = all interfaces;
--bind) - Cloudflare Learning — What is DNS?