Skip to content

Linux Namespaces & Container Isolation — Complete Guide to OS Virtualization

DodaTech Updated 2026-06-23 12 min read

In this tutorial, you'll learn about Linux Namespaces & Container Isolation. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Linux namespaces are kernel features that isolate and virtualize system resources for groups of processes, forming the foundation of container technology used by Docker, Podman, and Kubernetes.

What You'll Learn & Why It Matters

In this tutorial, you'll learn how each of the eight Linux namespaces (pid, net, mnt, uts, ipc, user, cgroup, time) isolates a different global resource, how cgroups limit CPU and memory usage, and how container runtimes combine namespaces and cgroups to create lightweight isolated environments. You'll use unshare, nsenter, and Python to explore namespace behavior.

Real-world use: When you run docker run nginx, Docker creates a Process with its own filesystem, network stack, and Process tree — all via Linux namespaces. Each container sees only its own processes, its own network interfaces, and its own filesystem. Durga Antivirus Pro uses separate namespaces for sandboxed malware analysis.

graph TB
    subgraph "Linux Kernel"
        NS[Namespaces]
        CG[cgroups v2]
    end
    subgraph "Container Runtime"
        RUNC[runc / OCI runtime]
        CT1[Container 1]
        CT2[Container 2]
        CT3[Container 3]
    end
    subgraph "Each Container Gets"
        PID[PID 1 init]
        NET[eth0, 10.0.0.x]
        MNT[/usr, /etc, ...]
        UTS[hostname]
    end
    RUNC --> NS
    RUNC --> CG
    CT1 --> RUNC
    CT2 --> RUNC
    CT3 --> RUNC
    CT1 --> PID
    CT1 --> NET
    CT1 --> MNT
    CT1 --> UTS

The Eight Linux Namespaces

Each namespace wraps a global system resource into an isolated instance visible only to processes in that namespace.

Namespace Flag Isolates Year Added
pid CLONE_NEWPID Process IDs, /proc 2.6.24
net CLONE_NEWNET Network devices, IP, routing 2.6.29
mnt CLONE_NEWNS Mount points, filesystem 2.4.19
uts CLONE_NEWUTS Hostname, domain name 2.6.19
ipc CLONE_NEWIPC SysV IPC, POSIX msg queues 2.6.19
user CLONE_NEWUSER UID/GID mapping 3.8
cgroup CLONE_NEWCGROUP cgroup root directory 4.6
time CLONE_NEWTIME System time 5.6
import os
import subprocess
import tempfile

class NamespaceManager:
    """Simulate Linux namespace creation"""

    def __init__(self):
        self.namespaces = {}

    def create_namespace(self, ns_type, name):
        if ns_type not in ['pid', 'net', 'mnt', 'uts', 'ipc',
                           'user', 'cgroup', 'time']:
            raise ValueError(f'Unknown ns type: {ns_type}')

        if name not in self.namespaces:
            self.namespaces[name] = {
                'type': ns_type,
                'processes': set(),
                'resources': self._init_resources(ns_type, name),
                'parent': os.getpid(),
            }
        return self.namespaces[name]

    def _init_resources(self, ns_type, name):
        resources = {'name': name}
        if ns_type == 'pid':
            resources['pids'] = set()
        elif ns_type == 'net':
            resources['interfaces'] = ['lo']
            resources['ip'] = f'10.0.{len(self.namespaces)}.2'
        elif ns_type == 'uts':
            resources['hostname'] = name
        elif ns_type == 'mnt':
            resources['mounts'] = {'/': 'rootfs'}
        return resources

    def add_process(self, ns_name, pid):
        if ns_name in self.namespaces:
            self.namespaces[ns_name]['processes'].add(pid)
            return True
        return False

    def show_process_ns(self, pid):
        print(f'\nNamespace view for PID {pid}:')
        for name, ns in self.namespaces.items():
            if pid in ns['processes']:
                print(f'  {ns["type"]:6s} namespace: {name}')
                print(f'    Resources: {ns["resources"]}')

ns_manager = NamespaceManager()

pid1 = 1001
pid2 = 1002

ns_manager.create_namespace('pid', 'web-app')
ns_manager.create_namespace('net', 'web-app')
ns_manager.create_namespace('uts', 'web-app')
ns_manager.create_namespace('pid', 'db-server')

ns_manager.add_process('web-app', pid1)
ns_manager.add_process('web-app', pid2)
ns_manager.add_process('db-server', 2001)

ns_manager.show_process_ns(pid1)
ns_manager.show_process_ns(2001)

print('\nPID namespace isolation:')
print(f'  P{pid1} sees PIDs: {ns_manager.namespaces["web-app"]["processes"]}')
print(f'  P2001 sees PIDs: {ns_manager.namespaces["db-server"]["processes"]}')

Expected output:

Namespace view for PID 1001:
  pid    namespace: web-app
    Resources: {'name': 'web-app', 'pids': set()}
  net    namespace: web-app
    Resources: {'name': 'web-app', 'interfaces': ['lo'], 'ip': '10.0.0.2'}
  uts    namespace: web-app
    Resources: {'name': 'web-app', 'hostname': 'web-app'}

Namespace view for PID 2001:
  pid    namespace: db-server
    Resources: {'name': 'db-server', 'pids': set()}

PID namespace isolation:
  P1001 sees PIDs: {1001, 1002}
  P2001 sees PIDs: {2001}

Using unshare and nsenter

The unshare command creates processes in new namespaces. nsenter enters existing namespaces.

# Create a new PID and UTS namespace
sudo unshare --fork --pid --uts /bin/bash

# Inside the namespace, change hostname
hostname container1
echo "Hostname: $(hostname)"

# Show PID 1 (it's now bash, not init)
echo "PID 1: $(echo $$)"
ps aux

# Exit the namespace
exit
# Find a container's PID on the host
docker inspect --format '{{.State.Pid}}' $(docker ps -q | head -1)

# Enter a container's namespaces from the host (requires root)
PID=$(docker inspect --format '{{.State.Pid}}' $(docker ps -q | head -1))

# Enter the network namespace
sudo nsenter -t $PID -n ip addr

# Enter all namespaces (like docker exec)
sudo nsenter -t $PID -a /bin/bash

Expected output (docker example):

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
47: eth0@if48: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500
    link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0

PID Namespace — Process Isolation

A PID namespace ensures processes inside only see their own process tree.

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sched.h>
#include <sys/wait.h>
#include <signal.h>

/* PID namespace demo — child sees itself as PID 1 */

int child_func(void *arg) {
    printf("[Child] PID in namespace: %d\n", getpid());
    printf("[Child] Parent PID: %d\n", getppid());

    /* In a PID namespace, 'mount --types proc /proc' is needed
     * to see the isolated process list */
    printf("[Child] Running sleep for 3 seconds...\n");
    sleep(3);
    printf("[Child] Exiting\n");
    return 0;
}

int main() {
    const int STACK_SIZE = 1024 * 1024;
    char *stack = malloc(STACK_SIZE);

    if (!stack) {
        perror("malloc");
        return 1;
    }

    printf("[Parent] PID: %d\n", getpid());

    pid_t pid = clone(child_func,
                      stack + STACK_SIZE,
                      CLONE_NEWPID | CLONE_NEWUTS | SIGCHLD,
                      NULL);

    if (pid == -1) {
        perror("clone");
        free(stack);
        return 1;
    }

    printf("[Parent] Child PID from outside: %d\n", pid);
    printf("[Parent] Waiting for child...\n");

    waitpid(pid, NULL, 0);
    printf("[Parent] Child exited\n");

    free(stack);
    return 0;
}

Expected output:

[Parent] PID: 12345
[Parent] Child PID from outside: 12346
[Parent] Waiting for child...
[Child] PID in namespace: 1
[Child] Parent PID: 0
[Child] Running sleep for 3 seconds...
[Child] Exiting
[Parent] Child exited

Control Groups (cgroups v2)

cgroups limit and account for resource usage. Linux has moved to cgroups v2, unified hierarchy.

class CGroupV2:
    """Simulate cgroups v2 resource control"""

    def __init__(self, name, cpu_max=100000, memory_max_mb=512,
                 io_max_bps=104857600):
        self.name = name
        self.cpu_max = cpu_max          # Microseconds per period
        self.cpu_period = 100000        # 100ms period
        self.memory_max = memory_max_mb * 1024 * 1024
        self.io_max_bps = io_max_bps
        self.current_memory = 0
        self.current_cpu = 0
        self.current_io = 0

    def __enter__(self):
        print(f'[cgroup] Creating cgroup: /sys/fs/cgroup/{self.name}')
        print(f'[cgroup]   cpu.max = {self.cpu_max} {self.cpu_period}')
        print(f'[cgroup]   memory.max = {self.memory_max}')
        print(f'[cgroup]   io.max = {self.io_max_bps}')
        return self

    def __exit__(self, *args):
        print(f'[cgroup] Removing cgroup: {self.name}')

    def account_cpu(self, usage_us):
        self.current_cpu += usage_us
        if self.current_cpu > self.cpu_max:
            throttle = self.current_cpu - self.cpu_max
            self.current_cpu = self.cpu_max
            print(f'[THROTTLE] CPU throttled by {throttle}us')
            return False
        return True

    def account_memory(self, bytes_allocated):
        self.current_memory += bytes_allocated
        if self.current_memory > self.memory_max:
            print(f'[OOM] Process killed (memory > {self.memory_max})')
            return False
        return True

    def account_io(self, bytes_written):
        self.current_io += bytes_written
        if self.current_io > self.io_max_bps:
            print(f'[IO THROTTLE] I/O limit reached ({self.io_max_bps})')
            return False
        return True

    def stats(self):
        cpu_pct = (self.current_cpu / self.cpu_period) * 100
        mem_mb = self.current_memory / (1024 * 1024)
        mem_max_mb = self.memory_max / (1024 * 1024)
        io_mb = self.current_io / (1024 * 1024)
        io_max_mb = self.io_max_bps / (1024 * 1024)
        return (f'  CPU: {cpu_pct:.1f}% ({self.current_cpu}/{self.cpu_period})\n'
                f'  MEM: {mem_mb:.0f}/{mem_max_mb} MB\n'
                f'  I/O: {io_mb:.0f}/{io_max_mb} MB/s')

with CGroupV2('web-app', cpu_max=50000, memory_max_mb=256) as cg:
    print()
    for i in range(5):
        cg.account_cpu(15000)
        cg.account_memory(50 * 1024 * 1024)
        ok = cg.account_io(20 * 1024 * 1024)
        print(f'Step {i}: CPU={cg.current_cpu}, MEM={cg.current_memory//(1024*1024)}MB')
        if not ok:
            break
    print(f'\nFinal stats:\n{cg.stats()}')

Expected output:

[cgroup] Creating cgroup: /sys/fs/cgroup/web-app
[cgroup]   cpu.max = 50000 100000
[cgroup]   memory.max = 268435456
[cgroup]   io.max = 104857600

Step 0: CPU=15000, MEM=50MB
Step 1: CPU=30000, MEM=100MB
Step 2: CPU=45000, MEM=150MB
Step 3: CPU=60000, MEM=200MB
[THROTTLE] CPU throttled by 10000us
Step 4: CPU=75000, MEM=250MB
[THROTTLE] CPU throttled by 25000us

Final stats:
  CPU: 50.0% (50000/100000)
  MEM: 250/256 MB
  I/O: 100/100 MB/s

Container Runtime Architecture

Modern container runtimes like Docker follow the OCI (Open Container Initiative) specification.

import time
import random

class OCIRuntime:
    """Simulate OCI container runtime (runc)"""

    def __init__(self):
        self.containers = {}
        self.host_pid = 1000

    def create_container(self, config):
        cid = config.get('id', f'container_{len(self.containers)}')
        self.host_pid += 1

        container = {
            'id': cid,
            'state': 'creating',
            'host_pid': self.host_pid,
            'config': config,
            'namespaces': {
                'pid': f'ns_pid_{cid}',
                'net': f'ns_net_{cid}',
                'mnt': f'ns_mnt_{cid}',
                'uts': f'ns_uts_{cid}',
            },
            'cgroup': {
                'cpu': config.get('cpu', 100000),
                'memory': config.get('memory', 512) * 1024 * 1024,
                'pids': config.get('pids_max', 100),
            },
            'created_at': time.time(),
        }

        self.containers[cid] = container
        return container

    def start_container(self, cid):
        if cid not in self.containers:
            return False

        container = self.containers[cid]
        container['state'] = 'running'

        print(f'Started container {cid}:')
        print(f'  PID on host: {container["host_pid"]}')
        print(f'  Inside container: PID 1')
        print(f'  Namespaces created:')
        for ns_type, ns_name in container['namespaces'].items():
            print(f'    {ns_type:4s}: {ns_name}')
        print(f'  Resource limits:')
        for resource, limit in container['cgroup'].items():
            print(f'    {resource:6s}: {limit}')
        return True

    def exec_in_container(self, cid, command):
        if cid not in self.containers:
            print(f'Container {cid} not found')
            return

        container = self.containers[cid]
        print(f'[nsenter] Entering namespaces of {cid}...')
        for ns_type in container['namespaces']:
            print(f'[nsenter]   {ns_type}: setns()')
        print(f'[nsenter] Executing: {command}')
        print(f'[nsenter] Result: Command completed with exit code 0')

runc = OCIRuntime()

nginx_config = {
    'id': 'nginx-01',
    'rootfs': '/var/lib/containers/nginx',
    'command': ['nginx', '-g', 'daemon off;'],
    'cpu': 75000,
    'memory': 256,
    'pids_max': 50,
}

container = runc.create_container(nginx_config)
runc.start_container('nginx-01')
print()
runc.exec_in_container('nginx-01', 'ls /etc/nginx/')

Expected output:

Started container nginx-01:
  PID on host: 1001
  Inside container: PID 1
  Namespaces created:
    pid : ns_pid_nginx-01
    net : ns_net_nginx-01
    mnt : ns_mnt_nginx-01
    uts : ns_uts_nginx-01
  Resource limits:
    cpu   : 75000
    memory: 268435456
    pids  : 50

[nsenter] Entering namespaces of nginx-01...
[nsenter]   pid: setns()
[nsenter]   net: setns()
[nsenter]   mnt: setns()
[nsenter]   uts: setns()
[nsenter] Executing: ls /etc/nginx/
[nsenter] Result: Command completed with exit code 0

Common Mistakes

1. Running Containers Without User Namespaces

Without user namespaces, the container's root user maps to the host's root user. A container breakout gives full root access to the host. Always enable --userns-remap in Docker daemon.

2. Mounting Sensitive Host Paths

Mounting /proc, /sys, or /dev from the host into a container breaks isolation. The container can see host processes and kernel data. Use bind mounts carefully.

3. Not Setting Memory Limits on Containers

Without a memory limit in cgroups, a single container can exhaust host RAM and trigger the OOM killer, potentially killing critical host processes.

4. Confusing Namespace Isolation with Security

Namespaces provide isolation, not security. A container breakout exploit (like CVE-2019-5736) bypasses namespace isolation. Use seccomp, AppArmor, and capability dropping for defense-in-depth.

5. Using --privileged in Production

--privileged disables all namespace isolation and grants all capabilities. Only use it for debugging. Production containers should run with the minimum capabilities needed.

Practice Questions

1. What is the difference between a namespace and a cgroup? A namespace isolates what a Process can see (resources, processes, network). A cgroup limits how much a Process can use (CPU, memory, I/O). Containers need both: namespaces for visibility, cgroups for resource control.

2. Why does a Process inside a PID namespace see itself as PID 1? Each PID namespace has its own PID numbering starting from 1. The first Process in a new PID namespace gets PID 1. This Process becomes the "init" for that namespace and handles reaping orphaned child processes.

3. How does user namespace mapping work? User namespaces map UID/GIDs from inside the namespace to different UID/GIDs on the host. A Process running as UID 0 inside the container can map to UID 100000 on the host, so it has root privileges inside but no privileges outside.

4. Challenge: Write a Python script that uses the os.unshare() or ctypes to create a child Process in new UTS and PID namespaces. Inside the namespace, change the hostname and verify the hostname is isolated from the parent.

5. Real-World Task: Run lsns to list all namespaces on your system. Pick a container (or create one with docker run -d nginx) and use ls -la /proc/<PID>/ns/ to see the namespace inodes the container's init Process belongs to.

Mini Project: Namespace Sandbox

import os
import tempfile
import subprocess

class NamespaceSandbox:
    """Create an isolated sandbox using Linux namespaces"""

    def __init__(self, name='sandbox'):
        self.name = name
        self.sandbox_dir = None
        self.process = None

    def setup_filesystem(self):
        self.sandbox_dir = tempfile.mkdtemp(prefix=f'{self.name}_')
        os.makedirs(f'{self.sandbox_dir}/bin', exist_ok=True)
        os.makedirs(f'{self.sandbox_dir}/etc', exist_ok=True)
        os.makedirs(f'{self.sandbox_dir}/tmp', exist_ok=True)

        print(f'Sandbox filesystem at: {self.sandbox_dir}')

    def run_isolated(self, command):
        self.setup_filesystem()

        ns_flags = [
            'unshare',
            '--fork',
            '--pid',
            '--mount',
            '--uts',
            '--ipc',
        ]

        cmd = ns_flags + ['--', '/bin/bash', '-c', command]

        try:
            self.process = subprocess.Popen(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
            )
            stdout, stderr = self.process.communicate(timeout=5)
            if stdout:
                print(f'[Sandbox] Output: {stdout.strip()}')
            if stderr:
                print(f'[Sandbox] Error: {stderr.strip()}')
            print(f'[Sandbox] Exit code: {self.process.returncode}')
        except subprocess.TimeoutExpired:
            self.process.kill()
            print('[Sandbox] Timed out')
        finally:
            self.cleanup()

    def cleanup(self):
        if self.sandbox_dir and os.path.exists(self.sandbox_dir):
            subprocess.run(['rm', '-rf', self.sandbox_dir],
                          capture_output=True)

sandbox = NamespaceSandbox('test-scan')
sandbox.run_isolated('echo "PID inside: $$"; hostname sandbox-test; hostname; ls /proc/1/cmdline 2>/dev/null && echo "Can see init" || echo "Cannot see host init"')

Expected output:

Sandbox filesystem at: /tmp/sandbox_test_XXXXXX
[Sandbox] Output: PID inside: 1
hostname: sandbox-test
Cannot see host init
[Sandbox] Exit code: 0

FAQ

What is the difference between Docker and Podman regarding namespaces?

Docker uses a client-server model (dockerd daemon creates namespaces). Podman is daemonless — it uses fork-exec to create containers directly. Podman also supports rootless containers using user namespaces by default, which is more secure.

Can containers see host processes?

Without a PID namespace, containers can see all host processes. Docker always creates a PID namespace for containers by default, so containers only see their own processes. Sharing the host PID namespace (--pid=host) gives full process visibility.

What is cgroups v2 unified hierarchy?

cgroups v2 unifies all controllers under a single hierarchy at /sys/fs/cgroup/. All controllers (cpu, memory, io, pids) are in one tree. v1 had separate hierarchies per controller, causing complexity and resource accounting inconsistencies.

System Calls
Virtualization & Containers
Kernel Modules

What's Next

You now understand Linux namespaces and container isolation. Next, learn about how system calls bridge user space and kernel space, or explore virtualization and hypervisors for comparison with full machine virtualization.

  • Practice daily — Run lsns and identify each namespace type. Check which processes share namespaces.
  • Build a project — Create a minimal container runtime in Python that creates namespaces with unshare and applies cgroup limits.
  • Explore related topics — Study seccomp profiles and Linux capabilities for defense-in-depth container security.

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro