Contents

Docker Internals Tutorial: How Namespaces, cgroups, and overlayfs Work

What is Docker? explained containers with an analogy. Here, we'll look directly at a Docker host to see what a container really is in Linux. The answer is one sentence:

A container is an ordinary Linux process limited by namespaces in what it can "see," by cgroups in how many resources it can use, and given a root directory made from overlayfs. Capabilities and seccomp then limit what it can do.

A Linux process surrounded by namespaces, cgroups, overlayfs, and security controls
Container = one process + namespaces + cgroups + overlayfs + security controls

"Host" below means the Docker test environment for this tutorial (Linux 5.4 kernel, cgroup v1). IDs and paths will differ on your computer. Systems using cgroup v2 also have different file names, which are noted below. Docker Desktop users can enter the Docker Desktop Linux virtual machine to inspect it with docker run -it --rm --privileged --pid=host alpine:3.23 nsenter -t 1 -m -u -i -n sh.

A container is a process on the host

Start an nginx container and limit it to 256 MB of memory and 0.5 CPU:

$ docker run -d --name demo --memory 256m --cpus 0.5 nginx:1.30-alpine
WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted. Memory limited without swap.
57dcd17505d4e7c71e57df55edef3e137c18249e6ad402c7e7b3f215341c47b4

$ docker exec demo ps
PID   USER     TIME  COMMAND
    1 root      0:00 nginx: master process nginx -g daemon off;
   29 nginx     0:00 nginx: worker process
   30 root      0:00 ps

$ docker inspect -f "{{.State.Pid}}" demo
12458
  • Inside the container, the nginx master process has PID 1, and you can see only its own processes.
  • docker inspect's .State.Pid is its PID on the host: 12458.

List processes on the host:

$ ps -o pid,ppid,args | grep -v -E 'ps -o|grep|cut' | cut -c1-110
PID   PPID  COMMAND
    1     0 docker-init -- dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375 --registry-mir
   19     1 dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375 --registry-mirror=https://mir
12434     1 /usr/local/bin/containerd-shim-runc-v2 -namespace moby -id 57dcd17505d4e7c71e57df55edef3e137c18249
12458 12434 nginx: master process nginx -g daemon off;
12495 12458 nginx: worker process

On the host, you can see the full process tree: dockerd → containerd-shim-runc-v2 → nginx: master process (12458) → nginx: worker process.

  • containerd-shim-runc-v2: One per container, it watches over the container's processes. Containers don't stop when dockerd restarts because the shim remains running.
  • runc: The program that actually creates the container. It sets up namespaces, cgroups, and the root directory, then starts nginx and exits. That's why you don't see it in the process tree.

The same process is PID 1 inside the container and PID 12458 on the host. That's the effect of a PID namespace.

Namespaces: What a container can see

lsns lists the namespaces a process belongs to:

$ lsns -p 12458 -o NS,TYPE,PID,COMMAND
        NS TYPE     PID COMMAND
4026531835 cgroup     1 docker-init -- dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375 --registry-mirror=https://mirror.gcr.io --dns=1.1.1.1 --dns=8.8.8.8
4026531837 user       1 docker-init -- dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375 --registry-mirror=https://mirror.gcr.io --dns=1.1.1.1 --dns=8.8.8.8
4026533161 mnt    12458 nginx: master process nginx -g daemon off;
4026533162 uts    12458 nginx: master process nginx -g daemon off;
4026533163 ipc    12458 nginx: master process nginx -g daemon off;
4026533164 pid    12458 nginx: master process nginx -g daemon off;
4026533166 net    12458 nginx: master process nginx -g daemon off;

The nginx process has 5 namespaces of its own (with IDs different from the host). It shares 2 others (cgroup and user) with host PID 1:

namespaceWhat it isolatesEffect in the container
pidProcess IDsYou see only the container's processes; the main process is PID 1
netNetwork interfaces, IP addresses, ports, and routesThe container has its own eth0 and IP address and can listen on port 80 independently
mntMount pointsThe container has its own root directory /
utsHostnameThe container's hostname is its container ID
ipcShared memory and semaphoresContainers can't interfere with each other through IPC
userUser ID mappingsNot isolated by default: root in the container is root on the host; Rootless mode uses it
cgroupView of the cgroup directoryNot isolated in this environment (cgroup v1); systems with cgroup v2 isolate it by default

The effect of the uts and net namespaces:

$ docker exec demo hostname
57dcd17505d4

$ docker exec demo ip -4 addr show eth0
2: eth0@if130: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP
    inet 172.18.0.2/16 brd 172.18.255.255 scope global eth0
       valid_lft forever preferred_lft forever

The container's hostname is the first 12 characters of its ID. Its eth0 has its own IP address (172.18.0.2), assigned by Docker networking. See Networking and container communication for details.

cgroups: How much a container can use

--memory 256m --cpus 0.5 isn't calculated by Docker itself; it's written to the Linux kernel's cgroup settings. Read those settings from inside the container:

$ docker info --format "Cgroup Version: {{.CgroupVersion}}"
Cgroup Version: 1

$ docker exec demo cat /sys/fs/cgroup/memory/memory.limit_in_bytes
268435456

$ docker exec demo cat /sys/fs/cgroup/memory/memory.usage_in_bytes
10842112

$ docker exec demo cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us /sys/fs/cgroup/cpu/cpu.cfs_period_us
50000
100000

$ docker stats --no-stream --format "{{.Name}}  {{.MemUsage}}  {{.CPUPerc}}" demo
demo  5.613MiB / 256MiB  0.00%
ValueMeaning
memory.limit_in_bytes = 268435456268435456 bytes = 256 MiB, which is --memory 256m
memory.usage_in_bytes = 10842112About 10 MB in use now (kernel statistics include file cache, so this is larger than the value shown by docker stats)
cpu.cfs_quota_us = 50000, cpu.cfs_period_us = 100000At most 50 ms of CPU time per 100 ms, or 0.5 CPU

The 256MiB limit shown by docker stats also comes from cgroups. If a process uses more memory than the limit, the kernel terminates it (OOM), with exit code 137.

On cgroup v2 systems (recent Ubuntu, Debian, Fedora, and Docker Desktop), the file names differ: the memory limit is in /sys/fs/cgroup/memory.max, and the CPU quota is in /sys/fs/cgroup/cpu.max (contents: 50000 100000).

overlayfs: The container filesystem

Image layers

An image is made up of multiple read-only layers:

$ docker image inspect -f "{{range .RootFS.Layers}}{{println .}}{{end}}" nginx:1.30-alpine
sha256:74d97c428c51a828f9051a7a40a53ff1fc99e54fc30323ce36760701b0b7f711
sha256:2d3fbe0ca19dd389108f7e9a1d89ec5c1f1d7dc3c11fb2123bc425fed39c5f22
sha256:ed752e851ebe79a0e72be1fd48e90474d36ae2bb2ddccff2475f8e050c20a804
sha256:ec2b9a359ab960e2254545e79e9bca77edaeefb652d97b26855217fb8a9c80b3
sha256:267e83adf4c4e342b08cbe40814662493e030c88311c720fbce2f0d7c809f3f4
sha256:8fb5da5ad6fa936ba8c24999da33095b2b6c5fd2bcf4aa22acbf299b0e7294df
sha256:0d250d8db7c20b3c0bd3059b3431dd47f130cb81e7a4c7820950139daafb91d6
sha256:e46f4042ccf5f36d1605617ef6912bdaac437fa0bca12530e77529b191eb02fa


$ docker history --format "{{.Size}}\t{{.CreatedBy}}" nginx:1.30-alpine | cut -c1-90
51.9MB	RUN /bin/sh -c set -x     && apkArch="$(cat …
0B	ENV ACME_VERSION=0.4.1
0B	ENV NJS_RELEASE=1
0B	ENV NJS_VERSION=1.0.1
0B	CMD ["nginx" "-g" "daemon off;"]
0B	STOPSIGNAL SIGQUIT
0B	EXPOSE map[80/tcp:{}]
0B	ENTRYPOINT ["/docker-entrypoint.sh"]
16.4kB	COPY 30-tune-worker-processes.sh /docker-ent…
12.3kB	COPY 20-envsubst-on-templates.sh /docker-ent…
12.3kB	COPY 15-local-resolvers.envsh /docker-entryp…
12.3kB	COPY 10-listen-on-ipv6-by-default.sh /docker…
8.19kB	COPY docker-entrypoint.sh / # buildkit
5.62MB	RUN /bin/sh -c set -x     && addgroup -g 101…
0B	ENV DYNPKG_RELEASE=1
0B	ENV PKG_RELEASE=1
0B	ENV NGINX_VERSION=1.30.5
0B	LABEL maintainer=NGINX Docker Maintainers <d…
0B	CMD ["/bin/sh"]
9.08MB	ADD alpine-minirootfs-3.24.2-x86_64.tar.gz /…
  • RootFS.Layers lists the hashes of 8 layers.
  • docker history lists the Dockerfile instruction that created each layer. Entries with size 0B (ENV, CMD, EXPOSE) only change configuration and create no files, so they have no corresponding layer. The bottom ADD alpine-minirootfs... is the Alpine base image.

Stacked into a root directory

When a container starts, Docker stacks these layers with overlayfs and adds a writable layer. On the host, inspect this container's mount information:

$ grep overlay /proc/mounts | grep $(docker inspect -f '{{.Id}}' demo) | tr ',' '\n' | sed 's#/var/lib/docker/containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots#…/snapshots#g'
overlay /var/lib/docker/rootfs/overlayfs/57dcd17505d4e7c71e57df55edef3e137c18249e6ad402c7e7b3f215341c47b4 overlay rw
relatime
lowerdir=…/snapshots/260/fs:…/snapshots/21/fs:…/snapshots/20/fs:…/snapshots/19/fs:…/snapshots/18/fs:…/snapshots/17/fs:…/snapshots/16/fs:…/snapshots/15/fs:…/snapshots/1/fs
upperdir=…/snapshots/261/fs
workdir=…/snapshots/261/work
xino=off 0 0

(The path is long, so … replaces /var/lib/docker/containerd/daemon/io.containerd.snapshotter.v1.overlayfs.)

ItemMeaning
lowerdirRead-only image layers stacked from top to bottom and shared by all containers using this image
upperdirThe writable layer for this container
workdirA temporary directory used internally by overlayfs
Mount point /var/lib/docker/rootfs/overlayfs/CONTAINER_IDThe merged result—the / the container sees
overlayfs: read-only image layers and a writable upperdir combine into the container filesystem
overlayfs: multiple read-only layers + one writable layer = the container root directory

Where files written by a container go

Create a file inside the container, then find it in the host's upperdir:

$ docker exec demo sh -c "echo hello > /usr/share/nginx/html/new.html"

$ find /var/lib/docker/containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/261/fs -type f | sed 's#.*/fs/#upperdir/#'
upperdir/usr/share/nginx/html/new.html
upperdir/run/nginx.pid
upperdir/etc/nginx/conf.d/default.conf

$ docker diff demo
C /etc
C /etc/nginx
C /etc/nginx/conf.d
C /etc/nginx/conf.d/default.conf
C /usr
C /usr/share
C /usr/share/nginx
C /usr/share/nginx/html
A /usr/share/nginx/html/new.html
C /run
A /run/nginx.pid
C /var
C /var/cache
C /var/cache/nginx
A /var/cache/nginx/client_temp
A /var/cache/nginx/fastcgi_temp
A /var/cache/nginx/proxy_temp
A /var/cache/nginx/scgi_temp
A /var/cache/nginx/uwsgi_temp
  • The new new.html appears in upperdir.
  • nginx writes nginx.pid when it starts. The image's startup script modified default.conf (adding IPv6 settings). When overlayfs modifies an existing file from the image, it first copies it to upperdir and then modifies it (copy-up).
  • docker diff lists the contents of upperdir: A means added, C means changed (including parent directories), and D means deleted.

So:

  • Deleting a container deletes its upperdir, so data written inside the container disappears. That's why you should put data in a Volume: a Volume is a directly mounted directory that bypasses overlayfs.
  • If you start 100 containers from one image, the image layers are stored only once.
  • In a Dockerfile, "COPY then RUN rm" doesn't make the image smaller because the file remains in a lower read-only layer. Security best practices even lists this as something to avoid.

Capabilities and seccomp: What a container can do

$ docker info --format "{{.Driver}} {{json .SecurityOptions}}"
overlayfs ["name=apparmor,profile=default","name=seccomp,profile=builtin"]

$ docker exec demo grep CapEff /proc/1/status
CapEff:	00000000a80425fb

$ docker run --rm --cap-drop ALL alpine:3.23 grep CapEff /proc/1/status
CapEff:	0000000000000000
  • docker info shows that this environment enables the default AppArmor and seccomp settings. seccomp blocks the container from making some dangerous system calls (such as loading kernel modules).
  • CapEff is the process's current capabilities, represented as bits. By default, root inside a container has only some privileges (a80425fb), not full root privileges; after --cap-drop ALL, they are all zero. See Security best practices for the practical effects.
  • --privileged gives a container all capabilities and disables seccomp and AppArmor, removing this layer of protection.

Comparing containers and virtual machines

ContainerVirtual machine
KernelShares the host's Linux kernelEach virtual machine has its own kernel
IsolationKernel features such as namespaces, cgroups, and seccompHardware virtualization
StartupStarts one process, usually in under 1 secondBoots in seconds to tens of seconds
OverheadAlmost noneRequires memory and CPU allocation for each virtual machine
Isolation strengthWeaker; a kernel vulnerability can affect all containersStronger

This also explains a few common observations:

  • On a Linux host, ps shows container processes. Windows and macOS can't see them because containers run inside the Docker Desktop virtual machine.
  • A container can't run an operating system with a different kernel. For example, a Linux host can't directly run a Windows container.
  • The exec format error for multi-platform images occurs because a container has no separate CPU; its program must be in a format the host CPU can run.

Recap

  • A container is a Linux process on the host, created by runc and watched over by containerd-shim.
  • Namespaces isolate processes, networking, mounts, hostnames, and other things the container can see; users aren't isolated by default.
  • cgroups implement --memory, --cpus, and --pids-limit. You can read the settings directly from cgroup files.
  • overlayfs combines read-only image layers with a writable upperdir to make the container's root directory. Deleting the container deletes its upperdir.
  • Capabilities, seccomp, and AppArmor limit what root inside a container can do. --privileged removes all of these controls.

Exercises

Exercise 1: Run docker run -d --name t1 --pids-limit 50 alpine:3.23 sleep 600, then read the PID limit from inside the container.

Answer

cgroup v1:

docker exec t1 cat /sys/fs/cgroup/pids/pids.max

cgroup v2:

docker exec t1 cat /sys/fs/cgroup/pids.max

Both should show 50. Use docker info --format "{{.CgroupVersion}}" to check which version your system uses.

Exercise 2: Delete a file that already exists in the image from inside the container (for example, docker exec t1 rm /etc/motd), then run docker diff t1. What do you see? Was the file actually deleted from the image?

Answer

docker diff shows D /etc/motd. The image layers are read-only, so the file wasn't actually deleted: overlayfs put a "deletion marker" (whiteout) in upperdir so the merged view hides it. Start a new container from the same image and /etc/motd is still there.

Exercise 3: Start two nginx:1.30-alpine containers at the same time and compare their lowerdir and upperdir.

Hint

Run grep overlay /proc/mounts on a Linux host. The two containers have identical lowerdir values (they share the image layers), but different upperdir values.

FAQ

What is the most fundamental difference between a container and a virtual machine?

A virtual machine emulates an entire computer and runs its own operating system kernel. A container has no kernel of its own: all containers use the host's Linux kernel directly and are isolated by namespaces and cgroups. That's why starting a container only requires starting a process and takes almost no time.

Why can Windows and macOS run Linux containers too?

Docker Desktop starts a small Linux virtual machine in the background (WSL 2 on Windows). Containers actually run on that virtual machine's Linux kernel.

What's the difference between cgroup v1 and v2?

Both are Linux kernel mechanisms for limiting resources. Version 2 is newer and uses a unified directory structure. File names differ: for example, the memory limit is memory.limit_in_bytes in v1 and memory.max in v2; the CPU quota is cpu.max in v2. Recent distributions and Docker Desktop use v2 by default. docker info shows the version in Cgroup Version.

Does starting 100 containers from one image use 100 times the space?

No. Image layers are read-only lowerdirs shared by all containers. Each container adds only its own upperdir to store files it writes or modifies.

Updated October 14, 2026