You have spent six modules using containers. This lesson explains why they work. The thesis is uncomfortable and liberating at the same time: a container does not exist. There is no "container" entity in the Linux kernel. There are ordinary processes with three mechanisms applied on top, and every one of them can be handled by hand.

Contents

  1. The thesis: three mechanisms and no object
  2. The seven namespaces
  3. The same process, two different PIDs
  4. /proc/<pid>/ns/, lsns and comparing containers
  5. Getting in by hand with nsenter
  6. Creating a namespace from scratch with unshare
  7. The user namespace and UID remapping
  8. Cgroups v2: the hierarchy
  9. aurora-db's limits, read from the kernel
  10. The OOM killer through memory.events
  11. OverlayFS: the real mount
  12. Copy-on-write in the upperdir
  13. Capabilities and seccomp at kernel level
  14. What runc actually does
  15. Docker Desktop: all of this happens in a VM

  1. The thesis: three mechanisms and no object

When you run docker run, no magical structure is created. An ordinary process is launched and three things are applied to it:

Mechanism Answers What you see in Docker
Namespaces What does the process see? Isolation of processes, network, files and users
Cgroups How much can it consume? --memory, --cpus, --pids-limit
Layered filesystem What filesystem does it have? Images, layers, copy-on-write
flowchart TB
  P["An ordinary Linux process<br/>(node src/server.js)"]
  NS["NAMESPACES<br/>pid · net · mnt · uts<br/>ipc · user · cgroup"]
  CG["CGROUPS v2<br/>memory.max · cpu.max<br/>pids.max · io.max"]
  FS["OVERLAYFS<br/>lowerdir + upperdir<br/>= merged"]
  P --> NS --> C(("What we call<br/>a «container»"))
  P --> CG --> C
  P --> FS --> C
  K["The host kernel: ONE, shared by everybody"] --- C

Everything else —images, registries, Compose, healthchecks— is machinery built around those three kernel primitives.

  1. The seven namespaces

A namespace is a partial view of a global system resource. The processes in a namespace see their version of the resource and not anybody else's.

Namespace Isolates Docker option
pid The process tree --pid=host disables it; --pid=container:X shares it
net Interfaces, routes, iptables, ports --network (lesson 05-01)
mnt Mount points The container's root and every -v
uts Host name and domain name --hostname
ipc Shared memory, message queues --ipc=host, --shm-size
user UID and GID mapping --userns-remap, rootless mode (lesson 05-03)
cgroup The view of the cgroup hierarchy --cgroupns

An eighth one, time (the monotonic clock), has existed in the kernel since 5.6 but Docker does not use it yet.

  1. The same process, two different PIDs

This is the demonstration that convinces people fastest.

docker compose exec aurora-api ps -eo pid,comm
pid=$(docker inspect aurora-libros-aurora-api-1 --format '{{.State.Pid}}')
ps -o pid,ppid,user,comm -p "$pid"
PID   COMMAND          <- from INSIDE the container
    1 node
   28 ps

  PID  PPID USER     COMMAND     <- from the HOST
48122  4791 1000     node

It is the same process. Inside it is PID 1 —hence everything you learned in lesson 03-02 about signals and PID 1—; on the host it is 48122, a child of the containerd shim and running as user 1000. There is no virtual machine: there is a node in your process list that happens to see a different tree from yours.

sudo grep -E '^(Name|NSpid|Uid)' /proc/$pid/status
Name:   node
NSpid:  48122  1
Uid:    1000    1000    1000    1000

NSpid says it all: 48122 in the host's namespace, 1 in its own. One process with two simultaneous identities.

  1. /proc/<pid>/ns/, lsns and comparing containers

Each namespace is a special file whose inode identifies it:

sudo ls -l /proc/$pid/ns/ | awk '{print $9, $10, $11}'
cgroup -> cgroup:[4026532897]
ipc -> ipc:[4026532835]
mnt -> mnt:[4026532833]
net -> net:[4026532838]
pid -> pid:[4026532836]
user -> user:[4026531837]
uts -> uts:[4026532834]

Comparing two containers is comparing those numbers:

pdb=$(docker inspect aurora-libros-aurora-db-1 --format '{{.State.Pid}}')
for ns in net pid mnt user; do
  a=$(sudo readlink /proc/$pid/ns/$ns); b=$(sudo readlink /proc/$pdb/ns/$ns)
  [ "$a" = "$b" ] && echo "$ns: SHARED" || echo "$ns: different"
done
echo "host user ns: $(sudo readlink /proc/1/ns/user)"
net: different
pid: different
mnt: different
user: SHARED
host user ns: user:[4026531837]

The most revealing result is the last one: aurora-api and aurora-db share the user namespace, and it happens to be the host's. Without --userns-remap or rootless mode, UID 0 inside is literally UID 0 on the host. There it is, measured: the claim from lesson 05-03 about why root in a container is dangerous.

sudo lsns -t net -o NS,PID,COMMAND | head -4
# 4026532838 48122 node src/server.js
# 4026532901 48310 postgres
# 4026532955 48502 nginx: master process

  1. Getting in by hand with nsenter

docker exec is not magic: it enters the process's namespaces and launches a command. nsenter does the same thing, and without going through Docker.

sudo nsenter -t "$pid" -m -u -i -n -p -- sh -c 'hostname; ls /; ip -brief addr; ps -eo pid,comm'
a91f3c8d2e10
app  bin  dev  etc  home  lib  proc  root  sys  tmp  usr  var
eth0   UP   172.21.0.5/16
  PID COMMAND
    1 node

The flags are exactly the namespaces: -m (mnt), -u (uts), -i (ipc), -n (net), -p (pid). You can enter only some of them, and that is what makes the technique useful: -n on its own gives you the container's network with the host's tools —precisely what you were doing in lesson 05-01— and it works even with distroless images that do not have an sh inside.

sudo nsenter -t "$pid" -n ss -tnp state established | head -3

  1. Creating a namespace from scratch with unshare

To see that there is no magic, let's build one by hand.

sudo unshare --pid --fork --mount-proc --uts --net --mount \
  sh -c 'hostname aurora-manual; ps -eo pid,comm; ip -brief addr; hostname'
  PID COMMAND
    1 sh
    5 ps
lo     DOWN
aurora-manual

No Docker, no images and no daemon: a shell that believes it is PID 1, with its own host name and an empty network stack. That is a container's isolation. What Docker adds on top is everything else: the image's filesystem, the network configuration with its veth and its bridge, the cgroups, the capabilities, seccomp and an API to manage it all.

  1. The user namespace and UID remapping

The user namespace lets the same UID mean different things inside and outside. It is the basis of rootless mode (lesson 05-03).

unshare --user --map-root-user sh -c 'id -u; cat /proc/self/uid_map; cat /etc/shadow' 2>&1 | tail -3
id -u
0
         0       1000          1
cat: /etc/shadow: Permission denied
1000

Inside you are root (id -u = 0) without having used sudo; the map says that UID 0 inside corresponds to 1000 outside, with length 1. Outside you are still 1000.

Root inside, but the kernel checks the file's permissions against the real host UID. That is the exact mechanism that makes the escalation from lesson 05-03 stop working in rootless mode: it is not a check Docker adds, it is UID arithmetic in the kernel.

  1. Cgroups v2: the hierarchy

Control groups limit and account for resources. Version 2, the unified one, has been the standard since 2022 and is exposed as a directory tree under /sys/fs/cgroup.

docker info --format 'Cgroup Driver: {{.CgroupDriver}} | Version: {{.CgroupVersion}}'
ls /sys/fs/cgroup/system.slice/ | grep docker | head -1
Cgroup Driver: systemd | Version: 2
docker-a91f3c8d2e10....scope
Controller Key files What it governs
memory memory.max, memory.current, memory.high, memory.events RAM and the OOM killer trigger
cpu cpu.max, cpu.stat, cpu.weight CPU quota and throttling
io io.max, io.stat Disk bandwidth and IOPS
pids pids.max, pids.current Number of processes (fork-bomb defense)

The essential difference from v1: in v2 a process belongs to a single cgroup and every controller acts on that one, instead of a separate hierarchy per resource. That simplifies the reasoning enormously.

  1. aurora-db's limits, read from the kernel

Now the check that closes the circle with lesson 03-07 and with Compose: what you declared in YAML, read straight from the kernel.

id=$(docker inspect aurora-libros-aurora-db-1 --format '{{.Id}}')
cg=/sys/fs/cgroup/system.slice/docker-$id.scope
for f in memory.max memory.current memory.high pids.max pids.current cpu.max; do
  printf '%-16s %s\n' "$f" "$(cat $cg/$f)"
done
memory.max       2147483648
memory.current   432791552
memory.high      max
pids.max         200
pids.current     14
cpu.max          200000 100000

Line by line: memory.max is 2,147,483,648 bytes = 2 GiB, the limits: { memory: 2G } from compose.prod.yaml; memory.current is what docker stats shows; pids.max is pids_limit: 200; and cpu.max with 200000 100000 means 200 ms of CPU for every 100 ms period, that is, cpus: "2.0".

docker stats --no-stream --format '{{.Name}} {{.MemUsage}}' aurora-libros-aurora-db-1
# aurora-libros-aurora-db-1 412.7MiB / 2GiB

An exact match. docker stats calculates nothing: it reads these files. And cpu.max finally explains the semantics of --cpus: it is not a number of assigned cores, it is a time quota per period; with 2.0, the container can consume 200 ms of CPU every 100 ms, spread across however many cores there are.

  1. The OOM killer through memory.events

docker run -d --name victim --memory 64m --memory-swap 64m alpine:3 \
  sh -c 'x=""; while true; do x="$x$(head -c 1048576 /dev/zero | tr "\0" "a")"; done'
sleep 6
id=$(docker inspect victim --format '{{.Id}}')
cat /sys/fs/cgroup/system.slice/docker-$id.scope/memory.events 2>/dev/null || echo "(cgroup already removed)"
docker inspect victim --format 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}}'
docker rm -f victim
low 0      high 0      max 412      oom 1      oom_kill 1
exit=137 oom=1

The counters tell the whole story: max 412 is the number of times the process hit its limit and the kernel had to reclaim memory; oom 1 says there came a point where it could reclaim no more; oom_kill 1 is the execution. The outcome, exit code 137, which you have known since lesson 03-02, is simply 128 + 9: terminated by SIGKILL.

A high max with oom_kill 0 is an extremely valuable signal that almost nobody watches: the container has not died, but it is fighting its limit and paying for it in latency. It is exactly what the MemoryNearLimit alert from lesson 05-06 catches before it is too late.

  1. OverlayFS: the real mount

docker compose exec aurora-api sh -c 'mount | grep " / "'
overlay on / type overlay (rw,relatime,lowerdir=/var/lib/docker/overlay2/l/QW3F:/var/lib/docker/overlay2/l/K7RT:/var/lib/docker/overlay2/l/P2MN,upperdir=/var/lib/docker/overlay2/9be21c/diff,workdir=/var/lib/docker/overlay2/9be21c/work)

The container's root / is an overlay mount, with the four directories from lesson 05-02. And the lowerdirs correspond one to one with the image's layers:

docker image inspect auroralibros/aurora-api:1.3.0 --format '{{len .RootFS.Layers}} layers'
docker compose exec aurora-api sh -c 'mount|grep " / "' | grep -o 'lowerdir=[^,]*' | tr ':' '\n' | wc -l
# 3 layers
# 3

Three layers in the image manifest, three lowerdirs in the mount. The correspondence is literal: every image layer is a directory on the host's disk, and OverlayFS stacks them in order.

  1. Copy-on-write in the upperdir

up=$(docker inspect aurora-libros-aurora-api-1 --format '{{.GraphDriver.Data.UpperDir}}')
docker compose exec aurora-api sh -c 'echo "note" > /tmp/note.txt'           # 1) create
docker compose exec aurora-api sh -c 'echo "// touched" >> /app/package.json' # 2) modify
docker compose exec aurora-api sh -c 'rm -f /app/src/util.js'                # 3) delete
sudo ls -l "$up/tmp/note.txt" "$up/app/src/util.js"
docker diff aurora-libros-aurora-api-1 | head -3
-rw-r--r-- 1 node node    5 Aug  5 11:02 .../diff/tmp/note.txt
c--------- 1 root root 0, 0 Aug  5 11:02 .../diff/app/src/util.js
A /tmp/note.txt
C /app/package.json
D /app/src/util.js

The three operations, with their three distinct effects in the upperdir:

  • A new file: it appears as it is. That is the A in docker diff.
  • A modified file: the kernel performs a copy-up —copying the whole original from the lowerdir— and writes on top of it. That is the C, and it is the expensive operation you measured in lesson 05-02.
  • A deleted file: a character device 0/0 appears, the famous whiteout. It deletes nothing: it hides the file from the lower layer, which still takes up space. That is the D, and it is the reason why deleting in a later layer does not slim down an image (lesson 05-04).

There it is, in three lines of ls: the physical explanation of lessons 01-05, 05-02 and 05-04.

  1. Capabilities and seccomp at kernel level

The capabilities from lesson 05-03 are a bitmask in the process descriptor.

sudo grep -E '^(CapEff|CapBnd|Seccomp|NoNewPrivs)' /proc/$pid/status
capsh --decode=$(sudo awk '/^CapEff/{print $2}' /proc/$pid/status)
CapEff: 0000000000000000
CapBnd: 00000000a80425fb
Seccomp:        2
NoNewPrivs:     1
0x0000000000000000=

CapEff at zero: aurora-api runs with no effective capabilities at all, exactly as you asked for with cap_drop: [ALL]. Compare it with a default container:

docker run -d --name defaults alpine:3 sleep 60
p2=$(docker inspect defaults --format '{{.State.Pid}}')
capsh --decode=$(sudo awk '/^CapEff/{print $2}' /proc/$p2/status) | tr ',' '\n' | head -4
docker rm -f defaults
0x00000000a80425fb=cap_chown
cap_dac_override
cap_fowner
cap_kill

Docker grants fourteen capabilities by default. The difference between that list and a CapEff of zero is the whole surface you have removed, and now you see it for what it is: bits in /proc.

Seccomp: 2 means BPF filter mode is active (0 would be disabled); NoNewPrivs: 1 is the no-new-privileges:true from compose.prod.yaml. Everything you configured in YAML ends up as two numbers in a file under /proc.

  1. What runc actually does

The chain from lesson 01-03, now seen from the inside: dockerdcontainerdshimrunc → process.

runc is a small binary that receives a directory with two things: the already-mounted filesystem and a config.json carrying the container's OCI specification.

{
  "ociVersion": "1.2.0",
  "process": {
    "user": { "uid": 1000, "gid": 1000 },
    "args": ["node", "src/server.js"],
    "capabilities": { "bounding": [], "effective": [], "permitted": [] },
    "noNewPrivileges": true
  },
  "root": { "path": "rootfs", "readonly": true },
  "linux": {
    "namespaces": [ { "type": "pid" }, { "type": "network" }, { "type": "ipc" },
                    { "type": "uts" }, { "type": "mount" }, { "type": "cgroup" } ],
    "resources": {
      "memory": { "limit": 536870912 },
      "cpu": { "quota": 200000, "period": 100000 },
      "pids": { "limit": 200 }
    },
    "seccomp": { "defaultAction": "SCMP_ACT_ERRNO" }
  }
}

You recognize every line: they are your docker run and Compose options translated into the standard. runc's steps are: create the namespaces (clone with the CLONE_NEW* flags), write the cgroups, pivot the root to rootfs (pivot_root), apply capabilities and seccomp, and finally execve the command. That is where its job ends: runc exits, and the process is left orphaned under the shim.

That is the role of the shim (containerd-shim-runc-v2): keeping stdin/stdout/stderr open, reporting the exit code and —crucially— allowing dockerd to restart without killing your containers, because the parent is not the daemon but the shim.

ps -o pid,ppid,comm -p "$pid"; ps -o pid,comm -p "$(ps -o ppid= -p $pid | tr -d ' ')"
# 48122  4791 node
#  4791 containerd-shim

The fact that all of this is an open standard (the OCI Runtime Specification) is what lets you swap runc for crun, youki or gVisor without changing anything else. That ecosystem is lesson 07-05.

  1. Docker Desktop: all of this happens in a VM

If you are on macOS or Windows, none of the above happens in your operating system: namespaces and cgroups are exclusively Linux kernel mechanisms. Docker Desktop runs a lightweight Linux virtual machine and the daemon lives inside it.

Aspect Linux macOS / Windows with Docker Desktop
Where the daemon runs In your kernel In a Linux VM
/var/lib/docker A path on your disk Inside the VM's virtual disk
The host's ps aux You see the containers' processes You do not: they are in the VM
Bind mounts Zero cost They cross the VirtioFS/WSL 2 boundary: slower
nsenter, lsns, docker0, cgroups Direct Only inside the VM

To reproduce this lesson from macOS or Windows, get into the VM:

docker run -it --rm --privileged --pid=host justincormack/nsenter1 /bin/sh
# once inside the VM:
ls /sys/fs/cgroup/ && lsns -t pid | head -3

And this explains earlier lessons along the way: the slowness of bind mounts (05-02 and 04-07) is not Docker's doing, it is the boundary between two filesystems; and that is why paths and processes "do not show up" where you expect them.

Common Mistakes and Tips

Believing a container is a lightweight machine. It is a process with three mechanisms on top and one single shared kernel. That is where the whole of lesson 05-03 comes from.

Looking for cgroups v1 on a modern system. Since 2022 it has been unified v2: one cgroup per process and different files (memory.max, not memory.limit_in_bytes). And do not write directly into /sys/fs/cgroup: Docker overwrites it when it reconciles; use docker update or Compose.

Running nsenter without -p and expecting to see the isolated process tree. Each namespace is entered separately; without -p you will see the host's processes.

Assuming that an oom_kill 0 means everything is fine. A high max indicates constant memory pressure and latency, even though nobody has died.

Trying lsns or /sys/fs/cgroup from macOS. They do not exist outside Linux. Get into Docker Desktop's VM.

Tip: when something does not add up, go down a level. A limit that seems not to apply is checked in memory.max; a networking problem, in /proc/<pid>/ns/net and nsenter -n; a file that appears or disappears, in the upperdir. The kernel does not lie and it can always be queried.

Exercises

Exercise 1. Prove that a container is a host process: find aurora-api's real PID, check that inside it is PID 1, verify with NSpid that it is the same process, and enter its network namespace with nsenter without using docker exec.

Exercise 2. Check that Compose's limits are exactly the cgroup files: read memory.max, cpu.max and pids.max for aurora-db, translate them back into the options that produced them, change them with docker update and verify that the file changes on the fly.

Exercise 3. Trigger the three copy-on-write operations (create, modify and delete) in aurora-api and locate their three distinct effects in the upperdir. Explain which of the three explains why deleting files does not slim down an image.

Solutions

Solution 1.

pid=$(docker inspect aurora-libros-aurora-api-1 --format '{{.State.Pid}}')
echo "PID on the host: $pid"
docker compose exec aurora-api sh -c 'echo "PID inside: $$"'
sudo grep NSpid /proc/$pid/status
sudo nsenter -t "$pid" -n ip -brief addr
sudo nsenter -t "$pid" -n -p -m ps -eo pid,comm | head -2
PID on the host: 48122
PID inside: 1
NSpid:  48122   1
eth0   UP   172.21.0.5/16
eth1   UP   172.22.0.4/16
  PID COMMAND
    1 node

NSpid: 48122 1 is the definitive proof: a single process with two identities, the host namespace's and its own. There is no copy, no emulation and no virtual machine; there is a mapping table in the kernel.

And nsenter demonstrates the second point: you have obtained exactly what docker exec gives you without talking to the daemon at any moment. That has two practical consequences. The first is diagnostic: if the daemon is hung or the image is distroless and has no sh, nsenter still works because it uses the host's binaries against the container's namespaces. The second is about security: anybody with root on the host gets into any container without leaving a trace in Docker's logs, which reinforces why root access to the host is the boundary that really matters.

Solution 2.

id=$(docker inspect aurora-libros-aurora-db-1 --format '{{.Id}}')
cg=/sys/fs/cgroup/system.slice/docker-$id.scope
printf 'memory.max %s | cpu.max %s | pids.max %s\n' \
  "$(cat $cg/memory.max)" "$(cat $cg/cpu.max)" "$(cat $cg/pids.max)"
memory.max 2147483648 | cpu.max 200000 100000 | pids.max 200
Kernel file Value The option that produced it
memory.max 2,147,483,648 B = 2 GiB deploy.resources.limits.memory: 2G
cpu.max 200000 / 100000 cpus: "2.0" (200 ms for every 100 ms)
pids.max 200 pids_limit: 200
docker update --memory 1g --memory-swap 1g --cpus 1.5 aurora-libros-aurora-db-1
printf 'memory.max %s | cpu.max %s\n' "$(cat $cg/memory.max)" "$(cat $cg/cpu.max)"
docker stats --no-stream --format '{{.MemUsage}}' aurora-libros-aurora-db-1
memory.max 1073741824 | cpu.max 150000 100000
412.7MiB / 1GiB

The change is instant and requires no container restart: cgroups v2 lets you rewrite the limit on the fly, and the process does not even notice until it tries to exceed it. docker stats reflects the new value because, literally, it reads that file.

The underlying reading is that docker run --memory and deploy.resources are not Docker abstractions: they are a convenient way of writing a number into /sys/fs/cgroup. And the interpretation of cpu.max clears up the most common confusion in the course: --cpus 1.5 does not reserve one and a half cores, it grants 150 ms of CPU time every 100 ms period, which can be spread across the host's eight cores.

Solution 3.

up=$(docker inspect aurora-libros-aurora-api-1 --format '{{.GraphDriver.Data.UpperDir}}')
docker compose exec aurora-api sh -c 'echo hello > /tmp/new.txt
  echo "//x" >> /app/package.json
  rm -f /app/src/util.js'
sudo stat -c '%n -> %F (%s bytes)' "$up/tmp/new.txt" "$up/app/package.json" "$up/app/src/util.js"
docker diff aurora-libros-aurora-api-1 | grep -E 'new|package.json|util.js'
.../diff/tmp/new.txt -> regular file (5 bytes)
.../diff/app/package.json -> regular file (612 bytes)
.../diff/app/src/util.js -> character special file (0 bytes)
A /tmp/new.txt
C /app/package.json
D /app/src/util.js

Three operations, three physically distinct representations in the upperdir:

Operation In the upperdir docker diff
Create An ordinary 5-byte file A
Modify An ordinary 612-byte file: the complete copy, not the delta C
Delete A character device 0/0 of 0 bytes: a whiteout D

The third one explains the phenomenon from lesson 05-04. Deletion removes nothing from the lowerdir —it is read-only, it cannot—: it creates a special marker that makes OverlayFS hide the file when it resolves the unified view. Inside the container, ls answers No such file or directory; on disk, the original file is still intact in its layer, downloaded on every docker pull and stored on every node.

Hence the rule you applied with the &&, which now has its full explanation: if you create and delete inside the same RUN, both operations happen before the layer is consolidated, and the file never comes to exist in anybody's lowerdir. And that is why a credential written and deleted in separate instructions is still extractable with docker save: it is not a Docker bug, it is how a union filesystem works.

And look at the second row, which has consequences of its own: modifying one byte stores the entire file in the upperdir. With package.json that is 612 bytes; with a PostgreSQL data file it would be hundreds of megabytes. That, measured in lesson 05-02 and explained here, is the physical reason databases use volumes.

Conclusion

A container does not exist. What exists is an ordinary Linux process —you have seen it with its real PID in the host's ps and its NSpid carrying two identities— to which the kernel applies three mechanisms: namespaces for what it sees, cgroups for what it consumes and OverlayFS for the filesystem it has. You know the seven namespaces and which Docker option leans on each one, you know how to read them in /proc/<pid>/ns/, compare them with lsns, enter them by hand with nsenter without going through the daemon, and create one from scratch with unshare, confirming that the isolation is nothing magical. And you have discovered, by comparing inodes, that without rootless or --userns-remap all your containers share the host's user namespace: the exact explanation of why root inside is root outside.

You have read your own limits in the kernel: memory.max with the 2 GiB from compose.prod.yaml, pids.max with the 200, and cpu.max with 200000 100000, which reveals that --cpus 2.0 is a time quota per period and not a reservation of cores. You have changed them on the fly with docker update and watched the file move. You have seen the OOM killer from the inside, in memory.events, with its oom_kill 1 and the exit code 137 you already knew. And you have handled copy-on-write with your own hands: a new file, a complete copy for modifying a single byte, and a character device 0/0 for a deletion, which is the physical explanation of why deleting files neither slims down an image nor removes a secret. You close the security circle by seeing CapEff at zero decoded with capsh, Seccomp: 2 and NoNewPrivs: 1; and you know what runc does with its OCI-specification config.json and why the shim allows dockerd to restart without killing anything. Plus the essential note for anybody working on macOS or Windows: all of this happens inside a Linux VM, and that is where those paths and performance figures that did not add up were coming from.

And with that, module 5 closes. You came in knowing how to use Docker very well and you leave knowing exactly what happens when you run it. You have opened up networking down to the bridges, the veth pairs and the iptables rules, with the warning that publishing a port bypasses the host firewall; storage down to overlay2 and a backup strategy with verified restores; security down to the threat model, rootless mode, capabilities, seccomp, CVE scanning and Cosign signing, with Aurora Libros hardened in an annotated compose.prod.yaml; optimization, which took the API from 142 MB to 102 MB with multi-stage builds and constant measurement; BuildKit and Buildx, with cache mounts, traceless secrets, remote cache and multi-architecture; observability, with structured logs, Loki, Prometheus, ratio-based alerts and a /health that tells the truth; and, finally, the kernel that holds it all up.

In module 6, Aurora Libros leaves your machine. You will prepare the definitive production image, set up a CI/CD pipeline that builds, scans, signs and publishes using the remote cache you already know how to use, and take the platform to a cluster: first with Docker Swarm and its overlay networks, then with Kubernetes —its objects, the real deployment of the four services, scaling and load balancing— and you will finish with the deployment and rollback strategies that let you publish a new version without anybody losing the ability to buy a book.

Docker: From Beginner to Advanced

Module 1: Introduction to Docker

Module 2: Working with Docker Images

Module 3: Docker Containers

Module 4: Docker Compose

Module 5: Advanced Docker Concepts

Module 6: Docker in Production

Module 7: Docker Ecosystem and Tools

© Copyright 2026. All rights reserved