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.

⏱ ~30 min hands-on · repo chapter p6-ports-dns-and-packages · cheatsheet: Linux & shell

prereq P6 / 7

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:

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.

the kernel's socket table — one listener per address:port 0.0.0.0:8000 LISTEN · python3 (pid 7) bind :8000 again? ✗ Errno 98 — already in use *:9999 (no listener) curl :9999 ✗ connection refused — nobody home
Both failures are the same table. Binding checks the table for a free slot; connecting checks it for an occupied one. "Address already in use" and "connection refused" are the two ways the kernel says no — and 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.

bind 127.0.0.1 — dead end Mac curl :8080 container (own net namespace) eth0 :8000 lo 127.0.0.1 server ✗ -p lands at eth0: nobody listening bind 0.0.0.0 — answers Mac curl :8080 container (own net namespace) eth0 :8000 lo 127.0.0.1 server ✓ listening at BOTH doors
Bind address = which doors the server watches. -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:

container "web" curl p6-db:8000 container "p6-db" python3 · pid 7 p6-net — Docker bridge network eth0 eth0 /etc/hosts → miss DNS 127.0.0.11: p6-db = 192.168.0.2 socket() · connect() → kernel (P5) SYN → 192.168.0.2:8000 out eth0, across the bridge socket table: 0.0.0.0:8000 LISTEN ✓ → accept established — just fd streams now (P3) if bound to 127.0.0.1:8000… eth0 delivery → ✗ refused
  1. Resolve the name: /etc/hosts first — miss — then Docker's embedded DNS at 127.0.0.11 answers: p6-db = 192.168.0.2.
  2. curl asks the kernel through syscalls — socket(), then connect() — P5's only doorway, in the wild.
  3. The connection request (SYN) leaves through web's eth0 and crosses the bridge to 192.168.0.2, port 8000.
  4. The receiving kernel checks its socket table: 0.0.0.0:8000 is in LISTEN — python3 accepts.
  5. Connected. From here it's plain reads and writes on file descriptors — sockets are streams (P3).
  6. 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.
Name → address → door → listener. Four checks, and every one of them is inspectable: 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

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?

  1. the request reaches the app normally
  2. the proxy connects but nobody answers
  3. docker rewrites the bind address automatically
  4. the kernel blocks the published port

What does apt-get update actually do?

  1. upgrades every installed package immediately
  2. installs pending security updates only
  3. downloads a fresh package index
  4. rebuilds the docker layer cache

curl localhost:9999 fails instantly with "Couldn't connect to server". What does that prove?

  1. no process is listening on that port
  2. the network stack itself is broken here
  3. a firewall silently dropped the request packets
  4. 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

  1. Ubuntu Server docs — Package management (apt index, install, dependency resolution)
  2. Docker Docs — Building best practices (apt-get update && install in one RUN; cleaning lists)
  3. Docker Docs — Networking (bridge networks, port publishing, embedded DNS at 127.0.0.11)
  4. man7 — capabilities(7) (CAP_NET_BIND_SERVICE for ports below 1024)
  5. The Twelve-Factor App — Port binding
  6. Python docs — http.server (default bind = all interfaces; --bind)
  7. Cloudflare Learning — What is DNS?