avatar🌌
TangentTangent Blog

技术博客 | Go & Kubernetes & Linux

技术博客 | Go & Kubernetes & Linux

runc 源码解读(一):从 SPEC.md 看容器命名空间

前言 ​

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 中关于命名空间的定义非常简洁:

FlagEnabled
CLONE_NEWPID1
CLONE_NEWUTS1
CLONE_NEWIPC1
CLONE_NEWNET1
CLONE_NEWNS1
CLONE_NEWUSER1
CLONE_NEWCGROUP1

Namespaces are created for the container via the unshare syscall.

短短两句话,却定义了容器隔离的全部核心。下面逐一解读这 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 流程如下:

c
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 创建容器的核心流程:

  1. 父进程准备阶段:runc 创建一个管道(pipe)用于父子进程同步,管道通过 FD 3 传递给 init 进程
  2. 创建命名空间:通过 unshare 系统调用,传入 7 个 CLONE_* 标志位,创建全部命名空间
  3. 父进程配置:在 init 进程阻塞等待管道的期间,父进程将 init 进程放入正确的 cgroup 层级,写入 uid/gid 映射
  4. init 进程启动:父进程关闭管道,init 进程继续执行,完成文件系统挂载、pivot_root 等操作
  5. 执行用户进程:最终 exec 用户指定的应用程序

这个流程中,命名空间的创建是第一步也是最关键的一步——它为后续所有的隔离配置提供了基础环境。

小结 ​

runc 的 SPEC.md 用一张简洁的表格和一句话,定义了容器隔离的全部 7 种命名空间。这 7 种命名空间分别隔离了进程、主机名、进程间通信、网络、文件系统、用户和资源视图,共同构成了我们所说的"容器"。

理解这些命名空间是深入 runc 源码的基础。在后续的文章中,我们将继续解读 SPEC.md 中的文件系统配置、Cgroup 资源控制、安全能力集等内容,逐步揭开容器运行时的完整面貌。

Reading runc Source Code (Part 1): Container Namespaces in SPEC.md
Linux unshare Command Explained: From Namespaces to DIY Containers
由 Valaxy v1.2.2 驱动|主题-Yunv1.2.2