What Docker actually is
Docker is used by developers as a way to easily ship and configure applications. It does this by allowing the developer to define a repeatable, isolated environment with a simple configuration file. From the application’s point of view it is running on a Linux system with full network and filesystem access.
Sounds cool, how does it work? This lesson takes the container you ran in lesson 1 apart, from the inside and from the outside.
A container is a process
A container is an isolated environment, it is not a virtual machine. In many ways docker is really just a clever way to use several features included in the linux kernel.
Three kernel features do the heavy lifting:
- Namespaces control what a process can see. A separate PID namespace means its processes are numbered from 1 and it cannot see yours. A mount namespace gives it a different root filesystem. Network, UTS (hostname), IPC and user namespaces do the same for their own slice of the world.
- cgroups control what a process can use. CPU, memory, block IO, and how many processes it may spawn.
- A union filesystem (overlayfs, usually) stacks read-only image layers with one thin writable layer on top. This minimizes the files that get copied around.
The Docker daemon assembles and manages those three into something you can drive from a command line.
Just one kernel
If a container really is a process on your kernel, then a container claiming to be Ubuntu 22.04 will report your kernel version, not Ubuntu’s. Lets look:
$ uname -a
Linux slopkiddie 6.8.0-136-generic #136-Ubuntu SMP PREEMPT_DYNAMIC Wed Jul 1 21:53:05 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
$ docker run --rm ubuntu:22.04 uname -a
Linux 5b2238e84881 6.8.0-136-generic #136-Ubuntu SMP PREEMPT_DYNAMIC Wed Jul 1 21:53:05 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
Same kernel, same build date, different hostname. Ubuntu 22.04 shipped with
5.15. The container is running 5.15, because nothing in that
container is running a kernel at all. The image is a userland: libc, a package
manager, /etc.
Two consequences fall straight out of that:
- A kernel exploit that works in the container works on the host, because it is the same kernel. Keep that security boundary in mind if you are running hostile code.
- You cannot run a Linux container on a Windows kernel or a Darwin kernel. On
macOS and Windows, Docker Desktop is quietly running a Linux VM and your
containers live in it.
uname -aon a Mac will show a LinuxKit kernel that belongs to that VM.
What can we see from the OS?
First start something that stays alive, and then ask Docker what is running in it:
$ docker run -d --name probe ubuntu:22.04 tail -f /dev/null
f4dacd21217a328a25265187897dc022a71add02dcef26f25861e7c58fc82b7f
Above we started a container, named it probe and had it detached -d so it
will run in the background. It printed the container’s full ID.
A container lives exactly as long as its main process, so that main process has
to be something that does not finish. tail -f /dev/null blocks forever and
costs nothing, which makes it the usual way to keep a container parked and waiting.
To look inside that container, we use the docker exec command which lets us
execute a process inside a running container.
$ docker exec probe ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.5 0.0 2824 1544 ? Ss 15:04 0:00 tail -f /dev/null
root 7 0.0 0.0 7064 3108 ? Rs 15:04 0:00 ps aux
Remember a container is just a clever way to run processes in different
namespaces. We can use docker top to see the running processes for the
container. In this example it is PID 388733.
$ docker top probe
UID PID PPID C STIME TTY TIME CMD
root 388733 388708 0 15:04 ? 00:00:00 tail -f /dev/null
Now leave Docker out of it entirely and ask your host machine:
$ ps -o pid,ppid,cmd -p 388733
PID PPID CMD
388733 388708 tail -f /dev/null
There it is, in your process table. docker top and ps are looking at the
same process from the different namespace. From the container PID 1, and nothing else
on the machine is visible. That is the PID namespace doing its job.
Look around inside
Let’s get a shell and look at it. Here we use -it to run the container in
interactive mode and allocates a TTY:
$ docker run --rm -it ubuntu:22.04 bash
root@5b2238e84881:/# ls /
bin dev home lib32 libx32 mnt proc run srv tmp var
boot etc lib lib64 media opt root sbin sys usr
A complete Linux filesystem, and none of it is yours. Your /home is not in
there. This is Ubuntu 22.04’s userland, unpacked from the image.
Where does it come from?
root@5b2238e84881:/# mount | grep ' / '
overlay on / type overlay (rw,relatime,lowerdir=…/snapshots/283/fs:…/snapshots/68/fs,upperdir=…/snapshots/284/fs,workdir=…/snapshots/284/work)
root@5b2238e84881:/# df -h /
Filesystem Size Used Avail Use% Mounted on
overlay 62G 25G 34G 43% /
/ is an overlay: two read-only lower directories, which are the image layers,
and one writable upper directory, which is this container. 62G is your disk,
because that is where all of it lives.
Watch what the writable layer means in practice:
$ docker run --rm ubuntu:22.04 sh -c 'echo secret > /tmp/loot; ls /tmp'
loot
$ docker run --rm ubuntu:22.04 ls /tmp
Nothing the second time. Same image, new container, new empty upper layer. Every change you make lives in that one thin layer and dies with the container, which is exactly the property that makes containers good for breaking things and useless for keeping things. Lesson 4 is how you get work back out.
You can see the layer’s contents from the host side:
$ docker exec probe sh -c 'touch /tmp/loot; echo hi > /root/notes.txt'
$ docker diff probe
C /root
A /root/notes.txt
C /tmp
A /tmp/loot
A for added, C for changed, D for deleted. docker diff is a fast answer
to “what did this container touch”, and it is worth remembering when the
container is not yours.
What the kernel is actually limiting
Namespaces isolate things. cgroups limit them, and by default a container gets no limits at all:
$ docker run --rm ubuntu:22.04 cat /sys/fs/cgroup/memory.max
max
max means unlimited. This is okay for development, but always remember A
container with a runaway process will happily take the whole machine down with
it. We can limit this with:
$ docker run --rm --memory 256m --cpus 0.5 ubuntu:22.04 sh -c 'cat /sys/fs/cgroup/memory.max; cat /sys/fs/cgroup/cpu.max'
268435456
50000 100000
268435456 bytes is 256MB. 50000 100000 is the CPU quota: 50ms of runtime per
100ms period, which is the half a core we asked for. Those two files are the
cgroup, and everything --memory and --cpus do is write to them.
Here is the part that catches people out:
$ docker run --rm --memory 256m ubuntu:22.04 free -h
total used free shared buff/cache available
Mem: 7.8Gi 1.4Gi 196Mi 1.0Mi 6.2Gi 6.0Gi
$ docker run --rm --memory 256m ubuntu:22.04 nproc
2
The container is limited to 256MB, and free reports 7.8GB, because
free reads /proc/meminfo and /proc is not namespaced for this. The process
sees the host’s memory and the host’s CPU count right up until it is killed for
exceeding a limit it could not see.
For us that cuts both ways. Tools that size themselves from nproc will
misbehave in a constrained container. And when you land in a container on an
engagement, /proc/meminfo and /proc/cpuinfo are telling you about the host,
which is information the person who built that container did not mean to give
you.
The network, from both sides
From inside, a container looks like a small machine on a private LAN:
$ docker run --rm builder sh -c 'ip -br a; ip route'
lo UNKNOWN 127.0.0.1/8 ::1/128
eth0@if180 UP 172.17.0.3/16
default via 172.17.0.1 dev eth0
172.17.0.0/16 dev eth0 proto kernel scope link src 172.17.0.3
By default a container starts with one interface. Here we have an address on
172.17.0.0/16, and a default route to
172.17.0.1. That gateway is your host. Every container you start can reach
whatever your machine is listening on, which is worth remembering before you run
something you do not trust.
Name resolution is Docker’s too:
$ docker exec probe cat /etc/resolv.conf
# Generated by Docker Engine.
# This file can be edited; Docker Engine will not make further changes once it
# has been modified.
nameserver 192.168.6.11
search .
# Based on host file: '/run/systemd/resolve/resolv.conf' (legacy)
# Overrides: []
The daemon writes that file into each container at start, based on the host’s own resolver config. It is a copy, not a link, so a container started before you changed networks keeps pointing at the old nameserver.
From outside, the same facts come from docker rather than from a shell:
$ docker inspect probe --format 'name={{.Name}} ip={{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}} pid={{.State.Pid}}'
name=/probe ip=172.17.0.2 pid=388733
$ docker network inspect bridge --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{println}}{{end}}'
probe 172.17.0.2/16
docker inspect is the one to remember. It prints everything the daemon knows
about a container: its address, its mounts, its environment variables, its
capabilities. On a machine that is not yours, that is a very good first command.
Clean up
tail -f /dev/null never finishes, so probe is still running. Stop it:
$ docker kill probe
probe
That leaves a stopped container, which docker ps will not show you and
docker ps -a will:
$ docker ps -a --filter name=probe
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
f4dacd21217a ubuntu:22.04 "tail -f /dev/null" 2 seconds ago Exited (137) Less than a second ago probe
Exited (137) is the exit code, 128 plus signal 9, which is what docker kill
sends. The writable layer is still on disk and the name is still taken, so
starting another probe fails until this one is gone:
$ docker rm probe
probe
docker rm -f probe does both steps at once. That is the one to reach for when
you do not care why it is running.
The daemon does the work
You met the socket in lesson 1. Now that you have seen namespaces and cgroups,
the division of labour makes sense: docker on your command line is a thin
client that sends HTTP requests over /var/run/docker.sock, and the daemon on
the other end, running as root, does the namespace, cgroup and overlay work.
When it is not running, the client says so plainly:
$ docker ps
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
Every docker command in this course is a request to that daemon. Hold on to
that idea, because the last lesson is about what happens when the wrong process
can send those requests.
Four nouns
The rest of the tooling is a management interface over four kinds of object. Learn these four and the command surface stops being intimidating.
Images are the read-only templates, built in layers and identified by a repository, a tag, and a digest.
$ docker images
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
alpine:latest 28bd5fe8b56d 13MB 3.93MB
python:3.11 7bd2bbe21121 1.61GB 427MB
ubuntu:22.04 941f1899488c 228MB 61.3MB
DISK USAGE is what the image costs you unpacked, CONTENT SIZE is what came
down the wire compressed. Alpine at 13MB and Python at 1.6GB in the same list is
a fair summary of how much variation there is between base images.
Containers are running (or stopped) instances of an image, each with its own writable layer.
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
f4dacd21217a ubuntu:22.04 "tail -f /dev/null" 1 second ago Up 1 second probe
docker ps shows running containers only. docker ps -a includes the dead
ones, which pile up fast and are worth knowing about before your disk fills.
Networks are virtual switches. You get three out of the box and can make as many more as you like:
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
f5fb625502a3 bridge bridge local
2ec9652013e4 host host local
c3e7261750b3 none null local
bridge is the default and the one we looked at above. The other two, and the ones
you build yourself, are lesson 6.
Volumes are storage that outlives a container. We will mostly use the simpler cousin, bind mounts, in lesson 4.
The flags you will type the most
Almost every command in this course is docker run with some combination of
five flags:
| Flag | What it does |
|---|---|
-i |
Keep stdin open, so you can type at it |
-t |
Allocate a TTY, so the shell looks like a shell |
--rm |
Delete the container when it exits |
-v host:container |
Bind a directory from your machine into the container |
-w /path |
Start in that directory inside the container |
-it together is what you want for an interactive shell. --rm will
keep your machine clean: without it, every experiment leaves a stopped
container behind for you to cleanup manually.
Next we start using it, beginning with never installing a tool again.