Containers are not a kernel feature on their own. Docker, Podman, LXC and Kubernetes combine two older building blocks: namespaces, which control what a process can see (its own process tree, hostname, network stack, mounts), and control groups (cgroups), which control how much it can use (CPU, memory, I/O, number of processes).

In this tutorial you will use both directly on Ubuntu 24.04, without any container runtime. You will start a shell with its own PID and hostname, build an isolated network namespace connected to the host with a virtual cable, and limit the memory and CPU of a process with cgroups v2, first through systemd and then through the raw filesystem interface. Understanding these pieces makes container problems such as out-of-memory kills or unreachable networks much easier to debug.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS. Debian 12 and Rocky Linux 9 also use cgroups v2 and behave the same way.
  • A non-root user with sudo privileges.
  • The stress-ng package for the resource limit examples, installed in Step 5.

Every change in this guide is temporary and disappears when the process ends or the server reboots.

Step 1 - Inspecting the namespaces of a process

Every process belongs to one namespace of each type. The kernel exposes them as links under /proc/<pid>/ns. Look at your current shell ($$ expands to its PID):

ls -l /proc/$$/ns
lrwxrwxrwx 1 sammy sammy 0 Sep 25 10:12 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 sammy sammy 0 Sep 25 10:12 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 sammy sammy 0 Sep 25 10:12 mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 sammy sammy 0 Sep 25 10:12 net -> 'net:[4026531840]'
lrwxrwxrwx 1 sammy sammy 0 Sep 25 10:12 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 sammy sammy 0 Sep 25 10:12 pid_for_children -> 'pid:[4026531836]'
lrwxrwxrwx 1 sammy sammy 0 Sep 25 10:12 time -> 'time:[4026531834]'
lrwxrwxrwx 1 sammy sammy 0 Sep 25 10:12 time_for_children -> 'time:[4026531834]'
lrwxrwxrwx 1 sammy sammy 0 Sep 25 10:12 user -> 'user:[4026531837]'
lrwxrwxrwx 1 sammy sammy 0 Sep 25 10:12 uts -> 'uts:[4026531838]'

The number in brackets identifies the namespace. Two processes with the same number share that namespace. The types you will use in this guide are:

NamespaceIsolates
pidProcess IDs: the first process inside sees itself as PID 1
mntThe list of mount points
utsHostname and domain name
netInterfaces, IP addresses, routes, firewall rules, sockets
userUser and group IDs, so root inside can be unprivileged outside
ipcSystem V IPC and POSIX message queues
cgroupThe view of the cgroup hierarchy

lsns lists every namespace on the system and the process that owns it:

sudo lsns --type net

On a fresh server you will mostly see the initial namespaces used by PID 1. If Docker or another runtime is running, each container shows up with its own entries.

Step 2 - Isolating processes with PID, mount and UTS namespaces

The unshare command from util-linux starts a program in new namespaces. Create a shell with its own PID, mount and UTS namespaces:

sudo unshare --pid --fork --mount-proc --uts bash

The options mean:

  • --pid creates a PID namespace. --fork is required so that bash runs as a child, which becomes PID 1 in the new namespace.
  • --mount-proc creates a mount namespace and mounts a fresh /proc in it, so tools like ps only see the processes of the new namespace.
  • --uts gives the shell its own hostname.

Inside the new shell, list the processes:

ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.1   9168  5504 pts/1    S    10:15   0:00 bash
root           8  0.0  0.1  11316  4480 pts/1    R+   10:15   0:00 ps aux

Only two processes are visible, and bash is PID 1. Change the hostname:

hostname container1
hostname
container1

Open a second SSH session to the server and run hostname there. It still shows the original name, because the change only happened inside the UTS namespace. From that second session you can also find the isolated shell: it is an ordinary process with a normal PID on the host.

ps -ef | grep 'unshare --pid'

Return to the first session and leave the namespace:

exit

When PID 1 of a PID namespace exits, the kernel kills every other process in it and the namespace disappears. This is the same reason a container stops when its main process ends.

User namespaces on Ubuntu 24.04

A user namespace maps user IDs, so a process can be root inside while being an ordinary user on the host. This is how rootless containers work. Ubuntu 24.04 restricts unprivileged user namespaces with AppArmor: only programs with an AppArmor profile that allows it can create them. Check the setting:

sysctl kernel.apparmor_restrict_unprivileged_userns
kernel.apparmor_restrict_unprivileged_userns = 1

Because of this, running unshare --user --map-root-user as a normal user fails with a permission error on a default installation. Rootless Podman and Docker ship the profiles they need, so you do not have to disable this protection to use them.

Step 3 - Building an isolated network namespace

A network namespace has its own interfaces, routing table and firewall rules. It starts with only a loopback interface that is down. The ip netns command from iproute2 manages named network namespaces. Create one:

sudo ip netns add demo
sudo ip netns exec demo ip -brief link
lo               DOWN           00:00:00:00:00:00 <LOOPBACK>

To connect the namespace with the host, create a veth pair. A veth pair behaves like a network cable with two ends: packets sent into one end come out of the other. Create it and move one end into the namespace:

sudo ip link add veth-host type veth peer name veth-demo
sudo ip link set veth-demo netns demo

Assign an address to each end and bring the interfaces up. The examples use the private subnet 10.200.0.0/24; choose another one if it overlaps with a network you already use:

sudo ip address add 10.200.0.1/24 dev veth-host
sudo ip link set veth-host up
sudo ip netns exec demo ip address add 10.200.0.2/24 dev veth-demo
sudo ip netns exec demo ip link set veth-demo up
sudo ip netns exec demo ip link set lo up

Test the connection from inside the namespace:

sudo ip netns exec demo ping -c 3 10.200.0.1
64 bytes from 10.200.0.1: icmp_seq=1 ttl=64 time=0.051 ms
64 bytes from 10.200.0.1: icmp_seq=2 ttl=64 time=0.043 ms
64 bytes from 10.200.0.1: icmp_seq=3 ttl=64 time=0.047 ms

The namespace can reach the host but not the internet, because it has no default route and the host does not forward its traffic. Compare the routing tables:

sudo ip netns exec demo ip route
10.200.0.0/24 dev veth-demo proto kernel scope link src 10.200.0.2

Container runtimes add three more things to give containers internet access: a default route pointing to the host end, IP forwarding on the host, and a NAT rule that masquerades the container subnet. Docker's docker0 bridge is this same design with a bridge between the host and many veth pairs.

Remove the namespace when you are done. Deleting it destroys the interfaces inside it, and with them the whole veth pair:

sudo ip netns delete demo
ip link show veth-host
Device "veth-host" does not exist.

Step 4 - Understanding the cgroups v2 hierarchy

cgroups organize processes in a tree and apply resource limits to each branch. Ubuntu 24.04 uses cgroups v2 only, mounted at /sys/fs/cgroup. Confirm it:

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

The controllers available in the root of the tree are listed in cgroup.controllers:

cat /sys/fs/cgroup/cgroup.controllers
cpuset cpu io memory hugetlb pids rdma misc

systemd owns this tree. Every service gets its own cgroup under system.slice, and every login session one under user.slice. See which cgroup your shell belongs to:

cat /proc/$$/cgroup
0::/user.slice/user-1000.slice/session-3.scope

systemd-cgls shows the whole tree with its processes, and systemd-cgtop shows live resource usage per cgroup, similar to top:

systemd-cgtop

Press q to exit.

Step 5 - Limiting memory and CPU with systemd-run

Because systemd manages the hierarchy, the recommended way to put a process in a limited cgroup is to let systemd create it. Install stress-ng, a tool that generates CPU and memory load on demand:

sudo apt update
sudo apt install stress-ng

Start a transient service with a memory limit of 100 MB, swap disabled for it, and a CPU limit of 20 % of one core. The workload uses one CPU worker and one memory worker that tries to allocate 200 MB:

sudo systemd-run --unit=limit-demo \
  --property=MemoryMax=100M \
  --property=MemorySwapMax=0 \
  --property=CPUQuota=20% \
  stress-ng --cpu 1 --vm 1 --vm-bytes 200M --timeout 60s
Running as unit: limit-demo.service; invocation ID: 6f1c...

Check the CPU usage while it runs:

top -b -n 1 | grep stress-ng

The stress-ng processes together stay around 20 % CPU, even though the CPU worker would normally use a full core. Now look at the cgroup that systemd created for the unit. The limits are plain files:

cat /sys/fs/cgroup/system.slice/limit-demo.service/memory.max
cat /sys/fs/cgroup/system.slice/limit-demo.service/cpu.max
104857600
20000 100000

cpu.max means the cgroup may use 20,000 microseconds of CPU time in every 100,000 microsecond period, which is 20 % of one CPU. The memory worker tries to use twice the limit, so the kernel's OOM killer terminates it inside the cgroup, and stress-ng restarts it. The counters record every kill:

cat /sys/fs/cgroup/system.slice/limit-demo.service/memory.events
low 0
high 0
max 1845
oom 12
oom_kill 12
oom_group_kill 0

oom_kill greater than zero is exactly what you see when a container "gets OOM killed": the rest of the server is unaffected. The unit finishes after 60 seconds; to stop it earlier, run:

sudo systemctl stop limit-demo

The same properties can be set permanently on any real service with sudo systemctl edit service_name, adding them under a [Service] section, or at runtime with sudo systemctl set-property service_name MemoryMax=500M.

Step 6 - Working with the cgroup filesystem directly

Container runtimes write to the cgroup files themselves, and knowing how helps when you read their configuration. Create a cgroup for testing by making a directory. The kernel fills it with control files automatically:

sudo mkdir /sys/fs/cgroup/demo
ls /sys/fs/cgroup/demo | head -n 5
cgroup.controllers
cgroup.events
cgroup.freeze
cgroup.kill
cgroup.max.depth

Set a limit of 50 MB of memory and 10 % of one CPU:

echo 50M | sudo tee /sys/fs/cgroup/demo/memory.max
echo "10000 100000" | sudo tee /sys/fs/cgroup/demo/cpu.max

A process joins a cgroup when its PID is written to cgroup.procs, and every child it starts afterwards inherits that cgroup. The following command starts a shell in the background, moves the shell into demo ($$ is its own PID) and then replaces it with stress-ng, so the workload and all its workers start inside the cgroup:

sudo sh -c 'echo $$ > /sys/fs/cgroup/demo/cgroup.procs && exec stress-ng --cpu 1 --timeout 60s' &

Watch it with top: the stress-ng worker stays at about 10 % CPU instead of a full core. Moving a process that has already started children would not move those children, which is why runtimes place the process in the cgroup before it runs the workload. Confirm which processes are in the group:

cat /sys/fs/cgroup/demo/cgroup.procs

A cgroup can only be removed when it is empty. Kill all of its processes by writing 1 to cgroup.kill, then remove the directory:

echo 1 | sudo tee /sys/fs/cgroup/demo/cgroup.kill
sudo rmdir /sys/fs/cgroup/demo

Troubleshooting

unshare: unshare failed: Operation not permitted. Creating most namespaces requires root. Run the command with sudo, or see the user namespace note in Step 2.

Cannot open network namespace "demo": No such file or directory. The namespace was not created or was already deleted. List existing ones with ip netns list.

tee: /sys/fs/cgroup/demo/cpu.max: No such file or directory. The cpu controller is not enabled for children of the root cgroup. Check cat /sys/fs/cgroup/cgroup.subtree_control; on a standard Ubuntu 24.04 system it includes cpu and memory.

rmdir: failed to remove '/sys/fs/cgroup/demo': Device or resource busy. The cgroup still has processes. Kill them with cgroup.kill as shown above and try again.

Conclusion

You have isolated a shell in its own PID, mount and UTS namespaces, wired a network namespace to the host with a veth pair, and limited CPU and memory with cgroups v2, both through systemd and through the cgroup filesystem. That combination is what every container runtime does when it starts a container, plus an image mounted as the root filesystem.

As next steps, run docker inspect or podman inspect on a container and match its PID with lsns and /proc/<pid>/cgroup, add resource limits to your own systemd services, and read the memory.events file of a container the next time one is restarted unexpectedly.