K8S 控制器模式

Kubernetes模型通常由以下部分组成:

TypeMeta

TypeMeta是Kubernetes对象的最基本定义,它通过引入GKV(Group,Kind,Version)定义了一个对象的类型。

Group

Kubernetes定义了非常多对象,如何归类这些对象是一门学问,将对象依据其功能范围归入不同的分组,比如把支撑最基本功能的对象归入core组,把与应用部署有关的对象归入apps组,会使这些对象可维护性和可理解性更高。

Kind

定义一个对象的基本类型,比如Node,Pod,Deployment等。

Version

社区每个季度会推出一个Kubernetes版本,随着Kubernetes版本的演进,对象从创建之初到能够完全生产化就绪的版本是不断变化的。与软件版本类似,通常社区提出一个模型定义以后,随着该对象不断成熟,其版本可能会从v1alpha1,到v1alpha2,或者到v1beta1,最终变成生产就绪版本v1。

Kubernetes通过Version属性来控制版本。当不同版本的对象定义发生变更时,有可能需要涉及到数据迁移,Kubernetes API Server允许通过Conversion方法转换不同版本的对象属性。这是一种自动数据迁移的机制,当集羣版本升级以后,已经创建的老版本对象会被自动转换为新版本。

这里所説的版本是对外版本(External Version),用户通过API能看到的版本。事实上资源定义都有对内版本(Internal Version),在Kubernetes API Server处先将对外版本转换成对内版本,然后再进行持久化。

Metadata

TypeMeta定义了“我是什麽”,Metadata定义了“我是谁”。为方便管理,Kubernetes将不同用户或不同业务的对象用不同的Namespace隔离。Metadata中有两个最重要属性——Namespace和Name,分别定义了对象的Namespace归属及名字,这两个属性唯一定义了某个对象实例。

前面説过,所有对象都会以API的形式发佈供用户访问,Typemeta、Namespace和Name唯一确定了该对象所在的API访问路径,该路径也会被自动生成并保存在对象Metadata属性的selfLink中,如下所示:

selfLink: /api/v1/namespaces/default/pods/nginx-6ccb6b48dd-zvfrj

Label

传统面向对象设计系统中,对象组合的方法通常是内嵌或引用,即将对象A内嵌到对象B中,或者将对象A的ID内嵌到对象B中。这种设计的弊端是这种关係是固化的,一个对象可能对多个其他对象发生关联,如果该对象发生变更,系统需要遍历所有其关联对象并做修改。

Kubernetes採用了更巧妙的方式管理对象和对象的松耦合关係,其依赖的就是Label和Selector。Label,顾名思义就是给对象打标籤,一个对象可以有任意对标籤,其存在形式是键值对。不像名字和UID,标籤不需要独一无二,多个对象可以有同一个标籤,每个对象可以有多组标籤。

Label定义了这些对象的可识别属性,Kubernetes API支持以Label作为过滤条件查询对象。因此Label通常用最简形式定义:

metadata:
  labels:
    app: web
    tier: front

其他对象只需要定义Label Selector就可按条件查询出其需要关联的对象。Label的查询可以基于等式如app=web,或app!=db,或基于集合如app in (web, db)或app notin (web, db),可以只查询Label键,比如app。Label对多个条件查询只支持“与”操作,如app=web, tier=front。

Annotation

Annotation与Label一样用键值对来定义,但其功能与Label不一样,所有在用法上也有不同原则,API也不支持针用Annotation做条件过滤。虽然Kubernetes把对象做了很好的抽象,在实际运用中特别是生产化落地过程中,总是需要保存一些在对象内置属性中无法保存的信息,Annotation就是为了满足这类需求,事实上Annotation是对象的属性扩展。社区在开发新功能,需要对象发生变更之前,往往会先把需要变更的属性放在Annotation中,当功能经历完实验阶段再将其移至正式属性中。

Annotation作为属性扩展,更多是面向系统管理员和开发人员的,因此Annotation需要像其他属性一样做合理归类。与Java开发中的包名设计类似,通常需要将系统以不同功能规划为不同的Annotation Namespace,其键应以如下形式存在:/key:value, 比如一个最常用场景,为Pod标记如下Annotation以吿知Prometheus为其抓取系统指标。

annotations:    
    prometheus.io/path: /mymetrics
    prometheus.io/port: "7355"
    prometheus.io/scrape: "true"

Finalizer

如果只看社区实现,那麽该属性毫无存在感,因为在社区代码中,很少有对Finalizer的操作。但在企业化落地过程中,它是一个十分重要,值得重点强调的属性。因为Kubernetes不是一个独立存在的系统,它最终会跟企业资源和系统整合,这意味着Kubernetes会操作这些集羣外部资源或系统。试想一个场景,用户创建了一个Kubernetes对象,假设对应的控制器需要从外部系统获取资源,当用户删除该对象时,控制器接收到删除事件后,会尝试释放该资源。可是如果此时外部系统无法连通,并且同时控制器发生重启了会有何后果?该对象永远泄露了。

Finalizer本质上是一个资源锁,Kubernetes在接收到某对象的删除请求,会检查Finalizer是否为空,如果为空则只对其做逻辑删除,即只会更新对象中metadata.deletionTimestamp字段。具有Finalizer的对象,不会立刻删除,需等到Finalizer列表中所有字段被删除后,也就是该对象相关的所有外部资源已被删除,这个对象才会被最终被删除。

因此,如果控制器需要操作集羣外部资源,则一定要在操作外部资源之前为对象添加Finalizer,确保资源不会因对象删除而泄露。同时控制器需要监听对象的更新时间,当对象的deletionTimestamp不为空时,则处理对象删除逻辑,回收外部资源,并清空自己之前添加的Finalizer。

ResourceVersion

通常在多线程操作相同资源时,为保证实物的一致性,需要在对象进行访问时加锁,以确保在一个线程访问该对象时,其他线程无法修改该对象。排它锁的存在确保某一对象在同一时刻只有一个线程在修改,但其排它的特性会让其他线程等待锁,使得系统整体效率显著降低。

ResourceVersion可以被看做是一种乐观锁,每个对象在任意时刻都有其ResourceVersion,当Kubernetes对象被客户端读取以后,ResourceVersion信息也被一併读取。客户端更改对象并回写APIServer时,ResourceVersion会被增加,同时APIServer需要确保回写的版本比服务器端当前版本高,在回写成功后服务器端的版本会更新为新的ResourceVersion。因此当两个线程同时访问某对象时,假设它们获取的对象ResourceVersion为1。紧接着第一个线程修改了对象,资源版本会变为2,回写至APIServer以后,该对象服务器端ResourceVersion会被更新为2。此时如果第二个线程对该对象在1的版本基础上做了更改,回写APIServer时,所带的新的版本信息也为2,APIServer校验会发现第二个线程新写入的对象ResourceVersion与服务器端ResourceVersion衝突,写入失败,需要第二个线程读取最新版本重新更新。

此机制确保了分佈式系统中,任意多线程无锁併发访问对象,极大提升系统整体效率。

Spec和Status

Spec和Status才是对象的核心,Spec是用户的期望状态,由创建对象的用户端定义。Status是对象的实际状态,由对应的控制器收集实际状态并更新。与TypeMeta和Metadata等通用属性不同,Spec和Status是每个对象独有的,后续的章节会通过介绍一些核心对象来深入理解。

为方便对Kubernetes对象的理解,下图展示了按照业务目的归类的常用Kubernetes对象和其分组。Kubernetes对象设计完全遵循互补的原则。鼓励API对象儘量实现面向对象设计时的要求,即“高内聚,松耦合”,对业务相关的概念有一个合适的分解,提高分解出来的对象的可重用性。高层API对象设计一定是从业务出发的,低层API对象能够被高层API对象所使用,从而实现减少宂馀、提高重用性的目的。

核心对象概览

常用Kubernetes对象和其分组

核心对象概览

Kubernetes的对象设计避免了简单封装和内部隐藏机制。简单地封装,A对象封装了B对象的定义,实际没有提供新的功能,反而增加了对所封装API的依赖性。内部隐藏的机制也非常不利于系统维护的设计方式。例如StatefulSet、ReplicaSet和DaemonSet,如图所示,本来就是三种Pod集合,那麽Kubernetes就用不同API对象来定义它们,而不会説将它们封装在同一个资源对象,内部再通过特殊的隐藏算法再来区分这个资源对象是有状态的、无状态的还是节点服务。Pod是Kubernetes应用程序的基本执行单元,即它是Kubernetes对象模型中创建或部署的最小和最简单的单元。多数核心对象都为Pod对象服务的,但是它们都从Pod对象中所剥离出来的,有自己的API定义。Secret、ConfigMap和PVC是不同的资源对象定义,都可以作为存储卷在Pod中使用。而在Pod中使用时,只需要指定该对象的名称即可,无需将其具体信息在Pod资源对象中扩展。

核心对象关系图

Namespace

Namespace是Kubernetes进行归类的对象,当一个集羣有多个用户或一个用户有多个应用需要管理时,有时需要将所有被管理的对象进行一定的隔离。Kubernetes引入了Namespace对象,类似文件目录,不同对象被划分到不同Namespace以后,可以通过权限控制来限制哪些用户以何种权限访问哪些Namespace的哪些对象,进而构建一个多租户、彼此隔离的通用集羣。

Pod

容器云平台需要解决的最核心问题是应用运行,Kubernetes将容器化应用运行的实体抽象为Pod,Pod类似豆荚,它是一个或者多个容器镜像的组合。当应用启动以后,每一个容器镜像对应一组进程,而同一个Pod的所有容器中的进程默认公用同一网络Namespace,并且共用同一网络标识。Pod具有基本的自恢复能力,当某个副本出现问题时,它会按照预定策略被重启。

当然,应用运行通常需要配置文件,这些配置文件又有可以明文读写的配置,也包含需要加密和严格权限控制的密码证书等配置,Kubernetes为这些配置分别定义了Configmap和Secret。Configmap和Secret,和PersistVolumeClaim类似,都可以作为卷加载给运行的Pod,Pod中运行的进程可以像访问本地文件一样访问它们。Configmap和Secret没有本质区别,Secret只是将内容进行base64编码,我们知道base64编码是一种对称加密,可以轻鬆解密,事实上没有太多安全性可言。但Kubneretes支持Secret在持久化时的加密存储,这样保存在硬盘的Secret数据是无法解密的。其次,Kubernetes可以通过权限严格控制能够访问Secret的用户,以保证密码和证书信息的安全。

Pod除了包含用户希望运行的容器镜像和配置文件,还允许用户定义其运行所需的资源,用户创建Pod以后,Kubernetes会为其选择一个最佳节点运行。计算节点被抽象成Node对象,节点数量和每个节点的资源彙总起来就是整个集羣能提供的算力。每个计算节点负责彙报自己的心跳信息,并上报节点的资源总量和可用资源。

ServiceAccount

Pod中运行的进程有时需要与Kubernetes API通信,在启用了安全配置的集羣后,Pod一定要以某种身份与Kubernetes通信,这个身份就是系统账户(ServiceAccount)。Kubernetes会默认为每个Namespace创建一个default ServiceAccount,并且为每个ServiceAccount生成一个JWT Token,这个Token保存在Secret中。用户可以在其Pod定义中指定ServiceAccount(默认为default),其对应的Token会被挂载在Pod中,Pod中的进程可以带着该Token与Kubernetes通信以标识其身份。

ReplicaSet

Pod只是单个应用实例的抽象,要构建高可用应用,通常需要构建多个同样的副本,提供同一个服务。Kubernetes为此抽象出副本集ReplicaSet,其允许用户定义Pod的副本数,每一个Pod都会被当作一个无状态的成员管理,Kubernetes保证总是有用户期望的数量的Pod正常运行。当某个副本宕机以后,控制器将会创建一个新的副本。当因业务负载发生变更而需要调整扩缩容时,可以方便地调整副本数量。

Deployment

对于无状态在线应用,Kubernetes提供了更高级的版本变更控制。版本变更是一个日常频繁发生的关键操作,如何在不中断业务的前提下更新版本,一直是业界努力解决的问题。Deployment就是一个用来描述发佈过程的对象,其实现机制是,当某个应用有新版本发佈时,Deployment会同时操作两个版本的ReplicaSet。其内置多种滚动升级策略,会按照既定策略降低老版本的Pod数量,同时创建新版本的Pod,并且总是保证正在运行的Pod总数与用户期望副本数一致,并依次将该Deployment中的所有副本都更新至新版本。下图展示了基于Deployment进行版本发佈的一箇中间状态。

Deployment的滚动升级

因为Deployment会维护ReplicaSet,ReplicaSet会创建Pod,因此通过Deployment维护针对无状态的应用是第一选择,它可以满足诸多需求,缩短应用上线的时间,在不造成停机的情况下创建弹性部署,能够使用户更快或更频繁地发佈应用和功能。

  • 创建并保证目标数量的Pod在运行状态。

  • 按既定策略滚动升级,同时支持升级暂停、恢复和回滚。选择滚动升级策略非常灵活,正确的策略对于交付弹性应用程序和基础架构都是至关重要的。

  • 便利的扩容和缩容。

Service和Ingress

即使在传统平台中,为支持应用的高可用,都需要在应用实例之上构建负载均衡。Service和Ingress就是描述负载均衡配置的对象,它允许用户定义发佈服务的协议和端口,并定义Selector选择后端服务的Pod。Selector本身是一个Label过滤器,它会选择所有Label与该Selector匹配的Pod作为目标。Kubernetes会为Service和其选择出来的Pod创建一个关联对象,Endpoint里面记录了所有Pod的IP,以及就绪状态,这些信息会被相应组件作为期望状态进行负载均衡配置。Ingress是在服务的基础上,定义API网关的对象。通过Ingress,用户可以定义七层转发规则、网关证书等高级路由功能。

PersistentVolume和PersistentVolumeClaim

PersistentVolume(PV)是集羣中的一块存储卷,可由管理员手动设置,或当用户创建PersistentVolumeClaim(PVC)时根据StorageClass动态设置。PV和PVC与Pod生命週期无关。也就是説当Pod中的容器重新启动、Pod重新调度或者删除时,PV和PVC不会受到影响,Pod存储于PV里的数据得以保留。对于不同的使用场景,用户通常需要不同属性(例如性能、访问模式等)的PV。所以集羣一般需要提供各种类型的PV,由StorageClass来区分。一般集羣环境都设置了默认的StorageClass。如果在PersistentVolumeClaim中未指定StorageClass,则使用羣集的默认StorageClass。

CustomResourceDefinition

自定义资源定义(CRD)是Kubernetes 1.7中引入的一项强大功能,它允许用户将自己的自定义对象添加到Kubernetes集羣中,当创建新CRD的定义时,APIServer将为指定的每个版本创建一个新的RESTful资源路径。当集羣中成功地创建了CRD,就可以像Kubernetes原生的资源一样使用它,利用Kubernetes的所有功能,例如其CLI、安全性、API服务、RBAC等。CRD的定义是集羣范围内的,CRD的资源对象的作用域可以是命名空间(Namespaced)或者集羣范围(Cluster-wide)的。与现有的内置对象一样,删除Namespace也会删除该Namespace中所有自定义的对象,但不会删除CRD的定义。Kubernetes还提供一系列Codegen工具(deepcopy-gen、client-gen、lister-gen、informer-gen等),能够自动生成该CRD资源的Golang版本的Clientset、Lister及Informer,这为该资源编写控制器提供了很大便利。

CRD就像数据库的开放式表结构,允许用户自定义Schema。有了这种开放式设计,使得用户可以基于CRD定义一切需要的模型,满足不同业务的需求。社区鼓励基于CRD的业务抽象,众多主流的扩展应用都是基于CRD构建的,比如Istio,比如Knative。甚至基于CRD推出了Operator Mode和Operator SDK,可以以极低的开发成本定义新对象,并构建新对象的控制器。

控制器模式

声明式系统的工作原理是什麽?当用户定义了对象的期望状态,Kubernetes通过何种机制确保实际状态与期望状态最终保持一致?定义瞭如此多的对象,那麽这些对象是如何联动起来,完成一个个业务流的呢?祕密就是控制器模式,Kubernetes定义了一系列的控制器,事实上几乎所有的Kubernetes对象都被一个或数个控制器监听,当对象发生变化时,控制器会捕获对象变化并完成配置操作。

Kubernetes的功能组件会在后面章节中展开,但本节深入理解控制器模式有助于理解Kubernetes的运作机制。APIServer是Kubernetes的大脑,保存了所有对象和其状态。开源项目client-go对控制器的编写提供了完备的自动化支持,任何Kubernetes对象都可以由client-go创建供控制器使用的Informer()和Lister()接口。如图所示,控制器的工作流程就是围绕着Informer()和Lister()的。

  • Informer()是用来接收资源对象的变化的Event,针对Add、Update和Delete的事件,可注册相应的EventHandler。在EventHandler内,根据传入的object调用controller.KeyFunc计算出字符串key,并把它加入控制器的队列中。

  • Lister()是给控制器提供主动查询资源对象的接口,根据labels.Selector去指定筛选条件。

控制器模式是一个标准的生产者消费者模式,一方面控制器在启动后,Informer会监听其所关注的对象变化。一旦对象发生了创建,更新和删除等事件,这些事件会由核心组件APIServer推送给控制器。控制器会将对象保存在本地缓存,并将对象的主键推送至消息队列,此为生产者。

另一方面,控制器会启动多个工作子线程(Worker),从队列中依次获取对象主键,并从缓存中读取完整状态,按照期望状态完成配置更改并将最终状态回写至APIServer,此为消费者。

Kubernetes就是基于此模式保证了整个系统的最终一致性。

控制器工作流程

Kubernetes运行一组控制器,以使资源的当前状态与所需状态保持匹配。基于事件的体系结构,控制器利用事件去触发相应的自定义代码,这部分都是由SharedInformer完成。例如创建Deployment的控制器,其核心代码如下:

kubeInformerFactory := kubeinformers.NewSharedInformerFactory(kubeClient, resyncPeriod)
deploymentInformer := kubeInformerFactory.Apps().V1().Deployments()
deploymentInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
  AddFunc: controller.handleObject,
  UpdateFunc: func(old, new interface{}) {
     newDepl := new.(*appsv1.Deployment)
     oldDepl := old.(*appsv1.Deployment)
     if newDepl.ResourceVersion == oldDepl.ResourceVersion {
        return
     }
     controller.handleObject(new)
  },
  DeleteFunc: controller.handleObject,
})
kubeInformerFactory.Start(stopCh)

具体地,如图所示,SharedInformer有Reflector、Informer、Indexer和Store四个组件。

inform 内部机制

Reflector是用来监听特定的Kubernetes API资源对象,可以是Kubernetes内建的或者是自定义的资源。具体的实现是通过ListAndWatch的方法。Reflector首先会将资源版本号设置为0,使用List操作获得指定资源对象,可能会导致本地的缓存相对于etcd里面的内容存在延迟。Reflector再通过Watch操作监听到APIServer处资源对象的版本号变化,并将最新的数据放入到Delta FIFO队列中,使得本地的缓存数据与etcd的数据保持一致。如果resyncPeriod不为零,那麽Reflector会以resyncPeriod为週期定期执行Delta FIFO的Resync函数,这样就可以使Informer定期处理所有的对象。

Informer是从Delta FIFO队列中弹出对象,一方面将对象存入本地存储以供检索,另一方面触发事件以调用资源事件回调函数。控制器后续的典型模式是获取资源对象的key,并将该key排入工作队列以进行进一步处理。Indexer提供对象的索引功能。

Indexer可以根据多个索引函数维护索引。Indexer使用线程安全的数据存储来存储对象及其键。在Store中定义了一个名为MetaNamespaceKeyFunc的默认函数,该函数生成对象的键的格式是/的组合。

控制器的协同工作原理

单个Kubernetes资源对象的变更,触发多个控制器对该资源对象的变更进行响应,继而还能引发其相关的其他对象发生变更,从而触发其他对象控制器的配置逻辑,这一模式使得整个系统成为声明式。下图简要描述了用户创建一个Deployment对象时各个控制器是如何协同工作的。

协同工作流程示例

除APIServer和etcd外,所有Kubernetes组件,不论其名称是Scheduler,Controller Manager、或是Kubelet,其本质都是一致的,都可以被称为控制器,因为这些组件中都有一个控制循环。他们监听APIServer中的对象变更,并在自己关注的对象发生变更后完成既定的逻辑控制,并将控制逻辑执行完成后的结果更新回APIServer,并持久化到etcd中。

APIServer作为集羣的API网关,接收所有来自用户的请求。用户发创建Deployment之后,该请求被髮送至APIServer,经过认证鑑权和准入三个环节,该Deployment对象被保存至etcd。

Controller Manager中的Deployment Controller监听APIServer中所有Deployment的变更事件,此时其捕获了Deployment的创建事件,并开始执行控制逻辑。Deployment Controller读取Deployment对象的Selector定义,并通过该属性过滤当前Namespace中所有ReplicaSet对象,并判断是否有任何ReplicaSet对象的OwnerReference属性为此Deployment。因为此Deployment刚刚创建,因此没有满足此查询条件的ReplicaSet,于是Deployment Controller会读取Deployment中定义的podTemplate,并将其做哈希计算,并依照如下约定创建新的ReplicaSet:

  • 创建新的ReplicaSet,将其命名为[deployment-name]-[pod-template-hash]。

  • 更新ReplicaSet,为ReplicaSet添加label,记pod-template-hash值为[计算出的哈希值]。

  • 将Deployment设置为ReplicaSet的OwnerReference。

Deployment Controller将新的ReplicaSet创建请求发送至APIServer,APIServer同样的经过认证授权和准入步骤,将该对象保存至etcd。

ReplicaSet Controller监听APIServer中所有ReplicaSet对象的变更,新对象的创建令其唤醒并开始执行控制逻辑。ReplicaSet Controller读取ReplicaSet对象的Selector定义,并通过该属性过滤当前Namespace中所有Pod对象,并判断是否有任何Pod对象的OwnerReference为该ReplicaSet。因为此ReplicaSet刚刚创建,因此没有满足此查询条件的Pod,于是ReplicaSet会按照如下约定创建Pod:

  • 读取Replicas定义,Replicas的数量代表需要创建Pod的数量。

  • 以ReplicaSet名作为Pod的GenerateName,该属性会作为Pod名的前缀,Kubernetes在此基础上加一个随机字符串作为Pod名。

  • 该ReplicaSet作为Pod的OwnerReference。

ReplicaSet Controller将新建Pod的请求发送至APIServer,APIServer将Pod悉数保存。

此时调度器被唤醒,其监听APIServer中所有nodeName为空的Pod,即未经过调度的Pod。经过一系列的调度算法,不满足Pod需求的节点被过滤,符合的节点按照空閒资源,端口占用情况,实际资源利用率等信息被排序,评分最高的节点名被更新至nodeName属性,该同样经APIServer保存至etcd。

最后,运行在Pod被调度节点的Kubelet监听到有归属于自己节点的新Pod,则开始加载Pod清单,下载Pod所需的配置信息,调用容器运行时接口启动容器,调用容器网络接口加载网络,调用容器存储接口挂载存储,并完成Pod的启动。

Kubernetes就是依靠这样的联动机制,通过分散的业务控制逻辑满足用户需求。从用户的角度看,只是发送了一个Deployment创建请求,但事实上,为满足该需求,可能会牵扯到数个甚至更多Kubernetes组件。此架构模式的优势是每个组件各司其职,巧妙而灵活,代码易维护,但带来的运维複杂度相对较高,此业务流中有任何组件出现故障,对用户感受来讲,都是Kubernetes不可用。

有疑问加站长微信联系(非本文作者)