
大家好,我是费益洲。上一篇文章我们梳理了 runc init 的初始化过程,并且提到 init 最终会阻塞在 exec.fifo 上,等待 runc start 放行。本文我们就沿着这条线,完整梳理 runc start 是如何把容器从 created 推进到 running 的。
前置条件
runc 源码版本:v1.1.12
runc 项目地址:https://github.com/opencontainers/runc
❝💡 从整体来看,runc start 的主要逻辑非常简单,主要就是放行 runc init 过程中的 FIFO。FIFO 放行后,就会执行用户进程,进而达到启动容器的目的。接下来我们就从源码的角度,详细说明一下这个过程。
runc start
先看命令入口:
start.go
// line 12
var startCommand = cli.Command{
Name: "start",
Usage: "executes the user defined process in a created container",
...
// line 21
Action: func(context *cli.Context)error {
...
// line 25 获取容器信息
container, err := getContainer(context)
if err != nil {
return err
}
// line 29 获取容器状态
status, err := container.Status()
if err != nil {
return err
}
switch status {
// line 34:允许启动
case libcontainer.Created:
...
// line 39 执行用户进程
if err := container.Exec(); err != nil {
return err
}
...
// line 46:直接报错,需要重新 create
case libcontainer.Stopped:
return errors.New("cannot start a container that has stopped")
// line 48:直接报错,避免重复 start
case libcontainer.Running:
return errors.New("cannot start an already running container")
// line 50:其他状态(如 paused):也不允许从 start 进入
default:
return fmt.Errorf("cannot start a container in the %s state", status)
}
},
}
从上面的代码可以看出,runc start 本身不会再对 namespace/rootfs/cgroup 进行初始化,而是直接对传递过来的容器状态进行校验,如果容器状态是 created,那么才会允许当前容器进入后续流程。所以,runc start 的入口阶段就只承担一个职责:过滤出状态为 created 的容器。
接下来我们继续查看 runc start 的核心逻辑:
放行用户进程
libcontainer/container_linux.go
// line 260
func(c *linuxContainer)Exec()error {
c.m.Lock()
defer c.m.Unlock()
return c.exec()
}
// line 266
func(c *linuxContainer)exec()error {
path := filepath.Join(c.root, execFifoFilename)
pid := c.initProcess.pid()
// line 269 通过异步阻塞式的方式打开fifo读端
blockingFifoOpenCh := awaitFifoOpen(path)
for {
select {
case result := <-blockingFifoOpenCh:
return handleFifoResult(result)
// 每100ms探活init pid
case <-time.After(time.Millisecond * 100):
stat, err := system.Stat(pid)
if err != nil || stat.State == system.Zombie {
// 非阻塞式的兜底检查
if err := handleFifoResult(fifoOpen(path, false)); err != nil {
return errors.New("container process is already dead")
}
returnnil
}
}
}
}
需要额外注意的是子 exec()函数中,并不是直接 open fifo 然后返回,而是通过异步阻塞的方式打开 fifo 读端,然后还有一个补偿措施:每 100ms 探活 init 进程,防止 child 进程已死但 runc start 还一直在等待。
但是如果 init 已经退出,代码会走一次非阻塞 fifoOpen(path, false) 兜底检查;若读不到有效数据,就返回 container process is already dead。 这个分支是典型的避免死循环的健壮性处理。
FIFO 结果处理
接下来还要继续看 handleFifoResult:
libcontainer/container_linux.go
// line 321
funchandleFifoResult(result openResult)error {
if result.err != nil {
return result.err
}
f := result.file
defer f.Close()
if err := readFromExecFifo(f); err != nil {
return err
}
// line 330
return os.Remove(f.Name())
}
继续看核心实现:
libcontainer/container_linux.go
// line 289
funcreadFromExecFifo(execFifo io.Reader)error {
// line 290 读取fifo内容
data, err := io.ReadAll(execFifo)
if err != nil {
return err
}
// line 294 判断fifo内容是否为空
iflen(data) <= 0 {
return errors.New("cannot start an already running container")
}
returnnil
}
以上的两个函数中,都有需要额外关注的点:
readFromExecFifo(): 需要关注第 294 行的代码,这行代码负责判断是否能够读取到 init 写入的内容。runc init 会在 fifo 中写入一个
0,所以这里的判断是只要读取到数据,就认为这是启动容器的任务,就会继续执行后续逻辑,否则直接返回不能重复启动容器的异常handleFifoResult():需要关注第 330 行代码,当从 readFromExecFifo 中读取到 fifi 内容后,会返回一个非空的 file,而 330 就负责立即删除这个 file 对象,而这个 file 对象,其实就是 fifo。需要额外注意的是,这个
删除 fifo的动作非常关键,后面就是靠它来区分容器的created和running状态的。
❝关于 runc init 使用 fifo 阻塞执行用户进程的部分在上一篇文章《Runc 源码解析: (二)Runc 初始化容器环境》中有介绍,感兴趣的同志可前往查看相关内容
状态变化
runc start 对容器状态变化的入口是 runType() 函数,这个函数也是 runc start 在入口处获取容器状态的底层函数,调用过程这里不再赘述,直接看 runType() 函数的具体实现:
libcontainer/container_linux.go
// line 1985
func(c *linuxContainer)runType()Status {
if c.initProcess == nil {
return Stopped
}
pid := c.initProcess.pid()
stat, err := system.Stat(pid)
if err != nil {
return Stopped
}
if stat.StartTime != c.initProcessStartTime || stat.State == system.Zombie || stat.State == system.Dead {
return Stopped
}
// We'll create exec fifo and blocking on it after container is created,
// and delete it after start container.
// line 1999
if _, err := os.Stat(filepath.Join(c.root, execFifoFilename)); err == nil {
return Created
}
return Running
}
这里需要额外注意第 1999 行,这里有个非常精巧的设计点:容器的状态根据 fifo 是否存在进行判断:
fifo 存在:当前容器的状态是 Createdfifo 不存在:当亲容器的状态是 Running
上一小节中,runc start 读取到 fifo 的内容后,就会立即删除 fifo。当 fifo 被删除后,容器的状态就自然会从 Created 变化为 Running。也就是说,容器的状态切换是由 握手文件 来驱动的,而不是靠某个单独的布尔标志位。
runc start 的代码量不大,但它在语义上非常关键。如果用一句话总结就是:runc create 负责 把容器环境准备到位并挂起 ,而 runc start 负责 打开 exec.fifo 解除挂起,让 init 最终 exec 用户进程并进入 running。至此,create -> init -> start 三段链路就完整闭环了。
夜雨聆风