Runc 源码解析:(一)Runc 创建容器

大家好,我是费益洲。经过梳理 Containerd 创建容器的源码,我们可以看到,容器的创建过程最终会通过 Containerd 调用 runc 的命令执行具体的操作,操作主要分为 create 和 start 两个过程,本文主要梳理在执行 runc create 时的具体逻辑和详细过程。
前置条件
runc 源码版本:v1.1.12
runc 项目地址:https://github.com/opencontainers/runc
runc 整体采用github.com/urfave/cli这个库来构建命令模块,它的代码结构很清晰,下面大致介绍下代码结构:
-
runc/main:命令模块的入口文件是 main.go,对应 runc 根命令模块,其余的子命令模块也都在同级目录下,如本文中涉及到的 create.go、init.go,以及 runc 启动容器涉及到的 start.go,分别对应 runc 的 create、init 以及 start 子命令 -
runc/libcontainer:该目录主要存放每个子命令模块的主体逻辑
❝
以上是比较重要的两点,同志们如果对其他感兴趣可自行查看
runc 创建容器其实是分为两部分:runc create 以及内部调用的 runc init,调用流程大致如下所示:

这两部分逻辑较多,接下来,我们从源码角度先详细梳理下 runc create 的主要逻辑。
runc create
runc create 的本质是创建一个容器,并完成所有初始化(namespace、rootfs、cgroup 等),但不执行用户进程,让容器悬停在 created 状态,命令入口如下所示:
create.go
// line 10
var createCommand = cli.Command{
Name: "create",
Usage: "create a container",
...
// line 55
Action: func(context *cli.Context)error {
// 检查参数
if err := checkArgs(context, 1, exactArgs); err != nil {
return err
}
// 创建容器统一入口
status, err := startContainer(context, CT_ACT_CREATE, nil)
...
},
}
我们继续看 startContainer 函数实现:
utils_linux.go
// line 372
funcstartContainer(context *cli.Context, action CtAct, criuOpts *libcontainer.CriuOpts)(int, error) {
...
// line 376 加载config.json
spec, err := setupSpec(context)
...
// line 391 创建 libcontainer 容器对象
container, err := createContainer(context, id, spec)
...
// line 413 构建runner结构体,调用r.run(spec.Process)
r := &runner{...}
return r.run(spec.Process)
}
我们先看下创建容器对象的 createContainer 函数的实现:
utils_linux.go
// line 195
funccreateContainer(context *cli.Context, id string, spec *specs.Spec)(libcontainer.Container, error) {
...
// line 200 OCI spec → libcontainer config
config, err := specconv.CreateLibcontainerConfig(&specconv.CreateOpts{...})
...
// line 213 创建 LinuxFactory
factory, err := loadFactory(context)
...
// line 217 创建容器
return factory.Create(id, config)
}
构建 init 执行入口
以上代码需要特别关注一下创建 LinuxFactory 的实现过程:
utils_linux.go
// line 28
funcloadFactory(context *cli.Context)(libcontainer.Factory, error) {
...
// line 47
return libcontainer.New(...)
}
这里会继续调用 libcontainer.New 函数创建 LinuxFactory:
libcontainer/factory_linux.go
// line 76
funcNew(root string, options ...func(*LinuxFactory)error) (Factory, error) {
...
// line 82
l := &LinuxFactory{
Root: root,
InitPath: "/proc/self/exe",
InitArgs: []string{os.Args[0], "init"},
...
}
...
}
这里需要格外注意一下,runc create 进程通过带着参数 init 重新执行自身的方式,来触发 runc init 的执行,此处留意即可,runc init 的代码下一篇文章会做单独介绍。现在我们回到进程分发执行的逻辑:
utils_linux.go
// line 236
func(r *runner)run(config *specs.Process)(int, error) {
...
// line 285
switch r.action {
case CT_ACT_CREATE:
err = r.container.Start(process)
case CT_ACT_RESTORE:
err = r.container.Restore(process, r.criuOpts)
case CT_ACT_RUN:
err = r.container.Run(process)
default:
panic("Unknown action")
}
}
此时 runc create 会进入 r.container.Start 分支逻辑,我们继续看 r.container.Start 的实现逻辑:
libcontainer/container_linux.go
// line 230
func(c *linuxContainer)Start(process *Process)error {
...
// line 236 创建FIFO
if process.Init {
if err := c.createExecFifo(); err != nil {
return err
}
}
// line 241 启动init进程
if err := c.start(process); err != nil {
if process.Init {
c.deleteExecFifo()
}
return err
}
returnnil
}
创建 fifo
上述代码的逻辑非常清晰,236 行主要功能是通过 exec.fifo 来创建 linux 的命名管道 FIFO。我们继续看下命名管道 FIFO 的创建过程:
libcontainer/container_linux.go
// line 421
func(c *linuxContainer)createExecFifo()error {
rootuid, err := c.Config().HostRootUID()
if err != nil {
return err
}
rootgid, err := c.Config().HostRootGID()
if err != nil {
return err
}
fifoName := filepath.Join(c.root, execFifoFilename)
if _, err := os.Stat(fifoName); err == nil {
return fmt.Errorf("exec fifo %s already exists", fifoName)
}
oldMask := unix.Umask(0o000)
if err := unix.Mkfifo(fifoName, 0o622); err != nil {
unix.Umask(oldMask)
return err
}
unix.Umask(oldMask)
return os.Chown(fifoName, rootuid, rootgid)
}
上述代码的逻辑很清晰,主要功能就是通过 unix.Mkfifo() 在 <root>/<id>/exec.fifo 创建命名管道。我们继续回到启动进程的逻辑:
libcontainer/container_linux.go
// line 338
func(c *linuxContainer)start(process *Process)(retErr error) {
// line 339 创建父进程
parent, err := c.newParentProcess(process)
...
// line 344 读取child日志文件管道
logsDone := parent.forwardChildLogs()
...
// line 365 进入 parent.start
if err := parent.start(); err != nil {
return fmt.Errorf("unable to start container process: %w", err)
}
...
}
创建 parent process(管道与命令模板)
上述代码中,339 行创建父进程,这里的父进程指的是当前 create 进程,而子进程指的是 init 进程,下面我们先进入 linuxContainer.newParentProcess:
libcontainer/container_linux.go
// line 467
func(c *linuxContainer)newParentProcess(p *Process)(parentProcess, error) {
// line 468 创建 UNIX socket pair(init pipe)
parentInitPipe, childInitPipe, err := utils.NewSockPair("init")
...
// line 474 创建 log pipe
parentLogPipe, childLogPipe, err := os.Pipe()
...
// line 480 构建 exec.Cmd 模板
cmd := c.commandTemplate(p, childInitPipe, childLogPipe)
...
// line 490 将 exec.fifo fd 传给子进程
if err := c.includeExecFifo(cmd); err != nil {
returnnil, fmt.Errorf("unable to setup exec fifo: %w", err)
}
// line 493 创建 initProcess
return c.newInitProcess(p, cmd, messageSockPair, logFilePair)
}
上述代码的主要作用就是初始化 process,而这个 process 其实就是 parent.start()中的 parent 进程,这里需要额外关注一下构建 exec.Cmd 模板的逻辑实现:
libcontainer/container_linux.go
// line 496
func(c *linuxContainer)commandTemplate(p *Process, childInitPipe *os.File, childLogPipe *os.File) *exec.Cmd {
// line 497 即: /proc/self/exe init
cmd := exec.Command(c.initPath, c.initArgs[1:]...)
...
// line 515
cmd.Env = append(cmd.Env,
"_LIBCONTAINER_INITPIPE="+strconv.Itoa(stdioFdCount+len(cmd.ExtraFiles)-1),
"_LIBCONTAINER_STATEDIR="+c.root,
)
...
// line 521
cmd.Env = append(cmd.Env,
"_LIBCONTAINER_LOGPIPE="+strconv.Itoa(stdioFdCount+len(cmd.ExtraFiles)-1),
"_LIBCONTAINER_LOGLEVEL="+p.LogLevel,
)
...
}
构建 exec.Cmd 模板,即生成 init 命令,并设置大量 _LIBCONTAINER_XXX 格式的环境变量。同时会把 init pipe、log pipe、exec.fifo 等通过 extraFiles 传给子进程;这些环境变量主要用于告知 init 进程各 fd 编号和状态目录:
-
_LIBCONTAINER_INITPIPE— init 管道 fd -
_LIBCONTAINER_STATEDIR— 状态目录 -
_LIBCONTAINER_LOGPIPE— 日志管道 fd -
_LIBCONTAINER_FIFOFD— exec.fifo 的 fd
启动 initProcess 与同步握手
parent process 创建完成后,就会调用 parent.Start 函数,正式启动 init 进程并完成同步握手:
libcontainer/process_linux.go
// line 356
func(p *initProcess)start()(retErr error) {
// line 358 调用runc init
err := p.cmd.Start()
...
// line 368
waitInit := initWaiter(p.messageSockPair.parent)
...
// line 411
if err := p.manager.Apply(p.pid()); err != nil {
return fmt.Errorf("unable to apply cgroup configuration: %w", err)
}
// line 419
if _, err := io.Copy(p.messageSockPair.parent, p.bootstrapData); err != nil {
return fmt.Errorf("can't copy bootstrap data to pipe: %w", err)
}
// line 422
err = <-waitInit
...
// line 452
if err := p.sendConfig(); err != nil {
return fmt.Errorf("error sending config to init process: %w", err)
}
...
// line 460 等子进程同步消息
ierr := parseSync(p.messageSockPair.parent, func(sync *syncT)error {
switch sync.Type {
case procSeccomp:
...
case procReady:
// line 500
if err := setupRlimits(p.config.Rlimits, p.pid()); err != nil {
return fmt.Errorf("error setting rlimits for ready process: %w", err)
}
if !p.config.Config.Namespaces.Contains(configs.NEWNS) {
...
// line 515
iflen(p.config.Config.Hooks) != 0 {
...
// line 525
if err := hooks[configs.Prestart].RunHooks(s); err != nil {
return err
}
if err := hooks[configs.CreateRuntime].RunHooks(s); err != nil {
return err
}
}
}
...
// line 549
state, uerr := p.container.updateState(p)
if uerr != nil {
return fmt.Errorf("unable to store init state: %w", err)
}
p.container.initProcessStartTime = state.InitProcessStartTime
// Sync with child.
if err := writeSync(p.messageSockPair.parent, procRun); err != nil {
return err
}
sentRun = true
case procHooks:
...
default:
...
}
})
}
上述代码虽然不长,但它是 runc create 最核心的一段控制流。这里的父进程指当前执行 runc create 的进程(即 initProcess.start() 所在进程),子进程指由 p.cmd.Start() 拉起并执行 runc init 的进程。p.cmd.Start() 会先拉起 runc init,随后通过 initWaiter(...) 建立对 init 侧状态和错误的监听;父进程在拿到子进程 pid 后执行 p.manager.Apply(p.pid()) 将其加入目标 cgroup,并通过 io.Copy(..., p.bootstrapData) 把初始化所需的 bootstrap 数据写入消息管道。接着父进程会在 err = <-waitInit 处等待 init 第一阶段完成,再调用 p.sendConfig() 下发最终 process/config,最后进入 parseSync(...) 循环处理来自子进程的同步消息(如 procSeccomp、procReady),直到容器初始化流程进入就绪状态。
从父进程视角看,procReady 是 create 流程中的关键同步点:父进程收到该消息后,会先完成本侧收尾动作(如设置 rlimits、按条件执行 Prestart/CreateRuntime hooks、更新并落盘容器状态),然后通过 writeSync(..., procRun) 回传继续信号给子进程。也就是说,procReady 表示“初始化握手已到可收敛阶段”,而不是“用户业务进程已经开始执行”。
这里的关键点是:runc create 并不是简单地拉起一个进程,而是通过管道与同步消息完成父子进程握手,最终把容器推进到 created 状态。至于 runc init 进程内部如何消费这些配置与同步消息、完成后续初始化,将在下一篇文章中单独展开。
夜雨聆风