你每天可能都在敲 docker run。但你有没有想过,这一行命令背后,内核到底干了什么?

容器没有虚拟机那套硬件模拟,没有独立内核,跑的还是宿主机上那个普通 Linux 内核。那它怎么做到「看起来像一台独立机器」的?

答案是三样东西的组合:namespace + cgroup + 受限的文件系统。仅此而已。

这个结论听起来太轻巧了。于是我决定动手验证——用 Rust 从零写一个能跑的容器运行时,把 Docker 的所有工程化封装剥掉,看看容器最核心的路径到底有多长。

项目叫 capsule-box(命令 cb),代码在 GitHub。它不是生产级的,也永远不打算成为生产级的。它是一个学习项目,用来回答一个问题:Docker 到底做了什么?


容器到底是什么

一句话:容器是一个被精心限制了视野和资源的普通进程。

容器 = 进程 + 受限视野(namespace)+ 受限资源(cgroup)+ 独立文件系统(chroot/OverlayFS)

把这三件事拆开看——

受限视野靠 namespace。PID namespace 让容器里的进程看不到宿主机上的其他进程,Mount namespace 让它有独立的挂载点,UTS namespace 让它有自己的主机名,Network namespace 让它有独立的网卡和路由表。

受限资源靠 cgroup。它规定这个进程最多吃多少内存、占多少 CPU、能开多少个子进程。

独立文件系统靠 chroot 或 OverlayFS。它把容器的根目录 / 重定向到一个隔离的目录,进程以为自己身处一个完整的世界。

这就是 Docker 的全部秘密。剩下的镜像格式、层叠存储、Compose 编排、镜像仓库,都是在这三样东西之上堆出来的工程化封装。

capsule-box 做了什么

capsule-box 把上面这套机制完整实现了一遍,外加一个 daemon/client 架构把命令传递和生命周期管理串起来。整体长这样:

client
  |
  | Unix Socket: /run/cb/ipc.sock
  v
daemon
  |
  +-- container manager   容器状态、IP 分配、生命周期
  +-- sandbox              namespace / cgroup / OverlayFS / 网络
  +-- storage              JSON 元数据持久化

架构上很朴素,但每一块都对应容器运行时的一个真实职责。cb daemon 常驻后台监听 Unix Socket,cb run 是个轻量客户端,把请求序列化成 JSON 发过去。这种 daemon/client 拆分不是凭空设计的——Docker 自己也是 dockerd + docker CLI 的结构,containerd 更是彻头彻尾的 daemon。容器运行时天然需要一个常驻进程来管理一堆容器的状态。

第一块拼图:进程隔离靠 namespace

namespace 是 Linux 内核给进程画的「视野边界」。同一个 namespace 里的进程互相可见,跨 namespace 的则互相看不到。

capsule-box 用到了四种 namespace:

PID namespace——让容器进程成为自己世界里的 PID 1。不隔离的话,容器里 ps aux 会看到宿主机的 systemd、nginx、你的 shell,全都在。隔离之后,容器只看到自己。

UTS namespace——独立的 hostname。容器可以叫自己 container-abc123,不影响宿主机。

Mount namespace——独立的挂载树。容器在 /proc 上挂载的 procfs 只在容器内可见,不会污染宿主机。

Network namespace——独立的网卡、路由表、iptables 规则。这是容器网络隔离的基础。

这里有个反直觉的坑:unshare(CLONE_NEWPID) 创建了新的 PID namespace,但调用者自己的 PID 不会变。必须再 fork 一次,新出生的子进程才会是那个 namespace 里的 PID 1。这就是所谓的 double fork。

另一件事:PID 1 在 Linux 里是特殊的。它是所有孤儿进程的「收养者」,而且默认不会被 SIGTERM 杀死。这就是为什么很多容器镜像用 tinidumb-init 当 PID 1——为了正确回收僵尸进程。capsule-box 里这一块还比较简化,但这个知识点本身很值得记住。

第二块拼图:资源限制靠 cgroup

namespace 管的是「能看到什么」,cgroup 管的是「能用多少」。

cgroup v2 的接口极其简单——它就是挂在 /sys/fs/cgroup/ 上的一个虚拟文件系统。你不需要调任何特殊 API,配置资源限制就是写普通文件:

# 创建一个 cgroup(mkdir 即创建)
mkdir /sys/fs/cgroup/cb/my_container

# 限制 256M 内存(写文件即配置)
echo "268435456" > /sys/fs/cgroup/cb/my_container/memory.max

# 把进程加进去(写 PID)
echo $PID > /sys/fs/cgroup/cb/my_container/cgroup.procs

就这样。没有配置文件,没有守护进程,没有 RPC。读写文件,完事。超出内存上限的进程会被内核的 OOM Killer 直接 SIGKILL 掉。

capsule-box 支持 cb run /bin/sh 256M 这样的语法,也接受 1G512Kmax(不限制)。解析完字符串就转换成字节数写进 memory.max

cgroup 的设置时机有个讲究:得在 fork 之前由父进程把 cgroup 目录和限制配好,fork 之后立刻把子进程的 PID 写进 cgroup.procs。否则子进程在「裸奔」的那几毫秒里是不受限的。

第三块拼图:文件系统靠 OverlayFS

如果所有容器都 chroot 到同一份 rootfs,会发生什么?容器 A 写了个文件,容器 B 能看到;容器 A 退出后,rootfs 被污染了,下一个容器拿到的是脏数据。

OverlayFS 解决了这个问题。它的思路是「写时复制」——三个目录叠成一个视图:

lowerdir(只读)  共享的基础镜像,所有容器共用,永不修改
upperdir(读写)  每个容器独占一层
workdir           OverlayFS 内部用
        ↓ 合并
merged(视图)    容器看到的 /

文件时先查 upperdir,没有再去 lowerdir 找;文件时只写 upperdir,lowerdir 永远不动;文件时在 upperdir 放一个 whiteout 标记遮住 lowerdir 的对应文件。

这就是 Docker 镜像分层的基础。你 docker pull 下来的每一层,本质上就是一个 lowerdir,多个层叠在一起构成完整文件系统。capsule-box 虽然没有镜像管理,但 OverlayFS 这一层把「多容器共享同一份基础 rootfs、各自独立可写」这件事做完了。

最难的一块:网络

到目前为止容器还是个「孤岛」——加了 Network namespace 后它只有一个 lo 回环接口,连不上外网,宿主机也连不上它。

要让容器能上网,得手动拉一根「网线」。具体来说:

1. 创建 veth pair——虚拟以太网线,两头各一张网卡。一头留在宿主机叫 veth0,另一头塞进容器的 network namespace 叫 eth0

2. 建 bridge——宿主机上建一个虚拟交换机 cb0,把所有 veth 的宿主端连上去。这样多个容器就在同一个二层网络里了。

3. 分配 IP——给每个容器的 eth0 和宿主端的 veth 各配一个 IP,配路由。

4. 配 NAT——在宿主机上写 iptables masquerade 规则,把容器发往外网的包的源地址改写成宿主机 IP。再开启 IP 转发(net.ipv4.ip_forward)。

5. 配 DNS——往容器的 /etc/resolv.conf 写入 DNS 服务器,否则域名解析不了。

这一整套流程,就是 Docker 默认 bridge 网络模式在做的事。平时 docker run 一敲,容器就能联网,背后其实是内核帮你做了这么多事。capsule-box 把这些步骤全部手写了一遍,是真·手动挡。

交互式终端:PTY 转发

cb run -it /bin/sh 256M 能给你一个交互式 shell,这背后是 PTY(伪终端)。

原理不复杂:daemon 给容器进程分配一个 PTY,把客户端的 stdin/stdout/stderr 通过 Unix Socket 转发到这个 PTY 上。你敲的每一个字符,从客户端 → Unix Socket → daemon → 容器的 PTY,容器的输出再原路返回。

听起来简单,但 PTY 的大小、信号转发(Ctrl+C 产生 SIGINT)、终端模式切换这些细节,全是坑。

生命周期:从 run 到 remove

一个容器从生到死,capsule-box 要维护它的完整状态:

cb run    创建 cgroup → 挂 OverlayFS → fork+unshare → 配网络 → exec
cb list   查看所有容器
cb stop   停止运行中的容器
cb remove 清理 OverlayFS → 删 cgroup → 删网卡 → 删 iptables → 删元数据

最难的不是「跑起来」,而是「干净地收拾干净」。卸载 OverlayFS 要 lazy umount(MNT_DETACH),删网卡要先找到它在哪个 namespace,清理 iptables 规则要精准匹配不能误删宿主机自己的规则。Docker 在生产环境里最让人头疼的「残留容器」「僵尸网络接口」,根源就是这些清理逻辑在各种异常路径下没跑全。

为什么用 Rust

系统编程,Rust 有几个天然优势:

和 C ABI 无缝对接forkunsharemountchroot 这些都是 libc 的系统调用,Rust 通过 nix crate 调用,类型安全且零开销。用 C 写容易出内存 bug,用 Go 写又有 runtime 和 GC 的包袱(fork 一个带 GC 的进程是出了名的危险)。

unsafe 边界清晰fork() 在多线程环境下极度危险——子进程继承了父进程的所有锁,但其他线程消失了,锁可能永远解不开。Rust 强制你用 unsafe 块标注这些操作,逼你在 fork 之后立刻 exec、不做任何复杂操作。这个约束本身就是对正确性的提醒。

async 友好。daemon 用 tokio 同时处理多个客户端连接和多个容器的生命周期,Rust 的 async 在系统编程场景里比手写 epoll 舒服太多。

我从这个项目里学到了什么

容器一点都不神秘。当你亲手 unshare 出一个 PID namespace、写一次 memory.max、挂一次 OverlayFS,Docker 那个庞大的生态就不再是一个黑盒了。你知道它每一层在干什么,出问题的时候知道去哪查。

Linux 的设计哲学很优雅。cgroup 是虚拟文件系统,namespace 是 fork 的一个 flag,网络是一堆可以手动配置的虚拟设备。没有什么是「魔法」,全是文件和系统调用。

清理比创建难十倍。「让一个容器跑起来」大概两百行代码,「让一个容器被干净彻底地删除」要处理无数边界情况。

教程是最好的学习方式。capsule-box 配了一套 9 篇、约 23 轮的教程,每一轮只引入一个新概念。写教程逼我把每个系统调用的「为什么」想清楚,这种理解的深度是只写代码达不到的。

试试看

如果你也想搞懂容器底层,强烈建议从 capsule-box 的 教程 开始,按顺序读到第 23 轮,你会获得一个自己写的、能跑的迷你容器运行时。

「你不能真正理解一个工具,直到你亲手实现过它。」

下一次敲 docker run 的时候,希望你知道那行命令背后,内核正在帮你做多少事。