容器基础
这个系列我们开始学习 k8s,但是想学好 k8s 并不容易,网络、操作系统、分布式原理都是 k8s 重要的组成部分。我自己将整个学习分成了如下几个系列:
- k8s 的设计和使用: 这个系列我们从使用层次上,明白 k8s 高层次的抽象和设计,达到能高效使用 k8s 的目的
- k8s 的源码解析: 这个系列我们从源码层次上,透析 k8s 的设计与实现,并学习 k8s 里面优秀的代码设计
- k8s 的网络模型: 这个系列我们从网络层次上,学习 k8s 上不同的网络插件对应的网络模型及其实现
这篇文章开始,我们学习 k8s 的设计和使用,选用的教材是:
而今天的内容则是跟容器有关。容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”,而使用到的核心技术就是:
- Namespace: 修改进程视图,创建进程的边界
- Cgroups: 限制容器的资源使用量
- chroot/容器镜像: 容器镜像配合 chroot 更改容器看到的根目录
1. Namespace
1.1 Namespace 原理
Namespace 用来修改应用进程看待整个计算机“视图”,即应用进程的“视线”被操作系统做了限制,只能“看到”某些指定的内容。Linux 操作系统提供了六种 Namespace:
- PID: 用于让被隔离进程只看到当前 Namespace 里启动的进程
- Mount: 用于让被隔离进程只看到当前 Namespace 里的挂载点信息
- Network: 用于让被隔离进程看到当前 Namespace 里的网络设备和配置
- UTS: 隔离域名
- IPC: 隔离进程间通信
- User: 隔离用户
要知道在 Namespace 的隔离技术是在,操作系统发展的后期演化的,他们都是在已有的进程创建扩展而来。所以这些 Namespace 实际上是在创建容器进程时,指定的一组 Namespace 参数。这样,容器就只能“看”到当前 Namespace 所限定的资源、文件、设备、状态,或者配置。所以说,容器,其实是一种特殊的进程而已。对于宿主机来说,这些被“隔离”了的进程跟其他进程并没有太大区别。
Namespace 的底层实现原理参见,耗子叔的博客:
1.2 进程 Namespace 查看
一个进程的每种 Linux Namespace,都在它对应的 /proc/[进程号]/ns 下有一个对应的虚拟文件,并且链接到一个真实的 Namespace 文件上。
|
|
正是因为 Namespace 对应的是一个个正是的文件,所以一个进程,可以选择加入到某个进程已有的 Namespace 当中,从而达到“进入”这个进程所在容器的目的,这正是 docker exec 的实现原理。而这个操作所依赖的,乃是一个名叫 setns() 的 Linux 系统调用。
Docker 还专门提供了一个参数,可以让你启动一个容器并“加入”到另一个容器的 Network Namespace 里,这个参数就是 -net,比如:
|
|
如果指定–net=host,就意味着这个容器不会为进程启用 Network Namespace,而共享宿主机的物理网络。
1.2 Cgroups
Linux Cgroups 是 Linux 内核中用来为进程设置资源限制的一个重要功能。Linux Cgroups 的全称是 Linux Control Group 用来限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。此外,Cgroups 还能够对进程进行优先级设置、审计,以及将进程挂起和恢复等操作。
Cgroups 接口
在 Linux 中,Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的 /sys/fs/cgroup 路径下。
|
|
/sys/fs/cgroup 目录下的每个子目录代表着一种资源的子系统,比如
- blkio,为块设备设定I/O 限制,一般用于磁盘等设备;
- cpuset,为进程分配单独的 CPU 核和对应的内存节点;
- memory,为进程设定内存使用的限制。
使用方法也很简单:
- 首先进入到想限制的资源的目录,然后创建一个目录,这个目录就是一个控制组
- cgroup 挂载的虚拟文件系统会在创建目录的同时,在目录内创建一系列资源限制文件
- 往这些文件里面写入进程的资源使用限额即可
- 当前目录下有一个 task 文件,将被限制进程 PID 写入 task,就可以指定资源限定的目标
Linux Cgroups 的设计还是比较易用的,简单粗暴地理解呢,它就是一个子系统目录加上一组资源限制文件的组合。而对于 Docker 等 Linux 容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的 PID 填写到对应控制组的 tasks 文件中就可以了。
而至于在这些控制组下面的资源文件里填上什么值,就靠用户执行 docker run 时的参数指定了,比如这样一条命令:
|
|
1.3 容器镜像
容器镜像和 Mount Namespace
要理解容器镜像的关键是搞清楚容器镜像与 Mount Namespace 之间的关系。
首先 Mount Namespace 修改的,是容器进程对文件系统“挂载点”的认知。但是,这也就意味着,只有在“挂载”这个操作发生之后,进程的视图才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点。这就是 Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效。
所以处理 Mount Namespace 我们还需要在容器进程启动之前重新挂载它的整个根目录“/”,而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫作:rootfs(根文件系统)。
在 Linux 操作系统里,有一个名为 chroot 的命令可以帮助你在 shell 中方便地完成重新挂载整个根目录“/”。顾名思义,它的作用就是帮你“change root file system”,即改变进程的根目录到你指定的位置。实际上,Mount Namespace 正是基于对 chroot 的不断改良才被发明出来的,它也是 Linux 操作系统里的第一个 Namespace。
容器镜像带来的改变
对一个应用来说,操作系统本身才是它运行所需要的最完整的“依赖库”。由于 rootfs 里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟。
1.4 容器启动过程
所以对 Docker 项目来说,它最核心的原理实际上就是为待创建的用户进程:
- 启用 Linux Namespace 配置;
- 设置指定的 Cgroups 参数;
- 切换进程的根目录(Change Root)。
不过,Docker 项目在最后一步的切换上会优先使用 pivot_root 系统调用,如果系统不支持,才会使用 chroot。这两个系统调用虽然功能类似,但是也有细微的区别。另外,需要明确的是,rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。
1.5 容器的缺陷
容器的缺陷,根本原因在于隔离不够彻底。
共享内核
首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核。这意味着,如果你要在 Windows 宿主机上运行 Linux 容器,或者在低版本的 Linux 宿主机上运行高版本的 Linux 容器,都是行不通的。
同时这也意味着,如果你的应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互,你就需要注意了:这些操作和依赖的对象,都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个“全局变量”,牵一发而动全身。
Namespace 隔离粒度不够
其次,在 Linux 内核中,有很多资源和对象是不能被 Namespace 化的,最典型的例子就是:时间。这就意味着,如果你的容器中的程序使用 settimeofday(2) 系统调用修改了时间,整个宿主机的时间都会被随之修改,这显然不符合用户的预期。
资源统计不准确
另外,跟 Namespace 的情况类似,Cgroups 对资源的限制能力也有很多不完善的地方,被提及最多的自然是 /proc 文件系统的问题。Linux 下的 /proc 目录存储的是记录当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息。但是,你如果在容器里执行 top 指令,就会发现,它显示的信息居然是宿主机的 CPU 和内存数据,而不是当前容器的数据。造成这个问题的原因就是,/proc 文件系统并不知道用户通过 Cgroups 给这个容器做了什么样的资源限制,即:/proc 文件系统不了解 Cgroups 限制的存在。
在生产环境中,这个问题必须进行修正,否则应用程序在容器里读取到的 CPU 核数、可用内存等信息都是宿主机上的数据,这会给应用的运行带来非常大的困惑和风险。这也是在企业中,容器化应用碰到的一个常见问题,也是容器相较于虚拟机另一个不尽如人意的地方。注: 解决办法参见 lxcfs。
安全限制不够
此外,由于上述问题,尤其是共享宿主机内核的事实,容器给应用暴露出来的攻击面是相当大的,应用“越狱”的难度自然也比虚拟机低得多。尽管在实践中我们确实可以使用 Seccomp 等技术,对容器内部发起的所有系统调用进行过滤和甄别来进行安全加固,但这种方法因为多了一层对系统调用的过滤,必然会拖累容器的性能。何况,默认情况下,谁也不知道到底该开启哪些系统调用,禁止哪些系统调用。
所以,在生产环境中,没有人敢把运行在物理机上的 Linux 容器直接暴露到公网上。当然,我后续会讲到的基于虚拟化或者独立内核技术的容器实现,则可以比较好地在隔离与性能之间做出平衡。
1.6 容器的单进程模型
正因为容器的 Namespace 和 Cgroups 是施加在单个进程上的,所以容器技术中一个非常重要的概念,即:容器是一个“单进程”模型。
由于一个容器的本质就是一个进程,用户的应用进程实际上就是容器里 PID=1 的进程,也是其他后续创建的所有进程的父进程。这就意味着,在一个容器中,你没办法同时运行两个不同的应用,除非你能事先找到一个公共的 PID=1 的程序来充当两个不同应用的父进程,这也是为什么很多人都会用 systemd 或者 supervisord 这样的软件来代替应用本身作为容器的启动进程。
在容器的设计模式中,容器本身的设计,是希望容器和应用能够同生命周期,这个概念对后续的容器编排非常重要。否则,一旦出现类似于“容器是正常运行的,但是里面的应用早已经挂了”的情况,编排系统处理起来就非常麻烦了。
2. 联合文件系统
为了提高容器镜像的复用能力,Docker 在容器镜像的制作上采用了一个叫联合文件系统的新实现。联合文件系统(Union File System)的能力。Union File System 也叫 UnionFS,最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。
2.1 overlay2
在我的 Centos 机器上,docker 使用的联合文件系统是 overlay2,它的关键目录位于 /var/lib/docker/overlay2。我在前面运行了一个 ubuntu 的容器,docker 自动将 ubuntu 的镜像拉取了本地。
这个所谓的“镜像”,实际上就是一个 Ubuntu 操作系统的 rootfs,它的内容是 Ubuntu 操作系统的所有文件和目录。而这个 Ubuntu 的镜像实际上是由多个层组成的。
首先我们看一下上面我们启动的 8cc9715e0e9d rootfs 挂载在哪:
|
|
在 overlay2 中:
- LowerDir:指向镜像层;
- UpperDir:指向容器层,在容器中创建文件后,文件出现在此目录;
- MergedDir:容器挂载点 ,lowerdir和upperdir整合起来提供统一的视图给容器,作为根文件系统;
- WorkDir:用于实现copy_up操作。
所以 3cfcb77fe21b 的镜像由下面三个层组成:
- /var/lib/docker/overlay2/b09b7979ceab286d53ed72fe122c2807cb2145e14b60bdc33ab3de3267a73200/diff
- /var/lib/docker/overlay2/b09b7979ceab286d53ed72fe122c2807cb2145e14b60bdc33ab3de3267a73200-init/diff
- /var/lib/docker/overlay2/7c200b7659c9c12d5ab0baeae54d03bbeb7d490c3d97f6a85d18c8ae8d1a2f0e/diff
并被联合挂载在 /var/lib/docker/overlay2/b09b7979ceab286d53ed72fe122c2807cb2145e14b60bdc33ab3de3267a73200/merged
而 7c200b7659c9c12d5ab0baeae54d03bbeb7d490c3d97f6a85d18c8ae8d1a2f0e/diff 正是 ubuntu 镜像的容器层:
|
|
通过系统的 mount 信息,可以看到同样的挂载信息:
|
|
2.2 容器的 rootfs 组成
从这个结构可以看出来,这个容器的 rootfs 由如下图所示的三部分组成:
- 第一部分,只读层: 这个容器的 rootfs 最下面的一层
- 第二部分,可读写层:
- 这个容器的 rootfs 最上面的一层
- 在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中
- 我们使用 docker commit 和 push 指令,保存的正是这个被修改过的可读写层
- 第三部分,Init 层:
- Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息
- 这一部分属于私密信息,即便用户修改后也不希望被提交到 docker hub 上去,所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。
注: 图片摘录自专栏,上面的 overlay2 就是如下三层:
|
|
2.3 copy-on-write
由于使用了联合文件系统,你在容器里对镜像只读层的 rootfs 所做的任何修改,都会被操作系统先复制到最上层的可读写层,然后再修改。这就是所谓的:Copy-on-Write。
3. docker volume
3.1 volume 介绍
有了容器镜像,我们还有两个问题需要考虑:
- 容器里进程新建的文件,怎么才能让宿主机获取到?
- 宿主机上的文件和目录,怎么才能让容器里的进程访问到?
这正是 Docker Volume 要解决的问题:Volume 机制,允许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。
在 Docker 项目里,它支持两种 Volume 声明方式
|
|
而这两种声明方式的本质,实际上是相同的:只不过,在第一种情况下,由于你并没有显示声明宿主机目录,那么 Docker 就会默认在宿主机上创建一个临时目录 /var/lib/docker/volumes/[VOLUME_ID]/_data
,然后把它挂载到容器的 /test 目录上。而在第二种情况下,Docker 就直接把宿主机的 /home 目录挂载到容器的 /test 目录上。
3.2 volume 原理
前面已经介绍过,当容器进程被创建之后,尽管开启了 Mount Namespace,但是在它执行 chroot(或者 pivot_root)之前,容器进程一直可以看到宿主机上的整个文件系统。而宿主机上的文件系统,也自然包括了我们要使用的容器镜像。在容器进程启动后这些容器的镜像层就会被联合挂载到 /var/lib/docker/overlay2/[container_id]/merged
下,这样容器所需的 rootfs 就准备好了。
我们只需要在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录(比如 /home 目录),挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib/docker/overlay2/[container_id]/merged/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 目录进行的所有操作,都实际发生在宿主机的对应目录里,而不会影响容器镜像的内容。
这个 /test 目录里的内容,既然挂载在容器 rootfs 的可读写层,它会不会被 docker commit 提交掉呢?也不会。这个原因其实我们前面已经提到过。容器的镜像操作,比如 docker commit,都是发生在宿主机空间的。而由于 Mount Namespace 的隔离作用,宿主机并不知道这个绑定挂载的存在。所以,在宿主机看来,容器中可读写层的 /test 目录,始终是空的。
不过,由于 Docker 一开始还是要创建 /test 这个目录作为挂载点,所以执行了 docker commit 之后,你会发现新产生的镜像里,会多出来一个空的 /test 目录。毕竟,新建目录操作,又不是挂载操作,Mount Namespace 对它可起不到“障眼法”的作用。
|
|
3.3 容器的总结
Docker 容器,我们就可以用下面这个“全景图”描述出来:
其包含如下几个部分:
- 这个容器进程“python app.py”,运行在由 Linux Namespace 和 Cgroups 构成的隔离环境里;
- 它运行所需要的各种文件,比如 python,app.py,以及整个操作系统文件,则由多个联合挂载在一起的 rootfs 层提供。
- rootfs 层的最下层,是来自 Docker 镜像的只读层
- 只读层之上,是 Docker 自己添加的 Init 层,用来存放被临时修改过的 /etc/hosts 等文件
- rootfs 的最上层是一个可读写层,它以 Copy-on-Write 的方式存放任何对只读层的修改,容器声明的 Volume 的挂载点,也出现在这一层
4. 从容器到 kubernetes
4.1 kubernetes 架构
Kubernetes 的架构由 Master 和 Node 两种节点组成,而这两种角色分别对应着控制节点和计算节点。
- 控制节点,即 Master 节点,由三个紧密协作的独立组件组合而成,它们分别是:
- 负责 API 服务的 kube-apiserver
- 负责调度的 kube-scheduler
- 负责容器编排的 kube-controller-manager
- 整个集群的持久化数据,则由 kube-apiserver 处理后保存在 Etcd 中
- 计算节点上最核心的部分,则是一个叫作 kubelet 的组件
- 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)。
Kubernetes 从 Google Borg 系统演化而来。因此从一开始就把关注点放到了如何编排、管理、调度用户提交的作业上。这个出发点来自于 Borg 的研究人员在论文中提到的一个非常重要的观点:运行在大规模集群中的各种任务之间,实际上存在着各种各样的关系。这些关系的处理,才是作业编排和管理系统最困难的地方。
4.2 kubernetes 核心功能
所以 Kubernetes 项目最主要的设计思想是,从更宏观的角度,以统一的方式来定义任务之间的各种关系,并且为将来支持更多种类的关系留有余地。除了应用与应用之间的关系外,应用运行的形态是影响“如何容器化这个应用”的第二个重要因素。
正是基于容器间关系和形态两个维度,Kubernetes 演化出了下面的核心功能:
当我们在使用这些核心功能时 Kubernetes 所推崇的使用方法是:
- 首先,通过一个“编排对象”,比如 Pod、Job、CronJob 等,来描述你试图管理的应用;
- 然后,再为它定义一些“服务对象”,比如 Service、Secret、Horizontal Pod Autoscaler(自动水平扩展器)等。这些对象,会负责具体的平台级功能。
这种使用方法,就是所谓的声明式 API。这种 API 对应的编排对象和服务对象,都是 Kubernetes 项目中的 API 对象(API Object)。
过去很多的集群管理项目(比如 Yarn、Mesos,以及 Swarm)所擅长的,都是把一个容器,按照某种规则,放置在某个最佳节点上运行起来。这种功能,我们称为“调度”。而 Kubernetes 项目所擅长的,是按照用户的意愿和整个系统的规则,完全自动化地处理好容器之间的各种关系。这种功能,就是我们经常听到的一个概念:编排。所以说,Kubernetes 项目的本质,是为用户提供一个具有普遍意义的容器编排工具。