容器的本质是一种特殊的进程,通过Linux Namespace实现“隔离”,使进程只能看到该Namespace内的“世界”,通过Linux Cgroups实现限制,使进程只能使用配额的CPU,内存等系统资源。而它运行所需要的各种文件,整个操作系统文件,是由多个联合挂载在一起的rootfs层提供。

docker vs vm

通过Linux Namespace机制实现容器的隔离

Namespace机制是Linux内核提供的一种隔离资源的机制。Docker使用Namespace实现容器之间的隔离:

  • PID namespace:隔离进程ID,使得容器内外的同一个PID实际上对应不同的进程。
  • IPC namespace:隔离系统间消息队列、信号量和共享内存对象,使得这些资源在容器之间隔离。
  • Network namespace:隔离网络设备、IP地址和端口,每个容器都有自己的网络接口、IP地址和端口。
  • UTS namespace:隔离主机名和域名,每个容器可以有自己的主机名。
  • Mount namespace:隔离挂载点,每个容器都有自己的根文件系统(rootfs)。
  • User namespace:隔离用户和组ID,使得容器内外用户和组ID相同的用户实际上是不同的用户。

Docker项目帮助用户启动的还是原来的的应用进程,只不过在创建这些进程时,Docker为它们加上了各种各样的Namespace参数。以PID namespace为例,在Linux系统中创建进程的系统调用是clone(),它允许在创建新进程时指定Namespace类型(CLONE_NEWPID), 这时,新创建的进程将会“看到”一个全新的进程空间。在这个空间里,它的PID是1.在宿主机的真实进程空间里,这个进程的PID还是真实的数值,比如100.

int pid = clone(main_func, stack_size, CLONE_NEWPID | SIGCHLD, NULL);

可见,容器其实是一种特殊的进程而已。

与传统虚拟机相比,容器的优势在于:

  • 容器化的应用依然是宿主机上的普通进程,不存在因为虚拟化而产生的性能损耗。
  • 不需要单独的客户操作系统,容器内的应用直接运行于宿主机的内核,从而更轻量级。

不过,有利就有弊,基于Linux Namespace的隔离机制相比于虚拟化技术也有很多不足之处。

  • 隔离的不彻底,容器内外还是共享很多系统资源,比如主机的内核、时间源等。这给安全性带来一定风险。
  • 如果要在Windows宿主机上运行Linux容器,或在低版本的Linux主机上运行高版本的Linux容器,都是行不通的。
  • Linux内核中,有很多资源是不能被Namespace化的,最典型的例子就是:时间

所以,在生产环境中,没人敢把在物理机上运行的Linux容器直接暴露在公网上。

通过Linux Cgroups实现容器资源限制

Linux Cgroups (Linux Control groups)最主要的作用就是限制一个进程组能够使用的资源上限,包括CPU,内存,磁盘,网络带宽,等等。
此外,Cgroups还能对进程进行优先级设置,审计,及将将进程挂起和恢复等操作。
在Linux中,Cgroups向用户暴露出来的操作接口是文件系统,在/sys/fs/cgroup路径下。
可以用mount指令将其显示:

$ mount -t cgroup
cpuset on /sys/fs/cgroup/cpuset
cpu on /sys/fs/cgroup/cpu
cpuacct  on /sys/fs/cgroup/cpuacct
blkio on /sys/fs/cgroup/blkio
memory on /sys/fs/cgroup/memory

Cgroups的每一项子系统都有其独有的资源限制能力,比如

  • cpu, 为进程设置cpu使用配额;
  • cpuset, 主要用于设置CPU的亲和性,可以限制cgroup中的进程只能在指定的CPU上运行,或者不能在指定的CPU上运行,同时cpuset还能设置内存的亲和性;
  • cpuacct, 包含当前cgroup所使用的CPU的统计信息;
  • blkio, 为块设备设定I/O限制,一般用于磁盘等设备;
  • memory, 为进程设定内存使用限制;

使用方法为在需要限制资源的子系统路径下创建“控制组”文件夹,在该文件夹下再将限制信息进行修改。最后将需要限制的进程ID写入文件夹下的tasks文件。
例如,以下docker run命令:

$ docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash

启动这个容器后,查看相关限制文件:

$ cat /sys/fs/cgroup/cpu/docker/<id>/cpu.cfs_period_us
100000
$ cat /sys/fs/cgroup/cpu/docker/<id>/cpu.quota_us
20000

这就意味着这个docker容器只能使用20%的cpu。

但是,Cgroups对资源的限制能力也有很多不完善之处,比如/proc文件系统的问题。
/proc下是表示当前内核运行状态的一系列特殊文件,比如CPU使用情况,内存占用率等。
这些文件也是top指令查看系统信息的主要数据来源。
但如果在容器内执行top命令,显示的却是宿主机的CPU和内存数据。
原因是/proc文件系统不了解Cgroups限制的存在。
这个问题是企业容器化应用中碰到的一个常见问题。

深入理解容器镜像

rootfs文件系统

Mount Namespace修改的是容器进程对文件系统“挂载点”的认知,只有这个“挂载”操作发生之后,进程的视图才会改变,而在这之前,新创建的容器会直接继承宿主机的各个挂载点。
但我们希望容器”看到“的文件系统就是一个独立的隔离环境,所以在容器启动之前,可以重新挂载它的整个根目录”/“,并为这个根目录挂载一个完整操作系统的文件系统.
这个挂载在容器根目录上用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有1个更专业的名字,rootfs(根文件系统)。

Docker项目最核心的原理就是为待创建的用户进程:

  • 启用Linux Namespace配置;
  • 设置指定的Cgroups参数;
  • 切换进程的根目录(change root)。

这样,一个完整的容器就诞生了。

Docker在最后一步的切换上,优先使用pivot_root系统调用,如果系统不支持,则会使用chroot命令。

但是,需要明确的是,rootfs只是一个操作系统所包含的目录,文件,配置,并不包含操作系统内核。
同一机器上的所有容器都共享宿主机操作系统内核,如果容器内应用程序需要配置内核参数,加载额外的内核模块,以及跟内核进行直接交互,就需要注意了,这些操作的对象都是宿主机操作系统的内核。

docker image layer

docker在镜像的设计中引入了层(layer)的概念,用户制作镜像的每一步操作都会生成一个层,也就是一个增量的rootfs。
它使用UnionFS(union file system,联合文件系统)的能力,将不同位置的目录联合挂载(union mount)到同一目录下。
例如,在Ubuntu下是使用AuFS这个UnionFS的实现。镜像的层是放置在/var/lib/docker/aufs/diff/<layer_id>目录下,然后被联合挂载在/var/lib/docker/aufs/mnt/<ID>中。
容器的roofs的层可分为3部分:

  • 只读层(ro+wh, 即readonly+whiteout)
  • Init层(ro+wh),用来存放/etc/hosts, /etc/resolv.conf等信息,docker commit不包含这一层的内容。
  • 可读写层(rw),rootfs最上面的一层,如果要删除只读层里的一个文件,AuFS会在可读写层创建一个whiteout文件,把只读层里的文件遮挡起来。
docker image layers

docker exec的工作原理

我们知道,在宿主机上可以通过docker exec -it ...命令进入容器,它是如何实现的呢?
一个进程的每种Namespace信息在宿主机上是存在的,并且以文件的形式存在,对应的目录是/proc/[进程号]/ns/下有一个对应的虚拟文件,并且链接到一个真实的Namespace文件上。 docker exec的原理就是加入容器进程的namespace,这样就可以看到和容器进程一样的视图了。

另外,docker run命令还提供了一个参数--net, 可以启动一个容器并“加入”另一个容器的Network Namespace中,比如:

docker run -it --net container:<id> busybox ifconfig

而如果指定--net=host,则直接共享宿主机的网络栈。

docker volume的工作原理

Volume(数据卷)机制可以将宿主机上的目录或文件挂载到容器中进行读取和修改。 支持两种声明方式:

$ docker run -v /test ...

$ docker run -v /home:/test

以上两种方式都是把一个宿主机的目录挂载进容器的/test目录。
第一种没有显式地声明宿主机目录,Docker默认在宿主机创建一个临时目录/var/lib/docker/volumes/[VOLUME_ID]/_data,然后挂载到/test目录上。
第二种是直接将宿主机的/home目录挂载到了容器的/test目录上。

在rootfs准备好之后,在执行chroot之前,把volume指定的宿主机目录(比如/home)挂载到指定的容器目录在宿主机上对应的目录(/var/lib/docker/aufs/mnt/[可读写层ID]/test)上,这个Volume挂载工作就完成了。
并且由于Mount Namespace已经开启了,宿主机上看不到这个挂载点。

Docker创建的一个容器初始化进程(dockerinit)会负责完成根目录的准备,挂载设备和目录,配置hostname等一系列需要在容器内进行的初始化操作。
最后,它通过execv()系统调用让应用进程取代自己成为容器里PID=1的进程。

最后

本文总结了Linux容器的核心实现原理,需要注意的是,Docker on Mac 以及 Windows Docker(Hyper-V实现)等实际上是基于虚拟化技术实现的,与Linux容器完全不同。