乐于分享
好东西不私藏

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

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(...) 循环处理来自子进程的同步消息(如 procSeccompprocReady),直到容器初始化流程进入就绪状态。

从父进程视角看,procReady 是 create 流程中的关键同步点:父进程收到该消息后,会先完成本侧收尾动作(如设置 rlimits、按条件执行 Prestart/CreateRuntime hooks、更新并落盘容器状态),然后通过 writeSync(..., procRun) 回传继续信号给子进程。也就是说,procReady 表示“初始化握手已到可收敛阶段”,而不是“用户业务进程已经开始执行”。

这里的关键点是:runc create 并不是简单地拉起一个进程,而是通过管道与同步消息完成父子进程握手,最终把容器推进到 created 状态。至于 runc init 进程内部如何消费这些配置与同步消息、完成后续初始化,将在下一篇文章中单独展开。