用 81 行 Bash 手写容器:Ubuntu 24 上内核原语的开销实测
A container in 81 lines of Bash: what the kernel primitives cost on Ubuntu 24
作者在 Ubuntu 24.04(HWE 内核 7.0.0-34-generic)上用 81 行 Bash 写出 minicontainer.sh,逐一手工搭建 namespaces、cgroups、rootfs 与 veth,并记录每项内核原语的实际开销与错误预测。
在这个实验的早期,我有一个 PID 为 1 的 shell,而就在同一个 shell 里,ps 却把 systemd 列为 PID 1。两者都没错。内核把这个 shell 放进了一个新的 PID 命名空间;ps 读到的仍然是为主机挂载的 /proc。隔离和工具所报告的内容是两回事,而大多数容器调试就发生在这个差距里。
这是关于在双节点家庭实验室上构建 AI 基础设施系列的第一篇文章。在转向前端开发之前,我运行了多年 Linux 服务器,现在正努力回到基础设施领域,这次是为了 AI 工作负载:GPU 节点、推理服务、可观测性以及 AI 智能体的沙箱。一切都发生在公开的 ai-homelab 仓库中,一次一个实验。每个实验都有一份包含每条命令的分步指南,以及一份包含原始数字(包括错误)的结果文件;每篇文章讲述一个实验的故事。
这是 Lab 0a。我之后要运行的每个推理服务器、沙箱和 Kubernetes pod,都是由同样的几个内核原语构建的:命名空间、cgroups、根文件系统和一根虚拟网线。所以在运行它们之前,我手工构建了一个容器,一次一个原语,并记下了每个数字和每个错误的预测。结果就是 minicontainer.sh,81 行 Bash。
- 指南:每条命令,一步一步
- 结果:测量数据、意外和错误
- 脚本:
minicontainer.sh
“完成”意味着什么
在写任何代码之前就确定的六项检查:
- 内部的
ps只显示容器的进程,并且 shell 是 PID 1。 - 内部的
hostname与主机不同,并且ls /显示的是 Alpine,而不是主机。 - 超过
memory.max的进程会被杀死,并且memory.events显示oom_kill 1。 - 从容器到 veth 对主机端的
ping可以工作。 lsns显示容器拥有不同的命名空间 inode。- 脚本会自行清理:没有残留的 cgroup、网络命名空间或 veth。
环境搭建
我的双节点家庭实验室中的 Z790 节点:i7-14700、64 GB DDR5、两块 RTX 5060 Ti 显卡(此处未使用)、Ubuntu 24.04 搭配 HWE 内核 7.0.0-34-generic。
容器的用户空间是 Alpine minirootfs 3.24.2:一个 3.7 MB 的 tarball,解压后 8.7 MB。我从 Alpine 的发布元数据中读取当前版本,然后按精确路径下载(v3.24/…,而不是 latest-stable/…),这样每次运行都使用相同的 rootfs,并用 sha256sum -c 进行了验证。来自同一服务器的校验和证明的是完整性,而不是真实性;Alpine 还发布了 GPG 签名,我把它留到后面的实验。
命名空间,一次一个
UTS:机器的名称
UTS 命名空间只隔离主机名,这使它成为观察整个机制最安全的方式。在新的 UTS 命名空间内更改主机名不会影响主机的名称,而 readlink /proc/self/ns/uts 显示了原因:
inside: uts:[4026533010]
outside: uts:[4026531838]这个数字是一个 inode,当两个进程的数字匹配时,它们就共享同一个命名空间。主机的值并非偶然:4026531838 是 0xEFFFFFFE,一个常量。内核的初始命名空间拥有固定的 inode 编号 4026531832–4026531839;新命名空间获得动态编号,这些编号在命名空间消亡后会被回收。看到其中一个常量就意味着“这是主机的命名空间”。
PID:为什么 --fork 不是可选项
unshare() 会立即将调用进程移入大多数新命名空间,但不会移入新的 PID 命名空间。进程的 PID 在诞生时就固定了,所以唯一的进入方式就是在那里诞生:PID 命名空间只适用于调用者未来的子进程。
没有 --fork,第一个子进程会成为新命名空间的 PID 1。当它退出时,该命名空间就没有 init 了,内核会拒绝在其中再创建任何进程:
sh PID: 6000
after 1st child
sh: 1: Cannot fork我还意外地以交互方式运行了 sudo unshare --pid。提示符还没出现,它就打印了 fork: Cannot allocate memory:.bashrc 在启动时 fork,那个子进程成了 PID 1 并退出,之后每条命令都无法 fork。只有内建的 exit 还能用。错误信息说的是“内存”,但真正的问题是命名空间已死。
在 /proc 被重新挂载之前,ps 一直在说谎
有了 --fork,shell 确实就是 PID 1——而 ps 仍然打印出了 1 systemd、2 kthreadd 以及宿主机上的其余内容。ps 不会去问内核它能看到哪些进程;它读取的是 /proc,而一个 proc 挂载始终显示它被挂载时所对应的 PID 命名空间。加上 --mount-proc(一个新的挂载命名空间外加一个全新的 /proc)后,只剩下了两行:1 sh 和 2 ps。第一项检查通过。
挂载传播:一个泄漏的 tmpfs
systemd 会让宿主机上的每个挂载都 shared,因此对等组中一个成员的挂载会传播到其他成员。用 --propagation shared 启动的新挂载命名空间仍留在宿主机的对等组中:我在里面挂载了一个 tmpfs,命名空间结束后,宿主机上的 findmnt 仍然显示它。使用 --propagation private 时,同样的挂载从未离开命名空间。这就是为什么 unshare 默认使用 private,这也是下一步的前提条件。
chroot 对比 pivot_root
chroot 进入 Alpine rootfs 成功了:NAME="Alpine Linux",但 uname -r 仍然打印出宿主机的 7.0.0-34-generic。容器有自己的文件系统,但共享内核。
我原以为在 chroot 内部运行 /proc/mounts 会列出几十个宿主机挂载。它只列出了一个。同一个挂载命名空间,两种视图:
| 根切换 | 切换前的挂载 | 切换后的挂载 | 宿主机文件系统…… |
|---|---|---|---|
chroot(外部 / 内部,同一命名空间) |
26 | 1 | 被隐藏,但仍挂载着 |
pivot_root(旧根 umount -l 之前 / 之后) |
28 | 2 | 已分离 |
/proc/self/mounts 只列出从进程根目录可达的挂载,因此 chroot 隐藏了宿主机的挂载而没有分离它们。pivot_root 会替换根挂载,并把旧根停放在 /.oldroot;随后一次惰性 umount -l 就会把它连同所有子挂载一起分离。之后,/.oldroot 变为空,进程再也没有回到宿主机的路径。
支线任务:一个 3.9 MB 的根文件系统
任何目录都可以成为 /。我把 /bin/bash 复制到一个空的 tiny/ 目录中,并尝试 chroot 进入它:
chroot: failed to run command '/bin/bash': No such file or directory文件是存在的。缺失的是内核最先启动的动态加载器;错误信息点出的是 bash,但问题出在加载器上。复制了加载器以及 ldd 列出的两个库之后,bash 能运行了,ls 不存在,而整个 rootfs 只有 3.9 MB。镜像实际上就是程序加上它所依赖的一切,这就是静态链接二进制文件和 distroless 镜像存在的原因。
手动操作 cgroups v2
systemd 如何运行一个真实服务
在编写自己的 cgroup 之前,我看了一个真实的例子:作为 systemd 服务运行的 Ollama。它位于 system.slice/ollama.service 中;在未加载模型的空闲状态下,memory.current 约为 552 MiB(包含页缓存),pids.current 为 18。并不存在单独的“cgroup 配置”:单元文件就是配置,systemd 会把它写入 cgroup 的文件中。
一个没人写下的限制:pids.max 是 70833。systemd 会对每个服务应用一个默认的 TasksMax,这样单个 fork 炸弹就无法耗尽整台机器。
swap 陷阱与一次 OOM 终止
创建 cgroup 就是在 /sys/fs/cgroup 中执行一次 mkdir;内核会用 86 个控制文件填充该目录。我把 memory.max 设为 100M,pids.max 设为 20,cpu.max 设为 50000 100000(半个核心),并把 memory.swap.max 设为 0。最后一项很重要:这台机器有一个 8 GB 的 swap 文件,没有它的话,处于 memory.max 的进程会被换出而不是被杀死。
然后 cgroup 内的一个 Python 进程分配了 200 MB:
Killed
exit code: 137
Memory cgroup out of memory: Killed process 5462 (python3) total-vm:223152kB, anon-rss:101884kB, file-rss:6404kBmemory.events |
值 |
|---|---|
max |
18 |
oom |
1 |
oom_kill |
1 |
oom_group_kill |
0 |
退出码 137 是 128 + 9,即 SIGKILL,没有机会清理。max 18 是 cgroup 达到上限、内核在放弃前尝试回收内存的次数。total-vm 是进程请求的内存量,anon-rss 是它死亡时的常驻内存量,正好在 100 MB 限制处。当容器“凭空消失”时,要留意的就是那行 dmesg。
pids.max 与 30 个 sleep 进程
我预测在 pids.max = 20 下启动 30 个后台 sleep 进程会产生 11 次 fork 失败。结果只得到一次:pids.current 为 19,一个单独的 sh: 0: Cannot fork,而 pids.events 显示 max 1。非交互式 dash 在第一次 fork 失败时就放弃。真正的 fork 炸弹会不断重试,而 cgroup 会在上限处不断阻止它。
dmem 与 NVIDIA:没有 VRAM 限制
内核的 cgroup 控制器列表以 dmem 结尾,即设备内存的控制器:dmem.min、dmem.low 和 dmem.max 的工作方式与其 memory 对应项类似,针对设备的每个内存区域。在这台机器上,dmem.capacity 存在且为空。
驱动程序必须通过注册其内存区域来选择加入。在此内核上,Intel 的 xe 和 AMD 的 amdgpu 已加入;NVIDIA 580 开源内核模块中没有任何一个引用 dmem。因此在这个实验环境中,GPU 内存无法通过 cgroups 限制。
网络:命名空间之间的一根网线
一个命名网络命名空间(ip netns add)加上一对 veth,将一端移入其中,就是两个网络栈之间的一根虚拟以太网线。在里面,新命名空间只有 lo(down)和它这一端的网线,显示为 @if8:另一个命名空间中其对端的索引。没有局域网接口,没有 Docker 网桥,甚至没有可用的回环。
两端都配上地址后,ping 通了:0.148、0.077 和 0.079 毫秒。第一个数据包较慢,因为它包含 ARP 解析;所有数据包都远低于一毫秒,因为它们从未离开内核。没有默认路由,所以无法访问互联网——NAT 是后续实验的主题。删除命名空间会销毁它这一端的网线,而由于 veth 对不能只有一端存在,内核也移除了主机端。一条命令就清理了整个东西。
Ubuntu 24.04 上的用户命名空间与 AppArmor
到目前为止的一切都是在 sudo 下运行的。用户命名空间是在不这样做的情况下实现它的方式:内部是 root,外部是普通用户。作为非特权用户,它失败并报 write failed /proc/self/uid_map: Operation not permitted,内核审计日志用两行解释了原因:
apparmor="AUDIT" operation="userns_create" … info="Userns create - transitioning profile" profile="unconfined" … target="unprivileged_userns"
apparmor="DENIED" operation="capable" profile="unprivileged_userns" … capability=21 capname="sys_admin"创建命名空间是允许的,但进程会立即被移入限制性的 unprivileged_userns 配置文件,该配置文件拒绝内部的 CAP_SYS_ADMIN。用户命名空间让非特权用户在内核的大部分区域拥有“root”权限,而这条路径曾带来许多权限提升漏洞。Ubuntu 24.04 只允许具有显式 AppArmor 配置文件的程序使用它们。
我没有全局关闭该保护。取而代之的是,一份 root 拥有的 unshare 副本获得自己的配置文件,其中唯一增加的是 userns,这样 AppArmor 不再将其移入限制性配置文件。该配置文件作为 Ansible 角色 的一部分存在于仓库中。这种权衡被写下来而不是隐藏起来:该副本不会收到 util-linux 安全更新,因此升级后必须重新运行该角色,或者删除该副本。
使用该副本后,无 root 测试成功了,并展示了用户命名空间中的“root”意味着什么:
uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
CapEff: 000001ffffffffff
touch: cannot touch '/etc/lab0a-test': Permission denied拥有所有 capabilities,却仍然没有对 /etc 的写权限:内核会根据命名空间外部的 UID 检查主机资源。
背景:2025 年 3 月,Qualys 发布了该限制的三种绕过方法。Canonical 将其视为纵深防御措施。该限制是一根留下审计记录的绊线,而非一堵墙;抵御内核漏洞利用的真正防线是打过补丁的内核。
在我的主机上,第一种方法奏效了:kernel.apparmor_restrict_unprivileged_unconfined 为 0,而 aa-exec -p trinity 通过一个甚至未安装的程序的配置文件,让一个非特权用户获得了拥有全部能力的用户命名空间。一个 sysctl 就能堵住它:设置 kernel.apparmor_restrict_unprivileged_unconfined = 1 后,同样的命令会以 Operation not permitted 失败,而专用的 unshare 副本仍然有效。
这个 81 行的脚本
minicontainer.sh 在经过一些预检查后,将所有这些整合为四个块:
- cgroup:创建它并写入限制,关闭 swap。
- 网络:命名空间、veth 对,容器端按 Docker 的做法重命名为
eth0。 - 内部,作为 PID 1:主机名、绑定挂载、
pivot_root、一个新的/proc、旧根目录的umount -l,然后以干净的环境执行execAlpine 的 shell。 - 启动:一个子 shell 先将自己移入 cgroup,这样它启动的一切都会继承该 cgroup,然后进入网络命名空间并用
unshare --uts --pid --mount --ipc --cgroup --fork创建其余部分。
在 EXIT 上执行 trap 会删除网络命名空间(连同 veth 两端一起),并通过一个简短的重试循环移除 cgroup,因为 cgroup 只有在其最后一个进程退出后才能消失。执行 exit 后,cgroup、命名空间、veth 对和挂载点全部消失了。六项检查全部通过。
这是容器运行时的进程树,附有某次运行中的主机 PID:
subshell → exec ip netns exec → exec unshare host PID 16242
joins the lab0a network namespace and cgroup,
unshare() moves it into new uts, mnt, ipc and cgroup namespaces
└── fork → /bin/sh host PID 16244, PID 1 inside
the first process in the new pid namespace从主机视角看:我的方案对比 Docker
lsns 显示一个进程的全部八种命名空间类型。以下是我的容器与 Docker 29.8.0 上的 docker run alpine:3.24 的对比:
| 命名空间 | minicontainer.sh |
Docker |
|---|---|---|
| mnt、uts、ipc、cgroup、net | 新建 | 新建 |
| pid | 新建 | 新建 |
| time | 与主机共享 | 新建 |
| user | 与主机共享 | 与主机共享 |
核心模型相同:新建 mnt、uts、ipc、pid、cgroup 和 net 命名空间,且没有用户命名空间。我的容器中的 root 就是主机上的 root,默认 Docker 容器也是如此。
有两处不同。Docker 还创建了一个 time 命名空间,而我此前预测它不会。这是 Docker 29.8.0 以其默认配置生成的 OCI 规范的一部分,偏移量为零:一个不改变任何可见内容的新命名空间。而 Docker 的进程模型更干净:每个命名空间都从容器自身的进程开始,因为 runc 直接在所有命名空间中创建它,而我的脚本将 unshare 父进程(上文的 16242)留在了六个新命名空间中的五个里。这正是前面提到的 PID 命名空间规则,在此变得可见。
启动:124 毫秒对比 518 毫秒
各运行五次,命令为 true,因此只测量创建和销毁:
| 5 次运行(实际) | 每次运行 | user + sys(5 次运行) | |
|---|---|---|---|
minicontainer.sh |
0.619 s | 约 124 ms | 0.022 + 0.047 s |
docker run --rm alpine:3.24 |
2.589 s | 约 518 ms | 0.025 + 0.042 s |
大约快四倍,但仍比我猜测的“几十毫秒”要慢。每次运行的 CPU 时间约为 14 毫秒,因此其余大部分时间都在等待:等待内核中的命名空间、veth 和挂载设置与拆除。精确分析时间花在哪里是后续实验的任务。
Docker 的 user 和 sys 同样很小,而这具有误导性。time 只统计客户端及其子进程;真正的工作发生在 dockerd、containerd 和 runc 中,它们不是该 shell 的子进程。在客户端-服务器系统上,time 测量的是延迟,而非成本。
这一切都不意味着 Docker 慢。它为容器间共享的分层镜像、overlayfs、带 NAT 的桥接网络、日志、重启策略和 API 付出了代价。我的 124 毫秒只是内核原语本身的成本。
我搞错的地方
错误是这次实验中最有用的部分,所以列在这里:
- 在
chroot之后,我预期/proc/mounts中会有几十个挂载点。结果只显示了一个。 - 我预期会有 11 次失败的 fork 对应
pids.max。dash在第一次之后就停止了。 - 我在容器内使用了
ip -br addr。-br是 iproute2;Alpine 的ip是 BusyBox 小程序。在容器内,工具是 Alpine 的。 - 我预期启动时间为几十毫秒。实际约为 124 毫秒。
- 我预期 Docker 会共享时间命名空间。它却创建了一个新的。
- 我意外运行了一个交互式
sudo unshare --pid,结果里面什么也无法执行。
缺口与下一步
这个脚本故意不完整。它没有 /dev(连 /dev/null 都没有),没有 /tmp 或 /sys,没有能力丢弃,没有 seccomp,也没有用户命名空间,而且和默认 Docker 一样,容器内的 root 就是宿主机上的 root。用来学习每个原语的作用没问题;但不能用它来运行不受信任的代码。
下一个实验会在同一台服务器上让这些原语发挥作用:沙箱化 Claude Code,也就是我在这个实验中使用的 AI 编程代理。在 Linux 上,它的沙箱基于 bubblewrap,会创建与本文手工构建的相同的命名空间,而在 Ubuntu 24.04 上,它会遇到同样的 AppArmor 壁垒。再往后,上述缺口会变成一次加固沙箱对比:Claude Code 的内置沙箱、NVIDIA OpenShell、gVisor 以及这个脚本的加固版本。
本文的全部内容:
- 指南: 每条命令,逐步说明
- 结果: 测量、意外与错误
- 脚本:
minicontainer.sh
来源:Hacker News · gshell.dev