记录
一直在使用容器docker,但对docker实现的原理却知道甚少,最近在阅读张磊的《深入剖析Kubernetes》,对容器的实现原理做进一步的了解。这里主要对该书的第二章做总结。
容器的基础问题
将容器的基础问题总结为以下几点:
什么是容器
进程
1、操作系统如何运行一个加法程序:
操作系统从程序中发现输入数据保存在一个文件中,这些文件就会被从磁盘只能加载到内存中待命;
操作系统读到计算加法的指令,这时候,它就需要指示CPU去完成加法操作。
CPU与内存写作进行加法运算,会使用寄存器来存放数值、内存堆栈来保存执行的命令和变量。
这个过程中,I/O设备还会不断地修改自己的状态。
如上所述,这个加法程序一旦被运行起来,它就从磁盘上的二进制文件变成了由计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件、以及各种设备的状态信息组成的一个集合。这个集合的环境总和,就称为进程。
因此,对进程来说,它的静态表现就是存在磁盘上面的程序。动态表现是这个程序运行起来后变成的计算机的数据和状态的总和。
容器
容器是一种特殊的进程。这个进程会存在约束: 它具有自己的“边界“,它看不到边界以外的东西,也会存在着资源的限制。后面会介绍这些的详细做法。
容器是怎么做到环境隔离的
示例
# 运行一个名为busybox的容器,并在容器中运行程序/bin/sh
docker run -it busybox /bin/sh
# 宿主机中的/bin/sh进程情况
ps -ef | grep /bin/sh
可以看到在宿主机上面的进程号为5009
# 容器busybox中的bin/sh进程情况
ps -ef
可以看到在容器内部,/bin/sh进程号为1,是这个容器的1号进程。
上面不同的进程号意味着,两个/bin/bash分别在不同的环境中。但实际上,容器中的1号进程,在宿主机中就是对应的5009号进程,只不过在容器中看不到宿主机中的进程罢了。
实现
上面所述的隔离机制实际上是对被隔离应用的进程空间动了手脚,这种技术是linux中的Namespace机制。
在linux上创建进程的系统调用为:
int pid = clone(main_function, stack_size, SIGCHLD, NULL);
这个系统调用会为我们创建一个新的进程,并且返回它的PID。
我们也可以在此系统调用中指定CLONE_NEWPID参数:
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);
这时候,新创建的进程将会在一个新的进程空间中,在这个进程空间中,他的PID为1,但在宿主机真实的进程空间中,这个PID还是原来的真实数值,比如上面的5009。
除了上面提到的PID Namespace,Linux操作系统还提供了Mount、Network等Namespace来对各种进程上下文实施“障眼法”。比如Mount Namespace用于让被隔离进程只看到当前挂载点的信息,Network Namespace 用于让被隔离进程看到当前Namespace里面的网络设备和配置。
容器和虚拟机的区别
虚拟机
虚拟化技术(虚拟机)也能做到不同进程间的隔离,它的结构图如下所示:
其中Hypervisor软件是虚拟机最主要的部分,它通过硬件虚拟化功能模拟出了运行一个操作系统所需要的各种硬件,比如CPU、内存、I/O设备等,然后在这些虚拟的硬件上安装了一个新的操作系统——客户端操作系统。然后用户的应用进程就可以在这个虚拟机的机器中运行了,它能看到的也只有操作系统的文件和目录,以及这台机器里的虚拟设备。
容器
由容器架构图可以看出,与虚拟机不同的是,真正对隔离环境负责的是宿主机操作系统本身。
容器和虚拟机的对比
-
由于虚拟机必须由Hypervisor来负责创建,这个虚拟机是真实存在的,需要单独的客户操作系统,这就带来了额外的资源消耗和占用。相比之下,容器化后的用户应用依然是宿主机上的普通进程,因虚拟化产生的资源占用可以忽略不计。
-
基于Linux Namespace隔离的容器也有不足之处,最主要的问题是隔离的不彻底。因为容器只是在宿主机上运行的一种特殊的进程,那么多个容器间使用的还是同一个宿主机的操作系统内核。这会带来的问题是:如果要在window宿主机运行linux容器,或者在低版本的linux宿主机上运行高版本的linux容器,是不可能的。
-
很多资源和对象不能被Namespace化。例如时间时间。这意味着如果在容器中使用系统调用修改了时间,则整个宿主机的时间都会被修改。
容器是怎么做到资源限制的
为什么需要对容器进行资源限制?
前面提到,系统虽然已经用Namespace对容器做了隔离,但是容器也是操作系统的一个进程,与其他进程具有同等的位置,他所用的资源可以随时被宿主机其他进程所占用,它也可能用光所有的资源。
用Cgroups来为进程设置资源限制
Cgroups 最主要的作用是限制一个进程组能够使用的资源上限,包括cpu、内存、磁盘、带宽等。
Cgroups向用户暴露出来的操作接口是文件系统:
在/sys/fs/cgroup下面诸如cpuset、cpu、memory这些子系统,是当前这台机器可以被Cgroups限制的资源种类。
在子系统对应的资源种类下,可以看到限制这类资源的方法:
例如cpu.cfs_period_us可用于限制长度为cfs_period的一段时间里,只能被分配到总量为
cfs_quota的cpu时间。
示例
如何用Cgroups配置文件为一个进程限制cpu使用时间:
-
在/sys/fs/groups/cpu/子系统下创建目录
cd /sys/fs/groups/cpu/ && mkdir test_container
操作系统会自动在你创建的test_container目录下,自动生成该子系统对应的资源限制文件。 -
在系统后台执行脚本,占满系统cpu
while : ; do : ; done &
该进程的PID为7472
用top 查看cpu占用率:
可以看到该进程目前已经把CPU占满
更改该进程的cpu最高使用率:
echo 20000 > /sys/fs/cgroup/cpu/test_container/cpu.cfs_quota_us # 更改cpu占用率,20us相当于默认的100us来说是20%
echo 7472 > /sys/fs/cgroup/cpu/test_container/tasks # 将上面脚本的PID 7472写到资源限制组的tasks中
再次用top命令查看cpu占用率,现在已经降到20%左右了
总的来说,Linix 的Cgroups设计就是一个子系统目录加上一组资源限制文件的组合。对于docker容器来说,只需要在每个子系统下面为容器创建一个控制组(即创建一个新目录),然后启动容器进程后,把容器进程的PID填到对应控制组的tasks文件中即可。
docker run命令也可以指定资源文件的值,eg:
docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash # 限制这个ubuntu容器只能使用20%的cpu带宽
则在/sys/fs/cgroups/cpu/docker/{docker id}对应的资源目录下可以看到填入的值
容器的文件系统
为了让容器内的文件系统更易于操作,我们一般会在容器的根目录下挂载一个完整操作系统的文件系统,比如Ubuntu 18.04的ISO,这个文件系统就是容器镜像,也称为roofs(根文件系统)。
roofs是操作系统的所有文件和目录,不包含操作系统内核。(内核与宿主机共享)
容器镜像可以解决环境一致性的问题:
因为容器镜像内包含着一整个操作系统,包括应用与它所需要的依赖,在不同的环境下,只要解压打包好的容器镜像,这个应用运行所需要的完整的执行环境就可以重现。
这里还有个问题没解决,镜像虽然解决了一致性问题,但是每升级或者开发一个应用,都需要重新制作一次roofs吗?如果需要的话那也还是相当麻烦,能不能基于一个旧的roofs,以增量的方式对roofs做修改?
Docker在镜像的设计中引入了层的概念,用户制作镜像的每一步操作都会生成一个层,也就是一个增量rootfs。
3个类型的层分别保存着不同类型的文件信息。例如Init层专门用来存放/ect/host/、/etc/resolv.conf等信息,而可读写层专门用来存放你修改rootfs后产生的增量,无论是对这个容器进行增删改查,都发生在这里,容器声明的挂载点也会声明在此处。
这3个类型的层被联合挂载到/var/lib/docker/aufs/mnt/目录下,表现为一个完整的Ubuntu操作系统供容器使用。
下面再简单介绍下几个常用docker 命令的实现原理:
docker exec进入 docker:
一个进程可以选择加入进程已有的某个Namespace中,从而进入该进程所在的容器。
docker commit:
在容器运行起来后,把最上层的可读写层加上原先容器镜像的只读层,再打包成一个新镜像。commit不会提交Init层,避免把/etc/hosts等隐私文件也一起提交。