DockOne技术分享(二十八): OCI标准和runC原理解读

在过去两年中随着互联网和容器技术的发展,几乎主要的全部的IT供应商和云服务提供商都开始采用以容器技术为基础的解决方案,与容器相关的组织也如雨后春笋般增加。因而为了确保容器的可迁移性,容器格式和运行时标准的创建就显得尤其重要。

因此,Linux基金会于2015年6月成立OCI(Open Container Initiative)组织,旨在围绕容器格式和运行时制定一个开放的工业化标准。该组织一成立便获得了包括谷歌、微软、亚马逊、华为等一系列云计算厂商的支持。linux

1. 容器格式标准是什么?

制定容器格式标准的宗旨归纳来讲就是不受上层结构的绑定,如特定的客户端、编排栈等,同时也不受特定的供应商或项目的绑定,即不限于某种特定操做系统、硬件、CPU架构、公有云等。

该标准目前由libcontainer和appc的项目负责人(maintainer)进行维护和制定,其规范文档就做为一个项目在GitHub上维护。git

1.1 容器标准化宗旨

标准化容器的宗旨具体分为以下五条。github

  1. 操做标准化:容器的标准化操做包括使用标准容器建立、启动、中止容器,使用标准文件系统工具复制和建立容器快照,使用标准化网络工具进行下载和上传。
  2. 内容无关:内容无关指无论针对的具体容器内容是什么,容器标准操做执行后都能产生一样的效果。如容器能够用一样的方式上传、启动,不论是PHP应用仍是MySQL数据库服务。
  3. 基础设施无关:不管是我的的笔记本电脑仍是AWS S3,亦或是OpenStack,或者其它基础设施,都应该对支持容器的各项操做。
  4. 为自动化量身定制:制定容器统一标准,是的操做内容无关化、平台无关化的根本目的之一,就是为了可使容器操做全平台自动化。
  5. 工业级交付:制定容器标准一大目标,就是使软件分发能够达到工业级交付成为现实。

 

1.2 容器标准包(bundle)和配置

一个标准的容器包具体应该至少包含三块部分:

config.json: 基本配置文件,包括与宿主机独立的和应用相关的特定信息,如安全权限、环境变量和参数等。具体以下:docker

  1. 容器格式版本
  2. rootfs路径及是否只读
  3. 各种文件挂载点及相应容器内挂载目录(此配置信息必须与runtime.json 配置中保持一致)
  4. 初始进程配置信息,包括是否绑定终端、运行可执行文件的工做目录、环境变量配置、可执行文件及执行参数、uid、gid以及额外须要加入的gid、hostname、低层操做系统及CPU架构信息。


runtime.json:运行时配置文件,包含运行时与主机相关的信息,如内存限制、本地设备访问权限、挂载点等。除了上述配置信息之外,运行时配置文件还提供了“钩子(hooks)”的特性,这样能够在容器运行前和中止后各执行一些自定义脚本。hooks的配置包含执行脚本路径、参数、环境变量等。

rootfs/:根文件系统目录,包含了容器执行所需的必要环境依赖,如/bin、/var、/lib、/dev、/usr等目录及相应文件。rootfs目录必须与包含配置信息的config.json文件同时存在容器目录最顶层。数据库

1.3 容器运行时和生命周期

容器标准格式也要求容器把自身运行时的状态持久化到磁盘中,这样便于外部的其它工具对此信息使用和演绎。该运行时状态以JSON格式编码存储。推荐把运行时状态的JSON文件存储在临时文件系统中以便系统重启后会自动移除。

基于Linux内核的操做系统,该信息应该统一地存储在/run/opencontainer/containers目录,该目录结构下以容器ID命名的文件夹(/run/opencontainer/containers/<containerID>/state.json)中存放容器的状态信息并实时更新。有了这样默认的容器状态信息存储位置之后,外部的应用程序就能够在系统上简便地找到全部运行着的容器了。

state.json文件中包含的具体信息须要有:json

  • 版本信息:存放OCI标准的具体版本号。
  • 容器ID:一般是一个哈希值,也能够是一个易读的字符串。在state.json文件中加入容器ID是为了便于以前提到的运行时hooks只需载入state.json就能够定位到容器,而后检测state.json,发现文件不见了就认为容器关停,再执行相应预约义的脚本操做。
  • PID:容器中运行的首个进程在宿主机上的进程号。
  • 容器文件目录:存放容器rootfs及相应配置的目录。外部程序只需读取state.json就能够定位到宿主机上的容器文件目录。 标准的容器生命周期应该包含三个基本过程。
  • 容器建立:建立包括文件系统、namespaces、cgroups、用户权限在内的各项内容。
  • 容器进程的启动:运行容器进程,进程的可执行文件定义在的config.json中,args项。
  • 容器暂停:容器实际上做为进程能够被外部程序关停(kill),而后容器标准规范应该包含对容器暂停信号的捕获,并作相应资源回收的处理,避免孤儿进程的出现。

 

1.4 基于开放容器格式(OCF)标准的具体实现

从上述几点中总结来看,开放容器规范的格式要求很是宽松,它并不限定具体的实现技术也不限定相应框架,目前已经有基于OCF的具体实现,相信不久后会有愈来愈多的项目出现。

容器运行时opencontainers/runc,即本文所讲的RunC项目,是后来者的参照标准。

虚拟机运行时hyperhq/runv,基于Hypervisor技术的开放容器规范实现。

测试huawei-openlab/oct基于开放容器规范的测试框架。安全

2. runC工做原理与实现方式

2.1 runC从libcontainer的变迁

runC的前身其实是Docker的libcontainer项目演化而来。runC实际上就是libcontainer配上了一个轻型的客户端。

从本质上来讲,容器是提供一个与宿主机系统共享内核但与系统中的其它进程资源相隔离的执行环境。Docker经过调用libcontainer包对namespaces、cgroups、capabilities以及文件系统的管理和分配来“隔离”出一个上述执行环境。一样的,runC也是对libcontainer包进行调用,去除了Docker包含的诸如镜像、Volume等高级特性,以最朴素简洁的方式达到符合OCF标准的容器管理实现。

整体而言,从libcontainer项目转变为runC项目至今,其功能和特性并无太多变化,具体有以下几点。网络

  1. 把原先的nsinit移除,放到外面,命令名称改成runC,一样使用cli.go实现,一目了然。
  2. 按照开放容器标准把原先全部信息混在一块儿的一个配置文件拆分红config.json和runtime.json两个。
  3. 增长了按照开放容器标准设定的容器运行前和中止后执行的hook脚本功能。
  4. 相比原先的nsinit时期的指令,增长了runc kill命令,用于发送一个SIG_KILL信号给指定容器ID的init进程。


整体而言,runC但愿包含的特征有:架构

  1. 支持全部的Linux namespaces,包括user namespaces。目前user namespaces还没有包含。
  2. 支持Linux系统上原有的全部安全相关的功能,包括Selinux、 Apparmor、seccomp、cgroups、capability drop、pivot_root、 uid/gid dropping等等。目前已完成上述功能的支持。
  3. 支持容器热迁移,经过CRIU技术实现。目前功能已经实现,可是使用起来还会产生问题。
  4. 支持Windows 10 平台上的容器运行,由微软的工程师开发中。目前只支持Linux平台。
  5. 支持Arm、Power、Sparc硬件架构,将由Arm、Intel、Qualcomm、IBM及整个硬件制造商生态圈提供支持。
  6. 计划支持尖端的硬件功能,如DPDK、sr-iov、tpm、secure enclave等等。
  7. 生产环境下的高性能适配优化,由Google工程师基于他们在生产环境下的容器部署经验而贡献。
  8. 做为一个正式真实而全面具体的标准存在!

 

2.2 runC是如何启动容器的?

从开放容器标准中咱们已经定义了关于容器的两份配置文件和一个依赖包,runC就是经过这些来启动一个容器的。首先咱们按照官方的步骤来操做一下。

runC运行时须要有rootfs,最简单的就是你本地已经安装好了Docker,经过docker pull busybox下载一个基本的镜像,而后经过
docker export $(docker create busybox) > busybox.tar导出容器镜像的rootfs文件压缩包,命名为busybox.tar。而后解压缩为rootfs目录,mkdir rootfstar -C rootfs -xf busybox.tar
这时咱们就有了OCF标准的rootfs目录,须要说明的是,咱们使用Docker只是为了获取rootfs目录的方便,runc的运行自己不依赖Docker。

接下来你还须要config.jsonruntime.json,使用runc spec能够生成一份标准的config.jsonruntime.json配置文件,固然你也能够按照格式本身编写。

若是你尚未安装runC,那就须要按照以下步骤安装一下,目前runC暂时只支持Linux平台。app

# create a 'github.com/opencontainers' in your GOPATH/srccd github.com/opencontainersgit clone https://github.com/opencontainers/runccd runcmakesudo make install


最后执行runc start你就启动了一个容器了。

2.3 runC start运行原理

上面说到过runC就是libcontainer外面裹上了一层很薄的Cli。其中的Cli是为了快速开发Go语言的命令行应用而实现的开发包,它能够为你处理诸如子命令定义,标志位定义和设置帮助信息等等。而且Cli也是托管在Git上面的一个开源项目,地址为:github.com/codegangsta/cli。
从源码角度,分析runC start的执行流程,整个分析过程以下图:

Picture3.jpg

 

2.3.1.一切从main()函数开始

整个程序首先执行main.go中的main()函数,在这个函数中,程序经过cli包对runC的各个子命令、参数、版本号以及帮助信息进行规定。而后程序会经过用户输入的子命令来调用对应的处理函数,这里则调用start.go中的startContainer()函数。

2.3.2.建立逻辑容器Container与逻辑进程process

所谓的逻辑容器container和逻辑进程process并不是时真正运行着的容器和进程,而是libcontainer中所定义的结构体。逻辑容器container中包含了namespace、cgroups、device和mountpoint等各类配置信息。逻辑进程process中则包含了容器中所要运行的指令以其参数和环境变量等。

对于runC来讲,容器的定义只须要一种就够了,不一样的容器只是实例的内容(属性和参数)不同而已。对于libcontainer来讲,因为它须要与底层打交道,不一样的平台上就须要建立出彻底异构的“逻辑容器对象”(好比Linux容器和Windows容器),这也就解释了为何这里会使用“工厂模式”:从此libcontainer能够支持更多平台上各类类型容器的实现,而没必要改变调用接口。

下面解释一下逻辑容器Container与逻辑进程process的建立过程。

在startContainer()函数中,程序首先将*.json装入能够被libcontainer使用的结构体config中。而后使用config做为参数来调用。libcontainer.New()生成用来产生container的工厂factory。再调用factory.Create(config),就会生成一个将config包含其中的逻辑容器container。接下来调用newProcess(config)来将config中关于容器内所要运行命令的相关信息填充到process结构体中,这个结构体即为逻辑进程process。使用container.Start(process)来启动逻辑容器。

2.3.3.启动逻辑容器container

runC会调用Start(),Start()函数位于libcontainer/container_linux.go中,主要工做就是调用newParentProcess()来生成parentprocess实例(结构体)和用于runC与容器内init进程相互通讯的管道。

在parentprocess实例中,除了有记录了未来与容器内进程进行通讯的管道与各类基本配置等,还有一个极为重要的字段就是其中的cmd。
cmd字段是定义在os/exec包中的一个结构体。os/exec包主要用于建立一个新的进程,并在这个进程中执行指定的命令。开发者能够在工程中导入os/exec包,而后将cmd结构体进行填充,即将所需运行程序的路径和程序名,程序所需参数,环境变量,各类操做系统特有的属性和拓展的文件描述符等。

在runC中程序将cmd的应用路径字段Path填充为/proc/self/exe(即为应用程序自己,runC)。参数字段Args填充为init,表示对容器进行初始化。SysProcAttr字段中则填充了各类runC所需启用的namespace等属性。

而后调用parentprocess.cmd.Start()启动物理容器中的init进程。接下来将物理容器中init进程的进程号加入到Cgroup控制组中,对容器内的进程实施资源控制。再把配置参数经过管道传送给init进程。最后经过管道等待init进程根据上述配置完成全部的初始化工做,或者出错退出。

2.3.4.物理容器的配置和建立

容器中的init进程首先会调用StartInitialization()函数,经过管道从父进程接收各类配置参数。而后对容器进行以下配置:

  1. 若是用户指定,则将init进程加入其指定的namespace。
  2. 设置进程的会话ID。
  3. 初始化网络设备。
  4. 对指定目录下的文件系统进行挂载,并切换根目录到新挂载的文件系统下。设置hostname,加载profile信息。
  5. 最后使用exec系统调用来执行用户所指定的在容器中运行的程序。

 

3.热迁移的配置与原理简介

3.1 热迁移简介

所谓热迁移就是将一个容器进行Checkpoint操做,并得到一系列文件,使用这一系列文件能够在本机或者其余主机上进行容器的Restore工做。目前,在runC中使用了CRIU做为热迁移的工具,并实现了对容器的Checkpoint和Restore功能。简要的过程以下图所示。

Picture4.jpg

 

3.2 runC热迁移原理简介

在runC中热迁移的工做主要是调用CRIU(Checkpoint and Restore in Userspace)来完成。CIRU负责冻结进程,并将做为一系列文件存储在硬盘上。并负责使用这些文件还原这个被冻结的进程。

runC使用SWRK模式来调用criu。这种模式是criu另外两种模式CLI和RPC的结合体,容许用户须要的时候像使用命令行工具同样运行criu,并接受用户远程调用的请求。

runC主要经过以下两个步骤完成热迁移工做。

  1. 生成container,经过state.json或者配置文件*.json来生成container结构体。
  2. 使用SWRK模式调用CRIU,runC首先收集并整理要进行Checkpoint或者Restore操做的容器的相关信息,并填入要发给SWRK模式下的CRIU的结构体中。结构体主要内容以下:
    req := &criurpc.CriuReq{
    Type: &t,     //C or R
    Opts: &rpcOpts,   //criu相关参数 
    }  
    

    其中的字段t指定了这个请求是进行Checkpoint操做仍是Restore操做,字段rpcOpts中则各类用户指定的选项和CRIU运行所需的参数。


随后经过syscall.Socketpair()建立runC(criuClient)与CIRU(criuServer)之间的通讯管道。而后使用go语言中的os/exec包,以SWRK方式启动criu。再经过criuClient向criuServer发送request。最后经过criuClient接收执行结果便可。

3.3 当前版本下runC热迁移的配置与使用

因为当前版本的CRIU并不是十分完善,还不能彻底支持runC中的一少部分特性,因此在进行热迁移工做的时候须要对配置文件进行一些修改。具体修改的内容和缘由以下:

  • 由于CRIU不支持seccomp,因此须要将config.json文件中关于seccomp的相关内容置空。
  • 由于CRIU不支外部终端,因此须要将config.json文件中terminal的值置为false。
  • 由于CRIU的需求runC所挂载的文件系统时可读的,因此将config.json文件中文件系统的可读写性设置为可读。


部分配置以下图所示。

Picture5.jpg


正确安装CRIU及其相关依赖而且对config.json作出以上的修改后就可使用runC内置的命令对容器进行热迁移了。

 

http://dockone.io/article/776