Namespaces and cgroups: what a container actually is
A container is not a thing the kernel knows about. It is an ordinary process with two kernel features applied to it: namespaces, which change what it can see, and cgroups, which change what it can use. Understanding that split explains most container behaviour that otherwise looks like magic, including why a container can see the host's memory and size itself wrong.
TL;DR: Namespaces control visibility, cgroups control consumption. A container is a normal Linux process with both applied. There is no container object in the kernel, which is exactly why several confusing behaviours make sense once you stop looking for one.
The two mechanisms
Namespaces partition what a process can see. There are several kinds and each virtualises one global resource. The PID namespace gives the process its own process tree, whose first process is PID 1; an application started by an init wrapper may have another PID. The mount namespace gives it its own filesystem view, which is what makes the image look like the whole disk. The network namespace gives it its own interfaces, routing table and port space, so processes in separate network namespaces can both bind port 8080. Containers in the same Kubernetes pod normally share a network namespace and therefore compete for the same ports. The UTS namespace gives it its own hostname, the IPC namespace its own shared memory segments, and the user namespace its own UID mapping, so root inside can be an unprivileged user outside.
Cgroups limit what a process can consume. Cgroups v2 is a unified hierarchy where each controller caps or weights a resource: memory, CPU, IO, PIDs. A memory limit in a container manifest becomes memory.max on the cgroup. A CPU limit becomes a quota and period pair.
Put both on a process and you have a container. Take them off and you have a process. The runtime, whether containerd or CRI-O, is doing bookkeeping around these two kernel features and an image filesystem.
Three behaviours that follow from the split
Once you stop looking for a container object and see two separate kernel features, three otherwise confusing behaviours become predictable. Each one is a direct consequence of either namespaces or cgroups, and knowing which is which tells you where to look.
PID 1 has no default signal handlers. When your app is PID 1 inside its PID namespace, the kernel does not apply default actions for catchable signals to it. An unhandled SIGTERM is discarded rather than terminating the process, which is why containers hang until the grace period expires.
A CPU limit is throttling, not slowing. The CPU controller enforces a quota per period, typically 100 milliseconds. A process that exhausts its quota is stopped until the next period begins. So a limit of 0.5 CPU does not run your code at half speed, one continuously runnable thread can consume its 50-millisecond budget before the 100-millisecond period ends; parallel threads can exhaust the aggregate budget sooner, which is why latency-sensitive services with tight CPU limits show periodic multi-millisecond stalls that look like garbage collection but are not.
Memory pressure can trigger reclaim, throttling or OOM. On cgroup v2, memory.high puts allocating tasks under reclaim pressure and throttles them when usage exceeds the threshold. memory.max is the hard boundary: if reclaim cannot bring usage under the limit, the cgroup can invoke the OOM killer. Kubernetes memory limits do not imply that memory.high has been configured. Exit code 137 means SIGKILL under the usual shell convention; it can also result from an administrator or runtime killing the process. Check memory.events, the container’s termination reason and node logs before calling it OOM. The kernel’s cgroup v2 documentation defines both controls and their failure behaviour.
The classic trap
Older runtimes and older language runtimes read /proc/meminfo, which is not namespaced. A JVM or Node process therefore sees the host's total memory rather than its cgroup limit, sizes its heap against that number, and dies during warm-up on a container with a limit far below it. Modern JVMs are container-aware by default and Node accepts an explicit heap size, but the underlying reason is worth carrying: /proc shows host values for anything the kernel has not namespaced, so a container's view of the machine is only partly virtual.
Self-check
A container is reported as "stuck at 100 percent CPU" with a CPU limit of 1. Separately, a JVM in another container with a 2 GiB memory limit is being killed during startup on a node with 256 GB of RAM, and its exit code is 137. And a third container will not terminate on kubectl delete until the grace period expires, thirty seconds later.
For each, say which of the two mechanisms is responsible, what the reading actually means, and which file or signal you would look at. Then say, in one sentence each, what a network namespace gives a container and what a memory cgroup takes away. Work through all of it before reading on.
The 100 percent reading is cgroups, and the number is almost certainly being misread. A limit of 1 CPU is a quota of 100ms per 100ms period, so "100 percent" means the cgroup is consuming all of the quota it was given, which is a statement about the limit rather than about the machine. Saturated and throttled are distinguishable, and the file that distinguishes them is cpu.stat: compare the increase in nr_throttled against nr_periods over the incident window. A high ratio means the group is hitting its ceiling and being stopped, in which case the fix is the limit or the workload's parallelism. A near-zero ratio with high usage means the work needs that much CPU and is getting it.
The JVM is cgroups again, and the trap is the host's memory being visible at all. This is the /proc/meminfo case: that file is not namespaced, so an unaware runtime reads 256 GB, sizes its heap against it, and allocates past the cgroup's memory.max of 2 GiB during warm-up. Exit 137 is consistent with SIGKILL under the usual convention and is not proof of a cgroup OOM, since an administrator or the runtime can also kill a process, so check memory.events on the cgroup, the container's termination reason, and the node's logs before concluding. The fix is to make the runtime container-aware or to set the heap explicitly, and the reason to remember the mechanism is that any runtime sizing itself from /proc has the same bug.
The termination delay is namespaces, and specifically the PID namespace. The application is PID 1 in its own namespace, and the kernel does not apply default actions for catchable signals to PID 1, so an unhandled SIGTERM is discarded rather than terminating the process. Nothing then happens until the grace period expires and SIGKILL arrives, which is not catchable. The fix is to handle SIGTERM in the application or to run an init process that does, and what gives it away is that the delay is always exactly the grace period rather than variable.
Then the two sentences. A network namespace gives the container its own interfaces, routing table and port space, so it can bind a port without regard to what the host or another namespace has bound. A memory cgroup takes away the ability to allocate beyond a ceiling, by putting allocating tasks under reclaim pressure and then invoking the OOM killer inside that cgroup when reclaim cannot bring usage under the limit.
Notice the pattern across all three: the visibility questions were namespaces and the consumption questions were cgroups, and the one that caused the most confusion was the case where visibility and consumption disagreed, because /proc/meminfo is not namespaced while memory.max is enforced.
Next: CFS quotas and throttling works the CPU half through in detail, and the Kubernetes control loop covers what arranges all of this.
Memory investigations also need to separate virtual mappings, resident pages and page cache within the affected accounting boundary.