目录
- 一、进程
- 二、隔离与限制
- 三、容器镜像
- 总结
- 参考书籍
一、进程
- 容器技术的兴起源于PaaS技术(平台即服务)的普及;
- Docker公司发布的Docker项目具有里程碑式的意义;
- Docker项目通过“容器镜像”解决了应用打包这个根本性难题(CloudFoundry)。
容器本身的价值非常有限,真正有价值的是容器编排。
容器只是一种特殊的进程。
- 容器到底是怎么回事?
容器实际上就是一种沙盒技术,沙盒就是能够像集装箱一样把应用“装”起来,这样应用之间就会因为有了边界而不会相互干扰;被装进集装箱的应用也可以被方便地“搬来搬去”。这就是PaaS最理想的状态。
- 边界的实现手段
假设有一个计算加法的小程序,输入来自一个文件,计算结果输出到另一文件中。计算机只认识0和1,因此最终这段程序会被翻译为二进制文件,再加上所需的数据(输入文件中),放在磁盘上,就等于一个程序,也叫代码的“可执行镜像”,然后就可在计算机上运行这个程序。运行过程如下:
- os从程序中发现输入数据保存在文件中,会将这些数据加载到内存中待命;
- os读到加法指令,指示CPU完成加法操作;
- CPU与内存协作进行加法计算,会使用寄存器存放数值、内存堆栈保存执行的命令和变量;同时计算机里还有被打开的文件,以及各种I/O设备在不断的调用中修改自己的状态。
因此,程序一旦被执行,就从磁盘上的二进制文件变成了由 {计算机上内存数据+寄存器的值+堆栈中的指令+被打开的文件+各种设备的状态信息}
组成的一个集合。
因此,进程的静态表现 = 存储在磁盘中;动态表现 = 运行起来变成计算机中数据和状态的总和。
容器的核心功能就是:通过约束和修改进程的动态表现,为其创造一个“边界”。对于Docker等大多数Linux容器来说,Croups技术
是用来制造约束的主要手段;Namespace技术
是用来修改进程视图的主要方法。
- Namespace技术
假设我Linux上已拥有Docker,首先创建一个容器:
docker run -it gcc:9.2.0 /bin/bash
# 启动容器,在容器中执行命令/bin/bash,并为我分配一个终端来与这个容器交互
-it :告诉Docker在启动容器后为我们分配一个文本输入/输出环境(TTY),与容器的标准输入关联,这样就可以与Docker容器进行交互;
gcc:9.2.0: 我已拉取到Docker中的镜像文件;
/bin/bash:在Docker容器中运行的程序。
至此我的ubuntu变成了宿主机,容器在此宿主机上运行。再在容器中运行命令:ps
在容器外在运行ps命令:
可以看到,再Docker内最开始执行的是/bin/bash,因此他是容器内部的1号进程(PID=1),而在容器外部,他的PID不再是1。这就意味着容器中正在执行的这两个进程,已经被Docker隔离到了与宿主机完全不同的世界中。什么原理呢?
当我们在宿主机上运行一个/bin/bash程序,os会为其分配一个PID(如图5302),粗略地将/bin/bash理解为公司的第5302号员工,1号是CEO。现在我们想通过Docker在容器运行这个/bin/bash程序,这时,Docker会在这个第5302号员工入职时给他一个”障眼法“,让他永远看不到前面的5301位员工,因此他就会误以为自己是公司的第一位员工(对应容器内的PID=1)。
这种机制实际上就是对被隔离的应用的进程空间动了手脚,使这些进程只能看到重新计算过的PID(例如PID=1),实际上在宿主机中他还是第5302号进程。
这种技术就是Linux中的Namespace机制,他只是Linux创建进程的一个可选参数。如Linux中创建进程的系统调用(之一)是clone() ,这个系统调用会为我们创建一个新进程,并返回他的PID:
int pid = clone(main_function, stack_size, SIGCHLD, NULL);
而当我们使用clone()系统调用创建一个新进程时,可以在参数中指定CLONE_NEWPID参数:
int pid = clone(main_function, stack_size, SIGCHLD | CLONE_NEWPID, NULL);
此时这个新创建的进程会看见一个全新的进程空间,在此空间中,他的PID就是1,他既看不到宿主机中真正的进程空间,也看不到其他PID Namespace中的具体情况。而在真实的宿主机空间中,这个进程的PID是他的真实数值(比如5302)。
除PID Namespace外,Linux还提供了Mount、UTS、IPC、Network、User这些Namespace。
这就是Linux容器实现的最基本原理。因此,Docker实际在创建容器进程时,指定了该进程所需要启用的一组Namespace参数
,这样该容器只能看到当前Namespace所限定的资源、文件、设备、状态或配置了,而对于宿主机及其他不相关程序就完全不透明。
因此,容器实际上就是一种特殊的容器而已。
二、隔离与限制
虚拟机与容器均有“为进程划分一个独立的空间”思想。
- 对比虚拟机与容器
虚拟机的工作原理中,Hypervisor的软件是虚拟机最主要的部分,他通过硬件虚拟化功能模拟出了运行一个操作系统所需要的各种硬件,如CPU、内存等,然后在这些虚拟的硬件上安装了一个客户操作系统,这样用户的应用进程就可以在这个虚拟机中运行了。Docker使用一个名为Docker引擎的软件替换了Hypervisor,Docker不严谨的也可称为是轻量级虚拟化技术。
但二者拥有本质不同:Hypervisor会创建实体虚拟机,并对应用的隔离环境负责;而Docker并不会真正的在宿主机中创建一个Docker容器,他帮用户启动的还是原来的应用进程,只不过为其加上了各种Namespace参数,因此真正对隔离环境负责的还是宿主机本身,甚至Docker可以去掉。因此对比图应该如下:
因此容器相较于虚拟机的好处:
- 使用虚拟化作为沙盒必须由Hypervisor创建一个真实存在的虚拟机,且里面需要运行一个完整的客户操作系统,因此带来了额外的资源消耗和占用。实验表明,一个与逆行CentOS的KVM在不做任何优化的情况下,虚拟机本身要占用100~200MB内存。而Docker使用Namespace作为隔离手段,不需要完整的客户操作系统,使得容器额外的资源占用几乎可忽略不计;
- 用户应用对宿主机操作系统的系统调用会被Hypervisor拦截和处理,又是一层性能消耗,尤其对计算资源、网络、磁盘I/O的损耗很大;容器化后的用户应用仍为宿主机上的普通进程,因此不产生性能损耗。
summary:敏捷,高性能。
- 隔离
有利就有弊:Linux Namespace最主要的问题:隔离的不彻底。由于容器只是宿主机上的特殊进程,因此容器间共用同一宿主机的操作内核
,带来了一系列问题:
- 容器通过Mount Namespace挂载其他版本的操作系统文件有的会行不通,如Windows上运行Linux容器、低版本Linux宿主机上运行高版本Linux容器等。
- Linux内核中很多对象不能被Namespace化。例如时间,若在容器中使用settimeofday(2)系统调用修改时间,则整个宿主机时间会被随之修改,因此在容器中部署应用时需要考虑“什么能做、什么不能做”。尽管可以使用Seccomp技术对所有发起的系统调用进行过滤和甄别,但会拖累系统性能,同时也不知道要开启或禁用哪些系统调用。
- 限制
隔离之后为什么要进行限制?以PID Namespace为例,虽然容器内的1号进程在“障眼法”下无法看到容器外的其他信息,但在宿主机上,他作为第5302号进程与其他所有进程间存在平等的竞争关系。这意味着虽然第5302号进程表面被隔离起来了,但他所能够使用到的资源(如CPU、内存等)可随时被宿主机上其他进程或容器占用,同时也可能自己用光资源。显然这作为一个沙盒不合理。
- Cgroups
Linux Cgroups的主要作用是限制一个进程组能够使用的资源上限
(包括CPU、内存、网络带宽等)。此外还可进行优先级设置、审计、挂起和恢复进程等。
Linux、中,Cgroups向用户暴露出来的操作接口是文件系统
,他以文件和目录的形式组织在/sys/fs/cgroup路径下,使用如下命令可显示:
mount -t cgroup
如图所示,输出结果是一系列文件系统目录。/sys/fs/cgroup下有许多诸如cpu、memory等的子目录,也称子系统,这些都是这台机器当前可被Cgroups限制的资源种类。在子系统对应的资源种类下,可以看到这类资源具体可以被限制的方法。以CPU子系统为例:
可以注意输出中有cpu.cfs_quota_us、cpu.cfs_period_us等关键词,这两个参数组合使用可以限制进程长度在period一段时间内,只能分配到总量为quota的CPU时间。
这样的配置文件如何使用呢?(以CPU为例)
首先在对应的子系统下创建目录,这个目录称为一个控制组,操作系统会在新创建的目录下自动生成该子系统的资源限制文件。
然后运行一个死循环脚本(该程序的PID是3750),该进程将CPU占满(使用top命令查看)。
接下来查看demo_container目录下的文件quota,此时发现quota还没有任何限制(=-1),CPU的period默认是100ms(10000us)。
接下来,修改这些文件内容来设限:向quota文件写入20ms(20000us),这个操作意味着在每100ms(period)时间里被该控制组限制的进程只能使用20ms(quota)的CPU时间,即该进程只能使用到20%的CPU带宽。然后将该进程的PID写入tasks文件,如上设置即可生效。
注意命令执行过程为:
1. sudo -i
2. cd /sys/fs/cgroup/cpu/demo_container
3. echo 20000 > cpu.cfs_quota_us
4. cat cpu.cfs_quota_us
5. echo 3750 > tasks
6. cat tasks
不能直接echo,也不能sudo echo…,权限不够。
然后使用top命令查看cpu利用率下降到了20%。
我们自己创建的demo_container目录可以通过rmdir demo_container命令删除。
除了CPU子系统外,Cgroups的每一项子系统都有其独有的资源限制能力,如:
- blkio(block I/O):为块设备限定I/O限制,一般用于磁盘等设备;
- cpuset:为进程分配单独的CPU核以及对应的内存节点;
- memory:为进程设定内存使用限制。
- 等等
对于Docker等Linux容器来说,Cgroups只需在每个子系统下面为每个容器创建一个控制组(新建一个容器目录),启动容器之后,将这个容器的PID写入控制组的tasks文件即可。而为控制组中的资源文件赋值,通过docker run命令中的参数:
docker run -it --cpu-period=100000 --cpu-quota=20000 image_name /bin/bash # 以mysql镜像为例
然后查看Cgroups中该容器的控制组中资源配置文件信息:
左侧为容器内部文件系统信息,右侧为宿主机文件系统信息。可以发现,在容器内部的/sys/fs/cgroup/cpu子系统下没有其他目录,前面提到这是因为Namespace给他施了一个“障眼法”,使其只能看见自己沙盒内部的情况;而在宿主机的/sys/fs/cgroup/cpu子系统下新增了一个docker目录。进入这个docker目录,发现多了刚刚所创建的容器的控制组。
进入该容器的控制组,查看资源配置信息,与docker run命令中所指定的参数一致:这意味着,这个容器只能使用20%的CPU带宽。
- 总结
一个正在运行的容器其实就是一个启用了多个Namespace的应用进程,这个进程所能够使用的资源受Cgroups配置的限制。因此,容器是一个单进程
模型。因此用户的应用进程实际就是容器中PID=1的进程,也是其他后续所创建的所有进程的父进程,这意味着,一个容器中无法同时运行两个应用,除非找一个PID=1的程序来担任这两个进程的父进程(比如systemd或supervisord)。
与Namespace相似,Cgroups本身也有许多不完善之处,最典型的是/proc文件系统的问题。
/proc目录下存储的是记录当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件查看系统以及当前正在运行的进程信息。如CPU使用情况、内存占用率等。这些文件也是top指令的主要数据来源。
若在容器中执行top命令会发现他显示的信息是宿主机中的信息,而非当前容器的数据。(目前已修正)
三、容器镜像
- 容器中的进程看到的文件系统是如何的?
这是一个关于Mount Namespace的问题,容器中的应用进程理应看到一套完全独立的文件系统,这样他在自己的容器目录下进行操作(例如/tmp),就完全不会受宿主机及其他容器的影响。
真实情况呢?
用如下程序验证:
#define _GNU_SOURCE
#include <sys/mount.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>#define STACK_SIZE (1024 * 1024)
static char container_stack[STACK_SIZE];char* const container_args[] = {"/bin/bash",NULL
};int container_main(void *arg) {printf("Container - inside the container!\n");execv(container_args[0], container_args);printf("Something's wrong");return 1;
}int main() {printf("Parent - start a container!\n");pid_t container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD, NULL);printf("%d\n", container_pid);waitpid(container_pid, NULL, 0);printf("Parent - container stopped!\n");return 0;
}
编译运行:
1. gcc -o test_clone_func test_clone_func.c
2. ./test_clone_func
遇到的问题:
clone()函数返回的pid为-1,证明创建失败。查阅clone()参数使用发现,我们在函数中使用CLONE_NEWNS时需要管理员权限。
因此我们需要将第二步替换成如下:
2. sudo ./test_clone_func
这样就进入了这个“容器”中。在容器中执行ls命令,会发现/tmp目录与宿主机相同!
Monut Namespace修改的是容器进程对文件系统的“挂载点”的认知,即只有在“挂载”这个操作发生之后才能改变进程视图,在此之前会直接继承宿主机的各个挂载点。因此在创建新进程时除了要启用Mount Namespace外,还要告诉容器那些目录需要重新挂载(比如/tmp):
int container_main(void *arg) {printf("Container - inside the container!\n");mount("none", "/tmp", "tmpfs", 0, ""); # 以“tmpfs”内存盘格式重新挂载/tmp目录execv(container_args[0], container_args);printf("Something's wrong");return 1;
}
此时再查看容器中的/tmp文件,为空,意味挂载生效:
因此,Mount Namespace的使用与其他Namespace不同的是:对容器进程视图的修改一定要伴随挂载操作才能生效。
- 如何实现容器看到的文件系统是一个隔离了的隔离环境,而不是继承宿主机的文件系统呢(容器镜像:rootfs)?
只需在容器启动之前重新挂载他的整个根目录“/”即可,由于Namespace的存在,这个挂载操作对宿主机并不可见。
在Linux操作系统中有一个chroot命令(change root file system),可以在shell中改变进程的根目录到指定位置。假设有一个$HOME/test目录,想把它作为/bin/bash的根目录:
# 创建test目录和几个lib文件夹
1. mkdir -p $HOME/test
2. mkdir -p $HOME/test/{bin,lib64,lib} # 注意,之间一定不能有空格
3. cd $T
# 把bash命令复制到test目录下对应的bin文件夹中
4. cp -v /bin/{bash,ls} $HOME/test/bin
# 把bash命令所需要的so文件复制到test目录下对应的lib文件夹中
# 使用ldd命令查找so文件
5. T=$HOME/test
6. list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')"
7. for i in $list; do cp -v "$i" "${T}${i}"; done
# 执行chroot命令,将/bin/bash的根目录更改为$HOME/test
8. chroot $HOME/test /bin/bash
# 查看根目录是否被“修改”
9. ls /
# 此时发现输出内容是$HOME/test内容,而非宿主机。
Mount Namespace正是基于对chroot的改进才发明出的,也是第一个Namespace。
容器镜像:通常在容器根目录下挂载整个操作系统的文件系统(如Ubuntu 20.04的ISO),这个挂载在容器根目录上为容器进程提供隔离后执行环境的文件系统成为“容器镜像”,或rootfs(根文件系统)。
Docker项目最核心的原理:为待创建的用户进程:
- 启用Linux Namespace配置;
- 设置指定的Cgroups参数;
- 切换进程的根目录(change root)
但rootfs只是一个操作系统所包含的目录、配置和文件,并不包括操作系统的内核。操作系统只有在开机启动时才会加载指定版本的内核镜像。同一台机器上的所有容器共享宿主机的操作系统内核,因此如果应用需要配置内核参数实际上会修改宿主机的,一经修改则对所有容器来说都是被修改过的。
由于rootfs的存在,容器拥有一重要特性:一致性
。对于PaaS来说,由于云端与本地服务器环境不同,应用打包是一个极其麻烦的问题,而容器镜像(rootfs)将整个操作系统与应用一起打包,应用与他运行时所需的依赖都被封撞到了一起。(对于一个应用,操作系统本身才是他运行时所需要的最完整的依赖库)。
这种下沉到操作系统级别的运行环境的一致性,填平了应用在本地开发和远端执行环境之间难以逾越的鸿沟。
- 新问题:难道每开发一次或升级现有的就要重复制作一次rootfs吗?
例如我用Ubuntu操作系统的ISO做了一个rootfs并安装了Java环境,来部署我的Java应用,我的同事在发布他的java应用是我希望直接使用我的rootfs而不是再重复一遍这个步骤。
因此Docker在镜像设计中引入了“层(layer)”概念,这些修改都基于旧的rootfs,其他修改以增量方式添加,所有人只需要维护一个相对于base rootfs修改的增量内容。用户制作镜像的每一步操作都会生成一个层,即增量rootfs。这种想法使用了UnionFS(Union file system)联合文件系统的能力:
UnionFS:将不同位置的目录联合挂载到同一目录下,且相同文件名的文件合并。
1. mkdir A B C
2. touch ./A/a.txt ./A/common.txt ./B/b.txt ./B/common.txt
3. #向./A/common.txt写入"hello A",./B/common.txt写入"hello B"
# 通过联合挂载将A B两个目录挂载到同一目录C上
4. sudo mount -t aufs -o dirs=./A:./B none ./C
此时如果在目录C中对a.txt b.txt commom.txt做修改,也会在对应目录A B中生效。
若想取消挂载:
sudo umount A B C
那对于相同文件名且内容也相同的呢?将./A/common.txt与./B/common.txt内容都修改为"hello world"后在挂载到C上,再对./C/common.txt做修改:
同上,对于相同文件,只有A目录下的文件被修改了,说明默认是A文件夹下的。
目前docker支持的联合文件系统有很多种,包括:AUFS、overlay、overlay2、DeviceMapper、VSF等。查看本机Docker所使用的UnionFS:例如我的是overlay2。
docker info
- 容器镜像(rootfs)
容器的rootfs由如下三部分组成(以ubuntu镜像为例):
- 只读层
挂载方式为readonly+whiteout,这些层都以增量的方式包含了镜像的一部分。
- 可读可写层
容器rootfs的最上面一层,挂载方式为read write,写入文件之前,这个目录是空的,一旦容器有了修改操作,修改的内容就会以增量的方式出现在该层中。对于删除操作会在可读可写层生成一个whiteout文件,把只读层文件内容“遮挡”起来。如删除只读层中的一个foo文件,这个操作实际上是在可读可写层创建一个.wh.foo文件,对应于只读层ro+wh的挂载方式。
对于修改后的容器,可使用docker commit和push指令保存这个修改后的可读可写层,而原先只读层的内容不会发生任何改变。
- Init层
这是一个以-init结尾的层,存在于只读层和可读可写层之间,是由Docker项目单独生成的一个内部层,用来存放/etc/hosts、/etc/resolv.conf等信息。这些文件本来属于只读层的一部分,但是用户在启动容器时需要写入一些指定的值(比如hostname等),且这些修改只对当前容器有效,并不希望在docker commit时提交这些信息,因此设立一个Init层,单独挂载这些文件的修改。
docker镜像相同的层间可以共享
- Docker Volume(数据卷)
宿主机如何获取容器进程中新建的文件?容器中的进程怎么才能访问到宿主机上的文件和目录?
Volume机制允许将宿主机上的指定目录或文件挂载到容器中进行读取和修改。
docker run -v /test ...
在宿主机上创建一个临时目录/var/lib/docker/volumes/[VOLUME_ID]/_data,并将它挂载到容器的/test目录上。
docker run -v /home:/test
直接将宿主机的/home目录挂载到容器的/test目录上。
原理是什么?当容器进程被创建后,尽管开启了Mount Namespace,但在执行chroot(或pivot_root)之前容器进程可以看到宿主机上的整个文件系统,因此只需在容器rootfs准备好后chroot执行之前,将指定的宿主机目录挂载到指定的容器目录在宿主机上对应的目录
上(具体路径参照overlay2原理)即可。由于挂载操作时容器已经创建,即Mount Namespace已经开启,因此这个挂载事件只对容器可见,宿主机看不到容器内部的这个挂载点。这就是绑定挂载。实际上就是一个inode替换过程。
这里的容器进程
是指Docker创建的一个容器初始化进程(dockerinit),而非应用进程(ENTRYPOINT+CMD)。dockerinit会负责完成根目录的准备、挂载设备和目录、配置hostname等一系列需要在容器内进行的初始化操作,最后它通过execv()系统调用,让应用进程取代自己成为容器中PID=1的进程。
绑定挂载(bind mount):将一个目录或文件而非整个设备挂载到指定目录上,在挂载点上进行的任何操作只发生在被挂载的目录或文件上,原挂载点的内容会被隐藏起来且不受影响。(例如对容器/test目录的实际操作会发生在宿主机/home上,而原/test内容不会被影响。)
容器的/test目录挂载在rootfs的可读可写层,但不会被docker commit提交。(因为docker commit发生在宿主机空间,而该挂载操作对宿主机不可见,始终以为容器/test目录对应在宿主机上的路径始终为空。但新建目录不是挂载操作,因此commit的镜像中会多出一个/test空目录。)
总结
容器实际上是由Linux Namespace、Linux Cgroups和rootfs这三种技术构建出来的进程隔离环境。一个正在运行的Linux容器可一分为二:
- 容器镜像(container image):一组联合挂载的rootfs;
- 容器运行时(container runtime):一个由Namespace+Cgroups构成的隔离环境。
参考书籍
《深入剖析Kubernetes》 张磊著。