探索 runC (上)

栏目: 编程工具 · 发布时间: 5年前

内容简介:容器运行时(本文包含以下内容:本文

前言

容器运行时( Container Runtime )是指管理容器和容器镜像的软件。当前业内比较有名的有 dockerrkt 等。如果不同的运行时只能支持各自的容器,那么显然不利于整个容器技术的发展。于是在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标准的一系列文件,这些文件包含了运行容器所需要的所有数据,它们存放在一个共同的目录,该目录包含以下两项:

  1. config.json:包含容器运行的配置数据
  2. 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 中的 argterminal 进行修改

{
    "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 等等配置

代码结构

runCGo 语言实现,当前(2018.12)最新版本是 v1.0.0-rc6 ,代码的结构可分为两大块,一是根目录下的 go 文件,对应各个 runC 命令,二是负责创建/启动/管理容器的 libcontainer ,可以说 runC 的本质都在 libcontainer
探索 runC (上)

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)
}

可以看到,上面的代码大体上分为

  1. 将配置存放到config
  2. 加载Factory
  3. 调用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 上记录的 InitPathInitArgs 赋给 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() 可分为两部分

  1. 调用 newProcess() 方法, 用 spec.Process 创建 libcontainer.Process ,注意第二个参数是 true ,表示新创建的 process 会作为新创建容器的第一个 process
  2. 根据 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()方法主要完成两件事

  1. 创建 fifo: 创建一个名为 exec.fifo 的管道,这个管道后面会用到
  2. 调用 start() 方法,如下
func (c *linuxContainer) start(process *Process) error {
    parent, err := c.newParentProcess(process) /*  1. 创建parentProcess */

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

start()也完成两件事:

  1. 创建一个 ParentProcess
  2. 调用这个 ParentProcessstart() 方法

那么什么是 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)
}

它有两个实现,分别为 initProcesssetnsProcess ,前者用于创建容器内的第一个进程,后者用于在已有容器内创建新的进程。在我们的创建容器例子中,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

  1. 创建一对 SocketPair 没什么好说的,生成的结果会放到 initProcess
  2. 创建 *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
}
  1. includeExecFifo() 方法打开之前创建的 fifo,也将其 fd 放到 cmd.ExtraFiles 中。
  2. 最后就是创建 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
}

回到 linuxContainerstart() 方法,创建好了 parent ,下一步就是调用它的 start() 方法了

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

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

----- 待续


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

深入理解计算机系统(原书第2版)

深入理解计算机系统(原书第2版)

(美)Randal E.Bryant、David O'Hallaron / 龚奕利、雷迎春 / 机械工业出版社 / 2011-1-1 / 99.00元

本书从程序员的视角详细阐述计算机系统的本质概念,并展示这些概念如何实实在在地影响应用程序的正确性、性能和实用性。全书共12章,主要内容包括信息的表示和处理、程序的机器级表示、处理器体系结构、优化程序性能、存储器层次结构、链接、异常控制流、虚拟存储器、系统级I/O、网络编程、并发编程等。书中提供大量的例子和练习,并给出部分答案,有助于读者加深对正文所述概念和知识的理解。 本书的最大优点是为程序......一起来看看 《深入理解计算机系统(原书第2版)》 这本书的介绍吧!

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换