Mert Özen Further With Every Line
Computer Science Fundamentals Operating System Fundamentals — #09

Containers from the OS Perspective: Namespaces, cgroup v2 and "A Container Is Just a Process"

Mert Özen Nov 14, 2026 10 min 2 views
Containers from the OS Perspective: Namespaces, cgroup v2 and "A Container Is Just a Process"

A container is an ordinary process whose view is limited by namespaces and whose consumption is limited by cgroups; it shares the host's kernel. We show that dotnet, PID 1 in the container, is process 300861 on the host, that the user namespace is shared, that .NET reads CPU and memory limits from the cgroup, and that creating threads past pids.max crashes with "Out of memory." while memory is free.

Version note: The code in this post was built and run with .NET 10; tests use xUnit v3 4.0.3, and the .NET 11 comparison used the 11.0.0 runtime. Measurements were taken with Docker 29 in mcr.microsoft.com/dotnet/sdk:10.0 and mcr.microsoft.com/dotnet/runtime:11.0 containers based on Ubuntu 24.04, on a Linux 6.x kernel with cgroup v2. Namespace and cgroup details come from the Linux man pages and kernel documentation, and Windows details from Microsoft Learn.

Guess First

Bookstore, the online bookstore used as the shared example across this site's series, runs in a container. When you run ps inside the container, the PID of the dotnet process is 1. If you look at the same process on the Linux machine that runs the container, what is its PID: 1 again, some other number, or is the process not visible there at all?

Second question: inside the container the id command says uid=0(root). Is this root the same user as the machine's root? In this last post we bring all the parts of the series together and look at a container through the eyes of the OS.

Container = Process + Namespaces + cgroups

There is no object called "container" in the Linux kernel. A container is an ordinary process surrounded by two kernel features. Namespaces limit what the process can see: its own PID numbers, its own network interfaces, its own view of the file system, its own hostname. cgroups limit how much it can use: CPU, memory, I/O, number of processes. Docker, containerd and Kubernetes are tools that manage these two features; an image packages the file system the process will see.

The difference from a VM follows from this. A VM runs its own kernel; a container shares the host's kernel. It is easy to see with images from two different distributions:

docker run --rm alpine:3 head -1 /etc/os-release
docker exec bookstore head -1 /etc/os-release
[ "$(docker run --rm alpine:3 uname -r)" = "$(docker exec bookstore uname -r)" ] && echo "same kernel in alpine and ubuntu containers"
NAME="Alpine Linux"
PRETTY_NAME="Ubuntu 24.04.5 LTS"
same kernel in alpine and ubuntu containers

The user side, libc, the shell and packages, differs; the kernel is the same. The system calls from the first post, the process table from the second, the scheduler from the fourth and the OOM killer from the sixth are the same mechanisms of the same kernel for the process in the container too.

Namespaces: What Does the Process See?

The namespaces(7) man page lists eight namespace types: cgroup, IPC, network, mount, PID, time, user and UTS, the hostname and domain name. Every process is a member of exactly one namespace of each type, and the membership shows up as a symlink under /proc/<pid>/ns; the number in square brackets identifies the namespace. I started the Bookstore container with its limits and looked inside:

docker run -d --name bookstore --memory=256m --cpus=1.5 --pids-limit=100 -v ${PWD}/out:/app -w /app mcr.microsoft.com/dotnet/sdk:10.0 dotnet Bookstore.OsLab.dll serve 1
docker exec bookstore sh -c 'ps -eo pid,comm; hostname; id; for n in pid net mnt uts ipc user cgroup; do printf "%s -> %s\n" $n $(readlink /proc/1/ns/$n); done'
    PID COMMAND
      1 dotnet
     14 sh
     20 ps
70992f41ff59
uid=0(root) gid=0(root) groups=0(root)
pid -> pid:[4026532694]
net -> net:[4026532696]
mnt -> mnt:[4026532690]
uts -> uts:[4026532691]
ipc -> ipc:[4026532692]
user -> user:[4026531837]
cgroup -> cgroup:[4026532695]

Thanks to the PID namespace, dotnet sees itself as PID 1; that was the reason for the signal behavior in the eighth post. The UTS namespace turned the hostname into the short form of the container ID. The mount namespace shows it the image's file system, and the network namespace its own interfaces. The line that stands out is user: the numbers of the others are close to each other and new, while the user namespace's number is much smaller. Docker does not create a new user namespace by default; the container shares the host's user namespace. We will come back to this for the second question.

Seeing the Container from the Outside

Now the first question. Docker knows the host-side PID of the container's main process:

docker top bookstore -eo pid,comm
docker inspect bookstore --format 'State.Pid={{.State.Pid}}'
PID                 COMMAND
300861              dotnet
State.Pid=300861

The answer: some other number. PID 1 in the container is an ordinary process numbered 300861 in the host's PID namespace. It appears among the other processes in the host's ps output, its CPU is counted in top, and it can be stopped from the host with kill. A process lives in several nested PID namespaces at once, with a different number in each; according to proc(5), the NSpid line in /proc/<pid>/status lists all of these numbers. In this environment the host was Docker's Linux VM; on Windows, Docker Desktop usually uses WSL 2's VM, which, according to Microsoft's WSL documentation, runs a real Linux kernel.

Namespaces can be shared, too. A Kubernetes debug container, or --pid=container: in Docker, puts a second container into the first one's PID namespace:

docker run --rm --pid=container:bookstore mcr.microsoft.com/dotnet/sdk:10.0 sh -c 'ps -eo pid,comm; for n in pid net mnt uts; do printf "%s -> %s\n" $n $(readlink /proc/self/ns/$n); done; hostname'
    PID COMMAND
      1 dotnet
     30 sh
     36 ps
pid -> pid:[4026532694]
net -> net:[4026532966]
mnt -> mnt:[4026532962]
uts -> uts:[4026532963]
309d1303072f

The PID namespace number is the same as Bookstore's, 4026532694, and the new container sees Bookstore's dotnet as PID 1, while the network, mount and UTS namespaces are its own. The ephemeral debug container we mentioned in the first post is exactly this: a container that brings its own tools but can see the target process. On a Linux server nsenter does the same: according to the nsenter(1) man page, it enters the namespaces of a given PID and runs a program there; sudo nsenter -t 300861 -n ss -tlnp runs the host's ss in the container's network namespace. That step needs root, so it was not run for this post; docker exec does the same job with Docker's own privileges.

cgroup v2: How Much Can the Process Use?

Namespaces limit visibility, cgroups limit consumption. According to the kernel's cgroup v2 documentation, cgroups form a single tree under /sys/fs/cgroup; every directory is a cgroup, and the files in it are the settings and counters of that cgroup's controllers: cpu.max, memory.max, io.max, pids.max. Because the container also gets a cgroup namespace, /proc/self/cgroup shows 0::/: the process takes its own cgroup for the root of the tree. I added a command to the lab app that prints these values and what .NET makes of them:

docker exec bookstore dotnet Bookstore.OsLab.dll container-info
runtime              = .NET 10.0.15
/proc/self/cgroup    = 0::/
memory.max           = 268435456
cpu.max              = 150000 100000
pids.max             = 100
ProcessorCount       = 2
GC available memory  = 192 MB
Process.MaxWorkingSet = 256 MB

--cpus=1.5 means 150 ms of CPU time in every 100 ms period; .NET rounded it up and made ProcessorCount 2. In the fourth post we saw --cpus=0.5 give 1, along with throttling. memory.max is 256 MB; the GC derived the 75% heap cap from the sixth post, 192 MB, from it. Process.MaxWorkingSet also read the limit from the cgroup.

pids.max is the least known but the sneakiest limit. According to the cgroup documentation, when the limit would be exceeded fork and clone fail with EAGAIN, and since threads on Linux are also created with clone, this limit counts threads too. I ran the thread-cost command from the third post in the same container with 200 threads:

docker exec bookstore sh -c 'cat /sys/fs/cgroup/pids.current; dotnet Bookstore.OsLab.dll thread-cost 200; echo "exit=$?"; cat /sys/fs/cgroup/pids.events'
9
Out of memory.
Aborted
exit=134
max 2

The process printed "Out of memory." and aborted; exit code 134, that is 128 + 6, the number of SIGABRT. Memory had not run out: the kernel refused the new thread with EAGAIN, and the runtime reported it as a memory error. The max counter in pids.events records how many times the limit was hit; it was 2 after two attempts. This is an error that sends anyone reading the logs to the wrong place: no OOMKilled, a flat memory graph, yet the process dies. The same command ran without trouble with 50 threads.

.NET's Container Awareness and .NET 11

The runtime reads cgroups in two places: the native side, for the GC's heap cap and ProcessorCount, and the managed side, for the Linux code of the Process class. Their code for walking the tree is separate. According to issue 133470 in dotnet/runtime, when the process sat at the root of the cgroup hierarchy, the 0::/ case above, the managed code could climb above the mount point while looking for the memory limit; the native code already checked this boundary. According to the .NET 11 RC 2 release notes, the runtime now traverses cgroup v2 memory limits correctly at the hierarchy root; the fix went into 11.0.0 GA and was backported to the 10.0 branch as well. I ran the same container-info command in the .NET 11 runtime image:

docker run --rm --memory=256m --cpus=1.5 -e DOTNET_ROLL_FORWARD=Major -v ${PWD}/out:/app -w /app mcr.microsoft.com/dotnet/runtime:11.0 dotnet Bookstore.OsLab.dll container-info
runtime              = .NET 11.0.0
/proc/self/cgroup    = 0::/
memory.max           = 268435456
cpu.max              = 150000 100000
pids.max             = max
ProcessorCount       = 2
GC available memory  = 192 MB
Process.MaxWorkingSet = 256 MB

Because I did not pass --pids-limit to this container, pids.max is max; the remaining values match .NET 10.0.15. In this environment .NET 10.0.15 also read the limits correctly. The practical conclusion is simple: if you run .NET in containers, stay on the current servicing release, because the cgroup reading code still receives fixes.

The Security Boundary: The Same Kernel

Now the second question. Because the user namespace is shared, uid=0 in the container is, in the kernel's eyes, the same user as root on the host. Other layers restrict it: by default Docker drops most capabilities, the pieces root's privileges are split into, and applies a seccomp profile that filters which system calls the process can make:

docker exec bookstore grep -E "^(CapEff|Seccomp)" /proc/1/status
docker exec --user app bookstore id
CapEff:	00000000a80425fb
Seccomp:	2
Seccomp_filters:	1
uid=1654(app) gid=1654(app) groups=1654(app)

CapEff is the bit mask of the effective capabilities; for full root all bits would be set. Seccomp: 2 says filter mode is on. These deepen the defense, but the boundary itself is the same kernel: a security bug in the kernel, or a setting such as --privileged that turns these layers off, is the path from the container to the host. That is why Windows containers with Hyper-V isolation and VM-based runtimes such as Kata give every container its own kernel. According to Microsoft's Windows container documentation, with process isolation containers share the kernel with the host, and with Hyper-V isolation each runs in an optimized VM with its own kernel.

The cheapest measure on your side is not running as root. The official .NET images have a ready-made app user, UID 1654, available in the APP_UID variable; USER $APP_UID in the Dockerfile is enough. A further step is user namespace remapping: root in the container is mapped to an unprivileged UID on the host. Dockerfile and image security belong to the upcoming Docker series, and the Kubernetes counterpart, securityContext, to the published Kubernetes series.

Production Notes

Read container metrics from the cgroup. memory.current, memory.events, nr_throttled in cpu.stat, and pids.events tell you why a container slowed down or died more accurately than process metrics do. When a pod crashes "for no reason," looking at these four files is the production counterpart of the measurements in the sixth and fourth posts.

Being able to look from the host is worth a lot. You can find a container's process in the host's top and read every file under /proc/<pid>/, maps, status and fd, from the host; every observation in this series can also be made from outside the container.

Common Pitfalls

Believing a container is as strong a security boundary as a VM. If you need to isolate workloads that share the same kernel from each other, such as different customers' code or untrusted plugins, consider a VM or a VM-based container runtime; root, --privileged and host path mounts make this boundary even thinner.

Thinking limits are only about memory and CPU. pids.max counts threads too; old code that opens a thread per request, or an unbounded Parallel.For, can bring the process down while memory is free. In Kubernetes the per-pod PID limit is a cluster setting; know which value applies.

Treating PID 1 in the container as an ordinary process. We saw in the eighth post that PID 1 handles signals differently and has to reap orphaned processes; either make your application PID 1 with the exec form or put a small init such as --init in front of it, but do not put a shell in between.

A Small Experiment

★☆☆ Start the Bookstore container with --cpus=2.5, --memory=1g and --pids-limit=500; predict every line of container-info using the rule from the relevant post in this series, then run it and compare.

★★☆ Start a second container with docker run --rm --network=container:bookstore and confirm that readlink /proc/self/ns/net is the same as Bookstore's. Think about whom you reach when this container connects to localhost, and use it to explain why containers in a Kubernetes pod can talk over localhost.

★★★ In a Linux VM, or inside WSL 2 on Windows, build a container without Docker: open new namespaces with unshare --pid --fork --mount-proc --uts, create a directory under /sys/fs/cgroup and set its memory.max to 128 MB, write the shell's PID to cgroup.procs, and run dotnet Bookstore.OsLab.dll fill 1024 there. Compare the result with the sixth post.

The series ends here. Starting from a system call, we went through processes, threads, the scheduler, virtual memory, the file system and I/O to arrive at containers; all of them were services the same kernel provides to the same .NET application. The next time you ask "why does my application behave like this," the answer will often be in /proc, in a strace line or in a cgroup file.