毫无疑问,声明式API以及Controller机制是Kubernetes设计理念的基础。Controller不断从API Server同步资源对象的期望状态并且在资源对象的期望状态和实际运行状态之间进行调谐,从而实现两者的最终一致性。Kubernetes系统中的各种组件,包括Scheduler,Kubelet以及各种资源对象的Controller都以这种统一的模式运行着。

虽然Kubernetes已经内置了Deployment,StatefulSet,Job等丰富的编排对象,但是在落地过程中,面对纷繁复杂的应用场景,尤其是针对Etcd,Prometheus等配置复杂的有状态应用,现有的编排对象依然显得捉襟见肘。所幸的是,Kubernetes在v1.7引入了CRD,允许用户自定义资源对象并且可以像原生对象一样操作它们。因此对于复杂的应用,我们完全可以自定义编排对象以及相应的Controller进行配置管理。

同时,声明式API的设计也让其他系统与Kubernetes的集成变得更为容易。例如,当使用Prometheus对Kubernetes系统进行监控,并开启抓取对象的自动发现时,Prometheus本质上也是一个Controller,它会不断地从API Server同步集群中所有的Pod,Service和Node信息并从中筛选出抓取对象进行抓取。

可见,Controller在Kubernetes生态系统中无处不在。事实上,Controller的执行框架都是类似的,Kubernetes社区也贴心地将通用的代码进行了抽象,封装了在client-go这个包里。手写Controller的难度也瞬间下降到了在代码中引入client-go包并且在适当的位置实现业务逻辑这样的"体力活"。话虽如此,但是对Controller执行框架的深入理解,对于更好地编写Controller乃至更好地理解Kubernetes都将是不可或缺的,而这也是本文叙述的重点。

1. Controller实现概述

不论对于同步资源对象的真正意义上的Controller,还是类似于Prometheus仅仅全量获取资源对象进行服务发现的广义的Controller。从API Server持续同步资源对象并对其进行处理是所有Controller都相同的执行框架。乍一看,这个问题并不复杂,我们甚至可以写一个脚本对API Server进行轮询,每次全量获取资源对象并逐个处理,似乎也能满足要求。

当然,在现实条件下,上述朴素的方法显然是不行的。从上文的分析可知,Controller是普遍存在的,而且资源对象之间往往存在关联关系,例如Deployment和Pod,Service和Endpoint,因此一个Controller通常需要对多种资源对象进行同步。因此,如此大量的轮询操作,对于API Server来说是无法接受的。

对于这个问题,Kubernetes社区的解法是List & Watch。首先利用List接口从API Server中获取资源对象的全量数据并存储在缓存中,之后再利用Watch接口对API Server进行监听并且以增量事件的方式持续接收资源对象的变更。一方面Controller可以对这些资源对象的增量变更事件进行即时处理,另一方面,也可对缓存进行更新,保证缓存与API Server中的源数据保持最终的一致性。

落实到具体的实现,通用的Controller架构如下图所示:

上图中有一条虚线体贴地将整张架构图切割为成了两部分:图的上半部分是client-go封装的与API Server的底层交互逻辑;下半部分则是Controller的业务代码,主要是针对资源对象增量事件的处理。接下来,本文将以资源对象的增量事件从API Server传输到Controller之后,在架构中的流转作为顺序,依次说明各个组件所起的作用。

2. Reflector & Delta FIFO

Reflector负责对特定的资源对象进行监听并且将资源对象的所有变更推送到Delta FIFO这个队列中。首先,Reflector会利用特定资源对象的client向API Server发起List操作,获取该资源对象的全量信息并将它们推送到Delta FIFO中。List本质上就是一个HTTP请求,其Request/Response类似的形式如下:

 GET /api/v1/namespaces/test/pods
---
200 OK
Content-Type: application/json
{
"kind": "PodList",
"apiVersion": "v1",
"metadata": {"resourceVersion":"10245"},
"items": [...]
}

List操作相当于对API Server中指定资源对象的内容作了一个快照,返回的元数据当中的ResourceVersion用于标记该快照。ResourceVersion本质上是资源对象在底层数据库中的版本号,它会随着资源对象的变更而不断累加。之后我们只要持续不断地利用Watch从API Server中获取资源对象的所有变更事件用于更新Controller中的缓存,那么缓存中的数据就可以认为与API Server中的数据是一致的(当然,最终还是与etcd中的数据一致)。Watch本质上也是一个HTTP请求,不过它是一个长连接,会不断地从服务器端获取数据,具体形式如下:

GET /api/v1/namespaces/test/pods?watch=1&resourceVersion=10245
---
200 OK
Transfer-Encoding: chunked
Content-Type: application/json
{
"type": "ADDED",
"object": {"kind": "Pod", "apiVersion": "v1", "metadata": {"resourceVersion": "10596", ...}, ...}
}
{
"type": "MODIFIED",
"object": {"kind": "Pod", "apiVersion": "v1", "metadata": {"resourceVersion": "11020", ...}, ...}
}
...

Watch会持续地返回资源对象的变更事件,"type"字段指定了事件的类型,主要类型如下:

  • Added:新的资源对象实例,例如:新增一个Pod
  • Modified:资源对象实例更新,例如:更新一个Pod
  • Deleted:资源对象实例删除,例如:删除一个Pod

"object"字段则包含了对应资源对象实例的全部信息。Reflector维护了一个lastSyncResourceVersion字段,最初由List返回结果中的ResourcVersion初始化,之后每次从Watch接收到一个事件,都会利用该事件中包含对象的ResourceVersion更新lastSyncResourceVersion。实际上,现实世界里网络是不可能永远稳定的,Watch底层的连接也随时可能会断开。一旦,Watch断开,Reflector就会以lastSyncResourceVersion为起点重新开始对API Server进行监听,从而既不会遗漏也不会重复接受资源对象的变更事件,保证了缓存数据与API Sever中原始数据的一致性。

Delta FIFO是一个先进先出队列,其中缓存了资源对象的变更事件,每一个事件即一个如下所示的Delta结构:

type Delta struct {
Type DeltaType // Added, Updated, Deleted, Sync
Object interface{}
}

其中Type是事件的类型,Object则是变更发生后,资源对象的状态。

Delta FIFO也是一个典型的生产者-消费者模型,Reflector就是生产者,它通过List&Watch从API Server获取相应的事件并根据事件的类型转换为Delta结构并放入队列中。而如下所示的Delta FIFO的Pop方法就是该队列的消费者:

type PopProcessFunc func(interface{}) error

func (f *DeltaFIFO) Pop(process PopProcessFunc) (interface{}, error)

Pop方法会持续地队列进行监听,一旦队列中存在"消费品"时,Pop方法就会将其出队并交由类型为PopProcessFunc的处理函数进行处理。这里需要注意的是,Pop方法每次从队列中弹出的并不是某个资源的单个事件,而是该资源在队列期间按发生的先后排好序的所有变更事件的集合。从而对于每个资源对象实例,你只需要处理它一次并且每次处理你都能看到从上次处理它以来,发生在它身上所有的变更。

3. Informer

从上文的架构图可以看到,Informer这个结构处于整个架构的中心,简单地说,它有以下三个作用:

  • 接收Delta FIFO的Pop方法弹出的资源对象的一系列变更事件并根据事件的类型进行处理
  • 根据资源对象的变更事件更新本地缓存,即Indexer
  • 将资源对象的变更事件分发给已经在Informer中注册的各个事件变更类型的Handler进行处理

如果你仔细研究过client-go的源码,那你一定感受过被Index支配的恐惧:IndexIndexersIndices以及IndexFunc这几种名字看起来非常相似但是作用却完全不同的自定义类型一定让你感到晕头转向。事实上,Indexer仅仅是只是在最简单的缓存之上封装了一个索引的功能。

client-go实现缓存的方法很简单,就是golang原生的Map加上一个读写锁,也就是上面架构图中的Thread Safe Store。其中Map的Value是具体的资源对象实例,例如PodA,而Map的Key的形式一般为Namespace/Name,例如对于NamespaceA下的PodA,它的Key即为NamespaceA/PodA,对于类似于节点这种不属于任何Namespace的资源对象,则Key直接为Name

当监听的资源对象是Pod时,获取某个Namespace之下的所有Pod,或者获取和某个Pod处于同一个Namaspace下的所有Pod等等,都是常见的需求。但是如果没有索引的话,我们每次都要遍历Map,从中筛选出符合要求的资源对象实例,这种方式显然是不能满足要求的。

因此,缓存Thread Safe Store在真正实现时,对应到具体的数据结构如下:

type threadSafeMap struct {
lock sync.RWMutex
items map[string]interface{} indexers Indexers
indices Indices
} type IndexFunc func(obj interface{}) ([]string, error)
type Indexers map[string]IndexFunc type Index map[string]sets.String
type Indices map[string]Index

可以看到,在实现加锁Map的结构以外还增加了一个类型为IndexersIndices的字段,那么这两个字段的作用又是什么呢?对于一组资源对象,我们可以从很多的维度构建索引,而Indexers其实是一组索引生成函数,每个键值对都代表不同的索引维度,最常见的自然是基于Namespace进行索引,此时Indexers的Key就是字符串namespace,而Value则为函数MetaNamespaceIndexFunc,它的参数是任意的资源对象,返回的则是该资源对象所处的Namespace。而Indices则是一个存储索引的结构。类似地,它的Key也是构建索引的维度,例如此处的namespace,而Index则是真正存储索引的结构,Key为某个索引值,Value则是与该索引值匹配的资源对象实例的Key。下面我们用一个例子对上述机制进行更为直观的说明。

例如,当NamespaceA下新增了一个PodA时,需要根据该事件更新缓存。threadSafeMap首先计算出这个Pod在items中存储的键值,即NamespaceA/PodA,接下来还需要为该Pod建立各个维度的索引,此时我们遍历Indexers调用各个维度的索引构造函数,例如对于namespace这个维度,索引构造函数得到PodA对应的索引值是NamespaceA,然后我们再用这一结果更新Indices,即threadSafeMap.indices["namespace"]["NamespaceA"] = sets.String{..., "NamespaceA/PodA"}。之后如果我们再想通过缓存获取NamespaceA下的所有Pod信息,那么最终利用的是threadSafeMap.indices["namespace"]["NamespaceA"]得到一系列的键值,再利用这些键值从threadSafeMap.items得到相关的Pod信息。索引建立之后,筛选特定的资源对象就不再需要使用遍历缓存这种原始而低效的方法了。

Controller基于的是一种面向事件的编程模型,因此Controller中业务逻辑的实现本质上是对与资源对象变更事件的处理。因此,通常需要向Informer注册针对变更事件的处理函数,分别对资源对象的增加(Add),修改(Update)以及删除(Delete)进行处理。当Informer从Delta FIFO弹出Delta时,它会根据Delta的类型,将Delta中的对象交由对应变更类型的事件处理函数进行处理。

上述即是client-go对于Controller通用逻辑的封装,概括地说,就是Reflector利用List & Watch从API Server得到关于特定资源对象的变更事件,Informer再利用这些事件更新缓存并且用注册的事件处理函数对各个事件进行处理。

client-go对Controller中的通用逻辑进行了封装,本质上,它为我们提供了两组接口:一组是特定资源对象的缓存,即上文中的Indexer,通过它可以知道对应资源对象所有实例的状态;另一组就是我们可以向Informer注册的事件处理函数,一旦某个资源对象实例发生变更,相应的事件处理函数就会被调用。

4. Controller中业务逻辑的实现

通过上文的分析可知,对于Controller的实现者来说,他所要做的仅仅是将业务逻辑封装在资源对象变更事件的处理函数里并向Controller注册。从编码的角度来看,Controller开发者只要实现如下的接口即可:

type ResourceEventHandler interface {
OnAdd(obj interface{})
OnUpdate(oldObj, newObj interface{})
OnDelete(obj interface{})
}
// 实际向Informer注册的其实是`ResourceEventHandlerFuncs`,`ResourceEventHandlerFuncs`可以允许指定三种事件处理函数中的某几种,但依然确保能够实现接口`ResourceEventHandler`

每当相应的事件类型发生,对应的处理函数就会被调用。不过通常来说,用户并不会真的在事件处理函数里面实现自己的业务逻辑。例如,Kubelet本质也是一个Controller,它会对Pod进行监听并且注册相应的处理函数进行处理。当系统中新增一个Pod时,Kubelet显然不会直接在OnAdd()函数中直接就开始将这个新增的Pod实例化,因为这样的操作往往非常耗时,从而阻塞Informer对于事件的分发。

因此,事件处理函数中的逻辑往往非常简单。它会直接获得一个Key用于表示该对象,就像我们在上文的缓存中做的那样,例如NamespaceA下的PodA,它的Key就是NamespaceA/PodA,并将这个Key放入一个队列。同时,我们可以创建多个Goroutine从队列中获取Key,对发生变更的资源对象实例进行处理。那么如何从Key转换成具体的对象实例呢?显然缓存可以帮我们做到这一点。获取到实例之后,我们就可以从容地展开业务逻辑了。例如,对于Kubelet来说就可以用新的Pod配置去创建或者更新Pod实例了(如果在缓存中找不到Key对应的实例,则说明它已经被删除了)。可以发现,这部分叙述的内容和上文架构图虚线以下的部分是完全对应的。

5. 总结

Controller其根本的理念是非常简单的:实现实际状态和期望状态的最终一致性。如果API Server有着无限的并发能力,Controller的实现完全可以简单到直接通过轮询全量资源对象的目标状态,然后据此对实际状态进行调整。但实际情况是,API Server的并发能力是有限的,因此我们需要利用List & Watch进行缓存,以增量式地,面向事件的模式对资源对象的更新进行处理。所幸的是,社区对Controller实现框架中的通用部分进行了抽象封装,从而极大地减轻了Controller开发者的心智负担,可以更多地专注于业务逻辑的处理。

参考文献

Kubernetes Controller执行框架解析的更多相关文章

  1. 白话ASP.NET MVC之三:Controller是如何解析出来的

    我们在上一篇文章中介绍Controller激活系统中所涉及到的一些类型,比如有关Controller类型的相关定义类型就包括了IController类型,IAsyncController类型,Cont ...

  2. [转载]iOS 10 UserNotifications 框架解析

    活久见的重构 - iOS 10 UserNotifications 框架解析 TL;DR iOS 10 中以前杂乱的和通知相关的 API 都被统一了,现在开发者可以使用独立的 UserNotifica ...

  3. ABP使用及框架解析系列 - [Unit of Work part.1-概念及使用]

    前言 ABP ABP是“ASP.NET Boilerplate Project”的简称. ABP的官方网站:http://www.aspnetboilerplate.com ABP在Github上的开 ...

  4. ABP使用及框架解析系列 - [Unit of Work part.2-框架实现]

    前言 ABP ABP是“ASP.NET Boilerplate Project”的简称. ABP的官方网站:http://www.aspnetboilerplate.com ABP在Github上的开 ...

  5. kubernetes多节点部署解析

    注:以下操作均基于centos7系统. 安装ansible ansilbe可以通过yum或者pip安装,由于kubernetes-ansible用到了密码,故而还需要安装sshpass: pip in ...

  6. iOS 10 UserNotifications 框架解析

    摘自:https://onevcat.com/2016/08/notification/ iOS 10 中以前杂乱的和通知相关的 API 都被统一了,现在开发者可以使用独立的 UserNotifica ...

  7. Sword框架解析——知识采集流程页面初始化

    Sword框架解析——知识采集流程页面初始化 Sword框架解析知识采集流程页面初始化 问题解答流程采集新增页面初始化 1后台t_xt_gnzy表和BLH类 2BLH类的写法前台目录树代码 3登录系统 ...

  8. AlexeyAB DarkNet YOLOv3框架解析与应用实践(六)

    AlexeyAB DarkNet YOLOv3框架解析与应用实践(六) 1. Tiny Darknet 听过很多人谈论SqueezeNet. SqueezeNet很酷,但它只是优化参数计数.当大多数高 ...

  9. AlexeyAB DarkNet YOLOv3框架解析与应用实践(四)

    AlexeyAB DarkNet YOLOv3框架解析与应用实践(四) Nightmare 从前,在一所大学的大楼里,西蒙尼亚.维达第和齐瑟曼有一个很好的主意,几乎和你现在坐的大楼完全不同.他们想,嘿 ...

随机推荐

  1. BZOJ3329 Xorequ[数位DP+递推矩阵快速幂]

    数    位    D    P    开    long    long 首先第一问是转化. 于是就可以二进制下DP了. 第二问是递推,假设最后$n-1$个01位的填法设为$f[i-1]$(方案包括 ...

  2. 初识linux(简单命令)

    之前一直搞不懂,为什么全是命令行的linux系统这么多公司都在用,当你看不懂那一行行命令时你一定会和我一样觉得头大.但当你学习了命令再结合桌面版觉得linux还是挺不错的

  3. 剑指Offer-快速排序

    剑指Offer上的快速排序的Partition函数与我在数据结构书上学到的不一样,因此就想要探索下这两种不同的处理方式. 1.基本思想 快速排序的基本思想是基于分治法.在待排序表L[1...n]中任取 ...

  4. [深度学习] R-CNN系论文略读

    总结: 一.R-CNN 摘要: 在对象检测方面,其性能在前几年就达到了一个比较稳定的状态.性能最好的方法是一种复杂的整体系统,它将多个图片的低级特征通过上下文组合起来. 本文提出了一种简单.可扩展的算 ...

  5. HTML标签与属性

    HTML中常用的一些标签和属性. 1.lang属性:搜索引擎      en:英文,  zh:中文 2.meta:元数据     charset属性:字符集编码方式 3.h1~h6:标题   一级标题 ...

  6. laravel短信验证

    短信验证按钮倒计时功能: $('.btn').click(function(){ var time = 59; // $('.btn').css('background-color','#ccc'); ...

  7. linux中more命令如何使用

         more命令,功能类似 cat ,cat命令是整个文件的内容从上到下显示在屏幕上.兄弟连Linux培训教程() more会以一页一页的显示方便使用者逐页阅读,而最基本的指令就是按空白键(sp ...

  8. 计算几何 点对处理 #345 (Div. 2) C. Watchmen

    题目:给你n(<=2*1e5)个点,求其中有多少个点对之间的连线向量平行坐标轴: #include <iostream> #include <cstdio> #inclu ...

  9. [POI2011]DYN-Dynamite

    题目链接:Click here Solution: 直接做似乎不太可行,我们考虑二分 我们设\(f[x]\)表示以\(x\)为根的子树中选择了的节点到\(x\)的距离的最小值,初值为\(inf\) \ ...

  10. VMware Guest customization fails on Linux

    1.1  症状现象 登录Guest OS,在/var/log/vmware-imc/toolsDeployPkg.log文件中,您会看到以下条目: Customization command fail ...