1

K8S 控制器模式

 3 years ago
source link: https://studygolang.com/articles/33334
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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,其键应以如下形式存在:<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对象所使用,从而实现减少宂馀、提高重用性的目的。

yErMBz2.png!mobile

核心对象概览

常用Kubernetes对象和其分组

核心对象概览

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

fUvUZvj.png!mobile

核心对象关系图

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进行版本发佈的一箇中间状态。

eIrye2V.png!mobile

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就是基于此模式保证了整个系统的最终一致性。

AZr6Rra.png!mobile

控制器工作流程

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四个组件。

JZfE3aA.png!mobile

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的默认函数,该函数生成对象的键的格式是<namespace>/<name>的组合。

控制器的协同工作原理

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

QrIJZz.png!mobile

协同工作流程示例

除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不可用。

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

eUjI7rn.png!mobile

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK