容器技术由 Linux 下三项技术构成。这三项技术分别是 Namespace、Cgroups 和 Unionfs。他们分别实现了系统逻辑资源的隔离物理资源的限制以及容器的文件系统

在这之中最为关键的是 Namespace。因为 Namespace 实现了虚拟化中最重要的隔离的功能。在 Namespace 之外即使不使用 Cgroups,用其他文件系统替代 Unionfs,依然能够实现一个容器的大部分功能。

所以本文我们尝试用 Namespace 构建一个简单的容器。让我们首先想想,一个容器中的环境究竟需要与 host 隔离哪些资源。(请把容器想象成 host 之外的另一台机器。)

  • 文件系统:容器中的进程不能访问 host 的文件系统。这意味着挂载点的隔离 – Mount Namespace
  • 进程空间:容器中的进程无法查看容器外的进程信息。这意味着进程号的隔离 – Pid Namespace
  • 网络接口:容器中的进程拥有自己的网络接口,不使用 host 上的网络接口。这意味着网络的隔离 – Network Namespace
  • 用户:容器中的用户和容器外的用户无关,例如容器内的 root 和容器外的 root 并不相同。这意味着用户的隔离 – User Namespace
  • 物理资源:容器能够看到和管理的物理资源和容器外的资源不同。这意味着 Cgroups 视图的隔离 – Cgroups Namespace
  • 时间:容器中的时间系统和容器外的时间不一定相同 – Time Namespace
  • 主机名:容器中的主机名和容器外的主机名不一定相同 – UTS Namespace
  • IPC:IPC,例如 Posix 消息队列,使用类似文件名的标识符,但是又并不真正存在于文件系统中。容器中的这些标识符和容器外的相同标识无关 – IPC Namespace

虽然种类很多,但是想要形成虚拟化的错觉只需要用到其中的一部分即可。为了构建我们的容器,我们选择只使用 Mount、Pid、User 和 UTS。

隔离文件系统

容器化最重要的是隔离文件系统。所谓的程序运行环境,本质上就是文件系统中的各类库和应用程序。同一主机上的不同发行版的容器都运行在相同的内核上,他们只是在库和应用程序上存在不同。

对于 shell 环境有一个很好的命令 unshare 来创建各种各样的 Namespace。我们的容器就完全建立在该命令的基础上。这里输入 --mount 启动一个新的 shell,该 shell 位于一个新的 Mount Namespace 中。进入新的 Namespace 后,我们会发现原本的命令依然能够使用。因为新的 Namespace 继承了之前的 Namespace 中的各挂载点。整个系统的文件系统在当前进程看来依然保持不变。

$ df /
文件系统          1K的块      已用    可用 已用% 挂载点
/dev/nvme0n1p6 205307624 191282292 3523192   99% /
$ sudo unshare --mount
$ df /
文件系统          1K的块      已用    可用 已用% 挂载点
/dev/nvme0n1p6 205307624 191282296 3523188   99% /

接下来我们要替换掉当前的文件系统。这里我们用 Alpine 发行版的文件系统。我们首先下载并解压 Alpine。下面的链接可以在 Alpine 官网找到。

$ sudo unshare --mount
$ wget https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/x86_64/alpine-minirootfs-3.22.2-x86_64.tar.gz
$ mkdir alpine_root
$ tar -xzvf alpine-minirootfs-3.22.2-x86_64.tar.gz -C ./alpine_root/

之后,我们希望使用 Alpine 的 root 替换掉当前的 root 路径。即将根路径挂载到 ./alpine_root/ 的位置。

首先,我们需要创建一个挂载点

$ mount --bind ./alpine_root ./alpine_root
$ df ./alpine_root
文件系统          1K的块      已用    可用 已用% 挂载点
/dev/nvme0n1p5 308520768 285484536 7291260   98% /home/wokron/path/to/alpine_root

之后,我们使用 pivot_root 命令将 ./alpine_root/ 挂载到根路径,原本的根移动到 ./alpine_root/old_root 处。随后切换到根路径

$ mkdir ./alpine_root/old_root
$ pivot_root ./alpine_root ./alpine_root/old_root
$ cd /

这时我们列出根路径下的文件,会发现 /old_root 就在其中。

$ ls
bin       etc       lib       mnt       opt       root      sbin      sys       usr
dev       home      media     old_root  proc      run       srv       tmp       var

接下来我们再用 df 列出挂载点。/old_root 挂载的就是原本的根路径。

df 需要读取 /proc,因此需要先挂载 /proc 文件系统。

$ mount -t proc proc /proc
$ df
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% /

最后我们再取消 /old_root 的挂载。这样容器进程就隔离了 host 的文件系统。

$ umount -l /old_root
$ df
Filesystem           1K-blocks      Used Available Use% Mounted on
/dev/nvme0n1p5       308520768 285494116   7281680  98% /

……真的是这样吗?尝试列出这个路径下的文件:/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

然后我们就会发现在容器里也可以访问到 host 的文件系统。

当然这个问题很好解决。出现这个问题的原因是我们能够挂载 /proc 文件系统,并访问位于容器外的进程。只需要解决这两个问题之一或全部,就可以避免对 host 文件系统的访问。User Namespace 可用于解决前者,Pid Namespace 则可解决后者。

/proc 下的一切都很奇怪,/proc/pid/root 同样如此。如果你查看该文件的类型,你会发现它是一个符号链接

$ sudo file /proc/1/root
/proc/1/root: symbolic link to /

可即使当前 Namespace 下没有挂载进程所在的文件系统,这个所谓的符号链接依然能够让你进入进程实际所在的文件系统。例如,在我们的小容器里运行一条命令,并在 host 中查看该进程的 /proc/pid/root。结果总是 /。也即是说,cd /proc/pid/rootcd $(readlink /proc/pid/root) 并不等价。

# on container
$ sleep 10000 &
[1] 173328

# on host
$ sudo readlink /proc/173328/root
/

隔离进程空间

和文件系统一样,进程空间也是一个树状的结构。我们可以选择将某个进程和其子进程放置于一个隔离的 Pid Namespace 中。在该 Namespace 中的进程将无法感知 Namespace 之外的进程。且其 pid 也会发生变化。

为了创建一个 Pid Namespace,需要使用 unshare 命令的 --pid 选项。但这依然不够,让我们试一下。

$ sudo unshare --pid
$ echo $$
123456
$ bash
$ echo $$
1
$ exit
$ bash
zsh: fork failed: 无法分配内存

可以看到,设置 --pid 之后,我们的进程 pid 还是处在原来的空间中。只有创建一个新的进程后才会真正进入到 Pid Namespace 中。但是退出该进程后,我们就无法再次创建子进程了。似乎 Pid Namespace 存在某种设计上的考量。

因此我们还需额外增加一个 --fork 选项。此时执行的进程将从一开始就处在 Pid Namespace 当中。

$ sudo unshare --pid --fork
$ echo $$
1

让我们再添加上 --mount 选项,重新创建一遍容器。

$ sudo unshare --mount --pid --fork
$ mount --bind ./alpine_root ./alpine_root
$ pivot_root ./alpine_root ./alpine_root/old_root
$ cd /
$ mount -t proc proc /proc
$ umount -l /old_root

省略了一些挂载点的创建。因为上一节中已经创建过了。

这一次再尝试访问 /proc/1/root/home/wokron/path/to/alpine_root/..

$ 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

很明显,在我们创建 Pid Namespace 之后,容器内的进程就无法访问容器外的进程的信息了。

$ ps aux
PID   USER     TIME  COMMAND
    1 root      0:00 -zsh
   32 root      0:00 ps aux

隔离用户

隔离了文件系统和进程空间后,一个进程便无法再访问 host 上的大多数资源。但许多时候这样的隔离并不是我们所需要的,我们依然需要让 host 和容器共享同一部分文件。这就引出了 docker 中 volume 的概念。既已了解了 Mount 和 Pid Namespace,那么 volume 的实现就不言自明了。下面我们将 host 上的 ./volume_dir 挂载到容器中的 /host_volume_dir。(即 docker 中的 -v ./volume_dir:/host_volume_dir

$ mkdir volume_dir # here!
$ sudo unshare --mount --pid --fork
$ HOST_VOLUME_DIR=$(realpath ./volume_dir)
$ mount --bind ./alpine_root ./alpine_root
$ pivot_root ./alpine_root ./alpine_root/old_root
$ cd /
$ mount -t proc proc /proc
$ mkdir /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

但是这存在一个问题。如果我们此时向 /host_volume_dir 中写入文件。文件的拥有者将会是 root。这一问题已经在上一篇文章(Linux 下的用户和容器中的用户)中进行讨论了。

$ echo 1234 > /host_volume_dir/a.txt
$ exit
$ ls -l ./volume_dir/a.txt
-rw-r--r-- 1 root root 5 10月 13 22:32 ./volume_dir/a.txt

解决这一问题的办法就是让容器中的 root 用户在容器外看来不是 root 用户。此功能需要通过 User Namespace 实现。

unshare 命令中增加 --user 选项可以创建一个新的 User Namespace。从下面的命令来看,创建 User Namespace 不需要 root 权限。不过结果可能和我们的预期稍有不符。我们的用户名变成了 nobody。使用 id 命令也可以发现 uid 变成了 id 最大值。

wokron@wokron-navi$ unshare --user
nobody@wokron-navi$ id
uid=65534(nobody) gid=65534(nogroup) 组=65534(nogroup)

这其实是因为我们并没有设置原来的用户到 Namespace 中用户的映射关系。因为不知道用户应该映射到哪个 uid,所以就将用户设置成了最大的 nobody 占位用户。nobody 用户和其他普通用户一样,没有任何权限。

我们可以在 Namespace 中创建一个文件,并在容器外查看该文件。这时我们会发现文件的拥有者就是创建 Namespace 时的用户。

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 10月 13 22:33 a.txt

如果我们在 User Namespace 中进行用户的映射,那么 Namespace 中的进程就不会有任何特权。举个例子,这次我们加上 --mount 选项,尝试 mount 命令将会报错。

wokron@wokron-navi$ unshare --user --mount
nobody@wokron$ mount --bind ./alpine_root/ ./alpine_root/
mount: /home/wokron/path/to/alpine_root: 必须以超级用户身份使用 mount.

我们需要 User Namespace 让容器外的普通用户成为容器内的超级用户。这样这个普通用户就能在容器内拥有像 root 一样操作其他 Namespace 的权限。这需要增加 --map-root-user 选项。现在执行 unshare 后,用户名变成了 root 而不是 nobody

wokron@wokron-navi$ unshare --user --map-root-user --mount
root@wokron-navi$ mount --bind ./alpine-root/ ./alpine-root/
root@wokron-navi$ echo $?
0

当然,也可以映射到其他非 root 用户。但是那样做有什么用处吗?

需要注意,如果 User Namespace 中的 root 用户依然无法操作同级 Namespace 之外的资源。例如,如果我们去掉上面命令中的 --mount。在尝试 mount 时同样会报错。

wokron@wokron-navi$ unshare --user --map-root-user
root@wokron-navi$ mount --bind ./alpine-root/ ./alpine-root/
mount: /home/wokron/Code/Experiment/namespace/test_total/alpine-root: 权限不足.

一个有趣的地方在于,如果我们在容器中创建用户,会发生什么?这个新的用户在 host 上又会是什么 uid?我们可以来试一下。

(稍等,马上填坑……)