K8S知识总结

目录

k8s知识总结

容器

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

Cgroups技术 是用来制造约束的主要手段,而 Namespace技术 则是用来修改进程视图的主要方法

Mount Namespace跟其他Namespace的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效

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

rootfs只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在Linux操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。

所以说,rootfs只包括了操作系统的“躯壳”,并没有包括操作系统的“灵魂”。那么,对于容器来说,这个操作系统的“灵魂”又在哪里呢?

实际上,同一台机器上的所有容器,都共享宿主机操作系统的内核。

这就意味着,如果你的应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互,你就需要注意了:这些操作和依赖的对象,都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个“全局变量”,牵一发而动全身

容器镜像的本质:rootfs+unionfs

一个进程,可以选择加入到某个进程已有的Namespace当中,从而达到“进入”这个进程所在容器的目的,这正是docker exec的实现原理

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

一个正在运行的Linux容器,其实可以被“一分为二”地看待:

  1. 一组联合挂载在/var/lib/docker/aufs/mnt上的rootfs,这一部分我们称为“容器镜像”(Container Image),是容器的静态视图;

  2. 一个由Namespace+Cgroups构成的隔离环境,这一部分我们称为“容器运行时”(Container Runtime),是容器的动态视图。

全局架构

image-20230308111043857

核心功能全景图

image-20230308113435340

emptyDir

什么是emptyDir类型呢?

它其实就等同于我们之前讲过的Docker的隐式Volume参数,即:不显式声明宿主机目录的Volume。所以,Kubernetes也会在宿主机上创建一个临时目录,这个目录将来就会被绑定挂载到容器所声明的Volume目录上。

不难看到,Kubernetes的emptyDir类型,只是把Kubernetes创建的临时目录作为Volume的宿主机目录,交给了Docker。这么做的原因,是Kubernetes不想依赖Docker自己创建的那个_data目录。

为什么我们需要Pod

Pod这个概念,提供的是一种编排思想,而不是具体的技术方案

Pod,实际上是在扮演传统基础设施里“虚拟机”的角色;而容器,则是这个虚拟机里运行的用户程序。

容器的本质是进程,pod相当于进程组,k8s就是操作系统

关于Pod最重要的一个事实是:它只是一个逻辑概念。**

也就是说,Kubernetes真正处理的,还是宿主机操作系统上Linux容器的Namespace和Cgroups,而并不存在一个所谓的Pod的边界或者隔离环境

Pod,其实是一组共享了某些资源的容器。

Pod里的所有容器,共享的是同一个Network Namespace,并且可以声明共享同一个Volume

在Kubernetes项目里,Pod的实现需要使用一个中间容器,这个容器叫作Infra容器。在这个Pod中,Infra容器永远都是第一个被创建的容器,而其他用户定义的容器,则通过Join Network Namespace的方式,与Infra容器关联在一起。这样的组织关系,可以用下面这样一个示意图来表达:

image-20230308161837524

如上图所示,这个Pod里有两个用户容器A和B,还有一个Infra容器。很容易理解,在Kubernetes项目里,Infra容器一定要占用极少的资源,所以它使用的是一个非常特殊的镜像,叫作: k8s.gcr.io/pause。这个镜像是一个用汇编语言编写的、永远处于“暂停”状态的容器,解压后的大小也只有100~200 KB左右。

而在Infra容器“Hold住”Network Namespace后,用户容器就可以加入到Infra容器的Network Namespace当中了。所以,如果你查看这些容器在宿主机上的Namespace文件(这个Namespace文件的路径,我已经在前面的内容中介绍过),它们指向的值一定是完全一样的。

这也就意味着,对于Pod里的容器A和容器B来说:

  • 它们可以直接使用localhost进行通信;
  • 它们看到的网络设备跟Infra容器看到的完全一样;
  • 一个Pod只有一个IP地址,也就是这个Pod的Network Namespace对应的IP地址;
  • 当然,其他的所有网络资源,都是一个Pod一份,并且被该Pod中的所有容器共享;
  • Pod的生命周期只跟Infra容器一致,而与容器A和B无关。

而对于同一个Pod里面的所有用户容器来说,它们的进出流量,也可以认为都是通过Infra容器完成的。这一点很重要,因为 将来如果你要为Kubernetes开发一个网络插件时,应该重点考虑的是如何配置这个Pod的Network Namespace,而不是每一个用户容器如何使用你的网络配置,这是没有意义的。

容器设计模式-sidecar模式

sidecar指的就是我们可以在一个Pod中,启动一个辅助容器,来完成一些独立于主进程(主容器)之外的工作。

pod对象

Pod,而不是容器,才是Kubernetes项目中的最小编排单位。

到底哪些属性属于Pod对象,而又有哪些属性属于Container呢?

如果你能把Pod看成传统环境里的“机器”、把容器看作是运行在这个“机器”里的“用户程序”,那么很多关于Pod对象的设计就非常容易理解了

凡是调度、网络、存储,以及安全相关的属性,基本上是Pod 级别的。

凡是跟容器的Linux Namespace相关的属性,也一定是Pod 级别的

Init Containers的生命周期,会先于所有的Containers,并且严格按照定义的顺序执行。

postStart定义的操作,虽然是在Docker容器ENTRYPOINT执行之后,但它并不严格保证顺序。也就是说,在postStart启动时,ENTRYPOINT有可能还没有结束。

preStop发生的时机,则是容器被杀死之前(比如,收到了SIGKILL信号)。而需要明确的是,preStop操作的执行,是同步的。所以,它会阻塞当前的容器杀死流程,直到这个Hook定义操作完成之后,才允许容器被杀死,这跟postStart不一样

在Kubernetes中,有几种特殊的Volume,它们存在的意义不是为了存放容器里的数据,也不是用来进行容器和宿主机之间的数据交换。这些特殊Volume的作用,是为容器提供预先定义好的数据。所以,从容器的角度来看,这些Volume里的信息就是仿佛是 被Kubernetes“投射”(Project)进入容器当中的。这正是Projected Volume的含义。

到目前为止,Kubernetes支持的Projected Volume一共有四种:

  1. Secret;

  2. ConfigMap

  3. Downward API;

  4. ServiceAccountToken

与Secret类似的是ConfigMap,它与Secret的区别在于,ConfigMap保存的是不需要加密的、应用所需的配置信息

其实,Secret、ConfigMap,以及Downward API这三种Projected Volume定义的信息,大多还可以通过环境变量的方式出现在容器里。但是,通过环境变量获取这些信息的方式,不具备自动更新的能力。所以,一般情况下,我都建议你使用Volume文件的方式获取这些信息。

Kubernetes项目的Projected Volume其实只有三种,因为第四种ServiceAccountToken,只是一种特殊的Secret而已。

容器健康检查和恢复机制

存活探针

就绪探针

Kubernetes中并没有Docker的Stop语义。所以虽然是Restart(重启),但实际却是重新创建了容器。

这个功能就是Kubernetes里的 Pod恢复机制,也叫restartPolicy。它是Pod的Spec部分的一个标准字段(pod.spec.restartPolicy),默认值是Always,即:任何时候这个容器发生了异常,它一定会被重新创建。

但一定要强调的是,Pod的恢复过程,永远都是发生在当前节点上,而不会跑到别的节点上去。事实上,一旦一个Pod与一个节点(Node)绑定,除非这个绑定发生了变化(pod.spec.node字段被修改),否则它永远都不会离开这个节点。这也就意味着,如果这个宿主机宕机了,这个Pod也不会主动迁移到其他节点上去。

restartPolicy:

  • Always:在任何情况下,只要容器不在运行状态,就自动重启容器;(长期运行的应用)
  • OnFailure: 只在容器 异常时才自动重启容器;(单次运行的job)
  • Never: 从来不重启容器。(保留容器现场)

如果你要关心这个容器退出后的上下文环境,比如容器退出后的日志、文件和目录,就需要将restartPolicy设置为Never。因为一旦容器被自动重新创建,这些内容就有可能丢失掉了(被垃圾回收了)

PodPreset(Pod预设置)

专门用来对Pod进行批量化、自动化修改的工具对象

PodPreset里定义的内容,只会在Pod API对象被创建之前追加在这个对象本身上,而不会影响任何Pod的控制器的定义。

比如,我们现在提交的是一个nginx-deployment,那么这个Deployment对象本身是永远不会被PodPreset改变的,被修改的只是这个Deployment创建出来的所有Pod。

如果你定义了同时作用于一个Pod对象的多个PodPreset,会发生什么呢?

实际上,Kubernetes项目会帮你合并(Merge)这两个PodPreset要做的修改。而如果它们要做的修改有冲突的话,这些冲突字段就不会被修改。

控制器

控制器模式:用一种对象管理另一种对象

通用编排模式:控制循环(control loop)

for {
  实际状态 := 获取集群中对象X的实际状态Actual State
  期望状态 := 获取集群中对象X的期望状态Desired State
  if 实际状态 == 期望状态{
    什么都不做
  } else {
    执行编排动作将实际状态调整为期望状态
  }
}

以Deployment为例,我和你简单描述一下它对控制器模型的实现:

  1. Deployment控制器从Etcd中获取到所有携带了“app: nginx”标签的Pod,然后统计它们的数量,这就是实际状态;

  2. Deployment对象的Replicas字段的值就是期望状态;

  3. Deployment控制器将两个状态做比较,然后根据比较结果,确定是创建Pod,还是删除已有的Pod

控制器定义模式

image-20230309165645858

如上图所示, 类似Deployment这样的一个控制器,实际上都是由上半部分的控制器定义(包括期望状态),加上下半部分的被控制对象的模板组成的

Deployment

一个ReplicaSet对象,其实就是由副本数目的定义和一个Pod模板组成的。不难发现,它的定义其实是Deployment的一个子集。

更重要的是,Deployment控制器实际操纵的,正是这样的ReplicaSet对象,而不是Pod对象。

对于一个Deployment所管理的Pod,它的ownerReference是谁?这个问题的答案就是:ReplicaSet

Deployment,与ReplicaSet,以及Pod的关系:

image-20230309170500553

扩展Deployment、ReplicaSet和Pod的关系图

image-20230309171725382

Deployment的控制器,实际上控制的是ReplicaSet的数目,以及每个ReplicaSet的属性。

而一个应用的版本,对应的正是一个ReplicaSet;这个版本应用的Pod数量,则由ReplicaSet通过它自己的控制器(ReplicaSet Controller)来保证。

通过这样的多个ReplicaSet对象,Kubernetes项目就实现了对多个“应用版本”的描述

合并对deployment的多次更新或暂停滚动更新操作

  1. kubectl rollout pause
  2. kubectl rollout resume

在这个kubectl rollout resume指令执行之前,在kubectl rollout pause指令之后的这段时间里,我们对Deployment进行的所有修改,最后只会触发一次“滚动更新”

该如何控制这些“历史”ReplicaSet的数量呢?

很简单,Deployment对象有一个字段,叫作spec.revisionHistoryLimit,就是Kubernetes为Deployment保留的“历史版本”个数。所以,如果把它设置为0,你就再也不能做回滚操作了。

StatefulSet

拓扑状态

实例之间有不对等关系,以及实例对外部数据有依赖关系的应用,就被称为“有状态应用”(Stateful Application)

StatefulSet的设计其实非常容易理解。它把真实世界里的应用状态,抽象为了两种情况:

  1. 拓扑状态。这种情况意味着,应用的多个实例之间不是完全对等的关系。这些应用实例,必须按照某些顺序启动,比如应用的主节点A要先于从节点B启动。而如果你把A和B两个Pod删除掉,它们再次被创建出来时也必须严格按照这个顺序才行。并且,新创建出来的Pod,必须和原来Pod的网络标识一样,这样原先的访问者才能使用同样的方法,访问到这个新Pod。

  2. 存储状态。这种情况意味着,应用的多个实例分别绑定了不同的存储数据。对于这些应用实例来说,Pod A第一次读取到的数据,和隔了十分钟之后再次读取到的数据,应该是同一份,哪怕在此期间Pod A被重新创建过。这种情况最典型的例子,就是一个数据库应用的多个存储实例。

所以, StatefulSet的核心功能,就是通过某种方式记录这些状态,然后在Pod被重新创建时,能够为新Pod恢复这些状态。

Headless Service不需要分配一个VIP,而是可以直接以DNS记录的方式解析出被代理Pod的IP地址。

所谓的Headless Service,其实仍是一个标准Service的YAML文件。只不过,它的clusterIP字段的值是:None,即:这个Service,没有一个VIP作为“头”。这也就是Headless的含义。所以,这个Service被创建后并不会被分配一个VIP,而是会以DNS记录的方式暴露出它所代理的Pod

当你创建了一个Headless Service之后,它所代理的所有Pod的IP地址,都会被绑定一个这样格式的DNS记录,如下所示:

<pod-name>.<svc-name>.<namespace>.svc.cluster.local

这个DNS记录,正是Kubernetes项目为Pod分配的唯一的“可解析身份”(Resolvable Identity)。

有了这个“可解析身份”,只要你知道了一个Pod的名字,以及它对应的Service的名字,你就可以非常确定地通过这条DNS记录访问到Pod的IP地址。

StatefulSet给它所管理的所有Pod的名字,进行了编号,编号规则是:

<statefulset name>-<ordinal index>

而且这些编号都是从0开始累加,与StatefulSet的每个Pod实例一一对应,绝不重复。更重要的是,这些Pod的创建,也是严格按照编号顺序进行的。

当我们把Pod删除之后,Kubernetes会按照原先编号的顺序,创建出了新的Pod。并且,Kubernetes依然为它们分配了与原来相同的“网络身份”;

通过这种严格的对应规则, StatefulSet就保证了Pod网络标识的稳定性

通过这种方法, Kubernetes就成功地将Pod的拓扑状态(比如:哪个节点先启动,哪个节点后启动),按照Pod的“名字+编号”的方式固定了下来。此外,Kubernetes还为每一个Pod提供了一个固定并且唯一的访问入口,即:这个Pod对应的DNS记录。

这些状态,在StatefulSet的整个生命周期里都会保持不变,绝不会因为对应Pod的删除或者重新创建而失效。

不过,相信你也已经注意到了,尽管这条DNS记录本身不会变,但它解析到的Pod的IP地址,并不是固定的。这就意味着,对于“有状态应用”实例的访问,你必须使用DNS记录或者hostname的方式,而绝不应该直接访问这些Pod的IP地址。

如果用一句话来总结的话,你可以这么理解这个过程:

StatefulSet这个控制器的主要作用之一,就是使用Pod模板创建Pod的时候,对它们进行编号,并且按照编号顺序逐一完成创建工作。而当StatefulSet的“控制循环”发现Pod的“实际状态”与“期望状态”不一致,需要新建或者删除Pod进行“调谐”的时候,它会严格按照这些Pod编号的顺序,逐一完成这些操作。与此同时,通过Headless Service的方式,StatefulSet为每个Pod创建了一个固定并且稳定的DNS记录,来作为它的访问入口

存储状态

Kubernetes中PVC和PV的设计, 实际上类似于“接口”和“实现”的思想。开发者只要知道并会使用“接口”,即:PVC;而运维人员则负责给“接口”绑定具体的实现,即:PV

通过PVC和PV及pod编号对应机制实现存储状态的一致

更新机制

StatefulSet Controller就会按照与Pod编号相反的顺序,从最后一个Pod开始,逐一更新这个StatefulSet管理的每个Pod。而如果更新发生了错误,这次“滚动更新”就会停止。此外,StatefulSet的“滚动更新”还允许我们进行更精细的控制,比如金丝雀发布(Canary Deploy)或者灰度发布, 这意味着应用的多个实例中被指定的一部分不会被更新到最新的版本。

这个字段,正是StatefulSet的spec.updateStrategy.rollingUpdate的partition字段。

总结

StatefulSet的工作原理

首先,StatefulSet的控制器直接管理的是Pod。这是因为,StatefulSet里的不同Pod实例,不再像ReplicaSet中那样都是完全一样的,而是有了细微区别的。比如,每个Pod的hostname、名字等都是不同的、携带了编号的。而StatefulSet区分这些实例的方式,就是通过在Pod的名字里加上事先约定好的编号。

其次,Kubernetes通过Headless Service,为这些有编号的Pod,在DNS服务器中生成带有同样编号的DNS记录。只要StatefulSet能够保证这些Pod名字里的编号不变,那么Service里类似于web-0.nginx.default.svc.cluster.local这样的DNS记录也就不会变,而这条记录解析出来的Pod的IP地址,则会随着后端Pod的删除和再创建而自动更新。这当然是Service机制本身的能力,不需要StatefulSet操心。

最后,StatefulSet还为每一个Pod分配并创建一个同样编号的PVC。这样,Kubernetes就可以通过Persistent Volume机制为这个PVC绑定上对应的PV,从而保证了每一个Pod都拥有一个独立的Volume。

在这种情况下,即使Pod被删除,它所对应的PVC和PV依然会保留下来。所以当这个Pod被重新创建出来之后,Kubernetes会为它找到同样编号的PVC,挂载这个PVC对应的Volume,从而获取到以前保存在Volume里的数据。

StatefulSet的设计思想

StatefulSet其实就是一种特殊的Deployment,而其独特之处在于,它的每个Pod都被编号了。而且,这个编号会体现在Pod的名字和hostname等标识信息上,这不仅代表了Pod的创建顺序,也是Pod的重要网络标识(即:在整个集群里唯一的、可被访问的身份)。

有了这个编号后,StatefulSet就使用Kubernetes里的两个标准功能:Headless Service和PV/PVC,实现了对Pod的拓扑状态和存储状态的维护。

容器化守护进程DaemonSet

主要作用

DaemonSet的主要作用,是让你在Kubernetes集群里,运行一个Daemon Pod。 所以,这个Pod有如下三个特征:

  1. 这个Pod运行在Kubernetes集群里的每一个节点(Node)上;

  2. 每个节点上只有一个这样的Pod实例;

  3. 当有新的节点加入Kubernetes集群后,该Pod会自动地在新节点上被创建出来;而当旧节点被删除后,它上面的Pod也相应地会被回收掉。

这个机制听起来很简单,但Daemon Pod的意义确实是非常重要的。我随便给你列举几个例子:

  1. 各种网络插件的Agent组件,都必须运行在每一个节点上,用来处理这个节点上的容器网络;

  2. 各种存储插件的Agent组件,也必须运行在每一个节点上,用来在这个节点上挂载远程存储目录,操作容器的Volume目录;

  3. 各种监控组件和日志组件,也必须运行在每一个节点上,负责这个节点上的监控信息和日志搜集。

更重要的是,跟其他编排对象不一样,DaemonSet开始运行的时机,很多时候比整个Kubernetes集群出现的时机都要早。

控制器控制循环

  1. 没有这种Pod,那么就意味着要在这个Node上创建这样一个Pod;

  2. 有这种Pod,但是数量大于1,那就说明要把多余的Pod从这个Node上删除掉;

  3. 正好只有一个这种Pod,那说明这个节点是正常的。

DaemonSet的“过人之处”,其实是依靠Toleration实现的

DaemonSet其实是一个非常简单的控制器。在它的控制循环中,只需要遍历所有节点,然后根据节点上是否有被管理Pod的情况,来决定是否要创建或者删除一个Pod。

只不过,在创建每个Pod的时候,DaemonSet会自动给这个Pod加上一个nodeAffinity,从而保证这个Pod只会在指定节点上启动。同时,它还会自动给这个Pod加上一个Toleration,从而忽略节点的unschedulable等“污点”。

当然, 你也可以在Pod模板里加上更多种类的Toleration,从而利用DaemonSet达到自己的目的

版本管理

Deployment管理这些版本,靠的是“一个版本对应一个ReplicaSet对象”。可是,DaemonSet控制器操作的直接就是Pod,不可能有ReplicaSet这样的对象参与其中。 那么,它的这些版本又是如何维护的呢?

Kubernetes v1.7之后添加了一个API对象,名叫 ControllerRevision,专门用来记录某种Controller对象的版本。

总结

相比于Deployment,DaemonSet只管理Pod对象,然后通过nodeAffinity和Toleration这两个调度器的小功能,保证了每个节点上有且只有一个Pod。这个控制器的实现原理简单易懂,希望你能够快速掌握。

与此同时,DaemonSet使用ControllerRevision,来保存和管理自己对应的“版本”。这种“面向API对象”的设计思路,大大简化了控制器本身的逻辑,也正是Kubernetes项目“声明式API”的优势所在。

而且,相信聪明的你此时已经想到了,StatefulSet也是直接控制Pod对象的,那么它是不是也在使用ControllerRevision进行版本管理呢?

没错。在Kubernetes项目里,ControllerRevision其实是一个通用的版本管理对象。这样,Kubernetes项目就巧妙地避免了每种控制器都要维护一套冗余的代码和逻辑的问题

Job与CronJob

Job

restartPolicy在Job对象里只允许被设置为Never和OnFailure;而在Deployment对象里,restartPolicy则只允许被设置为Always。

在Job对象中,负责并行控制的参数有两个:

  1. spec.parallelism,它定义的是一个Job在任意时间最多可以启动多少个Pod同时运行;

  2. spec.completions,它定义的是Job至少要完成的Pod数目,即Job的最小完成数。

Job Controller的工作原理

首先,Job Controller控制的对象,直接就是Pod。

其次,Job Controller在控制循环中进行的调谐(Reconcile)操作,是根据实际在Running状态Pod的数目、已经成功退出的Pod的数目,以及parallelism、completions参数的值共同计算出在这个周期里,应该创建或者删除的Pod数目,然后调用Kubernetes API来执行这个操作。

以创建Pod为例。在上面计算Pi值的这个例子中,当Job一开始创建出来时,实际处于Running状态的Pod数目=0,已经成功退出的Pod数目=0,而用户定义的completions,也就是最终用户需要的Pod数目=4。

所以,在这个时刻,需要创建的Pod数目 = 最终需要的Pod数目 - 实际在Running状态Pod数目 - 已经成功退出的Pod数目 = 4 - 0 - 0= 4。也就是说,Job Controller需要创建4个Pod来纠正这个不一致状态。

可是,我们又定义了这个Job的parallelism=2。也就是说,我们规定了每次并发创建的Pod个数不能超过2个。所以,Job Controller会对前面的计算结果做一个修正,修正后的期望创建的Pod数目应该是:2个。

这时候,Job Controller就会并发地向kube-apiserver发起两个创建Pod的请求。

类似地,如果在这次调谐周期里,Job Controller发现实际在Running状态的Pod数目,比parallelism还大,那么它就会删除一些Pod,使两者相等。

综上所述,Job Controller实际上控制了,作业执行的 并行度,以及总共需要完成的 任务数 这两个重要参数。而在实际使用时,你需要根据作业的特性,来决定并行度(parallelism)和任务数(completions)的合理取值。

CronJob

CronJob描述的,正是定时任务

CronJob是一个Job对象的控制器(Controller)!

没错,CronJob与Job的关系,正如同Deployment与ReplicaSet的关系一样。CronJob是一个专门用来管理Job对象的控制器。只不过,它创建和删除Job的依据,是schedule字段定义的、一个标准的 Unix Cron 格式的表达式

Cron表达式中的五个部分分别代表:分钟、小时、日、月、星期

由于定时任务的特殊性,很可能某个Job还没有执行完,另外一个新Job就产生了。这时候,你可以通过spec.concurrencyPolicy字段来定义具体的处理策略。比如:

  1. concurrencyPolicy=Allow,这也是默认情况,这意味着这些Job可以同时存在;

  2. concurrencyPolicy=Forbid,这意味着不会创建新的Pod,该创建周期被跳过;

  3. concurrencyPolicy=Replace,这意味着新产生的Job会替换旧的、没有执行完的Job。

而如果某一次Job创建失败,这次创建就会被标记为“miss”。当在指定的时间窗口内,miss的数目达到100时,那么CronJob会停止再创建这个Job。

这个时间窗口,可以由spec.startingDeadlineSeconds字段指定。比如startingDeadlineSeconds=200,意味着在过去200 s里,如果miss的数目达到了100次,那么这个Job就不会被创建执行了。

声明式API与k8s编程范式

声明式API

先kubectl create,再replace的操作,我们称为 命令式配置文件操作

到底什么才是“声明式API”呢?**

答案是,kubectl apply命令

可是,它跟kubectl replace命令有什么本质区别吗?

实际上,你可以简单地理解为,kubectl replace的执行过程,是使用新的YAML文件中的API对象, 替换原有的API对象;而kubectl apply,则是执行了一个 对原有API对象的PATCH操作

类似地,kubectl set image和kubectl edit也是对已有API对象的修改。

更进一步地,这意味着kube-apiserver在响应命令式请求(比如,kubectl replace)的时候,一次只能处理一个写请求,否则会有产生冲突的可能。而对于声明式请求(比如,kubectl apply), 一次能处理多个写操作,并且具备Merge能力

这种区别,可能乍一听起来没那么重要。而且,正是由于要照顾到这样的API设计,做同样一件事情,Kubernetes需要的步骤往往要比其他项目多不少。

但是,如果你仔细思考一下Kubernetes项目的工作流程,就不难体会到这种声明式API的独到之处

Kubernetes“声明式API”的独特之处:

  • 首先,所谓“声明式”,指的就是我只需要提交一个定义好的API对象来“声明”,我所期望的状态是什么样子。
  • 其次,“声明式API”允许有多个API写端,以PATCH的方式对API对象进行修改,而无需关心本地原始YAML文件的内容。
  • 最后,也是最重要的,有了上述两个能力,Kubernetes项目才可以基于对API对象的增、删、改、查,在完全无需外界干预的情况下,完成对“实际状态”和“期望状态”的调谐(Reconcile)过程。

所以说, 声明式API,才是Kubernetes项目编排能力“赖以生存”的核心所在

在Kubernetes项目中,一个API对象在Etcd里的完整资源路径,是由:Group(API组)、Version(API版本)和Resource(API资源类型)三个部分组成的。

通过这样的结构,整个Kubernetes里的所有API对象,实际上就可以用如下的树形结构表示出来:

image-20230310160117568

API对象创建过程

image-20230310160222115

Kubernetes编程范式

如何使用控制器模式,同Kubernetes里API对象的“增、删、改、查”进行协作,进而完成用户业务逻辑的编写过程

CRD

  1. 编写API对象本身相关定义并用工具生成clientset、informer和lister
  2. 编写对应资源的Controller,根据对象变化完成对资源的操作

Kubernetes API编程范式的核心思想

所谓的Informer,就是一个自带缓存和索引机制,可以触发Handler的客户端库。这个本地缓存在Kubernetes中一般被称为Store,索引一般被称为Index。

Informer使用了Reflector包,它是一个可以通过ListAndWatch机制获取并监视API对象变化的客户端封装。

Reflector和Informer之间,用到了一个“增量先进先出队列”进行协同。而Informer与你要编写的控制循环之间,则使用了一个工作队列来进行协同。

在实际应用中,除了控制循环之外的所有代码,实际上都是Kubernetes为你自动生成的,即:pkg/client/{informers, listers, clientset}里的内容。

而这些自动生成的代码,就为我们提供了一个可靠而高效地获取API对象“期望状态”的编程库。

所以,接下来,作为开发者,你就只需要关注如何拿到“实际状态”,然后如何拿它去跟“期望状态”做对比,从而决定接下来要做的业务逻辑即可。

Operator

Operator 是使用自定义资源(CR)管理应用及其组件的自定义 Kubernetes 控制器。高级配置和设置由用户在 CR 中提供。Kubernetes Operator 基于嵌入在 Operator 逻辑中的最佳实践将高级指令转换为低级操作

RBAC

在Kubernetes项目中,负责完成授权(Authorization)工作的机制,就是RBAC:基于角色的访问控制(Role-Based Access Control)

三个最基本的概念。

  1. Role:角色,它其实是一组规则,定义了一组对Kubernetes API对象的操作权限。

  2. Subject:被作用者,既可以是“人”,也可以是“机器”,也可以是你在Kubernetes里定义的“用户”。

  3. RoleBinding:定义了“被作用者”和“角色”的绑定关系。

而这三个概念,其实就是整个RBAC体系的核心所在。

实际上,Role、RoleBinding本身就是一个Kubernetes的API对象

所谓角色(Role),其实就是一组权限规则列表。而我们分配这些权限的方式,就是通过创建RoleBinding对象,将被作用者(subject)和权限列表进行绑定。

另外,与之对应的ClusterRole和ClusterRoleBinding,则是Kubernetes集群级别的Role和RoleBinding,它们的作用范围不受Namespace限制。

而尽管权限的被作用者可以有很多种(比如,User、Group等),但在我们平常的使用中,最普遍的用法还是ServiceAccount。所以,Role + RoleBinding + ServiceAccount的权限分配方式是你要重点掌握的内容

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字段必须一样。

PVC和PV的设计,其实跟“面向对象”的思想完全一致。

PVC可以理解为持久化存储的“接口”,它提供了对某种持久化存储的描述,但不提供具体的实现;而这个持久化存储的实现部分则由PV负责完成。

这样做的好处是,作为应用开发者,我们只需要跟PVC这个“接口”打交道,而不必关心具体的实现是NFS还是Ceph。毕竟这些存储相关的知识太专业了,应该交给专业的人去做。

所谓的“持久化Volume”,指的就是这个宿主机上的目录,具备“持久性”**。即:这个目录里面的内容,既不会因为容器的删除而被清理掉,也不会跟当前的宿主机绑定。这样,当容器被重启或者在其他节点上重建出来之后,它仍然能够通过挂载这个Volume,访问到这些内容。

显然,我们前面使用的hostPath和emptyDir类型的Volume并不具备这个特征:它们既有可能被kubelet清理掉,也不能被“迁移”到其他节点上。

所以,大多数情况下,持久化Volume的实现,往往依赖于一个远程存储服务,比如:远程文件存储(比如,NFS、GlusterFS)、远程块存储(比如,公有云提供的远程磁盘)等等

这个准备“持久化”宿主机目录的过程,我们可以形象地称为“两阶段处理”

为虚拟机挂载远程磁盘的操作,对应的正是“两阶段处理”的第一阶段。在Kubernetes中,我们把这个阶段称为Attach。

将磁盘设备格式化并挂载到Volume宿主机目录的操作,对应的正是“两阶段处理”的第二个阶段,我们一般称为:Mount。

在Kubernetes中,上述 关于PV的“两阶段处理”流程,是靠独立于kubelet主控制循环(Kubelet Sync Loop)之外的两个控制循环来实现的。

其中,“第一阶段”的Attach(以及Dettach)操作,是由Volume Controller负责维护的,这个控制循环的名字叫作: AttachDetachController。而它的作用,就是不断地检查每一个Pod对应的PV,和这个Pod所在宿主机之间挂载情况。从而决定,是否需要对这个PV进行Attach(或者Dettach)操作

而“第二阶段”的Mount(以及Unmount)操作,必须发生在Pod对应的宿主机上,所以它必须是kubelet组件的一部分。这个控制循环的名字,叫作: VolumeManagerReconciler,它运行起来之后,是一个独立于kubelet主循环的Goroutine。

通过这样将Volume的处理同kubelet的主循环解耦,Kubernetes就避免了这些耗时的远程挂载操作拖慢kubelet的主控制循环,进而导致Pod的创建效率大幅下降的问题。实际上, kubelet的一个主要设计原则,就是它的主控制循环绝对不可以被block

StorageClass对象的作用,其实就是创建PV的模板。**

具体地说,StorageClass对象会定义如下两个部分内容:

  • 第一,PV的属性。比如,存储类型、Volume的大小等等。
  • 第二,创建这种PV需要用到的存储插件。比如,Ceph等等。

有了这样两个信息之后,Kubernetes就能够根据用户提交的PVC,找到一个对应的StorageClass了。然后,Kubernetes就会调用该StorageClass声明的存储插件,创建出需要的PV。

概念关系示意图:

image-20230313150618575

从图中我们可以看到,在这个体系中:

  • PVC描述的,是Pod想要使用的持久化存储的属性,比如存储的大小、读写权限等。

  • PV描述的,则是一个具体的Volume的属性,比如Volume的类型、挂载目录、远程存储服务器地址等。

  • 而StorageClass的作用,则是充当PV的模板。并且,只有同属于一个StorageClass的PV和PVC,才可以绑定在一起。

当然,StorageClass的另一个重要作用,是指定PV的Provisioner(存储插件)。这时候,如果你的存储插件支持Dynamic Provisioning的话,Kubernetes就可以自动为你创建PV了。

容器网络

docker单机通信

image-20230313161701877

Flannel的VXLAN模式,基于VTEP设备进行“隧道”通信的流程

image-20230313161620905

Kubernetes是通过一个叫作CNI的接口,维护了一个单独的网桥来代替docker0。这个网桥的名字就叫作:CNI网桥,它在宿主机上的设备名称默认是:cni0。

以Flannel的VXLAN模式为例,在Kubernetes环境里,它的工作方式跟我们在上一篇文章中讲解的没有任何不同。只不过,docker0网桥被替换成了CNI网桥而已,如下所示:

image-20230313162534468

网络隔离

在Kubernetes里,网络隔离能力的定义,是依靠一种专门的API对象来描述的,即:NetworkPolicy

Kubernetes网络插件对Pod进行隔离,其实是靠在宿主机上生成NetworkPolicy对应的iptable规则来实现的

service

原理

实际上, Service是由kube-proxy组件,加上iptables来共同实现的。

ClusterIP模式的Service为你提供的,就是一个Pod的稳定的IP地址,即VIP。并且,这里Pod和Service的关系是可以通过Label确定的。

而Headless Service为你提供的,则是一个Pod的稳定的DNS名字,并且,这个名字是可以通过Pod名字和Service名字拼接出来的。

外界联通

Service的访问信息在Kubernetes集群之外,其实是无效的

外部访问Service的三种方式:

NodePort

LoadBalancer

External Name

总结

所谓Service,其实就是Kubernetes为Pod分配的、固定的、基于iptables(或者IPVS)的访问入口。而这些访问入口代理的Pod信息,则来自于Etcd,由kube-proxy通过控制循环来维护

service与ingress

这种全局的、为了代理不同后端Service而设置的负载均衡服务,就是Kubernetes里的Ingress服务。

所以,Ingress的功能其实很容易理解: 所谓Ingress,就是Service的“Service”

Kubernetes的资源模型与资源管理

在Kubernetes里,Pod是最小的原子调度单位。这也就意味着,所有跟调度和资源管理相关的属性都应该是属于Pod对象的字段。而这其中最重要的部分,就是Pod的CPU和内存配置

在Kubernetes中,像CPU这样的资源被称作“可压缩资源”(compressible resources)。它的典型特点是,当可压缩资源不足时,Pod只会“饥饿”,但不会退出。

而像内存这样的资源,则被称作“不可压缩资源(incompressible resources)。当不可压缩资源不足时,Pod就会因为OOM(Out-Of-Memory)被内核杀掉。

而由于Pod可以由多个Container组成,所以CPU和内存资源的限额,是要配置在每个Container的定义上的。这样,Pod整体的资源配置,就由这些Container的配置值累加得到。

Kubernetes里Pod的CPU和内存资源,实际上还要分为limits和requests两种情况

这两者的区别其实非常简单:在调度的时候,kube-scheduler只会按照requests的值进行计算。而在真正设置Cgroups限制的时候,kubelet则会按照limits的值来进行设置

Kubernetes里的QoS模型

  1. 当Pod里的每一个Container都同时设置了requests和limits,并且requests和limits值相等的时候,这个Pod就属于Guaranteed类别

  2. 当Pod仅设置了limits没有设置requests的时候,Kubernetes会自动为它设置与limits相同的requests值,所以,这也属于Guaranteed情况

  3. 而当Pod不满足Guaranteed的条件,但至少有一个Container设置了requests。那么这个Pod就会被划分到Burstable类别

  4. 而如果一个Pod既没有设置requests,也没有设置limits,那么它的QoS类别就是BestEffort

Kubernetes为Pod设置这样三种QoS类别,具体有什么作用呢?

实际上, QoS划分的主要应用场景,是当宿主机资源紧张的时候,kubelet对Pod进行Eviction(即资源回收)时需要用到的。

当Eviction发生的时候,kubelet具体会挑选哪些Pod进行删除操作,就需要参考这些Pod的QoS类别了。

  • 首当其冲的,自然是BestEffort类别的Pod。
  • 其次,是属于Burstable类别、并且发生“饥饿”的资源使用量已经超出了requests的Pod。
  • 最后,才是Guaranteed类别。并且,Kubernetes会保证只有当Guaranteed类别的Pod的资源使用量超过了其limits的限制,或者宿主机本身正处于Memory Pressure状态时,Guaranteed的Pod才可能被选中进行Eviction操作。

当然,对于同QoS类别的Pod来说,Kubernetes还会根据Pod的优先级来进行进一步地排序和选择。

cpuset

我们知道,在使用容器的时候,你可以通过设置cpuset把容器绑定到某个CPU的核上,而不是像cpushare那样共享CPU的计算能力。

这种情况下,由于操作系统在CPU之间进行上下文切换的次数大大减少,容器里应用的性能会得到大幅提升。事实上, cpuset方式,是生产环境里部署在线应用类型的Pod时,非常常用的一种方式。

可是,这样的需求在Kubernetes里又该如何实现呢?

其实非常简单。

  • 首先,你的Pod必须是Guaranteed的QoS类型;
  • 然后,你只需要将Pod的CPU资源的requests和limits设置为同一个相等的整数值即可。

比如下面这个例子:

spec:
  containers:
  - name: nginx
    image: nginx
    resources:
      limits:
        memory: "200Mi"
        cpu: "2"
      requests:
        memory: "200Mi"
        cpu: "2"

这时候,该Pod就会被绑定在2个独占的CPU核上。当然,具体是哪两个CPU核,是由kubelet为你分配的。

Kubernetes默认调度器

在Kubernetes项目中,默认调度器的主要职责,就是为一个新创建出来的Pod,寻找一个最合适的节点(Node)。

而这里“最合适”的含义,包括两层:

  1. 从集群所有的节点中,根据调度算法挑选出所有可以运行该Pod的节点;

  2. 从第一步的结果中,再根据调度算法挑选一个最符合条件的节点作为最终结果。

Kubernetes调度机制工作原理

image-20230313173316756

Kubernetes默认调度器的可扩展性设计

image-20230313173439395

kubelet工作原理

image-20230313172006872

CRI

image-20230313172213425

参考资料

深入剖析 Kubernetes