手写迷你容器:用Namespace、Cgroups和rootfs揭开容器原理
发布时间:2026/10/16 8:39:28 锦皓数字建站

与其对着各种配置文档猜容器技术底层原理不如亲手实现一个简单容器让内核把答案直接摆在你面前。如果有一个进程既能伪装成一台独立主机又能按预算消耗 CPU 和内存那它就是一个容器。这句话是我做完一个几十行 C 代码的实验之后对“容器”这个概念最直观的理解。容器技术如今几乎覆盖了服务端开发的每个角落但大多数人日常看到的只是封装好的命令和配置文件底层原理反而被层层包装遮住了。这篇文章不会去分析主流容器引擎的源码而是从内核提供的三个基础能力出发——Namespace、Cgroups、rootfs——带你动手写一个简陋但能运行的容器程序。适合想搞懂容器运行机制、被“容器是轻量级虚拟机”这句话误导过的人也适合正准备阅读运行时源码、但需要先建立心智模型的开发者。实验环境要求不高一台能跑 Linux 的机器内核版本最好在 5.x 以上有 root 权限装好 gcc。整个项目不依赖第三方库也没有复杂的构建流程核心代码一百行左右跑通之后你会发现容器并没有想象中神秘。1. 容器的本质一个被隔离和限流的普通进程1.1 容器不是缩小版虚拟机很多人第一次接触容器时都会接受一句话容器是轻量级虚拟机。这个类比用来解释产品体验可以但用来理解原理会带偏方向。虚拟机在硬件层面模拟出一个完整计算环境有自己的内核通过一个叫 hypervisor 的层把虚拟硬件暴露给客户操作系统所以不同虚拟机之间的内核版本、系统库都可以完全不同。容器则根本不虚拟化硬件它只是一个普通进程直接共享宿主机内核所谓隔离本质上是内核给这个进程组开了一组“视窗”和“限流阀”。这也解释了为什么容器镜像里不能随便更换内核模块容器内执行uname -r看到的版本号和宿主机一致因为两边用的是同一个内核。用生活化的方式理解虚拟机像是租了一整间独立公寓物业、水电、门禁全是自己的容器则是在集体宿舍里拉了一个帘子帘子里你可以摆自己的桌子、听自己的歌、挂自己的门牌但整栋楼的供水供电还是那一套。后者低成本、启动快但也意味着邻居出事你很难独善其身。1.2 三大内核机制的分工理解容器只需要抓住三个关键词Namespace、Cgroups、rootfs它们各自解决不同问题。机制管什么类比Namespace隔离进程、网络、挂载点、主机名等“视图”给你分房间、拉帘子Cgroups限制 CPU、内存、磁盘 IO 等资源配额给你装水表、电表rootfs 与挂载技术决定容器内看到哪个根目录、有哪些文件让你布置自己那张桌子三者缺一不可。没有 Namespace你在容器里执行ps会看到宿主机所有进程毫无隔离感没有 Cgroups容器里一个死循环能把整台机器资源打爆没有 rootfs容器进去之后看到的仍然是宿主目录根本算不上一个独立系统。一句话总结Namespace 提供隔离Cgroups 提供限制rootfs 提供根目录。把握住这个框架后面读任何容器运行时源码都会发现核心就是围绕这三件事转。2. 用 Namespace 制造“看不见的世界”2.1 先不写代码用 unshare 命令找手感在写代码之前我强烈建议先用内核自带的unshare命令体验一遍。root 权限下执行这一行unshare --fork --pid --mount --uts --ipc --net /bin/bash这条命令做的事情和创建一个容器的主干部分一模一样。参数逐个看--fork让 unshare 先 fork 出一个子进程因为如果不 forkPID namespace 对当前进程自身不生效--pid开启进程号隔离--mount创建新的挂载点视图--uts允许修改主机名--ipc隔离开进程间通信--net创建全新的网络协议栈。进入之后先试试设置主机名hostname mini hostname再执行ps aux很多第一次实验的人会看到两种情况要么报错要么看到一大堆宿主机进程。这里有个非常关键的细节创建 PID namespace 后内核并不会自动把/proc换成本 namespace 的视图。ps工具读取的是/proc文件系统而你现在看到的/proc还是继承自宿主机的挂载。解决办法是在新的 mount namespace 里重新挂载一份mount -t proc proc /proc然后再执行ps -ef进程列表立刻清爽很多。这个动作应该刻进你的肌肉记忆凡是切了 PID namespace就一定要重挂/proc。内核文档里也写得明白procfs 暴露的是挂载它的那个 PID namespace 的进程视图不重新挂载你看到的永远是旧世界。2.2 命令只是验证代码里用 cloneunshare命令适合临时验证真正的容器运行时不会启动一个 bash 再手动输入命令它们会在代码里直接调用系统调用。核心就是clone()它比fork()更进一步能通过 flags 精确控制内核要隔离什么。下面是一个极简片段#define _GNU_SOURCE #include sched.h #include stdio.h #include stdlib.h #include unistd.h static int child_func(void *arg) { printf(child inside container, pid%d\n, getpid()); return 0; } int main() { char *stack malloc(1 20); int flags CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWNET; pid_t pid clone(child_func, stack (1 20), flags | SIGCHLD, NULL); if (pid -1) { perror(clone); exit(1); } printf(child pid in host namespace: %d\n, pid); return 0; }clone()和fork()的区别在于你可以通过 flags 控制子进程进入哪些新的 namespace。CLONE_NEWPID表示子进程会拥有一个独立的进程编号空间在子进程视角里它的 PID 通常是 1而在宿主机视角它依然是某个普通 PID。CLONE_NEWNS创建新的挂载命名空间注意这里隔离的是“挂载点的视图”不是文件内容CLONE_NEWUTS让你能改主机名CLONE_NEWIPC隔离开 System V IPCCLONE_NEWNET则给子进程一个干净的网络协议栈。这些 flag 可以任意组合这也是容器隔离范围可以裁剪的原因。2.3 每种 Namespace 到底在隔离什么Namespace 类型clone 标志隔离对象常见影响UTSCLONE_NEWUTS主机名和域名容器内可以随意改 hostname不影响宿主PIDCLONE_NEWPID进程编号空间容器内第一个进程 PID 显示为 1MountCLONE_NEWNS挂载点列表容器内挂载和卸载操作不影响宿主NetworkCLONE_NEWNET网络设备、协议栈、路由表容器内默认只有 loopback没有外网网卡IPCCLONE_NEWIPCSystem V IPC 队列、信号量等不同容器之间不共享 IPC 对象UserCLONE_NEWUSERUID/GID 映射非 root 用户可以在容器内映射成“root”User namespace 非常关键它是 rootless 容器的基础但真正用起来涉及 id map 的映射关系相当繁琐。这个迷你项目先不引入专注把主流程走通等你有兴趣再深入。3. 用 Cgroups 给容器装上资源水龙头3.1 为什么隔离还不够假设你只用了 Namespace容器已经能在里面启动进程并切换根目录了。问题马上会出现如果这个进程写了一个死循环它会占满宿主机的所有 CPU 和内存其他服务全部遭殃。生产环境绝不允许出现这种情况所以内核提供了 Cgroups把进程放进一个可量化的“预算桶”里。Cgroups 的接口很有意思它不是一个专用系统调用而是文件系统目录。在/sys/fs/cgroup下每个目录就是一个控制组往目录里写配置、把进程 PID 写进去限制立刻生效。我第一次意识到这个设计时挺意外原来这么底层的资源控制用户态看到的全是在读写普通文件。这也让容器运行时不需要引入特殊库打开文件、写入字符串、关闭三步就走完了。3.2 手动创建一个控制组看效果如果你的系统是 cgroup v2这是现代主流发行版的默认方案把 CPU 限制成单核的 50%、内存上限设成 5MB只需要三条命令mkdir -p /sys/fs/cgroup/mini-demo echo 50000 100000 /sys/fs/cgroup/mini-demo/cpu.max echo 5000000 /sys/fs/cgroup/mini-demo/memory.max echo $$ /sys/fs/cgroup/mini-demo/cgroup.procscpu.max的格式是“quota period”50000 100000表示在 100000 微秒的周期里最多使用 50000 微秒 CPU 时间也就是半核。写入之后执行一个死循环yes /dev/null另开终端用top看到 CPU 使用率会稳定在 50% 左右。内存上限设成 5MB 后如果进程尝试申请更多内存内核会直接触发 OOM。如果你的系统还是老的 cgroup v1文件名和路径不一样CPU 配额写在cpu/cpu.cfs_quota_us和cpu/cpu.cfs_period_us内存写在memory/memory.limit_in_bytes。判断当前是 v1 还是 v2 很简单看/sys/fs/cgroup下是不是只有一个目录以及有没有cgroup.controllers文件。不管哪种版本本质都一样控制组是一棵树进程从属于某个节点并继承该节点的限制。3.3 在容器启动流程里挂上 Cgroups把上面的操作搬进 C 代码就是写一个辅助函数static void attach_cgroup(pid_t child_pid) { mkdir(/sys/fs/cgroup/mini-demo, 0755); int fd open(/sys/fs/cgroup/mini-demo/cgroup.procs, O_WRONLY); if (fd 0) { perror(open cgroup.procs); return; } dprintf(fd, %d\n, child_pid); close(fd); }父进程在clone()成功拿到子进程 PID 后立刻调用这个函数把子进程塞进刚建好的控制组。注意cgroup.procs写入的是主线程 PID之后这个进程 fork 出来的子线程和子进程都默认待在组里。如果你写的是cgroup.threads那就只能控制某一个线程颗粒度完全不同别搞混。这里有一个容易忽略的小窗口子进程先被clone()出来然后才被写进控制组中间存在一小段时间限制尚未生效。极端情况下如果子进程在写入前瞬间疯狂申请内存理论上可能冲破限制。真实容器运行时一般会先创建一个暂停状态的后代限制好之后再放行。麻雀虽小这个细节值得记下。有人可能会问为什么不用setrlimitsetrlimit是单进程粒度的资源限制管理起来离散没法把几十个进程放到同一个预算池里统一调整。Cgroups 的价值在于它是一个可层级化的资源池父目录管理一组进程随时可以热调整还能导出统计信息。容器运行时之所以用 Cgroups正因为它是为“进程组”设计的资源治理机制。4. 用 rootfs 让容器变成一个“小系统”4.1 chroot 很简单但生产级容器用 pivot_rootNamespace 和 Cgroups 解决的是“我有独立房间、水电有限额”接下来要解决容器根目录应该是一套自己的文件系统。最直接的办法是chroot。mkdir -p /root/rootfs # 把精简工具集放进去后 chroot /root/rootfs /bin/shchroot实现起来很简单但它只是把进程的根目录换掉存在一些安全边界上的瑕疵切换者当前的工作目录如果还在旧目录里某些路径组合可能绕出去已经打开的文件句柄也可能继续指向旧文件系统。因此生产级容器运行时一般用pivot_root它会将当前根文件系统移动到某个目录再把新的 rootfs 树替换为根。pivot_root有一个前置条件新的 rootfs 必须是一个挂载点。所以常规做法是先执行一次mount --bind rootfs rootfs把 rootfs 目录变成挂载点再调用pivot_root。这个小细节是很多迷你项目最容易漏的一步。如果只是为了理解原理用chroot完全够但要清楚真实工程里为什么不用它。4.2 手工做一个最小 rootfs如何得到一套最小的 rootfs不需要去安装完整发行版只需要一个静态编译的精简工具集。以常见的 BusyBox 为例步骤很简单mkdir -p /root/rootfs/{bin,proc,dev,sys,etc,tmp} cp /path/to/busybox-static /root/rootfs/bin/busybox for app in sh ls ps cat mount hostname; do ln -s busybox /root/rootfs/bin/$app done这里有几个容易踩的坑。第一/proc、/dev、/sys这些目录必须提前创建否则后面代码里执行mount(proc, /proc, proc, 0, )会失败。第二工具集必须是静态编译版不依赖宿主机的动态库否则切到新 rootfs 之后动态链接库找不到命令直接跑不起来。第三如果需要登录交互最好在/etc/passwd里补一个 root 用户否则部分命令行为会很奇怪。不想用 BusyBox 也可以把宿主机上几个静态链接小程序复制进去同样能跑但那样调试起来非常痛苦。精简工具集能大幅降低项目维护成本是这类实验的首选。4.3 镜像分层不是魔法overlay 文件系统真实容器镜像比单纯一个 rootfs 复杂因为要支持分层缓存、多个容器共享底层。这里依赖的是内核的联合文件系统能力。以 overlay 文件系统为例把只读的镜像层放在lowerdir容器可写的层放在upperdir合并视图挂到mergedmount -t overlay overlay \ -o lowerdir/root/rootfs,upperdir/root/container-layer,workdir/root/work \ /root/merged写操作发生在upperdir底层文件不会被动。如果容器要修改某个底层文件overlay 会先把文件从 lower 复制到 upper这叫 copy-up然后在 upper 里修改删除文件则会在 upper 层创建一个 whiteout 标识把 lower 里的文件“遮住”。这就是“镜像层只读、容器层记录变化”的本质。不过一个最简容器并不需要实现 overlay先使用单一 rootfs 把机制跑通再去理解分层会轻松很多。5. 拼一个能跑的 mini-container5.1 设计取舍不是生产级但五脏俱全前面三块单独都能验证接下来把它们全部塞进同一个 C 程序。目标很明确克隆出一个带 PID/Mount/UTS/IPC/Network 隔离的子进程设置 rootfs挂载/proc进入 Cgroups 限制然后执行一个/bin/sh交互 shell。涉及安全的 User namespace、capabilities 降权、seccomp 过滤、veth 虚拟网卡都不做这样能把代码控制在 100 行左右专注展示核心骨架。5.2 核心代码mini.c#define _GNU_SOURCE #include sched.h #include signal.h #include stdio.h #include stdlib.h #include string.h #include sys/mount.h #include sys/stat.h #include sys/wait.h #include fcntl.h #include unistd.h #define STACK_SIZE (1024 * 1024) static char child_stack[STACK_SIZE]; static const char *ROOTFS /root/rootfs; static void write_file(const char *path, const char *data) { int fd open(path, O_WRONLY); if (fd 0) { perror(path); return; } dprintf(fd, %s, data); close(fd); } static void setup_cgroup(pid_t pid) { mkdir(/sys/fs/cgroup/mini, 0755); write_file(/sys/fs/cgroup/mini/cpu.max, 50000 100000\n); write_file(/sys/fs/cgroup/mini/memory.max, 67108864\n); char buf[32]; snprintf(buf, sizeof(buf), %d\n, pid); write_file(/sys/fs/cgroup/mini/cgroup.procs, buf); } static int child_main(void *arg) { sethostname(mini-container, 15); if (chroot(ROOTFS) ! 0) { perror(chroot); exit(1); } chdir(/); mount(proc, /proc, proc, 0, ); execvp(/bin/sh, (char *[]){/bin/sh, NULL}); perror(exec); exit(1); } int main(void) { int flags CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWNET | SIGCHLD; pid_t pid clone(child_main, child_stack STACK_SIZE, flags, NULL); if (pid -1) { perror(clone); exit(1); } setup_cgroup(pid); printf(container started, pid%d\n, pid); waitpid(pid, NULL, 0); rmdir(/sys/fs/cgroup/mini); return 0; }这段代码的执行时序需要仔细看父进程先clone()出子进程子进程一进去就设置 hostname、切换 rootfs、挂载/proc然后执行/bin/sh父进程拿到子进程 PID 后立刻把它写入 Cgroups 的cgroup.procs然后阻塞在waitpid()上等子进程退出后清理控制组目录。由于子进程已经创建了新的 Mount namespace它内部挂载/proc完全不会影响宿主机。代码里我用了chroot()而不是pivot_root()一是为了短小清晰二是先理解根目录切换的核心。想更接近真实运行时可以把chroot换成“先mount --bind rootfs rootfs再调用pivot_root”的组合目录结构需要稍微调整。另外/sys/fs/cgroup/mini中 CPU 选项是 cgroup v2 的写法如果你的内核已经开启了 CPU 控制器一般不会有问题如果写入失败检查父控制组的cgroup.subtree_control。5.3 运行效果与验证清单编译运行gcc -o mini mini.c sudo ./mini进入容器后的输出类似container started, pid2734 # hostname mini-container # ps -ef PID USER COMMAND 1 root /bin/sh # cat /proc/self/cgroup 0::/mini # exit验证隔离是否生效可以按下面的清单来验证点容器内操作宿主机观察PID 隔离echo $$宿主ps -ef能看到一个真实的 PIDUTS 隔离hostname改为任意名称宿主机 hostname 不受影响Mount 隔离在容器内 mount 或 umount宿主机挂载表无变化资源限制执行yes /dev/null宿主机top中该进程 CPU 稳定在 50% 左右根目录切换ls /看到的是 rootfs 内容宿主机根目录还是原样每次实验结束后确认/sys/fs/cgroup/mini已经删除避免下次启动时目录冲突。我通常会加一个清理脚本把同名的控制组目录一并清掉。6. 实验中的高频翻车点和排查思路6.1 子进程退出变僵尸新手最容易遇到的现象mini 进程退出后宿主机的 bash 里冒出一个defunct状态的僵尸进程。原因是父进程clone()之后没有同步收尸子进程死亡后它的退出状态无人读取内核只能让它以僵尸态残留在进程表中。排查链路很清晰先ps -ef确认出现defunct再看父进程是否还活着然后用strace -p 父PID观察有没有调用wait4()。如果确实没有回归代码发现漏了waitpid()。这个细节在真实容器运行时里很讲究。容器内的 PID 1 不仅要收自己的直接子进程还要负责收养所有被 init 托管的孤儿进程否则容器退出时容易出现资源残留。迷你实验不会爆发那么复杂的问题但理解“谁创建谁回收”是必修课。6.2 容器里 ps 看到宿主机进程第二个高频坑是明明用了CLONE_NEWPID进容器执行ps还是一大堆进程。这种情况十有八九不是clone()失败而是没有重新挂载/proc。我之前已经强调过procfs 暴露的是挂载时的 PID namespace 视图旧挂载点不会自动切换。排查时先在容器里执行ls -la /proc/self如果看到进程号是宿主视角的大数字而不是 1基本可以断定/proc挂载没换。如果容器内部已经有一个/proc挂载点需要先umount /proc再重新挂载最好先对新的挂载执行mount --make-private避免挂载传播导致宿主机看到容器内挂载点。6.3 Cgroups 路径报错、控制器没开启cgroup v2 下最烦人的问题之一是明明路径都对写cpu.max却提示Operation not permitted或No such file or directory。原因通常是父控制组没有开启 CPU 控制器。需要先启用echo cpu /sys/fs/cgroup/cgroup.subtree_control echo memory /sys/fs/cgroup/cgroup.subtree_control在真实容器运行时的启动逻辑里第一件事通常就是遍历并开启需要用到的 controllers。另外一个典型错误是混淆 v1 和 v2 的文件名比如在 v2 环境里去找memory.limit_in_bytes无论如何都是找不到的。遇到报错时先确认/sys/fs/cgroup下有没有cgroup.controllers文件有就是 v2没有就是 v1然后选择对应的接口。这个判断能省掉大量调试时间。6.4 真实容器还差多少迷你容器能跑通但距离生产级还有明显差距没有 User namespace 和完整的 capabilities 安全降权没有 seccomp 过滤危险系统调用没有 veth 虚拟网卡和网络命名空间连接没有镜像校验和分层存储也没处理 PID 1 的 init 信号模型和孤儿进程收养。继续深入有两条路一条是去读 OCI 运行时源码对照这个 mini 程序逐项补另一条是自己继续加功能比如先接一个 console 交互再做网络虚拟设备最后加一个简单的 rootless 方式。整个实验做下来我最大的体会是容器本身并不复杂复杂的是它外面的工程体系。内核把进程管理的 API 摆在那里真正有价值的是如何把这些原语组织成安全、高效、可分发、可编排的产品能力。建议你也像我一样先不要急着抄现成源码亲手写一个能跑的迷你容器跑通之后再看任何容器工具的内部日志都会亲切很多。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。