Libcontainer 是Docker中用于容器管理的包,它基于Go语言实现,通过管理 namespaces
、 cgroups
、 capabilities
以及文件系统来进行容器控制。你可以使用Libcontainer创建容器,并对容器进行生命周期管理。
容器是一个可管理的执行环境,与主机系统共享内核,可与系统中的其他容器进行隔离。
在2013年Docker刚发布的时候,它是一款基于LXC的开源容器管理引擎。把LXC复杂的容器创建与使用方式简化为Docker自己的一套命令体系。随着Docker的不断发展,它开始有了更为远大的目标,那就是反向定义容器的实现标准,将底层实现都抽象化到Libcontainer的接口。这就意味着,底层容器的实现方式变成了一种可变的方案,无论是使用namespace、cgroups技术抑或是使用systemd等其他方案,只要实现了Libcontainer定义的一组接口,Docker都可以运行。这也为Docker实现全面的跨平台带来了可能。
目前版本的Libcontainer,功能实现上涵盖了包括namespaces使用、cgroups管理、Rootfs的配置启动、默认的Linux capability权限集、以及进程运行的环境变量配置。内核版本最低要求为 2.6
,最好是 3.8
,这与内核对namespace的支持有关。
目前除user namespace不完全支持以外,其他五个namespace都是默认开启的,通过 clone
系统调用进行创建。
文件系统方面,容器运行需要 rootfs
。所有容器中要执行的指令,都需要包含在 rootfs
中。所有挂载在容器销毁时都会被卸载,因为mount namespace会在容器销毁时一同消失。为了容器可以正常执行命令,以下文件系统必须在容器运行时挂载到 rootfs
中。
路径 | 类型 | 参数 | 权限及数据 |
/proc | proc | MS_NOEXEC,MS_NOSUID,MS_NODEV | |
/dev | tmpfs | MS_NOEXEC,MS_STRICTATIME | mode=755 |
/dev/shm | shm | MS_NOEXEC,MS_NOSUID,MS_NODEV | mode=1777,size=65536k |
/dev/mqueue | mqueue | MS_NOEXEC,MS_NOSUID,MS_NODEV | |
/dev/pts | devpts | MS_NOEXEC,MS_NOSUID | newinstance,ptmxmode=0666,mode=620,gid5 |
/sys | sysfs | MS_NOEXEC,MS_NOSUID,MS_NODEV,MS_RDONLY |
当容器的文件系统刚挂载完毕时, /dev
文件系统会被一系列设备节点所填充,所以 rootfs
不应该管理 /dev
文件系统下的设备节点,Libcontainer会负责处理并正确启动这些设备。设备及其权限模式如下。
路径 | 模式 | 权限 |
/dev/null | 0666 | rwm |
/dev/zero | 0666 | rwm |
/dev/full | 0666 | rwm |
/dev/tty | 0666 | rwm |
/dev/random | 0666 | rwm |
/dev/urandom | 0666 | rwm |
/dev/fuse | 0666 | rwm |
容器支持伪终端 TTY
,当用户使用时,就会建立 /dev/console
设备。其他终端支持设备,如 /dev/ptmx
则是宿主机的 /dev/ptmx
链接。容器中指向宿主机 /dev/null
的IO也会被重定向到容器内的 /dev/null
设备。当 /proc
挂载完成后, /dev/
中与IO相关的链接也会建立,如下表。
源地址 | 目的地址 |
/proc/1/fd | /dev/fd |
/proc/1/fd/0 | /dev/stdin |
/proc/1/fd/1 | /dev/stdout |
/proc/1/fd/2 | /dev/stderr |
pivot_root
则用于改变进程的根目录,这样可以有效的将进程控制在我们建立的 rootfs
中。如果 rootfs
是基于 ramfs
的(不支持 pivot_root
),那么会在 mount
时使用 MS_MOVE
标志位加上 chroot
来顶替。
当文件系统创建完毕后, umask
权限被重新设置回 0022
。
在 《Docker背后的内核知识:cgroups资源隔离》 一文中已经提到,Docker使用cgroups进行资源管理与限制,包括设备、内存、CPU、输入输出等。
目前除网络外所有内核支持的子系统都被加入到Libcontainer的管理中,所以Libcontainer使用cgroups原生支持的统计信息作为资源管理的监控展示。
容器中运行的第一个进程 init
,必须在初始化开始前放置到指定的cgroup目录中,这样就能放置初始化完成后运行的其他用户指令逃逸出cgroups的控制。父子进程的同步则通过管道来完成,在随后的运行时初始化中会进行展开描述。
容器安全一直是被广泛探讨的话题,使用namespace对进程进行隔离是容器安全的基础,遗憾的是,usernamespace由于设计上的复杂性,还没有被Libcontainer完全支持。
Libcontainer目前可通过配置 capabilities
、 selinux
、 apparmor
以及 seccomp
进行一定的安全防范,目前除 seccomp
以外都有一份 默认的配置项 提供给用户作为参考。
在本系列的后续文章中,我们将对容器安全进行更深入的探讨,敬请期待。
在容器创建过程中,父进程需要与容器的 init
进程进行同步通信,通信的方式则通过向容器中传入管道来实现。当 init
启动时,他会等待管道内传入 EOF
信息,这就给父进程完成初始化,建立uid/gid映射,并把新进程放进新建的cgroup一定的时间。
在Libcontainer中运行的应用(进程),应该是事先静态编译完成的。Libcontainer在容器中并不提供任何类似Unix init这样的守护进程,用户提供的参数也是通过 exec
系统调用提供给用户进程。通常情况下容器中也没有长进程存在。
如果容器打开了伪终端,就会通过 dup2
把console作为容器的输入输出(STDIN, STDOUT, STDERR)对象。
除此之外,以下四个文件也会在容器运行时自动生成。
用户也可以在运行着的容器中执行一条新的指令,就是我们熟悉的 docker exec
功能。同样,执行指令的二进制文件需要包含在容器的 rootfs
之内。
通过这种方式运行起来的进程会随容器的状态变化,如容器被暂停,进程也随之暂停,恢复也随之恢复。当容器进程不存在时,进程就会被销毁,重启也不会恢复。
目前libcontainer已经集成了 CRIU 作为容器检查点保存与恢复(通常也称为热迁移)的解决方案,应该在不久之后就会被Docker使用。也就是说,通过libcontainer你已经可以把一个正在运行的进程状态保存到磁盘上,然后在本地或其他机器中重新恢复当前的运行状态。这个功能主要带来如下几个好处。
要使用这个功能,需要保证机器上已经安装了1.5.2或更高版本的 criu
工具。不同Linux发行版都有 criu
的安装包,你也可以在CRIU官网上找到从 源码安装 的方法。我们将会在 nsinit
的使用中介绍容器热迁移的使用方法。
CRIU(Checkpoint/Restore In Userspace)由OpenVZ项目于2005年发起,因为其涉及的内核系统繁多、代码多达数万行,其复杂性与向后兼容性都阻碍着它进入内核主线,几经周折之后决定在用户空间实现,并在2012年被Linus加并入内核主线,其后得以快速发展。
你可以在CRIU官网查看 其原理 ,简单描述起来可以分为两部分,一是检查点的保存,其中分为3步。
第二部分自然是恢复,分为4步。
至此,libcontainer的基本特性已经预览完毕,下面我们将从使用开始,一步步深入libcontainer的原理。
nsinit
与Libcontainer的使用 俗话说,了解一个工具最好的入门方式就是去使用它, nsinit
就是一个为了方便不通过Docker就可以直接使用 libcontainer
而开发的命令行工具。它可以用于启动一个容器或者在已有的容器中执行命令。使用 nsinit
需要有 rootfs 以及相应的配置文件。
nsinit
的构建 使用 nsinit
需要 rootfs
,最简单最常用的是使用 Docker busybox
,相关配置文件则可以参考 sample_configs
目录,主要配置的参数及其作用将在 配置参数 一节中介绍。拷贝一份命名为 container.json
文件到你 rootfs
所在目录中,这份文件就包含了你对容器做的特定配置,包括运行环境、网络以及不同的权限。这份配置对容器中的所有进程都会产生效果。
具体的构建步骤在官方的 README
文档中已经给出,在此为了节省篇幅不再赘述。
最终编译完成后生成 nsinit
二进制文件,将这个指令加入到系统的环境变量,在busybox目录下执行如下命令,即可使用,需要 root 权限。
nsinit exec --tty --config container.json /bin/bash
执行完成后会生成一个以容器ID命名的文件夹,上述命令没有指定容器ID,默认名为”nsinit”,在“nsinit”文件夹下会生成一个 state.json
文件,表示容器的状态,其中的内容与配置参数中的内容类似,展示容器的状态。
nsinit
的使用 目前 nsinit
定义了9个指令,使用 nsinit -h
就可以看到,对于每个单独的指令使用 --help
就能获得更详细的使用参数,如 nsinit config --help
。
nsinit
这个命令行工具是通过 cli.go
实现的, cli.go
封装了命令行工具需要做的一些细节,包括参数解析、命令执行函数构建等等,这就使得 nsinit
本身的代码非常简洁明了。具体的命令功能如下。
nsinit
。 nsinit exec
后,传入到容器中运行的实际上是 nsinit init
,把用户指令作为配置项传入。 state.json
文件。 --image-path
参数,后面是检查点保存的快照文件路径。完整的命令示例如下。 nsinit checkpoint --image-path =/tmp/criu
restore:从容器检查点快照恢复容器进程的运行。参数同上。
总结起来, nsinit
与Docker execdriver进行的工作基本相同,所以在Docker的源码中并不会涉及到 nsinit
包的调用,但是 nsinit
为Libcontainer自身的调试和使用带来了极大的便利。
no_pivot_root
:这个参数表示用 rootfs
作为文件系统挂载点,不单独设置 pivot_root
。 parent_death_signal
: 这个参数表示当容器父进程销毁时发送给容器进程的信号。 pivot_dir
:在容器 root
目录中指定一个目录作为容器文件系统挂载点目录。 rootfs
:容器根目录位置。 readonlyfs
:设定容器根目录为只读。 mounts
:设定额外的挂载,填充的信息包括原路径,容器内目的路径,文件系统类型,挂载标识位,挂载的数据大小和权限,最后设定共享挂载还是非共享挂载(独立于 mount_label
的设定起作用)。 devices
:设定在容器启动时要创建的设备,填充的信息包括设备类型、容器内设备路径、设备块号(major,minor)、cgroup文件权限、用户编号、用户组编号。 mount_label
:设定共享挂载还是非共享挂载。 hostname
:设定主机名。 namespaces
:设定要加入的namespace,每个不同种类的namespace都可以指定,默认与父进程在同一个namespace中。 capabilities
:设定在容器内的进程拥有的 capabilities
权限,所有没加入此配置项的 capabilities
会被移除,即容器内进程失去该权限。 networks
:初始化容器的网络配置,包括类型(loopback、veth)、名称、网桥、物理地址、IPV4地址及网关、IPV6地址及网关、Mtu大小、传输缓冲长度 txqueuelen
、Hairpin Mode设置以及宿主机设备名称。 routes
:配置路由表。 cgroups
:配置cgroups资源限制参数,使用的参数不多,主要包括允许的设备列表、内存、交换区用量、CPU用量、块设备访问优先级、应用启停等。 apparmor_profile
:配置用于selinux的apparmor文件。 process_label
:同样用于selinux的配置。 rlimits
:最大文件打开数量,默认与父进程相同。 additional_groups
:设定 gid
,添加同一用户下的其他组。 uid_mappings
:用于User namespace的uid映射。 gid_mappings
:用户User namespace的gid映射。 readonly_paths
:在容器内设定只读部分的文件路径。 MaskPaths
:配置不使用的设备,通过绑定 /dev/null
进行路径掩盖。 在Docker中,对容器管理的模块为 execdriver
,目前Docker支持的容器管理方式有两种,一种就是最初支持的LXC方式,另一种称为 native
,即使用Libcontainer进行容器管理。在孙宏亮的《Docker源码分析系列》中,Docker Deamon启动过程中就会对execdriver进行初始化,会根据驱动的名称选择使用的容器管理方式。
虽然在 execdriver
中只有LXC和native两种选择,但是native(即 Libcontainer
)通过接口的方式定义了一系列容器管理的操作,包括处理容器的创建(Factory)、容器生命周期管理(Container)、进程生命周期管理(Process)等一系列接口,相信如果Docker的热潮一直像如今这般汹涌,那么不久的将来,Docker必将实现其全平台通用的宏伟蓝图。本节也将从Libcontainer的这些抽象对象开始讲解,与你一同解开Docker容器管理之谜。在介绍抽象对象的具体实现过程中会与Docker execdriver联系起来,让你充分了解整个过程。
Factory对象为容器创建和初始化工作提供了一组抽象接口,目前已经具体实现的是Linux系统上的Factory对象。Factory抽象对象包含如下四个方法,我们将主要描述这四个方法的工作过程,涉及到具体实现方法则以LinuxFactory为例进行讲解。
id
和一份配置参数创建容器,返回一个运行的进程。容器的 id
由字母、数字和下划线构成,长度范围为1~1024。容器ID为每个容器独有,不能冲突。创建的最终返回一个Container类,包含这个 id
、状态目录(在root目录下创建的以 id
命名的文件夹,存 state.json
容器状态文件)、容器配置参数、初始化路径和参数,以及管理cgroup的方式(包含直接通过文件操作管理和systemd管理两个选择,默认选cgroup文件系统管理)。 id
已经存在时,即已经 Create
过,存在 id
文件目录,就会从 id
目录下直接读取 state.json
来载入容器。其中的参数在配置参数部分有详细解释。 New
不加任何参数,默认在容器进程中运行的第一条命令就是 nsinit init
。在 execdriver
的初始化中,会向 reexec
注册初始化器,命名为 native
,然后在创建Libcontainer以后把 native
作为执行参数传递到容器中执行,这个初始化器创建的Libcontainer就是没有参数的。 至此,容器就已经创建和初始化完毕了。
Container对象主要包含了容器配置、控制、状态显示等功能,是对不同平台容器功能的抽象。目前已经具体实现的是Linux平台下的Container对象。每一个Container进程内部都是线程安全的。因为Container有可能被外部的进程销毁,所以每个方法都会对容器是否存在进行检测。
Status()
判断进程是否存在。 cgroup.procs
中的值,在 Docker背后的内核知识:cgroups资源限制 部分的讲解中我们已经提过, cgroup.procs
文件会罗列所有在该cgroup中的线程组ID(即若有线程创建了子线程,则子线程的PID不包含在内)。由于容器不断在运行,所以返回的结果并不能保证完全存活,除非容器处于“PAUSED”状态。 /sys/class/net/<EthInterface>/statistics
来实现。 Status()
方法的具体实现得知进程是否存活。 cmd
命令模板,配置参数的值就是从 factory.Create()
传入进来的,包括命令执行的工作目录、命令参数、输入输出、根目录、子进程管道以及 KILL
信号的值。 exec.Cmd
对象、cgroup路径、父子进程管道及配置保留到ParentProcess对象中;若不存在,则创建容器进程及相应namespace,目前对user namespace有了一定的支持,若配置时加入user namespace,会针对配置项进行映射,默认映射到宿主机的root用户,最后同样构建出相应的配置内容保留到ParentProcess对象中。通过在 cmd.Env
写入环境变量 _LIBCONTAINER_INITTYPE
来告诉容器进程采用的哪种方式启动。 exec.Cmd
内容,即执行 ParentProcess.start()
,具体的执行过程在Process部分介绍。 state.json
的内容刷新。 SIGKIL
信号(如果没有使用 pid namespace
就不对进程处理)。最后把cgroup及其子系统卸载,删除cgroup文件夹。 cgroup.event_control
写入 eventfd
(用作线程间通信的消息队列)和 cgroup.oom_control
(用于决定内存使用超限后的处理方式)来实现。 至此,Container对象中的所有函数及相关功能都已经介绍完毕,包含了容器生命周期的全部过程。
Libcontainer创建容器进程时需要做初始化工作,此时就涉及到使用了namespace隔离后的两个进程间的通信。我们把负责创建容器的进程称为父进程,容器进程称为子进程。父进程 clone
出子进程以后,依旧是共享内存的。但是如何让子进程知道内存中写入了新数据依旧是一个问题,一般有四种方法。
对于Signal而言,本身包含的信息有限,需要额外记录,namespace带来的上下文变化使其不易理解,并不是最佳选择。显然通过轮询内存的方式来沟通是一个非常低效的做法。另外,因为Docker会加入network namespace,实际上初始时网络栈也是完全隔离的,所以socket方式并不可行。
Docker最终选择的方式就是打开的可读可行文件描述符——管道。
Linux中,通过 pipe(int fd[2])
系统调用就可以创建管道,参数是一个包含两个整型的数组。调用完成后,在 fd[1]
端写入的数据,就可以从 fd[0]
端读取。
// 需要加入头文件: #include <unistd.h> // 全局变量: int fd[2]; // 在父进程中进行初始化: pipe(fd); // 关闭管道文件描述符 close(checkpoint[1]);
调用 pipe
函数后,创建的子进程会内嵌这个打开的文件描述符,对 fd[1]
写入数据后可以在 fd[0]
端读取。通过管道,父子进程之间就可以通信。通信完毕的奥秘就在于 EOF
信号的传递。大家都知道,当打开的文件描述符都关闭时,才能读到 EOF
信号,所以 libcontainer
中父进程先关闭自己这一端的管道,然后等待子进程关闭另一端的管道文件描述符,传来 EOF
表示子进程已经完成了初始化的过程。
Process 主要分为两类,一类在源码中就叫 Process
,用于容器内进程的配置和IO的管理;另一类在源码中叫 ParentProcess
,负责处理容器启动工作,与Container对象直接进行接触,启动完成后作为 Process
的一部分,执行等待、发信号、获得 pid
等管理工作。
ParentProcess对象,主要包含以下六个函数,而根据”需要新建容器”和“在已经存在的容器中执行”的不同方式,具体的实现也有所不同。
docker exec
调用,在execdriver包中,执行 exec
时会引入 nsenter
包,从而调用其中的C语言代码,执行 nsexec()
函数,该函数会读取配置文件,使用 setns()
加入到相应的namespace,然后通过 clone()
在该namespace中生成一个子进程,并把子进程通过管道传递出去,使用 setns()
以后并没有进入pid namespace,所以还需要通过加上 clone()
系统调用。 C
代码,通过管道获得进程pid,最后等待 C
代码执行完毕。 exec.Cmd
自带的 pid()
函数即可获得。 SIGKILL
信号结束进程。 Process对象,主要描述了容器内进程的配置以及IO。包括参数 Args
,环境变量 Env
,用户 User
(由于uid、gid映射),工作目录 Cwd
,标准输入输出及错误输入,控制终端路径 consolePath
,容器权限 Capabilities
以及上述提到的ParentProcess对象 ops
(拥有上面的一些操作函数,可以直接管理进程)。
本文主要介绍了Docker容器管理的方式Libcontainer,从Libcontainer的使用到源码实现方式。我们深入到容器进程内部,感受到了Libcontainer较为全面的设计。总体而言,Libcontainer本身主要分为三大块工作内容,一是容器的创建及初始化,二是容器生命周期管理,三则是进程管理,调用方为Docker的 execdriver
。容器的监控主要通过cgroups的状态统计信息,未来会加入进程追踪等更丰富的功能。另一方面,Libcontainer在安全支持方面也为用户尽可能多的提供了支持和选择。遗憾的是,容器安全的配置需要用户对系统安全本身有足够高的理解,user namespace也尚未支持,可见Libcontainer依旧有很多工作要完善。但是Docker社区的火热也自然带动了大家对Libcontainer的关注,相信在不久的将来,Libcontainer就会变得更安全、更易用。
孙健波, 浙江大学SEL实验室 硕士研究生,目前在云平台团队从事科研和开发工作。浙大团队对PaaS、Docker、大数据和主流开源云计算技术有深入的研究和二次开发经验,团队现将部分技术文章贡献出来,希望能对读者有所帮助。
感谢郭蕾对本文的策划和审校。
给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ,@丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入InfoQ读者交流群 )。