Container technology is built on three Linux kernel features: Namespaces, Cgroups, and Unionfs. They provide logical resource isolation, physical resource limits, and container filesystems, respectively.
Among these, Namespaces are the most critical. They implement isolation — the most essential aspect of virtualization. Even without Cgroups and with an alternative to Unionfs, you can still achieve much of what a container does, as long as you have Namespaces.
So in this article, we’ll try to build a simple container using Namespaces. Let’s first think about what resources a containerized environment should isolate from the host. (Think of the container as a separate machine from the host.)
- Filesystem: Processes inside the container must not access the host’s filesystem. This means mount point isolation — Mount Namespace
- Process space: Processes inside the container cannot see processes outside. This means PID isolation — PID Namespace
- Network interfaces: Processes inside the container have their own network interfaces, separate from the host’s. This means network isolation — Network Namespace
- Users: Users inside the container are unrelated to users outside. For instance, root inside the container is not the same as root on the host. This means user isolation — User Namespace
- Physical resources: The physical resources visible and manageable inside the container differ from those outside. This means Cgroups view isolation — Cgroups Namespace
- Time: The time system inside the container may differ from the host’s — Time Namespace
- Hostname: The hostname inside the container may differ from the host’s — UTS Namespace
- IPC: IPC mechanisms (e.g., POSIX message queues) use filename-like identifiers that don’t actually exist in the filesystem. Identifiers inside the container are independent of those outside — IPC Namespace
There are many types, but you only need a few to create the illusion of virtualization. For our container, we’ll use only Mount, PID, and User namespaces.
Isolating the Filesystem
Filesystem isolation is the most important part of containerization. A runtime environment is, at its core, just the libraries and applications in a filesystem. Containers running different distributions on the same host all share the same kernel — they only differ in their libraries and applications.
The shell provides a handy command, unshare, for creating various namespaces. Our container will rely entirely on this command. Let’s use --mount to start a new shell inside a new Mount Namespace. After entering the new namespace, we’ll find that existing commands still work. This is because the new namespace inherits the mount points from the old one — the entire filesystem still looks the same to the current process.
$ df /
Filesystem 1K-blocks Used Available Use% Mounted on
/dev/nvme0n1p6 205307624 191282292 3523192 99% /
$ sudo unshare --mount bash
$ df /
Filesystem 1K-blocks Used Available Use% Mounted on
/dev/nvme0n1p6 205307624 191282296 3523188 99% /
Next, we need to replace the current filesystem. We’ll use the Alpine Linux filesystem. First, download and extract Alpine. You can find the download link on the Alpine website.
$ sudo unshare --mount bash
$ wget https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/x86_64/alpine-minirootfs-3.22.2-x86_64.tar.gz
$ mkdir -p alpine_root
$ tar -xzvf alpine-minirootfs-3.22.2-x86_64.tar.gz -C ./alpine_root/
Now we want to replace the current root with Alpine’s root — i.e., mount it at the root path.
First, create a bind mount:
$ mount --bind ./alpine_root ./alpine_root
$ df ./alpine_root
Filesystem 1K-blocks Used Available Use% Mounted on
/dev/nvme0n1p5 308520768 285484536 7291260 98% /home/wokron/path/to/alpine_root
Then use the pivot_root command to move ./alpine_root/ to the root path. The original root is moved to ./alpine_root/old_root. Then switch to the root directory:
$ mkdir -p ./alpine_root/old_root
$ pivot_root ./alpine_root ./alpine_root/old_root
$ cd /
$ PATH=/bin:/sbin:$PATH
Now listing the root directory, we can see /old_root inside it:
$ ls
bin etc lib mnt opt root sbin sys usr
dev home media old_root proc run srv tmp var
Next, let’s use df to list mount points. /old_root is where the original root is mounted.
dfneeds to read/proc, so we must mount the/procfilesystem first.
$ mount -t proc proc /proc
$ df
Filesystem 1K-blocks Used Available Use% Mounted on
/dev/nvme0n1p6 205307624 191293340 3512144 98% /old_root
udev 7.5G 0 7.5G 0% /old_root/dev
tmpfs 7.6G 270.8M 7.4G 3% /old_root/dev/shm
tmpfs 1.5G 2.0M 1.5G 0% /old_root/run
...
/dev/nvme0n1p5 308520768 285486492 7289304 98% /
Finally, unmount /old_root. Now the container process is isolated from the host filesystem.
$ umount -l /old_root
$ df
Filesystem 1K-blocks Used Available Use% Mounted on
/dev/nvme0n1p5 308520768 285494116 7281680 98% /
…Or is it? Try listing files at this path: /proc/1/root/home/wokron/path/to/alpine_root/..
$ ls /proc/1/root/home/wokron/path/to/alpine_root/..
alpine-minirootfs-3.22.2-x86_64.tar.gz alpine_root
As we can see, the host filesystem is still accessible from inside the container.
This issue is easy to fix. The reason is that we can still mount /proc and access processes outside the container. Solving either (or both) of these problems prevents access to the host filesystem. User Namespace addresses the former, while PID Namespace addresses the latter.
Everything under
/procis quirky, and/proc/pid/rootis no exception. If you check its file type, you’ll see it’s a symbolic link:$ sudo file /proc/1/root /proc/1/root: symbolic link to /Yet even if the filesystem the process resides on isn’t mounted in the current namespace, this so-called symlink still lets you enter the process’s actual filesystem. For example, run a command in our little container, then check
/proc/pid/rootfrom the host. The result is always/. In other words,cd /proc/pid/rootandcd $(readlink /proc/pid/root)are not equivalent.# on container $ sleep 10000 & [1] 173328 # on host $ sudo readlink /proc/173328/root /
Isolating the Process Space
Like the filesystem, the process space is also a tree structure. We can place a process and its children in an isolated PID Namespace. Processes inside this namespace cannot perceive processes outside it, and their PIDs will change.
To create a PID Namespace, use the --pid option of unshare. But the command will fail:
$ sudo unshare --pid bash
bash: fork: Cannot allocate memory
In fact, after setting --pid, our process’s PID still resides in the old namespace. Only after creating a new process do we truly enter the PID Namespace. However, once we exit that process, we can no longer create child processes. There seems to be some design consideration at play here.
Therefore, we also need the --fork option. The executed process will then be in the PID Namespace from the start.
$ sudo unshare --pid --fork bash
$ echo $$
1
Let’s add the --mount option and rebuild the container:
$ sudo unshare --mount --pid --fork bash
$ mount --bind ./alpine_root ./alpine_root
$ mkdir -p ./alpine_root/old_root
$ pivot_root ./alpine_root ./alpine_root/old_root
$ cd /
$ PATH=/bin:/sbin:$PATH
$ mount -t proc proc /proc
$ umount -l /old_root
Now try accessing /proc/1/root/home/wokron/path/to/alpine_root/.. again:
$ ls /proc/1/root/home/wokron/path/to/alpine_root/..
ls: /proc/1/root/home/wokron/path/to/alpine_root/..: No such file or directory
Clearly, after creating a PID Namespace, processes inside the container can no longer access information about processes outside.
$ ps aux
PID USER TIME COMMAND
1 root 0:00 bash
23 root 0:00 ps aux
Isolating Users
With filesystem and process space isolated, a process can no longer access most resources on the host. But often this isn’t the kind of isolation we want — we still need the host and container to share certain files. This leads to Docker’s volume concept. Now that we understand Mount and PID Namespaces, the implementation of volumes is self-explanatory. Let’s mount the host’s ./volume_dir to /host_volume_dir inside the container. (This is equivalent to Docker’s -v ./volume_dir:/host_volume_dir.)
$ mkdir -p volume_dir # here!
$ sudo unshare --mount --pid --fork bash
$ HOST_VOLUME_DIR=$(realpath ./volume_dir)
$ mount --bind ./alpine_root ./alpine_root
$ mkdir -p ./alpine_root/old_root
$ pivot_root ./alpine_root ./alpine_root/old_root
$ cd /
$ PATH=/bin:/sbin:$PATH
$ mount -t proc proc /proc
$ mkdir -p /host_volume_dir # here!
$ mount --bind ./old_root/${HOST_VOLUME_DIR} /host_volume_dir # here!
$ umount -l /old_root
$ df
Filesystem 1K-blocks Used Available Use% Mounted on
/dev/nvme0n1p5 308520768 285502488 7273308 98% /
/dev/nvme0n1p5 308520768 285502488 7273308 98% /host_volume_dir
But there’s a problem. If we write a file to /host_volume_dir, the file owner will be root. This issue was discussed in the previous article (Linux Users and Users in Containers).
$ echo 1234 > /host_volume_dir/a.txt
$ exit
$ ls -l ./volume_dir/a.txt
-rw-r--r-- 1 root root 5 Oct 13 22:32 ./volume_dir/a.txt
The solution is to make the container’s root user appear as a non-root user from outside the container. This requires a User Namespace.
Adding the --user option to unshare creates a new User Namespace. As shown below, creating a User Namespace doesn’t require root privileges. However, the result may differ slightly from expectations — the username becomes nobody. Running id also shows the uid has become the maximum ID value.
wokron@wokron-navi$ unshare --user bash
nobody@wokron-navi$ id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)
This happens because we haven’t set up a mapping from the original user to users in the namespace. Since the system doesn’t know which uid the user should map to, it assigns the nobody placeholder user (the maximum uid). The nobody user, like any regular user, has no privileges.
Let’s create a file inside the namespace and inspect it from outside:
nobody@wokron-navi$ echo 1234 > nobody.txt
nobody@wokron-navi$ exit
wokron@wokron-navi$ ls -l ./nobody.txt
-rw-rw-r-- 1 wokron wokron 5 Oct 13 22:33 a.txt
The file owner is the user who created the namespace.
Without proper user mapping in the User Namespace, processes inside have no privileges. For instance, adding --mount this time, the mount command will fail:
wokron@wokron-navi$ unshare --user --mount bash
nobody@wokron$ mount --bind ./alpine_root/ ./alpine_root/
mount: /home/wokron/path/to/alpine_root: must be superuser to use mount.
We need the User Namespace to make an ordinary user on the host become the superuser inside the container. This way, the ordinary user gains root-like privileges to operate other namespaces inside the container. This requires the --map-root-user option. Now after unshare, the username shows as root instead of nobody.
wokron@wokron-navi$ unshare --user --map-root-user --mount bash
root@wokron-navi$ mount --bind ./alpine_root/ ./alpine_root/
root@wokron-navi$ echo $?
0
You can also map to other non-root users, of course.
Note that the root user inside a User Namespace still cannot operate on resources outside the namespace at the same level. For example, if we remove --mount from the previous command and try mount, it will also fail:
wokron@wokron-navi$ unshare --user --map-root-user
root@wokron-navi$ mkdir ./some_dir
root@wokron-navi$ mount --bind ./some_dir/ ./some_dir/
mount: /home/wokron/path/to/some_dir: Permission denied.
Finally, let’s create a container that uses a User Namespace.
First, we need to change the ownership of the Alpine filesystem to the current ordinary user. Otherwise, the root user inside the User Namespace won’t be able to access the files. Let’s start fresh in a new directory.
$ mkdir -p alpine_root volume_dir
$ tar -xzvf alpine-minirootfs-3.22.2-x86_64.tar.gz -C ./alpine_root/
Then use unshare with --user --map-root-user. Done.
$ mkdir -p volume_dir
$ unshare --user --map-root-user --mount --pid --fork bash
$ HOST_VOLUME_DIR=$(realpath ./volume_dir)
$ mount --bind ./alpine_root ./alpine_root
$ mkdir -p ./alpine_root/old_root
$ pivot_root ./alpine_root ./alpine_root/old_root
$ cd /
$ PATH=/bin:/sbin:$PATH
$ mount -t proc proc /proc
$ mkdir -p /host_volume_dir
$ mount --bind ./old_root/${HOST_VOLUME_DIR} /host_volume_dir
$ umount -l /old_root
Finally, here’s an interesting question: what happens if we create a new user inside the container? What uid would this new user have on the host? Let’s find out.
unshare doesn’t support this well, so we’ll test with Docker instead. Docker does not use User Namespace by default — this is also the reason for the container user issue discussed in the previous article. To enable User Namespace, modify the configuration in /etc/docker/daemon.json by adding the following:
{
"userns-remap": "wokron:wokron"
}
You can set
userns-remaptodefault. Docker will then automatically create adockremapuser and group. To avoid creating a new user, we’ll just use the current user here.
Then restart the Docker daemon:
$ sudo systemctl restart docker
If you’ve previously created images or run containers, you’ll notice they’ve all disappeared after the restart. Don’t worry — they’ll come back when you disable User Namespace.
Let’s use the same Dockerfile from the previous article:
FROM ubuntu:jammy
RUN useradd -u 1000 -m wokron
Build the image and run the container. Inside the container, the current username is wokron, and the uid matches the host.
wokron@host$ mkdir -m 777 test_dir
wokron@host$ docker build -t test:v1 .
wokron@host$ docker run -it --rm --user wokron -v ./test_dir:/test_dir test:v1 bash
wokron@container$ id
uid=1000(wokron) gid=1000(wokron) groups=1000(wokron)
But if you inspect the mounted test_dir, its owner and group show as nobody and nogroup. That is, uid 1000 from outside the container doesn’t map to any user inside the container.
wokron@container$ ls -l | grep test_dir
drwxrwxrwx 3 nobody nogroup 4096 Oct 14 03:13 test_dir
Create a file in test_dir and inspect it from outside the container:
wokron@container$ echo 1234 > ./test_dir/a.txt
# another shell
wokron@host$ ls -l ./test_dir/a.txt
-rw-r--r-- 1 101000 101000 5 Oct 14 11:13 ./test_dir/a.txt
So a user with uid 1000 inside the container has an actual uid of 101000 outside. Where is this mapping defined?
Let’s run cat /proc/self/uid_map inside the container. We get a triple:
wokron@container$ cat /proc/self/uid_map
0 100000 65536
The uid_map file under /proc/pid represents the uid mapping for the process’s User Namespace. It means: uids in the range [0, 65536) inside the namespace are mapped to [100000, 100000+65536) outside. For example, root inside the container is actually 100000 outside; 1000 inside is 101000 outside. This also explains why test_dir shows as owned by nobody — uid 1000 from the outside has no mapping into the container.
The same applies to gids, via
/proc/self/gid_map.
This mapping is configured in /etc/subuid and /etc/subgid. Checking these files from the host:
wokron@host$ cat /etc/subuid /etc/subgid
wokron:100000:65536
wokron:100000:65536
This means the wokron user has reserved uids in [100000, 100000+65536). When creating a user inside a User Namespace, the user’s uid is mapped into this reserved range.
More complex mappings can also be created. For example, mapping
wokrontoroot, and then mapping the range from/etc/subuidto[1, 1+65536).The
--map-root-useroption inunshareeffectively creates a mapping fromwokrontoroot. You can verify this by checking/proc/self/uid_mapinside a container created withunshare:$ cat /proc/self/uid_map 0 1000 1