mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
4736 字
13 分钟
runc 源码分析
2021-09-11

当你执行 runc run mycontainer 时,runc 做了什么?它读取 config.json,创建 Namespace,配置 Cgroup,挂载 OverlayFS,设置 seccomp 规则,最终执行容器进程。这个过程涉及几十个系统调用和数百行内核交互代码。

runc 是 OCI Runtime Spec 的参考实现,也是 Docker 和 Kubernetes 默认使用的低层容器运行时。它的主库 libcontainer 封装了所有与 Linux 内核的交互逻辑,从 clone() 创建 Namespace 到 mount() 挂载文件系统,从写入 Cgroup 文件到加载 seccomp BPF 程序。

runc 的前身是 libcontainer。2013 年,Docker 的创始人 Solomon Hykes 意识到 LXC(Linux Containers)的 API 不够稳定,决定用 Go 语言重写容器运行时,这就是 libcontainer。libcontainer 直接调用 Linux 系统调用(clone()mount()pivot_root() 等),不再依赖 LXC。2015 年 6 月,Docker 将 libcontainer 捐赠给新成立的 OCI(Open Container Initiative,隶属于 Linux 基金会),并重命名为 runc。runc 成为 OCI Runtime Spec 的参考实现,它定义了”一个符合 OCI 规范的运行时应该怎么写”。此后,runc 的代码路径成为所有容器运行时的”参考答案”:gVisor、Kata Containers、甚至 containerd 的容器创建逻辑,都遵循 runc 建立的模式。读 runc 源码,就是读容器运行时的”底层语法”。

前置知识#

Important
  • Ch05 OCI 规范详解:runc 是 OCI Runtime Spec 的参考实现,理解 Runtime Spec 是阅读 runc 源码的前提

  • Go 语言基础:runc 用 Go 编写,需要理解 goroutine、interface、error handling 等 Go 特性

  • Linux 系统编程:clone()mount()pivot_root()seccomp() 等系统调用

Note

runc 的核心逻辑集中在 libcontainer/ 目录,代码风格清晰,会逐行解读关键路径。

下面追踪 runc 容器创建的完整代码路径。

一、runc 项目结构#

1.1 目录结构#

runc

main.goCLI 入口

create.gorunc create 命令

start.gorunc start 命令

run.gorunc run 命令

kill.gorunc kill 命令

delete.gorunc delete 命令

spec.gorunc spec 命令

libcontainer核心库

container.goContainer 接口

process.goProcess 定义

factory.go容器工厂

linux

container.goLinux 容器实现

process.goLinux 进程实现

init.go容器初始化

standard_init.go标准初始化

nsenterNamespace 进入

configs

config.go容器配置

mount.go挂载配置

ns.goNamespace 配置

cgroupsCgroup 管理

seccompseccomp 配置

apparmorAppArmor 配置

system系统调用封装

user用户命名空间

contrib辅助工具

tests测试

1.2 核心依赖关系#

graph TB subgraph CLI层["CLI 层"] MAIN["main.go"] RUN["run.go"] CREATE["create.go"] START["start.go"] end subgraph 核心库["libcontainer"] FACTORY["Factory"] CONTAINER["Container"] PROCESS["Process"] INIT["Init (runc init)"] end subgraph 内核交互["内核交互层"] NS["Namespace<br/>clone/unshare/setns"] CG["Cgroup<br/>文件系统写入"] MNT["Mount<br/>pivot_root/mount"] SEC["Security<br/>seccomp/AppArmor/Cap"] end MAIN --> RUN MAIN --> CREATE MAIN --> START RUN --> FACTORY CREATE --> FACTORY START --> CONTAINER FACTORY --> CONTAINER CONTAINER --> PROCESS CONTAINER --> INIT INIT --> NS INIT --> CG INIT --> MNT INIT --> SEC style CLI层 fill:#bbdefb,stroke:#1565c0 style 核心库 fill:#c8e6c9,stroke:#2e7d32 style 内核交互层 fill:#fff3e0,stroke:#e65100

二、容器创建流程#

runc 的 CLI 命令对应容器生命周期的不同阶段,每个命令触发不同的代码路径:

命令容器状态代码入口主要操作
runc create不存在 → Createdcreate.go → Factory.Create()验证配置、创建状态目录、写入 config.json、构造容器对象(此时不创建 Namespace、不配 Cgroup、不挂载 rootfs)
runc startCreated → Runningstart.go → Container.Execute()执行 runc init,运行用户进程
runc run不存在 → Runningrun.go → Factory.Create() + Container.Run()create + start 的组合,一步到位
runc execRunningexec.go → Container.Exec()在已有容器中执行新进程(进入 Namespace)
runc killRunning → Stoppedkill.go → Container.Signal()向容器 init 进程发送信号
runc deleteStopped → 不存在delete.go → Container.Destroy()清理 Cgroup、卸载文件系统、删除状态文件

2.1 runc run 的代码路径#

runc run 是最常用的命令,它将容器的创建和启动合并为一步。跟踪它的代码路径,可以看清 runc 的整体架构:先通过 Factory 创建容器对象,再通过 Container.Run 启动容器进程。

// run.go(简化)
func runAction(ctx *cli.Context) error {
// 1. 解析命令行参数
spec, err := setupSpec(ctx)
// 2. 创建容器工厂
factory, err := libcontainer.New(
libcontainer.RootPath(rootfs),
)
// 3. 创建容器
container, err := factory.Create(ctx.String("id"), spec.Config)
// 4. 启动容器
process := &libcontainer.Process{
Args: spec.Process.Args,
Env: spec.Process.Env,
User: spec.Process.User.UID,
Stdin: os.Stdin,
Stdout: os.Stdout,
Stderr: os.Stderr,
}
err = container.Run(process)
// 5. 等待容器退出
status, err := process.Wait()
return nil
}

这段代码展示了 runc run 的顶层流程,但有两个设计决策值得展开:为什么 Factory.Create 只创建对象而不创建 Namespace?为什么 Container.Run 要通过 /proc/self/exe init 来启动子进程?

2.2 Factory.Create:创建容器#

Factory.Create 的职责是”准备容器运行所需的全部状态”,但并不执行任何 Namespace 创建或 Cgroup 配置。这是一个有意的设计选择:将”定义容器”和”运行容器”解耦。runc create 命令调用 Factory.Create 后容器进入 Created 状态,此时 Namespace 尚未创建,Cgroup 尚未写入,容器只是一个磁盘上的状态目录加一个 Go 结构体。这种分离使得容器可以被预先创建、检查、再启动,也使得 runc create + runc start 的两步操作成为可能。

// libcontainer/factory.go(简化)
func (f *LinuxFactory) Create(id string, config *configs.Config) (Container, error) {
// 1. 验证配置
if err := validateConfig(config); err != nil {
return nil, err
}
// 2. 创建容器状态目录
containerRoot := filepath.Join(f.Root, id)
if err := os.MkdirAll(containerRoot, 0711); err != nil {
return nil, err
}
// 3. 保存配置到状态目录
if err := writeConfig(containerRoot, config); err != nil {
return nil, err
}
// 4. 创建容器对象
c := &linuxContainer{
id: id,
root: containerRoot,
config: config,
initProcess: nil,
initStartTime: time.Now().UTC(),
}
return c, nil
}

可以看到,Factory.Create 的全部操作都是文件系统和内存操作:验证配置、创建目录、写入配置文件、构造 linuxContainer 结构体。没有 clone()、没有 mount()、没有任何内核交互。真正的 Namespace 创建和 Cgroup 配置延迟到 Container.Run 中执行。这样做的好处是:创建阶段出错时回滚成本低,只需要删除目录即可;而如果在创建阶段就 clone() 了 Namespace,回滚就需要清理 Namespace、Cgroup 等内核状态,复杂度大大增加。

2.3 Container.Run:启动容器#

Container.Run 的核心在于 start() 方法,它做了一件看似奇怪的事:用 exec.Command("/proc/self/exe", "init") 启动子进程,而不是直接 clone() 后执行用户命令。/proc/self/exe 是当前进程(runc 自身)的可执行文件路径,所以这条命令的本质是让 runc 重新执行自身,参数为 init。runc 的 main.go 检测到第一个参数是 init 时,会进入 runc init 的代码路径,而不是 CLI 命令路径。

为什么要这样做?因为 clone() 创建的子进程继承了父进程的内存和文件描述符,但不继承 Go runtime 的 goroutine 调度状态。如果子进程直接执行用户命令,Go runtime 的状态不一致会导致不可预测的行为。通过 /proc/self/exe init,子进程从一个干净的 Go runtime 状态开始,再执行初始化逻辑,避免了这个问题。

// libcontainer/linux/container.go(简化)
func (c *linuxContainer) Run(process *Process) error {
// 1. 如果容器未启动,先创建
if c.initProcess == nil {
if err := c.start(process); err != nil {
return err
}
}
// 2. 执行用户进程
return process.Start()
}
func (c *linuxContainer) start(process *Process) error {
// 1. 创建父进程管道(用于与子进程通信)
parentPipe, childPipe, err := newPipe()
// 2. 准备命令(runc init 是容器初始化进程)
cmd := exec.Command("/proc/self/exe", "init")
cmd.Stdin = process.Stdin
cmd.Stdout = process.Stdout
cmd.Stderr = process.Stderr
// 3. 设置 Namespace clone 标志
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: c.getCloneFlags(),
}
// 4. 启动子进程(runc init)
if err := cmd.Start(); err != nil {
return err
}
// 5. 通过管道发送配置给子进程
if err := writeConfig(childPipe, c.config); err != nil {
return err
}
// 6. 等待子进程初始化完成
if err := readError(parentPipe); err != nil {
return err
}
c.initProcess = process
return nil
}

这段代码展示了一个完整的父子进程通信协议,runc 使用管道(pipe)而非命令行参数或环境变量来传递配置,原因有三:

  1. 配置量大:容器的完整配置包括 Namespace 列表、Cgroup 参数、挂载点、Capabilities、seccomp 规则等,数据量远超命令行参数的长度限制(ARG_MAX 通常 2MB),也远超环境变量的合理范围。
  2. 安全性:通过管道传递配置,配置数据不会出现在 /proc/<pid>/cmdline/proc/<pid>/environ 中,避免敏感信息泄露。
  3. 同步控制:管道天然提供同步机制。父进程写入配置后调用 readError(parentPipe) 阻塞等待,子进程读取配置并完成初始化后通过管道通知父进程。这种”写配置,等确认”的握手模式确保父进程在子进程就绪前不会继续执行。

通信流程如下:父进程创建管道对(parentPipe/childPipe),将 childPipe 作为子进程的额外文件描述符传入;子进程启动后从 childPipe 读取配置,执行初始化,完成后向 parentPipe 写入确认或错误;父进程从 parentPipe 读取结果,确认子进程初始化成功或获取错误信息。

2.4 getCloneFlags:Namespace 配置#

start() 方法通过 cmd.SysProcAttr.Cloneflags 将 Namespace 创建标志传给 clone() 系统调用。getCloneFlags 负责将 config.json 中的 Namespace 列表转换为 clone() 能识别的位掩码。

func (c *linuxContainer) getCloneFlags() uintptr {
var flag uintptr
for _, ns := range c.config.Namespaces {
switch ns.Type {
case configs.NEWPID:
flag |= syscall.CLONE_NEWPID
case configs.NEWNS:
flag |= syscall.CLONE_NEWNS
case configs.NEWUTS:
flag |= syscall.CLONE_NEWUTS
case configs.NEWIPC:
flag |= syscall.CLONE_NEWIPC
case configs.NEWNET:
flag |= syscall.CLONE_NEWNET
case configs.NEWUSER:
flag |= syscall.CLONE_NEWUSER
case configs.NEWCGROUP:
flag |= syscall.CLONE_NEWCGROUP
}
}
return flag
}

getCloneFlags 将配置中的 Namespace 列表转换为 clone() 系统调用的位掩码。每个 CLONE_NEW* 常量对应一种 Namespace 类型,多个标志通过按位或(|)组合,一次 clone() 调用即可同时创建多种 Namespace。例如 CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET 表示同时创建 PID、Mount 和 Network 三种 Namespace。

注意 CLONE_NEWUSER 的处理顺序:如果配置中包含 User Namespace,runc 会确保它最先被设置(在 Init() 的第一步 setupUser() 中处理),因为 User Namespace 影响后续所有操作的 Capability 集合。getCloneFlags 只是收集标志位,真正的顺序控制发生在 runc init 的初始化流程中。

三、runc init:容器初始化#

3.1 runc init 的角色#

runc init 是容器进程的第一个进程,它在新的 Namespace 中运行,负责完成所有容器初始化工作,然后执行用户命令。

sequenceDiagram participant Parent as runc (父进程) participant Child as runc init (子进程) participant Kernel as Linux 内核 Parent->>Kernel: clone(CLONE_NEWPID|CLONE_NEWNS|...) Kernel->>Child: 在新 Namespace 中启动 Note over Child: 此时还在宿主 rootfs Parent->>Child: 通过管道发送配置 Child->>Child: 1. 设置 User Namespace UID 映射 Child->>Child: 2. 设置 Cgroup Child->>Child: 3. 配置 mount namespace Child->>Child: 4. 挂载 proc/sys/dev Child->>Child: 5. pivot_root 切换根文件系统 Child->>Child: 6. 设置 hostname Child->>Child: 7. 配置网络 Child->>Child: 8. 设置 seccomp Child->>Child: 9. 设置 AppArmor Child->>Child: 10. 设置 Capabilities Child->>Parent: 通过管道报告初始化完成 Parent->>Child: 发送执行用户命令的信号 Child->>Child: exec 用户命令

3.2 standard_init_linux.go:核心初始化#

runc init 的入口函数是 linuxStandardInit.Init(),它按固定顺序执行所有初始化步骤。这个顺序不是随意排列的,每一步都依赖前一步建立的执行环境。

// libcontainer/linux/standard_init_linux.go(简化)
func (l *linuxStandardInit) Init() error {
// 1. 设置 User Namespace UID/GID 映射
if err := l.setupUser(); err != nil {
return err
}
// 2. 配置 Cgroup
if err := l.setupCgroup(); err != nil {
return err
}
// 3. 准备 rootfs
if err := l.prepareRootfs(); err != nil {
return err
}
// 4. 设置 hostname
if l.config.Hostname != "" {
if err := syscall.Sethostname([]byte(l.config.Hostname)); err != nil {
return err
}
}
// 5. 设置网络
if err := l.setupNetwork(); err != nil {
return err
}
// 6. 设置路由
if err := l.setupRoute(); err != nil {
return err
}
// 7. 设置 seccomp
if l.config.Seccomp != nil {
if err := seccomp.InitSeccomp(l.config.Seccomp); err != nil {
return err
}
}
// 8. 设置 AppArmor
if l.config.AppArmorProfile != "" {
if err := apparmor.ApplyProfile(l.config.AppArmorProfile); err != nil {
return err
}
}
// 9. 设置 Capabilities
if err := l.setupCapabilities(); err != nil {
return err
}
// 10. 切换到用户身份
if err := syscall.Setuid(l.config.UID); err != nil {
return err
}
// 11. 执行用户命令
return syscall.Exec(l.config.Args[0], l.config.Args, l.config.Env)
}

3.3 prepareRootfs:挂载文件系统#

文件系统隔离是容器与宿主环境分离的关键一步。prepareRootfs 先挂载配置中的所有挂载点(proc、sys、dev 等),再通过 pivot_root 将容器的根目录切换到 rootfs,最后卸载旧的根文件系统,完成文件系统视角的隔离。

func (l *linuxStandardInit) prepareRootfs() error {
// 1. 挂载所有配置的挂载点
for _, m := range l.config.Mounts {
if err := mount(m.Source, m.Destination, m.Type, m.Flags, m.Data); err != nil {
return err
}
}
// 2. pivot_root 切换根文件系统
if err := pivotRoot(l.config.Rootfs); err != nil {
return err
}
// 3. 重新挂载 /proc, /sys 等
if err := remountProc(); err != nil {
return err
}
return nil
}
func pivotRoot(rootfs string) error {
// 1. 绑定挂载 rootfs 到自身(确保是挂载点)
if err := syscall.Mount(rootfs, rootfs, "", syscall.MS_BIND, ""); err != nil {
return err
}
// 2. 创建 pivot_root 的 put_old 目录
putOld := filepath.Join(rootfs, ".pivot_root")
if err := os.MkdirAll(putOld, 0755); err != nil {
return err
}
// 3. 执行 pivot_root 系统调用
if err := syscall.PivotRoot(rootfs, putOld); err != nil {
return err
}
// 4. 修正工作目录
if err := syscall.Chdir("/"); err != nil {
return err
}
// 5. 卸载旧的根文件系统
if err := syscall.Unmount(".pivot_root", syscall.MNT_DETACH); err != nil {
return err
}
return os.Remove(".pivot_root")
}

四、runc init 初始化顺序与依赖#

runc init 进程的初始化按严格顺序执行,每一步都依赖前一步的完成:

步骤操作内核接口失败影响
1设置 Namespaceclone(CLONE_NEWNS|CLONE_NEWPID|…)容器无法隔离
2配置 Cgroup写入 /sys/fs/cgroup/…资源限制不生效
3挂载 rootfsmount(), pivot_root()容器无法访问文件系统
4设置 hostnamesethostname()容器内主机名不正确
5配置网络netlink, veth pair容器网络不可用
6设置 seccompprctl(PR_SET_SECCOMP)系统调用过滤不生效
7设置 AppArmor/proc/self/attr/apparmor/安全策略不生效
8设置 capabilitiescapset()权限控制不正确
9执行用户命令execve()容器进程无法启动

runc init 的各初始化步骤存在严格的先后依赖,顺序错误会导致容器创建失败:

flowchart TB START["runc init 启动"] --> USER["1. setupUser<br/>User Namespace UID/GID 映射"] USER --> CG["2. setupCgroup<br/>创建 cgroup 目录 + 写入限制"] CG --> ROOTFS["3. prepareRootfs<br/>挂载 proc/sys/dev + pivot_root"] ROOTFS --> HOSTNAME["4. sethostname"] HOSTNAME --> NET["5. setupNetwork<br/>配置网络设备"] NET --> ROUTE["6. setupRoute<br/>设置路由"] ROUTE --> SECCOMP["7. InitSeccomp<br/>加载 BPF 过滤规则"] SECCOMP --> APPARMOR["8. ApplyProfile<br/>设置 AppArmor"] APPARMOR --> CAP["9. setupCapabilities<br/>设置 Capabilities"] CAP --> UID["10. setuid/setgid<br/>切换到非 root 用户"] UID --> EXEC["11. execve<br/>执行用户命令"] USER -.->|"必须在其他 NS 操作之前<br/>因为 User NS 影响 Capability"| CAP ROOTFS -.->|"必须在 Cgroup 之后<br/>因为 pivot_root 后路径改变"| CG SECCOMP -.->|"必须在 execve 之前<br/>否则无法过滤用户进程"| EXEC style START fill:#bbdefb,stroke:#1565c0 style EXEC fill:#c8e6c9,stroke:#2e7d32
Warning

runc init 的初始化顺序不可随意调整。User Namespace 必须最先设置,因为它决定了后续操作的 Capability 集合;seccomp 必须在 execve 之前加载,否则用户进程不受过滤;pivot_root 必须在 mount 操作之后,否则挂载点路径不正确。修改 runc 源码时务必遵守这些依赖关系。

五、Cgroup 管理#

5.1 runc 的 Cgroup 实现#

Cgroup v2 统一了 v1 的多层级结构,所有控制器挂载在 /sys/fs/cgroup/ 这一个层级下。runc 对 Cgroup v2 的操作方式是纯文件写入:创建子目录(对应一个 Cgroup)、向 cgroup.procs 写入 PID 将进程加入 Cgroup、向各控制器文件写入配置值。这是因为 Cgroup v2 的内核接口就是基于 cgroup 文件系统(cgroupfs)的,每个控制器暴露为目录下的文件,写入文件等于配置内核参数,不需要额外的系统调用。与 Cgroup v1 相比,v1 的每个控制器有独立的层级(/sys/fs/cgroup/cpu//sys/fs/cgroup/memory/ 等),进程可以出现在不同的 Cgroup 层级中,导致资源控制不一致。v2 将所有控制器统一到同一层级,一个进程只属于一个 Cgroup,所有控制器对同一组进程生效,语义更清晰,管理更简单。

// libcontainer/cgroups/fs2/manager.go(简化)
type Manager struct {
controllers map[string]controller
path string
}
// 所有控制器写入都是同一个模式:拼路径、写字节、0644
func writeControl(path, name, value string) error {
return os.WriteFile(filepath.Join(path, name), []byte(value), 0644)
}
func (m *Manager) Apply(pid int) error {
// 1. 创建 cgroup 目录
if err := os.MkdirAll(m.path, 0755); err != nil {
return err
}
// 2. 将进程加入 cgroup
if err := writeControl(m.path, "cgroup.procs", strconv.Itoa(pid)); err != nil {
return err
}
// 3. 分发给各控制器,各自写自己的配置文件
for _, c := range m.controllers {
if err := c.Apply(m.path); err != nil {
return err
}
}
return nil
}
// CPU 控制器:写 cpu.max(配额 周期)和 cpu.weight
type cpuController struct{}
func (c *cpuController) Apply(path string) error {
if err := writeControl(path, "cpu.max", "200000 100000"); err != nil {
return err
}
return writeControl(path, "cpu.weight", "100")
}
// 内存控制器:写 memory.max
type memoryController struct{}
func (c *memoryController) Apply(path string) error {
return writeControl(path, "memory.max", "536870912")
}

代码中出现了两个需要解释的值。

cpu.max 的值 200000 100000 由两个字段组成:CPU 配额(quota)和 CPU 周期(period),单位都是微秒。200000 100000 表示在每个 100ms(100000us)的周期内,该 Cgroup 中的进程最多可以使用 200ms(200000us)的 CPU 时间,相当于 2 个 CPU 核心的限额。如果只想限制为 1 核,则写 100000 100000;如果想无限制,写 max 100000。Cgroup v2 的 CPU 控制器用这种 quota/period 模型替代了 v1 的 cpu.cfs_quota_uscpu.cfs_period_us 两个独立文件,合二为一更直观。

memory.max 的值 536870912 是 512MB 换算成字节的结果(512 * 1024 * 1024 = 536870912)。代码中写死这个值是因为这是简化后的示例代码,实际 runc 从 config.json 的 linux.resources.memory.limit 字段读取配置值,由 Docker 或 Kubernetes 在生成 config.json 时写入。Docker 默认不限制内存,Kubernetes 则根据 Pod 的 resources.limits.memory 计算出字节数。memory.max 在 Cgroup v2 中对应 v1 的 memory.limit_in_bytes,当进程内存使用超过这个值时,内核会触发 OOM Killer 或暂停进程(取决于 memory.oom_group 的配置)。

Note

Cgroup v1 和 v2 的文件路径差异是 runc 维护两套 Cgroup 驱动(cgroups/fs/cgroups/fs2/)的原因。v1 的 CPU 限制写在 /sys/fs/cgroup/cpu/<cgroup>/cpu.cfs_quota_us,v2 写在 /sys/fs/cgroup/<cgroup>/cpu.max。runc 根据系统挂载的 Cgroup 文件系统类型自动选择驱动,用户无需关心底层差异。

六、安全配置#

6.1 Capabilities 设置#

Linux Capabilities 将传统的 root 全能权限拆分为细粒度的能力单元,每个 Capability 控制一类特权操作。例如 CAP_NET_BIND_SERVICE 允许绑定 1024 以下端口,CAP_SYS_ADMIN 控制挂载文件系统等管理操作。每个进程有四组 Capability 集合,理解它们的关系是理解 runc Capabilities 配置的关键:

集合含义作用
Bounding能力上限进程能获得的 Capability 的上限集合,无论通过什么方式都无法获得 Bounding 集合之外的 Capability
Permitted允许持有进程当前可以切换到 Effective 集合的 Capability 池,但不能直接使用,需要先提升到 Effective
Effective实际生效进程当前实际拥有的 Capability,内核在执行权限检查时看的就是这个集合
Inheritable可继承通过 execve() 执行新程序时可以继承的 Capability,普通文件是否保留 Inheritable Capability 取决于文件的 Ambient 集合

这四组集合的约束规则是:Effective 必须是 Permitted 的子集,Permitted 必须是 Bounding 的子集。Bounding 集合是”天花板”,一旦降低就无法恢复。

func (l *linuxStandardInit) setupCapabilities() error {
// 1. 清除所有 Capabilities
if err := cap.Reset(); err != nil {
return err
}
// 2. 只添加 config.json 中指定的 Capabilities
for _, capName := range l.config.Capabilities.Bounding {
if err := cap.Set(capName); err != nil {
return err
}
}
return nil
}

cap.Reset() 先将所有四组集合清空,然后遍历 config.json 中指定的 Bounding 列表逐一 cap.Set()。这种”先清空再添加”的模式是最小权限原则(Principle of Least Privilege)的直接体现:默认没有任何 Capability,只显式授予容器运行所需的权限。

为什么从 Bounding 集合读取而不是 Effective 集合?因为 OCI 规范的 process.capabilities 字段区分了 boundingeffectivepermittedinheritable 四个子字段,runc 的 cap.Set() 实际上会同时设置 Bounding、Permitted 和 Effective 三个集合。先 Reset 清空所有集合,再 Set 指定的 Capability,确保了 Effective 集合不会超出 Bounding 集合的范围。如果容器只需要 CAP_NET_BIND_SERVICECAP_CHOWN,那么最终进程只拥有这两个 Capability,即使容器内进程尝试提权也无法获得其他 Capability,因为 Bounding 集合已经限制了上限。

Warning

Bounding 集合的一个关键特性是”只降不升”:一旦从 Bounding 集合中移除某个 Capability,即使进程拥有 CAP_SETCAP 也无法将其加回来。这就是为什么 runc 先 Reset() 清空所有集合(此时 Bounding 集合为空),再逐一 Set() 将需要的 Capability 加回:cap.Set() 在设置 Effective 和 Permitted 的同时也会将对应 Capability 加入 Bounding 集合,但只能加入当前 Bounding 集合中已有的或新的 Capability。这个”先降后升”的顺序保证了 Bounding 集合是精确的,不多不少。

6.2 seccomp 配置#

seccomp(Secure Computing Mode)是 Linux 内核提供的系统调用过滤机制。它允许进程定义一组 BPF(Berkeley Packet Filter)规则,在每次系统调用发生时进行过滤,决定允许、拒绝或通知该调用。runc 使用 seccomp 来限制容器内进程可以调用的系统调用,减少攻击面。

func InitSeccomp(config *configs.Seccomp) error {
// 1. 构建 BPF 程序
filter, err := buildSeccompFilter(config)
if err != nil {
return err
}
// 2. 加载 BPF 程序
prog := &syscall.SockFprog{
Len: uint16(len(filter)),
Filter: &filter[0],
}
// 3. 应用 seccomp 过滤
if _, _, err := syscall.Syscall(
syscall.SYS_PRCTL,
syscall.PR_SET_SECCOMP,
syscall.SECCOMP_MODE_FILTER,
uintptr(unsafe.Pointer(prog)),
); err != 0 {
return err
}
return nil
}

InitSeccomp 的流程分为三步:构建 BPF 过滤程序、封装为 SockFprog 结构、通过 prctl(PR_SET_SECCOMP) 加载到内核。其中 buildSeccompFilter 是核心,它将 config.json 中的 seccomp 配置转换为 BPF 指令数组。BPF 规则的构建逻辑如下:

  1. 确定默认动作:config.json 的 defaultAction 字段指定了对未匹配系统调用的默认处理方式。SCMP_ACT_ERRNO 表示拒绝并返回错误码,SCMP_ACT_ALLOW 表示允许,SCMP_ACT_KILL 表示直接终止进程。Docker 的默认配置使用 SCMP_ACT_ERRNO,即白名单模式:默认拒绝,只放行明确允许的系统调用。

  2. 为每个系统调用生成规则:遍历 syscalls 列表,对每条规则生成一组 BPF 指令。每条规则包含系统调用号(如 read 对应 0,write 对应 1)和动作(允许或拒绝)。BPF 程序在运行时检查当前系统调用的编号,与规则中的编号逐一比较,匹配则返回对应动作。

  3. 处理参数条件过滤:规则可以指定参数条件,例如”允许 openat 但参数 flags 不能包含 O_WRONLY”。BPF 程序通过检查 seccomp_data 结构中的参数字段(args[0]args[5])来实现条件过滤。每个参数条件由操作符(等于、不等于、位掩码匹配等)和值组成,BPF 指令使用按位与和比较操作来评估条件。

  4. 组装 BPF 程序:将所有规则指令按顺序排列,末尾添加默认动作。内核按顺序执行 BPF 指令,遇到匹配的规则就返回对应动作,否则继续执行下一条,最终到达默认动作。这个顺序很重要:特定规则在前,默认动作在后,类似于防火墙规则的匹配逻辑。

flowchart TB CONFIG["config.json<br/>seccomp 配置"] --> PARSE["解析 defaultAction<br/>+ syscalls 列表"] PARSE --> DEFAULT["生成默认动作 BPF 指令<br/>SCMP_ACT_ERRNO / SCMP_ACT_ALLOW"] PARSE --> RULES["遍历 syscalls 列表<br/>为每条规则生成 BPF 指令"] RULES --> ARGS["处理参数条件<br/>检查 seccomp_data.args"] ARGS --> ASSEMBLE["组装 BPF 程序<br/>规则指令 + 默认动作"] DEFAULT --> ASSEMBLE ASSEMBLE --> LOAD["prctl PR_SET_SECCOMP<br/>加载到内核"] style CONFIG fill:#bbdefb,stroke:#1565c0 style LOAD fill:#c8e6c9,stroke:#2e7d32
Tip

seccomp BPF 程序在 prctl(PR_SET_SECCOMP) 加载后不可修改、不可移除,这是内核的安全保证。即使容器内进程被攻破,攻击者也无法关闭 seccomp 过滤。这也意味着 seccomp 规则必须在 execve() 之前加载,否则用户进程不受过滤约束。

七、runc 与 containerd 的交互#

7.1 containerd 如何调用 runc#

containerd 通过 containerd-shim 调用 runc,shim 是 runc 的直接调用者:

// containerd-shim 调用 runc 的方式
func (s *service) Create(ctx context.Context, r *task.CreateRequest) (*task.CreateResponse, error) {
// 1. 准备 OCI Bundle
bundle := &Bundle{
ID: r.ID,
Path: r.Bundle,
Rootfs: r.Rootfs,
}
// 2. 构造 runc 命令
cmd := exec.Command("runc", "create",
"--bundle", bundle.Path,
"--pid-file", pidFile,
r.ID,
)
// 3. 执行 runc create
if err := cmd.Run(); err != nil {
return nil, err
}
// 4. 读取容器 PID
pid, _ := os.ReadFile(pidFile)
return &task.CreateResponse{Pid: uint32(pid)}, nil
}

7.2 runc 的进程关系#

graph TB CTNRD["containerd<br/>PID 1000"] SHIM["containerd-shim<br/>PID 2000"] RUNC_CREATE["runc create<br/>PID 3000(临时)"] RUNC_INIT["runc init<br/>PID 3001(容器 init 进程)"] APP["nginx<br/>PID 1(容器内)<br/>PID 3001(宿主)"] CTNRD -->|"fork"| SHIM SHIM -->|"exec"| RUNC_CREATE RUNC_CREATE -->|"clone + exec"| RUNC_INIT RUNC_INIT -->|"exec"| APP SHIM -->|"监督"| APP style CTNRD fill:#bbdefb,stroke:#1565c0 style SHIM fill:#c8e6c9,stroke:#2e7d32 style RUNC_CREATE fill:#fff3e0,stroke:#e65100 style RUNC_INIT fill:#e1bee7,stroke:#6a1b9a style APP fill:#ffcdd2,stroke:#c62828

八、动手实践#

8.1 用 strace 追踪 runc 的系统调用#

#!/bin/bash
# 追踪 runc 的系统调用
# 1. 创建 OCI Bundle
mkdir -p /tmp/strace-bundle/rootfs
cd /tmp/strace-bundle
docker export $(docker create busybox) | tar -C rootfs -xvf -
runc spec
# 2. 用 strace 追踪 runc
sudo strace -f -o /tmp/runc-strace.log \
-e trace=clone,unshare,mount,pivot_root,prctl,capset,write \
runc run strace-test
# 3. 分析系统调用
echo "=== clone 调用(创建 Namespace)==="
grep "clone" /tmp/runc-strace.log | head -5
echo "=== mount 调用(挂载文件系统)==="
grep "mount" /tmp/runc-strace.log | head -10
echo "=== pivot_root 调用(切换根文件系统)==="
grep "pivot_root" /tmp/runc-strace.log
echo "=== prctl 调用(seccomp/Capabilities)==="
grep "prctl" /tmp/runc-strace.log | head -5
echo "=== Cgroup 写入 ==="
grep "cgroup" /tmp/runc-strace.log | head -5

8.2 修改 runc 源码添加日志#

// 在 libcontainer/linux/standard_init_linux.go 的 Init 方法中添加日志
func (l *linuxStandardInit) Init() error {
logFile, _ := os.OpenFile("/tmp/runc-init.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
log := log.New(logFile, "[runc-init] ", log.Lmicroseconds)
log.Println("Starting container initialization")
log.Printf("Container ID: %s", l.config.ContainerID)
log.Printf("Namespaces: %v", l.config.Namespaces)
// ... 原有初始化代码 ...
log.Println("Setting up user namespace")
if err := l.setupUser(); err != nil {
log.Printf("setupUser failed: %v", err)
return err
}
log.Println("Setting up cgroup")
// ... 继续添加日志 ...
log.Println("Container initialization complete, executing user command")
return syscall.Exec(l.config.Args[0], l.config.Args, l.config.Env)
}

九、本章小结#

上一章深入探讨了 OCI 规范体系。

组件职责关键代码
Factory创建容器对象factory.go
Container管理容器生命周期linux/container.go
runc init容器初始化进程standard_init_linux.go
Cgroup ManagerCgroup 资源管理cgroups/fs2/manager.go
seccomp系统调用过滤seccomp/seccomp.go
Capabilities权限控制capabilities/capabilities.go
Note

runc 的代码虽然复杂,但核心逻辑是线性的:读取 config.json → 创建 Namespace → 配置 Cgroup → 挂载文件系统 → 设置安全策略 → 执行用户命令。理解这条主线,就能把握 runc 的全貌。

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

runc 源码分析
https://blog.souloss.cn/posts/container-runtime/runc-source-code/
作者
Souloss
发布于
2021-09-11
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

相关文章 智能推荐
1
容器运行时深入系列导读
容器运行时 从 Linux 内核的 Namespace、Cgroup、OverlayFS 出发,深入 OCI 规范、runc 源码、containerd 架构与 shim 机制,再覆盖容器安全、网络、镜像构建三个运行时核心维度,建立从内核机制到工业实现的完整认知链。
2
容器完整流程:docker run 背后
容器运行时 当你执行 docker run nginx 时,背后发生了什么?本章完整追踪从 Docker CLI 到容器进程启动的每一步,镜像拉取、OCI Bundle 生成、shim 启动、runc 创建、Namespace/Cgroup/OverlayFS 配置、容器进程执行,让你对 docker run 的每一步都了如指掌。
3
OCI 规范详解
容器运行时 OCI(Open Container Initiative)定义了容器镜像格式和运行时接口的开放标准。从零讲透 OCI 的三大规范,Image Spec(镜像格式、manifest、config、layer)、Runtime Spec(OCI Bundle、config.json、生命周期)、Distribution Spec(Registry API),理解容器生态标准化的基石,为阅读 runc 源码和理解 containerd 架构奠定基础。
4
容器安全:seccomp/AppArmor/Capabilities
容器运行时 容器的安全边界在哪里?Namespace 提供视图隔离,但不阻止特权操作。从零讲透 Linux 安全模块在容器中的应用,Capabilities(权限细分)、seccomp(系统调用过滤)、AppArmor(文件访问控制),以及 rootless 容器的实现,让你理解容器的安全边界和加固方法。
5
Cgroup v2 深入
容器运行时 Cgroup 是容器资源限制的核心机制。逐层拆解 Cgroup v2 的统一层级设计、CPU/内存/IO 三大控制器的实现原理、eBPF 扩展机制,以及容器运行时如何通过 Cgroup 实现资源隔离,从「docker run --memory=512m」到内核的 cgroup 文件系统,理解每一步的资源控制逻辑。