Isolation in Containers. Linux Primitives that help Achieve It

18 Minutes Apr 11, 2026 3654 Words


Container Isolation Header Image

⚙️ The Kernel Did It First

Docker didn’t invent isolation. Linux did. Years earlier.

You trust your container runtime to isolate your workloads. But Docker and Podman don’t enforce anything at runtime. The Linux kernel does. This article is about the kernel primitives those runtimes configure and what happens when they are misconfigured.

Lets take example of the container we created previously my-limited-httpd. Its pid is 13684

 1podman run -d \
 2 --name my-limited-httpd \
 3 --replace \
 4 --memory=512m \        # hard memory ceiling
 5 --memory-swap=512m \   # swap ceiling equal to memory = no swap allowed
 6 --cpus=1.5 \           # CPU quota: 1.5 cores worth of CPU time
 7 --cpu-shares=512 \     # relative CPU weight during contention
 8 --pids-limit=100 \     # max 100 processes/threads in this container
 9 --blkio-weight=500 \   # relative block I/O weight
10 -p 8081:80 \
11 registry.access.redhat.com/ubi8/httpd-24

Linux Namespaces - the eight curtains that hide the world from a process

How Linux Gives Every Process Its Own View of the World

We heard that containers provide isolation and security. Have you ever thought about what that actually means at the kernel level?

When a bad actor tries to run malicious code inside a container, a reverse shell, a privilege escalation attempt, they cannot creep into other parts of your system. That boundary is not enforced by Docker, Podman, or any container runtime. It is enforced by a Linux kernel feature called namespaces.

What Is a Namespace?

The man page puts it well:

A namespace wraps a global system resource in an abstraction that makes it appear to the processes within the namespace that they have their own isolated instance of the global resource.

My shorter version:

A namespace gives a process a filtered, isolated view of a specific system resource and localises control over it.

Without namespaces, all system resources are global. If any process changes the IP address of a network interface or unmounts a filesystem, every other process on the machine sees that change immediately. Namespaces scope that control so changes made inside one namespace stay there.

A Quick Proof with Hostnames

I ran uname -n on the host and inside two separate containers to see this in action.

 1# Host hostname
 2krish-local:~ # uname -n
 3krish-local
 4
 5# Container 1
 6krish-local:~ # podman exec my-httpd uname -n
 7614c1e03ee0b
 8
 9# Container 2
10krish-local:~ # podman exec my-limited-httpd uname -n
11a67892f30832

Each container sees a completely different hostname and the host is unaffected. Without Unix Time Sharing (UTS) namespace isolation, starting any container would overwrite krish-local with whatever hostname the container sets, and every process on the machine would see that change.

How to Find Namespaces on a Running System

Namespaces are not config files you can browse. They are kernel objects, and there are two practical ways to inspect them.

Via /proc/<pid>/ns/

Every process has a directory under /proc listing the namespaces it belongs to as symlinks. Each link points to the kernel namespace object via an inode number. I compared PID 1 on the host against the container process at PID 13684.

 1# Namespaces for host init process (PID 1)
 2krish-local:~ # ls -la /proc/1/ns/
 3total 0
 4lrwxrwxrwx 1 root root 0 Mar 31 01:47 cgroup -> 'cgroup:[4026531835]'
 5lrwxrwxrwx 1 root root 0 Mar 31 01:47 ipc    -> 'ipc:[4026531839]'
 6lrwxrwxrwx 1 root root 0 Mar 31 01:47 mnt    -> 'mnt:[4026531841]'
 7lrwxrwxrwx 1 root root 0 Mar 31 01:47 net    -> 'net:[4026531840]'
 8lrwxrwxrwx 1 root root 0 Mar 31 01:47 pid    -> 'pid:[4026531836]'
 9lrwxrwxrwx 1 root root 0 Mar 31 01:47 time   -> 'time:[4026531834]'
10lrwxrwxrwx 1 root root 0 Mar 31 01:47 user   -> 'user:[4026531837]'
11lrwxrwxrwx 1 root root 0 Mar 31 01:47 uts    -> 'uts:[4026531838]'
12
13# Namespaces for the container process (host PID 13684)
14krish-local:~ # ls -la /proc/13684/ns/
15total 0
16lrwxrwxrwx 1 default root 0 Mar 31 01:47 cgroup -> 'cgroup:[4026531835]'
17lrwxrwxrwx 1 default root 0 Mar 31 01:47 ipc    -> 'ipc:[4026532278]'
18lrwxrwxrwx 1 default root 0 Mar 31 01:47 mnt    -> 'mnt:[4026532276]'
19lrwxrwxrwx 1 default root 0 Mar 31 01:47 net    -> 'net:[4026532280]'
20lrwxrwxrwx 1 default root 0 Mar 31 01:47 pid    -> 'pid:[4026532277]'
21lrwxrwxrwx 1 default root 0 Mar 31 01:47 user   -> 'user:[4026531837]'
22lrwxrwxrwx 1 default root 0 Mar 31 01:47 uts    -> 'uts:[4026532279]'

Most inode numbers differ between the two processes, meaning they live in separate namespace instances. But cgroup and user share the same inode on both. That is expected for a rootless Podman setup on SLES 16 - those two namespaces are shared with the host by default.

These files are not readable and contain no data. They are handles the kernel uses for setns() (join an existing namespace) and unshare() (create a new one).

Via lsns

lsns gives a system-wide view of all active namespaces, which process owns each one, and how many processes share it.

 1krish-local:~ # lsns
 2        NS TYPE   NPROCS   PID USER    COMMAND
 34026531835 cgroup    142     1 root    /usr/lib/systemd/systemd
 44026531836 pid       138     1 root    /usr/lib/systemd/systemd
 54026531837 user      142     1 root    /usr/lib/systemd/systemd
 64026531838 uts       138     1 root    /usr/lib/systemd/systemd
 74026531839 ipc       138     1 root    /usr/lib/systemd/systemd
 84026531840 net       138     1 root    /usr/lib/systemd/systemd
 94026531841 mnt         1     1 root    /usr/lib/systemd/systemd
104026532276 mnt         8 13684 default httpd -D FOREGROUND
114026532277 pid         8 13684 default httpd -D FOREGROUND
124026532278 ipc         8 13684 default httpd -D FOREGROUND
134026532279 uts         8 13684 default httpd -D FOREGROUND
144026532280 net         8 13684 default httpd -D FOREGROUND

The container’s mnt, pid, ipc, uts, and net namespaces show up as separate entries with their own inodes. The NPROCS column shows 8 processes sharing each one, the httpd worker pool all living inside the same namespace instances.

Do Namespaces Exist Per Process, or Only When Explicitly Created?

Every process on Linux is always inside a namespace. There is no such thing as a process with no namespace membership. When the kernel boots, it creates the initial default namespaces and every subsequent process inherits its parent’s namespaces unless it explicitly asks for new ones via unshare() or clone().

I verified this by comparing two unrelated host processes:

1# PID 1 (systemd) and PID 2 (kthreadd) — do they share the PID namespace?
2krish-local:~ # readlink /proc/1/ns/pid
3pid:[4026531836]
4
5krish-local:~ # readlink /proc/2/ns/pid
6pid:[4026531836]

Same inode. Both processes are in the initial PID namespace, inherited at boot. Now compare against the container:

1krish-local:~ # readlink /proc/13684/ns/pid
2pid:[4026532277]

Different inode. When Podman started the container, it called `clone()` with `CLONE_NEWPID`, which created a fresh PID namespace. The container process lives in that new namespace. Everything else on the host does not.

Before Namespaces - Why chroot Was Not Enough

Before namespaces, chroot was the closest Linux had to process isolation. It changes the apparent root directory of a process so it cannot walk up the filesystem tree and access files outside its jail. Filesystem isolation only, and even that was leaky.

A process inside a chroot jail could still see every running process on the host, bind to any network port, and read /proc for sensitive information. Worse, a root process could escape the jail entirely using a two-step chdir and second chroot call. It was a convenience tool that got misused as a security boundary. Namespaces were built precisely because chroot was not enough.

The 8 Namespace Types

There are 8 namespace types in the Linux kernel, each isolating a specific category of system resource.

Namespace Kernel Flag What It Isolates
pid CLONE_NEWPID Process ID space. Processes start from PID 1 inside the namespace and cannot see PIDs outside it.
net CLONE_NEWNET Network stack - interfaces, routing tables, iptables rules, sockets. Each namespace gets its own lo.
mnt CLONE_NEWNS Filesystem mount points. Mount and unmount operations stay local to the namespace.
uts CLONE_NEWUTS Hostname and NIS domain name. UTS stands for UNIX Time-sharing System, a historical name.
ipc CLONE_NEWIPC System V IPC objects and POSIX message queues. Shared memory segments and semaphores are invisible across IPC namespaces.
user CLONE_NEWUSER UID and GID mappings. A process can be UID 0 inside a user namespace while remaining unprivileged on the host. This is the foundation of rootless containers.
cgroup CLONE_NEWCGROUP The visible root of the cgroup hierarchy. A process sees its own cgroup as / rather than its full path on the host.
time CLONE_NEWTIME Clock offsets for CLOCK_MONOTONIC and CLOCK_BOOTTIME. Added in kernel 5.6.

The failure modes map directly to the namespace that is missing or misconfigured.

  • Network traffic bleeding between containers? Check the net namespace flags.
  • A container that can see host processes? Missing CLONE_NEWPID.
  • A rootless container that cannot bind port 80? That is the user namespace UID mapping, not a firewall rule.

Why Every Container Gets Its Own Namespaces

A container without its own namespaces is just a process running under a different user. Nothing stops it from seeing every other process on the host, connecting to any network interface, reading any mounted filesystem, or changing the hostname.

The isolation contract containers offer, the reason you trust them to run third-party code or multi-tenant workloads, is entirely backed by namespace separation. I wrote a small loop to verify exactly which namespaces Podman isolates versus shares for this container:

 1krish-local:~ # for ns in cgroup ipc mnt net pid user uts; do \
 2>     host=$(readlink /proc/1/ns/$ns); \
 3>     container=$(readlink /proc/13684/ns/$ns); \
 4>     if [ "$host" = "$container" ]; then \
 5>         echo "$ns: SHARED      ($host)"; \
 6>     else \
 7>         echo "$ns: ISOLATED    host=$host  container=$container"; \
 8>     fi; \
 9> done
10cgroup: ISOLATED    host=cgroup:[4026531835]  container=cgroup:[4026532514]
11ipc: ISOLATED    host=ipc:[4026531839]  container=ipc:[4026532512]
12mnt: ISOLATED    host=mnt:[4026531841]  container=mnt:[4026532510]
13net: ISOLATED    host=net:[4026531840]  container=net:[4026532438]
14pid: ISOLATED    host=pid:[4026531836]  container=pid:[4026532513]
15user: SHARED      (user:[4026531837])
16uts: ISOLATED    host=uts:[4026531838]  container=uts:[4026532511]

cgroup and user are shared because this is a rootless Podman setup. In a rootful setup or inside a Kubernetes pod the picture changes. But each isolated entry is a hard boundary enforced by the kernel, not by Podman.

Namespaces are not a container feature. They are a Linux kernel feature that container runtimes use. At its core, podman run is a sequence of clone() syscalls with the right namespace flags, followed by execve() to start your workload.

Once you see it that way, debugging changes. When a container leaks network traffic, you look at the net namespace configuration. When PID visibility bleeds across containers, you check pid namespace flags. When a rootless container cannot bind to a privileged port, you trace it back to user namespace UID mapping.

The runtime configures. The kernel enforces. Namespaces are the mechanism sitting between them.


cgroups - resource accounting, limiting, and the hierarchy model

What is cgroup

cgroup provide a mechanism to allocate and control the amount of resources provided to a process. They are a core part of kernel and is found in all latest major linux distributions.

cgroup are managed via a virtual filesystem called cgroupfs which is mounted on /sys/fs/cgroup

cgroup majorly composed of 2 parts - core and controller

  • core is responsible for hierarchically organising the processes in tree structure
  • controller is responsible for distributing the resources

How is cgroup represented

Capturing the CGROUP_PATH using PID:

1krish-local:~ # PID=13684
2krish-local:~ # CGROUP_PATH=$(cat /proc/$PID/cgroup | cut -d: -f3)
3krish-local:~ # echo $CGROUP_PATH
4/machine.slice/libpod-a67892f3083285e34c738fd1e75cccd7eaadbda71f5a8c60a522e73546c0d5a2.scope

The string /machine.slice/libpod-a67892f3083285e34c738fd1e75cccd7eaadbda71f5a8c60a522e73546c0d5a2.scope is a hierarchical identifier used by systemd and the kernel to organize and track resource allocation.

Component Breakdown

Component Meaning
/machine.slice The Systemd Slice. Slices are units that manage resources for a group of processes. machine.slice is the default container for virtual machines and containers.
libpod- The Prefix. This indicates that the process is managed by Podman (Libpod).
a67892f3…d5a2 The Container ID. This is the full 64-character SHA-256 hash of the Podman container.
.scope The Systemd Unit Type. Unlike a .service, a .scope manages externally started processes. It ensures that if the container exits, all child processes are cleaned up.

Contents of cgroup

cgroup v2 filesystem

There are a dozen files in that directory. I will walk through three that directly map to the limits we set, because they show how the kernel translates a podman run flag into an enforced constraint.

memory.max — the hard memory ceiling


1krish-local:/sys/fs/cgroup/...scope # cat memory.max | numfmt --to=iec
2512M

This is the number the kernel checks on every memory allocation inside this cgroup. We passed --memory=512m to Podman. Podman wrote 536870912 (512 in bytes) to this file, and the kernel picked it up immediately.

The container does not know this limit exists — its allocator just starts getting OOM-killed if it crosses the line.

The numfmt --to=iec is just for readability. The raw value is bytes. The kernel does not deal in mebibytes.

cpu.max — the CPU bandwidth contract

1krish-local:/sys/fs/cgroup/...scope # cat cpu.max
2150000 100000

This one takes a moment to read. The format is [quota] [period], both in microseconds. What it says is:within every 100ms window, this cgroup is allowed to consume at most 150ms of CPU time across all cores.

On a multi-core machine, that is not a contradiction. If two cores are both running threads from thiscontainer simultaneously, they each consume from the same quota bucket. The math is 150000 / 100000 = 1.5,which is exactly the --cpus=1.5 we passed. The container is logically entitled to 1.5 vCPUs.

When the quota runs out mid-period, the CFS scheduler parks every thread in this cgroup until the next periodstarts. From inside the container, this looks like the CPU went silent. From outside, cpu.stat will show itas throttled time. That distinction matters when you are debugging latency spikes that do not correlate withload.

memory.events — your first stop for unexplained restarts

1krish-local:/sys/fs/cgroup/...scope # cat memory.events
2low 0
3high 0
4max 0
5oom 0
6oom_kill 0
7oom_group_kill 0

All zeros here means the container has not been under memory pressure. But this file is the reason I check cgroup state before reading application logs when a container restarts unexpectedly.

Each counter maps to a specific kernel event. low and high are early-warning thresholds, the kernel tries to reclaim memory before things get critical. max counts how many times an allocation was about to breach memory.max. oom_kill is the one that matters: it increments every time the kernel selected and killed a process inside this cgroup to recover memory.

If oom_kill is non-zero and your container keeps restarting, you have your answer. It was not a crash, not a bug in your application, not a transient deployment issue. The kernel ran out of room and killed something. The memory limit is too tight. No amount of reading application logs will tell you this as the kill happens at the kernel level, below your process, before it gets a chance to write anything. This is the first file to check.

How does cgroups enforce limits

The linux kernel enforces the cgroups limits. It is specifically done via Completely Fair Scheduler (CFS) for CPU and the Memory Controller (memcg) for RAM.

It is helpful to think of the files in /sys/fs/cgroup as Shared Memory between you and the Kernel. When you write to a file, you are updating a variable in the Kernel’s internal data structures.

The kernel does not snapshot these values at container start. The CFS scheduler reads cpu.max on every scheduling period. The memory controller checks memory.maxon every page fault. Writing a new value to memory.max takes effect immediately on the running container, no restart required. That is what makes live resource adjustment in Kubernetes possible.

How containers leverage cgroups

The container runtime serves as a configuration interface that translates human-readable resource requests into kernel-enforced constraints.

While the runtime performs the initial validation and writes the limits to the cgroup filesystem, it remains passive during execution. Real-time enforcement is handled exclusively by the Linux kernel via subsystems like the Completely Fair Scheduler (CFS) and the memory controller.

 1Process allocates memory
 2        │
 3        ▼
 4  mem_cgroup_charge()  ◄─── called on every page fault / alloc
 5        │
 6        ├── usage < memory.max?  ──► allow, increment counter
 7        │
 8        └── usage >= memory.max?
 9                │
10                ├── memory.swap.max available? ──► swap out pages
11                │
12                └── no swap headroom?  ──► trigger OOM killer
13                                             inside the cgroup

In this architecture, the runtime functions as a policy-maker that establishes boundaries, while the kernel operates as the persistent enforcer that monitors and restricts resource consumption at the hardware level.

The cgroup filesystem acts as the shared ledger of these restrictions, ensuring process isolation without requiring the runtime to remain active.

Failure mode: a container runtime crash like Docker daemon dying or Podman exiting does not release or relax the cgroup limits already written. The kernel continues enforcing them against every process in that cgroup until the cgroup itself is deleted. This is why a container can keep running and stay resource-limited even if the runtime that started it is no longer present.


Are cgroups and namespaces vital part of serverless computing?

cgroups and namespaces are foundational pillars of serverless computing.

Serverless platforms must run thousands of tenant workloads on shared physical hosts, with strict guarantees around:

  • Resource isolation (CPU, memory, I/O per function invocation)
  • Resource limits (prevent one noisy tenant from starving others)
  • Fast spin-up / teardown (cold start latency matters enormously)

cgroups are what enforce the resource side of this contract. And namespaces provide isolation of system resources for the process

 1┌──────────────────────────────────────┐
 2│         Serverless Function          │
 3├──────────────────────────────────────┤
 4│    Linux Namespaces (pid, net, mnt)  │  ← What it can *see*
 5│    cgroups v2                        │  ← What it can *consume*
 6│    seccomp / AppArmor / SELinux      │  ← What syscalls it can *make*
 7├──────────────────────────────────────┤
 8│    Container Runtime / MicroVM       │
 9│    (runc, gVisor, Firecracker)       │
10├──────────────────────────────────────┤
11│         Host Kernel                  │
12└──────────────────────────────────────┘

Serverless Requirement cgroup Subsystem What It Does
Memory limit per function memory Enforces max RSS, triggers OOM kill if breached
CPU throttling cpu / cpuquota Limits how many CPU cycles a function can consume per period
CPU fairness across tenants cpu.shares / cpu.weight Weighted scheduling between competing cgroups
I/O bandwidth control blkio / io (v2) Limits disk reads/writes per function sandbox
Process count limits pids Prevents fork bombs inside a container
Network accounting net_cls, net_prio Tags traffic for per-tenant accounting

cgroups also play a subtle role in cold start performance. When a new sandbox is created:

  1. A new cgroup hierarchy is created (mkdir /sys/fs/cgroup/...)
  2. The process is forked and its PID is written to cgroup.procs
  3. Resource limits are written to the subsystem files

Steps 1–3 are microsecond-range operations in the kernel that are intentionally designed to be lightweight so platforms can spin up sandboxes at high frequency.

This is why AWS Lambda chose Firecracker over full VMs Firecracker boots a microVM in ~125ms partly because creating the cgroup hierarchy for the new sandbox is a microsecond-range kernel operation.

Google’s gVisor takes a different path: it usesSECCOMP_RET_TRAP to redirect syscalls to a user-space kernel called Sentry, but the Sentry process itself is still bound by a cgroup on the host. The abstraction layer changes; the cgroup dependency does not.


Seccomp and Linux Capabilities — the syscall filter layer that completes the isolation picture

Namespaces and cgroups leave one attack surface open. A process inside a fully isolated namespace with strict memory and CPU limits can still make arbitrary syscalls to the kernel - ptrace(), mount(), kexec_load(). None of that is restricted by what a process can see or how much it can consume. Seccomp closes that gap.

Seccomp (Secure Computing Mode) installs a BPF program into the kernel that intercepts every syscall before it executes. The filter has access to the syscall number and its register arguments. It returns a verdict - allow, block with errno, or kill the process immediately. There is no userspace involved. The interception happens at the kernel syscall entry point, before any kernel subsystem processes the request.

For a deeper reference on Linux Capabilities and the full privilege model, see the capabilities(7) man page.

How containers use seccomp

Container runtimes ship a default seccomp profile, a JSON allowlist of syscalls the container is permitted to make. When runc starts a container, it installs this profile via prctl(PR_SET_SECCOMP) before handing control to the container process. You can verify it is active on any running container:

1krish-local:~ # cat /proc/13684/status | grep Seccomp
2Seccomp:         2
3Seccomp_filters: 1

  • Seccomp: 2 is filter mode.
  • Seccomp_filters: 1 confirms one BPF profile is loaded.

The default profile blocks syscalls like kexec_load, mount, ptrace, and perf_event_open , things a legitimate application container has no business calling but which are useful primitives for host escape or side-channel attacks.

Failure mode

The exposure is silent. A container running with seccomp disabled starts and behaves identically to one with a profile loaded until something exploits the open syscall surface. There is nothing in container logs or runtime output that indicates the filter is missing.

1krish-local:~ # cat /proc/<pid>/status | grep Seccomp
2Seccomp:         0

Seccomp: 0 means disabled. The most common cause is --security-opt seccomp=unconfined set for debugging and never removed, or a custom base image that explicitly disables it. Either way it is a finding, not a configuration preference.


How isolation is actually achieved

Isolation levels

Docker or Podman didn’t build the cage. Linux did.

The runtime is a thin configuration layer. It translates --memory=512m and --cpus=1.5 into writes on a virtual filesystem, sets up namespace boundaries via clone(), installs a seccomp BPF filter, and steps aside. From that point, the kernel owns enforcement permanently, without the runtime present, without the container knowing.

Namespaces control what a process sees. cgroups control what it consumes. Seccomp controls what it can ask the kernel to do. Three orthogonal subsystems, each independently enforceable, none sufficient alone.

When isolation breaks in production, the failure is almost never in the runtime. It is in one of these three layers - a missing namespace flag, a memory limit that was never set, a seccomp profile replaced with unconfined for a debug session that never got reverted. The kernel enforces exactly what you configure. Not more.

That is the contract. It is precise, unambiguous, and entirely your responsibility to get right.


References