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

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

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

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

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

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

1.1 容器标准化宗旨

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

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

 

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

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

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

  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//state.json)中存放容器的状态信息并实时更新。有了这样默认的容器状态信息存储位置以后,外部的应用程序就可以在系统上简便地找到所有运行着的容器了。

state.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平台。

# 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的执行流程,整个分析过程如下图:

 

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功能。简要的过程如下图所示。

 

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文件中文件系统的可读写性设置为可读。


部分配置如下图所示。


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

 

http://dockone.io/article/776

你可能感兴趣的:(docker)