前言
runc 是 OCI(Open Container Initiative)标准的参考实现,也是 Docker、containerd、Kubernetes 等容器生态的底层运行时。当我们执行 docker run 时,最终负责创建和运行容器的正是 runc。
要理解 runc 的源码,最好的起点是它的 libcontainer/SPEC.md——这份文档定义了 v1 版本容器的标准配置,涵盖命名空间、文件系统、资源控制、安全能力等核心内容。本篇作为系列的第一篇,聚焦于其中最基础也最关键的部分:Namespaces(命名空间)。
Container Specification v1 概览
SPEC.md 开篇这样描述:
This is the standard configuration for version 1 containers. It includes namespaces, standard filesystem setup, a default Linux capability set, and information about resource reservations. It also has information about any populated environment settings for the processes running inside a container.
翻译过来就是:v1 容器规范包含了命名空间、标准文件系统配置、默认 Linux 能力集,以及资源预留信息。同时还定义了容器内进程的环境变量设置。
该规范还强调了系统要求:
- 最低内核版本:推荐 3.10,最低 2.6.2x(需要反向移植补丁)
- Cgroup 挂载:每个子系统需要在独立的层级中挂载
The v1 profile is meant to be able to accommodate the majority of applications with a strong security configuration.
v1 规范的设计目标是在保证强安全配置的前提下,兼容绝大多数应用场景。
命名空间:容器隔离的基石
SPEC.md 中关于命名空间的定义非常简洁:
| Flag | Enabled |
|---|---|
| CLONE_NEWPID | 1 |
| CLONE_NEWUTS | 1 |
| CLONE_NEWIPC | 1 |
| CLONE_NEWNET | 1 |
| CLONE_NEWNS | 1 |
| CLONE_NEWUSER | 1 |
| CLONE_NEWCGROUP | 1 |
Namespaces are created for the container via the
unsharesyscall.
短短两句话,却定义了容器隔离的全部核心。下面逐一解读这 7 种命名空间。
1. PID Namespace(CLONE_NEWPID)—— 进程隔离
PID Namespace 为容器提供独立的进程编号空间。容器内的第一个进程 PID 为 1,扮演 init 进程的角色。
操作系统背景:在 Linux 中,PID 1 是 init 进程(现代系统中通常是 systemd),负责收割孤儿进程。容器内的 PID 1 同样承担这个职责——如果容器的 1 号进程退出,整个容器就会终止。
宿主机视角: 容器内视角:
PID 1 (systemd) PID 1 (nginx)
PID 2 (kthreadd) PID 2 (nginx worker)
...
PID 28431 (nginx) <- 同一个进程,不同的 PID
PID 28432 (nginx)这意味着容器内的进程完全看不到宿主机上的其他进程,实现了进程级别的隔离。
2. UTS Namespace(CLONE_NEWUTS)—— 主机名隔离
UTS(Unix Time-Sharing)Namespace 隔离主机名和域名。容器可以拥有独立的 hostname,不影响宿主机。
操作系统背景:UTS 这个名字来源于 uname 系统调用返回的结构体 utsname。在容器场景中,独立的 hostname 让每个容器都能标识自己的身份,这在服务发现和日志收集中非常重要。Kubernetes 中 Pod 的 hostname 就是通过 UTS Namespace 实现的。
3. IPC Namespace(CLONE_NEWIPC)—— 进程间通信隔离
IPC Namespace 隔离 System V IPC 和 POSIX 消息队列。容器内的进程无法通过共享内存、信号量、消息队列等 IPC 机制与宿主机或其他容器的进程通信。
操作系统背景:Linux 的 IPC 机制包括信号量(semaphore)、消息队列(message queue)和共享内存(shared memory)。这些是进程间高效通信的手段,但如果不隔离,容器间就可能互相干扰甚至窃取数据。
4. Network Namespace(CLONE_NEWNET)—— 网络隔离
Network Namespace 为容器提供独立的网络栈,包括网络设备、IP 地址、端口、路由表、iptables 规则等。
操作系统背景:每个 Network Namespace 都拥有独立的:
- 网络接口(如
eth0、lo) - IP 地址和端口空间
- 路由表
- 防火墙规则(iptables/nftables)
/proc/net目录
这就是为什么多个容器可以同时监听 80 端口而互不冲突。Docker 的桥接网络(bridge)就是通过 veth pair 将容器的 Network Namespace 与宿主机连接起来的。
5. Mount Namespace(CLONE_NEWNS)—— 文件系统隔离
Mount Namespace 隔离文件系统挂载点,让容器拥有独立的目录树视图。
操作系统背景:这是 Linux 最早实现的 Namespace(2002 年,内核 2.4.19),因此它的 flag 名称是 CLONE_NEWNS(NS = Namespace)而不是更直观的 CLONE_NEWMOUNT。后来新增的 Namespace 才采用了更具描述性的命名。
Mount Namespace 是容器文件系统隔离的基础。配合 pivot_root 系统调用,容器可以将自己的 rootfs(通常来自容器镜像)设置为根目录,完全看不到宿主机的文件系统。SPEC.md 中描述的 pivot_root 流程如下:
put_old = mkdir(...);
pivot_root(rootfs, put_old);
chdir("/");
unmount(put_old, MS_DETACH);
rmdir(put_old);6. User Namespace(CLONE_NEWUSER)—— 用户隔离
User Namespace 隔离用户和用户组 ID。容器内的 root(UID 0)可以映射为宿主机上的普通用户。
操作系统背景:这是容器安全的重要防线。没有 User Namespace 时,容器内的 root 就是宿主机的 root,一旦容器逃逸,攻击者就拥有宿主机的最高权限。通过 User Namespace,容器内的 root 实际上只是宿主机上的一个无特权用户(例如 UID 65534),即使逃逸也无法造成严重破坏。
SPEC.md 中提到,父进程需要在初始化开始前写入 uid/gid 映射:
This allows the parent to have time to set the new process inside a cgroup hierarchy and/or write any uid/gid mappings required for user namespaces.
这个映射通过 /proc/<pid>/uid_map 和 /proc/<pid>/gid_map 文件完成。
7. Cgroup Namespace(CLONE_NEWCGROUP)—— 资源视图隔离
Cgroup Namespace 隔离 cgroup 的根目录视图,让容器只能看到分配给自己的资源限制。
操作系统背景:Cgroup(Control Groups)是 Linux 内核的资源限制机制,用于限制进程的 CPU、内存、IO 等资源使用。Cgroup Namespace 并不隔离资源本身(那是 cgroup 子系统的工作),而是隔离 cgroup 文件系统的视图。
没有 Cgroup Namespace 时,容器内可以通过 /proc/self/cgroup 看到自己在宿主机 cgroup 层级中的完整路径,这既泄露了宿主机信息,也可能被利用来越权修改资源分配。
unshare:创建命名空间的系统调用
SPEC.md 明确指出命名空间通过 unshare 系统调用创建。Linux 中操作命名空间有三个关键系统调用:
| 系统调用 | 作用 | 使用场景 |
|---|---|---|
clone | 创建新进程并将其放入新的命名空间 | 创建容器的 init 进程 |
unshare | 将当前进程脱离原有命名空间,放入新的命名空间 | runc 初始化阶段 |
setns | 将当前进程加入一个已存在的命名空间 | docker exec 进入运行中的容器 |
runc 选择 unshare 而非 clone 来创建命名空间,是因为 runc 的初始化进程需要先在当前进程上下文中完成一系列设置(如 cgroup 配置、uid/gid 映射),然后再将自己"脱离"到新的命名空间中。
值得注意的是,虽然调用的是 unshare,但参数使用的是 CLONE_* 前缀的标志位。这是历史原因——这些标志位最初是为 clone 系统调用设计的,unshare 后来复用了同一套定义。
命名空间与容器运行时的协作
理解了命名空间,我们可以串联起 runc 创建容器的核心流程:
- 父进程准备阶段:runc 创建一个管道(pipe)用于父子进程同步,管道通过 FD 3 传递给 init 进程
- 创建命名空间:通过
unshare系统调用,传入 7 个CLONE_*标志位,创建全部命名空间 - 父进程配置:在 init 进程阻塞等待管道的期间,父进程将 init 进程放入正确的 cgroup 层级,写入 uid/gid 映射
- init 进程启动:父进程关闭管道,init 进程继续执行,完成文件系统挂载、
pivot_root等操作 - 执行用户进程:最终
exec用户指定的应用程序
这个流程中,命名空间的创建是第一步也是最关键的一步——它为后续所有的隔离配置提供了基础环境。
小结
runc 的 SPEC.md 用一张简洁的表格和一句话,定义了容器隔离的全部 7 种命名空间。这 7 种命名空间分别隔离了进程、主机名、进程间通信、网络、文件系统、用户和资源视图,共同构成了我们所说的"容器"。
理解这些命名空间是深入 runc 源码的基础。在后续的文章中,我们将继续解读 SPEC.md 中的文件系统配置、Cgroup 资源控制、安全能力集等内容,逐步揭开容器运行时的完整面貌。