avatar🌌
TangentTangent Blog

技术博客 | Go & Kubernetes & Linux

技术博客 | Go & Kubernetes & Linux

runc 源码解读(二):从 SPEC.md 看容器文件系统

前言 ​

在上一篇文章中,我们解读了 SPEC.md 中的命名空间部分,了解了容器如何通过 7 种 Namespace 实现资源隔离。本篇继续深入 SPEC.md 的 Filesystem 章节,看看容器的文件系统是如何从零搭建起来的。

文件系统的初始化是容器启动过程中最复杂的环节之一。它不仅要为容器提供一个独立的目录树,还要挂载各种虚拟文件系统、创建设备节点、配置终端和 I/O 重定向——每一步都有严格的顺序和安全要求。

Rootfs:容器的独立世界 ​

SPEC.md 开篇就明确了容器文件系统的第一原则:

A root filesystem must be provided to a container for execution. The container will use this root filesystem (rootfs) to jail and spawn processes inside where the binaries and system libraries are local to that directory. Any binaries to be executed must be contained within this rootfs.

容器必须拥有一个根文件系统(rootfs),所有要执行的程序和依赖的系统库都必须包含在其中。

操作系统背景:rootfs 通常来自容器镜像——当你执行 docker pull ubuntu 时,下载的就是一个精简的 Ubuntu 目录结构(/bin、/etc、/usr 等)。runc 通过 pivot_root 或 chroot 将容器进程"囚禁"在这个目录中,使其无法访问宿主机的文件系统。

SPEC.md 还指出了一个重要的自动清理机制:

Mounts that happen inside the container are automatically cleaned up when the container exits as the mount namespace is destroyed and the kernel will unmount all the mounts that were setup within that namespace.

容器内的所有挂载操作都在独立的 Mount Namespace 中进行。当容器退出、Namespace 被销毁时,内核会自动卸载其中的所有挂载点,不会在宿主机上留下任何残留。

虚拟文件系统挂载 ​

仅有静态文件的 rootfs 是不够的。Linux 程序需要与内核交互,而这种交互依赖于虚拟文件系统。SPEC.md 定义了容器启动时必须挂载的 6 个文件系统:

PathTypeFlagsData
/procprocMS_NOEXEC,MS_NOSUID,MS_NODEV
/devtmpfsMS_NOEXEC,MS_STRICTATIMEmode=755
/dev/shmtmpfsMS_NOEXEC,MS_NOSUID,MS_NODEVmode=1777,size=65536k
/dev/mqueuemqueueMS_NOEXEC,MS_NOSUID,MS_NODEV
/dev/ptsdevptsMS_NOEXEC,MS_NOSUIDnewinstance,ptmxmode=0666,mode=620,gid=5
/syssysfsMS_NOEXEC,MS_NOSUID,MS_NODEV,MS_RDONLY

各挂载点的作用 ​

  • /proc(procfs):进程信息和内核状态的窗口。没有它,ps、top 等命令无法工作。每个进程的状态都以 /proc/<pid>/ 目录的形式暴露
  • /dev(tmpfs):设备文件目录,挂载为基于内存的临时文件系统。容器启动时它是空的,后续由运行时填充设备节点
  • /dev/shm(tmpfs):POSIX 共享内存,用于进程间高速数据交换。size=65536k 限制了共享内存的上限为 64MB
  • /dev/mqueue(mqueue):POSIX 消息队列文件系统,提供进程间异步通信能力
  • /dev/pts(devpts):伪终端从设备目录。newinstance 确保容器拥有独立的 pts 命名空间,docker run -it 的交互式终端就依赖于此
  • /sys(sysfs):内核对象和硬件设备信息的导出接口。注意它被挂载为只读(MS_RDONLY),防止容器修改内核参数

挂载标志的安全含义 ​

这些 Flags 构成了容器文件系统的安全防线:

Flag含义防御目标
MS_NOEXEC禁止在该文件系统上执行二进制文件防止攻击者将恶意程序写入 /dev 或 /proc 并执行
MS_NOSUID忽略 SetUID/SetGID 标志位防止通过特殊权限文件进行提权攻击
MS_NODEV禁止访问或创建设备文件防止容器通过伪造设备节点访问宿主机硬件
MS_RDONLY只读挂载防止容器修改 /sys 下的内核参数
MS_STRICTATIME严格更新访问时间确保 /dev 下文件的访问时间记录准确

设备节点:容器的基础硬件抽象 ​

虚拟文件系统挂载完成后,/dev 目录还是空的。SPEC.md 要求运行时必须在其中创建一组基础设备节点:

PathModeAccess
/dev/null0666rwm
/dev/zero0666rwm
/dev/full0666rwm
/dev/tty0666rwm
/dev/random0666rwm
/dev/urandom0666rwm

It is expected that a rootfs does not need to have any device nodes specified for /dev within the rootfs as the container will setup the correct devices that are required for executing a container’s process.

镜像制作者不需要在 rootfs 中预置这些设备文件,运行时会自动创建。

六大设备的作用 ​

  • /dev/null:数据黑洞。写入的数据被丢弃,读取返回 EOF。command > /dev/null 是最常见的用法
  • /dev/zero:零字节源。读取时无限返回 \0,常用于初始化文件或创建空白块设备
  • /dev/full:永远满的设备。写入时返回 ENOSPC(No space left on device),用于测试程序的磁盘满容错
  • /dev/tty:当前进程的控制终端。程序可以通过它直接与用户交互
  • /dev/random 和 /dev/urandom:随机数生成器。加密操作、UUID 生成、TLS 握手等都依赖它们

权限设计 ​

  • Mode 0666(rw-rw-rw-):所有用户可读写。这是合理的,因为普通用户的程序也需要使用 /dev/null 或 /dev/urandom
  • Access rwm:这是 cgroup 设备控制器的权限——r(读)、w(写)、m(mknod,允许创建设备节点)。运行时在 cgroup 层面也为这些设备开放了通行证

I/O 与伪终端 ​

设备节点就位后,SPEC.md 转向了容器 I/O 的核心问题:终端和标准输入输出。

ptmx 与伪终端 ​

/dev/ptmx will need to be a symlink to the host’s /dev/ptmx within the container.

/dev/ptmx(Pseudo-Terminal Master Multiplexer)是伪终端的"工厂入口"。当程序打开它时,内核会分配一对 Master/Slave 伪终端:

  • Master 端:由宿主机(Docker 引擎)持有,连接你真实的键盘和屏幕
  • Slave 端:交给容器内的进程,表现得像一个真实的终端设备

容器本身没有独立的内核,因此 /dev/ptmx 必须链接到宿主机的对应文件,才能向内核申请新的伪终端。

/dev/console 的绑定挂载 ​

The use of a pseudo TTY is optional within a container and it should support both.

容器支持两种运行模式:

  • 后台模式(如 docker run -d nginx):不需要终端
  • 交互模式(如 docker run -it ubuntu):需要分配伪终端

当使用交互模式时,SPEC.md 要求通过 bind mount 将宿主机的 PTY Slave 端挂载到容器的 /dev/console:

SourceDestinationUID GIDModeType
pty host path/dev/console0 00600bind

权限 0600 意味着只有 root 可以读写控制台,防止容器内的普通用户窃听终端数据。

操作系统背景:这个过程就像给一台没有显示器的主机接上外部屏幕——宿主机通过 pty.Open() 向内核申请一对伪终端,拿到 Slave 端路径(如 /dev/pts/3),然后 runc 将其 bind mount 到容器的 /dev/console。容器内的 bash 往 /dev/console 写数据,数据通过 Slave → Master 传回宿主机,最终显示在你的屏幕上。

I/O 安全:dup2 防泄漏 ​

If the container’s io is pointing to /dev/null outside the container we close and dup2 the /dev/null that is local to the container’s rootfs.

如果容器的标准输入输出指向了宿主机的 /dev/null(例如 docker run ubuntu > /dev/null),runc 会检测到这个跨命名空间的文件描述符引用,关闭它,然后用 dup2 将其重定向到容器内部自己的 /dev/null。这确保了容器不会持有任何指向宿主机文件系统的句柄,维护了沙箱隔离的完整性。

标准 I/O 软链接 ​

After the container has /proc mounted a few standard symlinks are setup within /dev/ for the io.

SourceDestination
/proc/self/fd/dev/fd
/proc/self/fd/0/dev/stdin
/proc/self/fd/1/dev/stdout
/proc/self/fd/2/dev/stderr

这些软链接必须在 /proc 挂载之后创建,因为它们的目标路径位于 /proc 内部。

为什么需要手动创建? 在普通 Linux 系统中,udev 或 systemd 会在启动时自动创建这些链接。但容器的 /dev 是全新的 tmpfs,没有设备管理器运行,所以运行时必须手动建立。很多程序(如日志框架)会硬编码 /dev/stdout 路径来输出日志,如果缺少这些软链接,程序会直接报错 "No such file or directory"。

/proc/self/fd/1 的巧妙之处在于 self 是一个动态指针——哪个进程访问它,它就指向哪个进程的文件描述符表。这意味着不同进程写入 /dev/stdout 时,数据会各自流向正确的输出目标。

pivot_root:切换根目录 ​

文件系统准备就绪后,runc 使用 pivot_root 将容器进程的根目录切换到 rootfs:

c
put_old = mkdir(...);
pivot_root(rootfs, put_old);
chdir("/");
unmount(put_old, MS_DETACH);
rmdir(put_old);

这个过程的含义是:

  1. 在 rootfs 内创建一个临时目录 put_old
  2. pivot_root 将当前根目录切换为 rootfs,同时将旧的根目录挂载到 put_old
  3. chdir("/") 确保工作目录指向新的根
  4. 卸载并删除 put_old,彻底切断与宿主机文件系统的联系

操作系统背景:pivot_root 与 chroot 的区别在于,pivot_root 真正改变了整个 Mount Namespace 的根挂载点,而 chroot 只是改变了当前进程的根目录视图,安全性较低(可以通过多次 chroot 逃逸)。

对于运行在 ramfs 上的容器,pivot_root 不被支持,此时使用 MS_MOVE + chroot 作为替代方案:

c
mount(rootfs, "/", NULL, MS_MOVE, NULL);
chroot(".");
chdir("/");

最后,umask 被重置为 0022,恢复标准的文件创建权限掩码。

容器文件系统初始化的完整流程 ​

将以上所有步骤串联起来,runc 初始化容器文件系统的完整流程如下:

  1. 挂载虚拟文件系统:按照规范表格挂载 /proc、/dev、/dev/shm、/dev/mqueue、/dev/pts、/sys
  2. 创建设备节点:在空的 /dev 中创建 /dev/null、/dev/zero 等 6 个基础设备
  3. 配置伪终端:创建 /dev/ptmx 软链接,如果需要交互模式则 bind mount /dev/console
  4. I/O 安全检查:检测并修复跨命名空间的文件描述符引用(dup2)
  5. 创建 I/O 软链接:建立 /dev/stdin、/dev/stdout、/dev/stderr 等标准链接
  6. 切换根目录:通过 pivot_root 将进程囚禁在 rootfs 中
  7. 重置 umask:恢复为 0022

每一步都有严格的顺序依赖——/proc 必须先于 I/O 软链接,/dev 的 tmpfs 必须先于设备节点创建,所有挂载必须先于 pivot_root。

小结 ​

SPEC.md 的 Filesystem 章节定义了容器文件系统从"一无所有"到"完全可用"的全部过程。从挂载虚拟文件系统到创建设备节点,从配置伪终端到建立 I/O 软链接,再到最终的 pivot_root 切换——每一步都在安全性和功能性之间取得了精确的平衡。

在下一篇文章中,我们将继续解读 SPEC.md 中的 Resources(Cgroup 资源控制)部分。

runc 源码解读(三):从 SPEC.md 看容器资源控制
runc 源码解读(一):从 SPEC.md 看容器命名空间
由 Valaxy v1.2.2 驱动|主题-Yunv1.2.2