容器技术概念入门
文章目录进程篇Namespace技术示例隔离与限制虚拟机与容器技术对比Linux Namespace的不足Cgroups技术容器镜像Mount Namespacechrootrootfs联合文件系统与增量rootfs-容器镜像的分层Docker exec运行原理Docker Volume机制的原理kubernetes的本质初探kubernete结构声明式API进程篇什么是“程序”,什么是“进程”?
文章目录
进程篇
什么是“程序”,什么是“进程”?
如要写一个计算加法的小程序,程序的输入来自一个文件,计算完成后的结果则输出到另一个文件中。磁盘上的数据加上代码本身的二进制文件放在磁盘上,就是我们平常所说的“程序”,也叫代码的可执行镜像。一旦“程序”被执行起来,它就从磁盘上的二进制文件,变成了计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息的一个集合。**像这样一个程序运行起来后的计算机执行环境的总和,就是我们所说的进程。**进程的静态表现是程序,而一旦运行起来,就变成了计算机里的数据和状态的总和。
容器技术的核心功能,就是通过约束和修改进程的动态表先,从而为其创造出一个“边界”。容器的Cgroups技术用来制造约束,Namespace技术则用来修改进程视图。
Namespace技术示例
首先创建一个容器,-it
参数告诉Docker在容器启动后需要给用户分配一个文本输入/输出环境,即TTY
,跟容器的标准输入输出相关联,即可以和这个Docker容器交互。/bin/sh
即在Docker容器中运行的程序。
$ docker run -it busybox /bin/sh
/ #
如果在容器里执行ps指令,则会出现如下结果。可以看到,在Docker里最开始执行的/bin/sh,就是容器内部的1号进程,而这个容器一共只有两个进程在运行,这意味着Docker为容器进程创建了一个与宿主机不同的隔离环境。**这种技术,就是Linux里面的Namespace机制。**对被隔离应用的进程空间做手脚,使得这些进程只能看到重新计算过的进程编号,比如PID=1。
Namespace的使用方式:它其实是Linux创建新进程的一个可选参数,在Linux系统中创建线程的系统调用是clone()
,比如:
int pid = clone(main_function, stack_size, SIGCHILD, NULL)
这个系统调用就会为我们创建一个新的进程,并返回它的进程号pid。而当在参数中指定CLONE_NEWPID
参数,新创建的进程将会“看到”一个全新的进程空间,它的PID是1:
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHILD, NULL)
可以多次执行上面的 clone() 调用,这样就会创建多个 PID Namespace,而每个 Namespace 里的应用进程,都会认为自己是当前容器里的第 1 号进程,它们既看不到宿主机里真正的进程空间,也看不到其他 PID Namespace 里的具体情况。
**除了刚刚用到的PID Namespace,Linux操作系统还提供了Mount, UTS, IPC, Network和User这些Namespace,用来对各种不同的进程上下文进行“障眼法”操作。**比如,Mount Namespace,用于让被隔离进程只看到当前 Namespace 里的挂载点信息;Network Namespace,用于让被隔离进程看到当前 Namespace 里的网络设备和配置。
这就是Linux容器最基本的实现原理。实际上就是在创建容器进程时,指定进程所需要的启用的一组Namespace参数,这样容器就只能看到当前Namespace所限定的资源、文件、设备或者配置,而对于宿主机以及其他不相关的程序就完全看不到了。所以,容器,其实就是一种特殊的进程。
隔离与限制
虚拟机与容器技术对比
用户运行在容器里的应用进程,跟宿主机上的其他进程一样,都由宿主机操作系统统一管理,只不过这些被隔离的进程拥有额外设置过的Namespace参数,Docker项目在这里扮演的角色更多的使旁路式的辅助和管理工作。
使用虚拟化技术作为应用沙盒,就必须要由 Hypervisor 来负责创建虚拟机,这个虚拟机是真实存在的,并且它里面必须运行一个完整的 Guest OS 才能执行用户的应用进程。这就不可避免地带来了额外的资源消耗和占用。一个运行着 CentOS 的 KVM 虚拟机启动后,在不做优化的情况下,虚拟机自己就需要占用 100~200 MB 内存。此外,用户应用运行在虚拟机里面,它对宿主机操作系统的调用就不可避免地要经过虚拟化软件的拦截和处理,这本身又是一层性能损耗,尤其对计算资源、网络和磁盘 I/O 的损耗非常大。
相比之下,容器化后的用户应用,却依然还是一个宿主机上的普通进程,这就意味着这些因为虚拟化而带来的性能损耗都是不存在的;而另一方面,使用 Namespace 作为隔离手段的容器并不需要单独的 Guest OS,这就使得容器额外的资源占用几乎可以忽略不计。
Linux Namespace的不足
基于Linux Namespace的隔离机制相比于虚拟化技术的最主要的不足之处是:隔离的不彻底。
首先,容器只是运行在宿主机上的一种特殊进程,但多个容器之间使用的还是同一个宿主机的操作系统内核。不能在Windows宿主机上运行Linux容器,或者在低版本的Linux宿主机上运行高版本的Linux容器。
其次,在Linux内核中,有很多资源和对象是不能被Namespace化的,例如时间。这就意味着,如果你的容器中的程序使用 settimeofday(2) 系统调用修改了时间,整个宿主机的时间都会被随之修改,这显然不符合用户的预期。
此外,由于共享宿主机内核,容器应用“越狱”的难度比虚拟机低得多。
Cgroups技术
虽然容器内的第 1 号进程在“障眼法”的干扰下只能看到容器里的情况,但是宿主机上,它作为第 100 号进程与其他所有进程之间依然是平等的竞争关系。这就意味着,虽然第 100 号进程表面上被隔离了起来,但是它所能够使用到的资源(比如 CPU、内存),却是可以随时被宿主机上的其他进程(或者其他容器)占用的。当然,这个 100 号进程自己也可能把所有资源吃光。这些情况,显然都不是一个“沙盒”应该表现出来的合理行为。
Linux Cgroups就是Linux内核中用来为进程设置资源限制的一个重要功能。
Linux Cgroups的全称是Linux Control Group。它最主要的作用是限制一个进程组能够使用的资源上限,包括CPU、内存、磁盘、网络带宽等。
在Linux中,Cgroups给用户暴露出来的操作系统接口是文件系统,即它以文件和目录的方式组织在OS的/sys/fs/cgroup
路径下。如下命令的输出是一系列文件系统目录,可以看到,在 /sys/fs/cgroup 下面有很多诸如 cpuset、cpu、 memory 这样的子目录,也叫子系统。这些都是这台机器当前可以被 Cgroups 进行限制的资源种类。
以CPU子系统为例,可以看到如下配置文件:
可以看到输出里有cfs_period
和cfs_quota
这样的关键词,这两个参数组合使用可以用来限制进程在长度为cfs_period
的一段时间内只能被分配到总量为cfs_quota
的CPU时间。具体使用方法为:
- 在对应的子系统下面创建一个目录,比如进入
/sys/fs/cgroup/cpu
目录下创建container目录,这个目录被称为一个“控制组”,操作系统会在新创建的container目录下自动生成该子系统对应的资源限制文件。
-
在后台执行一条脚本,执行死循环,将计算机的CPU吃到100%,根据输出可以看到脚本在后台运行的进程号为226。
-
此时查看container目录下的文件,看到container控制组里的CPU quota没有限制,CPU period是默认的100ms (1000000us)
- 接下来通过修改文件内容来设置限制,比如向container组里的
cfs_quota
文件写入20ms,这意味着在每100ms的时间里,被控制组限制的进程只能使用20ms的CPU时间,即进程只能使用20%的CPU带宽。
-
最后将被限制的进程的PID写入container组的tasks文件,设置的就会对进程生效。
**Linux Cgroups的设计其实就是一个子系统目录加上一组资源限制文件的组合。**而对于Docker等Linux容器项目来说,只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,将这个进程PID填写到对应控制组的tasks文件。而在这些控制组下面的资源文件填什么值,通过用户执行docker run的参数来指定:
跟 Namespace 的情况类似,Cgroups 对资源的限制能力也有很多不完善的地方,被提及最多的自然是 /proc 文件系统的问题。众所周知,Linux 下的 /proc 目录存储的是记录当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息,比如 CPU 使用情况、内存占用率等,这些文件也是 top 指令查看系统信息的主要数据来源。但是,你如果在容器里执行 top 指令,就会发现,它显示的信息居然是宿主机的 CPU 和内存数据,而不是当前容器的数据。造成这个问题的原因就是,/proc 文件系统并不知道用户通过 Cgroups 给这个容器做了什么样的资源限制,即:/proc 文件系统不了解 Cgroups 限制的存在。
解决办法:https://juejin.cn/post/6847902216511356936
容器镜像
Mount Namespace
Mount Namespace修改的,是容器进程对文件系统“挂载点”的认知。但是,只有在“挂载“这个操作发生之后,进程的视图才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点。Mount Namespace跟其他Namespace使用不同的地方在于:它对容器进程视图的改变,一定是伴随着挂载操作才能生效。
下面是一个示例。
#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>
#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!\n");
return 1;
}
int main()
{
printf("Parent - start a container!\n");
int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD , NULL);
waitpid(container_pid, NULL, 0);
printf("Parent - container stopped!\n");
return 0;
}
在 main 函数里,我们通过 clone() 系统调用创建了一个新的子进程 container_main,并且声明要为它启用 Mount Namespace(即:CLONE_NEWNS 标志)。而这个子进程执行的,是一个“/bin/bash”程序,也就是一个 shell。所以这个 shell 就运行在了 Mount Namespace 的隔离环境中。
编译一下这个程序即进入到”容器“中。可是,如果在“容器”里执行一下 ls 指令的话,我们就会发现一个有趣的现象: /tmp 目录下的内容跟宿主机的内容是一样的。原因上面已经讲了。
**解决办法:**创建新进程时,除了声明要启用 Mount Namespace 之外,我们还可以告诉容器进程,有哪些目录需要重新挂载,就比如这个 /tmp 目录。于是,我们在容器进程执行前可以添加一步重新挂载 /tmp 目录的操作:
int container_main(void* arg)
{
printf("Container - inside the container!\n");
// 如果你的机器的根目录的挂载类型是 shared,那必须先重新挂载根目录
// mount("", "/", NULL, MS_PRIVATE, "");
mount("none", "/tmp", "tmpfs", 0, ""); // 告诉容器以tmpfs(内存盘)的格式,重新挂载/tmp目录
execv(container_args[0], container_args);
printf("Something's wrong!\n");
return 1;
}
修改后的代码执行结果如下,/tmp目录变成了一个空目录。可以使用mount -l检查一下(容器里的/tmp目录是以tmpfs方式单独挂载的):
在宿主机上用mount -l 检查会发现这个挂载并不存在,因为创建的新进程启用了Mount Namespace,这次挂载操作只对容器进程的Mount Namespace有效。
chroot
在 Linux 操作系统里,有一个名为 chroot 的命令可以帮助你在 shell 中方便地完成这个工作。顾名思义,它的作用就是帮你“change root file system”,即改变进程的根目录到你指定的位置。它的用法也非常简单。
假设,我们现在有一个 $HOME/test 目录,想要把它作为一个 /bin/bash 进程的根目录。首先,创建一个 test 目录和几个 lib 文件夹:
然后,把 bash 命令拷贝到 test 目录对应的 bin 路径下:
接下来,把 bash 命令需要的所有 so 文件,也拷贝到 test 目录对应的 lib 路径下。找到 so 文件可以用 ldd 命令:
最后,执行 chroot 命令,告诉操作系统,我们将使用 $HOME/test 目录作为 /bin/bash 进程的根目录:
这时,你如果执行 “ls /”,就会看到,它返回的都是 $HOME/test 目录下面的内容,而不是宿主机的内容。更重要的是,对于被 chroot 的进程来说,它并不会感受到自己的根目录已经被“修改”成 $HOME/test 了。
实际上,Mount Namespace正是基于对chroot的不断改良才被发明出来的,它也是Linux操作系统的第一个Namespace。
rootfs
挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。其更专业的名字是rootfs(根文件系统)。
一个最常见的根文件系统,或者说容器镜像,会包括如下所示的一些目录和文件,比如/bin, /etc, /proc等。而当进入容器之后执行的/bin/bash,与宿主机的/bin/bash完全不同。
需要明确的是,rootfs只是一个操作系统包含的文件、配置和目录,并不包括操作系统内核。在Linux操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。 所以说,rootfs只包括了操作系统的”躯壳“,并不包含OS的”灵魂“。实际上,同一台机器的所有容器,都共享宿主机操作系统的内核。这就意味着,如果你的应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互,你就需要注意了:这些操作和依赖的对象,都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个“全局变量”,牵一发而动全身。
这也是容器相比于虚拟机的主要缺陷之一:毕竟后者不仅有模拟出来的硬件机器充当沙盒,而且每个沙盒里还运行着一个完整的 Guest OS 给应用随便折腾。
**正是由于 rootfs 的存在,容器才有了一个被反复宣传至今的重要特性:一致性。**无论在本地、云端还是在一台任何地方的机器上,用户只需要解压打包好的容器镜像,这个应用运行所需要的完整的执行环境就被重现出来了。
对Docker项目来说,它最核心的原理实际上就是为待创建的用户进程:
- 启用Linux Namespace配置;
- 设置指定的Cgroups参数;
- 切换进程的根目录(Change Root)
这样一个完整的容器就诞生了,不过Docker项目在最后一步的切换上会优先使用pivot_root系统调用,如果系统不支持,才会使用chroot。
联合文件系统与增量rootfs-容器镜像的分层
参考:深入理解容器镜像
Docker在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量rootfs。这个想法用到了一种叫做联合文件系统(Union File System, UnionFS)的能力,它最主要的功能是将多个不同位置的目录联合挂载到同一个目录。比如现在有两个目录A和B,它们分别有两个文件:
然后使用联合挂载的方式将这两个目录挂载到一个公共的目录C上:
mkdir C
mount -t aufs -o dirs=./A:./B none ./C
这时再查看目录C的内容,就能看到目录A和B下的文件被合并在一起了。
可以看到,在这个合并后的目录C里,有a\b\x三个文件,并且x文件只有一份。如果在目录C里对a\b\x文件做修改,这些修改也会在对应的目录A、B中生效。
AUFS(AnotherUnionFS)是一种Union FS,是文件级的存储驱动。AUFS能透明覆盖一或多个现有文件系统的层状文件系统,把多层合并成文件系统的单层表示。简单来说就是支持将不同目录挂载到同一个虚拟文件系统下的文件系统。这种文件系统可以一层一层地叠加修改文件。无论底下有多少层都是只读的,只有最上层的文件系统是可写的。当需要修改一个文件时,AUFS创建该文件的一个副本,使用CoW将文件从只读层复制到可写层进行修改,结果也保存在可写层。在Docker中,底下的只读层就是image,可写层就是Container。结构如下图所示:
Overlay驱动
Overlay是Linux内核3.18后支持的,也是一种Union FS,和AUFS的多层不同的是Overlay只有两层:一个upper文件系统和一个lower文件系统,分别代表Docker的镜像层和容器层。当需要修改一个文件时,使用CoW将文件从只读的lower复制到可写的upper进行修改,结果也保存在upper层。在Docker中,底下的只读层就是image,可写层就是Container。结构如下图所示:
关于overlay的镜像文件存储位置参考:https://www.cnblogs.com/wdliu/p/10483252.html
Docker exec运行原理
通过如下指令可以看到正在运行的Docker容器的进程号(PID)为15734,查看宿主机的proc文件,看到这个进程的所有namespace对应的文件,可以看到,一个进程的每种Linux Namespace,都在它对应的/proc/[进程号]/ns下有一个对应的虚拟文件,并且链接到一个真实的Namespace文件上。
**这意味着一个进程,可以选择加入到某个进程已有的Namespace当中,从而达到“进入”这个进程所在容器的目的,这正是docker exec的实现原理。**而这个操作可以通过setns()Linux系统调用来实现。
#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0)
int main(int argc, char *argv[]) {
int fd;
fd = open(argv[1], O_RDONLY);
if (setns(fd, 0) == -1) {
errExit("setns");
}
execvp(argv[2], &argv[2]);
errExit("execvp");
}
这段代码通过open()系统调用打开了指定的Namespace文件,并把这个文件的描述符fd传给setns(),在setns()执行之后,当前进程就加入了这个文件对应的Linux Namespace中。
当我们执行ifconfig查看网络设备,发现只有两个网卡,没有docker0,说明这个进程成功加入到了容器的网络命名空间中。查看这两个进程的网络命名空间,可以发现这两个进程共享相同的名叫 net:[4026532281] 的 Network Namespace。
Docker Volume机制的原理
Volume机制,允许将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。
在Docker项目里,支持两种volume声明方式,可以把宿主机目录挂载进容器的/test目录:
$ docker run -v /test ...
$ docker run -v /home:/test ...
这两种声明方式都是将一个宿主机的目录挂载进了容器的/test目录。只不过,在第一种情况下,由于你并没有显示声明宿主机目录,那么 Docker 就会默认在宿主机上创建一个临时目录 /var/lib/docker/volumes/[VOLUME_ID]/_data,然后把它挂载到容器的 /test 目录上。而在第二种情况下,Docker 就直接把宿主机的 /home 目录挂载到容器的 /test 目录上。
**当容器进程被创建之后,尽管开启了 Mount Namespace,但是在它执行 chroot(或者 pivot_root)之前,容器进程一直可以看到宿主机上的整个文件系统。(继承宿主机的挂载点)**而宿主机上的文件系统,也自然包括了我们要使用的容器镜像。这个镜像的各个层,保存在 /var/lib/docker/aufs/diff 目录下,在容器进程启动后,它们会被联合挂载在 /var/lib/docker/aufs/mnt/ 目录中,这样容器所需的 rootfs 就准备好了。
所以,我们只需要在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录(比如 /home 目录),挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib/docker/aufs/mnt/[可读写层 ID]/test)上,这个 Volume 的挂载工作就完成了。
更重要的是,由于执行这个挂载操作时,“容器进程”已经创建了,也就意味着此时 Mount Namespace 已经开启了。所以,这个挂载事件只在这个容器里可见。你在宿主机上,是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破。
注意:这里提到的 " 容器进程 ",是 Docker 创建的一个容器初始化进程 (dockerinit),而不是应用进程 (ENTRYPOINT + CMD)。dockerinit 会负责完成根目录的准备、挂载设备和目录、配置 hostname 等一系列需要在容器内进行的初始化操作。最后,它通过 execv() 系统调用,让应用进程取代自己,成为容器里的 PID=1 的进程。
而这里要使用到的挂载技术,就是 Linux 的绑定挂载(bind mount)机制。它的主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。
其实,如果你了解 Linux 内核的话,就会明白,绑定挂载实际上是一个 inode 替换的过程。在 Linux 操作系统中,inode 可以理解为存放文件内容的“对象”,而 dentry,也叫目录项,就是访问这个 inode 所使用的“指针”。
正如上图所示,mount --bind /home /test,会将 /home 挂载到 /test 上。其实相当于将 /test 的 dentry,重定向到了 /home 的 inode。这样当我们修改 /test 目录时,实际修改的是 /home 目录的 inode。这也就是为何,一旦执行 umount 命令,/test 目录原先的内容就会恢复:因为修改真正发生在的,是 /home 目录里。
所以,在一个正确的时机,进行一次绑定挂载,Docker 就可以成功地将一个宿主机上的目录或文件,不动声色地挂载到容器中。
这样,进程在容器里对这个 /test 目录进行的所有操作,都实际发生在宿主机的对应目录(比如,/home,或者 /var/lib/docker/volumes/[VOLUME_ID]/_data)里,而不会影响容器镜像的内容。
那么,这个 /test 目录里的内容,既然挂载在容器 rootfs 的可读写层,它会不会被 docker commit 提交掉呢?
也不会。
这个原因其实我们前面已经提到过。容器的镜像操作,比如 docker commit,都是发生在宿主机空间的。而由于 Mount Namespace 的隔离作用,宿主机并不知道这个绑定挂载的存在。所以,在宿主机看来,容器中可读写层的 /test 目录(/var/lib/docker/aufs/mnt/[可读写层 ID]/test),始终是空的。
不过,由于 Docker 一开始还是要创建 /test 这个目录作为挂载点,所以执行了 docker commit 之后,你会发现新产生的镜像里,会多出来一个空的 /test 目录。毕竟,新建目录操作,又不是挂载操作,Mount Namespace 对它可起不到“障眼法”的作用。
kubernetes的本质初探
kubernete结构
控制节点,即 Master 节点,由三个紧密协作的独立组件组合而成,它们分别是负责 API 服务的 kube-apiserver、负责调度的 kube-scheduler,以及负责容器编排的 kube-controller-manager。整个集群的持久化数据,则由 kube-apiserver 处理后保存在 Etcd 中。
计算节点上最核心的部分,则是一个叫作 kubelet 的组件。
在 Kubernetes 项目中,kubelet 主要负责同容器运行时(比如 Docker 项目)打交道。而这个交互所依赖的,是一个称作 CRI(Container Runtime Interface)的远程调用接口,这个接口定义了容器运行时的各项核心操作,比如:启动一个容器需要的所有参数。
具体的容器运行时,比如 Docker 项目,则一般通过 OCI 这个容器运行时规范同底层的 Linux 操作系统进行交互,即:把 CRI 请求翻译成对 Linux 操作系统的调用(操作 Linux Namespace 和 Cgroups 等)。
此外,kubelet 还通过 gRPC 协议同一个叫作 Device Plugin 的插件进行交互。这个插件,是 Kubernetes 项目用来管理 GPU 等宿主机物理设备的主要组件,也是基于 Kubernetes 项目进行机器学习训练、高性能作业支持等工作必须关注的功能。
而kubelet 的另一个重要功能,则是调用网络插件和存储插件为容器配置网络和持久化存储。这两个插件与 kubelet 进行交互的接口,分别是 CNI(Container Networking Interface)和 CSI(Container Storage Interface)。
声明式API
在kubernetes项目中,所推崇的使用方法是:
- 首先,通过一个“编排对象”,比如Pod、Job、CronJob等,来描述视图管理的应用;
- 然后,再为它定义一些“服务对象”,比如Service、Secret、Horizontal Pod Autoscaler(自动水平扩展器)等。这些对象,会负责具体的平台级功能;
这种使用方法,就是所谓的“声明式API"。这种API对应的”编排对象“和”服务对象“,都是kubernetes项目中的API对象(API Object)
更多推荐
所有评论(0)