乐趣区

探索 runC (上)

前言
容器运行时 (Container Runtime) 是指管理容器和容器镜像的软件。当前业内比较有名的有 docker,rkt 等。如果不同的运行时只能支持各自的容器,那么显然不利于整个容器技术的发展。于是在 2015 年 6 月,由 Docker 以及其他容器领域的领导者共同建立了围绕容器格式和运行时的开放的工业化标准,即 Open Container Initiative(OCI),OCI 具体包含两个标准:运行时标准 (runtime-spec) 和容器镜像标准(image-spec)。简单来说,容器镜像标准定义了容器镜像的打包形式(pack format),而运行时标准定义了如何去运行一个容器。
本文包含以下内容:

runC 的概念和使用
runC 运行容器的原理剖析

本文不包含以下内容:
docker engine 使用 runC
runC 概念
runC 是一个遵循 OCI 标准的用来运行容器的命令行工具(CLI Tool),它也是一个 Runtime 的实现。尽管你可能对这个概念很陌生,但实际上,你的电脑上的 docker 底层可能正在使用它。至少在笔者的主机上是这样。
root@node-1:~# docker info
…..
Runtimes: runc
Default Runtime: runc
…..
安装 runC
runC 不仅可以被 docker engine 使用,它也可以单独使用(它本身就是命令行工具),以下使用步骤完全来自 runC’s README, 如果
依赖项

Go version 1.6 或更高版本

libseccomp 库
yum install libseccomp-devel for CentOS
apt-get install libseccomp-dev for Ubuntu

下载编译
# 在 GOPATH/src 目录创建 ’github.com/opencontainers’ 目录
> cd github.com/opencontainers
> git clone https://github.com/opencontainers/runc
> cd runc

> make
> sudo make install
或者使用 go get 安装
# 在 GOPATH/src 目录创建 github.com 目录
> go get github.com/opencontainers/runc
> cd $GOPATH/src/github.com/opencontainers/runc
> make
> sudo make install
以上步骤完成后,runC 将安装在 /usr/local/sbin/runc 目录
使用 runC
创建一个 OCI Bundle
OCI Bundle 是指满足 OCI 标准的一系列文件,这些文件包含了运行容器所需要的所有数据,它们存放在一个共同的目录,该目录包含以下两项:

config.json:包含容器运行的配置数据
container 的 root filesystem

如果主机上安装了 docker,那么可以使用 docker export 命令将已有镜像导出为 OCI Bundle 的格式
# create the top most bundle directory
> mkdir /mycontainer
> cd /mycontainer

# create the rootfs directory
> mkdir rootfs

# export busybox via Docker into the rootfs directory
> docker export $(docker create busybox) | tar -C rootfs -xvf –
> ls rootfs
bin dev etc home proc root sys tmp usr var
有了 root filesystem,还需要 config.json,runc spec 可以生成一个基础模板,之后我们可以在模板基础上进行修改。
> runc spec
> ls
config.json rootfs
生成的 config.json 模板比较长,这里我将它 process 中的 arg 和 terminal 进行修改
{
“process”: {
“terminal”:false,<– 这里改为 true
“user”: {
“uid”: 0,
“gid”: 0
},
“args”: [
“sh” <– 这里改为 “sleep”,”5″
],
“env”: [
“PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin”,
“TERM=xterm”
],
“cwd”: “/”,
},
“root”: {
“path”: “rootfs”,
“readonly”: true
},
“linux”: {
“namespaces”: [
{
“type”: “pid”
},
{
“type”: “network”
},
{
“type”: “ipc”
},
{
“type”: “uts”
},
{
“type”: “mount”
}
],
}
}

config.json 文件的内容都是 OCI Container Runtime 的订制,其中每一项值都可以在 Runtime Spec 找到具体含义,OCI Container Runtime 支持多种平台,因此其 Spec 也分为通用部分(在 config.md 中描述)以及平台相关的部分(如 linux 平台上就是 config-linux)

process: 指定容器启动后运行的进程运行环境,其中最重要的的子项就是 args,它指定要运行的可执行程序,在上面的修改后的模板中,我们将其改成了 ”sleep 5″

root:指定容器的根文件系统,其中 path 子项是指向前面导出的中 root filesystem 的路径

linux: 这一项是平台相关的。其中 namespaces 表示新创建的容器会额外创建或使用的 namespace 的类型

运行容器
现在我们使用 create 命令创建容器
# run as root
> cd /mycontainer
> runc create mycontainerid
使用 list 命令查看容器状态为 created
# view the container is created and in the “created” state
> runc list
ID PID STATUS BUNDLE CREATED OWNER
mycontainerid 12068 created /mycontainer 2018-12-25T19:45:37.346925609Z root
使用 start 命令查看容器状态
# start the process inside the container
> runc start mycontainerid
在 5s 内 使用 list 命令查看容器状态为 running
# within 5 seconds view that the container is running
runc list
ID PID STATUS BUNDLE CREATED OWNER
mycontainerid 12068 running /mycontainer 2018-12-25T19:45:37.346925609Z root
在 5s 后 使用 list 命令查看容器状态为 stopped
# after 5 seconds view that the container has exited and is now in the stopped state
runc list
ID PID STATUS BUNDLE CREATED OWNER
mycontainerid 0 stopped /mycontainer 2018-12-25T19:45:37.346925609Z root
使用 delete 命令可以删除容器
# now delete the container
runc delete mycontainerid
runC 的实现原理
runC 可以启动并管理符合 OCI 标准的容器。简单地说,runC 需要利用 OCI bundle 创建一个独立的运行环境,并执行指定的程序。在 Linux 平台上,这个环境就是指各种类型的 Namespace 以及 Capability 等等配置
代码结构
runC 由 Go 语言实现,当前 (2018.12) 最新版本是 v1.0.0-rc6, 代码的结构可分为两大块, 一是根目录下的 go 文件,对应各个 runC 命令,二是负责创建 / 启动 / 管理容器的 libcontainer,可以说 runC 的本质都在 libcontainer
runc create 的过程
以上面的例子为例,以 ’runc create’ 这条命令来看 runC 是如何完成从无到有创建容器
create 命令的响应入口在 create.go, 我们直接关注其注册的 Action 的实现,当输入 runc create mycontainerid 时会执行注册的 Action,并且参数存放在 Context 中
/* run.go */
Action: func(context *cli.Context) error {
……
  spec, err := setupSpec(context)

  status, err := startContainer(context, spec, CT_ACT_CREATE, nil)
…..
}

setupSpec:从命令行输入中找到 -b 指定的 OCI bundle 目录,若没有此参数,则默认是当前目录。读取 config.json 文件,将其中的内容转换为 Go 的数据结构 specs.Spec,该结构定义在文件 github.com/opencontainers/runtime-spec/specs-go/config.go, 里面的内容都是 OCI 标准描述的

startContainer:尝试创建启动容器,注意这里的第三个参数是 CT_ACT_CREATE, 表示仅创建容器。本文使用 linux 平台,因此实际调用的是 utils_linux.go 中的 startContainer()。startContainer()根据用户将用户输入的 id 和刚才的得到的 spec 作为输入,调用 createContainer() 方法创建容器,再通过一个 runner.run()方法启动它

/× utils_linux.go ×/
func startContainer(context *cli.Context, spec *specs.Spec, action CtAct, criuOpts *libcontainer.CriuOpts) (int, error) {
id := context.Args().First()

container, err := createContainer(context, id, spec)

r := &runner{
container: container,
action: action,
init: true,
……
}
return r.run(spec.Process)
}
这里需要先了解下 runC 中的几个重要数据结构的关系
Container 接口
在 runC 中,Container 用来表示一个容器对象,它是一个抽象接口,它内部包含了 BaseContainer 接口。从其内部的方法的名字就可以看出,都是管理容器的基本操作
/* libcontainer/container.go */
type BaseContainer interface {
ID() string
Status() (Status, error)
State() (*State, error)
Config() configs.Config
Processes() ([]int, error)
Stats() (*Stats, error)
Set(config configs.Config) error
Start(process *Process) (err error)
Run(process *Process) (err error)
Destroy() error
Signal(s os.Signal, all bool) error
Exec() error
}

/* libcontainer/container_linux.go */
type Container interface {
BaseContainer

Checkpoint(criuOpts *CriuOpts) error
Restore(process *Process, criuOpts *CriuOpts) error
Pause() error
Resume() error
NotifyOOM() (<-chan struct{}, error)
NotifyMemoryPressure(level PressureLevel) (<-chan struct{}, error)
}
有了抽象接口,那么一定有具体的实现,linuxContainer 就是一个实现,或者说,它是当前版本 runC 在 linux 平台上的唯一一种实现。下面是其定义,其中的 initPath 非常关键
type linuxContainer struct {
id string
config *configs.Config
initPath string
initArgs []string
initProcess parentProcess
…..
}
Factory 接口
在 runC 中,所有的容器都是由容器工厂(Factory)创建的, Factory 也是一个抽象接口,定义如下,它只包含了 4 个方法
type Factory interface {
Create(id string, config *configs.Config) (Container, error)
Load(id string) (Container, error)
StartInitialization() error
Type() string
}
linux 平台上的对 Factory 接口也有一个标准实现 —LinuxFactory, 其中的 InitPath 也非常关键,稍后我们会看到
// LinuxFactory implements the default factory interface for linux based systems.
type LinuxFactory struct {
// InitPath is the path for calling the init responsibilities for spawning
// a container.
InitPath string
……

// InitArgs are arguments for calling the init responsibilities for spawning
// a container.
InitArgs []string
}
所以,对于 linux 平台,Factory 创建 Container 实际上就是 LinuxFactory 创建 linuxContainer
回到 createContainer(), 下面是其实现
func createContainer(context *cli.Context, id string, spec *specs.Spec) (libcontainer.Container, error) {
/* 1. 将配置存放到 config */
rootlessCg, err := shouldUseRootlessCgroupManager(context)
config, err := specconv.CreateLibcontainerConfig(&specconv.CreateOpts{
CgroupName: id,
UseSystemdCgroup: context.GlobalBool(“systemd-cgroup”),
NoPivotRoot: context.Bool(“no-pivot”),
NoNewKeyring: context.Bool(“no-new-keyring”),
Spec: spec,
RootlessEUID: os.Geteuid() != 0,
RootlessCgroups: rootlessCg,
})

/* 2. 加载 Factory */
factory, err := loadFactory(context)
if err != nil {
return nil, err
}

/* 3. 调用 Factory 的 Create()方法 */
return factory.Create(id, config)
}
可以看到,上面的代码大体上分为

将配置存放到 config
加载 Factory
调用 Factory 的 Create()方法

第 1 步存放配置没什么好说的,无非是将已有的 spec 和其他一些用户命令行选项配置换成一个数据结构存下来。而第 2 部加载 Factory,在 linux 上,就是返回一个 LinuxFactory 结构。而这是通过在其内部调用 libcontainer.New()方法实现的
/* utils/utils_linux.go */
func loadFactory(context *cli.Context) (libcontainer.Factory, error) {
…..
return libcontainer.New(abs, cgroupManager, intelRdtManager,
libcontainer.CriuPath(context.GlobalString(“criu”)),
libcontainer.NewuidmapPath(newuidmap),
libcontainer.NewgidmapPath(newgidmap))
}
libcontainer.New() 方法在 linux 平台的实现如下,可以看到,它的确会返回一个 LinuxFactory,并且 InitPath 设置为 ”/proc/self/exe”,InitArgs 设置为 ”init”
/* libcontainer/factory_linux.go */
func New(root string, options …func(*LinuxFactory) error) (Factory, error) {
…..
l := &LinuxFactory{
…..
InitPath: “/proc/self/exe”,
InitArgs: []string{os.Args[0], “init”},
}
……
return l, nil
}
得到了具体的 Factory 实现,下一步就是调用其 Create()方法, 对 linux 平台而言,就是下面这个方法,可以看到,它会将 LinuxFactory 上记录的 InitPath 和 InitArgs 赋给 linuxContainer 并作为结果返回
func (l *LinuxFactory) Create(id string, config *configs.Config) (Container, error) {
….
c := &linuxContainer{
id: id,
config: config,
initPath: l.InitPath,
initArgs: l.InitArgs,
}
…..
return c, nil
}
回到 startContainer() 方法,再得到 linuxContainer 后,将创建一个 runner 结构, 并调用其 run()方法
/* utils_linux.go */
func startContainer(context *cli.Context, spec *specs.Spec, action CtAct, criuOpts *libcontainer.CriuOpts) (int, error) {
id := context.Args().First()

container, err := createContainer(context, id, spec)

r := &runner{
container: container,
action: action,
init: true,
……
}
return r.run(spec.Process)
}
runner 的 run() 的入参是 spec.Process 结构,我们并不需要关注它的定义,因为它的内容都来源于 config.json 文件,spec.Process 不过是其中 Process 部分的 Go 语言数据的表示。run() 方法的实现如下:
func (r *runner) run(config *specs.Process) (int, error) {
……
process, err := newProcess(*config, r.init)
……
switch r.action {
case CT_ACT_CREATE:
err = r.container.Start(process) /* runc start */
case CT_ACT_RESTORE:
err = r.container.Restore(process, r.criuOpts) /* runc restore */
case CT_ACT_RUN:
err = r.container.Run(process) /* runc run */
default:
panic(“Unknown action”)
}
……
return status, err
}
上面的 run() 可分为两部分

调用 newProcess() 方法, 用 spec.Process 创建 libcontainer.Process, 注意第二个参数是 true,表示新创建的 process 会作为新创建容器的第一个 process

根据 r.action 的值决定如何操作得到的 libcontainer.Process

libcontainer.Process 结构定义在 /libcontainer/process.go, 其中大部分内容都来自 spec.Process
/* parent process */
// Process specifies the configuration and IO for a process inside
// a container.
type Process struct {
Args []string
Env []string
User string
AdditionalGroups []string
Cwd string
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
ExtraFiles []*os.File

ConsoleWidth uint16
ConsoleHeight uint16
Capabilities *configs.Capabilities
AppArmorProfile string
Label string
NoNewPrivileges *bool
Rlimits []configs.Rlimit
ConsoleSocket *os.File
Init bool

ops processOperations
}
接下来就是要使用 Start() 方法了
func (c *linuxContainer) Start(process *Process) error {

if process.Init {
if err := c.createExecFifo(); err != nil { /* 1. 创建 fifo */
return err
}
}
if err := c.start(process); err != nil {/* 2. 调用 start() */
if process.Init {
c.deleteExecFifo()
}
return err
}
return nil
}
Start() 方法主要完成两件事

创建 fifo: 创建一个名为 exec.fifo 的管道,这个管道后面会用到
调用 start() 方法,如下

func (c *linuxContainer) start(process *Process) error {
parent, err := c.newParentProcess(process) /* 1. 创建 parentProcess */

err := parent.start(); /* 2. 启动这个 parentProcess */
……

start() 也完成两件事:

创建一个 ParentProcess

调用这个 ParentProcess 的 start() 方法

那么什么是 parentProcess ? 正如其名,parentProcess 类似于 linux 中可以派生出子进程的父进程,在 runC 中,parentProcess 是一个抽象接口,如下:
type parentProcess interface {
// pid returns the pid for the running process.
pid() int

// start starts the process execution.
start() error

// send a SIGKILL to the process and wait for the exit.
terminate() error

// wait waits on the process returning the process state.
wait() (*os.ProcessState, error)

// startTime returns the process start time.
startTime() (uint64, error)

signal(os.Signal) error

externalDescriptors() []string

setExternalDescriptors(fds []string)
}
它有两个实现,分别为 initProcess 和 setnsProcess,前者用于创建容器内的第一个进程,后者用于在已有容器内创建新的进程。在我们的创建容器例子中,p.Init = true,所以会创建 initProcess
func (c *linuxContainer) newParentProcess(p *Process) (parentProcess, error) {
parentPipe, childPipe, err := utils.NewSockPair(“init”) /* 1. 创建 Socket Pair */

cmd, err := c.commandTemplate(p, childPipe) /* 2. 创建 *exec.Cmd */

if !p.Init {
return c.newSetnsProcess(p, cmd, parentPipe, childPipe)
}

if err := c.includeExecFifo(cmd); err != nil {/* 3. 打开之前创建的 fifo */
return nil, newSystemErrorWithCause(err, “including execfifo in cmd.Exec setup”)
}
return c.newInitProcess(p, cmd, parentPipe, childPipe) /* 4. 创建 initProcess */
}
newParentProcess() 方法动作有 4 步,前 3 步都是在为第 4 步做准备,即生成 initProcess

创建一对 SocketPair 没什么好说的,生成的结果会放到 initProcess

创建 *exec.Cmd, 代码如下,这里设置了 cmd 要执行的可执行程序和参数来自 c.initPath,即源自 LInuxFactory 的 “/proc/self/exe”, 和 “init”,这表示新执行的程序就是 runC 本身,只是参数变成了 init,之后又将外面创建的 SocketPair 的一端 childPipe 放到了 cmd.ExtraFiles,同时将_LIBCONTAINER_INITPIPE=%d 加入 cmd.Env, 其中 %d 为文件描述符的数字

func (c *linuxContainer) commandTemplate(p *Process, childPipe *os.File) (*exec.Cmd, error) {
cmd := exec.Command(c.initPath, c.initArgs[1:]…)
cmd.Args[0] = c.initArgs[0]

cmd.ExtraFiles = append(cmd.ExtraFiles, p.ExtraFiles…)
cmd.ExtraFiles = append(cmd.ExtraFiles, childPipe)
cmd.Env = append(cmd.Env,
fmt.Sprintf(“_LIBCONTAINER_INITPIPE=%d”, stdioFdCount+len(cmd.ExtraFiles)-1),
)
……
return cmd, nil
}

includeExecFifo() 方法打开之前创建的 fifo,也将其 fd 放到 cmd.ExtraFiles 中。
最后就是创建 InitProcess 了,这里首先将_LIBCONTAINER_INITTYPE=”standard” 加入 cmd.Env,然后从 configs 读取需要新的容器创建的 Namespace 的类型,并将其打包到变量 data 中备用,最后再创建 InitProcess 自己,可以看到,这里将之前的一些资源和变量都联系了起来

func (c *linuxContainer) newInitProcess(p *Process, cmd *exec.Cmd, parentPipe, childPipe *os.File) (*initProcess, error) {
cmd.Env = append(cmd.Env, “_LIBCONTAINER_INITTYPE=”+string(initStandard))
nsMaps := make(map[configs.NamespaceType]string)
for _, ns := range c.config.Namespaces {
if ns.Path != “” {
nsMaps[ns.Type] = ns.Path
}
}
_, sharePidns := nsMaps[configs.NEWPID]
data, err := c.bootstrapData(c.config.Namespaces.CloneFlags(), nsMaps)
if err != nil {
return nil, err
}
return &initProcess{
cmd: cmd,
childPipe: childPipe,
parentPipe: parentPipe,
manager: c.cgroupManager,
intelRdtManager: c.intelRdtManager,
config: c.newInitConfig(p),
container: c,
process: p,
bootstrapData: data,
sharePidns: sharePidns,
}, nil
}
回到 linuxContainer 的 start() 方法,创建好了 parent,下一步就是调用它的 start() 方法了
func (c *linuxContainer) start(process *Process) error {
parent, err := c.newParentProcess(process) /* 1. 创建 parentProcess (已完成) */

err := parent.start(); /* 2. 启动这个 parentProcess */
……
—– 待续

退出移动版