2 minutes ago RUN apt-get update && apt-get install ... 4.1MB
... 3 weeks ago the rows of the python:3.12-slim base
```
- The rows read bottom to top, and the base image is at the bottom.
- The
CMD row is 0 bytes. It changed no file, so it added no layer.
Note:
- The
history field "Describes the history of each layer", and created_by is the command that created it [S60].
- A history entry with no filesystem change carries
empty_layer [S60].
- The rows read bottom to top, and the base image sits at the bottom.
---
Part II · Step 4 · Making it repeatable
One file brings the same setup up
```yaml
services:
app:
build: .
ports: ["8080:8080"]
volumes: ["appdata:/data"]
volumes:
appdata:
```
Docker
```bash
docker rm -f app 2>/dev/null
docker compose up -d
docker compose down
```
One command builds the image, creates the volume and publishes the port.
Podman
```bash
podman rm -f app 2>/dev/null
sudo apt install podman-compose
podman compose up -d
podman compose down
```
Without podman-compose the command reports no provider.
- Compose is the one place where the engines differ in substance.
- It names its volume after the project, so the count starts again.
Note:
- "podman compose is a thin wrapper around an external compose provider such as docker-compose or podman-compose" [S66].
- Docker ships the compose implementation with the engine, and Podman ships only the wrapper [S66].
- The file records one container's image, volume and port.
- Compose names its volume after the project, so the run does not reuse the
appdata volume.
- Several services in one file is orchestration and is out of scope.
---
Part II · Step 5 · What it may use and do
Predict: how much memory will it report?
You start the container with a hard memory limit. Write down what the tool inside will print.
Docker
```bash
docker run --rm --memory 256m \
alpine sh -c 'free -h; nproc'
```
Podman
```bash
podman run --rm --memory 256m \
alpine sh -c 'free -h; nproc'
```
Question 1
Does free -h inside print 256 MB, or the whole machine? Does nproc count the host processors?
Question 2
Does the limit actually hold if the program allocates 512 MB?
- Keep the figure small. On Windows the virtual machine already took half of the memory.
Note:
--memory sets a memory limit on the container [S61], and a control group is what enforces it [S2].
- Docker states that
free inside a container reports "the host's available swap" [S68].
/proc/meminfo and its neighbours "will always display memory numbers from the host system" [S41].
- The WSL virtual machine defaults to 50 percent of Windows memory [S71].
---
Part II · Step 5 · What it may use and do
Observe: the limit holds, the report does not
The limit
Enforced
- Allocate 512 MB under a 256 MB limit, and the kernel kills the process.
- The kernel enforces the limit. The engine does not.
The report
The host total
free -h prints the whole machine's memory, and so does /proc/meminfo.
nproc counts the host processors. The memory limit does not change that number.
Open question 4: the limit is real and the report ignores it. Two different mechanisms are at work.
Note:
--memory sets the limit the container runs under [S61], and a control group is what holds it [S2].
- An allocation above that limit fails inside the kernel, so the kernel stops the process and the engine does not.
- Docker states that
free inside a container reports the host's available swap [S68].
/proc/meminfo and its neighbours "will always display memory numbers from the host system" [S41].
- No namespace covers the processor count [S1].
- A control group limits and measures consumption, and it does not change what a process reads [S2].
- So
nproc inside the container counts the host processors.
- Part III names the two mechanisms and why only one of them hides anything.
---
Part II · Step 5 · What it may use and do
Predict: two tracing questions, not one
The same tool, two targets. Write down whether each one works.
Docker
```bash
# A: the tracer starts the target
docker run --rm alpine sh -c \
'apk add -q strace; strace -c /bin/true'
# B: attach to a running process
docker rm -f web 2>/dev/null
docker run --rm -d --name web nginx:alpine
docker exec web sh -c \
'apk add -q strace; strace -p 1'
```
Podman
```bash
# A: the tracer starts the target
podman run --rm alpine sh -c \
'apk add -q strace; strace -c /bin/true'
# B: attach to a running process
podman rm -f web 2>/dev/null
podman run --rm -d --name web nginx:alpine
podman exec web sh -c \
'apk add -q strace; strace -p 1'
```
- Case A starts its own target.
- Case B attaches to a process that is already running.
Note:
strace in case A traces its own child [S67].
strace in case B attaches to a process it did not start [S67].
- The default capability set of both engines excludes
CAP_SYS_PTRACE [S42] [S43].
---
Part II · Step 5 · What it may use and do
Observe: one works, one refuses
Case A
own child
It runs, and prints the calls it saw. No extra option was given.
Usually works on a current kernel
Case B
attach
strace: attach: ptrace(PTRACE_SEIZE, 1): Operation not permitted
Refused
Case B again
--cap-add SYS_PTRACE
Add the option to the run above, and the same attach succeeds.
One option changed the answer
```bash
docker rm -f web 2>/dev/null # podman: the same, with podman
docker run --rm -d --name web --cap-add SYS_PTRACE nginx:alpine
docker exec web sh -c 'apk add -q strace; strace -p 1'
```
Open question 5: one option, and the refused command was allowed to run. What did it switch on?
Note:
- Attaching needs
CAP_SYS_PTRACE, or a target that is a descendant of the tracer [S67].
- Neither engine grants
CAP_SYS_PTRACE by default [S42] [S43].
- Adding the option also allowlists the related calls in the filter [S17].
- The refusal is the machinery working as documented, and is not a broken tool [V10].
---
Part II · Step 5 · What it may use and do
Observe: what the option switched on
A capability is one named piece of root's power, such as the right to trace another process. The kernel lists a process's capabilities in /proc/self/status.
Docker
```bash
docker run --rm alpine sh -c '
apk add -q libcap
grep Cap /proc/self/status |
while read n h; do
echo "$n $(capsh --decode=$h)"
done'
```
Podman
```bash
podman run --rm alpine sh -c '
apk add -q libcap
grep Cap /proc/self/status |
while read n h; do
echo "$n $(capsh --decode=$h)"
done'
```
- Run it twice, once with
--cap-add SYS_PTRACE after run.
- Without the option, the
CapEff line has no cap_sys_ptrace. With it, cap_sys_ptrace joins the list.
- The two engines start from different lists. Docker keeps
cap_net_raw, cap_mknod and cap_audit_write, and Podman drops them.
Note:
- A capability is a distinct unit of the privileges traditionally associated with the superuser, which the kernel enables and disables one by one [S3].
/proc//status shows a process's capability sets, and CapEff is the effective set that the kernel checks [S3].
CAP_SYS_PTRACE is the capability that allows tracing a process the tracer did not start [S3] [S67].
- Verified 2026-09-29 on kernel 6.8 with Podman 4.9.3 and Docker 29.7.2: the default
CapEff lacks cap_sys_ptrace on both engines, and --cap-add SYS_PTRACE adds it.
- Docker's default set holds three capabilities that Podman's does not,
cap_net_raw, cap_mknod and cap_audit_write [S42] [S43].
---
Part II · Five open questions
Five questions the commands left open
1
Two process lists
One process, two views. What makes the second one short?
2
The vanishing file
What is a layer, and why is the image unchanged?
3
The mounted directory
How does a directory appear inside one process only?
4
The honest limit
Why does the limit hold while the report shows the host?
5
The one option
What did the one option change?
Five questions, five kernel mechanisms, in this order.
Note:
- The five mechanisms together are what the word container means [V1].
---
Part III
What is underneath
Part II raised five questions that running the commands could not answer. Part III answers them with five kernel mechanisms, in the same order.
Note:
Part III answers Part II. Same application, same commands, new vocabulary.
---
Part III · Question 1
Nothing was hidden from the host
The container process was in the host list all along. What changed was the list the process itself can read.
Docker
```bash
docker rm -f web 2>/dev/null
docker run --rm -d --name web nginx:alpine
docker inspect -f '{{.State.Pid}}' web
docker exec web ps -o pid,comm
```
Podman
```bash
podman rm -f web 2>/dev/null
podman run --rm -d --name web nginx:alpine
podman inspect -f '{{.State.Pid}}' web
podman exec web ps -o pid,comm
```
- The second command prints a host process identifier. The third prints 1, and almost nothing else.
- The host numbering did not change. A second numbering was added.
- Inside that second numbering the command is process 1, and the host's processes have no number at all.
- A process with no number in your table is a process you cannot signal.
Note:
- In a PID namespace the process becomes PID 1 and the other programs are gone [S18].
- A namespace "wraps a global system resource in an abstraction" so the processes inside see their own instance [S1].
- The resource wrapped here is the process identifier space [S1].
---
Part III · Question 1
A namespace is a kernel object, not a file
A namespace is a kernel object that wraps one global resource, so the processes inside it see their own instance. The entries under /proc are handles to it.
```bash
ls -l /proc/self/ns/
# pid -> 'pid:[4026531836]' uts -> 'uts:[4026531838]'
```
1 · The link
Each entry is a symbolic link whose target names the type and a number.
2 · The handle
Opening one returns a file handle for that namespace, and keeps it alive while open.
3 · The test
Two processes are in the same namespace when the two numbers match.
Compare the numbers for a container process and for your shell.
Note:
- Each
/proc/pid/ns/ entry is a symbolic link naming the type and an inode number [S1].
- Opening one "returns a file handle for the corresponding namespace" [S1].
/proc exposes the kernel object and does not represent it as a file [V11].
---
Part III · Question 1
One idea, applied eight times
Linux defines eight namespace types. Each one wraps one global resource.
| Type | Flag | What the process sees its own copy of |
|---|---|---|
| PID | `CLONE_NEWPID` | Process identifiers |
| Mount | `CLONE_NEWNS` | Mount points |
| Network | `CLONE_NEWNET` | Network devices, addresses and ports |
| User | `CLONE_NEWUSER` | User and group identifiers |
| IPC | `CLONE_NEWIPC` | Shared memory and message queues |
| UTS | `CLONE_NEWUTS` | The hostname |
| Cgroup | `CLONE_NEWCGROUP` | Its own position in the kernel's resource-limit tree |
| Time | `CLONE_NEWTIME` | The boot and monotonic clocks |
- The eight types are independent, and UTS is the kernel's old name for the hostname.
Note:
- The kernel documents eight namespace types with these flags [S1].
- The IPC namespace partitions System V IPC and POSIX message queues [V18].
- A container takes a private set of namespaces, and the set is a choice [V1].
---
Part III · Question 1
Three system calls, all traceable
No special interface creates a namespace. Three ordinary system calls do, and strace shows them.
clone(2)
Creates a process. With one or more CLONE_NEW* flags it creates new namespaces at the same time.
unshare(2)
Moves the calling process into a new namespace.
setns(2)
Joins an existing namespace, named by a file descriptor from /proc.
```bash
sudo unshare --pid --fork --mount-proc ps -ef # a two-line process list
```
--mount-proc gives the new namespace its own /proc, which is what ps reads. No engine ran.
- Part IV traces both engines and names the program that makes the call.
Note:
clone(2) creates new namespaces when the flags request them [S1].
- Part IV traces
docker run and podman run and finds the caller.
unshare(2) moves the caller to a new namespace, and setns(2) joins one by descriptor [S1].
- A short program that calls
clone with these flags builds a container without an engine [S56].
---
Part III · Question 1
The user namespace is the load-bearing one
Only one namespace type changes which account counts as root inside it.
- The user namespace remaps user and group numbers, so root inside it is an ordinary account outside.
- Creating any other namespace type needs administrative privilege an ordinary account does not hold.
- Creating a user namespace does not, unless the Linux distribution switches that off. Ubuntu restricts it by default from release 23.10.
- That single exception is what containers without root rest on.
Note:
CLONE_NEWUSER appeared in 2.6.23 and the final pieces landed in 3.8 [S21].
- Every namespace type other than the user namespace requires
CAP_SYS_ADMIN to create [S21].
- The user namespace is the one that isolates user and group identifiers [S1].
- Ubuntu 24.04 "restricts the use of unprivileged user namespaces" by default [S48].
- Rootless containers are a consequence of that single decision [V1].
- Part IV shows what both engines build on top of it.
---
Part III · Question 2
Union filesystem: what it sees as root
A union filesystem presents several read-only directories plus one writable directory as a single tree.
On Linux this filesystem is overlayfs, and it takes three directories as mount options.
lowerdir
The read-only image layers, stacked.
upperdir
The container's writable directory.
merged
The tree the process sees as its root.
- The kernel implements this. The engine's storage driver names which filesystem does the work.
- The union filesystem serves a read from the topmost layer that holds the file. A write goes to the writable directory.
A layer is one directory in this stack.
Note:
- The kernel describes an overlay filesystem as presenting the result of overlaying one filesystem on another [S74].
- Docker's driver names the three directories
lowerdir, upperdir and merged [S6].
- The storage driver is a name for a kernel filesystem, not for a vendor invention.
---
Part III · Question 2
Layering is copy-on-write, one instruction at a time
The first write to an existing file copies it up from the image layer into the writable layer.
1
Share
Every container from one image reads the same layer directories.
2
Protect
The kernel mounts those directories read-only.
3
Copy on write
The first write to a file copies that file into the writable layer, and the write lands on the copy.
- The copy happens on the first write to each file, not on the first write overall.
fork(2) runs this same protocol on memory pages instead of files.
Note:
- The first write to an existing file triggers a
copy_up from the image layer [S6].
- The trap fires on the first write to each unit, not the first write overall [V19].
fork(2) runs the same protocol at page granularity [V19].
- One build instruction produces one layer in the stack [S23].
---
Part III · Question 2
The unit of sharing is a whole file
A union filesystem works at file level, not block level. The copy is never partial.
2 GB
the overlay driver copies when a process appends one byte to a 2 GB file
128
read-only layers the default overlay driver supports at once
1
copy of a file held in memory, shared by every container reading it
- A database writing small records into an image layer therefore copies each file whole before its first change.
- It is also why fifty containers from one image cost one copy of that image's files in memory.
Note:
- All
copy_up operations copy the entire file, however small the change [S6].
- The overlay2 driver supports up to 128 lower layers [S6].
- "Multiple containers accessing the same file share a single page cache entry for that file" [S6].
- Modifying a large file in an image layer copies all of it first [V20].
---
Part III · Question 3
The mount namespace, and what a volume really is
A mount namespace gives the process its own table of mount points.
Docker
```bash
docker run --rm -v appdata:/data alpine \
cat /proc/self/mounts | grep /data
# an entry the host's own table does not have
```
Podman
```bash
podman run --rm -v appdata:/data alpine \
cat /proc/self/mounts | grep /data
# an entry the host's own table does not have
```
1 · The kernel part
The mount namespace. Mount points added inside it are invisible outside.
2 · The engine part
Creating the directory, remembering its name, and asking for the mount.
3 · The consequence
A volume is a host directory placed into that table, so it survives the container.
Note:
- The mount namespace isolates mount points [S1].
- The OCI runtime bind-mounts volumes while setting the container up [V29].
- The engine says a volume is "completely managed by Docker" [S76].
- That wording describes the engine's bookkeeping and names no kernel object.
- Volumes retain data after the containers using them are removed [S64].
---
Part III · Question 4
Control groups: what it can consume
A control group organises processes into a hierarchy whose use of a resource can be limited and measured.
Docker
```bash
docker run --rm --memory 256m alpine \
cat /sys/fs/cgroup/memory.max
# 268435456
```
Podman
```bash
podman run --rm --memory 256m alpine \
cat /sys/fs/cgroup/memory.max
# 268435456
```
- Version 2 collects every resource type in one tree, and only the tree's outermost groups may hold processes.
- The resource types that matter here are memory, processor time, process count and input or output.
- A control group constrains what a process may consume. It changes nothing a process reads.
Note:
- Control groups organise processes into hierarchical groups whose resource use is limited and monitored [S2].
- Under version 2 "all mounted controllers reside in a single unified hierarchy" [S2].
- Version 2 requires that processes reside only in leaf nodes [S2].
- Resource limits on a container without root require version 2 [S61].
---
Part III · Question 4
Namespaces hide, control groups constrain
Two mechanisms, two different verbs. The memory result needs both words to explain it.
Namespace
Changes what is visible
- Process identifiers, mounts, hostname, network.
- Nothing about consumption.
Control group
Changes what is allowed
- Memory, processor time, process count.
- Nothing about visibility.
The limit held because a control group enforced it. The report showed the host because no namespace covers that file.
Note:
- A namespace makes processes inside see their own instance of one resource [S1].
- A control group limits and measures resource usage [S2].
- The two are configured separately and do different work [V1].
---
Part III · Question 4
Nobody gave /proc/meminfo a namespace
No kernel change fixes the memory report. A userspace workaround covers those paths instead.
/proc/meminfo and its neighbours always display numbers from the host system.
- Docker's own documentation states that
free inside a container reports the host's swap.
- A separate project, LXCFS, mounts a small userspace filesystem over those paths so the numbers match the limit.
- It is a workaround in userspace, and no kernel change replaces it.
Note:
/proc/meminfo, /proc/vmstat and friends "are not containerized" and always show the host [S41].
- Docker states that
free inside a container reports the host's available swap [S68].
- LXCFS is "a small FUSE filesystem written with the intention of making Linux containers feel more like a virtual machine" [S69].
- A container whose memory numbers match its limit is still one process on the host kernel.
---
Part III · Question 5
Capabilities: how much of root is left
Linux divides the privilege traditionally held by the superuser into distinct units, each switchable on its own. One such unit is a capability.
CAP_SYS_ADMIN
Mounting filesystems, setting the hostname, and creating namespaces. Close to root on its own.
CAP_SYS_PTRACE
Tracing arbitrary processes and reading their memory. Not in either engine's default set.
CAP_NET_RAW
Raw and packet sockets. In Docker's default set and not in Podman's.
- The engine drops most capabilities before it starts the container, and you can print the set.
Note:
- Linux divides superuser privilege into independently switchable capabilities [S3].
CAP_SYS_ADMIN permits mounting filesystems, setting the hostname and creating namespaces [S3].
CAP_SYS_PTRACE permits tracing arbitrary processes and reading their memory [S3].
CAP_NET_RAW permits raw and packet sockets [S3].
- Docker's default set adds
CAP_NET_RAW [S43], and Podman's eleven defaults do not include it [S42].
- Neither default set holds
CAP_SYS_PTRACE: Podman's eleven [S42], or Docker's fourteen [S43].
- The manual page warns against assigning new features to
CAP_SYS_ADMIN, whose scope is already excessive [S3].
---
Part III · Question 5
Read the default set from the engine's own source
The default is not folklore. Both engines declare it in one file of their source code.
Podman
Eleven capabilities, from CHOWN to SYS_CHROOT, in pkg/config/default.go.
Docker
The same eleven plus MKNOD, NET_RAW and AUDIT_WRITE, in daemon/pkg/oci/caps/defaults.go.
Both
Neither list contains SYS_PTRACE.
Docker
```bash
docker run --rm alpine sh -c \
'grep Cap /proc/self/status'
```
Podman
```bash
podman run --rm alpine sh -c \
'grep Cap /proc/self/status'
```
Note:
- Podman's default set lists eleven capabilities and no
CAP_SYS_PTRACE [S42].
- Docker's default set adds
CAP_MKNOD, CAP_NET_RAW and CAP_AUDIT_WRITE to those eleven [S43].
- The capability set of a running process can be read from
/proc [S3].
---
Part III · Question 5
Seccomp: which calls it may issue
A seccomp filter is a small program the kernel runs before each system call. It returns allow, an error, a kill, or a notification to a tracer.
- Docker's default profile refuses about 44 of more than 300 system calls.
mount is refused, with the stated reason that the capability already gates it.
- Installing a filter needs
CAP_SYS_ADMIN, or the no_new_privs setting, which stops the process gaining privilege later.
- The capability set and the filter are coupled in practice. Granting a capability also allowlists the calls that go with it.
Note:
- The default profile "disables around 44 system calls out of 300+" [S5].
mount carries the reason "Deny mounting, already gated by CAP_SYS_ADMIN" [S5].
- Installing a filter requires
CAP_SYS_ADMIN or the no_new_privs bit [S4].
- In containerd, a process that has a capability is also given the system calls that go with it [S17].
---
Part III · Question 5
The refusal named a mechanism
A tracer attaches in two cases only. The Yama kernel module applies both.
1
Capability
The tracer holds CAP_SYS_PTRACE. The option grants it.
2
Relationship
Or the target is a descendant of the tracer.
3
Yama
Yama applies the limit that kernel.yama.ptrace_scope sets.
4
The filter
A seccomp filter refuses separately, on the call itself.
Read the refusal. --privileged switches every restriction off.
Note:
- Yama is a Linux Security Module, and
kernel.yama.ptrace_scope sets how far a tracer may reach [S84].
- Scope 1 is the default. There a tracer attaches only to its own descendants, unless it holds
CAP_SYS_PTRACE or the target declared it a tracer [S84] [S67].
- At scope 2 only a holder of
CAP_SYS_PTRACE may attach at all [S84].
- Under Yama's default setting, attaching requires
CAP_SYS_PTRACE or a predefined relationship, by default a descendant of the caller [T5] [S67].
- The manual page names Yama as one enabled Linux Security Module, beside SELinux and Smack, and the tracing checks depend on it [S67].
- Adding the capability also allowlists
ptrace, process_vm_readv, process_vm_writev and kcmp [S17].
- Docker's profile blocked
ptrace only on kernels before 4.8 [S5].
--privileged and a mounted engine socket are the absence of a boundary [V8].
---
Part III · The five together
One file holds all five requests
One configuration file is written before a container starts. Every mechanism in this part has a field in it.
```json
{ "root": { "path": "rootfs" },
"process": { "capabilities": { "bounding": ["CAP_CHOWN", "CAP_KILL"] } },
"linux": {
"namespaces": [ {"type": "pid"}, {"type": "mount"}, {"type": "ipc"},
{"type": "uts"}, {"type": "network"} ],
"cgroupsPath": "/machine.slice/container.scope",
"resources": { "memory": { "limit": 268435456 } },
"seccomp": { "defaultAction": "SCMP_ACT_ERRNO", "syscalls": [] } } }
```
root names the tree the union filesystem assembled. process.capabilities says how much of root is left.
namespaces says what the process can see. cgroupsPath and resources say what it can consume. seccomp says which calls it may issue.
- This is an excerpt. Each mechanism from this part has its own field, and one file describes one container.
Note:
- The configuration's
root object names the container's root filesystem path [S81].
process.capabilities carries the capability sets of the container process [S81].
- The Linux section of the file defines
namespaces, cgroupsPath, resources and seccomp [S7].
- The
namespaces array names each namespace, and an optional path joins an existing one [S7].
cgroupsPath names the control group [S7].
resources carries the resource limits [S7].
seccomp carries the system-call filter [S7].
- The OCI runtime reads this file and creates the namespaces [V29].
- The OCI runtime then attaches the control group and applies the SELinux labels [V29].
- The OCI runtime loads the seccomp filter and bind-mounts the volumes [V29].
---
Part III · The five together
A container is a list of requests
There is no struct container in Linux, no call that creates one, and no subsystem that owns the word.
1 · Namespaces
What the process can see.
2 · Control group
What it can consume.
3 · Capabilities
How much of root it keeps.
4 · Seccomp filter
Which system calls it may issue.
5 · Union filesystem
What it sees as its root directory.
Remove any one and something still runs. You just have less of a container.
Note:
- A container is an ordinary process. Unrelated kernel interfaces restrict what it sees and what it consumes [T1] [V1].
- "Remove any one of those five and you still have something that runs" [V2].
- The question "is this a container?" has no answer, and "which of the five did this command request?" does [V2].
- The kernel maintainers declined the word, and renamed process containers to control groups [V6].
---
Part IV
Two architectures
The same five requests. Two different answers to one question: which process owns the container?
Note:
Part IV compares process architectures, not kernel usage.
---
Part IV · The tiers
Engine, OCI runtime, and the work
Two programs, two jobs. The engine writes the request. The OCI runtime issues it.
The engine
- Resolves the image name.
- Unpacks the layers.
- Writes the configuration file.
The OCI runtime
- Reads that file.
- Creates the namespaces.
- Attaches the control group.
- Sets the capability set and installs the seccomp filter.
- Mounts the volumes.
- Starts the command.
runc, crun and youki are OCI runtimes. They implement one standard, published by the Open Container Initiative.
- Changing the engine does not change this tier. Both engines can use either OCI runtime.
Note:
- The OCI runtime creates the namespaces and attaches the control group [V29].
- The OCI runtime applies the SELinux labels and loads the seccomp filter [V29].
- The OCI runtime bind-mounts the volumes and forks the container process [V29].
- The three OCI runtimes are interchangeable implementations of one specification [V16].
- The Open Container Initiative publishes that runtime specification [S7] [S9].
- Podman's default OCI runtime is
crun, selectable with the --runtime option [S13].
---
Part IV · Docker
Docker: a client, a daemon, and two tiers
A daemon is a program that keeps running after the command that started it exits, and owns resources on the user's behalf.
- The client and the daemon talk over an HTTP interface on a local socket file.
- The daemon builds, runs and distributes containers, and owns the images and volumes.
- A shim is a small process that containerd leaves in place to own one container.
containerd was split out of the daemon to separate low-level container management.
Note:
- "Docker uses a client-server architecture", talking over a REST API on UNIX sockets [S10].
- The daemon manages images, containers, networks and volumes [S10].
- Low-level container management was split into containerd in Docker Engine 1.11 [S22].
- Each shim instance "communicates with containerd as a daemon, while parenting containers via invoking independent runtimes" [S70].
---
Part IV · Docker
What the host process table actually shows
The container is not a child of the daemon on a current installation. A small shim owns it.
Docker
```bash
ps -ef --forest | grep -A2 containerd-shim
# containerd-shim-runc-v2, then the container
```
Podman
```bash
ps -ef --forest | grep -B2 nginx
# no daemon above it, only your login session
```
Why
The daemon does not launch containers itself. A shim instance parents the container and invokes the OCI runtime.
What survives
Under Docker the container outlives your terminal, because nothing in your session owns it.
Read the process tree before trusting any drawing of it.
Note:
- "containerd, the daemon, does not directly launch containers" [S70].
- Each shim instance "communicates with containerd as a daemon, while parenting containers via invoking independent runtimes" [S70].
- The container outlives the terminal that started it.
---
Part IV · Docker
Stop the daemon, and its containers stop
The shim is the container's parent, but the daemon decides its lifecycle. When the daemon stops, by default it stops every container it started.
Docker
```bash
docker run -d --name web nginx:alpine
systemctl --user stop docker # rootful: sudo systemctl stop docker
systemctl --user start docker
docker inspect -f '{{.State.Status}}' web
# exited
```
Podman
```bash
podman run -d --name web nginx:alpine
# there is no daemon to stop
podman inspect -f '{{.State.Status}}' web
# running
```
Why
One daemon owns the lifecycle of every container. If it stops or crashes, its containers stop with it.
Live restore
An optional daemon setting that keeps containers running while the daemon is down. It is off by default.
Note:
- "By default, when the Docker daemon terminates, it shuts down running containers" [S12].
- The shim parents the container [S70], so the process tree and the lifecycle are separate facts.
- Live restore is the setting
"live-restore": true, made for "daemon crashes, planned outages, or upgrades" [S12].
- Across daemon updates it works "only" for patch releases (
YY.MM.x), "not for major (YY.MM) daemon upgrades" [S12].
- Verified 2026-09-30 in the Lima VM, rootless Docker 29.7.2 and Podman 4.9.3.
- Stopping the daemon ended the Docker container's process, and it came back as
exited.
- The Podman container kept running.
- This is takeaway 3 made visible: no single daemon whose stop takes every Podman container with it.
---
Part IV · Podman
Podman: fork and exec, with a monitor left behind
Podman has no daemon. Each command is an ordinary program that sets the container up and starts the OCI runtime.
```bash
podman rm -f web 2>/dev/null
podman run -d --name web nginx:alpine
ps -ef --forest | grep -B1 nginx # conmon, then nginx, under your session
```
- The
podman process itself exits once the container is running.
- A small program named
conmon stays behind. It started the OCI runtime, and it sits between your session and the container.
- The container is therefore a child of your session or of a systemd unit.
Note:
- Podman is "a fully featured container engine that is a simple daemonless tool" [S13].
- Most Podman commands run as a regular user without extra privileges [S13].
conmon execs crun or runc, which sets the container up and forks the container process [V29].
conmon therefore remains between the user's session and the container process.
---
Part IV · Podman
What a start without root actually does
Removing the daemon moves work into userspace. Podman does it in a fixed order.
1 · Pause process
Keeps one user namespace alive for all your containers.
2 · Identifier map
Reads your account's reserved user numbers and maps them.
3 · Storage
Makes a mount namespace and assembles the layers.
4 · Network
Starts a userspace network stack. The kernel refuses a full one.
5 · OCI runtime
Execs conmon, then the OCI runtime.
Note:
- Podman joins the pause process namespace "because a multithreaded program cannot change its user namespace" [S15].
- Without that process Podman reads
/etc/subuid and /etc/subgid, builds the mapping, and forks the pause process [V32].
- Podman then creates a mount namespace and assembles the image layers [V33].
- Full separate networking is not allowed for non-root users, so a userspace stack emulates it [S15].
- The OCI runtime then creates the namespaces and attaches the control group [V29].
- The OCI runtime applies the SELinux labels and loads the seccomp filter [V29].
- The OCI runtime bind-mounts the volumes last [V29].
---
Part IV · Which program asks the kernel
Predict: whose trace shows the namespace call?
Write your answer down. Run these in the Linux shell from the setup, as your own user.
Docker
```bash
strace -f -Y \
-e trace=clone,clone3,unshare,setns \
docker run --rm alpine true
```
Podman
```bash
strace -f -Y \
-e trace=clone,clone3,unshare,setns \
podman run --rm alpine true
```
Question 1
In which of the two traces do you see unshare?
Question 2
Which program makes the call: the one you typed, or a descendant of it?
-Y prints the program name beside each process number. The -e trace= list keeps the trace short.
- Install the tracer inside the Linux shell first:
sudo apt install strace, or sudo dnf install strace.
Note:
unshare(2) moves the caller into a new namespace, and setns(2) joins one by descriptor [S1].
- A written prediction before the observation is the method this course uses [S57].
- Podman execs
conmon, and conmon execs the OCI runtime [S15].
- Docker's client talks to a daemon over a socket, so the work happens in another process tree [S10].
Collect the answers first.
---
Part IV · Which program asks the kernel
Observe: only one of the two traces holds the call
Docker
```text
connect(3, ".../docker.sock") = 0
# and no namespace call at all
```
Podman
```text
setns(5, 0) = 0
unshare(
CLONE_NEWNS|CLONE_NEWCGROUP|
CLONE_NEWUTS|CLONE_NEWIPC|
CLONE_NEWPID) = 0
```
- Under Podman, one call creates five namespaces at once.
- The program that makes it is
runc:[1:CHILD], a descendant of the podman command you typed.
- Under Docker, the client only opens a socket to the daemon.
- The same call happens under the daemon, in a process tree your trace never entered.
Note:
- The OCI runtime creates the namespaces for the container [V29].
- Podman execs
conmon, and conmon execs the OCI runtime [S15], so the runtime is a descendant of the command you typed.
unshare(2) takes one or more CLONE_NEW* flags and moves the caller into new namespaces [S1].
- Docker's client and daemon talk over a REST API on a UNIX socket [S10].
- Nothing in the client's own trace therefore creates a namespace [S10].
---
Part IV · Which program asks the kernel
Explain: the call is the same, the parent is not
Docker
```bash
sudo strace -f -Y \
-e trace=clone,clone3,unshare,setns \
-p $(pgrep -u $USER -x containerd)
# then, in a second shell:
docker run --rm alpine true
```
Podman
```bash
# nothing extra: the trace you
# already ran holds the call
```
- Attached to
containerd, the trace shows the same runc:[1:CHILD] calling unshare.
strace -p joins a process that is already running.
- A Linux setting named Yama allows that only with extra privilege, unless
strace started the process itself.
- The system started
containerd, not your shell. So you need sudo to trace it, even when it runs under your account.
Two architectures, one system call. What differs is whose child makes it.
Note:
- Each shim instance parents containers by invoking independent runtimes under
containerd [S70].
- The OCI runtime is what creates the namespaces, under either engine [V29].
- Under Yama's default setting a tracer is restricted to its own descendants, unless it holds
CAP_SYS_PTRACE [S84] [S67].
containerd is a long-lived service, so its tree is not a descendant of the user's shell and the attach needs privilege [S70] [S84].
- The flag set differs between engine and kernel versions, so a trace is read and not memorised.
- Say it aloud: flag lists differ between versions, so read your own trace rather than this one.
---
Part IV · The OCI runtimes
Two OCI runtimes, one specification
runc is written in Go. crun is written in C. They implement the same standard and are interchangeable.
3.34 s
100 sequential containers under runc, in the crun project's own benchmark
1.69 s
the same 100 under crun, same benchmark
512 KB
memory limit crun still ran under. runc failed even with 4 MB
- These are the project's own figures on its own benchmark. Read them as directional.
youki is a third OCI runtime built to the same standard.
Note:
crun is "a fast and low-memory footprint OCI Container Runtime fully written in C" [S16].
- The 1.69-second and 3.34-second figures, and the 512 KB result, are the project's own [S16].
runc, crun and youki are interchangeable implementations of one specification [V16].
---
Part IV · Without root
Rootless: two different concessions
Rootless means both the engine and the container run as an unprivileged user, on top of a user namespace.
Docker
Keeps the daemon, moves it
- The daemon and the containers both run inside a user namespace.
- The daemon itself runs unprivileged too.
- It needs at least 65,536 reserved user numbers for your account.
Podman
Has no daemon to move
- Rootless is the ordinary way to run it.
- Podman states it holds no more privilege than the account that started it.
- Part VI shows a kernel flaw that no identifier mapping stops.
Note:
- Rootless mode runs "the Docker daemon and containers inside a user namespace" [S11].
- In rootless mode "both the daemon and the container are running without root privileges" [S11].
- It requires at least 65,536 subordinate identifiers for the user [S11].
- Rootless Podman builds its identifier mapping from
/etc/subuid and /etc/subgid [V32].
- Podman states that rootless containers "will never have more privileges than the user that launched them" [S14].
- The sentence is a design statement, not a measurement.
- Dirty Pipe let an unprivileged process write to any file it could read [S30].
- The published proof of concept escaped a container that was granted no extra privilege [S30].
- An identifier map does not change a kernel code path, so it does not stop that flaw.
---
Part IV · Without root
On Ubuntu, a security setting can stop rootless containers
Rootless containers need a user namespace. Ubuntu 24.04 gives a user namespace its full use only to programs with an AppArmor profile.
```bash
sysctl kernel.apparmor_restrict_unprivileged_userns # 1 means the setting is on
unshare -Ur id -u # a program with no profile
# unshare: write failed /proc/self/uid_map: Operation not permitted
```
- AppArmor is a Linux security module. It gives each program a profile of what the program may do.
- Ubuntu ships profiles for
podman, runc, crun and rootlesskit. So the engines from the Ubuntu or vendor packages work.
- If an engine stops with the error above, it runs without a profile. Install it from the packages instead.
The setting belongs to the host. No engine option changes it.
Note:
- Ubuntu announced the restriction on unprivileged user namespaces with release 23.10 [S77].
- Ubuntu 24.04 "restricts the use of unprivileged user namespaces" by default [S48].
- Programs whose AppArmor profile carries the
userns rule keep the full use of user namespaces [S77].
- Verified 2026-09-30 on Ubuntu 24.04.4: the sysctl reads 1, and
unshare -Ur id -u fails at the user map.
- On the same system rootless Podman runs, and
/etc/apparmor.d holds profiles for podman, crun, runc, rootlesskit, slirp4netns and buildah.
- Whether the restriction applies inside WSL depends on whether the WSL kernel runs AppArmor, which this deck does not claim.
---
Part IV · Without root
The network path without root is a userspace stack
An unprivileged user cannot create the full network path. A program in userspace copies packets instead.
17.9 Gbps
one iperf3 stream, container running as root
570 Mbps
the same stream, container without root, in one study
- The study's authors attributed the gap to per-packet handling in
slirp4netns.
- Podman 6 removed that stack. The shipped default is now
pasta.
- Publishing a port without root is a userspace listener, not a firewall rule.
Note:
- One study measured 570 Mbps against 17.9 Gbps and attributed the gap to
slirp4netns [V14].
- Podman 6.0.0 removed slirp4netns and directs users to Pasta [S65].
- The shipped configuration now sets the rootless network command to
pasta [S78].
- Both engines use a userspace stack for this path [V28].
---
Part IV · One format
One image, either engine
The image format and the OCI runtime specification are written standards, not one vendor's implementation detail.
Docker
```bash
docker pull \
docker.io/library/python:3.12-slim
```
Podman
```bash
podman pull \
docker.io/library/python:3.12-slim
```
- The same registry, the same manifest, the same layer archives for both.
- The manifest names each layer archive by the hash of its content, and any engine can unpack them.
- A registry serves the same image to both engines over the same protocol.
- This is why a second engine can be a replacement rather than a copy.
Note:
- The image manifest names "the content-addressable identity of one or more filesystem layer changeset archives" [S8].
- The runtime specification defines the configuration every OCI runtime reads [S7].
- Podman keeps the OCI image format and a deliberately compatible command set [S13].
---
Part IV · The summary
The disagreement is about ownership
Both engines make the same five requests to the same kernel. They disagree about which process holds the container.
| | Docker | Podman |
|---|---|---|
| Path | client to daemon to containerd to shim to `runc` | one command, `conmon`, then `crun` |
| Owner | a long-lived privileged program | your own session, or a systemd unit |
| Default OCI runtime | `runc` | `crun` |
| Without root | supported, daemon moved into a user namespace | the ordinary mode |
| Default capabilities | eleven, plus `MKNOD`, `NET_RAW` and `AUDIT_WRITE` | eleven |
| Buys | one interface always listening, an integrated build system | least privilege by default, no idle program |
| Costs | a privileged program on the path of every container | userspace shims where the kernel refuses |
Note:
- The disagreement is about who owns the container process [T3] [V15].
- Docker's path runs through a daemon and containerd [S10] [S22].
- Podman execs
conmon, which execs the OCI runtime and stays behind as the monitor [S15].
- Neither engine asks the kernel for a different mechanism [V1].
- Their default capability sets do differ: Docker's fourteen against Podman's eleven [S42] [S43].
- Podman's default OCI runtime is
crun [S13]. Docker reaches runc through the shim named containerd-shim-runc-v2 [S70].
- Docker runs without root by moving the daemon and the containers into a user namespace [S11].
- Most Podman commands run as a regular user without extra privilege, so this is its ordinary mode [S13].
- The Docker daemon listens for API requests on a socket, and it also builds and distributes images [S10].
- Rootless mode is what runs Docker's daemon without root privileges, and
userns-remap mode keeps it privileged [S11].
- The daemon therefore sits on the path of every Docker container, and it holds root privileges unless rootless mode runs it [S10] [S11].
- Podman runs no daemon, so no long-lived program waits between commands [S13].
- Podman's default set holds three fewer capabilities than Docker's, so its container starts with less privilege [S42] [S43].
- Without root the kernel refuses full networking to Podman, so a userspace program carries the packets instead [S15].
---
Part V
What follows from that
Two consequences of Part IV: the commands are portable, and macOS and Windows needed a Linux virtual machine.
Note:
Part V draws consequences. No new mechanism.
---
Part V · Consequence 1
Same verbs, different engine
Part II showed the commands for one engine. Almost all of them run on the other unchanged.
| Task | Docker | Podman |
|---|---|---|
| Run | `docker run` | `podman run` |
| List | `docker ps` | `podman ps` |
| Build | `docker build` | `podman build` |
| Compose | built in | needs an external provider installed |
| Trace | `--cap-add SYS_PTRACE` | `--cap-add SYS_PTRACE` |
- The verbs match because the image format and the OCI runtime specification are standards.
- Compose is the exception. Podman ships a wrapper and no implementation.
Note:
- Podman keeps the OCI image format and a deliberately compatible command set [S13].
- "podman compose is a thin wrapper around an external compose provider" [S66].
- Podman states that a container without root never holds more privilege than the user who launched it [S14].
---
Part V · Consequence 2
Why macOS and Windows need a Linux virtual machine
A virtual machine runs its own kernel, so the guest kernel is not the host kernel. That is what macOS and Windows are missing.
1 · The mechanisms
All five are Linux kernel features.
2 · The gap
The macOS and Windows kernels lack them.
3 · The answer
Supply a Linux kernel in a virtual machine.
4 · Your own case
Lima and WSL 2 are instances of this, not workarounds.
Microsoft says so plainly: WSL 2 is a Linux kernel in a small virtual machine.
Note:
- Every Docker on a Mac is Docker inside a Linux virtual machine [T4] [V9].
- WSL 2 uses "a Linux kernel inside of a lightweight utility virtual machine (VM)" [S46].
- WSL 2 also "runs Linux distributions as isolated containers inside the managed VM" [S46].
- The mechanisms named are Linux kernel interfaces [S1] [S2] [S3].
---
Part V · Consequence 2
Where "a Linux virtual machine" stops being the answer
The claim holds for Linux containers. Windows has containers of its own, and they need a Windows kernel.
Linux container on Windows
Needs a Linux kernel
- Supplied by WSL 2, inside a virtual machine.
- Everything in this topic applies.
Windows container on Windows
Needs the Windows kernel
- It shares the Windows kernel directly. Microsoft describes that as roughly how Linux containers run.
- Adding a Linux virtual machine does not help here.
Note:
- Process-isolated Windows containers "share the same kernel with the host as well as each other" [S35].
- Microsoft calls that "approximately the same as how Linux containers run" [S35].
- The takeaway is scoped to Linux containers for this reason.
---
Part V · Consequence 2
One shared machine, or one for each container
How many virtual machines the engine builds is a design choice, and the answers differ.
Lima and WSL 2
Lima, WSL 2
One virtual machine. Every container is a process inside it.
One kernel, many containers
Apple
its Containerization project
Each Linux container runs inside its own small virtual machine.
Stronger separation, more machines
- On the second shape two containers share no kernel, so the shared-kernel claim describes the usual shape and not a law.
Note:
- Apple states that "Containerization executes each Linux container inside of its own lightweight virtual machine" [S37].
- The Lima and WSL 2 path runs one virtual machine for many containers [V9].
- Part VI states the shared-kernel argument for the usual shape and names this exception.
---
Part VI
Where it breaks
The boundary, the escapes through it, and the leaks you can measure on your own laptop.
Note:
Part VI is the diagnostic part. Evidence first, judgement after.
---
Part VI · The boundary
The boundary is the system-call interface
A virtual machine gives a guest its own kernel behind a hardware boundary. A container shares the host kernel, so its boundary is that kernel's system-call interface.
1
kernel shared by every container on the host
300+
system calls the boundary is drawn on
~44
of them refused by Docker's default profile
- Linux publishes a stable, versioned, documented system-call interface.
- Stability makes it dependable. Width makes it large to defend.
- A kernel bug is therefore a possible container escape.
Note:
- Namespaces separate a container more weakly than a hardware-backed virtual machine separates a guest [T2] [V7] [S73].
- The attack surface takes in the kernel's whole system-call interface [V7].
- The kernel's stable, versioned, documented user-space interface is the numbered system-call interface [V21].
- The default profile refuses around 44 of more than 300 calls [S5].
- Kubernetes states that containers "offer a weaker isolation boundary than virtual machines that utilize hardware-based virtualization" [S73].
---
Part VI · The escapes
A container escape, in four steps
CVE-2019-5736 overwrote the host OCI runtime binary from inside a container.
1
Bait
A malicious image points its entry at /proc/self/exe.
2
Re-entry
The OCI runtime re-executes itself inside it.
3
Handle
A library the attacker loaded opens the host's OCI runtime binary for writing.
4
Overwrite
The next start runs attacker code as root.
- The attack needed only the default options.
- It reached Docker, Podman, CRI-O and containerd alike.
Note:
- The entry point is a symlink to
/proc/self/exe, so the OCI runtime re-executes itself inside the container [S25].
- An
LD_PRELOADed library constructor "can obtain a writable descriptor to the host binary" [V34].
- The flaw "let a malicious image overwrite the host
runc binary" [V34].
- The engine executes that same host
runc binary again at every later container start [V34].
- Rootless mode is what runs Docker's daemon without root privileges, and
userns-remap mode keeps it privileged [S11].
- Docker's daemon binds a UNIX socket owned by root, so a default install runs it with root privileges [S85].
- The OCI runtime that this privileged path executes therefore runs as root too.
- The next start therefore executes the attacker's code as root.
- The escape needed only the default options and a standard configuration [V37].
- The documented response is to treat the kernel as belonging to a single tenant [S25].
- The flaw reached Docker, Podman, Kubernetes, CRI-O and containerd alike [S24].
---
Part VI · The escapes
One class, disclosed four times
One class of bug returned four times in runc. The container controls a path, and the OCI runtime follows it.
Nov 2025
Three flaws of one class. Each turned on a path the container controlled.
One mechanism
A link replaces /dev/null, so the OCI runtime mounts a host path read-write.
Another
The kernel runs a helper on the host, and that helper ignores namespaces.
2026
CVE-2026-41579. Again a /dev link, again limited host writes.
- An untrusted image or build file is the most likely way in. The
runc 1.1.x branch stayed unpatched.
Note:
- November 2025 disclosed three CVEs of one class: CVE-2025-31133, CVE-2025-52565 and CVE-2025-52881 [V36].
- Those three abused masked paths and
/dev/console mount races [V36].
- Each of the four turned on a path the container itself controlled, which is what makes them one class.
runc is the OCI runtime Docker, Podman, Kubernetes, CRI-O and containerd all use [S24].
- The fixes shipped in runc 1.2.8, 1.3.3 and 1.4.0-rc.3 [S26].
- The runc 1.1.x branch and everything earlier were left unpatched [S26].
- CVE-2026-41579 is the fourth disclosure of the same class [S28] [V36].
- In CVE-2025-31133, replacing
/dev/null with a symlink makes runc bind-mount an attacker-chosen host path read-write [S26].
- "As kernel upcalls are not namespaced", the configured binary runs with full host privileges [S26].
- CVE-2026-41579 "allowed a malicious image with a
/dev symlink to have limited write access to the host filesystem" [S28].
- Untrusted images and build files are the "most likely delivery method" [S27]. Youki and crun "are understood to have similar flaws" [S27].
---
Part VI · The escapes
When the kernel is the bug
Dirty Pipe was a kernel flaw. No fix in an OCI runtime could have prevented it.
- CVE-2022-0847 let an unprivileged process write to any file it could read.
- A public proof of concept escaped a container that was granted no extra privilege. It overwrote the cached copy of the OCI runtime binary.
- Patching the engine would not have helped. Changing the engine would not have helped.
- This is what "the boundary is the kernel" means in practice.
Every process in every container on that host was behind the same one bug.
Note:
- Dirty Pipe allowed an unprivileged process to write to any file it could read [S30].
- The published proof of concept escaped a container that was granted no extra privilege, through the page-cache copy of
runc [S30].
- No fix in the OCI runtime could have prevented a kernel bug.
---
Part VI · The evidence
One systematic study, and what it counted
One published survey counted the flaws across eleven implementations instead of estimating them.
59
published flaws across the 11 "container runtimes" one survey examined
46%
of the 28 that had a working proof of concept enabled an escape
7 of 9
tested escapes prevented by user namespaces
- The survey's authors put most cases in one category: a host component leaked into the container.
- A user namespace prevented seven of the nine exploits the survey tested. Two still worked.
- The two that survived involved the host OCI runtime still running as root against objects the container controls.
Note:
- The study covers 59 flaws across the 11 "container runtimes" it examined, 28 of them with public proof-of-concept exploits [S29].
- 46 percent of those enabled container escape, and the dominant cause was a leaked host component [S29].
- User namespaces prevented 7 of 9 tested escape exploits, so two of the nine still worked [S29].
- The two exploits that survived did so because the host OCI runtime still executes as root against container-controlled objects [V35].
- An LXC maintainer who worked on the runc fix states that privileged containers "are simply never going to be truly secure" [S24].
---
Part VI · The comparison
The other boundary is narrower, not absolute
A virtual machine's interface is smaller than a system-call interface. Smaller is not zero.
- CVE-2025-22225 is a published flaw in VMware ESXi. An attacker with enough privilege could reach the host through the process that runs the virtual machine.
- The comparison is about the width of the interface, not about a guarantee on either side.
Container
The whole system-call interface of a shared kernel.
Virtual machine
A much smaller device and instruction interface. An attacker crossed it too.
Note:
- For CVE-2025-22225 "an attacker with requisite privileges could exploit this vulnerability through the VMX process to escape the sandbox" [S36].
- Kubernetes states the container boundary is weaker than hardware-based virtualization [S73].
- The claim is comparative width, not a guarantee on either side.
---
Part VI · The comparison
Containers went back inside virtual machines
AWS replaced Linux containers with microVMs to isolate the functions its on-demand service runs.
- AWS first isolated its on-demand function service with Linux containers, and reported itself unsatisfied.
- Their stated reason: reliance on a single kernel forces a trade-off between security and compatibility.
- They built Firecracker instead of choosing between the two.
- Kubernetes says sandboxing is often recommended for untrusted code, where workloads are assumed to be malicious.
Packaging is where containers replaced the alternative. Untrusted tenants are where they did not.
Note:
- AWS replaced Linux containers with Firecracker microVMs for the functions Lambda runs [S38].
- "When we first built AWS Lambda, we chose to use Linux containers to isolate functions… We were unsatisfied with this approach" [S38].
- Reliance on a single kernel means "a fundamental tradeoff between security and code compatibility" [S38].
- AWS states "We built Firecracker because we didn't want to choose" [S38].
- Kubernetes states that sandboxing "is often recommended when you are running untrusted code, where workloads are assumed to be malicious" [S73].
- Part I named multi-tenancy as the first need, and this is where it landed.
---
Part VI · The scope
Which containers are not virtual machines
The shared-kernel claim covers the usual shape. Two documented cases fall outside it.
Kata Containers
project page
An OCI runtime that builds a small virtual machine for each container.
An engine runs it instead of runc
The standard
the OCI runtime specification
The runtime specification carries a virtual-machine section of its own.
Not a deviation
- So the claim is scoped. A namespace-based Linux container shares the host kernel, and an OCI runtime that builds a virtual machine still conforms to the same standard.
Note:
- Kata Containers is "An open source container runtime, building lightweight virtual machines" [S33].
- An engine's
--runtime option takes any OCI-compatible binary, so a conforming runtime replaces the default one [S13].
- The runtime specification "describes the schema for the virtual-machine-specific section of the container configuration" [S34].
- The claim is therefore scoped to namespace-based Linux containers.
---
Part VI · The leaks
The leak you can measure: files across the boundary
On macOS a bind mount crosses out of the virtual machine on every access. A volume does not.
3.5x
slower than Linux with VirtioFS, many small files through a bind mount, in one measurement
10x
slower on the same files with gRPC-FUSE
~1x
in line with Linux, inside a named volume
- One author measured these ratios on one codebase. They show direction only.
- Many small files dominate the cost, not total bytes.
- On Windows, keep working files in the Linux filesystem.
- Two machines on one engine version differ if one uses the slower file-sharing driver.
Note:
- Every container on macOS runs inside a Linux virtual machine, so a bind mount crosses that boundary [V9].
- One measurement puts macOS at roughly 3.5 times slower with VirtioFS and 10 times with gRPC-FUSE [S44].
- Moving the directory into a named volume brought timings back in line with Linux [S44].
- Microsoft advises storing files on the Linux drive for speed [S72].
- Storage carrying a
.has-mount-program flag file keeps using FUSE whatever the kernel [V13].
- The program leaves the virtual machine on every access to a bind-mounted path, and the measurement tracks that crossing [V9] [S44].
---
Part VI · The leaks
The leak you can trip over: the wrong architecture
An image built for one processor architecture on a machine with another runs under translation, or not at all.
Clear failure
exec format error. The image was built for another architecture and nothing translates it here.
Unclear failure
Illegal instruction. The translator does not implement an instruction the program uses.
Slow success
It runs. Each system call is caught, converted and forwarded, so system-call-heavy work suffers most.
Fix
Name your own architecture with --platform linux/arm64 or --platform linux/amd64. Prefer images built for that architecture.
Note:
exec format error is the canonical symptom of an image built for the wrong architecture [S59].
- Rosetta does not implement AVX, so such images fail with
Illegal instruction [S45].
- Under translation each system call is caught, converted and forwarded to the host kernel [S59].
--platform=OS/ARCH selects the image for a given platform [S61].
---
Part VI · The leaks
What is inside an image nobody opened
An image freezes a package set on the day it was built, and nothing refreshes it afterwards.
- One scanning study of images built for neuroscience data analysis found they contain hundreds of known vulnerabilities.
- Rebuilding the same images against current packages removed about two thirds of those vulnerabilities.
- The same paper reports, from earlier work rather than its own, that over 30 percent of official images on one registry carry high-priority vulnerabilities.
- The images pulled in Part II are somebody else's filesystem, running under your engine.
- Rebuild on a schedule, because nothing else refreshes the package set.
Note:
- The study measured images for neuroscience data analysis and found they "contain hundreds of vulnerabilities" [S31].
- Software updates removed "roughly two-thirds of these" [S31]. The 30-percent figure for official images is quoted by that paper from earlier work [S31].
- The 2025 advisories name untrusted images as the most likely delivery method [S27].
- Image layers are read-only below the container's writable layer [S6], so only a new build applies those updates.
---
Part VI · Honest limits
Three things the evidence leaves open
•
A namespace can outlive its processes
namespaces(7)
Bind-mounting an entry in /proc keeps a namespace alive after every process in it exits.
•
The memory report has no kernel fix
Docker docs
Nothing namespaced those files. The workaround is a userspace filesystem.
•
Your own installation has its own defaults
containers.conf
Read your network backend and storage driver from containers.conf.
Read the real limit from /sys/fs/cgroup/memory.max, not free. Check /proc for a bind mount before trusting that a namespace is gone.
Note:
- Bind mounting a file in
/proc/pid/ns "keeps the corresponding namespace… alive even if all processes currently in the namespace terminate" [S1].
/proc/meminfo and its neighbours always display host numbers [S41].
- The control group holds the limit, and the files
free reads do not [S2] [S41].
- The remaining default is read from
containers.conf and the release notes for the installed version [V27].
- Where an inside number and an outside number disagree, count the boundary crossings between them [V23].
---
Part VII · Optional
Where it came from
Four pictures. Optional.
Note:
Part VII is optional. Skip it without loss.
---
Part VII · Optional
One line, 1982 to 2015
chroot shows a process one subtree of the file tree and hides the rest.
- FreeBSD jails, in 2000, answered the same need with a compartment that reached past the file tree.
- Solaris Zones, in 2004, answered the same need on Solaris, and limited what each compartment consumed.
- The
chroot date comes from a reading of the source-control logs, not from folklore.
- Popular timelines print 1979. The two accounts genuinely disagree, and the log date is better evidenced.
- Control groups are dated by the kernel release that carried them, not by a press date.
Note:
chroot(2) was added to BSD by Bill Joy on 18 March 1982, per Kirk McKusick's reading of the SCCS logs [V30].
chroot limits a process to a single subtree of the filesystem name space [S19].
- The compartment of
chroot "does not extend to the process or networking spaces", and a jail does [S19].
- The jails paper states that a jail "effectively amounts to a virtual machine environment" [S19].
- The Solaris Zones paper states the need as isolation plus a limit on resource use, which is what a zone provides [S58].
- The author of FreeBSD jails states that
chroot was never intended as a security mechanism [S20].
- Popular timelines date the call to 1979 and to Version 7 Unix, and the two accounts genuinely disagree [V31].
- The mount namespace arrived in kernel 2.4.19 in 2002 [S21].
- Control groups version 1 appeared in Linux 2.6.24 [S2].
- The 2013 date for the first Docker image and registry is the one every account cited here gives.
- The Open Container Initiative was launched on 22 June 2015 [S9].
---
Part VII · Optional
Three lanes that met
- The lanes are parallel answers to one pressure, not a line of descent. Accounts written at the time attribute the idea of namespacing to Plan 9, not to BSD.
- The kernel maintainers refused the word. Google's "process containers" became control groups.
Note:
- Jails, Zones and namespaces are three parallel responses rather than one inherited design [V5].
- Contemporaneous accounts attribute the idea of namespacing to Bell Labs' Plan 9 [V5].
- Google engineers began "process containers" in 2006, and the name changed to control groups to avoid overloading the word container [V6].
- Jails were presented as amounting to a virtual machine environment, which is where the reading began [S19].
---
Part VII · Optional
What 2013 added, and what it did not
Isolation was available for a decade before the first Docker release. What was missing was a way to move an application between machines.
Not new in 2013
The isolation
- Namespaces from 2002, control groups from 2008.
- LXC had already combined them, and Docker began as a command-line front end to LXC.
New in 2013
The image and the transport
- A layered image named by the hash of its content.
- A protocol for moving it between machines.
- Deployment becomes one command against a named hash.
Note:
- The problem containers were built to solve is distribution, not isolation [V3].
- The technically interesting part was the least technically novel part [V4].
- Docker began as a Python command-line front end to LXC and AUFS [S22].
- Low-level container management later moved out of Docker into containerd [S22].
---
Part VII · Optional
Kubernetes dropped Docker, and the images kept working
Since 2015 the image format and the runtime contract are standards. One consequence arrived seven years later.
- Kubernetes removed
dockershim, its adapter for Docker, in release 1.24.
- Images built by Docker kept working everywhere, because they are standard OCI images.
- Docker was replaced underneath the format. The format did not move.
Docker no longer controls the implementation, and the images it builds still run under every conforming engine.
Note:
- The v1.24 release of Kubernetes "actually removed the dockershim", the adapter for Docker [S39].
- "The image that Docker produces isn't really a Docker-specific image—it's an OCI image" [S40].
- A standard image therefore keeps working under every conforming engine [S40].
- The standard was launched in June 2015 by Docker, CoreOS and others [S9].
---
Takeaways
1
No kernel object
Five mechanisms in one configuration file. Remove one and something still runs.
2
Not a small machine
The boundary is a shared kernel. The record has real escapes through it.
3
Same parts, two shapes
They differ over who owns the container, not over what is asked of the kernel.
4
Linux, or a Linux inside
Lima and WSL 2 supply that kernel. Windows containers are the exception.
5
Configuration, not magic
Read the capability set before reaching for an option that removes it.
Note:
- A container is a list of kernel requests, not a kernel object [T1] [V2].
- The boundary is the system-call interface of a shared kernel [T2] [V7].
- The two engines disagree about ownership of the container process [T3] [V15].
- A Linux container on macOS or Windows runs inside a Linux virtual machine [T4] [V9].
- What a process may do is a readable capability set and a readable filter [T5] [V10].
---
What follows
Four things to do differently once a container stops being a black box.
1 · Read the refusal
Find which capability or filter rule refused. Then grant that one.
2 · Choose where data lives
Writable layer, volume or bind mount. Decide before the first run.
3 · Check the host, not the flag
Control group version, namespace policy and architecture set the limits. No option overrides them.
4 · Keep the habit
Predict, observe, explain.
Three of these read a value the machine already holds: the capability set, the storage destination, and the control group version.
Note:
- Never answer a permission error with
--privileged, which is the absence of a boundary [V8].
- Treat root within a container as if it is root outside of the container [S32].
- The three storage destinations have three different lifetimes [S64].
- The capability set of a running process is readable from
/proc [S3].
- Host policy gates rootless operation, and no engine option reaches it [S48] [S77].
- Podman 6.0.0 removed support for control groups version 1, so the host's version decides whether the engine runs at all [S65].
- Resource limits on a rootless container need control groups version 2 [S61].
exec format error is the symptom of an image built for the host's other architecture [S59].
--platform=OS/ARCH selects the image for a platform, and it cannot change the host's own [S61].
- The same correction applies to another everyday word: a thread is not a kernel concept [V26].
- The course toolchain is one reproducible environment built from these primitives [V24].
---
## References · 1/18
- **[S1]** namespaces(7) — Linux manual page — https://man7.org/linux/man-pages/man7/namespaces.7.html
- **[S2]** cgroups(7) — Linux manual page — https://man7.org/linux/man-pages/man7/cgroups.7.html
- **[S3]** capabilities(7) — Linux manual page — https://man7.org/linux/man-pages/man7/capabilities.7.html
- **[S4]** seccomp(2) — Linux manual page — https://man7.org/linux/man-pages/man2/seccomp.2.html
- **[S5]** Seccomp security profiles for Docker — https://docs.docker.com/engine/security/seccomp/
- **[S6]** Use the OverlayFS storage driver — https://docs.docker.com/engine/storage/drivers/overlayfs-driver/
---
## References · 2/18
- **[S7]** OCI Runtime Specification — Linux Container Configuration — https://github.com/opencontainers/runtime-spec/blob/main/config-linux.md
- **[S8]** OCI Image Format Specification — https://github.com/opencontainers/image-spec/blob/main/spec.md
- **[S9]** About the Open Container Initiative — https://opencontainers.org/about/overview/
- **[S10]** Docker overview — https://docs.docker.com/get-started/docker-overview/
- **[S11]** Rootless mode — https://docs.docker.com/engine/security/rootless/
- **[S12]** Live restore — https://docs.docker.com/engine/daemon/live-restore/
- **[S13]** podman(1) — Podman manual page — https://docs.podman.io/en/latest/markdown/podman.1.html
---
## References · 3/18
- **[S14]** podman — project README — https://github.com/podman-container-tools/podman
- **[S15]** What happens behind the scenes of a rootless Podman container? — https://www.redhat.com/en/blog/behind-scenes-podman
- **[S16]** containers/crun — https://github.com/containers/crun
- **[S17]** Why strace doesn't work in Docker — https://jvns.ca/blog/2020/04/29/why-strace-doesnt-work-in-docker/
- **[S18]** What even is a container — https://jvns.ca/blog/2016/10/10/what-even-is-a-container/
- **[S19]** Jails: Confining the omnipotent root — http://phk.freebsd.dk/pubs/sane2000-jail.pdf
- **[S20]** Jails – High value but shitty Virtualization — http://phk.freebsd.dk/sagas/jails/
---
## References · 4/18
- **[S21]** Namespaces — https://k1024.org/papers/namespaces.html
- **[S22]** From dotCloud to Docker — https://jpetazzo.github.io/2017/02/24/from-dotcloud-to-docker/
- **[S23]** Docker build cache — https://docs.docker.com/build/cache/
- **[S24]** A container-confinement breakout — https://lwn.net/Articles/781013/
- **[S25]** Anatomy of CVE-2019-5736: A runc container escape — https://aws.amazon.com/blogs/compute/anatomy-of-cve-2019-5736-a-runc-container-escape/
- **[S26]** runc security advisory GHSA-9493-h29p-rfm2 — https://github.com/opencontainers/runc/security/advisories/GHSA-9493-h29p-rfm2
- **[S27]** runc container breakout vulnerabilities: a technical overview — https://www.cncf.io/blog/2025/11/28/runc-container-breakout-vulnerabilities-a-technical-overview/
---
## References · 5/18
- **[S28]** runc releases — https://github.com/opencontainers/runc/releases
- **[S29]** Towards Improving Container Security by Preventing Runtime Escapes — https://doi.org/10.2172/1890908
- **[S30]** Escaping containers using the Dirty Pipe vulnerability — https://securitylabs.datadoghq.com/articles/dirty-pipe-container-escape-poc/
- **[S31]** An analysis of security vulnerabilities in container images for scientific data analysis — https://academic.oup.com/gigascience/article/10/6/giab025/6291571
- **[S32]** Containers do not contain — https://opensource.com/business/14/7/docker-security-selinux
- **[S33]** Kata Containers — https://katacontainers.io/
---
## References · 6/18
- **[S34]** OCI Runtime Specification — Virtual Machine Container Configuration — https://github.com/opencontainers/runtime-spec/blob/main/config-vm.md
- **[S35]** Isolation modes for Windows containers — https://learn.microsoft.com/en-us/virtualization/windowscontainers/manage-containers/hyperv-container
- **[S36]** Zero-Day Vulnerabilities in VMware ESXi — https://www.tenable.com/blog/cve-2025-22224-cve-2025-22225-cve-2025-22226-zero-day-vulnerabilities-in-vmware-esxi
- **[S37]** Containerization — https://github.com/apple/containerization
- **[S38]** Firecracker: Lightweight Virtualization for Serverless Applications — https://www.usenix.org/system/files/nsdi20-paper-agache.pdf
- **[S39]** Dockershim Removal FAQ — https://kubernetes.io/blog/2022/02/17/dockershim-faq/
---
## References · 7/18
- **[S40]** Don't Panic: Kubernetes and Docker — https://kubernetes.io/blog/2020/12/02/dont-panic-kubernetes-and-docker/
- **[S41]** Memory inside Linux containers — https://fabiokung.com/2014/03/13/memory-inside-linux-containers/
- **[S42]** containers/common — pkg/config/default.go — https://github.com/containers/common/blob/main/pkg/config/default.go
- **[S43]** moby/moby — daemon/pkg/oci/caps/defaults.go — https://github.com/moby/moby/blob/master/daemon/pkg/oci/caps/defaults.go
- **[S44]** Docker on macOS is slow and how to fix it — https://www.cncf.io/blog/2023/02/02/docker-on-macos-is-slow-and-how-to-fix-it/
---
## References · 8/18
- **[S45]** Rosetta emulation on arm64 macOS breaks many amd64 images — https://github.com/docker/for-mac/issues/7137
- **[S46]** Comparing WSL Versions — https://learn.microsoft.com/en-us/windows/wsl/compare-versions
- **[S47]** Systemd support in WSL — https://learn.microsoft.com/en-us/windows/wsl/systemd
- **[S48]** Ubuntu 24.04 LTS release notes — https://documentation.ubuntu.com/release-notes/24.04/
- **[S49]** Docker Hub usage and rate limits — https://docs.docker.com/docker-hub/usage/
- **[S50]** Docker Desktop license agreement — https://docs.docker.com/subscription/desktop-license/
- **[S51]** Install Docker Engine — https://docs.docker.com/engine/install/
- **[S52]** Podman Installation Instructions — https://podman.io/docs/installation
---
## References · 9/18
- **[S53]** Lima templates — https://lima-vm.io/docs/templates/
- **[S54]** Lima configuration — https://lima-vm.io/docs/config/
- **[S55]** podman-machine-init(1) — https://docs.podman.io/en/latest/markdown/podman-machine-init.1.html
- **[S56]** containers-from-scratch — https://github.com/lizrice/containers-from-scratch
- **[S57]** Predict-Observe-Explain — https://science-education-research.com/teaching-science/constructivist-pedagogy/predict-observe-explain/
- **[S58]** Solaris Zones: Operating System Support for Consolidating Commercial Workloads — https://www.usenix.org/legacy/event/lisa04/tech/full_papers/price/price.pdf
---
## References · 10/18
- **[S59]** Moving ARM64 Docker Builds Off QEMU — https://www.warpbuild.com/guides/qemu-vs-native-arm64-builds
- **[S60]** OCI Image Format Specification — Image Configuration — https://github.com/opencontainers/image-spec/blob/main/config.md
- **[S61]** podman-run(1) — Podman command reference — https://docs.podman.io/en/latest/markdown/podman-run.1.html
- **[S62]** An introduction to Docker for reproducible research — https://polaris.imag.fr/arnaud.legrand/research/readings/acm_sigops_si_rsea/p71-boettiger.pdf
- **[S63]** An Updated Performance Comparison of Virtual Machines and Linux Containers — https://www.read.seas.harvard.edu/~kohler/class/cs260r-s19/containerperf14.pdf
---
## References · 11/18
- **[S64]** Storage overview — Docker Docs — https://docs.docker.com/engine/storage/
- **[S65]** Podman v6.0.0 release notes — https://github.com/podman-container-tools/podman/releases/tag/v6.0.0
- **[S66]** podman-compose(1) — Podman manual page — https://docs.podman.io/en/latest/markdown/podman-compose.1.html
- **[S67]** ptrace(2) — Linux manual page — https://man7.org/linux/man-pages/man2/ptrace.2.html
- **[S68]** Resource constraints — Docker Docs — https://docs.docker.com/engine/containers/resource_constraints/
- **[S69]** lxc/lxcfs — project README — https://github.com/lxc/lxcfs
- **[S70]** containerd Runtime v2 (shim API) — https://containerd.io/docs/2.3/runtime-v2/
---
## References · 12/18
- **[S71]** Advanced settings configuration in WSL — https://learn.microsoft.com/en-us/windows/wsl/wsl-config
- **[S72]** Working across Windows and Linux file systems (WSL) — https://learn.microsoft.com/en-us/windows/wsl/filesystems
- **[S73]** Multi-tenancy — Kubernetes documentation — https://kubernetes.io/docs/concepts/security/multi-tenancy/
- **[S74]** Overlay Filesystem — the Linux Kernel documentation — https://docs.kernel.org/filesystems/overlayfs.html
- **[S75]** What is a container? — Docker Docs — https://docs.docker.com/get-started/docker-concepts/the-basics/what-is-a-container/
---
## References · 13/18
- **[S76]** Volumes — Docker Docs — https://docs.docker.com/engine/storage/volumes/
- **[S77]** Ubuntu 23.10 restricted unprivileged user namespaces — https://ubuntu.com/blog/ubuntu-23-10-restricted-unprivileged-user-namespaces
- **[S78]** containers.conf(5) — configuration reference — https://github.com/containers/common/blob/main/docs/containers.conf.5.md
- **[S79]** Troubleshooting Windows Subsystem for Linux — https://learn.microsoft.com/en-us/windows/wsl/troubleshooting
- **[S80]** Bind mounts — Docker Docs — https://docs.docker.com/engine/storage/bind-mounts/
- **[S81]** OCI Runtime Specification — Configuration — https://github.com/opencontainers/runtime-spec/blob/main/config.md
---
## References · 14/18
- **[S82]** Install Docker Engine on Ubuntu — https://docs.docker.com/engine/install/ubuntu/
- **[S83]** Homebrew: The Package Manager for Everywhere — https://brew.sh
- **[S84]** Yama — the Linux Kernel documentation — https://docs.kernel.org/admin-guide/LSM/Yama.html
- **[S85]** Linux post-installation steps for Docker Engine — https://docs.docker.com/engine/install/linux-postinstall/
- **[S86]** Homebrew — Installation — https://docs.brew.sh/Installation
- **[V1]** Containers with Docker and Podman — note in the author's research vault.
- **[V2]** Containers with Docker and Podman — note in the author's research vault.
- **[V3]** Containers with Docker and Podman — note in the author's research vault.
---
## References · 15/18
- **[V4]** Containers with Docker and Podman — note in the author's research vault.
- **[V5]** Containers with Docker and Podman — note in the author's research vault.
- **[V6]** Containers with Docker and Podman — note in the author's research vault.
- **[V7]** Containers with Docker and Podman — note in the author's research vault.
- **[V8]** Containers with Docker and Podman — note in the author's research vault.
- **[V9]** Containers with Docker and Podman — note in the author's research vault.
- **[V10]** Containers with Docker and Podman — note in the author's research vault.
- **[V11]** Containers with Docker and Podman — note in the author's research vault.
- **[V12]** Containers with Docker and Podman — note in the author's research vault.
---
## References · 16/18
- **[V13]** Containers with Docker and Podman — note in the author's research vault.
- **[V14]** Containers with Docker and Podman — note in the author's research vault.
- **[V15]** Containers with Docker and Podman — note in the author's research vault.
- **[V16]** Containers with Docker and Podman — note in the author's research vault.
- **[V17]** Containers with Docker and Podman — note in the author's research vault.
- **[V18]** Containers with Docker and Podman — note in the author's research vault.
- **[V19]** Copy-on-Write — note in the author's research vault.
- **[V20]** Copy-on-Write — note in the author's research vault.
- **[V21]** The System Call Boundary — note in the author's research vault.
---
## References · 17/18
- **[V22]** Where the Abstraction Leaks — note in the author's research vault.
- **[V23]** Where the Abstraction Leaks — note in the author's research vault.
- **[V24]** devcontainers — note in the author's research vault.
- **[V26]** Processes, Threads and Scheduling — note in the author's research vault.
- **[V27]** Containers with Docker and Podman — note in the author's research vault.
- **[V28]** Containers with Docker and Podman — note in the author's research vault.
- **[V29]** Containers with Docker and Podman — note in the author's research vault.
- **[V30]** Containers with Docker and Podman — note in the author's research vault.
- **[V31]** Containers with Docker and Podman — note in the author's research vault.
---
## References · 18/18
- **[V32]** Containers with Docker and Podman — note in the author's research vault.
- **[V33]** Containers with Docker and Podman — note in the author's research vault.
- **[V34]** Containers with Docker and Podman — note in the author's research vault.
- **[V35]** Containers with Docker and Podman — note in the author's research vault.
- **[V36]** Containers with Docker and Podman — note in the author's research vault.
- **[V37]** Containers with Docker and Podman — note in the author's research vault.
- **[V38]** Containers with Docker and Podman — note in the author's research vault.
The references document (PDF and web page) carries the full source list, and a summary of every [V] source with its own external references.
Note:
Source list only.
---
Take it with you
Everything from this talk
ondrasek.github.io/talks
me@ondrejkrajicek.com
Note:
Everything is already online — slides, the references and these notes. Say the address once, and let the room photograph the slide rather than writing it down.