深入剖析Kubernetes(极客时间)

05. 白话容器基础(一):从进程说开去

容器本身没有价值,有价值的是“容器编排”。

容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”。

跟真实存在的虚拟机不同,在使用 Docker 的时候,并没有一个真正的“Docker 容器”运行在宿主机里面。Docker 项目帮助用户启动的,还是原来的应用进程,只不过在创建这些进程时,Docker 为它们加上了各种各样的 Namespace 参数。这时,这些进程就会觉得自己是各自 PID Namespace 里的第 1 号进程,只能看到各自 Mount Namespace 里挂载的目录和文件,只能访问到各自 Network Namespace 里的网络设备,就仿佛运行在一个个“容器”里面,与世隔绝。

06. 白话容器基础(二):隔离与限制

“敏捷”和“高性能”是容器相较于虚拟机最大的优势,也是它能够在 PaaS 这种更细粒度的资源管理平台上大行其道的重要原因。

容器相比于虚拟机也有很多不足之处,其中的最主要问题是:隔离得不彻底

  • 首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核。
  • 其次,在 Linux 内核中,有很多资源和对象是不能被 Namespace 化的,最典型的例子就是:时间。

Linux Cgroups 的设计还是比较易用的,简单粗暴地理解呢,它就是一个子系统目录加上一组资源限制文件的组合。而对于 Docker 等 Linux 容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的 PID 填写到对应控制组的 tasks 文件中就可以了。

一个正在运行的 Docker 容器,其实就是一个启用了多个 Linux Namespace 的应用进程,而这个进程能够使用的资源量,则受 Cgroups 配置的限制。这也是容器技术中一个非常重要的概念,即:容器是一个“单进程”模型。

在一个容器中,你没办法同时运行两个不同的应用,除非找到一个公共的 PID=1 的程序来充当两个不同应用的父进程(systemd、supervisord)。但还有更好的解决方法,因为容器本身的设计就是希望容器和应用能够同生命周期,不希望出现“容器是正常运行的,但是里面的应用早已经挂了”的情况。

07. 白话容器基础(三):深入理解容器镜像

Mount Namespace 是第一个进入 Linux 内核的 namespace,它们隔离了每个进程可以看到的挂载点列表。

Mount Namespace 修改的,是容器进程对文件系统“挂载点”的认知。只有在“挂载”这个操作发生之后,进程的视图才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点。所以在创建新进程时,除了声明启用 Mount Namespace 之外,可以告诉进程那些目录需要重新挂载。

我们可以在容器进程启动之前重新挂载它的整个根目录“/”。而由于 Mount Namespace 的存在,这个挂载对宿主机不可见,所以容器进程就可以在里面随便折腾了。chroot 命令可以改变进程的根目录到你指定的位置。

为了让容器的根目录更”真实“,一般会在容器的根目录下挂载一个完整操作系统的文件系统,而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫作:rootfs(根文件系统)。

对 Docker 项目来说,它最核心的原理实际上就是为待创建的用户进程:

  1. 启用 Linux Namespace 配置
  2. 设置指定的 Cgroups 参数
  3. 切换进程的根目录 (Change Root)(优先使用 pivot_root 系统调用)

rootfs 只包括了操作系统的“躯壳”(文件、配置和目录),并没有包括操作系统的“灵魂”(内核)。同一台机器上的所有容器,都共享宿主机操作系统的内核。

正是由于 rootfs 的存在,容器才有了一个被反复宣传至今的重要特性:一致性。容器镜像“打包操作系统”的能力赋予了容器的一致性。

Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。

Docker 镜像使用了联合文件系统(Union File System),最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。下文中以 AuFS 为例。

容器的 rootfs 由三部分组成:
[ 可读写层 ](容器层)
[ Init层 ]
[ 只读层 ](镜像层)

  1. 只读层:位于最下面,挂载方式都是只读的(readonly + whiteout)
  2. 可读写层:位于最上边,挂载方式为 readwrite。
    在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中。而如果要删除一个只读层里的文件,AuFS(我的主机上使用的其实是 overlay2) 会在可读写层创建一个 whiteout 文件,把只读层里的文件“遮挡”起来。可读写层的作用,就是专门用来存放你修改 rootfs 后产生的增量,无论是增、删、改,都发生在这里,而只读层不会有任何改变。
  3. Init 层:是一个以“-init”结尾的层,夹在只读层和读写层之间。Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改。而用户执行 docker commit 只会提交可读写层,并不会包含这些内容。
    相同的文件上层会覆盖掉下层。在修改镜像层文件时,首先会从上到下查找有没有这个文件,找到,就复制到容器层中,修改,修改的结果就会作用到下层的文件,这种方式也被称为copy-on-write。

08. 白话容器基础(四):重新认识Docker容器

docker exec 的实现原理:一个进程,可以选择加入到某个进程已有的 Namespace 当中,从而达到“进入”这个进程所在容器的目的。而这个操作所依赖的,乃是一个名叫 setns() 的 Linux 系统调用。

Volume 机制,允许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录(比如 /home 目录),挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib/docker/aufs/mnt/[可读写层 ID]/test)上,这个 Volume 的挂载工作就完成了。

更重要的是,由于执行这个挂载操作时,“容器进程”(容器初始化进程-dockerint)已经创建了,也就意味着此时 Mount Namespace 已经开启了。所以,这个挂载事件只在这个容器里可见。你在宿主机上,是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破。

这里使用的是 Linux 的绑定挂载(bind mount)机制,它的主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。(所有操作都发生在宿主机,而不会影响容器镜像)

容器 Volume 里的信息,并不会被 docker commit 提交掉;但这个挂载点目录 /test 本身,则会出现在新的镜像当中。

09. 从容器到容器云:谈谈Kubernetes的本质

Alt text

控制节点,Master:

  • kube-apiserver:负责 API 服务
  • kube-scheduler:负责调度
  • kube-controller-manager:负责容器编排
  • etcd:负责存储集群的持久化数据

计算节点,Worker:

  • kubelet:
    • 处理容器运行时:CRI
    • 网络插件和存储插件为容器配置网络和持久化存储:CNI、CSI

从一开始,Kubernetes 项目就没有像同时期的各种“容器云”项目那样,把 Docker 作为整个架构的核心,而仅仅把它作为最底层的一个容器运行时实现。

运行在大规模集群中的各种任务之间,实际上存在着各种各样的关系。这些关系的处理,才是作业编排和管理系统最困难的地方。

Kubernetes 项目最主要的设计思想是,从更宏观的角度,以统一的方式来定义任务之间的各种关系,并且为将来支持更多种类的关系留有余地。

Alt text

从容器出发
容器间”紧密协作“ : pod
一次启动多个 pod : Deployment
通过固定的 IP 和端口以负载均衡的方式访问一组 pod : Service
需要配置文件:ConfigMap
需要配置隐私文件:Secret
需要一次性运行的 Pod:Job
需要每个节点上只运行一个副本的守护进程:DaemonSet
需要定时任务:Crontab

Kubernetes 项目并没有像其他项目那样,为每一个管理功能创建一个指令,然后在项目中实现其中的逻辑。这种做法,的确可以解决当前的问题,但是在更多的问题来临之后,往往会力不从心。

相比之下,在 Kubernetes 项目中,我们所推崇的使用方法是:

  • 首先,通过一个“编排对象”,比如 Pod、Job、CronJob 等,来描述你试图管理的应用;
  • 然后,再为它定义一些“服务对象”,比如 Service、Secret、Horizontal Pod Autoscaler(自动水平扩展器)等。这些对象,会负责具体的平台级功能。