SevyDevy

Guide · Backend

Docker Containers Explained: Namespaces, Cgroups and the Root Filesystem

22 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • Here is how Docker containers work: a container is an ordinary Linux process that the kernel, the core of the operating system, fences in and limits.
  • Namespaces decide what the process can see, such as its own process list and hostname. Cgroups (control groups) decide how much memory, CPU and processes it may use.
  • The image is unpacked into a folder. Docker then makes that folder the process's root directory, so the process sees those files as its whole system.
  • Every container shares the host's kernel. That is why a container starts quickly, and also why it is not a virtual machine.
  • Beginners can stop after the section on virtual machines. Engineers get the parts most guides skip: why docker stop waits 10 seconds, and the exact system calls runc 1.5.2 makes.

What goes wrong when you treat a Docker container like a small virtual machine?

You make wrong guesses about safety, memory and shutdown, because a container is not a separate computer. It is a process on your server, sharing your server's kernel. Knowing how Docker containers work turns those guesses into facts you can check.

Many people learn Docker as "a lightweight VM". The words are easy, but they hide what is really going on. A virtual machine, or VM, boots its own kernel on pretend hardware. A container boots no kernel at all.

Picture a common setup. A small team in Pune runs a college admissions website on one cloud server with 8 GB of memory. Three containers run there: the web app, a PDF report worker and a database. The setup is made up, but every command below was run on Docker 29.9.2.

The team hit three surprises in one week. Each deploy waited about 10 seconds on every container before it stopped. The report worker died with exit code 137, while a memory check inside it still showed the full 8 GB. And a developer found the web app in the server's own process list, running as root.

None of these is a bug. Each one follows from how containers are built. By the end of this guide you will be able to explain all three.

Which words do you need before looking inside a container?

You need five words: process, PID, kernel, image and root filesystem. Each one gets a plain meaning here.

  • Process: a program that is running right now. Your browser is one process; a web server is another.
  • PID: the process ID, a number the kernel gives each process. PID 1 is the first process, which normally starts everything else.
  • Kernel: the core of Linux that talks to the hardware. Every program asks it for memory, files and network access.
  • Image: a packed set of files, such as a small set of Linux tools plus your app. It is not running; it is just files.
  • Root filesystem: the top folder, written "/", that a process sees as the start of all its files.

How Docker containers work: what does docker run really build?

To see how Docker containers work, start with one fact: a container is a normal Linux process with four kinds of fences around it. Docker asks the kernel to set those fences up, then starts your program inside them.

A system call is how a program asks the kernel to do something, like open a file or start a process. Every container on the server makes its system calls to the same kernel.

  • Namespaces control what the process can see: its own process list, hostname, network and mount points.
  • Cgroups control what the process can use: how much memory, CPU time and how many processes.
  • A root filesystem gives the process its own files: the image is unpacked into a folder that becomes its "/".
  • Capabilities and seccomp control what the process may do: they take away root powers and block risky system calls.

Here is one everyday picture. A container is like a flat in an apartment block. The front door and windows decide what you can see, like namespaces. The flat's electricity meter with its fuse is the cgroup. Your own furniture is the root filesystem.

The society rules on what you may change are capabilities and seccomp. The building's foundation and lifts are the kernel, shared by every flat. Where the picture breaks: flat walls are concrete, but container walls are kernel bookkeeping. One kernel bug can open every flat at once.

How can you see that a container is just a process?

Start a container, then look for it in the host's normal process list. It is there, with a normal PID, run by the host's root user. Inside the container, the same process thinks it is PID 1.

Try it on any Linux machine with Docker. Add sudo before docker if your user is not in the docker group.

bash
docker run -d --name web --hostname box alpine sleep 3600

# On the host: the container is an ordinary process
docker top web
ps -o pid,user,cmd -p $(docker inspect -f '{{.State.Pid}}' web)

# Inside: the same process is PID 1, with its own hostname
docker exec web cat /proc/1/comm
docker exec web hostname

# Same kernel inside and outside
docker exec web cat /proc/sys/kernel/osrelease
uname -r

On our box, the host saw the process as PID 1332, user root, command sleep 3600. Inside, PID 1 was sleep, and the hostname was box. Both kernel release lines printed 6.18.44-fc-v153. Your PID and kernel version will differ, but the two release lines will match each other.

This explains the team's third surprise. The web app showed up in the server's process list because it really is a process on that server. Docker only changed what the process can see.

What do Linux namespaces hide from a container?

A namespace gives a process its own private copy of one global thing, such as the process list or the hostname. The kernel manual defines eight types, and Docker uses several of them for each container.

You can compare namespaces yourself. Each process has links in /proc/PID/ns, one per type. If two processes show the same number for a type, they share that namespace.

bash
PID=$(docker inspect -f '{{.State.Pid}}' web)
for n in pid uts net mnt ipc user cgroup time; do
  echo "$n host=$(readlink /proc/self/ns/$n) container=$(sudo readlink /proc/$PID/ns/$n)"
done
The eight namespace types from namespaces(7), and what docker run did on our test box on 23 October 2026
NamespaceWhat it isolatesDocker default on our box
PIDProcess IDs, so the container sees only its own processesNew: the app is PID 1 inside
UTSHostname and NIS domain nameNew: hostname box inside, the host's own name outside
NetworkNetwork devices, network stacks and portsNew: only eth0 and lo inside
MountThe list of mounted filesystemsNew: the image is mounted at /
IPCShared memory and message queues between processesNew
UserUser and group IDsShared with host, so root inside is root outside
CgroupWhich cgroup folder looks like the topShared on our cgroup v1 box; Docker docs say private by default on cgroup v2
TimeBoot and monotonic clocksSeparate on our box; we did not test clock changes

Look at the User row. By default, Docker did not give the container its own user namespace. So user ID 0 inside the container is user ID 0 on the host. That is why the team saw their app running as root.

Docker's security page says the daemon itself needs root privileges unless you opt in to Rootless mode. User namespaces, covered later, are one way to reduce that risk.

How do cgroups limit memory, CPU and processes for a container?

A cgroup is a kernel group of processes with a resource limit attached. Docker puts each container in its own cgroup and writes your limits into it. The kernel then enforces them on every process in the group.

Docker's own documentation is direct about the default: a container has no resource constraints unless you set them. Without a limit, one container can use all of the server's memory.

What happens at a memory limit

When a cgroup reaches its memory limit and the kernel cannot free enough, it runs the out-of-memory (OOM) killer inside that cgroup. The killer ends a process in the group with SIGKILL. When that process is your main one, Docker reports exit code 137, which is 128 plus 9.

bash
# A shell that doubles a string until it runs out of memory
docker run --name hog --memory 64m --memory-swap 64m alpine \
  sh -c 'a=x; while true; do a=$a$a; done'
echo "exit=$?"
docker inspect -f 'OOMKilled={{.State.OOMKilled}}' hog
docker rm hog

Expected output: exit=137, then OOMKilled=true. This is safe to run, because the limit protects the rest of your machine. That was the report worker's surprise in our scenario.

Now the second half of that surprise. Inside a container limited to 64 MB, /proc/meminfo still showed the host's full 8220244 kB on our box. Its manual says the file reports memory use on the whole system, and our test showed the cgroup limit does not change it. The free command reads this file too. So check the limit from outside instead.

bash
docker run -d --name capped --memory 64m --pids-limit 20 alpine sleep 3600
docker inspect -f 'Memory={{.HostConfig.Memory}} PidsLimit={{.HostConfig.PidsLimit}}' capped
docker stats --no-stream --format '{{.Name}} {{.MemUsage}} {{.PIDs}}' capped

Expected output: Memory=67108864 PidsLimit=20, then a line like capped 660KiB / 64MiB 1. The 67108864 is 64 times 1024 times 1024 bytes, shown by docker stats as 64MiB.

Process and CPU limits

The pids limit caps how many processes the cgroup may hold. With a limit of 20, our test tried to start 30 background jobs, and bash printed "fork: retry: Resource temporarily unavailable" before giving up. The kernel's cgroup guides, for both version 1 and version 2, say new processes fail with EAGAIN at the limit. That error is this message.

CPU limits work through a quota. In cgroup version 2, the cpu.max file holds two numbers: the run time allowed, then the length of each period. Docker's CPU flags change these cgroup settings for you. We did not run a CPU test, so this part comes from the kernel and Docker docs.

Cgroup version 1 and version 2

Linux has two cgroup designs. The cgroups(7) manual says version 2 keeps all controllers in a single tree and is meant to replace version 1. Run this to see which one your machine uses.

bash
stat -fc %T /sys/fs/cgroup/

cgroup2fs means version 2. tmpfs means version 1, or a mix of both, which is what our test box printed. Docker 29.9.2 printed a warning there that cgroup v1 support is deprecated, with removal planned by May 2029 at the latest.

How does a container get its own root filesystem?

Docker unpacks the image's layers into folders, stacks them into one view with a filesystem called overlay, and then runc makes that view the process's root. The process sees the image's files, plus a few mounts Docker adds, such as /proc, /sys and /dev, plus its own copies of the container's hosts and resolv.conf files.

An image is a stack of read-only layers. Docker's storage documentation says a running container adds one thin writable layer on top. Files you change go into that top layer and vanish with the container, unless they sit in a volume or bind mount. A tmpfs mount is also outside the layer, but it lives in memory and is lost too. On our box, /proc/PID/mountinfo showed "/" as an overlay of exactly these layers.

From image layers to the container's /

Notice that nothing is copied into the container. The kernel stacks folders, and pivot_root makes the stacked view the only root the process can see.

Diagram connections:

  • Image layers to Overlay mount (lowerdir)
  • Writable layer to Overlay mount (upperdir)
  • Overlay mount to New mount namespace (mounted inside)
  • New mount namespace to pivot_root call (then)
  • pivot_root call to Your app process (new root)
  • Image layers: Read-only folders unpacked from the image
  • Writable layer: New and changed files for this container
  • Overlay mount: Kernel merges layers into one folder
  • New mount namespace: Private mount list for this container
  • pivot_root call: runc swaps the root to the overlay folder
  • Your app process: Sees the overlay folder as /

Why pivot_root and not just chroot

chroot changes where "/" starts for a process and its children, but nothing else. Its manual says it is not intended for any kind of security purpose. pivot_root swaps the whole root mount inside a mount namespace, and the old root can then be detached. The pivot_root(2) manual names container setup as a modern use.

We tested chroot alone on our box. Inside a plain chroot, ps listed more than 70 processes, every process on the host. A new root folder hides files, but without namespaces it hides nothing else.

Build a container by hand with unshare

You can do most of what Docker does with one command from util-linux, the package that provides unshare. Run these as root in an empty folder. We tested them with util-linux 2.39.3.

  1. Get a root filesystem

    Run mkdir rootfs and CID=$(docker create alpine sh). Then run docker export $CID | sudo tar -x -C rootfs, and docker rm $CID to clean up.

  2. Start a fenced process

    Run sudo unshare --uts --pid --mount --ipc --net --fork --root=rootfs --mount-proc /bin/sh. This creates five namespaces. Its --root option uses chroot, not pivot_root, which is fine for a demo.

  3. Check you are PID 1

    Type echo $$ inside. It prints 1, because the shell is the first process in its new PID namespace.

  4. Change the hostname

    Type hostname mybox, then hostname. It prints mybox. Open another terminal and run hostname: the host's name has not changed.

  5. Leave

    Type exit. The kernel tears the namespaces down once their last process ends, unless something else holds them open.

What is missing compared with Docker? There is no cgroup limit, no capability drop and no seccomp filter. The new network namespace has only a loopback device, so nothing can reach the internet. Docker and runc add all of those.

What stops root inside a container from controlling the host?

Two kernel features do most of the work: capabilities, which split root's power into small pieces, and seccomp, which blocks chosen system calls. Docker turns on both by default.

The capabilities(7) manual explains that Linux divides root's power into separate units that can be switched on and off. Changing the hostname, mounting filesystems and calling pivot_root all need one called CAP_SYS_ADMIN.

On our box, a default container kept 14 capabilities, such as binding to ports below 1024. CAP_SYS_ADMIN was not among them. So root inside the container got "hostname: you must be root to change the host name", and mounting a tmpfs failed with permission denied.

bash
docker exec web grep -E 'CapEff|Seccomp:' /proc/self/status
docker exec web hostname other

Expected output on Docker 29.9.2: CapEff 00000000a80425fb, Seccomp 2, then the hostname error. CapEff is a bitmask: each 1 bit is one capability the process holds. capsh --decode turned this value into the 14 names, and all zeros means none. Seccomp 2 means a filter is active. Docker's seccomp page says its default profile disables around 44 system calls out of more than 300, including mount and keyctl.

How common docker run flags changed the fences on our box (Docker 29.9.2)
FlagCapabilities (CapEff)SeccompWhat it means
none00000000a80425fb (14 caps)2 (filter on)The safe default
--cap-drop ALL00000000000000002Root inside has no special powers
--user 1000:100000000000000000002Runs as a normal user, no capabilities
--security-opt seccomp=unconfined00000000a80425fb (14 caps)0 (off)No system call filter
--privileged000001fffeffffff on our box0 (off)Every capability the host allows, which varies by host; avoid

User namespaces add another layer when you turn them on. The user_namespaces(7) manual says a process can be user 0 inside a namespace and an ordinary user outside it. When we tried this as the unprivileged user nobody, id printed root, but reading the shadow password file still failed.

Why is a container not a virtual machine?

A container shares the host's kernel, while a virtual machine runs its own kernel on emulated hardware. Everything else follows from that one difference.

Container vs virtual machine at a glance
QuestionContainerVirtual machine
Whose kernel runs your code?The host's kernel, shared with every containerIts own guest kernel
What does starting it mean?Starting one process with fencesBooting a whole operating system
Can it run a different kernel version?No, the release string matches the hostYes, each VM picks its own
What separates it from others?Namespaces, cgroups, capabilities and seccompA hypervisor and virtual hardware

Two caveats apply to the kernel rows. Docker Desktop runs a virtual machine, even on Linux, so containers share that VM's kernel. Runtimes such as gVisor, an application kernel, and Kata Containers, lightweight VMs, also add a kernel layer.

Isolation ends at the shared kernel. A kernel bug can reach every container, because there is no second kernel in the way. Docker's security page lists the kernel's own security as one area to consider, and warns that only trusted users should control the Docker daemon.

If you need to run code you do not trust, such as code written by an AI agent, a microVM gives each workload its own kernel. Our guide on AI agent sandboxing covers that option.

Why does docker stop take 10 seconds for some containers?

Because the app is PID 1 in its namespace, and the kernel protects PID 1 from signals it has not asked to handle. Docker sends SIGTERM, the polite "please stop" signal, waits 10 seconds by default, then sends SIGKILL. An image's STOPSIGNAL or the --stop-signal flag can change the first signal.

The pid_namespaces(7) manual spells out the rule. Signals reach the namespace's init process only if it has set up a handler for them. SIGKILL and SIGSTOP are the exception when sent from a parent namespace, such as the host.

Our test showed it plainly. A container running sleep, which has no SIGTERM handler, ignored kill -TERM from the host and kept running. kill -KILL ended it at once with exit code 137. docker stop took 10.144 seconds and also ended with 137.

  1. Make your app handle SIGTERM

    Add a SIGTERM handler that closes connections and exits. Once a handler exists, the kernel delivers the signal, and docker stop returns quickly.

  2. Or add a tiny init process

    Run with docker run --init. Docker puts docker-init at PID 1, which forwards signals to your app and cleans up finished child processes.

  3. Check the result

    Time docker stop. With --init, our container stopped in 0.123 seconds with exit code 143, which is 128 plus 15 for SIGTERM.

A shell can cause the same delay. In a Dockerfile, the shell form of CMD, written without square brackets, runs your command through /bin/sh -c. When we built CMD sleep 300; echo done, /bin/sh was PID 1 and docker stop took 10.165 seconds again.

That was the team's first surprise. Their web app ignored SIGTERM as PID 1, so every deploy waited out the full grace period before SIGKILL.

What does runc 1.5.2 do when Docker starts a container?

runc asks the kernel for new namespaces, swaps the root with pivot_root, sets the hostname, and finally replaces itself with your program. We traced these steps on our box with strace, a tool that prints a program's system calls.

What runs when you type docker run

Each arrow is one program starting the next, as traced on Docker 29.9.2. Notice that runc init replaces itself with your app, so no Docker program stays inside the container (unless you use --init).

Diagram connections:

  • docker CLI to dockerd (API call)
  • dockerd to containerd (create task)
  • containerd to containerd-shim-runc-v2 (starts)
  • containerd-shim-runc-v2 to runc (runs)
  • runc to runc init (re-executes itself)
  • runc init to Your app (execve your program)
  • docker CLI: Sends your request to the daemon
  • dockerd: Docker daemon, prepares image and config
  • containerd: Manages container lifecycles
  • containerd-shim-runc-v2: Stays as the parent of your app
  • runc: Reads the OCI config, creates fences
  • runc init: Protected copy of runc
  • Your app: PID 1 inside, normal PID on host

You can give runc a folder yourself. runc spec writes a config.json in the OCI format, the open standard for container settings. On our box, its default config listed five namespaces: pid, network, ipc, uts and mount. The trace then showed these calls:

text
unshare(CLONE_NEWNS|CLONE_NEWUTS|CLONE_NEWIPC|CLONE_NEWPID|CLONE_NEWNET) = 0
pivot_root(".", ".")              = 0
sethostname("runc", 4)            = 0

pivot_root with the same folder twice is a trick from the pivot_root(2) manual. You change into the new root, pivot onto it, then detach the old root. No spare folder is needed.

Why runc has a part written in C

runc is written in Go, but its nsenter package is C code that runs before the Go runtime starts. Its README says this lets it join namespaces and avoid the problems the Go runtime has with multiple threads.

The "Docker is weird" post goes further. Its author calls Go "kind of a terrible language for a container runtime", mainly because of its threads and lack of fork. That is the author's opinion. runc's own README only states the threading problem.

The post says runc re-runs itself through /proc/self/exe. On runc 1.5.2, our trace showed more detail. runc opened /proc/self/exe, mounted a read-only overlay, then ran "runc init" from file descriptor 6.

runc's changelog explains this. Since version 1.2.0, runc uses an overlayfs protection for its own binary when it has enough privileges, and a copy of the binary otherwise. It defends against CVE-2019-5736, a 2019 flaw that let root in a container overwrite the host's runc binary.

How do you explain Docker containers in a system design interview?

Explain a container as a fenced process, then name each fence and where the fences end. Aim to show the trade-off, not a list of commands.

  1. Define it in one line

    A container is a Linux process that the kernel isolates with namespaces, limits with cgroups and roots in an unpacked image.

  2. Walk through docker run

    The daemon prepares the image, containerd and the shim call runc, and runc creates namespaces, applies cgroups, pivots the root and starts the app.

  3. Name the limits you would set

    Set memory and pids limits per container, because the default is none. Mention exit code 137 as the sign of an OOM kill.

  4. State the security layers

    Default capabilities drop CAP_SYS_ADMIN, seccomp blocks risky calls, and user namespaces or rootless mode remove host root.

  5. Say where isolation ends

    All containers share one kernel. For untrusted code, choose a microVM or a separate VM per tenant.

  6. Close with operations

    Handle SIGTERM or use an init process, so deploys and autoscaling stop containers fast and cleanly.

Questions people ask

Is a Docker container a virtual machine?
No, a container is a process that shares the host's kernel, while a virtual machine runs its own kernel. On our test box, the kernel release inside a container matched the host exactly. That shared kernel is why containers start quickly, and why a kernel bug can affect every container on the host.
Is root inside a container the same as root on the host?
By default the user ID is the same, but its powers are smaller. Docker did not create a user namespace on our box, so the app ran as host user 0. It kept only 14 capabilities, without CAP_SYS_ADMIN, and seccomp blocked risky system calls. Run as a normal user with --user to reduce risk further.
Why does free show all my server's memory inside a container?
Because free reads /proc/meminfo, which reports memory for the whole system and is not changed by cgroups. Inside a 64 MB container on our box, /proc/meminfo still showed the host total of 8220244 kB. Check the real limit from outside with docker stats or docker inspect, which showed 64MiB and 67108864 bytes.
What does exit code 137 mean for a container?
It means the main process was killed by SIGKILL, because 137 is 128 plus signal 9. The common causes are the OOM killer at a memory limit, or docker stop running out its grace period. Run docker inspect and check State.OOMKilled. If it says true, raise the limit or reduce memory use.
Do I need Docker to run containers?
No, containers are built from kernel features, so other tools can create them too. runc can run a folder and a config.json on its own, and util-linux's unshare can create namespaces from a shell. Docker adds image handling, networking, limits and security defaults on top of these.
Are containers secure enough for untrusted code?
Not on their own, because every container shares one kernel with the host. Default capabilities, seccomp and user namespaces reduce what code can do. Still, a kernel bug or a risky flag like --privileged can break the wall. For untrusted code, use a microVM or a separate virtual machine.
Does Docker use chroot or pivot_root?
runc uses pivot_root by default. Our strace of runc 1.5.2 showed a pivot_root call on the new root folder inside a fresh mount namespace. pivot_root swaps the whole root mount, so the old root can be detached. A plain chroot changes only where the path "/" starts.

Sources and further reading

Practise this with Lala

Practise