当我们要从一个新镜像运行一个容器的时候,Podman 会从镜像 registry 处拉取镜像文件,并运行这个镜像中的程序。但 Podman 到底是如何 rootless 容器的呢?让我们在下面一探究竟。
当一个非 root 用户登录之后,该用户所有的进程都具有同一个 UID(比如说 1000)。但镜像文件则不同,它内部的文件会具有多个 UID。在下面的案例中,我们会枚举整个容器镜像中所有的文件的拥有者的 UID:
# 以我们之前构建的 my_custom_nginx 为例
podman run --rm --user=root localhost/my_custom_nginx bash -c "find / -xdev -printf \"%U=%u\n\" | sort -un 2>/dev/null"-
--user=root保证后面的命令通过 root 执行,以保证读取容器中尽可能多的文件。 -
bash -c用 bash 运行一个命令 -
find / -xdev -printf \"%U=%u\n\"以根目录为基点,递归查找根目录所在的文件系统中的所有文件,并打印文件所有者的 UID 和用户名称。 -
sort -un将输出的列表排序,并去重 -
2>/dev/null移除报错
可以看到,容器内的所有文件具有三个 UID:
0=root
999=nginx
65534=nobody不过这里有一个问题,我们的 Podman 是运行在 rootless 模式的。也就是说,这个容器的进程实际上是运行在我们宿主主机的当前用户下的,而 Linux 内核不允许非 root 用户以其它 UID 运行程序,这会导致一个问题:比如,宿主机上的当前用户可以映射为容器中的 root 用户,但 nginx 运行时会从 root 启动,然后切换到 nginx 用户;但由于宿主机对于非 root 用户的限制,导致容器中的 nginx fork 出来的 worker 是无法切换到 nginx 用户的(Linux 内核不允许非 root 用户切换第二个 UID)。Podman 使用用户命名空间(user namespaces)解决这个问题。
在解释用户命名空间之前,我们需要先初步了解什么是命名空间。
简单来说,命名空间是 Linux 内核提供的、一种面向进程的、系统资源隔离措施。命名空间类似虚拟内存,都是让进程认为自己占有了某组完整的系统资源,但实际上这组资源是宿主机资源的一个子集。同时,属于不同命名空间的进程之间不会相互影响。所以命名空间还有隔离进程的作用。
虽然命名空间常用于容器技术中,但这并不代表命名空间仅出现在容器技术中。实际上,只要内核开启了命名空间,那么任何一个非内核进程,都会处于某一套命名空间下。比如,我们查看 PID 1 所在的命名空间:
# 下面的操作需要 root 权限
ls -l /proc/1/ns可以看到,即便是 PID 为 1 的进程,也处于一套命名空间下。而它创建的新进程,要么继承这个命名空间,要么创建新的命名空间。
|
Tip
|
如果你仔细观察返回值,会发现这些文件都是符号链接文件,但是它们指向的内容却非同寻常,看起来并不像是文件系统上的文件。 当然,这个软链接的内容并不能简单地被 |
|
Note
|
就当前内核(Linux Kernel 6.13.8)中命名空间来说,只有用户命名空间和 PID 命名空间是有较为明确的继承关系的,其它同类型的命名空间内部没有明确的继承关系(比如 cgroup 与 cgroup,mnt 与 mnt 之间是没有明确的继承关系)。但是其它类型的命名空间依旧会继承在用户命名空间下的。 |
要想知道当前系统上存在的所有命名空间,以及它们的继承关系,可以使用 lsns 命令:
# 注意,要使用 root 权限才能查看整个系统上的所有命名空间,若使用普通用户运行,就只能查看自己创建的进程的命名空间。
# 使用 --tree 以树状结构列举继承关系。
lsns --tree从返回值我们可以知晓,系统启动时,PID 1 的 systemd 创建了一个用户命名空间。之后,在这个用户命名空间下,又创建了完整的一套命名空间。而其它程序也可能创建新的命名空间,这些命名空间则会基于父进程所在的命名空间进行创建。
比如,现在我们启动一个 Podman 容器,并再次运行 lsns --tree,可以发现:Podman 启动的 catatonit -P 会从从 systemd 的用户命名空间下,再创建一个用户命名空间,并在这个用户命名空间里创建额外的命名空间,以运行容器中的程序。
|
Note
|
不同用户使用 lsns 返回的同一个命名空间对应的信息(子进程数、进程号、用户、命令行)可能不同,这是由于 lsns 是通过遍历 /proc 完成命名空间信息的收集,相同命名空间 ID 取最小 PID 号,并展示该 PID 号的信息来完成的。但由于每个用户的权限不同,能读取的 /proc 的内容是不同的,会导致 lsns 获得的信息不同,因而返回的内容也随着用户的不同而不同。 |
|
Tip
|
如果我们仅关注 Podman 容器相关的 namespace,可以使用 |
关于命名空间的帮助,见 man 7 namespaces。
Linux 支持一种称为用户命名空间的概念,它可以将宿主系统上的 UID/GID 映射到用户命名空间的(不同的)UID/GID 上。
“用户命名空间”是命名空间的一种,它的作用是隔离 UID/GID。举例来说,我们可以用常规用户开启一个命名空间,在这个命名空间中,进程可以 UID 0,也就是 root 用户运行程序,这个进程可以“全权掌握”该命名空间中的资源,但是在命名空间外部看来,它只不过是一个具普通用户运行的进程而已。
在我们在宿主机上创建一个用户的时候,系统不仅修改了 /etc/passwd 和 /etc/group 来记录我们的用户信息和群组,还修改了 /etc/subuid 和 /etc/subgid,为我们分配了在用户命名空间中可以使用的 UID 和 GID 的范围。
比如我的 /etc/subuid 和 /etc/subgid 的内容如下:
<用户1>:524288:65536
<用户2>:589824:65536<用户1>:524288:65536
<用户2>:589824:65536它们的内容相似,都是 <用户名>:<起始编号>:<分配长度> 的格式。
|
Tip
|
关于上面的数字: 第一个用户的起始序号:524288 = 2^16 * 8 第二个用户的起始序号:589824 = 2^16 * 9 可分配ID长度:65536 = 2^16 所以 subuid 和 subgid 是随着新增用户连续分配的。 |
当一个父进程认为自己将要创建的子进程需要一个新的用户命名空间,就会在创建子进程的时候告知内核这一信息。接着,在创建好子进程后,需要写入 /proc/<子进程 PID>/uid_map 和 /proc/<子进程 PID>/gid_map 两个文件,完成用户命名空间的设置,子进程才会在新的用户命名空间中运行。
我们首先查看一下 PID 1 的 uid_map(gid_map 类似,不再表述):
cat /proc/1/uid_map返回值
0 0 4294967295它表示,将用户命名空间内部 UID 0 映射为外部 UID 0,且映射的总长度为 4294967295(即 2^32 - 1)。也就是说,PID 1 创建的用户命名空间,没有实际修改 UID/GID 映射,而且映射的总长度为 2^32 - 1(也就是可用 UID 为 0 到 2^32 - 2,2^32 - 1 被保留,因为它表示“无 UID”)。
而我们再看一下前面我们启动的容器的 uid_map
# 查询容器初始进程(init process,也就是通常意义上的容器的主进程)的 PID 号
podman ps --ns
# 用 podman container inspect --format '{{.State.Pid}}' <容器名> 也可以获取容器的 PID
# 获取容器初始进程的 uid_map
cat /proc/<容器初始进程的 PID>/uid_map 0 1001 1
1 589824 65536它有两行,
-
第一行表示,内部 UID 0 映射为外部 UID 1001,映射总长度为 1。(UID 1001 在宿主机上是一个无特权账户)
-
第二行表示,内部 UID 1 映射为外部 UID 589824,这段映射的总长度为 65536。(注意这段的内容,和 /etc/subuid 中的配置保持一致)
|
Note
|
|
如果你在容器中查看根目录的信息,你会发现 /proc 和 /sys 的所有者为 nobody,对应的 UID 为 65534。它表示的是,这个文件夹的实际的 UID 没有被 uid_map 映射到容器所在的用户命名空间,于是内核将这个 UID 赋予 overflow uid 65534,ls 将其显示为 nobody。
|
Tip
|
关于 ID Mapping 和 overflowuid/overflowgid 的文章,见 https://www.kernel.org/doc/html/latest/filesystems/idmappings.html |
挂载命名空间可以让你在该命名空间中挂载文件系统,但这些挂载点不会被该命名空间之外的进程看到。比如说,在一个挂载命名空间中,你可以在 /tmp 挂载一个 tmpfs,这样就可以阻止该命名空间中的进程读写 /tmp 中原有的内容;但是在该挂载命名空间之外的进程则依旧可以读写原始 /tmp 中的内容,而不受到挂载命名空间的影响。
比如,我们对比一下 podman unshare 所在的命名空间和外部命名空间的差异:
diff --color=always <(podman unshare sh -c "ls -l /proc/self/ns | awk '/^l/ {print \$(NF-2), \$(NF-1), \$NF}'") <(ls -l /proc/self/ns | awk '/^l/ {print $(NF-2), $(NF-1), $NF}')可以发现 podman unshare 创建了用户命名空间和挂载命名空间。
然后我们比较一下 podman unshare 和外部挂载的内容的不同:
diff --color=always <(mount | sort) <(podman unshare sh -c 'mount | sort')然后我们可以在 podman unshare 自行挂载一个文件:
echo "hello" > /tmp/mytestfile
podman unshare sh -c 'mount -o bind /tmp/mytestfile /etc/shadow; cat /etc/shadow'
cat /etc/shadow可以发现,unshare 创建的命名空间内部的 /etc/shadow 的内容的确被 mount 成为了 /tmp/mytestfile 的内容。而命名空间外的 /etc/shadow 并未受到影响。
从上一个案例中可以看到,若你可以 bind mount /etc/shadow 文件,那么你就可以迷惑具有 setuid 的软件(比如 /bin/su 和 /bin/sudo),让你可以获得 root 权限。这也是为什么非 root 用户不可以执行挂载的原因。
在 podman unshare 的过程中,即便我们可以修改 /etc/shadow 从而迷惑具有 setuid 的软件,我们也无法逃脱用户命名空间,而影响宿主系统。
不过,即便使用了命名空间,Linux 内核也不会让非 root 用户随意挂载任何文件系统,因为部分文件系统可以修改 Linux 内核的关键部分。
在系统启动后,首次(成功)运行 Podman 程序(无需启动一个容器)的时候,Podman 会读取 /etc/subuid 和 /etc/subgid,并从中查询与当前用户相匹配的条目,从而获取当前用户可以使用的 subuid 和 subgid。之后就会创建一个 catatonit -P 进程,该进程会持有用户命名空间和一个挂载命名空间,其中 catatonit -P 进程的 /proc/self/uid_map 和 /proc/self/gid_map 则会包含当前用户的 UID/GID 以及 subuid/subgid。
即便系统上的所有容器都已经退出,Podman 依旧会保留 catatonit -P 进程,这个进程会继续持有用户命名空间和挂载命名空间,直到该进程退出(当前用户退出、强制退出该进程,或者执行了 podman system migrate)。catatonit -P 持续存在的意义,就是持有用户进程空间,让所有的容器都运行在同一个用户命名空间里。所有的容器都运行在同一个命名空间下,才能让共享内容和其它命名空间成为可能。
|
Note
|
若你在运行了 Podman 之后修改了 /etc/subuid 或 /etc/subgid,你会发现这个修改不是立刻生效的,因为 |
当我们使用 podman pull 拉取镜像文件的时候,Podman 会首先拉取远程服务器上的镜像文件列表,并与当前系统上已有的镜像进行对比,若存在则不实际执行拉取,否则执行拉取操作。
执行拉取操作的过程,大致有以下几步:
-
从 registry 下载镜像的 manifest
-
依照 manifest 下载镜像的 config 和 layer 文件
-
通过 OverlayFS 将解压的 layer 文件和 config 文件逐层挂载为最终的容器文件
这里需要注意的是,拉取操作是在 catatonit -P 创建的用户命名空间和挂载命名空间下执行的。也就是说,Podman 解包镜像 tar 文件的时候,解压出来的文件就已经在 Podman 的用户命名空间下了。在宿主机看来,这些文件具有的 UID/GID 实际上完全处于普通用户的 UID/GID/SUBUID/SUBGID 下了。
|
Note
|
如果你拉取/导入的镜像文件中的文件或文件夹,具有的 UID/GID 大于当前账户能使用的 SUBUID/SUBGID 长度(比如镜像中有一个 UID 1000 的普通账户,但 SUBUID 仅支持 1~100 的值)。那么,在解包这个镜像文件的时候,就会产生错误 potentially insufficient UIDs or GIDs available in user namespace 从而禁止导入。 你可以尝试在默认的 SUBUID/SUBGID 创建的命名空间下,通过 useradd 在容器中创建一个普通账户,这个账户的 UID/GID 应该为 1000。之后使用 |
这一步就是 Podman 会基于镜像创建一个容器,在构建好的 overlayfs 上再挂载一个可读写的新 overlayfs 作为用户操作的最终文件系统。之后将容器的各种信息加入到一个 Podman 内部的数据库中。
之后,Podman 需要为容器配置网络。
在 rootless 容器中,Podman 在容器中使用 pasta 程序为容器提供网络服务。
|
Note
|
|
pasta 会在 rootless 容器中创建一个类型为 TUN/TAP 的网络接口,而这个网络接口的另一端则为宿主机上运行的 pasta 进程,这个 pasta 进程会通过套接字与宿主机上的网络栈交互,从而达到转发容器与外界的网络报文的功能(这里包括将容器内的端口映射至宿主机上的功能)。
|
Note
|
rootless 容器除了直接使用 pasta,其实也是支持使用网桥模式的。我们可以在创建 rootless 容器时,传入 根据测试,使用 |
在容器的存储了网络都配置好之后,Podman 将启动容器进程。
Podman 会运行 conmon(CONtainer MONitor),它会监测容器的整个运行周期,并负责接收容器日志,转发标准输入输出、标准错误,处理信号转发,向 Podman 回报容器状态的功能。在容器运行的初期,它还会依照配置调用容器的运行时(Podman 一般使用 crun)来为容器实际的主进程。
OCI 运行时会读取 OCI 配置文件,并配置内核以运行容器。OCI 运行时会执行如下操作:
-
为容器创建额外的命名空间
-
配置 cgroups v2
-
为运行中的容器设置 SELinux 上下文
-
读取 /usr/share/containers/seccomp.json 文件,将其中规则转换为 seccomp 内核规则,并写入内核中,以额外限制容器的权限。
-
为容器设置环境变量
-
将 Volume 挂载至 rootfs 上
-
将当前的根目录切换到 rootfs 上
-
fork 出容器进程
-
执行任何 OCI 钩子程序,并向其传递 rootfs 和容器的 PID 1 进程
-
执行镜像指定的命令
-
推出 OCI 运行行,让 conmon 继续监视容器
若我们需要连接容器的输入输出,则是通过 conmon 执行转发的。
容器中的进程可以自行退出,或者被用户的 podman stop <容器名称> 退出。
当容器的进程退出后,内核会向对应的 conmon 发送 SIGCHLD 信号,以提示 conmon 其管理的子进程的状态发生变化。之后,conmon 会执行如下操作:
-
接收容器的退出码(这一步也防止子进程变成僵尸进程)
-
关闭容器的日志文件
-
关闭 Podman 命令的 StdOut 和 StdErr
-
执行
podman conainer cleanup <容器 ID>命令 -
conmon 自身退出
podman container cleanup 负责退出 pasta 网络,并卸载容器的所有挂载点。若我们指定了 --rm 参数,则还会额外移除容器存储的 layer,并将容器从数据库中移除。