容器技术概念入门篇
从进程说开去
容器本身没有价值,有价值的是“容器编排”。
容器其实是一种沙盒技术。顾名思义,沙盒就是能够像一个集装箱一样,把你的应用“装”起来的技术。
- 应用与应用之间,就因为有了边界而不至于相互干扰;
- 而被装进集装箱的应用,也可以被方便地搬来搬去,这不就是 PaaS 最理想的状态嘛。
边界和Namespace
对于进程而言
- 静态表现就是程序,平常都安安静静地待在磁盘上
- 动态表现,就是程序一旦运行起来,它就变成了计算机里的数据和状态的总和
容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”。
对于 Docker 等大多数 Linux 容器来说,Cgroups 技术是用来制造约束的主要手段,而 Namespace 技术则是用来修改进程视图的主要方法。
当我们执行docker run -it busybox /bin/sh时,实际上是告诉计算,请帮我启动一个容器,在容器里执行 /bin/sh,并且给我分配一个命令行终端跟这个容器交互。
之后,我在容器中,执行ps后可以看到,/bin/sh的PID是1,ps的PID是10。看不到其他进程信息,这是因为容器已经被Docker隔离在一个和宿主机完全不同的世界里了。
而能做到这一点,其实是一个“障眼法”。本来在宿主机上执行/bin/sh,分配给PID是100,Docker使用这种“障眼法”,让我们看不到前面99个进程,让我们误以为/bin/sh的PID是1。
做到“障眼法”的方法就是使用了Linux 里面的 Namespace 机制。当我们使用clone在Linux中创建新进程的时候,
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);
使用第三个参数 CLONE_NEWPID,新创建的这个进程将会“看到”一个全新的进程空间。
而除了我们刚刚用到的 PID Namespace,Linux 操作系统还提供了 Mount、UTS、 IPC、Network 和 User 这些 Namespace,用来对各种不同的进程上下文进行“障眼 法”操作。
例如
- Mount Namespace,用于让被隔离进程只看到当前 Namespace 里的挂载点信息;
- Network Namespace,用于让被隔离进程看到当前 Namespace 里的网络设备和配置。
所示说容器,其实是一种特殊的进程而已。
虚拟机和容器的对比
左边的图,是虚拟机的工作原理。其中名为Hypervisor是最核心的部分。Hypervisor通过硬件虚拟化技术,首先模拟出一个操作系统需要的各种硬件,比如 CPU、内存、I/O 设备,之后在这些虚拟硬件上安装一个新的操作系统,即Guest OS。
如此,在Guest OS上的用户只能看到Guest OS中的目录和文件,一起其他虚拟设备。这也就是做到了虚拟机的应用进程的隔离。
右边的图,是Docker的原理图。使用一个名为 Docker Engine 的软件替换了 Hypervisor。和虚拟机不同的是,使用Docker的时候,并没有一个真正的Docker容器运行在宿主机上。Docker帮助用户启动的应用进程,还是原来的应用进程,只不过使用了Namespace技术进行了隔离。
虽然使用了Namespace 技术,但实际上Namespace只修改了应用进程看待整个计算机“视图”,即它的“视线”被操作系统做了限制,只能“看到”某些指定 的内容。但这些进程和宿主机上的进程并没有太大区别。
其实也说明了一点,Docker Engine 或者任何容器管理工具和Hypervisor作用并不一致。Hypervisor负责为应用程序隔离环境,而对Docker而言,宿主机才负责隔离环境,而非Docker Engine 或者任何容器管理工具。所以Docker的示意图,更像是右边的。
但这也解释了为什么Docker是轻量级的,也更受欢迎。
因为使用虚拟化技术作为应用沙盒,就必须要由 Hypervisor 来负责创建虚拟机,这个虚拟机是真实存在的,并且它里面必须运行一个完整的 Guest OS 才能执行用户的应用 进程。这就不可避免地带来了额外的资源消耗和占用。
而相比之下,容器化后的用户应用,却依然还是一个宿主机上的普通进程,这就意味着这些 因为虚拟化而带来的性能损耗都是不存在的;而另一方面,使用 Namespace 作为隔离手 段的容器并不需要单独的 Guest OS,这就使得容器额外的资源占用几乎可以忽略不计。
所以说,“敏捷”和“高性能”是容器相较于虚拟机最大的优势,也是它能够在 PaaS 这 种更细粒度的资源管理平台上大行其道的重要原因。
限制和Linux Cgroups
不过Linux Namespace 也有不足之处:隔离得不彻底。
- 首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同 一个宿主机的操作系统内核。
- 其次,在 Linux 内核中,有很多资源和对象是不能被 Namespace 化的,最典型的例子就 是:时间。
虽然使用了Namespace“障眼法”,在容器内进程和宿主机都是平等的,但是对于资源而言,这些进程间也是平等的。这就导致容器内进程可以随意使用宿主机的资源,甚至占用所有资源。
而Linux Cgroups 就是 Linux 内核中用来为进程设置资源限制的一个重要功能。Linux Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。
Linux Cgroups 的设计还是比较易用的,简单粗暴地理解呢,具体使用方法就是创建一个子系统目录,并在目录中加上一组资源限制文件。
而在Docker中,这些资源限制文件的内容,则使用docker run的参数指定的。
docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash
容器镜像
Namespace 的作用是“隔离”,它让应用进程只能看到该 Namespace 内的“世界”;而 Cgroups 的作用是“限制”,它给这个“世界”围上了一 圈看不见的墙。
Mount Namespace 修改的,是容器进程对 文件系统“挂载点”的认知。
这就是 Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它对容器进程 视图的改变,一定是伴随着挂载操作(mount)才能生效。
而对于普通人来说,每当创建一个新容器时,我希望容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。
Linux中有个chroot 的命令可以帮你“change root file system”,即改变进程的根目录到指定的位置。
为了能够让容器的这个根目录看起来更“真实”,我们一般会在这个容器的根目录下 挂载一个完整操作系统的文件系统,比如 Ubuntu16.04 的 ISO。这样,在容器启动之后, 我们在容器里通过执行 "ls /" 查看根目录下的内容,就是 Ubuntu 16.04 的所有目录和文 件。
这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓 的“容器镜像”。它还有一个更为专业的名字,叫作:rootfs(根文件系统),rootfs下的内容如下:
Docker 项目来说,它最核心的原理实际上就是为待创建的用户进程:
- 启用 Linux Namespace 配置;
- 设置指定的 Cgroups 参数;
- 切换进程的根目录(Change Root)。会优先使用 pivot_root 系统调用,如果系统不支持,才会使用 chroot。
这样,一个完整的容器就诞生了。
所以说,rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。
rootfs 只包括了操作系统的“躯壳”,并没有包括操作系统的“灵魂”。实际上,同一台机器上的所有容器,都共享宿主机操作系统的内核。而共享内核,就会导致应用程序的内核操作,会影响宿主机。容器相比于虚拟机的主要缺陷之一。
容器镜像一致性
正是由于 rootfs 的存在,容器才有了一个被反复宣传至今的重要特性:一致性。
由于 rootfs 里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。这种“打包操作系统”的能力,成就了容器的一致性。
这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难 以逾越的鸿沟。
Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作 镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。
这种增量的方式,用到了一种叫作联合文件系统(Union File System)的能力。
以 Ubuntu 16.04 和 Docker CE 18.05为例,这个组合下使用的 是AuFS 这个联合 文件系统。对于 AuFS 来说,它最关键的目录结构在 /var/lib/docker 路径下的 diff 目录。
启动docker run -d ubuntu:latest sleep 3600,Docker 就会从 Docker Hub 上拉取一个 Ubuntu 镜像到本地。实际上,拉取的就是ubuntu的rootfs。不过Docker镜像使用的是rootfs往往由多层组成。
可以看到这个ubuntu镜像有五层组成,每一层都是一个增量rootfs。在启动镜像的时候,Docker会把这些增量联合挂载到一个统一的挂载点上, /var/lib/docker/aufs/mnt/。这个目录下,就是ubuntu镜像的完整操作系统。
/sys/fs/aufs记录了Ubuntu 文件系统的各个增量rootfs信息。从下图可以看到,增量rootfs各层,都放置在 /var/lib/docker/aufs/diff 目录下。
从上图也可以看出各层,大致可以分为三类。
- 第一部分,只读层。容器的下5层,对应的正是 ubuntu:latest 镜像的五层,这部分都是只读的。
- 第二部分,可读写层。专门用来存放你修改 rootfs 后产生的增量。它是这个容器的 rootfs 最上面的一层(6e3be5d2ecccae7cc),它的挂载方式为:rw。在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你 修改产生的内容就会以增量的方式出现在这个层中。
a. 修改过的容器之后,还可以使用 docker commit 和 push 指令,保存这个被修改过的可读写层,并上传到 Docker Hub 上,供其他人使用; - Init 层。夹在只读层和读写层之间。Init 层是 Docker 项目单独生成 的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。
a. 这是由于,用户在启动镜像的时候,需要修改一些指定的值比如 hostname。可是,这些修改往往只对当前的容器有效,我们并不希望执行 docker commit 时,把这些信息连同可读写层一起提交掉。所以Docker的做法就是把这类修改,放在init层,但不提交。
重新认识Docker容器
之所以,一致要强调 Linux 容器,是因为比如 Docker on Mac,以及 Windows Docker(Hyper-V 实现),实际上是基于虚拟化技术实现的,跟Linux 容器完全不同。
容器实际案例
案例需要部署一个python的web应用,应用由app.py和requirements.txt组成。
-
第一步,制作容器镜像。使用Dockerfile,制作增量rootfs。Dockerfile 的设计思想,是使用一些标准的原语,描述我们所要构建的 Docker 镜像。并且这些原语,都是按顺序处理 的。
a.
b. RUN 原语就是在容器里执行 shell 命令的意思
c. WORKDIR,意思是在这一句之后,Dockerfile 后面的操作都以这一句指定的 /app 目 录作为当前目录。
d. CMD,意思是 Dockerfile 指定 python app.py 为这个容器的进程。,CMD [“python”, “app.py”] 等价于 "docker run python app.py"。
e. ENTRYPOINT 的原语,完整执行格式是:“ENTRYPOINT CMD”。默认情况下,Docker 会为你提供一个隐含的 ENTRYPOINT,即:/bin/sh -c。在本例中就是/bin/sh -c “python app.py”,即 CMD 的内容就是 ENTRYPOINT 的参数。 - 制作镜像,执行命令docker build -t helloworld
a. -t 的作用是给这个镜像加一个 Tag,即:起一个好听的名字。
b. docker build 会自动 加载当前目录下的 Dockerfile 文件,然后按照顺序,执行文件中的原语。
c. 需要注意的是,Dockerfile 中的每个原语执行后,都会生成一个对应的镜像层。即使原语 本身并没有明显地修改文件的操作(比如,ENV 原语),它对应的层也会存在。 - 启动镜像:docker run -p 4000:80 helloworld
a. -p 4000:80,说明容器内4000端口映射到80端口 - commit镜像:执行命令docker commit
a. 实际上就是在容器运行起来后,把最上层的“可读写层”,加上原先容 器镜像的只读层,打包组成了一个新的镜像。
b. Init 层的存在,避免在执行 docker commit 时,把 Docker 对 /etc/hosts 等文件做的修改,也一起提交掉。 - 提交镜像:docker push geektime/helloworld:v2
docker exec
一个进程,可以选择加入到某个进程已有的 Namespace 当中,从而达 到“进入”这个进程所在容器的目的,这正是 docker exec 的实现原理。
这个操作所依赖的,乃是一个名叫 setns() 的 Linux 系统调用。
Volume(数据卷)
Volume 机制,允许你将宿主机上指定的目录或 者文件,挂载到容器里面进行读取和修改操作。
两种 Volume 声明方式
而这两种声明方式的本质,实际上是相同的:都是把一个宿主机的目录挂载进了容器的 /test 目录。
不同之处在于,在第一种情况下,由于并没有显示声明宿主机目录,那么 Docker 就会默认在宿 主机上创建一个临时目录 /var/lib/docker/volumes/[VOLUME_ID]/_data
只需要在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录(比如 /home 目录),挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib/docker/aufs/mnt/[可读写层 ID]/test)上,这个 Volume 的挂载工作 就完成了。
此时,“容器进程”已经创建了,也就意味着此时 Mount Namespace 已经开启了。所以,这个挂载事件只在这个容器里可见。你在宿主机 上,是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破。
挂载技术,就是 Linux 的绑定挂载(bind mount)机制。
进程在容器里对这个 /test 目录进行的所有操作,都实际发生在宿主机的对应目录 (比如,/home,或者 /var/lib/docker/volumes/[VOLUME_ID]/_data)里,而不会影响 容器镜像的内容。
对挂载点的修改操作,并不会被docker commit提交,这是由于 Mount Namespace 的隔离作用,宿主机并不知道这个绑定挂载的存在。所以,在宿主机看来,容器中可读写层的 /test 目录 (/var/lib/docker/aufs/mnt/[可读写层 ID]/test),始终是空的。
但是在docker commit之后,会发现一个空的test目录。
Docker容器全景图
Kubernetes的本质
一个“容器”,实际上是一个由 Linux Namespace、 Linux Cgroups 和 rootfs 三种技术构建出来的进程的隔离环境。
Linux容器可以看做两部分:
- 一组联合挂载在 /var/lib/docker/aufs/mnt 上的 rootfs,这一部分我们称为“容器镜 像”(Container Image),是容器的静态视图;
- 一个由 Namespace+Cgroups 构成的隔离环境,这一部分我们称为“容器运行 时”(Container Runtime),是容器的动态视图。
从一个开发者和单一的容器镜像,到无数开发者和庞大的容器集群,容器技术实现了从“容 器”到“容器云”的飞跃。容器就从一个开发者手里的小工具,一跃成为了云计算领域的绝对主角;而能够定义 容器组织和管理规范的“容器编排”技术,则当仁不让地坐上了容器技术领域的“头把交椅”。
Kubernetes 项目要解决的问题是什么
Kubernetes每个阶段要解决的问题都是不同的。
但是对于大多数用户来说,他们希望 Kubernetes 项目带来的体验是确定的: 现在我有了应用的容器镜像,请帮我在一个给定的集群上把这个应用运行起来。我还希望 Kubernetes 能给我提供路由网关、水平扩展、监控、备份、灾 难恢复等一系列运维能力。
从一开始,Kubernetes 项目就没有像同时期的各种“容器 云”项目那样,把 Docker 作为整个架构的核心,而仅仅把它作为最底层的一个容器运行 时实现。
Kubernetes 项目的架构,由 Master 和 Node 两种节点组成,而这两种角色分别对应着控制节点和计算节点。
- 控制节点,即 Master 节点,由三个紧密协作的独立组件组合而成
- 负责 API 服务的 kube-apiserver、
- 负责调度的 kube-scheduler
- 负责容器编排的 kube- controller-manager。
- 整个集群的持久化数据,则由 kube-apiserver 处理后保存在 Etcd 中。
- 计算节点上最核心的部分,则是一个叫作 kubelet 的组件。
- kubelet 主要负责同容器运行时(比如 Docker 项目)打交道
- 交互依赖的是一个称作 CRI(Container Runtime Interface)的远程调用接口,这也是Kubernetes 项目并不关心你部署的是什么容器运行时、使用的什么技术实现.比如 Docker 项目,则一般通过 OCI 这个容器运行时规范同底层的 Linux 操作系统进行交互
- kubelet 还通过 gRPC 协议同一个叫作 Device Plugin 的插件进行交互,管理GPU等宿主机的物理设备
- kubelet通过调用网络插件和存储插件为容器配置网络和持久化存储。这两个插件与 kubelet 进行交互的接口,分别是 CNI(Container Networking Interface)和 CSI(Container Storage Interface)
Kubernetes 项目要着重解决的问题
运行在大规模集群中的各种任务之间,实际上存在着各种各样的关系。这些 关系的处理,才是作业编排和管理系统最困难的地方。
容器技术出现以后,在“功能单位”的划分上,容器有着独一无二的“细 粒度”优势:毕竟容器的本质,只是一个进程而已。
原先拥挤在同一个虚拟机里的各个应用、组件、守护进程,都 可以被分别做成镜像,然后运行在一个个专属的容器中。它们之间互不干涉,拥有各自的资源配额,可以被调度在整个集群里的任何一台机器上
Kubernetes 项目最主要的设计思想是,从更宏观的角度,以统一的方式来定义任 务之间的各种关系,并且为将来支持更多种类的关系留有余地。
- Kubernetes 项目对容器间的“访问”进行了分类,首先总结出了一类非常常见 的“紧密交互”的关系。这些应用之间需要非常频繁的交互和访问;又或者,它们会直 接通过本地文件进行信息交换。
○ 这些容器则会被划分为一个“Pod”, Pod 里的容器共享同一个 Network Namespace、同一组数据卷,从而达到高效率交换信 息的目的。 - Web 应用与数据库之间的访问关系
○ 提供了一种叫作“Service”的服务,给 Pod 绑定一个 Service 服务,而 Service 服务声明的 IP 地址等信息是“终生不变”的。这个Service 服务的主要作用,就是作为 Pod 的代理入 口(Portal),从而代替 Pod 对外暴露一个固定的网络地址。
如上的场景的不断扩展,总Kubernetes 项目发展而来的核心功能“全景图”
除了应用与应用之间的关系外,应用运行的形态是影响“如何容器化这个应用”的第二个重要因素。使用“声明式 API”。这种 API 对应的“编排对象”和“服务对象”,都是 Kubernetes 项目中的 API 对象(API Object)。
容器编排与Kubernetes作业管理
POD
Pod,是 Kubernetes 项目中最小的 API 对象,也是 Kubernetes 项目的原子调度单位。
容器的本质是进程。容器,就是未来云计算系统中的进程;容器镜像就是这个系统里的“.exe”安装包。Kubernetes 就是操作系统。
Kubernetes 将“进程组”的概念映射到了容器技术中,并使其 成为了这个云计算“操作系统”里的“一等公民”。
Pod 是 Kubernetes 里的原子调度单位。这就意味着,Kubernetes 项目的调度器,是统一按照 Pod 而非容器的资源 需求进行计算的。
Pod一方面是为了解决“超亲密关系”(容器间的紧密协作)这样的调度问题,一个Pod包含多个容器。
Pod 的实现原理
Pod只是一个逻辑概念。其实是一组共享了某些资源的容器。具体来说,Pod 里的所有容器,共享的是同一个 Network Namespace,并且可以声明共享同一个 Volume。
在 Kubernetes 项目里,Pod 的实现需要使用一个中间容器,这个容器叫作 Infra 容 器。在这个 Pod 中,Infra 容器永远都是第一个被创建的容器,而其他用户定义的容器,则 通过 Join Network Namespace 的方式,与 Infra 容器关联在一起。
Infra 容器一定要占用极少的资源,所以它使用的是一个非常特殊的镜像,叫作:k8s.gcr.io/pause。这个镜像是一个用汇编语言编写的、永远处于“暂停”状态的容器,解压后的大小也只有 100~200 KB 左右。
共享 Volume 就简单多了,Kubernetes 项目只要把所有 Volume 的 定义都设计在 Pod 层级即可。
容器设计模式
Pod 这种“超亲密关系”容器的设计思想,实际上就是希望,当用户想在一个容器里跑多 个功能并不相关的应用时,应该优先考虑它们是不是更应该被描述成一个 Pod 里的多个容器。
- 例如:WAR 包与 Web 服务器分别放在不同的容器中,但是在同一个Pod中。这个所谓的“组合”操作,就是所谓的 sidecar。sidecar 指的就是我们可以在一个 Pod 中,启动一个辅助容器,来完成一些独 立于主进程(主容器)之外的工作。
- 容器的日志收集的例子
Pod 扮演的是 传统部署环境里“虚拟机”的角色。凡是调度、网络、存储,以及安全相关的属性,基本上是 Pod 级别的。这些属性的共同特征是,它们描述的是“机器”这个整体,而不是里面运行的“程序”。
Pod中几个重要字段
- NodeSelector:是一个供用户将 Pod 与 Node 进行绑定的字段
- HostAliases:定义了 Pod 的 hosts 文件(比如 /etc/hosts)里的内容
- Container字段,包括Image(镜像)、Command(启动命 令)、workingDir(容器的工作目录)、Ports(容器要开发的端口),以及 volumeMounts(容器要挂载的 Volume)
○ ImagePullPolicy:值默认是 Always,即每次创建 Pod 都重新拉取一次镜像。另外,当容器的镜像是类似于 nginx 或者 nginx:latest 这样的名字时,ImagePullPolicy 也会被认为 Always。而定义为 Never 或者 IfNotPresent,则意味着 Pod 永远不会主动拉取这个 镜像,或者只在宿主机上不存在这个镜像时才拉取。
○ Lifecycle, Container Lifecycle Hooks。顾名思义, Container Lifecycle Hooks 的作用,是在容器状态发生变化时触发一系列“钩子”。例如postStart,preStop(优雅停) - Status,体现Pod 生命周期的变化。
○ Pending。这个状态意味着,Pod 的 YAML 文件已经提交给了 Kubernetes,API 对象 已经被创建并保存在 Etcd 当中。但是,这个 Pod 里有些容器因为某种原因而不能被顺利创建。比如,调度不成功。
○ Running。这个状态下,Pod 已经调度成功,跟一个具体的节点绑定。它包含的容器都已经创建成功,并且至少有一个正在运行中。
○ Succeeded。这个状态意味着,Pod 里的所有容器都正常运行完毕,并且已经退出了。这种情况在运行一次性任务时最为常见。
○ Failed。这个状态下,Pod 里至少有一个容器以不正常的状态(非 0 的返回码)退出。这个状态的出现,意味着你得想办法 Debug 这个容器的应用,比如查看 Pod 的 Events和日志。
○ Unknown。这是一个异常状态,意味着 Pod 的状态不能持续地被 kubelet 汇报给kube-apiserver,这很有可能是主从节点(Master 和 Kubelet)间的通信出现了问题。
投射数据卷
Projected Volume,即“投射数据卷”。这些特殊 Volume 的作用,是为容器提供预先定义好的数据。所以,从容器的角度来看,仿佛是被Kubernetes“投射”(Project)进入容器当中的。主要由以下四种
- Secret:把 Pod 想要访问的加密数据,存放到 Etcd 中。通过在 Pod 的容器里挂载 Volume 的方式使用这些加密数据。
○ 通过挂载方式进入到容器里的 Secret,一旦其对应的 Etcd 里的数据被 更新,这些 Volume 里的文件内容,同样也会被更新。但存在一定的延迟。 - ConfigMap:不需要加密的、应用所需的配置信息。用法和Secret一致。
- Downward API:让 Pod 里的容器,能够获取到这个Pod的一些基本信息。
○ Downward API 能够获取到的信息,一定是 Pod 里的容器进程启动 之前就能够确定下来的信息
○ 如何想要获取 Pod 容器运行后才会出现的信息,可以考虑sidecar - ServiceAccountToken。
○ Service Account 对象的作用,就是 Kubernetes 系统内置的一种“服务账户”,它是 Kubernetes 进行权限分配的对象。
○ 像Service Account 的授权信息和文件,实际上保存在它所绑定的一个特殊的 Secret 对象里的。这个特殊的 Secret 对象,就叫作ServiceAccountToken
○ 在Pod中,Kubernetes 已经提供了一个的默认“服务账户”(default Service Account)。
容器健康检查和恢复机制
可以为 Pod 里的容器定义一个健康检查“探针”(Probe),kubelet 就会根据这个 Probe 的返回值决定这个容器的状态
livenessProbe,除了在容器中执行命令外,livenessProbe 也可以定义为发起 HTTP 或者 TCP 请求的方 式
与livenessProbe相对的,另一个检查字段readinessProbe。readinessProbe检查结果的成功与否,决定的 这个 Pod 是不是能被通过 Service 的方式访问到。
Pod 恢复机制,也叫 restartPolicy。它是 Pod 的 Spec 部 分的一个标准字段(pod.spec.restartPolicy),默认值是 Always。可选value包括
- Always:在任何情况下,只要容器不在运行状态,就自动重启容器; OnFailure: 只在容器 异常时才自动重启容器;
- Never: 从来不重启容器。
Pod 的恢复过程,永远都是发生在当前节点上,而不会跑到别的节点 上去。
PodPreset
运维人员事先定义好的Pod YAML文件。
PodPreset 里定义的内容,只会在 Pod API 对象被创建之前追加在这个 对象本身上,而不会影响任何 Pod 的控制器的定义。
控制器(Controller)
K8S通过控制器(Controller),操作pod。kube-controller-manager 组件,是一系列控制器的集合。所有控制器被统一放置在kubernetes/pkg/controller/目录中,其中Deployment就是控制器的一种。
这一系列控制器都遵循一个通用的编排模式,即:循环控制(control loop)。
循环控制(control loop),包括三部分:监控期望状态,监控实际状态,对比调整pod。
● 在具体实现中,实际状态往往来自于 Kubernetes 集群本身。
● 而期望状态,一般来自于用户提交的 YAML 文件。
● 第三步,对比调整pod,也叫做调谐(Reconcile)。这个调谐的过程,则被称作“Reconcile Loop”(调谐循环)或者“Sync Loop”(同步循环)。
deployment实现了pod的“水平扩展/收缩”(horizontal scaling out/in)。这个能力依赖的是 Kubernetes 项目中的一个非常重要的概念(API 对象): ReplicaSet。
一个 ReplicaSet 对象,其实就是由副本数目的定 义和一个 Pod 模板组成的。不难发现,ReplicaSet的定义其实是 Deployment 的一个子集。更重要的是,Deployment 控制器实际操纵的,正是这样的 ReplicaSet 对象,而不是 Pod 对象。
Deployment、ReplicaSet和Pod的关系
Deployment 的控制器,实际上控制的是 ReplicaSet。
而一个应用的版本,对应的正是一个 ReplicaSet; 这个版本应用的 Pod 数量,则由 ReplicaSet 通过它自己的控制器(ReplicaSet Controller)来保证。
通过这样的多个 ReplicaSet 对象,Kubernetes 项目就实现了对多个“应用版本”的描述。
相关命令
kubectl rollout status deployment/nginx-deployment
实时查看 Deployment 对象的 状态变化
kubectl get rs,rs的名字后缀会有一个随机数, pod-template-hash。rs会把这个随机数加到他的Pod里面,从而方便管理。
kubectl get deployments
kubectl edit deployment/nginx-deployment
kubectl describe deployment nginx-deployment
kubectl scale deployment nginx-deployment --replicas=4
kubectl set image deployment/nginx-deployment nginx=nginx:1.91
kubectl rollout undo deployment/nginx-deployment,回滚到上一个版本
kubectl rollout history deployment/nginx-deployment,查看历史版本
kubectl rollout history deployment/nginx-deployment --revision=2,查看某个版本的细节
kubectl rollout undo deployment/nginx-deployment --to-revision=2,回滚到某个版本
kubectl rollout pause deployment/nginx-deployment,暂停更新
kubectl rollout resume deploy/nginx-deployment,开启更新
StatefulSet
实例之间有不对等关系,以及实例对外部数据有依赖关系的应用,就被称为“有状态应用”(Stateful Application)。
StatefulSet 其实就是一种特殊的 Deployment,而其独特之处在于,它的每个 Pod 都被编号了。而且,这个编号会体现在 Pod 的名字和 hostname 等标识信息上,这不仅代表了 Pod 的创建顺序,也是 Pod 的重 要网络标识(即:在整个集群里唯一的、可被的访问身份)。
有了这个编号后,StatefulSet 就使用 Kubernetes 里的两个标准功能:Headless Service 和 PV/PVC,实现了对 Pod 的拓扑状态和存储状态的维护。
StatefulSet把现实世界中应用的关系,抽象为两种:
● 拓扑状态:先后关系
● 存储状态:一个数据库应用的多个存储实例
维护拓扑状态
StatefulSet 的核心功能,就是通过某种方式记录这些状态,然后在 Pod 被重新创建时,能够为新 Pod 恢复这些状态。
StatefulSet 其实可以认为是对 Deployment 的改良。
StatefulSet 这个控制器的主要作用之一,就是使用 Pod 模板创建 Pod 的时候,对它们进行编号,并且按照编号顺序逐一完成创建工作。而当 StatefulSet 的“控制循环”发现 Pod 的“实际状态”与“期望状态”不一致,需要新建或者删除 Pod 进行“调谐”的时候,它会严格按照这些 Pod 编号的顺序,逐一完成这些操作。
Service 是 Kubernetes 项目中用来将一组 Pod 暴露给外界访问的一种机制。Service 能够被访问到主要是两种方式:
- 是以 Service 的 VIP(Virtual IP,即:虚拟 IP)方式。访问Service的IP地址10.0.23.1,实际上,会被转发到该Service所代理的某个Pod上。
- 以 Service 的 DNS 方式。只要我访问“my-svc.my- namespace.svc.cluster.local”这条 DNS 记录,就可以访问到名叫 my-svc 的 Service 所 代理的某一个 Pod。实现也是两种方式:
a. Normal Service。访问“my-svc.my- namespace.svc.cluster.local”解析到的,正是 my-svc 这个 Service 的 VIP,后面的流程 就跟 VIP 方式一致了。
b. Headless Service。访问“my-svc.my- namespace.svc.cluster.local”解析到的,直接就是 my-svc 代理的某一个 Pod 的 IP 地址。区别在于直接访问到了某个Pod的IP地址。在yml定义Service中,clusterIP 字段的值设置为None。
对于2.b中的情况,StatefulSet 又是如何使用这个 DNS 记录来维持 Pod 的拓扑状态的呢?
在定义StatefulSet的yml时,使用serviceName指定要使用的Service(service name为nginx的service,clusterIP设置为None)。就是在告诉 StatefulSet 控制器,在执行控制循环(Control Loop)的时候,请使用 nginx 这个 Headless Service 来保证 Pod 的“可解析身份”。
维护存储状态
Kubernetes 项目中,为了降低了用户声明和使用持久化 Volume 的门槛,和避免过度暴露存储细节,引入了一组叫作 Persistent Volume Claim(PVC)和 Persistent Volume(PV)的 API 对象。
Kubernetes 中 PVC 和 PV 的设计,实际上类似于“接口”和“实现”的思想。开发者只要知道并会使用“接口”,即:PVC;而运维人员则负责给“接口”绑定具体的实现,即:PV。
StatefulSet通过以下过程,实现对应用存储状态的管理
- StatefulSet 的控制器直接管理的是 Pod
- 通过 Headless Service,为这些有编号的 Pod,在 DNS 服务器中生成带有同样编号的 DNS 记录
- StatefulSet 还为每一个 Pod 分配并创建一个同样编号的 PVC
相关命令
kubectl patch statefulset mysql --type='json' -p='[{"op": "replace", "path": "/spec/template/spec/containers/0/image", "value":"mysql:5.7.23"}]', 修改模板中的字段,其中path是模板中的路径,value是要修改的值
kubectl patch statefulset mysql -p '{"spec":{"updateStrategy":{"type":"RollingUpdate","rollingUpdate":{"partition":2}}}}',修改模板中的值,类似edit
DaemonSet
DaemonSet 的主要作用,是在 Kubernetes 集群里,运行一个 Daemon Pod。DaemonSet 开始运行的时机,很多时候比整个 Kubernetes 集群出现的时机都要早。
这个 Pod 有如下三个特征:
- 这个 Pod 运行在 Kubernetes 集群里的每一个节点(Node)上;
- 每个节点上只有一个这样的 Pod 实例;
- 当有新的节点加入 Kubernetes 集群后,该 Pod 会自动地在新节点上被创建出来;而当旧节点被删除后,它上面的 Pod 也相应地会被回收掉。
DaemonSet其实就是依靠 Toleration 实现的。通过Toleration,调度器在调度这个 Pod 的时候,就会忽略当前节点上 的“污点”,从而成功地将网络插件的 Agent 组件调度到这台机器上启动起来。
相比于 Deployment,DaemonSet ,和StatefulSet一样,只管理 Pod 对象。并且都是通过ControllerRevision进行版本管理。
在 Kubernetes 项目里,ControllerRevision 是一个通用的版本管理对象。
相关命令
kubectl get ds -n kube-system fluentd-elasticsearch,查看DaemonSet
Job
早在 Borg 项目中,Google 就已经对作业进行了分类处理,提出了 LRS(Long
Running Service)和 Batch Jobs 两种作业形态,对它们进行“分别管理”和“混合调
度”
v1.4 版本之后,社区才逐步设计出了一个用来描述离线业务的 API 对象,它的
名字就是:Job
这个 Job 对象在创建后,它的 Pod 模板,被自动加上了一个 controller-uid=
< 一个随机字符串 > 这样的 Label。而这个 Job 对象本身,则被自动加上了这个 Label 对
应的 Selector,从而 保证了 Job 与它所管理的 Pod 之间的匹配关系
Pod 模板中定义 restartPolicy=Never 的原因:离线计算的 Pod 永远都不应该被重启,否则它们会再重新计算一遍。(restartPolicy 在 Job 对象里只允许被设置为 Never 和 OnFailure)。
当Job失败的时候,会尝试重启。但是重启不是无限进行的, spec.backoffLimit字段里定义了重试次数为 4(即,backoffLimit=4),而这个字段的默认值是 6。且重新创建 Pod 的间隔是呈指数增加的。
在Job 的 API 对象里,有一个 spec.activeDeadlineSeconds 字段可以设置最长运行时间,在Pod不肯结束的时候,主动结束。
Batch Job
Batch Job是可以并行的方式运行。Job Controller 对并行作业的控制方法。
在 Job 对象中,负责并行控制的参数有两个:
- spec.parallelism,它定义的是一个 Job 在任意时间最多可以启动多少个 Pod 同时运
行; - spec.completions,它定义的是 Job 至少要完成的 Pod 数目,即 Job 的最小完成数
相关命令
kubectl get job,获取Job信息,包括Desired和Successful
Job Controller的工作原理
- Job Controller控制的对象,直接就是 Pod
- Job Controller 在控制循环中进行的调谐(Reconcile)操作,根据实际情况调整Pod数量
Job Controller 实际上控制了,作业执行的并行度,以及总共需要完成的任务数这两个重要参数。
三种常用的、使用 Job 对象的方法
- 第一种用法,也是最简单粗暴的用法:外部管理器 +Job 模板。
- 第二种用法:拥有固定任务数目的并行 Job
- 第三种用法,也是很常用的一个用法:指定并行度(parallelism),但不设置固定的completions 的值
CronJob
CronJob 是一个专门用来管理 Job 对象的控制器,其YAML文件中,最重要的关键词就是jobTemplate。其次schedule字段,定义一个标准的Unix Cron格式的表达式,用于创建和删除job。
当旧job尚未执行完成,新job就开始产生了。可以通过spec.concurrencyPolicy指定处理策略。
- concurrencyPolicy=Allow,这也是默认情况,这意味着这些 Job 可以同时存在;
- concurrencyPolicy=Forbid,这意味着不会创建新的 Pod,该创建周期被跳过;
- concurrencyPolicy=Replace,这意味着新产生的 Job 会替换旧的、没有执行完的Job
而如果某一次 Job 创建失败,这次创建就会被标记为“miss”。当在指定的时间窗口内,miss 的数目达到 100 时,那么 CronJob 会停止再创建这个 Job。这个时间窗口,可以由spec.startingDeadlineSeconds 字段指定
声明式 API
声明式 API,才是 Kubernetes 项目编排能力“赖以生存”的核心所在
先 kubectl create,再 replace 的操作,我们称为命令式配置文件操作。和Docker Swarm 的两句命令,没什么本质上的区别。只不过,它是把 Docker 命令行里的参数,写在了配置文件里而已。
kubectl apply 命令,才是声明式 API。
区别在于,kubectl replace 的执行过程,是使用新的 YAML 文件中的API 对象,替换原有的 API 对象;而 kubectl apply,则是执行了一个对原有 API 对象的PATCH 操作
更进一步地,这意味着 kube-apiserver 在响应命令式请求(比如,kubectl replace)的时候,一次只能处理一个写请求,否则会有产生冲突的可能。而对于声明式请求(比如,kubectl apply),一次能处理多个写操作,并且具备 Merge 能力
Kubernetes“声明式 API”的独特之处:
● 首先,所谓“声明式”,指的就是我只需要提交一个定义好的 API 对象来“声明”,我所期望的状态是什么样子。
● 其次,“声明式 API”允许有多个 API 写端,以 PATCH 的方式对 API 对象进行修改,而无需关心本地原始 YAML 文件的内容。
● 最后,也是最重要的,有了上述两个能力,Kubernetes 项目才可以基于对 API 对象的增、删、改、查,在完全无需外界干预的情况下,完成对“实际状态”和“期望状态”的调谐(Reconcile)过程
API 对象
一个 API 对象在 Etcd 里的完整资源路径,是由:Group(API组)、Version(API 版本)和 Resource(API 资源类型)三个部分组成的
通过这样的结构,整个 Kubernetes 里的所有 API 对象,实际上就可以用如下的树形结构表示出来。在Kubernetes 里, API 对象的组织方式,其实是层层递进的。
以一个CronJob为例,在这个 YAML 文件中,“CronJob”就是这个 API 对象的资源类型(Resource),“batch”就是它的组(Group),v2alpha1 就是它的版本(Version)。
提交这个YAML文件后,Kubernetes就会把这个文件内容转换成一个CronJob对象
CRD
在 Kubernetes v1.7 之后,得益于API 插件机制:CRD(Custom Resource Definition ),允许用户在Kubernetes 中添加一个跟 Pod、Node 类似的、新的 API 资源类型,即:自定义 API资源。
基于声明式 API 的业务功能实现,往往需要通过控制器模式来“监视”API 对象的变化(比如,创建或者删除 Network),然后以此来决定实际要执行的具体工作。
基于角色的权限控制之 RBAC
RBAC,基于角色的访问控制(Role-Based Access Control),在 Kubernetes 项目中,负责完成授权(Authorization)工作的机制。
三个最基本的概念
-
Role:角色,它其实是一组规则,定义了一组对 Kubernetes API 对象的操作权限。
○
○ Role 本身就是一个 Kubernetes 的 API 对象
○ YAML中的rules字段,规定了允许哪些人,对哪些资源,做哪些操作
○ 具体的“被作用者”,通过RoleBinding定义 - Subject:被作用者,既可以是“人”,也可以是“机器”,也可以使你在 Kubernetes里定义的“用户”。
- RoleBinding:定义了“被作用者”和“角色”的绑定关系。
○ 本身也是一个 Kubernetes 的 API 对象
○ 通过 roleRef 字段,RoleBinding 对象就可以直接通过名字,来引用 Role 对象(example-role),从而定义了“被作用 者(Subject)”和“角色(Role)”之间的绑定关系。
Role和RoleBinding对象都是 Namespaced 对象(Namespaced Object),它们对权限的限制规则仅在它们自己的 Namespace 内有效。
ClusterRole 和 ClusterRoleBinding
ClusterRole 和 ClusterRoleBinding 这两个组合,可以作用于所有的 Namespace 的时候,不受Namespace限制。
ServiceAccount
在大多数时候,我们其实都不太使用“用户”这个功能,而是直接 使用 Kubernetes 里的“内置用户”,ServiceAccount。
在创建ServiceAccount后,Kubernetes 会为一个 ServiceAccount 自动创建并分配一个 Secret 对象。这个 Secret,就是这个 ServiceAccount 对应的、用来跟 APIServer 进行交互的授权文 件,我们一般称它为:Token。它以一个 Secret 对象的方式保存在 Etcd 当中。
在Pod中,声明了serviceAccountName后,Kubernetes 会把这个Token的Serect自动挂载到容器的 /var/run/secrets/kubernetes.io/serviceaccount 目录中,可以exec后查看这个目录的内容。容器内的应用,就可以使用这个 ca.crt文件访问APIServer。但权限仅限于role中设置的权限。
默认情况下,如果一个 Pod 没有声明 serviceAccountName,Kubernetes 会自动在它的 Namespace 下创建一个名叫 default 的默认 ServiceAccount,然后分配给这个 Pod。这种情况下,这个默认 ServiceAccount 并没有关联任何 Role,此时它有 访问 APIServer 的绝大多数权限。
用户组(Group)
Kubernetes 里对应的“用户”的名字是,system:serviceaccount:<ServiceAccount 名字 >
它对应的内置“用户组”的名字是,system:serviceaccounts:<Namespace 名字 >
在 Kubernetes 中已经内置了很多个为系统保留的 ClusterRole,它们的名字都以 system: 开头。你可以通过 kubectl get clusterroles 查看到它们
除此之外,Kubernetes 还提供了四个预先定义好的 ClusterRole 来供用户直接使用
- cluster-admin; cluster-admin 角色,对应的是整个 Kubernetes 项目中 的最高权限(verbs=*)
- admin;
- edit;
- view。
Operator
一个相对更加灵活和编程友好的管理“有状态应用”的解决方案
以Etcd Operator为例子,其实就是一个 Deployment
伴随Etcd Operator 的 Pod 进入了 Running 状态,会有一个CRD自动被创建出来,名为etcdclusters.etcd.database.coreos.com。
这个 CRD 相当于告诉了 Kubernetes,请识别 API 组(Group)是 etcd.database.coreos.com、API 资源类型(Kind)是“EtcdCluster”的对象。实际上是在 Kubernetes 里添加了一个名叫EtcdCluster 的自定义资源类型。
Operator 的工作原理,实际上是利用了 Kubernetes 的自定义 API 资源(CRD),来描述我们想要部署的“有状态应用”;然后在自定义控制器里,根据自定义 API 对象的变化,来完成具体的部署和运维工作。
Etcd Operator 的实现,虽然选择的也是静态集群,但这个集群具体的组建过程,是逐个节点动态添加的方式,即:
- 首先,Etcd Operator 会创建一个“种子节点”;
- 然后,Etcd Operator 会不断创建新的 Etcd 节点,然后将它们逐一加入到这个集群当中,直到集群的节点数等于 size。
区分种子节点和普通节点,在于–initial-cluster-state的value是new还是existing,如果是new,就是一个种子节点。
Etcd Operator 在业务逻辑的实现方式上的流程图。Etcd Operator 的特殊之处在于,它为每一个 EtcdCluster 对象,都启动了一个 控制循环,“并发”地响应这些对象的变化。
其中,第一个工作只在该 Cluster 对象第一次被创建的时候才会执行。这个工作,就是我们前面提到过的 Bootstrap,即:创建一个单节点的种子集群。
Cluster 对象的第二个工作,则是启动该集群所对应的控制循环。循环每隔一段时间,就是进行diff,根据实际情况和期望情况,进行add或者remove操作。
在 Etcd Operator 里,为什么我们使用随机名字就可以了呢?
这是由于add和remove操作,都是在集群内部进行,无需在集 群外部通过编号来固定这个拓扑关系。
为什么我没有在 EtcdCluster 对象里声明 Persistent Volume?
Etcd 是一个基于 Raft 协议实现的高可用 Key-Value 存储。根据 Raft 协议的设 计原则,当 Etcd 集群里只有半数以下(在我们的例子里,小于等于一个)的节点失效时, 当前集群依然可以正常工作。
当这个 Etcd 集群里有半数以上的节点失效的时候,这个集群就会丧失数据写入的能力,从而进入“不可用”状态。在这种状态下,就可以使用 Etcd 本身的备份数据来对集群进行恢复操作。
Etcd Backup Operator负责备份。
Etcd Restore Operator负责恢复。
容器持久化存储
PV、PVC、StorageClass
PV 描述的,是持久化存储数据卷。这个 API 对象主要定义的是一个持久化存储在宿主机上的目录,比如一个 NFS 的挂载目录。通常情况下,PV 对象是由运维人员事先创建在Kubernetes 集群里待用的。
PVC 描述的,则是 Pod 所希望使用的持久化存储的属性。比如,Volume 存储的大小、 可读写权限等等。PVC 对象通常由开发人员创建;或者以 PVC 模板的方式成为 StatefulSet 的一部分,然后 由 StatefulSet 控制器负责创建带编号的 PVC。
用户创建的 PVC 要真正被容器使用起来,就必须先和某个符合条件的 PV 进行绑定。
第一个条件,当然是 PV 和 PVC 的 spec 字段。比如,PV 的存储(storage)大小,就 必须满足PVC 的要求。
而第二个条件,则是 PV 和 PVC 的 storageClassName 字段必须一样。
在 Kubernetes 中,实际上存在着一个专门处理持久化存储的控制器,叫作 Volume Controller。这个 Volume Controller 维护着多个控制循环,其中有一个循环,扮演的就 是撮合 PV 和 PVC 的“红娘”的角色。它的名字叫作 PersistentVolumeController。
将一个 PV 与 PVC 进行“绑定”,其实就是将这个 PV 对象的名字,填在了 PVC 对象的spec.volumeName 字段上。
所谓容器的 Volume,其实就是将一个宿主机上的目录,跟一个容器里的目录绑定挂载在了一起。而所谓的“持久化 Volume”,指的就是这个宿主机上的目录,具备“持久性”。
这个准备“持久化”宿主机目录的过程,我们可以形象地称为“两阶段处理”。
- 第一阶段,为虚拟机挂载远程磁盘的操作,在 Kubernetes 中,我们把这个阶段称为 Attach。
- 第二个阶段,将磁盘设备格式化并挂载到 Volume 宿主机目录的操作,我们一般称为:Mount。
a. 如果Volume 类型是远程文件存储(比如 NFS)的话,kubelet 可以跳过“第一阶段”(Attach)的操作。
在删除一个 PV 的时候,Kubernetes 也需要 Unmount 和 Dettach 两个阶段来处理。
PV二阶段循环独立于 kubelet 主控制循环
关于 PV 的“两阶段处理”流程,是靠独立于 kubelet 主 控制循环(Kubelet Sync Loop)之外的两个控制循环来实现的。
“第一阶段”的 Attach(以及 Dettach)操作,是由 Volume Controller 负责维护的,这个控制循环的名字叫作:AttachDetachController。
“第二阶段”的 Mount(以及 Unmount)操作,必须发生在 Pod 对应的宿主机上,所 以它必须是 kubelet 组件的一部分。这个控制循环的名字,叫作: VolumeManagerReconciler,
将 Volume 的处理同 kubelet 的主循环解耦,Kubernetes 就避免了这些耗时的远程挂载操作拖慢 kubelet 的主控制循环,进而导致 Pod 的创建效率大幅下降的问题。
实际上,kubelet 的一个主要设计原则,就是它的主控制循环绝对不可以被 block。
StorageClass
人工管理 PV 的方式就叫作 Static Provisioning
但是当集群规模扩大,热工管理PV的方式就不可行了。Kubernetes 为我们提供了一套可以自动创建 PV 的机制,即:Dynamic Provisioning。
Dynamic Provisioning 机制工作的核心,在于一个名叫 StorageClass 的 API 对象。 该对象的作用,其实就是创建 PV 的模板。
StorageClass 对象会定义如下两个部分内容:
第一,PV 的属性。比如,存储类型、Volume 的大小等等。
第二,创建这种 PV 需要用到的存储插件。比如,Ceph 等等。
有了这两个信息后,Kubernetes 就能够根据用户提交的 PVC,找到一个对应的 StorageClass了。然后,Kubernetes 就会调用该 StorageClass 声明的存储插件,创建出 需要的 PV。
作为应用开发者,我们只需要在 PVC 里指定要使用的 StorageClass 名字即可
Kubernetes 只会将 StorageClass 相同的 PVC 和 PV 绑定起来。
有了 Dynamic Provisioning 机制,运维人员只需要在 Kubernetes 集群里创建出数量有限的 StorageClass 对象就可以了。这就好比,运维人员在 Kubernetes 集群里创建出了各种各样的 PV 模板。
PVC、PV 和 StorageClass 的关系
PVC 描述的,是 Pod 想要使用的持久化存储的属性,比如存储的大小、读写权限等。
PV 描述的,则是一个具体的 Volume 的属性,比如 Volume 的类型、挂载目录、远程存储服务器地址等。
而 StorageClass 的作用,则是充当 PV 的模板。并且,只有同属于一个 StorageClass 的 PV 和 PVC,才可以绑定在一起。
为什么要PV、PVC体系
Kubernetes 很多看起来比较“繁琐”的设计(比如“声明式 API”,以及我今天讲 解的“PV、PVC 体系”)的主要目的,都是希望为开发者提供更多的“可扩展性”,给使 用者带来更多的“稳定性”和“安全感”。
可以看到,正是通过 PV 和 PVC,以及 StorageClass 这套存储体系,这个后来新添加的持 久化存储方案,对 Kubernetes 已有用户的影响,几乎可以忽略不计。作为用户,你的 Pod 的 YAML 和 PVC 的 YAML,并没有任何特殊的改变,这个特性所有的实现只会影响 到 PV 的处理,也就是由运维人员负责的那部分工作。
而这,正是这套存储体系带来的“解耦”的好处。
本地持久化卷
本地持久化卷,直接使用的是本地磁盘,尤其是 SSD 盘,它的读写性能相比于大多数远程存储要好得多。
Kubernetes 在 v1.10 之后,依靠 PV、PVC 体系实现本地持久化卷,叫作:Local Persistent Volume。且要求Local Persistent Volume 的应用必须具备数据备份和恢复的能力。
Local Persistent Volume 的设计,主要面临两个难点。
● 如何把本地磁盘抽象成 PV。
○ 不应该把一个宿主机上的目录当作 PV 使用,这是因为目录存储的不可控性,磁盘随时可能写满,宿主机可能会宕机,且I/O缺乏隔离。
○ 所以一个 Local Persistent Volume 对应的存储介质,一定是一块额外挂载在宿主机的 磁盘或者块设备,即“一个 PV一块盘”
● 调度器如何保证 Pod 始终能被正确地调度到它所请求的 Local Persistent Volume 所在的节点上呢?
○ 对于常规的 PV 来说,Kubernetes 都是先调度 Pod 到某个节点上,然后再通过“两阶段处理”来“持久化”这台机器上的 Volume 目录,进而完成 Volume 目录与容器的绑定挂载。
○ 对于 Local PV 来说,节点上可供使用的磁盘必须是运维人员提前准备好的。所以,这时候,调度器就必须能够知道所有节点与 Local Persistent Volume 对应的磁盘的关联关系,然后根据这个信息来调度 Pod。
○ 也就是“在调度的时候考虑 Volume 分布”,在Kubernetes的调度器中,负责过滤就是,VolumeBindingChecker。
在实践整个Local Persistent Volume的过程中,
首先需要挂载磁盘
-
为本地磁盘定义对应的 PV
a.
b. 其中local 字段,指定了它是一个 Local Persistent Volume; 而 path 字段,指定的是 PV 对应的本地磁盘的路径,即:/mnt/disks/vol1
c. 想要使用这个PV的Pod必须和该磁盘在同一个node,需要限制nodeAffinity,为node-1。这正是 Kubernetes 实现“在调度的时候就考虑 Volume分布”的主要方法。
d. 创建PV,kubectl create -f local-pv.yaml -
创建一个 StorageClass 来描述这个 PV
a.
b. name为 local-storage。 provisioner 字段为no-provisioner。这是因为 Local Persistent Volume 目前尚不支持 Dynamic Provisioning,所以它没办法在用户创建 PVC 的时候,就自动创建出对应的 PV。也就是说,必须手动创建PV。
c. volumeBindingMode=WaitForFirstConsumer 属性用于延迟绑定,虽然知道PV和PVC可以绑定,要等到第一个声明使用该 PVC 的 Pod 出现在调度器之后,调度器再综合考虑所有的调度规则,当然也包括每个 PV 所在的节点位置。从而保证了这个绑定结果不会影响 Pod 的正常调 度。
d. 创建StorageClass,kubectl create -f local-sc.yaml -
定义PVC
a.
b. storageClassName 字段是 local-storage。所以,将来 Kubernetes 的 Volume Controller 看到这个 PVC 的时候,不会为它进行绑定操作
c. 创建PVC,kubectl create -f local-pvc.yaml,但此时的PVC状态为Pendding -
一个 Pod 来声明使用这个 PVC
a.
b. volumns使用example-local-claim
c. 创建Pod后,PVC的状态转换为Bound
Static Provisioner
Kubernetes 其实提供了一个 Static Provisioner 来帮助你管理这些 PV。
StorageClass 的名字、本地磁盘挂载点的位置,都可以通过 provisioner 的配置文件指定。
当 Static Provisioner 启动后,它就会通过 DaemonSet,自动检查每个宿主机的 /mnt/disks 目录。然后,调用 Kubernetes API,为这些目录下面的每一个挂载,创建一 个对应的 PV 对象出来。
存储插件FlexVolume与CSI
存储插件的开发有两种方式:FlexVolume 和 CSI。
Flexvolume插件
编写完成 FlexVolume 的实现之后,一定要把它的可执行文件放在每个节点的插件目录下供Kubernetes使用。
在 FlexVolume 插件中,操作参数的名字是固定的,比如 init、mount、unmount、attach,以及 dettach 等等,分别对应不同的 Volume 处理操作。
lFexVolume 实现方式,虽然简单,但局限性却很大。
不能支持 Dynamic Provisioning(即:为每个 PVC 自动创建 PV 和对应的 Volume)。除非你再为 它编写一个专门的 External Provisioner。
这是因为FlexVolume 每一次对插件可执行文件的调用,都是一次完全独 立的操作。
FlexVolume原理示意图如下所示,FlexVolume仅仅是 Volume 管理中的“Attach阶段”和“Mount 阶段”的具体执行者。
Container Storage Interface(CSI)
CSI 的设计思想,把插件的职责从“两阶段处理”,扩展成了 Provision、Attach 和 Mount 三个阶段。其中,Provision 等价于“创建磁盘”, Attach 等价于“挂载磁盘到虚拟机”,Mount 等价于“将该磁盘格式化后,挂载在 Volume 的宿主机目录上”。
CSI原理示意图
- External Components
○ Driver Registrar 组件,负责将插件注册到 kubelet 里面
○ External Provisioner 组件,负责的正是 Provision 阶段
○ External Attacher 组件,负责的正是“Attach 阶段” - CSI 插件的里三个服务
○ CSI 插件的 CSI Identity 服务,负责对外暴露这个插件本身的信息
○ CSI Controller 服务,定义的则是对 CSI Volume(对应 Kubernetes 里的 PV)的管 理接口
○ CSI Node 服务里定义了CSI Volume 需要在宿主机上执行的操作
容器网络
容器网络基础
Linux 容器能看见的“网络栈”,实际上是被隔离在它自己的 Network Namespace 当中的。
“网络栈”,就包括了:网卡(Network Interface)、回环设备(Loopback Device)、路由表(Routing Table)和 iptables 规则。
作为一个容器,也可以不启用Network Namespace,而直接使用宿主机的网络栈(–net=host)
docker run –d –net=host --name nginx-host nginx
如此使用比较方便,但是会引入共享网络资源的问题,例如端口冲突
所以在大多数情况下,我们都希望容器进程能使用自己 Network Namespace 里的网络栈,即:拥有属于自己的 IP 地址和端口。
容器进程如何网络交互?
被限制在 Network Namespace 里的容器进程,实际上是通过 Veth Pair 设备 + 宿主机网桥的方式,实现了 跟同其他容器的数据交换。
同一个宿主机下,不同容器间通信的示意图。
- Docker 项目会默认在宿主机上创建一个名叫 docker0 的网桥,凡 是连接在 docker0 网桥上的容器,就可以通过它来进行通信。
- 创建Veth Pair的虚拟设备,Veth Pair一端在容器里充当默认网卡、另一端在宿主机上。
a. Veth Pair 设备的特点是:它被创建出来后,总是以两张虚拟网卡(Veth Peer)的形式成 对出现的。并且,从其中一个“网卡”发出的数据包,可以直接出现在与它对应的另一张“网卡”上,哪怕这两个“网卡”在不同的 Network Namespace 里。
b. Veth Pair 就充当了Network Namespace 的“网线”
跨主通信
在 Docker 的默认配置下,一台宿主机上的 docker0 网桥,和其他宿主机上的 docker0 网 桥,没有任何关联,它们互相之间也没办法连通。
可以通过软件的方式,创建一个整个集群“公用”的网桥,然后把集群里的所有容器都连接到这个网桥上。这种网络被称为Overlay Network(覆盖网络)。
除了通过软件配置“公用”的网桥,也可以通过某种方式配置宿主机的路由表,把数据包转发到正确的宿主机上。
Docker容器跨主机网络
Docker容器跨主机通信利用Flannel 项框架,对外提供容器网络功能。Flannel框架有三种后端实现分别是:
● VXLAN
● host-gw
● UDP:最早支持,性能最差,已被废弃
UDP(已被废弃)
Flannel UDP 模式提供的其实是一个三层的 Overlay 网络,即:它首先对发出端 的 IP 包进行 UDP 封装,然后在接收端进行解封装拿到原始的 IP 包,进而把这个 IP 包转发给 目标容器。这就好比,Flannel 在不同宿主机上的两个容器之间打通了一条“隧道”,使得这两个容器可以直接使用 IP 地址进行通信,而无需关心容器和宿主机的分布情况。
示意图中 flannel0 设备,一个 TUN 设备(Tunnel 设备)。
整个过程如下:
- 当 IP 包从容器经过 docker0 出现在宿主机
- 会根据路由表进入 flannel0 设备
- 宿主机上的 flanneld 进程,就会收到这个 IP 包。(当操作系统将一个 IP 包发送给 flannel0 设备之后,flannel0 就会把这个 IP 包,交给创建这个设备的应用程序,也就是 Flannel 进程。这是一个从内核态(Linux 操作 系统)向用户态(Flannel 进程)的流动方向)
- Flannel 进程根据子网与宿主机的对应关系(保存在 Etcd),把这个 IP 包直接封装在一个 UDP 包里,然后发送给 Node 2。
- Node 2接受到UDP包后,进行解封和反向操作,最终发送给Node2上的Container-2
但是 UDP 模式有严重的性能问题
相比于两台宿主机之间的直接通信,基于 Flannel UDP 模式的容器通信多了一个额 外的步骤,即 flanneld 的处理过程。
仅在发出 IP 包的过程中,就需要经过三次用户态与内核态之间的数据拷贝
● 第一次:用户态的容器进程发出的 IP 包经过 docker0 网桥进入内核态;
● 第二次:IP 包根据路由表进入 TUN(flannel0)设备,从而回到用户态的 flanneld 进程;
● 第三次:flanneld 进行 UDP 封包之后重新进入内核态,将 UDP 包通过宿主机的 eth0 发出 去。
Flannel 进行 UDP 封装(Encapsulation)和解封装 (Decapsulation)的过程,也都是在用户态完成的。
在进行系统级编程的时候,有一个非常重要的优化原则,就是要减少用户态到内核态的切换次数,并且把核心的处理逻辑都放在内核态进行。
VXLAN
VXLAN,即 Virtual Extensible LAN(虚拟可扩展局域网,是 Linux 内核本身就支持的一种网络虚似化技术。
VXLAN设计思想是:在现有的三层网络之上,“覆盖”一层虚拟的、由内核 VXLAN 模块负责维护的二层网络,使得连接在这个 VXLAN 二层网络上的“主机”(虚拟机 或者容器都可以)之间,可以像在同一个局域网(LAN)里那样自由通信。
为了能够打通隧道的两端,就需要一个VTEP,即:VXLAN Tunnel End Point,与flanneld 进程非常相似。不同点在于它进行封装和解封装 的对象,是二层数据帧(Ethernet frame)。而且而且整个流程,全部是在内核里完成的(因为 VXLAN 本身就是 Linux 内核中的一个模块)。
VTEP得到的二层数据帧,最终还是会被Linux 内核进一步封装成 UDP 包里发给Node2.
Kubernetes网络模型与CNI网络插件
Kubernetes 对容器网络的主要处理方法与Docker类似。只不过, Kubernetes 是通过一个叫作 CNI 的接口,维护了一个单独的CNI 网桥(宿主机上的设备名称默认是:cni0)来代替 docker0。
NetworkPolicy - 弱多租户soft multi-tenancy
在 Kubernetes 里,NetworkPolicy对象用来提供网络隔离能力。
Kubernetes 里的 Pod 默认都是“允许所 有”(Accept All)的,即:Pod可以接收来自任何发送方的请求;或者,向任何接收方发送请求。
如果需要作出限制,就必须通过 NetworkPolicy 对象来指定。一旦 Pod 被 NetworkPolicy 选中,那么这个 Pod 就会进入“拒绝所有”(Deny All)的状态,即:这个 Pod 既不允许被外界访问,也不允许对外界发起访问。
policyTypes 字段,定义了这个 NetworkPolicy 的类型是 ingress 和 egress,即:它既会影响流入(ingress)请求,也会影响流出(egress) 请求。可以定义三种类型三种类型分别是:ipBlock、 namespaceSelector 和 podSelector。
需要注意的是“或”(OR)的关系、“与”(AND)的关系。差别仅在于多了一个“-”
NetworkPolicy 在 Kubernetes 集群真正起产生作用的前提,你的 CNI 网络插件就必须是支持 Kubernetes 的 NetworkPolicy 的。
在具体实现上,凡是支持 NetworkPolicy 的 CNI 网络插件,都维护着一个 NetworkPolicy Controller,通过控制循环的方式对 NetworkPolicy 对象的增删改查做出响应,然后在宿主机上完成 iptables 规则的配置工作。
Service、DNS与服务发现
Service 提供的是 Round Robin 方式的负载均衡。对于这种方式,我们称为:ClusterIP 模式的 Service
Service 如何实现负载均衡
最基本的工作原理:Service 是由 kube-proxy 组件,加上 iptables 来共同实现的。
- Service创建
- kube-proxy 就可以通过 Service 的 Informer 感知到这样一个 Service 对象的添加
- kube-proxy会在宿主机上创建一条 iptables 规则:凡是发送到Service对应IP和Port的IP包,都会转发到另外一条iptables规则:KUBE-SVC-NWV5X2332I4OT4T3
- KUBE-SVC-NWV5X2332I4OT4T3是一个规则集合,目的是转发到另外三条iptables规则KUBE-SEP-WNBA2IHDGP2BOBGZ、KUBE-SEP- X3P2623AGDH6CDF3 和 KUBE-SEP-57KPRZ3JQVENLNBR,这三条分别指向对应的Pod。
a. 这次转发的被接受的概率分别为1/3,1/2和1,从而达到三条规则触发的概率都是1/3
-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -m statistic --mode random --probability 0.33332999982 -j KUBE-SEP-WNBA2IHDGP2BOBGZ
-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-X3P2623AGDH6CDF3
-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -j KUBE-SEP-57KPRZ3JQVENLNBR
从外界连通Service与Service调试“三板斧
所谓 Service,其实就是 Kubernetes 为 Pod 分配的、固定的、 基于 iptables(或者 IPVS)的访问入口。而这些访问入口代理的 Pod 信息,则来自于 Etcd,由 kube-proxy 通过控制循环来维护。
NodePort
在 NodePort 方式下,Kubernetes 会在 IP 包离开宿主机发往目的 Pod 时,对这个 IP 包做一次 SNAT 操作
LoadBalancer
在上述 LoadBalancer 类型的 Service 被提交后,Kubernetes 就会调用 CloudProvider 在公有云上为你创建一个负载均衡服务,并且把被代理的 Pod 的 IP 地址配置给负载均衡服务做后端。
ExternalName
ExternalName 类型的 Service,其实是在 kube-dns 里为你添加了一条 CNAME 记录。这时,访问 my- service.default.svc.cluster.local 就和访问 my.database.example.com 这个域名是一个 效果了。
也可以指定externalIPs,为Service分配共有IP
K8S网络调试
Kubernetes Service 机制的工作原理之后,很多与 Service 相关的问 题,其实都可以通过分析 Service 在宿主机上对应的 iptables 规则(或者 IPVS 配置)得 到解决。
- Kubernetes 自 己的 Master 节点的 Service DNS 是否正常
a. 如果上面访问 kubernetes.default 返回的值都有问题,那你就需要检查 kube-dns 的运行 状态和日志了 - 检查自己的 Service 定义是不是有问题。
a. 检查的是这个 Service 是否有 Endpoints( Pod 的 readniessProbe 没通过,它也不会出现在 Endpoints 列 表里)
b. Endpoints 正常,那么你就需要确认 kube-proxy 是否在正确运行
i. kube-proxy 一切正常,你就应该仔细查看宿主机上的 iptables
谈谈Service与Ingress
LoadBalancer 类型的 Service,它会在 Cloud Provider里创建一个与该 Service 对应的负载均衡服务。每个 Service 都要有一个负载均衡服务,所以这个做法 实际上既浪费成本又高。
另外一种,全局的、为了代理不同后端 Service 而设置的负载均衡服务,就是 Kubernetes 里的 Ingress 服务。
Ingress
所谓 Ingress,就是 Service 的“Service”
其中rules为IngressRule,http中的每个path都对应一个service
所谓 Ingress 对象,其实就是 Kubernetes 项目对“反向代 理”的一种抽象。
Ingress Controller
在实际的使用中,你只需要从社区里选择一个具体的 Ingress Controller,把它部署在 Kubernetes 集群里即可。
目 前,业界常用的各种反向代理项目,比如 Nginx、HAProxy、Envoy、Traefik 等,都已经 为 Kubernetes 专门维护了对应的 Ingress Controller。
以Nginx Ingress Controller为例进行说明
一个 Nginx Ingress Controller 为你提供的服务,其实是一个可以根据 Ingress 对象和被代理后端 Service 的变化,来自动进行更新的 Nginx 负载均衡器。
- 部署 Nginx Ingress Controller $ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/mandatory.yaml
- 在部署type为NodePort的Service
作业调度与资源管理
资源模型与资源管理
在 Kubernetes 里,Pod 是最小的原子调度单位。所有跟调度和资源管理相关的属性都应该是属于 Pod 对象的字段。最为重要的就是, Pod 的 CPU 和内存配置。
资源分类
“可压缩资源”(compressible resources),当可压缩资源不足时,Pod 只会“饥饿”,但不会退出。典型的就是,CPU。
“不可压缩资源(incompressible resources)。当不可 压缩资源不足时,Pod 就会因为 OOM(Out-Of-Memory)被内核杀掉。典型的就是内存
资源设置
CPU 设置的单位是“CPU 的个数”。比如,cpu=1 指的就是, 这个 Pod 的 CPU 限额是 1 个 CPU。当然,具体“1 个 CPU”在宿主机上如何解释,是 1 个 CPU 核心,还是 1 个 vCPU,还是 1 个 CPU 的超线程(Hyperthread),完全取决于 宿主机的 CPU 实现方式。
Kubernetes 只负责保证 Pod 能够使用到“1 个 CPU”的计算能力。所谓 500m,指的就是 500 millicpu,也就是 0.5 个 CPU 的意思。这样, 这个 Pod 就会被分配到 1 个 CPU 一半的计算能力
对于内存资源来说,它的单位自然就是 bytes。Kubernetes 支持你使用 Ei、Pi、Ti、 Gi、Mi、Ki(或者 E、P、T、G、M、K)的方式来作为 bytes 的值。
这里要注意区分 MiB(mebibyte)和 MB(megabyte)的区别。
1Mi=10241024;1M=10001000
limits和requests
在调度的时候,kube-scheduler 只会按照 requests 的值进行计算。而在真正设置 Cgroups 限制的时候,kubelet 则会按照 limits 的值来进行设置
这种调度策略实际上是参考了Borg 论文中对“动态资源边界”的定义。
容器化作业在提交时所设置的资源边界,并不一定是调度系统所必须严格遵守的,这是因为在实际场景中,大多数作业使用到的资源其实远小于它所请求的资源限额。
QoS 模型
● Guaranteed 类别:每一个 Container 都同时设置了 requests 和 limits,并且 requests 和 limits 值相等的时候,这个 Pod 就属于 Guaranteed 类别
○ 当 Pod 仅设置了 limits 没有设置 requests 的时候, Kubernetes 会自动为它设置与 limits 相同的 requests 值,所以,这也属于 Guaranteed 情况。
● Burstable 类别:当 Pod 不满足 Guaranteed 的条件,但至少有一个 Container 设置了 requests。
● BestEffort类别:如果一个 Pod 既没有设置 requests,也没有设置 limits。
是当宿主机资源紧张的时候,kubelet 对 Pod 进行Eviction(即资源回收),此时会根据QoS的类型进行不同的处理。
触发Eviction的资源紧张,具体指的是,宿主机上不可压缩资源短缺,比如,可用内存(memory.available)、可用的宿主机磁盘空间 (nodefs.available),以及容器运行时镜像存储空间(imagefs.available)。
Eviction 的默认阈值
Eviction也可以配置hard和soft模式
Soft Eviction 允许你为 Eviction 过程设置一段“优雅时间”,比如上面例子里的 imagefs.available=2m,就意味着当 imagefs 不足的阈值达到 2 分钟之后,才会开始Eviction。
而Hard Eviction,则是在阈值达到之后立刻开始Eviction。
当宿主机的 Eviction 阈值达到后,就会进入 MemoryPressure 或者 DiskPressure 状态, 从而避免新的 Pod 被调度到这台宿主机上。
当 Eviction 发生时,
● 首当其冲的,自然是 BestEffort 类别的 Pod。
● 其次,是属于 Burstable 类别、并且发生“饥饿”的资源使用量已经超出了 requests 的 Pod。
● 最后,才是 Guaranteed 类别。并且,Kubernetes 会保证只有当 Guaranteed 类别的 Pod 的资源使用量超过了其 limits 的限制,或者宿主机本身正处于 Memory Pressure 状态时,Guaranteed 的 Pod 才可能被选中进行 Eviction 操作。
cpuset 的设置
可以通过设置 cpuset 把容器绑定到某个 CPU 的核上, 而不是像 cpushare 那样共享 CPU 的计算能力。
这种情况下,由于操作系统在 CPU 之间进行上下文切换的次数大大减少,容器里应用的性 能会得到大幅提升。事实上,cpuset 方式,是生产环境里部署在线应用类型的 Pod 时, 非常常用的一种方式。
Kubernetes默认调度器
调度策略
在 Kubernetes 项目中,默认调度器的主要职责,就是为一个新创建出来的 Pod,寻找一个最合适的节点(Node)。
寻找过程包括:
- 从集群所有的节点中,根据调度算法挑选出所有可以运行该 Pod 的节点;
- 从第一步的结果中,再根据调度算法挑选一个最符合条件的节点作为最终结果。
具体在Kubernetes的调度过程如下图所示
Kubernetes 的调度器的核心,实际上就是两个相互独立的控制循环。整个调度的特点在于:“Cache 化”,“乐观绑定”和“无锁化”。
第一个控制循环,是Informer Path。
主要目的,是启动一系列 Informer,用来监听(Watch)Etcd 中 Pod、Node、Service 等与调度相关的 API 对象 的变化。比如,当一个待调度 Pod(即:它的 nodeName 字段是空的)被创建出来之 后,调度器就会通过 Pod Informer 的 Handler,将这个待调度 Pod 添加进调度队列。
默认调度器还要负责对调度器缓存(即:scheduler cache)进行更新。
第二个控制循环,是调度器负责 Pod 调度的主循环,Scheduling Path。
- Scheduling Path 的主要逻辑,就是不断地从调度队列里出队一个 Pod。
- 然后,调用 Predicates 算法进行“过滤”,这一步“过滤”得到的一组 Node,就是所有可以运行这 个 Pod 的宿主机列表。Predicates 算法需要的 Node 信息是从Scheduler Cache获取的。
- 再调用 Priorities 算法为上述列表里的 Node 打分,分数从 0 到 10。 得分最高的 Node,就会作为这次调度的结果。
- 最后进行Bind,也就是将 Pod 对象的 nodeName 字段的值修改为调度结果Node。
- 但是为了性能,Bind阶段只会更新 Scheduler Cache 里的 Pod 和 Node 的信息。这种基于“乐观”假设的 API 对象更新方式,在 Kubernetes 里被称作 Assume。也就是所谓的“乐观绑定”。
- Assume 之后,调度器才会创建一个 Goroutine 来异步地向 APIServer 发起更新 Pod 的 请求,来真正完成 Bind 操作。
上面的过程体现了“Cache 化”和“乐观绑定”。对于“无锁化”则在于,调度器会避免设置任何全局的竞争资源。
● 在 Scheduling Path 上,调度器会启动多个 Goroutine 以节点为粒度并发执行 Predicates 算法,从而提高这一阶段的执行效率。
● 在Priorities 算法上,也会以 MapReduce 的方式并行计算然后再进行汇总。
默认调度器插件化
默认调度器,却成了 Kubernetes 项目里最后一个没有对外暴露出良好定义过的、可扩展接口的组件。
Kubernetes 默认调度器的可扩展性设计如下所示,其中绿色部分为插件化部分,可以使用Go语言插件进行逻辑替换。
默认调度器调度策略解析
调度器里关于集群和 Pod 的信息都已经缓存化,所以这些算法的执 行过程还是比较快的。
Predicates
Predicates 在调度过程中的作用,可以理解为 Filter,即:它按照调度策略,从当前集群的所有节点中,“过滤”出一系列符合条件的节点。也就是待运行Pod的宿主机。
三种调度策略
- GeneralPredicates,一组过滤规则,负责的是最基础的调度策略。
○ PodFitsResources:检查宿主机的 CPU 和内存资源等是否够用
○ PodFitsHost:检查宿主机的名字和Pod 的 spec.nodeName是否一致
○ PodFitsHostPorts:检查宿主机的port和Pod的配置是否冲突
○ PodMatchNodeSelector:检查nodeSelector 或者 nodeAffinity
○ Admit后会再次执行检查,执行的就是GeneralPredicates这组规则 - 与 Volume 相关的过滤规则
○ NoDiskConflict:多个 Pod 声明挂载的持久化 Volume 是否有冲 突。比如有些Volume不能挂载多个Pod
○ MaxPDVolumeCountPredicate:一个节点上某种类型的持久化 Volume 是不是已经超过了一定数目
○ VolumeZonePredicate:检查持久化 Volume 的 Zone(高可用域)标签,是否与 待考察节点的 Zone 标签相匹配。
○ VolumeBindingPredicate:是该 Pod 对应的 PV 的 nodeAffinity 字段,是否跟某个节点的标签相匹配。 - 宿主机相关的过滤规则,主要考察待调度 Pod 是否满足 Node 本身的某些条件
○ PodToleratesNodeTaints:检查Node的污点
○ NodeMemoryPressurePredicat:当前节点的内存是不是已经不够充足 - Pod 相关的过滤规则
○ 大多数的规则,跟 GeneralPredicates重合
○ PodAffinityPredicate,检查待调度 Pod 与 Node 上的已有 Pod 之间 的亲密(affinity)和反亲密(anti-affinity)关系
Priorities
Priorities 阶段是为Predicates拿到的节点进行打分。这里打分的范围是 0-10 分,得分最高的节点就是最后被 Pod 绑定的最佳节点。
可选的打分规则:
● LeastRequestedPriority:选择空闲资源(CPU 和 Memory)最多的宿主机。
● BalancedResourceAllocation:调度完成后,所有节点里各种资源分配最均衡的那个节点,从而避免一个节点上 CPU 被大量分配、而 Memory 大量剩余的情况。。
● 此外,还有NodeAffinityPriority、TaintTolerationPriority 和 InterPodAffinityPriority。顾名思义,它们与前面的 PodMatchNodeSelector、 PodToleratesNodeTaints 和 PodAffinityPredicate计算方法类似。
● ImageLocalityPriority:如果待调度 Pod 需要使用的镜像很大,并且已经存在于 某些 Node 上,那么这些 Node 的得分就会比较高。
默认调度器的优先级与抢占机制
优先级(Priority )和抢占(Preemption)机制,解决的是 Pod 调度失败时该怎么办的问题。
优先级
正常情况下,当一个Pod调度失败,这个Pod会被暂时搁置起来。但有些时候,高优先级的Pod调度失败后,会抢占某个低优先级的Pod,以此来保证高优先级Pod的调度成功。
使用这个机制,需要首先配置PriorityClass
Kubernetes 规定,优先级是一个 32 bit 的整数,最大值不超过 1000000000(10 亿, 1 billion),并且值越大代表优先级越高。而超出 10 亿的值,其实是被 Kubernetes 保留下来分配给系统 Pod 使用的,以此保证系统Pod不会被用户Pod抢占。
PriorityClass在Pod中可以像下图方式使用。
当这个Pod被提交后,PriorityAdmissionController 就会自动将这个 Pod 的 spec.priority 字段设置为 1000000。
之前提到,调度器里维护着一个调度队列。当 Pod 拥有了 优先级之后,高优先级的 Pod 就可能会比低优先级的 Pod 提前出队,从而尽早完成调度过程。这就是所谓的“优先级”。
抢占机制
而当一个高优先级的 Pod 调度失败的时候,调度器的抢占能力就会被触发。这时,调度器就会试图从当前集群里寻找一个节点,使得当这个节点上的一个或者多个低优先级 Pod 被 删除后,待调度的高优先级 Pod 就可以被调度到这个节点上。者就是“抢占”机制。
Pod 为“抢占者”,称被抢占的 Pod 为“牺牲者”
在调度队列的实现里,其实使用了两个不同的队列。
● activeQ:activeQ 里的 Pod,都是下一个调度周期需要调度的对象。之前提到的调度队列就是activeQ
● unschedulableQ:专门用来存放调度失败的 Pod,其中的Pod被跟新后,会被调度到activeQ
当一个高优先级Pod调度失败后,就会被放入unschedulableQ中,这个失败时间就会触发调度器为抢占者寻找牺牲者的流程。
- 第一步,检查失败原因,因为有很多 Predicates 的失败是不能通过抢占来解决的。
- 第二步,如果确定抢占可以发生,那么调度器就会把自己缓存的所有节点信息复制一份,然后使用这个副本来模拟抢占过程。在这个模拟过程中,调度器会遍历每一个节点,在每个节点中都依据Pod的优先级从低到高,逐一删除一些Pod。每次删除Pod后,都会检查“抢占者”能否在该节点运行,如果不行则继续删除;如果可以则停止,记录Node的名字和被删除Pod的列表。在遍历完成之后,根据尽量减少抢占对整个系统影响的原则,选择一个节点。最后得到"牺牲者"列表和节点名字。
- 第三步,真正开始抢占的操作。
a. 调度器会检查牺牲者列表,清理这些 Pod 所携带的 nominatedNodeName 字 段。
b. 调度器会把抢占者的 nominatedNodeName,设置为被抢占的 Node 的名字。从而把抢占者放入activeQ,重新进入调度流程。
c. 调度器会开启一个 Goroutine,同步地删除牺牲者。通过标准的 DELETE API 来删除被抢占的 Pod,但是存在“优雅退出”时间(默认是 30s)。在这个时间内,可能会发生很多种可能性,例如新的抢占者出现。所以也是提到的,调度器并不保证抢占的结果。
Kubernetes GPU管理与Device Plugin机制
为了能够在 Kubernetes 集群上运行 TensorFlow 等机器学习框架所创建的训练
(Training)和服务(Serving)任务,Kubernetes需要支持GPU等硬件资源的管理。
以 NVIDIA 的 GPU 设备为例,当用户的容器配置了GPU的设备,在创建的时候,需要在容器中出现两个目录:
- GPU 设备,比如 /dev/nvidia0;
- GPU 驱动目录,比如 /usr/local/nvidia/*。
不过,Kubernetes 在 Pod 的 API 对象里,并没有为 GPU 专门设置一个资源类型字段, 而是使用了一种叫作 Extended Resource(ER)的特殊字段来负责传递 GPU 的信息。比如下面这个例子:
这个Pod申请一个GPU资源。
Extended Resource,其实是 Kubernetes 为用户设置的一种对自定义资源的支持,是一种通用性的抽象。在 kube-scheduler 里面不关心key是什么,例如nvidia.com/gpu,只会把调度器里保存的可用量,减去Pod申请对应资源的数量。
Device Plugin
在 Kubernetes 中,对所有硬件加速设备进行管理的功能,都是由一种叫作 Device Plugin 的插件来负责的。
- 对于每一种硬件设备,都需要有它所对应的 Device Plugin 进行管理,NVIDIA GPU对应的插件叫作NVIDIA GPU device plugin。Device Plugin通过 gRPC 的方式同 kubelet通信。
- Device Plugin 会通过一个叫作 ListAndWatch 的 API,定期向 kubelet 汇报该 Node 上 GPU 的列表。kubelet 在拿到这个列表之后,就可以直接在它向 APIServer 发送的心跳 里,以 Extended Resource 的方式,加上这些 GPU 的数量。
- 当一个 Pod 想要使用一个 GPU 的时候,在yml的limits 字段声明nvidia.com/gpu: 1。那么接下来,Kubernetes 的调度器就会从它的缓存里,寻找 GPU 数量满足条件的 Node,然后将缓存里的 GPU 数量减 1,完成 Pod 与 Node 的绑定。
- 调度成功后的 Pod 信息,自然就会被对应的 kubelet 拿来进行容器操作。而当 kubelet 发现这个 Pod 的容器请求一个 GPU 的时候,kubelet 就会从自己持有的 GPU 列表里分配一个 GPU。此时,kubelet 就会向Device Plugin发起一个 Allocate() 请求。这个请求携带的参数,正是即将分配给该容器的设备 ID 列表。
- Device Plugin 收到 Allocate 请求之后,它就会根据 kubelet 传递过来的设备 ID,从 Device Plugin 里找到这些设备对应的设备路径和驱动目录。
- 被分配 GPU 对应的设备路径和驱动目录信息被返回给 kubelet 之后,kubelet 就完成了为一个容器分配 GPU 的操作。接下来,kubelet 会把这些信息追加在创建该容器所对应的 CRI 请求当中。
容器运行时
在调度结束后,Kubernetes就需要负责将Pod在对应Container在宿主机上启动起来。这些就是kubelet这个核心组件的主要功能。
SIG-Node与CRI
与 kubelet 以及容器运行时管理相关的内容,都属于 SIG-Node 的范畴
kubelet的工作原理可以如下面的示意图描述
kubelet 的工作核心,就是一个控制循环,即:SyncLoop(图中的大圆圈)。 而驱动这个控制循环运行的事件,包括四种:
- Pod 更新事件;
- Pod 生命周期变化;
- kubelet 本身设置的执行周期;
- 定时的清理事件。
kubelet 还负责维护着很多很多其他的子控制循环(也就是图中的小圆圈)。这些控制循环的名字,一般被称作某某 Manager,比如 Volume Manager、Image Manager、 Node Status Manager 等等。
这些控制循环的责任,就是通过控制器模式,完成 kubelet 的某项具体职责。 比如 Node Status Manager,就负责响应 Node 的状态变化,然后将 Node 的状态收集 起来,并通过 Heartbeat 的方式上报给 APIServer。
SyncLoop如何监听 Pod 对象的变化
kubelet 也是通过 Watch 机制,监听了与自己相关的 Pod 对象的变化。当然需要过滤Pod的nodeName字段。
- 当一个Pod完成调度,bind了对应的Node后。Pod的变化就会触发kubelet在循环中注册的Handler,也就是示意图中的HandlePods。
- 之后检查Pod在kubelet内存中的状态,判断是Add还是Update等具体操作。
- 然后,kubelet会启动一个名叫 Pod Update Worker 的、单独的 Goroutine 来完成对 Pod 的处理工作。
a. 为这个新的 Pod 生成对应的 Pod Status
b. 检查Volume 是不是已经准备好
c. 调用下层的容器运行时,开始创建这个 Pod 所定义的容器
CRI接口间接执行容器操作
对于最后一步,调用下层容器运行时,kubelet并不会直接调用 Docker 的 API,而是通过一组叫作 CRI(Container Runtime Interface,容器运行时接口)的 gRPC 接口来间接执行的。
之所以如此,是为了屏蔽不同底层容器运行时的差异,当前具体的底层容器,可能包括Docker、 rkt、runV。
只是需要在每台宿主机上单独安装一个负责响应 CRI 的组件,这个组件,一般被称作 CRI shim。顾名思义,CRI shim 的工作,就是扮演 kubelet 与容器项目 之间的“垫片”(shim)。
所以它的作用非常单一,那就是实现 CRI 规定的每个接口,然后把具体的 CRI 请求“翻译”成对后端容器项目的请求或者操作。
CRI与容器运行时
CRI 机制能够发挥作用的核心,就在于每一种容器项目现在都 可以自己实现一个 CRI shim,自行对 CRI 请求进行处理。
CRI的统一容器抽象层,使得K8S可以对接不同的下层容器运行时。
CRI具体上可以分为两组:
- RuntimeService。它提供的接口,主要是跟容器相关的操作。比如,创建和启动容器、删除容器、执行 exec 命令等等。
- ImageService。它提供的接口,主要是容器镜像相关的操作,比如拉取 镜像、删除镜像等等。
而除此之外,CRI shim 还有一个重要的工作,就是如何实现 exec、logs 等接口。
这类接口,kubelet 需要跟容器项目维护一个长连接来传输数据,称之为 Streaming API。CRI shim 里对 Streaming API 的实现,依赖于一套独立的 Streaming Server 机制,如下图所示
- 对一个容器执行 kubectl exec 命令的时候,这个请求首先交给 APIServer,然后 API Server 就会调用 kubelet 的 Exec API。
- kubelet 就会调用 CRI 的 Exec 接口,而负责响应这个接口的,自然就是具体的 CRI shim。
- CRI shim 并不会直接去调用后端的容器项目(比如 Docker )来进行处理, 而只会返回一个 URL 给 kubelet。这个 URL,就是该 CRI shim 对应的 Streaming Server 的地址和端口。
- kubelet 在拿到 URL 之后,返回给 API Server。API Server 就会通过重定向来向 Streaming Server 发起真正的 /exec 请求,与 它建立长连接。
KataContainers与gVisor.
KataContainers 和 gVisor都是拥有独立内核的安全容器项目
容器监控与日志
Prometheus、MetricsServer与Kubernetes监控体系
Kubernetes 项目的监控体以 Prometheus 项目为核心的一套统一的方案。
Prometheus
Prometheus 项目工作的核心,是使用 Pull (抓取)的方式去搜集被监控对象 的 Metrics 数据(监控指标数据),然后,再把这些数据保存在一个 TSDB (时间序列数 据库,比如 OpenTSDB、InfluxDB 等)当中,以便后续可以按照时间进行检索。
除了核心监控机制外,还包括
● Pushgateway,可以允许被监控对象以 Push 的方式向 Prometheus 推送 Metrics 数据。
● Alertmanager,则可以根据 Metrics 信息灵活地设置报警。
● 通过 Grafana 对外暴露出的、可以灵活配置的监控数据可视化界面
Kubernetes的监控体系,Metrics包含以下几类:
- 宿主机的监控数据。需要借助一个由 Prometheus 维护的Node Exporter 工具,Node Exporter 会以 DaemonSet 的方式运行在宿主机上。之后暴露给Prometheus,暴露的信息包括节点的负 载(Load)、CPU 、内存、磁盘以及网络这样的常规信息
- 来自于 Kubernetes 的 API Server、kubelet 等组件的 /metrics API。除了常规的 CPU、内存的信息外,这部分信息还主要包括了各个组件的核心监控指标,例如各个 Controller 的 工作队列(Work Queue)的长度、请求的 QPS 和延迟数据等等。
○ 这部分信息,是检查 Kubernetes 本身工作情况的主要依据。 - Kubernetes 相关的监控数据。也是核心监控数据(core metrics)。这其中包括了 Pod、Node、容器、Service 等主要 Kubernetes 核心概念的 Metrics。
○ 其中容器相关的 Metrics 主要来自于 kubelet 内置的 cAdvisor 服务
○ cAdvisor随kubelet启动,能够提供的信息,可以细化到每一个容器的 CPU、文件系统、内存、网络等资源的使用情况。
○ 核心监控数据,其实使用的是 Kubernetes 的一个非常重要的扩展能力,叫作 Metrics Server
○ 有了 Metrics Server 之后,用户就可以通过标准的 Kubernetes API 来访问到这些监控 数据。http://127.0.0.1:8001/apis/metrics.k8s.io/v1beta1/namespaces/<namespace-name>/pods/<pod-name>
Aggregator
Metrics Server 并不是 kube-apiserver 的一部分,而是通过 Aggregator 这种插件机制。
当 Kubernetes 的 API Server 开启了 Aggregator 模式之后,你再访问 apis/metrics.k8s.io/v1beta1 的时候,实际上访问到的是一个叫作 kube-aggregator 的 代理。而 kube-apiserver,正是这个代理的一个后端;而 Metrics Server,则是另一个后端。
kube- aggregator 其实就是一个根据 URL 选择具体的 API 后端的代理服务器。
CustomMetrics让AutoScaling不再“食之无味”
借助上述监控体系,Kubernetes 就可以为你提供 Custom Metrics,自定义监控指标。
在真实场景中,用户需要根据自定义的监控指标,例如应用的等待队列长度,来决定Auto Scaling。
现在,Kubernetes的自动扩展器组件 Horizontal Pod Autoscaler (HPA), 可以直接使用Custom Metrics 来执行用户指定的扩展策略,这里的整个过程都是非常灵活 和可定制的。
Kubernetes的Custom Metrics 也是借助上面提到的kube- aggregator。
具体原理,就是当把 Custom Metrics APIServer 启动之后, Kubernetes 里就会出现一个叫作custom.metrics.k8s.io的 API。而当访问这个 URL 时,Aggregator 就会把你的请求转发给 Custom Metrics APIServer 。而 Custom Metrics APIServer 的实现,其实就是一个 Prometheus 项目的 Adaptor。
至于HPA配置,可以如下配置。其实就是设置 Auto Scaling 规则的地方。
容器日志收集与管理
Kubernetes 里面对容器日志的处理方式,都叫作 cluster-level- logging,即:这个日志处理系统,与容器、Pod 以及 Node 的生命周期都是完全无关的。
这个设计是为了保证,无论容器、Pod出问题,日志都得以保存,可以被查找到。
主要由三种日志方案。
在 Node 上部署 logging agent
这套方案的核心就是logging agent ,它一般都会以 DaemonSet 的方式运行在 节点上,然后将宿主机上的容器日志目录挂载进去,最后由 logging-agent 把日志转发出去。
但是如果日志不是直接输出到容器的 stdout 和 stderr 里,这套方案就不可行。
通过 sidecar 容器把日志文件重新输出到 sidecar 的 stdout 和 stderr 上
通过一个 sidecar 容器把这些日志文件 重新输出到 sidecar 的 stdout 和 stderr 上
由于 sidecar 跟主容器之间是共享 Volume 的,所以这里的 sidecar 方案的额外性能损耗并不高,也就是多占用一点 CPU 和内存罢了。但存在两份日志文件,当日志量比较大时,就会对磁盘影响较大。
通过一个 sidecar 容器,直接把应用的日志文件发送到远程存储里面去
相当于把方案一里的 logging agent,放在了应用 Pod 里。如果使用fluentd作为logging-agent,fluentd 的输入源只是从stdout 和 stderr变为了日志文件。