当你执行 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 源码,就是读容器运行时的 “底层语法”。
前置知识
Ch05 OCI 规范详解:runc 是 OCI Runtime Spec 的参考实现,理解 Runtime Spec 是阅读 runc 源码的前提
Go 语言基础:runc 用 Go 编写,需要理解 goroutine、interface、error handling 等 Go 特性
Linux 系统编程:
clone()、mount()、pivot_root()、seccomp()等系统调用
runc 的核心逻辑集中在 libcontainer/ 目录,代码风格清晰,会逐行解读关键路径。
下面追踪 runc 容器创建的完整代码路径。
一、runc 项目结构
1.1 目录结构
libcontainer核心库
linux
configs
1.2 核心依赖关系
二、容器创建流程
runc 的 CLI 命令对应容器生命周期的不同阶段,每个命令触发不同的代码路径:
| 命令 | 容器状态 | 代码入口 | 主要操作 |
|---|---|---|---|
runc create | 不存在 → Created | create.go → Factory.Create() | 验证配置、创建状态目录、写入 config.json、构造容器对象(此时不创建 Namespace、不配 Cgroup、不挂载 rootfs) |
runc start | Created → Running | start.go → Container.Execute() | 执行 runc init,运行用户进程 |
runc run | 不存在 → Running | run.go → Factory.Create() + Container.Run() | create + start 的组合,一步到位 |
runc exec | Running | exec.go → Container.Exec() | 在已有容器中执行新进程(进入 Namespace) |
runc kill | Running → Stopped | kill.go → Container.Signal() | 向容器 init 进程发送信号 |
runc delete | Stopped → 不存在 | 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)而非命令行参数或环境变量来传递配置,原因有三:
- 配置量大:容器的完整配置包括 Namespace 列表、Cgroup 参数、挂载点、Capabilities、seccomp 规则等,数据量远超命令行参数的长度限制(
ARG_MAX通常 2MB),也远超环境变量的合理范围。 - 安全性:通过管道传递配置,配置数据不会出现在
/proc/<pid>/cmdline或/proc/<pid>/environ中,避免敏感信息泄露。 - 同步控制:管道天然提供同步机制。父进程写入配置后调用
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 中运行,负责完成所有容器初始化工作,然后执行用户命令。
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 | 设置 Namespace | clone(CLONE_NEWNS|CLONE_NEWPID|…) | 容器无法隔离 |
| 2 | 配置 Cgroup | 写入 /sys/fs/cgroup/… | 资源限制不生效 |
| 3 | 挂载 rootfs | mount(), pivot_root() | 容器无法访问文件系统 |
| 4 | 设置 hostname | sethostname() | 容器内主机名不正确 |
| 5 | 配置网络 | netlink, veth pair | 容器网络不可用 |
| 6 | 设置 seccomp | prctl(PR_SET_SECCOMP) | 系统调用过滤不生效 |
| 7 | 设置 AppArmor | /proc/self/attr/apparmor/ | 安全策略不生效 |
| 8 | 设置 capabilities | capset() | 权限控制不正确 |
| 9 | 执行用户命令 | execve() | 容器进程无法启动 |
runc init 的各初始化步骤存在严格的先后依赖,顺序错误会导致容器创建失败:
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}
// 所有控制器写入都是同一个模式:拼路径、写字节、0644func 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.weighttype 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.maxtype 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_us 和 cpu.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 的配置)。
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 字段区分了 bounding、effective、permitted、inheritable 四个子字段,runc 的 cap.Set() 实际上会同时设置 Bounding、Permitted 和 Effective 三个集合。先 Reset 清空所有集合,再 Set 指定的 Capability,确保了 Effective 集合不会超出 Bounding 集合的范围。如果容器只需要 CAP_NET_BIND_SERVICE 和 CAP_CHOWN,那么最终进程只拥有这两个 Capability,即使容器内进程尝试提权也无法获得其他 Capability,因为 Bounding 集合已经限制了上限。
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 规则的构建逻辑如下:
-
确定默认动作:config.json 的
defaultAction字段指定了对未匹配系统调用的默认处理方式。SCMP_ACT_ERRNO表示拒绝并返回错误码,SCMP_ACT_ALLOW表示允许,SCMP_ACT_KILL表示直接终止进程。Docker 的默认配置使用SCMP_ACT_ERRNO,即白名单模式:默认拒绝,只放行明确允许的系统调用。 -
为每个系统调用生成规则:遍历
syscalls列表,对每条规则生成一组 BPF 指令。每条规则包含系统调用号(如read对应 0,write对应 1)和动作(允许或拒绝)。BPF 程序在运行时检查当前系统调用的编号,与规则中的编号逐一比较,匹配则返回对应动作。 -
处理参数条件过滤:规则可以指定参数条件,例如 “允许
openat但参数flags不能包含O_WRONLY”。BPF 程序通过检查seccomp_data结构中的参数字段(args[0]到args[5])来实现条件过滤。每个参数条件由操作符(等于、不等于、位掩码匹配等)和值组成,BPF 指令使用按位与和比较操作来评估条件。 -
组装 BPF 程序:将所有规则指令按顺序排列,末尾添加默认动作。内核按顺序执行 BPF 指令,遇到匹配的规则就返回对应动作,否则继续执行下一条,最终到达默认动作。这个顺序很重要:特定规则在前,默认动作在后,类似于防火墙规则的匹配逻辑。
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 的进程关系
八、动手实践
8.1 用 strace 追踪 runc 的系统调用
#!/bin/bash# 追踪 runc 的系统调用
# 1. 创建 OCI Bundlemkdir -p /tmp/strace-bundle/rootfscd /tmp/strace-bundledocker export $(docker create busybox) | tar -C rootfs -xvf -runc spec
# 2. 用 strace 追踪 runcsudo 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 -58.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 Manager | Cgroup 资源管理 | cgroups/fs2/manager.go |
| seccomp | 系统调用过滤 | seccomp/seccomp.go |
| Capabilities | 权限控制 | capabilities/capabilities.go |
runc 的代码虽然复杂,但核心逻辑是线性的:读取 config.json → 创建 Namespace → 配置 Cgroup → 挂载文件系统 → 设置安全策略 → 执行用户命令。理解这条主线,就能把握 runc 的全貌。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时








