-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontent.json
More file actions
1 lines (1 loc) · 772 KB
/
content.json
File metadata and controls
1 lines (1 loc) · 772 KB
1
{"posts":[{"title":"记录一次Ceph故障—数据平衡之殇","text":"存储是一个公司IT基础设施的重中之重,是最基础的组件,它的稳定保障着上层业务的平稳运行,也保障着很多人的幸福生活,尤其是运维的。 Ceph作为一个流行的开源分布式存储系统,逐渐进入到很多公司的数据中心,它的一个非常重要的特性就是数据动态平衡。大部分存储系统在数据写入后端存储设备后,很少再进行数据迁移,随着时间的推移,设备的不断上架下架,数据会逐渐变的不均衡。Ceph将数据打散,以Object的方式组织数据,然后通过CRUSH算法,计算数据的落位,数据在新旧设备替换时,在设备之间自动进行迁移,比较好的解决了数据不均衡的问题。 经常用到的数据平衡相关的参数主要就是设备的权重值了,即weight和reweight,前者是和磁盘容量大小有直接关系的权重值,一般1T的盘对应的设置的权重值大小为1,该值会参与到CRUSH算法中,去选取OSD,当选择出来OSD之后,还需要使用reweight进行一次过载测试,如果测试失败,则将拒绝该OSD,该值是一个[0,1]之间的数字,默认为1,即不进行过载测试,直接选中该OSD,如果为0,则直接拒绝该OSD,如果居中的话,则会根据哈希的结果,有一定的概率性是否选中该OSD。 本次故障,就是和这两个参数调整相关,因为客户将权重值设置不合理,引发了一系列问题,我觉得该问题挺经典的,也很少在生产中会遇到这个问题,所以有必要记录一下。 首先来看下故障截图吧: 集群中还有大量的快满的OSD: 懂行的同学,可能已经看出来,这个Ceph集群是真的已经故障了,而且还比较严重,具体来说有以下几点: 有部分pg处于stale以及inactive的状态,不能对外提供服务,所以有一部分业务IO卡住,其中包括客户的一些核心业务 有很多near full的osd,客户将full的值调到了99.5%,当前有大量osd都处于98%的状态 mon集群状态不正常,首先是63没有在集群中,客户说很早就down掉了,一直加不进去,其次是61和62都报store is too big,而且都有40G多的大小 数据分布不均衡,权重值比较混乱,客户在上周末的时候,因为看到集群快满,就扩了4台节点进去,为了尽快让其迁移数据,将上面1.6T的盘的权重值调成了10,并且有的osd,为了尽快迁出使用率高的盘的数据,将其权重设置的非常小,只有0.1,而且有的osd调的weight,有的调的是reweight,而且大部分调的是reweight值。 虽然现在的Ceph状态很危险,但是并没有完全故障,只是影响了一部分业务,部分业务还在正常运行,而且还跑着几个核心系统,不能停,客户非常着急,希望尽快帮忙恢复业务,同时,业务部门已经在着手将数据迁移出来。 经过一番商量,我们决定采用下面的方案进行恢复: 1. 首先要解决mon的问题因为mon store现在有40G的数据,导致mon的响应非常慢,mon如果不正常的话,会影响后续的操作,而且因为63长期不在quorum中,并且当前集群处在一个非常不健康的状态,有大量的pg在backfill/recovery的状态,会导致mon store的数据增长很快,63基本上是加不进来的,因为63启动的时候需要去leader那里同步数据,同步数据的速度慢于数据增长的速度,导致数据同步不完,无法启动。目前唯一的解决办法就是停掉61和62,进行离线压缩的方法,因为离线压缩速度很快: 12ceph-monstore-tool /var/lib/ceph/mon/ceph-server-61 compactceph-monstore-tool /var/lib/ceph/mon/ceph-server-62 compact 使用该命令需要安装ceph-test包,因为数据量很大,为了防止互相同步数据,我们将mon同时停掉,然后进行压缩,再启动。关于压缩mon的数据,参考这里的文档: https://access.redhat.com/documentation/en-us/red_hat_ceph_storage/3/html/troubleshooting_guide/troubleshooting-monitors#compacting-the-monitor-store https://access.redhat.com/solutions/1982273 mon状态的正常非常关键,这里store too big,超过了40G,除了命令执行慢之外,还可能会导致osd down,甚至重启osd卡在booting的阶段。因为集群处在不健康的状态,mon store的数据量增长很快,所以当观察到增长到一定程度的话,就需要再次压缩,直到集群状态恢复正常,mon store的增长速度也恢复正常。 关于停止所有的mon对业务的影响是,对已经跟osd建立连接的客户端其IO是不受影响的,即现有虚拟机的业务不受影响,但是集群不能接受新的IO连接,比如新建卷,重启虚拟机,卸载盘等操作,风险就是当这个时候有osd down的话,那么也会影响该osd上的客户端。 2. 新建replica-domain,迁移数据到新的replica-domain里客户的故障域模式是这样的: 12345failure-domain sa01 replica-domain replica-sa01 osd-domain osd-group-sa-rack_7 osd-domain osd-group-sa-rack_8 osd-domain osd-group-sa-rack_9 客户受影响的failure-domain里,只有一个replica-domain,叫sa01,这一个replica-domain下的3个osd-domain下,分别有200个左右的osd,故障域非常的大。周末扩容的节点分别加到了这3个osd-domain下,其上已经同步了一小部分数据,原有的osd,有部分使用率达到了98%,20个左右的osd使用率在90%以上。 因此第二步就是要处理高使用率的osd,以防其写满,造成整个集群的故障,因为其原有replica-domain的权重值非常混乱,所以我们将新扩容的节点单独划成一个replica-domain,迁移一部分osd到新的replica-domain上,将其reweight值都设置回1,weight值设置为正常水平,然后让其开始同步数据。设置为新的replica-domain之后的故障域是这样的: 123456789failure-domain sa01 replica-domain replica-sa01 osd-domain osd-group-sa-rack_7 osd-domain osd-group-sa-rack_8 osd-domain osd-group-sa-rack_9 replica-domain replica-sa02 osd-domain osd-group-sa-rack_a osd-domain osd-group-sa-rack_b osd-domain osd-group-sa-rack_c 注意,尽量保证三个osd-domain的权重值是一致的,否则可能会出现pg选不出来osd的情况。该步骤中的最关键点,是如何确保98%的osd不会继续增长,因此需要给每一个OSD设置一个backfill full的值,将其设置为90%: 1ceph tell osd.* injectargs --osd_backfill_full_ratio=0.9 该参数的作用是在osd的full比例超过该值时,该osd将会拒绝接受backfill,也即其上的数据不会再接受新平衡数据的迁入,由于客户参数设置错误,将该值设置为了0.995,即99.5%,导致osd的使用率一直涨到了98%,部分涨到99%的osd,被out了出去。设置了该值之后,再平衡数据,将不会往超过90%使用率的osd上迁入数据,保证了其安全性。 3. 处理故障的pg从ceph的状态中,可以看到,有一部分的pg处于stale/down/peering等状态,这部分异常的pg不能提供对外提供服务,影响了业务的可用性,通过ceph health detail找到这部分异常的pg,发现其中有一些pg的upset中都没有映射到osd,或者三副本只选出来2个osd,没有选出来第3个osd,下面是当时故障的pg的状态: 这个现象很有可能是权重不平衡导致的,关于权重在0.94.7版本的ceph中,有两个参数,一个是weight,一个是reweight,weight会参与crush算法,计算出要落位osd,然后reweight是在此基础上再去决定是否选择此osd,但是reweight不会参与crush算法,crush算法本质上是一个概率算法,因此当权重相差悬殊的时候,很有可能选不出来osd,客户环境中部分osd的reweight设置成了0.09,有将近一半的osd都将reweight设置成了小于1的值,这就有可能导致pg出现异常,从而选不出osd。因此尝试将故障pg对应的osd的reweight重置为1: 123ceph health detail | grep staleceph pg <pgid> queryceph osd reweight <id> 1 置为1之后,观察到该pg重新映射出了osd,并且消除了stale状态,恢复了服务。因为reweight的不确定性,我们调整权重,一般不调整reweight,让它始终保持为1,在L版之前的ceph中,需要通过调整weight值进行数据平衡,L版之后新增了weight-set功能,可以更有效的去平衡数据。 此时,可以将所有reweight不为1的osd重置为1: 1for i in `cat reweight_osds.txt`; do setsid ceph osd reweight $i 1; done 重置为1之后,stale的pg全部恢复了正常,业务也恢复了正常。 4. 数据重平衡后续需要做的操作就是继续平衡数据,但是要保持各个osd-domain的权重值大小一致,然后可以微调osd的weight值,将一个osd-domain中高使用率的调低,同时也要将另一个osd-domain中低使用率的调高,平衡数据,直到各个osd的使用率趋于均衡。 5. 恢复mon服务等待数据平衡完成之后,压缩61 62的mon服务,然后启动,再将63加进集群。 至此故障处理完成,所以最终总结一下,引起该故障的本质原因,在于调整数据均衡的方式不对,权重调整的幅度过于大,不同osd之间的权重相差悬殊,导致pg出现了问题,进而引发了后续的一系列问题。因此,关于权重值需要关注以下几点: 保持各个bucket的故障域的权重是相等的,bucket里面的osd权重值可以不一致,但是osd上的权重值得保持相等,扩容/缩容,都需要考虑这个问题 weight不要设置过大与过小,需要跟它的实际容量保持一致 尽量不调整reweight值,即使调整,也是微调","link":"/2019/11/17/ceph/cephrebalance.html"},{"title":"Elasticsearch大文本字段(large text field)优化方案","text":"背景在全文检索的场景下,经常会有text类型的字段存储的数据量比较大,比如一个pdf文档或者是一本书的内容,可能会有几兆,几十兆,上百兆的大小,单个字段的内容过大,会对集群性能以及稳定性造成比较大的影响,因此本文档针对该场景给出优化建议。 优化点尽量避免大文本字段首先应该尽量避免大文本字段,大文本字段会给磁盘、网络、CPU带来较大的压力,并且不能够高效利用文件系统缓存,会对查询以及高亮的性能带来较大影响。通常的建议是在应用侧能够将大文本拆分成小文本,比如要索引一本书的内容,不要把整本书的内容全放到一个doc的text字段中,而是可以按照书的章节或者是段落进行划分,每个doc存放书的一部分,然后在每个doc中添加一个字段,标志该doc属于哪本书即可。这样虽然增加了应用层的逻辑,但是会给ES的性能以及稳定性带来较大的提升。 Elasticsearch在text类型上是没有强制大小限制的,但是在http请求上,默认是有100M的大小限制的,由参数 http.max_content_length 控制,超过该限制,则索引会失败,但即使该参数可调无限大,在Lucene层面也有2GB的大小限制。 根据经验,将单文档大小,或者说text字段的大小,维持在1-2MB,是比较推荐的。 参考资料: https://www.elastic.co/guide/en/elasticsearch/reference/current/general-recommendations.html https://discuss.elastic.co/t/how-big-a-field-can-be/158810 尽量避免返回全文档默认情况下,Elasticsearch mapping中定义的字段只会索引数据,而不会存储原始数据,原始数据是存放在 _source 字段中的。在查询时,原始数据都会放在 _source 字段返回,每次查询,都会从磁盘上读取 _source 字段的内容,将它解析成json格式的数据,所以当text字段的数据量很大时,这个过程会给CPU(解析一个很大的Json结构的数据)、磁盘(读取很多数据)、网络(传输大量数据)带来很大压力,并且也会导致文件系统缓存没法高效利用。因此可以考虑只获取 _source 字段的部分字段,只返回有需要的数据,可以采取以下几种方法达到该目的: 1. 使用 fields 选项返回特定字段在search时,可以使用fields选项指定特定的字段返回,比如一个文档有author, title, content三个字段,content是text类型,存储的数据量较大,在search时,不想返回该字段,则可以这样写: 12345678910GET my-index-000004/_search{ "_source": false, "query": { "match": { "content": "elasticsearch" } }, "fields": ["author", "title"]} 这样,在返回值中,就只有author 和 title字段了: 12345678910111213141516"hits" : [ { "_index" : "my-index-000004", "_type" : "_doc", "_id" : "1", "_score" : 0.57344866, "fields" : { "title" : [ "fooooo" ], "author" : [ "guangyu" ] } }] 2. 使用 source filtering可以使用 _source 选项指定 _source 字段中包含的字段,即只返回一个子集,比如: 123456789GET my-index-000004/_search{ "_source": ["author", "title"], "query": { "match": { "content": "elasticsearch" } }} 返回值: 123456789GET my-index-000004/_search{ "_source": ["author", "title"], "query": { "match": { "content": "elasticsearch" } }} 3. 将大文本字段单独存储,不放在 _source 中在索引文档时,可以将text字段的数据单独存储,而不存储在 _source 字段中,这样默认在查询时,在 _source 字段中,就不会返回该大文本字段了,比如: 1234567891011121314151617181920PUT my-index-000004/{ "mappings": { "_source": { "excludes": ["content"] }, "properties": { "content": { "type": "text", "store": true }, "author": { "type": "keyword" }, "title": { "type": "keyword" } } }} 默认情况下,索引字段只索引数据,并不存储数据,将 content 字段从 _source 字段中排除,然后将 content 字段的 store 设置为 true,即将 content字段不仅索引数据还存储数据,这样就将 content 字段的数据跟 _source 字段分离开了,这样在进行search时, _source 字段中就不会包含 content 字段的原始数据了: 12345678GET my-index-000004/_search{ "query": { "match": { "content": "elasticsearch" } }} 返回值: 123456789101112"hits" : [ { "_index" : "my-index-000004", "_type" : "_doc", "_id" : "1", "_score" : 0.57344866, "_source" : { "author" : "guangyu", "title" : "fooooo" } }] 如果想要获取到 content 字段的原始内容,需要用到 stored_fields 参数: 123456789GET my-index-000004/_search{ "query": { "match": { "content": "elasticsearch" } }, "stored_fields": ["content"]} 但是禁用 _source字段或者是禁用 _source 字段中的部分字段,都会带来副影响: update, update_by_query, reindex 会受到影响 highlighting 会受到影响 所以,为了避免带来不必要的影响,非特殊情况不用方法3,建议考虑方法1和2。 参考资料: https://www.elastic.co/guide/en/elasticsearch/reference/current/search-fields.html https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-source-field.html https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-store.html https://www.agilytic.be/blog/tech-talk-elasticsearch-stored-fields 使用 fvh highlighter一般全文检索,都需要用到高亮 highlighting,将搜索到的匹配内容用一些tag标记出来,highlight的大致原理就是将text按照分词切分成fragments,然后对这些fragments进行打分,在找到的匹配的fragments中标记出来对应的查询term。 在highlight过程中,需要用到分词的一些词频、位置以及在文本中的偏移等信息,获取这些信息主要有3种途径: the postings list,即在mapping中给某个字段的index_options选项配置成offsets,这样它就会将词频、文档数、位置、偏移等信息存储到倒排索引结构中,但是默认情况下,不会存储偏移登信息到倒排索引中,具体参考文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/index-options.html term vectors,是启用额外的数据结构来存储词频、位置、偏移等信息,每个文档都会有对应的term vector结构,在mapping中给某个字段加上 “term_vector”: “with_positions_offsets” 就可以启用该功能,但是打开term vectors的话,会占用额外的磁盘空间,因为使用额外的数据结构存放了更多的数据,具体参考文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-termvectors.html plain highlighting,是实时计算这些信息的,会在内存中创建一个小索引,来重新运行query获得这些信息,该方式耗时且耗资源。 highlight的算法目前也有3种方式: Unified highlighter,在有term vectors或者postings list信息可用时,会优先使用他们中提前存储好的信息来切分fragments以及进行评分,评分是使用的BM25算法,该方式比较通用,是默认的highlighter; Plain highlighter,只能使用plain highlighting实时的方式获取分词位置偏移等信息,打分也是比较简单的通过统计查询的分词个数进行打分,该方式适用于在单字段上进行简单的查询,跨字段复杂查询建议使用其他两种; Fast vector highlighter,只能使用term vectors的方式获取分词位置偏移等信息,打分采用tf–idf算法,类似于Plain highlighter,但是考虑了更多因素,该方式在大文本字段(大于1M)的场景下,有比较好的性能; 因此在大文本字段的场景下,由于fvh的方式使用提前存储到term vectors中的分词信息,不需要实时计算,性能表现更佳,因此推荐使用fvh highlighter。使用方式如下: 在text字段上添加上”term_vector”: “with_positions_offsets”选项,打开该字段的term vectors功能: 1234567891011121314151617PUT my-index-000006{ "mappings" : { "properties" : { "author" : { "type" : "keyword" }, "content" : { "type" : "text", "term_vector": "with_positions_offsets" }, "title" : { "type" : "keyword" } } }} 查询时指定type为fvh 123456789101112131415GET my-index-000006/_search{ "query": { "match": { "content": "best" } }, "highlight": { "fields": { "content": { "type": "fvh" } } }} 参考资料: https://www.elastic.co/guide/en/elasticsearch/reference/current/highlighting.html https://www.agilytic.be/blog/tech-talk-elasticsearch-highlighting-kibana https://cloud.tencent.com/developer/article/1066368","link":"/2022/09/26/elasticsearch/elasticsearch_large_text_field.html"},{"title":"TCP长连接中断导致的业务问题","text":"本周处理了客户的一个运维Case,是关于TCP长连接的问题,经过两天的排查,最终找到了问题的原因,觉得这个问题挺经典的,所以记录下来。 在接手这个case之前,已经让运维工程师查了几天了,有了一些进展,但是我没有怎么过问,后来客户那边的运维老大打过来电话,特意说了这个事情,并且担忧的说到:“怎么上云有这么多问题,这以后怎么让我们放心?” 我意识到这个问题的严重性,之前这位客户就跟我聊天说过,他们的业务很关键,都是一些重量级的企业客户,得罪不起,上到云上都有哪些坑?有哪些地方需要注意的?我们还需要做哪些改进? 我能理解客户的心情,即使告诉他我们已经有很多客户成功的上云,并且稳定运行很多年了,这些客户中也不乏大规模重量级的业务,但是他仍然没有信心,毕竟他们的业务跟别人不一样,毕竟他们现在看到有很多问题。我知道这不是三言两语就能让他放心的,不是说给他们部署了一个云平台,然后这个生意就算做完了,相反的是这才是万里长征的第一步,后面还有很多的事情和挑战,架构的优化调整,数据的容灾备份,业务的适配上云等等,对自己数据中心的变革绝不仅仅是找个厂商部署个云平台就算万事大吉了,而我们对自己的定位也绝不止步于此。 问题背景客户有一个Tomcat的应用从原来的物理机上迁移到云平台上,这个应用要通过http去请求一个服务器上的一个应用,这个服务器目前仍然在原来的物理环境里,物理环境和云平台通过三层将网络打通,原来都在物理环境里运行的没有问题,但是将Tomcat应用迁移到云平台之后,发现Tomcat应用到物理环境服务器的http连接无法正常返回,等很久之后连接就异常终止了。Tomcat应用是运行在Windows Server 2008里的,服务端是运行在一台Linux系统中的,还有个特殊点是这个http请求是个重量级的请求,一个请求发过去,正常情况下也要执行几分钟,甚至半小时才能正常返回。 问题分析因为该http请求可以直接在浏览器上发起,所以在Windows Server自带的IE 8浏览器进行测试,发现浏览器发起请求之后,差不多要等半个小时的时间请求才会失败,为了想看下该请求发起和返回的状态,使用Chrome浏览器进行了相同测试,发现Chrome浏览器竟然能够正常返回,而且只花了几分钟的时间,经过多次测试发现结果都是一样的,这两个浏览器发起的请求有什么区别呢?为什么一个正常,一个却不正常呢? 为了弄清楚底层到底发生了什么,我们对服务端和客户端的网卡进行抓包分析,发现在请求发出去之后,即TCP连接建立起来之后,Chrome会周期性的发送ACK心跳包,服务端抓包情况,如下图: 该心跳包是客户端用来保活该TCP连接的,防止TCP连接长时间处于空闲状态,导致被异常断开。当服务端处理完请求,发起断开TCP连接时,整个过程是可以正常断开的。然而IE浏览器发起的请求的行为却不是这样的,下图为IE浏览器测试情况下,在服务端抓包的情况: 可以看到在TCP经过3次握手建立起来连接之后,一直到服务端处理完请求,发起断开连接的Fin包之前,一直没有心跳包,服务端发起的断开连接请求,客户端一直没有回应,但是此时服务端实际上已经处理完了请求,并且主动去断开连接。 问题验证","link":"/2018/08/26/operations/tcp-keepalive.html"},{"title":"Python单元测试—深入理解unittest","text":"单元测试的重要性就不多说了,可恶的是python中有太多的单元测试框架和工具,什么unittest, testtools, subunit, coverage, testrepository, nose, mox, mock, fixtures, discover,再加上setuptools, distutils等等这些,先不说如何写单元测试,光是怎么运行单元测试就有N多种方法,再因为它是测试而非功能,是很多人没兴趣触及的东西。但是作为一个优秀的程序员,不仅要写好功能代码,写好测试代码一样的彰显你的实力。如此多的框架和工具,很容易让人困惑,困惑的原因是因为并没有理解它的基本原理,如果一些基本的概念都不清楚,怎么能够写出思路清晰的测试代码? 今天的主题就是unittest,作为标准python中的一个模块,是其它框架和工具的基础,参考资料是它的官方文档:unittest和源代码,文档已经写的非常好了,我在这里记录的主要是它的一些重要概念、关键点以及可能会碰到的一些坑,目的在于对unittest加深理解,而不是停留在泛泛的表面层上。 unittest是一个python版本的junit,junit是java中的单元测试框架,对java的单元测试,有一句话很贴切:Keep the bar green,相信使用eclipse写过java单元测试的都心领神会。unittest实现了很多junit中的概念,比如我们非常熟悉的test case, test suite等,总之,原理都是相通的,只是用不同的语言表达出来。 在文档的开篇就介绍了unittest中的4个重要的概念:test fixture, test case, test suite, test runner,我觉得只有理解了这几个概念,才能真正的理解单元测试的基本原理,下面就主要围绕这几个概念来展开这篇文章。 首先通过查看unittest的源码,来看一下这几个概念,以及他们之间的关系,他们是如何在一起工作的,其静态类图如下: 一个TestCase的实例就是一个测试用例。什么是测试用例呢?就是一个完整的测试流程,包括测试前准备环境的搭建(setUp),执行测试代码(run),以及测试后环境的还原(tearDown)。元测试(unit test)的本质也就在这里,一个测试用例是一个完整的测试单元,通过运行这个测试单元,可以对某一个问题进行验证。 而多个测试用例集合在一起,就是TestSuite,而且TestSuite也可以嵌套TestSuite。 TestLoader是用来加载TestCase到TestSuite中的,其中有几个loadTestsFrom__()方法,就是从各个地方寻找TestCase,创建它们的实例,然后add到TestSuite中,再返回一个TestSuite实例。 TextTestRunner是来执行测试用例的,其中的run(test)会执行TestSuite/TestCase中的run(result)方法。 测试的结果会保存到TextTestResult实例中,包括运行了多少测试用例,成功了多少,失败了多少等信息。 这样整个流程就清楚了,首先是要写好TestCase,然后由TestLoader加载TestCase到TestSuite,然后由TextTestRunner来运行TestSuite,运行的结果保存在TextTestResult中,整个过程集成在unittest.main模块中。 现在已经涉及到了test case, test suite, test runner这三个概念了,还有test fixture没有提到,那什么是test fixture呢??在TestCase的docstring中有这样一段话: Test authors should subclass TestCase for their own tests. Construction and deconstruction of the test’s environment (‘fixture’) can be implemented by overriding the ‘setUp’ and ‘tearDown’ methods respectively. 可见,对一个测试用例环境的搭建和销毁,是一个fixture,通过覆盖TestCase的setUp()和tearDown()方法来实现。这个有什么用呢?比如说在这个测试用例中需要访问数据库,那么可以在setUp()中建立数据库连接以及进行一些初始化,在tearDown()中清除在数据库中产生的数据,然后关闭连接。注意tearDown的过程很重要,要为以后的TestCase留下一个干净的环境。关于fixture,还有一个专门的库函数叫做fixtures,功能更加强大,以后会介绍到。 至此,概念和流程基本清楚了,下面通过简单的例子再来实践一下,就拿unittest文档上的例子吧: 1234567891011121314151617181920212223242526272829import randomimport unittestclass TestSequenceFunctions(unittest.TestCase): def setUp(self): self.seq = range(10) def test_shuffle(self): # make sure the shuffled sequence does not lose any elements random.shuffle(self.seq) self.seq.sort() self.assertEqual(self.seq, range(10)) # should raise an exception for an immutable sequence self.assertRaises(TypeError, random.shuffle, (1,2,3)) def test_choice(self): element = random.choice(self.seq) self.assertTrue(element in self.seq) def test_sample(self): with self.assertRaises(ValueError): random.sample(self.seq, 20) for element in random.sample(self.seq, 5): self.assertTrue(element in self.seq)if __name__ == '__main__': unittest.main() TestSequenceFunctions继承自unittest.TestCase,重写了setUp()方法,并且定义了三个以’test’开头的方法,那这个TestSequenceFunctions类到底是个什么呢?它是一个测试用例,还是三个测试用例?说是三个测试用例的话,它本身继承自TestCase,说是一个测试用例的话,里面又有三个test_*()方法,明显是三个测试用例。其实,我们只要看一些TestLoader是如何加载测试用例的,就一清二楚了,在loader.TestLoader类中有一个loadTestsFromTestCase()方法: 12345678910def loadTestsFromTestCase(self, testCaseClass): """Return a suite of all tests cases contained in testCaseClass""" if issubclass(testCaseClass, suite.TestSuite): raise TypeError("Test cases should not be derived from TestSuite." \\ " Maybe you meant to derive from TestCase?") testCaseNames = self.getTestCaseNames(testCaseClass) if not testCaseNames and hasattr(testCaseClass, 'runTest'): testCaseNames = ['runTest'] loaded_suite = self.suiteClass(map(testCaseClass, testCaseNames)) return loaded_suite getTestCaseNames()是从TestCase这个类中找所有以“test”开头的方法,然后注意第9行,在构造TestSuite对象时,其参数使用了一个map方法,即对testCaseNames中的每一个元素,使用testCaseClass为其构造对象,其结果是一个TestCase的对象集合,可以用下面的代码来分步说明: 1234testcases = []for name in testCaeNames: testcases.append(TestCase(name))loaded_suite = self.suiteClass(tuple(testcases)) 可见,对每一个以test开头的方法,都为其构建了一个TestCase对象,值得注意的是,如果没有定义test开头的方法,而是将测试代码写到了一个名为runTest的方法中,那么会为该runTest方法构建TestCase对象,如果定义了test开头的方法,就会忽略runTest方法。 至此,基本就清楚了,每一个以test开头的方法,都会为其构建TestCase对象,也就是说TestSequenceFunctions类中其实定义了三个TestCase,之所以写成这样,是为了方便,因为这几个测试用例的fixture是相同的,如果每一个测试用例单独写成一个TestCase的话,会有很多的冗余代码。 明白了这些,文档就可以很轻松的看懂了,至于怎么运行测试用例,以及其他的内容,直接看文档吧。","link":"/2017/09/11/python/unittest.html"},{"title":"Glance Interoperable Image Import功能介绍","text":"Glance最核心的功能就是镜像管理,我们最常做的操作就是管理员给Glance上传一个镜像,然后大家就都可以从这个镜像创建虚拟机了,这在私有云场景下,没有任何问题。但是在公有云场景下,需要考虑的问题就比较多了,像“应用市场”就是可以让普通用户上传自己制作的镜像,并且共享给其他人,这一方面是请求量比在私有云场景大了很多,可能同时有很多人在传镜像,而且镜像一般都很大,如果不做优化,Glance API可能很快就卡死了;另一方面是安全性问题,普通用户是不可信的,管理员要对其上传的镜像做各种安全性、合法性检查;其他的,像管理员可能需要对用户上传的镜像自动做类型转换,以及打一些元数据标签之类的;所以,Glance的核心功能虽然简单,但是随着需求的不断提出,它还是经历过了一个比较“漫长”的演进过程。 整体来看,主要经历了三个阶段: 1. 通过API同步上传早期版本,大概是在L版本之前,实现的是最简单粗暴的上传镜像,直接通过API把本地数据传到Glance的后端存储上去,这个也是私有云最常用的传镜像的方式,到现在该API仍然保留: 12POST /v2/imagesPUT /v2/images/{image_id}/file 对应的命令行为: 12glance image-createglance image-upload POST /v2/images只是创建一个镜像的数据库记录,然后通过PUT /v2/images/{image_id}/file向该镜像上传数据,该上传镜像的接口是阻塞式的,它直接调用后端存储的接口传输数据,等到传输完成,才会返回。 示例: 12glance image-create --disk-format raw --container-format bare --visibility public --name testfastglance image-upload --file CentOS---7.3-64bit---2017-12-11-a.dsk e80e40ea-8567-4919-83b5-632829cf530b 其实也可以合并写成一条命令: 1glance image-create --disk-format raw --container-format bare --visibility public --name testfast --file CentOS---7.3-64bit---2017-12-11-a.dsk 这条命令背后会依次调用上面的两个API。 2. Tasks的引入在大概L版左右,引入了一个Tasks这个特性,Tasks的引入其实主要就是为了解决上面说的公有云场景下镜像管理的需求的,Tasks可以将镜像相关的耗时比较长的操作异步执行,即将这些操作放在后台执行,不再阻塞API,然后可以查看这些task的状态,是成功了还是失败了,并且可以通过自定义插件的形式,对镜像做一些特殊操作,相关的API为: 123POST /v2/tasksGET /v2/tasksGET /v2/tasks/{task_id} 对应的命令行分别为: 123glance task-createglance task-listglance task-show 比如上传镜像,比较耗时,那么可以创建一个上传镜像的task,让其在后台执行,示例Python代码如下: 1234567891011def create_task(url, **imageinfo): input_parameter = { 'type': 'import', 'input': { 'image_properties': imageinfo, "import_from": url, "import_from_format": ""}} glance = get_glance_client() task = glance.tasks.create(**input_parameter) return task.id 主要传type和input两个参数,input是镜像相关的一些信息,type为import,表示导入镜像,具体这个task可以干什么事,其实是通过插件的形式实现的,每一个type对应一个插件,input就是这个task需要用到的参数,当前Glance内置了两个type: import和api_image_import,后者就是我们本篇文章的主题Interoperable Image Import用到的task类型,管理员可以通过写插件的方式,来自定义一个task type,实现一些和镜像相关的操作。 这个task异步执行的实现,其实依赖的是TaskFlow这个库,将一个task中的步骤都抽象成Flow,编排在一起,可以线性执行这些Flow,如果有报错,可以顺序回滚,确保这些任务执行的可靠性和一致性。不同Task是可以并发执行的,使用GreenThread实现,可以通过task list或者task show查看到当前的状态以及详情信息。 值得注意的是,上面介绍的两种类型的task,又可以通过插件的形式向其中添加自定义的Flow,在真正上传镜像到Glance后端之前,可以通过自定义的Flow来实现前面说到的安全检查、合法性检查、类型转换等操作。如下图为import task的workflow示意图: _CreateImage,这个Flow的作用就是创建image的数据库记录 _ImportToFS,这个Flow是将镜像导入到一个临时的目录中,供插件对该镜像进行处理,这个只有在有插件的时候,才会执行这个Flow 三个虚线框,标识的就是插件式的Flow,目前是硬编码指定这三个插件 _ImportToStore,这个Flow就是经过了前面插件的处理,最终要上传到Glance的后端存储去 _DeleteFromFS,这个Flow就是上传完成后,从临时目录中删除临时镜像 3. Interoperable Image Import这个大概是在R版引入的功能,Tasks的引入其实已经可以将镜像上传异步化,但是并没有将此外封装API提供出来,想要异步上传镜像的话,必须得像第2步中介绍的操作一样,通过创建Task来实现,这多少有点麻烦,而且镜像的来源可能多种多样,可能是本地文件,也可能是网络文件,也可能是现有的镜像,所以Glance就又通过插件的方式实现了一个叫做api_image_import的task类型,并且实现了相关的API,该API可以调用api_image_import task的workflow来实现异步上传镜像,这种新的上传镜像的方式就叫做:Interoperable Image Import。 来看下新增的这两个API: 12PUT /v2/images/{image_id}/stagePOST /v2/images/{image_id}/import 对应的命令行为: 12glance image-stageglance image-import image-import这个接口又有三种上传镜像的方式,通过参数method指定: glance-direct,这种方式需要先通过image-stage API将镜像文件先上传到一个临时目录,称作staging area,然后image-import API再将staging area中的镜像通过异步的方式传到最终的后端存储中去,这种方式是默认的,即不指定method的话,默认采用这种方式。 web-download,这种方式是将一个位于网络位置中的镜像,首先下载到staging area中,然后再将其传到最终的后端存储中去; copy-image,这种方式是通过拷贝现有的一个镜像来上传一个新镜像。 我们以glance-direct方式为例,来看下怎么使用: 123glance image-create --disk-format raw --container-format bare --visibility public --name testfastglance image-stage --file CentOS---7.3-64bit---2017-12-11-a.dsk f456c8a6-23f6-42a4-85df-507fcb3abe34glance image-import --backend fast f456c8a6-23f6-42a4-85df-507fcb3abe34 先通过image-create命令建一个镜像记录,拿到image id,然后通过image-stage命令将镜像传到staging area,再然后通过image-import命令将staging area中的镜像异步传输到后端存储中。 以上也可以合并成一条命令: 1glance image-create-via-import --disk-format raw --container-format bare --visibility public --name testfast --file CentOS---7.3-64bit---2017-12-11-a.dsk 这个staging area通过下面的配置项配置: 12[os_glance_staging_store]filesystem_store_datadir = /var/lib/glance/staging 需要注意的是,如果需要起多个glance-api做高可用的话,那么这个目录得是一个在多个节点之间共享的目录,否则可能会报找不到镜像的错误。 与import task类似,api_image_import task也可以通过插件的方式对上传的镜像做一些处理,目前内置的有inject_image_metadata, image_conversion这两个。 参考资料: https://docs.openstack.org/api-ref/image/v2/#interoperable-image-import https://docs.openstack.org/glance/train/admin/interoperable-image-import.html","link":"/2020/11/11/openstack/glance-interoperable-image-import.html"},{"title":"Glance多后端功能介绍","text":"所谓Glance多后端,其实是针对Cinder的多后端功能对比的,我们知道在Cinder中,cinder-volume后面可以接多个存储后端,并且通过cinder types来选择使用哪个存储后端,Cinder中的这个功能很早就有了,因为Cinder要纳管多种存储,对外提供统一的接口,这个功能是刚需。但是对于Glance来说,类似Cinder的这种Glance多后端就没那么刚了,没有多后端也是可以用的,拿Ceph作为后端存储来说,如果镜像只是存在其中一个存储池A的话,要在另外的存储池B中建虚拟机,发现不能执行rbd clone操作,那么它会将该镜像下载到本地,然后再传到存储池B中,而且下载下来的镜像会缓存到本节点,只要在该节点下载过一次,后面在该节点再使用相同的镜像建虚拟机,就不需要再下载了,这种方式虽然效率低一些,但是不影响使用。但是毕竟不完美啊,所以社区一直到R版,才实现了这个功能,到T版算是生产可用,但是仍然有一些bug还在修复,不过不影响使用。 如何配置参考这里的文档进行配置,主要配置下面几个配置项: 1234567891011121314151617181920212223242526272829[DEFAULT]enabled_backends = fast:rbd, cheap:rbd, shared:file, reliable:file[glance_store]default_backend = fast[shared]filesystem_store_datadir = /opt/stack/data/glance/shared_images/store_description = "Shared filesystem store"[reliable]filesystem_store_datadir = /opt/stack/data/glance/reliablestore_description = "Reliable filesystem backend"[fast]store_description = "Fast rbd backend"rbd_store_chunk_size = 8rbd_store_pool = imagesrbd_store_user = adminrbd_store_ceph_conf = /etc/ceph/ceph.confrados_connect_timeout = 0[cheap]store_description = "Cheap rbd backend"rbd_store_chunk_size = 8rbd_store_pool = imagesrbd_store_user = adminrbd_store_ceph_conf = /etc/ceph/ceph1.confrados_connect_timeout = 0 enabled_backends配置的是多个后端的ID号和类型,冒号左边是ID号,跟下面的以ID为section名字的配置组对应,冒号右边的是后端存储的类型,支持rbd, file等。 default_backend配置的是默认后端,即上传镜像时不指定存储后端的话,默认使用这里配置的后端 下面配置的就是各个存储后端的详细信息了。 配置好之后,可以通过如下的命令,来查看多后端的信息: 123456[root@control1 ~]# glance stores-info+----------+--------------------------------------------------------------------+| Property | Value |+----------+--------------------------------------------------------------------+| stores | [{"default": "true", "id": "rbd"}, {"id": "fast"}, {"id": "file"}] |+----------+--------------------------------------------------------------------+ 注意,该命令是直接读取的配置文件,不存数据库。 如何使用在Glance Interoperable Image Import功能介绍中介绍过上传镜像的三种方式,目前,这三种方式,都支持多后端特性,跟Cinder通过指定type不同的是,Glance中是通过Header来指定使用哪个存储后端的,我们以最简单的image upload的方式为例,来看下如何使用多后端: 首先需要创建一个镜像记录: 1glance image-create --disk-format raw --container-format bare --visibility public --name testfast 该命令对应的API调用如下: 1curl -g -i -X POST http://10.110.105.30:9292/v2/images -H "Content-Type: application/json" -H "User-Agent: python-glanceclient" -H "X-Auth-Token: {SHA1}53b399279a96b953fc584fc4091ec78bbc47eea1" -d '{"container_format": "bare", "disk_format": "raw", "name": "testfast", "visibility": "public"}' 返回值如下: 1RESP: [201] Openstack-Image-Import-Methods: glance-direct,web-download Openstack-Image-Store-Ids: rbd,fast,file X-Openstack-Request-Id: req-ad9f63ef-184a-4b35-96f8-59ba7ab00975 注意到HTTP Response中有一个Header: Openstack-Image-Store-Ids: rbd,fast,file,它表示当前系统支持哪些存储后端,即上面glance stores-info列出的内容。 然后向该镜像记录上传镜像 1glance image-upload --file CentOS---7.3-64bit---2017-12-11-a.dsk e80e40ea-8567-4919-83b5-632829cf530b --backend fast 通过--backend参数来指定要向哪个后端传镜像,其对应的API调用如下: 1curl -g -i -X PUT http://10.110.105.30:9292/v2/images/e80e40ea-8567-4919-83b5-632829cf530b/file -H "Content-Type: application/octet-stream" -H "User-Agent: python-glanceclient" -H "X-Auth-Token: {SHA1}3b3d90bfe99d476eef59a67725e9436b35f0853b" -H "x-image-meta-store: fast" -d '<generator object _chunk_body at 0x7fb4c606b870>' 可以看到在Header中通过”x-image-meta-store: fast”来指定要使用fast存储后端。以上命令可以合并成一条命令来执行: 1glance image-create --disk-format raw --container-format bare --visibility public --name testfast --file CentOS---7.3-64bit---2017-12-11-a.dsk --backend fast 上传完成后,可以通过image-show命令查看Location属性,指定了该镜像使用哪个后端: 123456789101112131415161718192021222324252627282930[root@control1 ~]# glance image-show 00f121f4-929f-4a0c-b706-cfef2e4764ca+------------------+----------------------------------------------------------------------------------+| Property | Value |+------------------+----------------------------------------------------------------------------------+| checksum | 7050b89af6d23f80aa5776f51f5387af || container_format | bare || created_at | 2020-11-12T09:23:55Z || direct_url | rbd://c5f578c9-2026-4413-bab0-963c63748a6c/fastimages/00f121f4-929f- || | 4a0c-b706-cfef2e4764ca/snap || disk_format | raw || id | 00f121f4-929f-4a0c-b706-cfef2e4764ca || locations | [{"url": "rbd://c5f578c9-2026-4413-bab0-963c63748a6c/fastimages/00f121f4-929f- || | 4a0c-b706-cfef2e4764ca/snap", "metadata": {"store": "fast"}}] || min_disk | 0 || min_ram | 0 || name | testfast || os_hash_algo | sha512 || os_hash_value | 367dcbf754cee36ad2823bf40d0efa36b902954d1d97eded078634eb277b0fe6cbb4d4af382a608f || | d3e4cbf0656204e7a8418c7b9a194815f73601a4f2912149 || os_hidden | False || owner | 3c638b2eb36b4da6944040bb31084421 || protected | False || size | 3221225472 || status | active || stores | fast || tags | [] || updated_at | 2020-11-12T09:25:05Z || virtual_size | Not available || visibility | public |+------------------+----------------------------------------------------------------------------------+ 而对于Interoperable Image Import方式来说,除了可以使用Header来指定存储后端外,还可以通过POST body中的”stores”参数来同时指定多个后端一起上传,或者”all_stores”指定所有后端,并且通过”all_stores_must_succeed”参数来设置部分后端上传失败后的行为,如下: 1234567{ "method": { "name": "glance-direct" }, "stores": ["common", "cheap", "fast", "reliable"], "all_stores_must_succeed": false} 不过这些参数在U版才可用,而且还有些Bug正在修复,使用时需要注意: https://bugs.launchpad.net/glance/+bug/1863879 总结:在配置了Glance的多后端之后,就可以跟Cinder的多后端结合,通过boot from volume的方式,从同一个存储后端建虚拟机,volume直接从image做clone,加快了虚拟机的创建速度,也简化了镜像的管理。 参考资料: https://docs.openstack.org/glance/ussuri/admin/multistores.html","link":"/2020/11/12/openstack/glance-multistore.html"},{"title":"Kubernetes APIServer NonGoRestfulMux","text":"在go-restful的介绍中,有介绍过Kubernetes中核心的API是使用go-restful来构建的,我们知道除了核心API,Kubernetes APIServer还提供了两种扩展机制,分别为Aggregation和APIExtensions,这两者中的API对象,则是使用NonGoRestfulMux来构建的,之所以不继续使用go-restful,原因还没有细究,可能是因为一些兼容性问题,后续有可能抛弃go-restful,完全切到NonGoRestfulMux上来,但是看改造工作量还是比较大的。下面我们来看看这个NonGoRestfulMux究竟是个什么东西,其代码位于:apiserver/pkg/server/mux/pathrecorder.go 其结构体如下: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546type PathRecorderMux struct { // name is used for logging so you can trace requests through name string lock sync.Mutex notFoundHandler http.Handler pathToHandler map[string]http.Handler prefixToHandler map[string]http.Handler // mux stores a pathHandler and is used to handle the actual serving. // Turns out, we want to accept trailing slashes, BUT we don't care about handling // everything under them. This does exactly matches only unless its explicitly requested to // do something different mux atomic.Value // exposedPaths is the list of paths that should be shown at / exposedPaths []string // pathStacks holds the stacks of all registered paths. This allows us to show a more helpful message // before the "http: multiple registrations for %s" panic. pathStacks map[string]string}// pathHandler is an http.Handler that will satisfy requests first by exact match, then by prefix,// then by notFoundHandlertype pathHandler struct { // muxName is used for logging so you can trace requests through muxName string // pathToHandler is a map of exactly matching request to its handler pathToHandler map[string]http.Handler // this has to be sorted by most slashes then by length prefixHandlers []prefixHandler // notFoundHandler is the handler to use for satisfying requests with no other match notFoundHandler http.Handler}// prefixHandler holds the prefix it should match and the handler to usetype prefixHandler struct { // prefix is the prefix to test for a request match prefix string // handler is used to satisfy matching requests handler http.Handler} PathRecorderMux即为NonGoRestfulMux,它里面有个原子类型的属性mux atomic.Value是其中最重要的属性,atomic是golang中一个用来做同步操作的数据类型,可以在不加锁的情况下,实现原子操作,通过store和load方法,来对其值进行存取。它存储的数据即为下面的pathHandler,pathHandler中又包含三个重要属性:pathToHandler, prefixHandlers以及notFoundHandler,即具体path到Handler的映射,某一个prefix到Handler的映射,以及路径没有匹配时映射的Handler,通过原子操作对其属性进行更新,而PathRecorderMux中的pathToHandler和prefixToHandler则都是为了能够线程安全的更新mux中的pathHandler而存在的。 PathRecorderMux的构建方法如下: 12345678910111213func NewPathRecorderMux(name string) *PathRecorderMux { ret := &PathRecorderMux{ name: name, pathToHandler: map[string]http.Handler{}, prefixToHandler: map[string]http.Handler{}, mux: atomic.Value{}, exposedPaths: []string{}, pathStacks: map[string]string{}, } ret.mux.Store(&pathHandler{notFoundHandler: http.NotFoundHandler()}) return ret} 通过Store()方法将一个初始化的pathHandler存储到mux中。然后通过Handle(), HandleFunc(), HandlePrefix()等方法向PathRecorderMux中注册Handler,以Handle()方法为例: 1234567891011121314151617181920212223242526272829303132333435func (m *PathRecorderMux) Handle(path string, handler http.Handler) { m.lock.Lock() defer m.lock.Unlock() m.trackCallers(path) m.exposedPaths = append(m.exposedPaths, path) m.pathToHandler[path] = handler m.refreshMuxLocked()}func (m *PathRecorderMux) refreshMuxLocked() { newMux := &pathHandler{ muxName: m.name, pathToHandler: map[string]http.Handler{}, prefixHandlers: []prefixHandler{}, notFoundHandler: http.NotFoundHandler(), } if m.notFoundHandler != nil { newMux.notFoundHandler = m.notFoundHandler } for path, handler := range m.pathToHandler { newMux.pathToHandler[path] = handler } keys := sets.StringKeySet(m.prefixToHandler).List() sort.Sort(sort.Reverse(byPrefixPriority(keys))) for _, prefix := range keys { newMux.prefixHandlers = append(newMux.prefixHandlers, prefixHandler{ prefix: prefix, handler: m.prefixToHandler[prefix], }) } m.mux.Store(newMux)} 可见,其通过加锁的方式,首先向PathRecorderMux中的pathToHandler中添加path到Handler的映射,然后调用refreshMuxLocked()方法,构建了一个新的pathHandler,然后使用PathRecorderMux中的属性更新该pathHandler,然后再将其存储到mux中,通过这种方式完成了原子更新。这里可能有个疑问,既然atomic可以不加锁就实现原子操作,为什么这里还要加锁,其实,这里还有个golang的知识点,就是golang中的map是线程不安全的,所以在多线程环境下,操作map,一般都需要通过加锁,保证线程安全。那么问题又来了,既然加锁了,那为啥不直接使用map,还费这么大劲,搞个atomic,其实我感觉不是不可以,只是使用atomic会更方便些,因为当你要读取atomic中的数据时,就不需要再加锁了,保证原子性。 下面来看看其ServeHTTP()方法,看它费这么大劲儿,到底是如何工作的: 123456789101112131415161718192021222324// ServeHTTP makes it an http.Handlerfunc (m *PathRecorderMux) ServeHTTP(w http.ResponseWriter, r *http.Request) { m.mux.Load().(*pathHandler).ServeHTTP(w, r)}// ServeHTTP makes it an http.Handlerfunc (h *pathHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if exactHandler, ok := h.pathToHandler[r.URL.Path]; ok { klog.V(5).Infof("%v: %q satisfied by exact match", h.muxName, r.URL.Path) exactHandler.ServeHTTP(w, r) return } for _, prefixHandler := range h.prefixHandlers { if strings.HasPrefix(r.URL.Path, prefixHandler.prefix) { klog.V(5).Infof("%v: %q satisfied by prefix %v", h.muxName, r.URL.Path, prefixHandler.prefix) prefixHandler.handler.ServeHTTP(w, r) return } } klog.V(5).Infof("%v: %q satisfied by NotFoundHandler", h.muxName, r.URL.Path) h.notFoundHandler.ServeHTTP(w, r)} 看到PathRecorderMux中实现的ServeHTTP()方法,通过load()读出mux中的数据,然后进行类型转换,转换成pathHandler指针,然后调用pathHandler的ServeHTTP()方法,在pathHandler的ServeHTTP()方法中,则可以看到NonGoRestfulMux的真正作用了,即分三步走: 首先从pathToHandler中找请求的路径跟注册的path完全匹配的,如果有,则由该Handler进行处理 如果没有完全匹配的,则看是否跟注册的prefix匹配,如果有,则由该Handler进行处理 如果上面都没匹配到,则由notFoundHandler进行处理 以上,就是NonGoRestfulMux的核心功能了。","link":"/2020/10/04/kubernetes/kube-apiserver-nongorestfulmux.html"},{"title":"Kubernetes APIServer 机制概述","text":"断断续续研究Kubernetes代码已经大半年时间了,一直在看APIServer相关的代码,因为API是一个系统的入口,是所有功能对外的抽象体现,同时也是其它组件都依赖的一个组件,处于非常核心的地位,因此它被社区进行了精心的设计,了解了它,就可以顺藤摸瓜去了解其他核心的功能。不过,经过这么长时间的摸索,发现Kubernetes的代码是真心复杂,明显感觉要比看OpenStack的代码费劲多了,感觉它的复杂性主要来自于以下几个方面: Kubernetes经过这么多年的发展,功能不断在扩展,不断地在复杂化,为了应对这种复杂化,代码也不断被重构和抽象,逐渐往模块化,插件化,自动化发展,典型的像apimachinery这个库,就是api层面最高层的抽象,如果不结合它的使用,单看这个库的代码,几乎是看不懂的,而且往往Kubernetes里面一个结构体的内容非常多,结构体里又嵌套多层结构体,围绕这个结构体又有一堆的方法,信息量巨大,此外代码中还有大量的magic code,不了解背景的话,很难理解为什么写这段代码。 Kubernetes是用Golang写的,Golang是没有类似于C++, Java, Python那种类的概念的,也没有继承,多态,这种面向对象的编程方式,它的抽象方式,只有一种,就是Interface,以及实现了这个Interface的结构体,所以面对这种复杂的项目,代码组织是非常凌乱的。 毕竟Golang没有Python/Java这种编程语言老牌,Kubernetes项目中,用到的第三方库比较少,很多都是自己写的库,典型的像APIServer中,处理REST请求的库,虽然使用了第三方go-restful,但还是自己开发了一个NonGoRestfulMux,因为go-restful不能满足它的一些功能要求,与之类似的,还有API对象的序列化,以及对数据库的操作,都是自己写的库,这在Python里面都有成熟的强大的库,可以屏蔽掉这些细节,这些都显著增加了它的复杂度。 面对它四五百万行的代码量,真心感觉罗马不是一天建成的。单看APIServer,里面有各种各样的机制,比如authentication, authorization, admission, storage, api group, extension, metric, log, audit, client, informer等等,本系列文章,打算介绍下Kubernetes APIServer一些主要机制的实现方式,包括如下几个方面: APIServer是怎么run起来的 APIServer是怎么跟数据库打交道的 APIServer中定义的API的Group和Version是怎么组织的 APIServer的扩展机制是怎么实现的 APIServer的序列化机制 本篇文章,主要介绍下APIServer的大致脉络,即上面提到的第一个问题,APIServer是怎么run起来的。本质上,APIServer是使用golang中net/http库中的Server构建起来的,所以在这之前,我们先来看看golang里面的http Server是怎么使用的。下面是一个非常简单的例子: 12345678910111213141516171819202122232425262728293031package mainimport ( "fmt" "log" "net/http" "time")type MyHandler struct { foo string}func (h *MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { fmt.Println(h.foo)}func main() { handler := &MyHandler{ foo: "hello world", } s := &http.Server{ Addr: ":8080", Handler: handler, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, MaxHeaderBytes: 1 << 20, } log.Fatal(s.ListenAndServe())} Handler是其中一个非常重要的概念,它是最终处理HTTP请求的实体,在golang中,定义了Handler的接口: 123type Handler interface { ServeHTTP(ResponseWriter, *Request)} 凡是实现了ServeHTTP()方法的结构体,那么它就是一个Handler了,所以上面定义的MyHandler结构体因为实现了ServeHTTP()方法,所以它是一个Handler,可以用来处理HTTP请求。然后在main()方法中,实例化一个MyHandler的对象,将其通过Handler参数传给了http.Server,然后ListenAndServe(),将Server运行起来,这样就完成了一个简单的HTTP Server了。这其实就是Kubernetes APIServer的骨架了,只不过它有非常复杂的Handler。 宏观上来看,APIServer就是一个实现了REST API的WebServer,最终是使用golang的net/http库中的Server运行起来的,数据库使用etcd,而且目前是唯一支持的后端存储,所以简单理解,APIServer所做的事情,就是对数据库的增删查改。但是,作为一个功能完备的Web Server,不能只有对数据库的增删查改,还需要比如:对外暴露API,必须要有认证和授权,而且Kubernetes为了能够让管理员更进一步控制API,还实现了其独有的Admission机制,此外,通过group和version(组和版本)来组织其API对象,为了保持兼容性,多个版本的对象可以共存,还有其扩展机制,即著名的CRD和Aggregation,等等这些,让APIServer丰满和复杂起来。APIServer启动的过程,就是对这些机制setup的过程,其大致流程如下图所示: init()是在main()函数启动之前,就进行的一些初始化操作,主要做的事情就是注册各种API对象类型到APIServer中,这个后续会讲到。 随后就是进行命令行参数的解析,以及设置默认值,还有校验了,APIServer使用cobra来构建它的CLI,各种参数通过POSIX风格的参数传给APIServer,比如下面的参数示例: 1234"--bind-address=0.0.0.0","--secure-port=6444","--tls-cert-file=/var/run/kubernetes/serving-kube-apiserver.crt","--tls-private-key-file=/var/run/kubernetes/serving-kube-apiserver.key", 这些显示指定的参数,以及没有指定,而使用默认值的参数,最终都被解析,然后集成到了一个叫做ServerRunOptions的结构体中,而这个结构体又包含了很多xxxOptions的结构体,比如EtcdOptions, SecureServingOptions等,供后面使用。 随后就到了CreateServerChain阶段,这个是整个APIServer启动过程中,最重要的也是最复杂的阶段了,整个APIServer的核心功能就包含在这个里面,这里面最主要的其实干了两件事:一个是构建起各个API对象的Handler处理函数,即针对REST的每一个资源的增删查改方法的注册,比如/pod,对应的会有CREATE/DELETE/GET/LIST/UPDATE/WATCH等Handler去处理,这些处理方法其实主要是对数据库的操作;第二个就是通过Chain的方式,或者叫Delegation的方式,实现了APIServer的扩展机制,如上图所示,KubeAPIServer是主APIServer,这里面包含了Kubernetes的所有内置的核心API对象,APIExtensions其实就是我们常说的CRD扩展,这里面包含了所有自定义的CRD,而Aggretgator则是另外一种高级扩展机制,可以扩展外部的APIServer,三者通过 Aggregator –> KubeAPIServer –> APIExtensions 这样的方式顺序串联起来,当API对象在Aggregator中找不到时,会去KubeAPIServer中找,再找不到则会去APIExtensions中找,这就是所谓的delegation,通过这样的方式,实现了APIServer的扩展功能。此外,还有认证,授权,Admission等都在这个阶段实现。 然后是PrepareRun阶段,这个阶段主要是注册一些健康检查的API,比如Healthz, Livez, Readyz等; 最后就到了Run阶段,经过前面的步骤,已经生成了让Server Run起来的所有东西,其中最重要的就是Handler了,然后将其通过NonBlocking的方式run起来,即将http.Server在一个goroutine中运行起来;随后启动PostStartHook,PostStartHook是在CreateServerChain阶段注册的hook函数,用来周期性执行一些任务,每一个Hook起在一个单独的goroutine中;这之后就是通过channel的方式将关闭API Server的方法阻塞住,当channel收到os.Interrup或者syscall.SIGTERM signal时,就会将APIServer关闭。 以上,就是对Kubernetes APIServer机制的一个大概认识,了解下APIServer的本质,以及它启动的一个大致流程,后续会对其中一些步骤进行深入剖析。","link":"/2020/08/09/kubernetes/kube-apiserver-overview.html"},{"title":"Kubernetes Kubelet CNI 机制解析","text":"CNI简介这里说Kubelet CNI,其实说法有些不准确,在Kubernetes Kubelet机制概述中就介绍过,Kubelet并没有直接跟CNI交互,而是通过容器运行时跟外部网络进行交互的,换句话说,CNI解决的是容器网络插件化的问题,跟Kubernetes这种容器编排系统并没有直接关系,但是有很多文章都说Kubelet支持了CNI,包括Kubernets官方的文档Network Plugins,其实这里是特指的Docker,因为Docker本身并不支持CNI,kubelet将对Docker网络环境的创建和删除,通过CNI的方式,放在了dockershim中,如果Kubernetes移除了对Docker的支持,就会移除dockershim,那么kubelet对CNI的支持也就会移除,这样两者就完全没有关系了。 现在Kubelet标准的做法是只对接支持CRI的容器运行时,CRI协议中定义了PodSandbox的概念,代表的是容器运行的网络环境,比如linux network namespace或者是一个虚拟机,在为容器创建PodSandbox时,如果该容器运行时同时也支持CNI,那么就会调用对应的网络插件去给network namespace或者虚拟机配置网卡,路由,DNS等网络信息,通过不同的网络插件,就可以选择不同的网络方案,所以,为PodSandbox配置网络,就是CNI发挥作用的地方,我们这里讨论的CNI机制也主要是针对容器运行时和网络插件的,示意图如下: CNI的协议在这里查看,从v0.1.0到v1.0.0,已经发布了6个版本,该协议中定义了容器运行时和网络插件交互的标准规范,该协议主要规定了以下一些内容: 网络插件都是放到指定目录下的可执行文件,并且以json格式的配置文件来描述网络配置,当需要设置容器网络时,由容器运行时来调用网络插件,需要在环境变量中指定对应的操作,Container ID以及Namespace ID等,然后从标准输入(stdin)读入json格式的配置文件,执行成功的话,将结果输出到标准输出(stdout)中,执行失败的话,将错误输出到标准错误(stderr)中。 目前定义了4种操作:ADD, DEL, CHECK, VERSION,分别表示将容器添加到网络或者对现有的网络配置进行更改,从网络中删除容器或者取消对应修改,检查网络配置是否符合预期,查看网络插件支持的CNI版本,这4种操作由容器运行时通过环境变量的方式传给网络插件,决定了网络插件要做什么操作。 CNI协议还规定了这些网络插件的链式调用关系,即chained plugin,在配置文件中,可以指定一组网络插件,第一个插件叫”Interface Plugin”,主要是用来在network namespace中创建网卡,设置IP等基本的网络配置,其它的插件叫做”Chained Plugin”,它使用前一个插件的输出作为输入,对已有的网卡做进一步配置,比如做一些调优设置等,这种链式结构带来的好处就是可以使网络插件的功能单一化,多个网络插件配合使用,可以组合出来更多的功能。 此外,CNI还规定了一种”Delegation Plugin”,与它对应的叫”Main Plugin”,它是将主Plugin的一部分功能抽出来单独组成了一个Plugin,典型的就是IPAM Plugin,因为分配IP,路由,网关等操作不同的网络插件可能是相同的,所以将这个功能单独抽出来,可以被主Plugin引用,即Delegation,这样就避免了各个网络插件去重复实现相同的功能。 以上是CNI协议的一些简介,下面还有一些不错的介绍CNI协议的资料,可以参考: https://juejin.cn/post/6986495816949039141 https://cizixs.com/2017/05/23/container-network-cni/ https://www.redhat.com/sysadmin/cni-kubernetes CNI协议实现下面我们重点来看看CNI这个协议具体是如何实现的,包括容器运行时和网络插件是如何实现CNI协议的。 CNI libcni 库CNI定义了一个lib库,libcni,该lib库其实就是CNI协议的具体实现,容器运行时可以直接引用libcni来实现CNI协议,它里面就包含两个文件:api.go和conf.go。 conf.go主要是用来解析json格式的网络配置文件,生成下面的对象: 123456789101112type NetworkConfig struct { Network *types.NetConf Bytes []byte}type NetworkConfigList struct { Name string CNIVersion string DisableCheck bool Plugins []*NetworkConfig Bytes []byte} api.go则使用上面生成的配置对象,调用对应的网络插件进行网络配置,它里面定义了如下的接口和结构体: 12345678910111213141516171819202122type CNI interface { AddNetworkList(ctx context.Context, net *NetworkConfigList, rt *RuntimeConf) (types.Result, error) CheckNetworkList(ctx context.Context, net *NetworkConfigList, rt *RuntimeConf) error DelNetworkList(ctx context.Context, net *NetworkConfigList, rt *RuntimeConf) error GetNetworkListCachedResult(net *NetworkConfigList, rt *RuntimeConf) (types.Result, error) GetNetworkListCachedConfig(net *NetworkConfigList, rt *RuntimeConf) ([]byte, *RuntimeConf, error) AddNetwork(ctx context.Context, net *NetworkConfig, rt *RuntimeConf) (types.Result, error) CheckNetwork(ctx context.Context, net *NetworkConfig, rt *RuntimeConf) error DelNetwork(ctx context.Context, net *NetworkConfig, rt *RuntimeConf) error GetNetworkCachedResult(net *NetworkConfig, rt *RuntimeConf) (types.Result, error) GetNetworkCachedConfig(net *NetworkConfig, rt *RuntimeConf) ([]byte, *RuntimeConf, error) ValidateNetworkList(ctx context.Context, net *NetworkConfigList) ([]string, error) ValidateNetwork(ctx context.Context, net *NetworkConfig) ([]string, error)}type CNIConfig struct { Path []string exec invoke.Exec cacheDir string} CNIConfig实现了上面定义的接口,这些接口就包含了CNI协议中定义的3种操作:ADD, DEL, CHECK,此外还额外实现了GetVersionInfo()方法,用来支持VERSION操作,AddNetworkList()方法就是链式插件的实现,循环调用配置文件中定义的插件,将前一个插件的执行结果作为后一个插件的参数输入,而AddNetwork()是单独调用一个网络插件,其核心代码如下: 1234567891011func (c *CNIConfig) addNetwork(ctx context.Context, name, cniVersion string, net *NetworkConfig, prevResult types.Result, rt *RuntimeConf) (types.Result, error) { c.ensureExec() pluginPath, err := c.exec.FindInPath(net.Network.Type, c.Path) ...... newConf, err := buildOneConfig(name, cniVersion, net, prevResult, rt) if err != nil { return nil, err } return invoke.ExecPluginWithResult(ctx, pluginPath, newConf.Bytes, c.args("ADD", rt), c.exec)} 容器运行时CNI实现容器运行时可以调用libcni中的conf.go去解析配置文件,调用api.go去构造CNIConfig对象,然后就可以调用对应的方法去给网络插件发送相应的操作了。我们以cri-o容器运行时为例,来介绍下它是如何实现CNI的,cri-o是RedHat主导实现的为Kubernetes量身定制的容器运行时,它实现了CRI和CNI协议,非常具有代表性,但它并没有直接调用libcni,而是定义了另外一个项目,叫ocicni,去间接调用libcni,这个项目作为一个中间层,可以为符合OCI规范的容器运行时提供CNI支持,而不是仅仅为cri-o使用,在这个项目里定义了如下的接口和结构体: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354// ocicni/pkg/ocicni/types.gotype CNIPlugin interface { // Name returns the plugin's name. This will be used when searching // for a plugin by name, e.g. Name() string // GetDefaultNetworkName returns the name of the plugin's default // network. GetDefaultNetworkName() string // SetUpPod is the method called after the sandbox container of // the pod has been created but before the other containers of the // pod are launched. SetUpPod(network PodNetwork) ([]NetResult, error) // SetUpPodWithContext is the same as SetUpPod but takes a context SetUpPodWithContext(ctx context.Context, network PodNetwork) ([]NetResult, error) // TearDownPod is the method called before a pod's sandbox container will be deleted TearDownPod(network PodNetwork) error // TearDownPodWithContext is the same as TearDownPod but takes a context TearDownPodWithContext(ctx context.Context, network PodNetwork) error // GetPodNetworkStatus is the method called to obtain the ipv4 or ipv6 addresses of the pod sandbox GetPodNetworkStatus(network PodNetwork) ([]NetResult, error) // GetPodNetworkStatusWithContext is the same as GetPodNetworkStatus but takes a context GetPodNetworkStatusWithContext(ctx context.Context, network PodNetwork) ([]NetResult, error) // NetworkStatus returns error if the network plugin is in error state Status() error // Shutdown terminates all driver operations Shutdown() error}// ocicni/pkg/ocicni/ocicni.gotype cniNetworkPlugin struct { cniConfig *libcni.CNIConfig sync.RWMutex defaultNetName netName networks map[string]*cniNetwork nsManager *nsManager confDir string binDirs []string shutdownChan chan struct{} watcher *fsnotify.Watcher done *sync.WaitGroup ......} cniNetworkPlugin结构体实现了上面定义的CNIPlugin接口,在初始化这个结构体时,会调用libcni中的方法来从json格式的网络配置文件构造NetworkConfig对象,然后在SetUpPod()方法中,又会调用libcni中的方法去执行添加网络等操作,如下: 1234567891011// ocicni/pkg/ocicni/ocicni.gofunc (network *cniNetwork) addToNetwork(ctx context.Context, rt *libcni.RuntimeConf, cni *libcni.CNIConfig) (cnitypes.Result, error) { logrus.Infof("About to add CNI network %s (type=%v)", network.name, network.config.Plugins[0].Network.Type) res, err := cni.AddNetworkList(ctx, network.config, rt) if err != nil { logrus.Errorf("Error adding network: %v", err) return nil, err } return res, nil} 所以ocicni这个项目,又在Pod这个维度进一步对libcni进行封装,使得容器运行时在实现CNI时进一步简化,这样在cri-o中,只需要初始化一个cniNetworkPlugin: 12345678910111213// cri-o/pkg/config/config.gofunc (c *NetworkConfig) Validate(onExecution bool) error { // Init CNI plugin cniPlugin, err := ocicni.InitCNI( c.CNIDefaultNetwork, c.NetworkDir, c.PluginDirs..., ) c.cniPlugin = cniPlugin}func (c *NetworkConfig) CNIPlugin() ocicni.CNIPlugin { return c.cniPlugin} 然后在实现CRI协议中定义的RunPodSandbox()接口时,直接调用cniPlugin的SetUpPod()方法就行了: 12// cri-o/server/sandbox_network.go_, err = s.config.CNIPlugin().SetUpPodWithContext(startCtx, podNetwork) 以上是倒叙的方式从底层向上层梳理了一遍,如果反过来,从上层到底层的调用顺序就是:cri-o –> ocicni –> libcni 网络插件CNI实现CNI还实现了一些基础的网络插件,包含了bridge, ipvlan, macvlan等主插件,还有dhcp, host-local, static等IPAM插件,以及tuning, portmap等其它插件。我们以bridge为例,来看看网络插件是如何实现CNI的,bridge用来将容器桥接到网桥上,并且在network namespace里面添加网卡,设置IP,配置路由等,是一种很基础很常用的容器网络模型。网络插件的实现,主要是实现CNI协议中定义的4种操作(ADD, DEL, CHECK, VERSION)的具体方法,比如bridge插件中,实现了下面三个方法: 123func cmdAdd(args *skel.CmdArgs) error {}func cmdCheck(args *skel.CmdArgs) error {}func cmdDel(args *skel.CmdArgs) error {} 分别对应ADD, DEL, CHECK操作,然后其main()方法如下: 123func main() { skel.PluginMain(cmdAdd, cmdCheck, cmdDel, version.All, bv.BuildString("bridge"))} skel.PluginMain()方法就来自于cni项目中的skel模块,在该模块中定义了从环境变量中读取传递的参数的公共方法,然后根据环境变量,回调注册进来的对应方法,利用skel这个框架,网络插件就可以只实现具体的动作,而不用去重复实现变量处理等操作了。 总结本篇文章,首先简单介绍了下CNI协议的内容,然后重点介绍了容器运行时和网络插件是如何实现CNI协议的,容器运行时主要依赖libcni去实现,但是可以引入一个ocicni中间层,做进一步抽象,而网络插件,则可以利用cni项目提供的skel框架,实现了所有网络插件都需要用到的公共代码,让网络插件只实现具体的动作即可,简化了插件开发。","link":"/2021/09/20/kubernetes/kube-kubelet-cni.html"},{"title":"Kubernetes Kubelet CRI 机制解析","text":"CRI机制简介CRI机制早在2016年的1.5版本就发布出来了,官方在这篇博文中做了介绍,引入CRI的目的是为了让Kubernetes能够对接多种Container Runtime,而不仅限于Docker这一种。我们知道Docker曾经是容器领域的王者,它的出现开启了容器化时代,紧随其后,又出了很多种其他的容器技术,并且随着容器技术的流行,自然而然产生了对容器编排的需求,于是又涌现了一批容器编排的技术,而Kubernetes就是其中的佼佼者,凭借其强大的社区力量和先进的设计理念,很快独领风骚,在众多竞争者中脱颖而出。 早期的Kubernetes仅仅支持Docker这一种容器化技术,根本没有设计什么插件化的机制去支持其他的容器技术,一来可以快速迭代出原型,二来也没有这个必要,当时容器领域基本是Docker的天下,但是随着Docker的各种骚操作,很快容器领域有了其他竞争者,于是让Kubernetes支持其他容器技术的呼声就越来越高,但是对当时的Kubernetes架构来说,如果要支持其他容器技术,就要对Kubernetes的代码做很多ugly的修改,想想当嵌入的容器技术多了的话,维护起来必然很困难,因此一种优雅的插件机制就呼之欲出,很快,社区就设计出了CRI。其实这也是一个项目在走向成熟的路上,必然要经历的阶段,尤其是像Kubernetes这种偏向底层的资源管理的技术,它要管理很多种不同的资源,一定是向着松耦合,可扩展的方向发展,社区只维护核心的代码逻辑,对应的资源厂商以及社区在核心代码树之外,维护各自的插件。 CRI的设计规范可以在这里查看,它使用grpc和protocol buffers技术,将对container runtime的调用转换为远程过程调用,kubelet作为grpc的客户端,container runtime作为grpc的服务端,他们之间走的数据格式为protocol buffers,CRI规定了远程过程调用的接口,container runtime只要相应的实现了这些接口,Kubernetes就可以利用CRI与其进行对接,其架构图如下: 有的container runtime不支持CRI定义的这些接口,比如Docker,一方面它比Kubernetes出现的早,另一方面它也有跟Kubernetes竞争的产品,叫做Docker Swarm,所以它并不打算实现CRI协议,这种情况就可以引入一个中间层,也就是上图中的CRI shim,作为grpc的服务端来实现CRI定义的接口,然后再将其转换成Docker自己的接口协议,向其发起请求,Docker对应的CRI shim叫做dockershim,目前是内置在Kubernetes的代码中,由Kubernetes社区在维护。但是有的container runtime天生就是支持CRI协议的,比如cri-o,从名字上就可以看出它是专门针对Kubernetes的CRI协议来实现的,是一个非常轻量级的container runtime,甚至单独使用都没有意义,它不像Docker一样,可以独立使用,有各种丰富的功能,但是这些功能可能对Kubernetes来说并没有什么用处,cri-o是红帽主导开发的,目前在红帽的OpenShift 4.0版本中,已经替代Docker成为了默认的container runtime,此外支持CRI的还有rkt, frakti, cri-containerd等容器技术。 这里非常有必要提一下去年,也就是2020年,发布的1.20版本,Kubernetes将Docker标为了deprecated,说要在将来的版本中移除对Docker的支持,一石激起千层浪,瞬间引起了国内外广泛的讨论,甚至引起了恐慌,要知道绝大多数运行Kubernetes的,底层都是使用的Docker,要是移除了对Docker的支持,那这些环境该何去何从,未来该如何技术选型,为此Kubernetes社区还专门出了一篇博文来进行解释,大意就是告诉大家不要慌,目前只是打了一个Warning日志进行提示,但是未来一定会移除的,然后告诉大家为什么这么做,以及除了Docker之外,还有哪些选择,但是如果坚持想用Docker的话,还是可以继续使用的,只是得有人去维护dockershim。这件事其实从2016年引入CRI机制时,就注定会发生的,只是时间问题,它成为了一个标志性事件,一方面标志着Kubernetes成为了事实上容器编排领域的王者,可以对曾经容器领域的王者say no,Docker在Kubernetes中不再是特殊的存在,另一方面其实说明了Docker在容器以及容器编排领域逐渐被竞争对手赶超,风光不再,如果它再不支持CRI协议,或者是维护好dockershim,那用Docker的人会越来越少,或者至少应用场景会很受限,更多的可能是用在开发者的桌面环境中,这对Docker来说,其实是一个生死挑战。好了,相比这些,其实我还是对kubelet在哪打出来的那条warning日志更感兴趣一些,以及将来它要移除对Docker的支持的话,会去改动哪些代码,我们还是先来看看那个Warning日志吧: 1234567891011121314151617witch containerRuntime { case kubetypes.DockerContainerRuntime: klog.Warningf("Using dockershim is deprecated, please consider using a full-fledged CRI implementation") if err := runDockershim( kubeCfg, kubeDeps, crOptions, runtimeCgroups, remoteRuntimeEndpoint, remoteImageEndpoint, nonMasqueradeCIDR, ); err != nil { return err } case kubetypes.RemoteContainerRuntime: // No-op. break 这条日志,在kubelet服务启动的时候,如果container runtime是docker的话,就会打出来。container runtime就只有两个选项,一个是docker,一个是remote,如果是remote的话,就直接走的是CRI接口协议,但是为了统一行为,如果选择docker的话,也是走的CRI协议,只不过它不能直接跟Docker交互,而是需要通过dockershim来中转一下,dockershim就相当于是grpc的服务端,只不过它是启动在kubelet的一个线程中的。 CRI机制分析下面我们来分析下CRI这套插件机制是怎么设计的吧,其实整体上,可以分为三层,我们从底层到上层依次来分析下: CRI客户端和服务端的接口规范CRI的接口协议定义在cri-api这个项目中,除了包含一个proto格式的接口协议之外,还包含一个go实现的lib库,这个lib库是使用grpc的工具根据proto文件自动生成的,在这个lib库中定义了CRI的接口,包括客户端和服务端的接口,并且实现了发送protocol buffer格式的grpc请求的客户端逻辑,这样CRI接口的实现者,就可以直接调用这些lib库中的客户端代码,方便高效的发送请求给服务端,我们来看下相关的静态类图: 可以看到,在CRI的接口规范中,定义了两个客户端相关的接口:RuntimeServiceClient和ImageServiceClient,前者定义了容器相关的行为规范,主要是Container和PodSandbox的增删查改,后者定义了和镜像相关的行为规范,比如拉取镜像,查询镜像列表等,然后又定义了runtimeServiceClient和imageServiceClient这两个结构体,分别实现了这两个客户端接口中定义的方法,实现的主要逻辑就是向服务端发送相应的protocol buffer格式的grpc请求,这两个结构体中的grpc.ClientConn变量就是跟服务端的连接,然后在kubelet的CRI客户端代码中,即remoteRuntimeService和remoteImageService中,直接引用了这两个结构体作为成员变量,用来作为跟remote container runtime交互的桥梁。来看一个runtimeServiceClient实现的发送grpc请求的例子: 123456789func (c *runtimeServiceClient) RunPodSandbox(ctx context.Context, in *RunPodSandboxRequest, opts ...grpc.CallOption) (*RunPodSandboxResponse, error) { out := new(RunPodSandboxResponse) err := c.cc.Invoke(ctx, "/runtime.v1alpha2.RuntimeService/RunPodSandbox", in, out, opts...) if err != nil { return nil, err } return out, nil} 除了客户端的接口,CRI中还定义了服务端的两个接口:RuntimeServiceServer和ImageServiceServer,可以看到跟客户端的接口是一一对应的,只不过输入输出的参数不一样,grpc服务端需要一一实现这些接口,才能够正确接收到客户端发来的CRI协议的请求,比如cri-o中的Server结构体就完全实现了这两个接口中的方法,在这些方法中,不同的container runtime又会有各自的实现逻辑,但是对外的接口是统一的,这样kubelet就可以跟这些container runtime无缝对接了,完全不需要改代码,仅仅是改个配置而已,这种方式简洁优雅,而且代码易维护,这就是标准规范带来的好处。 CRI Services接口规范除了上面核心的客户端和服务端的接口之外,cri-api还定义了一些Services接口,这些接口其实还是定义了一些容器和镜像相关的操作,只不过这些接口更偏向上层一些,它们由Kubernetes这一侧去实现,在实现的方法中,构造了cri-api中发送grpc请求需要用到的参数,然后对grpc请求的返回值进行处理,主要是错误处理,所以Services这层接口的作用,相当于是规范了Kubernetes在调用CRI接口时的行为规范,其类图如下: Kubelet中的remoteRuntimeService和remoteImageService结构体分别实现了这些接口方法,在这些方法中,构造好发送grpc请求用到的参数,比如Context, RunPodSandboxRequest等,然后调用cri-api中定义的runtimeServiceClient和imageServiceClient去发送相应的grpc请求,然后再对grpc返回值做进一步处理,比如取出返回值的有用信息,或者是错误处理,来看一个例子: 1234567891011121314151617func (r *remoteRuntimeService) ListPodSandbox(filter *runtimeapi.PodSandboxFilter) ([]*runtimeapi.PodSandbox, error) { klog.V(10).Infof("[RemoteRuntimeService] ListPodSandbox (filter=%v, timeout=%v)", filter, r.timeout) ctx, cancel := getContextWithTimeout(r.timeout) defer cancel() resp, err := r.runtimeClient.ListPodSandbox(ctx, &runtimeapi.ListPodSandboxRequest{ Filter: filter, }) if err != nil { klog.Errorf("ListPodSandbox with filter %+v from runtime service failed: %v", filter, err) return nil, err } klog.V(10).Infof("[RemoteRuntimeService] ListPodSandbox Response (filter=%v, items=%v)", filter, resp.Items) return resp.Items, nil} 实现了这些接口方法,在Kubelet的管理Runtime的Manager中,即kubeGenericRuntimeManager,就可以直接引用remoteRuntimeService和remoteImageService这两个service变量,去跟Remote Container Runtime进行交互,相关的参数构造以及错误处理,在Service这个层面就处理好了,Manager这一层就不用再关心这些相对偏底层的信息。 Kubelet Runtime ManagerKubelet Runtime Manager就是Kubelet中一个非常重要的Manager,关于Manager,在Kubelet机制概述中介绍过,RuntimeManager的作用就是管理本节点上的容器和镜像,将定义在apiserver中的Pod对象声明,在Container Runtime中同步出来,主要是对Pod的增删改这些操作,RuntimeManager在处理Pod同步的SyncLoop中被调用来执行相关的操作,而RuntimeManager又是通过上面介绍到的remoteRuntimeService和remoteImageService来跟Remote Container Runtime打交道的,来看下其类图: 在kubelet/container/runtime.go中定义了RuntimeManager需要实现的接口,比如处理同步Pod操作的SyncPod(),执行垃圾回收的GarbageCollect()等等,kubeGenericRuntimeManager结构体则实现了这些接口,这些实现的方法分布在kuberuntime_xxx.go文件中,然后kubeGenericRuntimeManager被Kubelet这个结构体引用,赋值给其中的containerRuntime, streamingRuntime, runner等成员变量,后续Kubelet都是通过它们来跟Remote Container Runtime间接进行交互的。 总结本文先介绍了下CRI的背景知识,然后重点介绍了CRI的协议规范,以及Kubelet中是如何使用CRI机制的,Kubelet对CRI这套机制的设计和应用,从上到下,可以分为这么几层:Kubelet –> kubeGenericRuntimeManager –> remoteRuntimeService/remoteImageServcie –> runtimeServiceClient/imageServiceClient,每一层都有对应的接口规范,并且依赖下一层提供的功能来实现本层相关的业务逻辑,处于最底层的,就是CRI协议的核心内容,规定了CRI的客户端和服务端需要遵循的接口规范,也是我们本文的重点内容,任何实现了CRI服务端接口的容器运行时,就可以跟Kubelet进行对接,这种设计思路和编码方式,很值得学习。","link":"/2021/09/07/kubernetes/kube-kubelet-cri.html"},{"title":"Kubernetes Scheduler性能优化之Cache","text":"背景在Kubernetes Scheduler机制概览中就介绍过Cache的作用,它的主要作用是加速调度过程中查询pod和node等信息的速度,此外,还有Snapshot机制,即为了保持调度时数据的一致性,对缓存打的快照。 在查看Cache相关的代码时,发现Cache中有一些很奇怪的数据结构,看不明白为什么要做这样的设计,于是就追根溯源去查了下相关的issue和pr,了解了下当时的设计背景,发现针对scheduler的性能优化,还真是一个漫长的演进过程,从2015年就开始了,逐渐变成了现在这个样子,本篇文章就盘点一下,当年为提升scheduler性能而做的一些优化,顺便解释下Cache中那些奇怪的数据结构设计。 数据结构我们先来看看现在cache和snapshot数据结构长什么样子: Cache相关的数据结构: 1234567891011121314151617181920212223242526type schedulerCache struct { stop <-chan struct{} ttl time.Duration period time.Duration // This mutex guards all fields within this cache struct. mu sync.RWMutex // a set of assumed pod keys. // The key could further be used to get an entry in podStates. assumedPods map[string]bool // a map from pod key to podState. podStates map[string]*podState nodes map[string]*nodeInfoListItem // headNode points to the most recently updated NodeInfo in "nodes". It is the // head of the linked list. headNode *nodeInfoListItem nodeTree *nodeTree // A map from image name to its imageState. imageStates map[string]*imageState}type nodeInfoListItem struct { info *framework.NodeInfo next *nodeInfoListItem prev *nodeInfoListItem} 首先是为什么要有Cache?Informer中不是已经有了缓存了吗?难道那个还不能满足需求?其次,既然cache是做pod和node的缓存的,那么schedulerCache中podStates和nodes就很好理解了,使用Map来作为缓存的结构,通过哈希可以快速的找到某一个具体的node或者pod,但是令人迷惑的是headNode和nodeTree这两个结构,headNode是一个双向列表的第一个元素,通过next和prev指向下一个和前一个元素,这里竟然使用nodeInfo构造了一个双向链表,有map还不够吗?用意为何?此外nodeTree里面只保存了node的name,从map到slice的映射,这个又是做什么用的? Snaphost相关的数据结构: 123456789type Snapshot struct { // nodeInfoMap a map of node name to a snapshot of its NodeInfo. nodeInfoMap map[string]*framework.NodeInfo // nodeInfoList is the list of nodes as ordered in the cache's nodeTree. nodeInfoList []*framework.NodeInfo // havePodsWithAffinityNodeInfoList is the list of nodes with at least one pod declaring affinity terms. havePodsWithAffinityNodeInfoList []*framework.NodeInfo generation int64} Snapshot是用来给Cache打快照的,那nodeInfoMap这个map还不够吗?为什么还有个nodeInfoList这样一个list? 原因探究1. 为什么要引入Cache带着这些疑问,我们将历史的年轮拨回2015年,先来看看为什么要引入Cache,相关的issue和pr如下: https://github.com/kubernetes/kubernetes/issues/18831 https://github.com/kubernetes/kubernetes/pull/20669 https://github.com/kubernetes/kubernetes/pull/21016 在该issue中,作者说的很明白,是因为当前的调度算法的复杂度是O(containers),甚至都不是O(pods)的,更别提O(nodes)了,在每一次调度时,都会遍历一遍该node上所有的containers,目的是为了计算出已有pod的request值,然后跟当前被调度的pod的request值进行比较,时间大部分被浪费在了遍历containers计算request值上了,因此改进的方法就是依赖informer的事件机制,动态的在本地再维护一份聚合之后的缓存,这样调度时,就可以直接获取到对应的数据,而不用实时计算了,可以看下缓存的node聚合之后的数据结构: 12345678910111213141516171819202122232425262728293031323334353637383940// NodeInfo is node level aggregated information.type NodeInfo struct { // Overall node information. node *v1.Node // Pods running on the node. Pods []*PodInfo // The subset of pods with affinity. PodsWithAffinity []*PodInfo // Ports allocated on the node. UsedPorts HostPortInfo // Total requested resources of all pods on this node. This includes assumed // pods, which scheduler has sent for binding, but may not be scheduled yet. Requested *Resource // Total requested resources of all pods on this node with a minimum value // applied to each container's CPU and memory requests. This does not reflect // the actual resource requests for this node, but is used to avoid scheduling // many zero-request pods onto one node. NonZeroRequested *Resource // We store allocatedResources (which is Node.Status.Allocatable.*) explicitly // as int64, to avoid conversions and accessing map. Allocatable *Resource // ImageStates holds the entry of an image if and only if this image is on the node. The entry can be used for // checking an image's existence and advanced usage (e.g., image locality scheduling policy) based on the image // state information. ImageStates map[string]*ImageStateSummary // TransientInfo holds the information pertaining to a scheduling cycle. This will be destructed at the end of // scheduling cycle. // TODO: @ravig. Remove this once we have a clear approach for message passing across predicates and priorities. TransientInfo *TransientSchedulerInfo // Whenever NodeInfo changes, generation is bumped. // This is used to avoid cloning it if the object didn't change. Generation int64} 可以看到,聚合的数据包括该节点上已经分配的ports信息,以及该节点所有pod的request之和,还有已经分配出去的cpu/memory资源之和等。 下面两个pr就是cache的实现,优化之后,30K个pod的调度延迟从200ms缩小到20ms,大大提高了性能,算法复杂度从O(containers)变成了O(nodes),即调度效率跟containers个数无关,跟nodes节点个数有关。 2. 为什么要引入SnapshotSnapshot是对Cache打的快照,这个机制最早是在下面的PR中引入: https://github.com/kubernetes/kubernetes/pull/67308 https://github.com/kubernetes/kubernetes/issues/67260 通过查看issue可以知道,这是在跑测试的时候,发现的缓存不同步,导致的调度失败,究其本质原因,是因为在某一个Pod调度过程中,其使用的cache发生了变化,尤其是在pod和node发生变更时,cache会实时更新,导致调度前后获取的信息不一致,才出现了问题。因此解决办法就是给Cache打一个快照,将Cache中的数据clone一份到Snapshot中,并且使用一个单调递增的值来标识这个快照的版本,即Snapshot中的generation字段,某个版本的Snapshot中的数据是不会发生变化的。 Snapshot发展到现在,已经将调度过程中,需要查询node和pod信息的地方,全都切换到了snaphost: https://github.com/kubernetes/kubernetes/issues/83922 https://github.com/kubernetes/kubernetes/pull/83921 https://github.com/kubernetes/kubernetes/pull/84293 切换到Snaphost之后,对性能有一个明显的好处,就是因为Snapshot是只读的,访问它的数据是不需要加锁的,这样在调度过程中,将很多地方实现了“无锁化”,将性能又提升了2倍。 3. Cache中为什么要用双向链表所谓双向链表,就是schedulerCache结构中的成员变量:headNode *nodeInfoListItem所表示的结构,headNode只存储链表中的第一个元素,通过next和prev指针,来指向下一个和前一个元素,但是需要注意的是链表中存储的实际是nodeInfo的地址,并不是实际的数据。我们在上大学的时候,在数据结构课程上,就学习过双向链表这个结构,知道它的主要特点是体现在遍历上,即可以后序遍历,又可以前序遍历,并且插入删除节点,只是改变指针的指向,并没有实际移动数据,那这里引入双向链表又是为何呢?来看看引入双向链表的pr: https://github.com/kubernetes/kubernetes/pull/74041 这个pr说,通过测试发现,在给cache打snapshot时,花费了较多的CPU时间,通过引入双向列表,将最近更新的node放到双向列表的列表头,再结合Snapshot Generation机制,大大缩减了给Cache打快照的时间,我们来看下给Cache打快照的核心代码逻辑变化: 以前是遍历cache中的nodes列表,现在变成了遍历这个双向列表,而一但发现node中的generation值比snapshot中的generation值小的时候,就退出遍历,之所以能退出遍历,是因为所有对node的最近更新都被放到了链表头,而每次更新node,node中的generation值都会单调递增,如果在向后遍历node链表时,发现generation值比打快照时的值低了,那说明后面的node没有被更新过,就不需要再往后去更新了,只更新打快照之后,更新过的node就可以了。所以这里引入双链表,就是为了能够将“最近更新”过的Node放到一起,遍历时,只遍历这些更新过的node即可,而不用向以前一样,全量遍历整列表。当pod或者node有更新时,会将对应的更新之后的node节点放到链表头部位置,来看下这个移动的过程: 123456789101112131415161718192021222324func (cache *schedulerCache) moveNodeInfoToHead(name string) { ni, ok := cache.nodes[name] if !ok { klog.Errorf("No NodeInfo with name %v found in the cache", name) return } // if the node info list item is already at the head, we are done. if ni == cache.headNode { return } if ni.prev != nil { ni.prev.next = ni.next } if ni.next != nil { ni.next.prev = ni.prev } if cache.headNode != nil { cache.headNode.prev = ni } ni.next = cache.headNode ni.prev = nil cache.headNode = ni} 通过局部遍历代替全量遍历,加快了Cache打快照的时间,性能提升了20%,在5000个node的规模下,调度10000个pod,延迟从8.5ms降低到了6.7ms。 不过,One more thing,还有一个未解之谜,就是为什么是“双向”列表?看代码这里遍历只用到了后序遍历,并没有找到在哪用到了前序遍历。如果只是后序遍历的话,那单向列表完全就满足需求了啊,用双向列表不是增加了复杂度吗?难道是作者在炫技?或者是后序可能还会用到后序遍历,这里只是留下了口子?迷惑的行为又增加了。。。 4. Cache中的nodeTree是做什么用的nodeTree主要是考虑到node跨多zone而设计的,甚至还有pod针对zone的亲和性和反亲和性,所以在给某个pod选择合适的node时,node的遍历顺序还是很重要的,nodeTree主要决定了node的遍历顺序,其结构如下: 123456789101112131415161718// nodeTree is a tree-like data structure that holds node names in each zone. Zone names are// keys to "NodeTree.tree" and values of "NodeTree.tree" are arrays of node names.// NodeTree is NOT thread-safe, any concurrent updates/reads from it must be synchronized by the caller.// It is used only by schedulerCache, and should stay as such.type nodeTree struct { tree map[string]*nodeArray // a map from zone (region-zone) to an array of nodes in the zone. zones []string // a list of all the zones in the tree (keys) zoneIndex int numNodes int}// nodeArray is a struct that has nodes that are in a zone.// We use a slice (as opposed to a set/map) to store the nodes because iterating over the nodes is// a lot more frequent than searching them by name.type nodeArray struct { nodes []string lastIndex int} 所谓tree,其实就是zone到该zone所包含node列表的一个映射,遍历node列表时,是以轮询zone的方式进行遍历的,以Index来标识当前遍历到哪个zone或者node了,比如zone1(node1, node2, node3), zone2(node4, node5, node6),那么所谓”轮询zone”的遍历方式,就是以node1, node4, node2, node5, node3, node6的顺序进行遍历。 而因为调度时,都需要切换到使用snapshot里的信息,所以nodeTree也需要被打快照到Snapshot中,相关逻辑在下面的pr中实现: https://github.com/kubernetes/kubernetes/pull/84014 但是该快照没有直接使用nodeTree这个结构,而是按照nodeTree的遍历顺序,将其变换成了一维的数组,即Snapshot结构体中的nodeInfoList,这样,在调度时,就可以直接遍历这个nodeInfoList了: 1allNodes, err := g.nodeInfoSnapshot.NodeInfos().List() 通过将nodeTree打快照到Snapshot中,就可以进一步“无锁化”,将性能又提高了3%。 总结从上面的性能优化过程可以看出来,引入Cache将性能提升了一个大台阶,在Cache基础上,又引入了Snapshot,逐步实现了“无锁化”的调度过程,将调度延迟从200ms降低到了6.7ms,满足了大多数集群规模对调度性能的要求。","link":"/2020/12/20/kubernetes/kube-scheduler-cache.html"},{"title":"OpenStack可靠性验证和故障演练-Gremlin","text":"最近做了一个项目,叫做OpenStack可靠性验证和故障演练,主要是使用Ansible把平台运维过程中遇到的一些经典问题给编排了一下,算是将这些运维经验通过代码的方式给积累下来,当然,该项目不仅仅是一些故障case的累积,它还系统性的对OpenStack云平台的可靠性,可用性进行验证,通过关停一些服务,down一些节点,验证其鲁棒性。 最近在看一本书,讲的是infrastructure as code的思想,介绍了一个概念,叫做Antifragility,也正好切合这个项目的主旨: The effect of physical stress on the human body is an example of antifragility in action. Exercise puts stress on muscles and bones, essentially damaging them, causing them to become stronger. Protecting the body by avoiding physical stress and exer‐ cise actually weakens it, making it more likely to fail in the face of extreme stress. Similarly, protecting an IT system by minimizing the number of changes made to it will not make it more robust. Teams that are constantly changing and improving their systems are much more ready to handle disasters and incidents. 一个系统想要获得稳定的运行,就像人想要获得健康的身体一样,得锻炼,在锻炼中逐渐变得健壮,如果害怕它出事,越不敢动它,那它会变得越来越弱。 于此类似的,AWS的系统众所周知是非常稳定的,他们倡导的设计理念中,有一个很重要的概念是:Design for Failures,为失效而设计,即充分考虑所有系统失效的可能性,并假定一切低概率故障事件都可能发生,在这种情况下来设计你的业务系统。 这些思想都是极好的,因此为了能够让这个思想在OpenStack领域落地,于是有了Gremlin这个项目,并且已经通过Github开源了,gremlin这个单词的意思是爱搞破坏的小精灵,它会对这个系统做各种各样的破坏,系统可能有一定的鲁棒性,能够承受这个破坏,这些case主要来验证云平台的高可用性,有的可能把系统搞挂了,这个主要是用来做故障演练,是我们平时运维过程中,遇到的一些经典case的回放,如果能够把这些case用来培训新人,想必是极有帮助的。 Gremlin的代码是经过精心设计,进行了比较好的抽象,可以很方便的添加测试用例,而且覆盖面很全,对系统的各个方面进行了系统性的覆盖,它不仅能够验证,甚至还可能发现一些平时没有遇到的问题。 为了更好的宣传这个项目,还请我们的美女设计师帮忙设计了一个logo,我的要求是:萌萌的,坏坏的,贱贱的,设计师很好的Get到了我的要点,于是有了下面这个呆萌蠢: 项目地址是:https://github.com/unitedstack/gremlin 希望能有更多的人贡献他们的经验,积累越来越多的case,让这个项目变得越来越完善。","link":"/2017/09/11/openstack/gremlin/gremlin.html"},{"title":"OpenStack Heat嵌套Stack解析","text":"Stack是Heat里面抽象出来的概念,是一组资源的集合,Stack之间是可以嵌套的,也就是说一个Stack可以有子Stack,子Stack又可以有子Stack,关于Heat Stack的基础知识,见这篇文章:Heat基础知识 这篇文章重点介绍下嵌套stack在heat里面的实现,因为在实际使用heat时,模板稍微复杂一点,就会用到嵌套的stack,当嵌套的层数非常多时,里面的关系就会变得非常复杂,一旦遇到一些问题,如果不懂其实现的原理,就会束手无策。 所谓嵌套,其实就是需要在数据库里面记录清楚各个资源之间的关系,谁是父,谁是子,把数据库的关联关系搞清楚了,嵌套stack的原理也就清楚了,所以重点是数据库模型是怎么建模的。 Heat里面有两个最重要的概念,一个是刚刚提到的stack,还有一个是resource,resource代表的是某个stack里面封装的资源,也就是说某个stack里面有哪些resource,嵌套的关键点就在这里,因为stack可以嵌套stack,所以被嵌套的stack其实也是一个resource。 我们以tripleo里面的几层嵌套stack为例,来进行下解析。TripleO里面关于基础网络的相关的逻辑,被封装在多个模板中,这些模板通过互相引用,形成了嵌套stack,有如下几个模板: overcloud.yaml #该模板是tripleo最上层的模板 1234resources: Networks: type: OS::TripleO::Network network/networks.yaml 12345resources: ExternalNetwork: type: OS::TripleO::Network::External network/external.yaml 12345678910resources: ExternalNetwork: type: OS::Neutron::Net properties: ... ExternalSubnet: type: OS::Neutron::Subnet properties: ... 上面自定义的资源类型的映射关系如下: 12OS::TripleO::Network: network/networks.yamlOS::TripleO::Network::External: network/external.yaml 从 Heat基础知识 里面我们可以知道这是嵌套了3层,总共有三层stack,三层resource,即overcloud.yaml中定义的 Networks 是 overcloud.yaml 这个stack的resource,但同时,Networks 自己也嵌套了stack,即 network/networks.yaml,network/networks.yaml这个stack里又包含了ExternalNetwork这个resource,跟Networks类似,ExternalNetwork在作为resource的同时,也嵌套了自己的stack,即network/external.yaml,而network/external.yaml这个stack里面包含的ExternalNetwork和ExternalSubnet就是真正的资源了,因为他们对应的type已经是最小粒度的type了,没有再嵌套了,OS::Neutron::Net和OS::Neutron::Subnet就分别对应的是Neutron里面的网络和子网了,这几个模板的目的,就是要在Neutron里建External Network和对应的Subnet。 像Networks和ExternalNetwork这样的类型的resource,因为他们具有双重身份,既是resource,又是stack,在Heat里面,这样的resource会被抽象为StackResource,StackResource是Resource的子类,即将这些resource当成stack来看待。在StackResource类中会有has_nested()和nested()等方法,来判断这个resource是不是一个嵌套的stack,如果是的话,可以通过nested()来将这个resource封装成一个Stack对象,所以,可以简单理解成:stack也是一种resource。 那这些关系,在数据库里面是怎么关联的呢?在数据库中对应的有两个表,分别为stack和resource表,在这两个表中记录了stack和resource的关联关系,我们将上面的例子里面的stack和resource,一层一层从数据库中查出来相关的记录,就比较清楚了: 首先找最上层的stack,即overcloud: 123456MariaDB [heat]> select id,name from stack where id="2c4efe8b-738f-4536-a8ec-d2804dad5b88";+--------------------------------------+-----------+| id | name |+--------------------------------------+-----------+| 2c4efe8b-738f-4536-a8ec-d2804dad5b88 | overcloud |+--------------------------------------+-----------+ 然后找overcloud这个stack里面包含的资源,它包含了很多资源,我们这里只找Networks这个资源: 123456MariaDB [heat]> select id,nova_instance,stack_id,name from resource where stack_id="2c4efe8b-738f-4536-a8ec-d2804dad5b88" and name="Networks";+----+--------------------------------------+--------------------------------------+----------+| id | nova_instance | stack_id | name |+----+--------------------------------------+--------------------------------------+----------+| 37 | 0858496d-149e-40db-9e41-c140ea39735a | 2c4efe8b-738f-4536-a8ec-d2804dad5b88 | Networks |+----+--------------------------------------+--------------------------------------+----------+ 注意,这里有一个字段叫做nova_instance,这是个关键点,这个字段并不像字面的意思一样,只表示nova里面instance的id,它真实的含义其实是:physical resource id,它适用于所有的资源,当这个resource代表的是真实的资源时,比如Network, Subnet, Instance, Port等,nova_instance则记录这些资源真实的uuid,通过这些uuid可以show出来对应的资源;而当这个resource代表的不是真实的资源,也即代表的是stack时,nova_instance则表示的是这个stack的uuid,它虽然不是真实的资源,但是可以通过openstack stack show xxx来查询出来这个stack,从这个角度上来说,它也是一个资源,只不过是heat里的stack而已,这跟neutron里的network, subnet, port是一个道理。 从上面的分析我们知道,0858496d-149e-40db-9e41-c140ea39735a是一个stack uuid,我们把这个stack和其包含的resource,从数据库中查出来: 123456MariaDB [heat]> select id,name from stack where id="0858496d-149e-40db-9e41-c140ea39735a";+--------------------------------------+---------------------------------+| id | name |+--------------------------------------+---------------------------------+| 0858496d-149e-40db-9e41-c140ea39735a | overcloud-Networks-7rk5izmwi4nz |+--------------------------------------+---------------------------------+ 同样,Networks这个stack包含了很多的资源,我们只关注ExternalNetwork这个资源: 123456MariaDB [heat]> select id,nova_instance,stack_id,name from resource where stack_id="0858496d-149e-40db-9e41-c140ea39735a" and name = "ExternalNetwork";+------+--------------------------------------+--------------------------------------+-----------------+| id | nova_instance | stack_id | name |+------+--------------------------------------+--------------------------------------+-----------------+| 2567 | 5edc0e4a-6b6a-4964-bb42-236f0ef834cf | 0858496d-149e-40db-9e41-c140ea39735a | ExternalNetwork |+------+--------------------------------------+--------------------------------------+-----------------+ 可以看到,上面查询出来的stack,其名字不叫Networks,而是叫overcloud-Networks-7rk5izmwi4nz,从名字上,就可以看到stack的嵌套关系,是 父stack的名字 + 对应的资源名字 + 随机字符串,形成的子stack的名字。 overcloud-Networks-7rk5izmwi4nz这个stack里面包含了一个叫做ExternalNetwork的resource,从上面我们知道,这个ExternalNetwork其实也是一个嵌套的stack,即nova_instance这个字段表示的仍然是一个stack id,我们可以继续往下查: 123456MariaDB [heat]> select id,name from stack where id="5edc0e4a-6b6a-4964-bb42-236f0ef834cf";+--------------------------------------+--------------------------------------------------------------+| id | name |+--------------------------------------+--------------------------------------------------------------+| 5edc0e4a-6b6a-4964-bb42-236f0ef834cf | overcloud-Networks-7rk5izmwi4nz-ExternalNetwork-q6hqzjzffbzb |+--------------------------------------+--------------------------------------------------------------+ 1234567MariaDB [heat]> select id,nova_instance,stack_id,name from resource where stack_id="5edc0e4a-6b6a-4964-bb42-236f0ef834cf";+----+--------------------------------------+--------------------------------------+-----------------+| id | nova_instance | stack_id | name |+----+--------------------------------------+--------------------------------------+-----------------+| 48 | 921f17a0-c2c4-49cb-8eaf-56fb736f461c | 5edc0e4a-6b6a-4964-bb42-236f0ef834cf | ExternalSubnet || 49 | 00ee7810-b2cf-48d4-afd2-4f340051d6cb | 5edc0e4a-6b6a-4964-bb42-236f0ef834cf | ExternalNetwork |+----+--------------------------------------+--------------------------------------+-----------------+ 可以看到,这个ExternalNetwork对应的stack名字又多了一层,而这个stack下面有两个resource,和上面network/external.yaml模板中的内容是对应的,而这两个resource对应的nova_instance字段就不是stack id了,而是真正的在Neutron里面的network和subnet id,可以通过neutron net-show 和 subnet-show查看到。 至此,嵌套的stack查询完毕,到了最终要操作的资源。所以,理解嵌套stack,有两个关键点: stack也是一种resource 在数据库层面,通过nova_instance这个字段来对stack和resource进行关联 这种嵌套,其实就是天然的递归,可以一层一层的递归进去,比如在heat里面,查询stack中嵌套的所有资源,就是通过递归去实现的,关键代码如下: 1234567891011121314151617181920def iter_resources(self, nested_depth=0, filters=None): """Iterates over all the resources in a stack. Iterating includes nested stacks up to `nested_depth` levels below. """ for res in self._find_filtered_resources(filters): yield res resources = self._find_filtered_resources() for res in resources: if not res.has_nested() or nested_depth == 0: continue nested_stack = res.nested() if nested_stack is None: continue # 递归查询 for nested_res in nested_stack.iter_resources(nested_depth - 1, filters): yield nested_res 理解了上面的原理,就可以处理一些heat里常遇到的一些奇怪问题了,比如在tripleo里,经常会遇到这样的错误: 1Stack CREATE FAILED (overcloud-Networks-7rk5izmwi4nz-ExternalNetwork-hl6wiafrngab): Resource CREATE failed: Conflict: resources.ExternalNetwork: Unable to create the flat network. Physical network external is in use. 遇到这样的错误,说明overcloud-Networks-7rk5izmwi4nz-ExternalNetwork-hl6wiafrngab这个stack对应的resource在heat里面找不到了,也就是父stack找不到原来的子stack了,关联关系被破坏掉了,所以在执行stack的更新时,会再去建这些resource,但是实际上这些resource是存在的,就创建失败了,导致报了这样的错。解决思路就是按照上面的解析,把这些stack和resource的关系一步一步给梳理出来,然后可以修改相关的字段,主要是nova_instance字段,将正确的关系重新梳理正确,让heat能够找到正确的resource,就可以更新成功了。 修改完之后,如果还遇到这样的问题: 12019-01-05 10:21:51Z [overcloud]: UPDATE_FAILED resources.Networks: resources.TenantNetwork: The Stack (overcloud-Networks-atpivemskzkd-TenantNetwork-wgbvjqwnbxby) could not be found. 可以在数据库中将对应的现在的stack name修改为现在找不到的stack name: 1update stack set name="overcloud-Networks-atpivemskzkd-TenantNetwork-wgbvjqwnbxby" where name="overcloud-Networks-atpivemskzkd-TenantNetwork-4arueh4pzole"; 至于为什么这样,原因待查。","link":"/2018/09/23/openstack/tripleo/heat-nested-stack.html"},{"title":"Heat基础知识","text":"在TripleO中使用到了很多Heat中的语法,需要理解这些基本的语法才能够理解Heat Template。Heat中自定义了一套描述式语言,称为HOT(Heat Orchestration Template),用来描述整个软件栈,包括如何创建资源,如何配置资源等等,HOT中描述的软件栈被抽象成stack,可以定义输入,输出,参数,资源等等,一个stack还可以嵌套其他stack,Heat默认集成了OpenStackh很多组件,可以通过HOT来管理OpenStack资源,此外,还集成了Puppet/Ansible等配置管理工具,形成了一套完整的体系,功能十分强大,这里就主要介绍下部署TripleO过程中用到的Heat一些基础知识。 什么是Stack以及嵌套Stack一个Stack可以说是一个模板描述的软件栈的一个实例,在一个Stack中定义了输入,输出,以及资源这3个主要属性,给出特定的输入,这个Stack会创建定义的资源,然后给出特定的输出,一个包含了输入,输出以及资源的模板,就可以实例化出一个Stack。在TripleO里,充分运用了嵌套stack的概念,只有理解什么是一个Stack,才能够更好的界定TripleO的中定义的模板。比如有如下模板: 12345678910111213141516171819202122232425262728293031323334[stack@pre4-undercloud demos]$ cat test_security.yamlheat_template_version: 2016-10-14parameters: CloudName: default: overcloud.localdomain description: the cloud name type: stringresources: HorizonSecurity: type: OS::Heat::RandomString NovaSecurity: type: ./test_nova_security.yamloutputs: SecurityOutput: description: some descs value: HorizonSecurityOutput: {get_attr: [HorizonSecurity, value]} NovaSecurityOutput: {get_attr: [NovaSecurity, NovaSecurityOutput]} SecurityStringOutput: description: some desc value: str_replace: template: This is HorizonSecurityOutput and NovaSecurityOutput in CloudName params: HorizonSecurityOutput: {get_attr: [HorizonSecurity, value]} NovaSecurityOutput: {get_attr: [NovaSecurity, NovaSecurityOutput]} CloudName: {get_param: CloudName} 123456789101112[stack@pre4-undercloud demos]$ cat test_nova_security.yamlheat_template_version: 2016-10-14resources: NovaSecurity: type: OS::Heat::RandomStringoutputs: NovaSecurityOutput: description: some descs value: {get_attr: [NovaSecurity, value]} test_security.yaml嵌套了test_nova_security.yaml,两个模板都定义了输入,输出以及资源,该例子中使用的资源类型是Heat中定义的生成随机字符串,使用如下的命令可以从模板创建stack: 1openstack stack create -t test_security.yaml test_security 创建好之后,查看创建的stack: 123456[stack@pre4-undercloud demos]$ openstack stack list+--------------------------------------+---------------+-----------------+----------------------+--------------+| ID | Stack Name | Stack Status | Creation Time | Updated Time |+--------------------------------------+---------------+-----------------+----------------------+--------------+| b3c8a10f-9b7e-49a2-a150-4fa2078c1093 | test_security | CREATE_COMPLETE | 2017-04-20T14:34:39Z | None |+--------------------------------------+---------------+-----------------+----------------------+--------------+ 加上–nested可以查看所有嵌套的stack: 123456789[stack@pre4-undercloud demos]$ openstack stack list --nested+--------------------------------+--------------------------------+-----------------+----------------------+--------------+----------------------------------+| ID | Stack Name | Stack Status | Creation Time | Updated Time | Parent |+--------------------------------+--------------------------------+-----------------+----------------------+--------------+----------------------------------+| 9844af34-1dcc-489a-80db- | test_security-NovaSecurity- | CREATE_COMPLETE | 2017-04-20T14:34:40Z | None | b3c8a10f-9b7e- || 953f76baea4a | fl2f6un4mvpt | | | | 49a2-a150-4fa2078c1093 || b3c8a10f-9b7e- | test_security | CREATE_COMPLETE | 2017-04-20T14:34:39Z | None | None || 49a2-a150-4fa2078c1093 | | | | | |+--------------------------------+--------------------------------+-----------------+----------------------+--------------+----------------------------------+ 可以查看某个stack的输出: 123456789101112131415161718[stack@pre4-undercloud demos]$ openstack stack output show test_security --all+----------------------+------------------------------------------------------------------------------------------------------------------------------+| Field | Value |+----------------------+------------------------------------------------------------------------------------------------------------------------------+| SecurityStringOutput | { || | "output_value": "This is zwinXyb5q12VTMSNyEnyfyGBL6mWwbLF and qa31seo3eLE9UJTfegmaPhUfIqJXDiQ7 in overcloud.localdomain", || | "output_key": "SecurityStringOutput", || | "description": "some desc" || | } || SecurityOutput | { || | "output_value": { || | "HorizonSecurityOutput": "zwinXyb5q12VTMSNyEnyfyGBL6mWwbLF", || | "NovaSecurityOutput": "qa31seo3eLE9UJTfegmaPhUfIqJXDiQ7" || | }, || | "output_key": "SecurityOutput", || | "description": "some descs" || | } |+----------------------+------------------------------------------------------------------------------------------------------------------------------+ 12345678910[stack@pre4-undercloud demos]$ openstack stack output show test_security-NovaSecurity-fl2f6un4mvpt --all+--------------------+--------------------------------------------------------+| Field | Value |+--------------------+--------------------------------------------------------+| NovaSecurityOutput | { || | "output_value": "qa31seo3eLE9UJTfegmaPhUfIqJXDiQ7", || | "output_key": "NovaSecurityOutput", || | "description": "some descs" || | } |+--------------------+--------------------------------------------------------+ 从以上的例子中可以看到,只要一个模板定义了输入,输出,以及资源,那它就是一个stack,一个stack的输出是保存在Heat的数据库中的,生成的output类型可以是字符串,可以是对象,还可以是数组等等。Heat中还定义了一些内置的方法,用来执行一些特定任务,比如该例子中用到的str_template,就是定义了一个模板,然后传递了模板变量,在Heat中渲染成了一个字符串,这些内置方法只能在resources的properties字段和outputs字段使用。 知道了一个Stack的构成之后,我们重点介绍几个Resource,这几个Resource是在TripleO中频繁使用的,对理解TripleO Heat Template非常重要。 OS::Heat::RandomString这个是生成一个随机字符串,这在上面的例子中已经介绍过了。 OS::Heat::ResourceChain使用相同的配置创建一个或多个资源,这些资源是通过一个列表来指定的,由resources参数指定,此外还可以加上concurrent参数,并发的创建这些资源,如果不指定concurrent或者设置为false,那么就会按照顺序一个一个创建。 OS::Heat::ResourceGroup也是创建一组配置相同的资源,但是这个和ResourceChain不同的是,它指定的是创建数量,由count参数指定,而不是一个列表,并且是并发创建的,不能控制顺序。它在TripleO里面用来创建Nova里面的Server,即一次性创建指定数量的节点。 OS::Heat::SoftwareConfigSoftwareConfig在TripleO里面作用很重要,是用来定义配置信息的,有两个参数比较重要:一个是config,用来指定配置信息,比如一段脚本,或者Puppet代码,一个是group,用来指定是哪种类型的配置信息,如果config指定的是一段脚本,那么group就是script,如果config是一段puppet代码,那么group就是puppet,根据group的不同,在应用这段配置的时候,会相应的选择不同的策略去执行。需要注意的是config里指定的配置信息是一段字符串,当创建了SoftwareConfig这个resource时,并没有应用这个配置,而仅仅是把配置信息保存在了Heat的数据库中,会被其他的resource引用。 OS::Heat::StructuredConfigStructuredConfig和SoftwareConfig的作用是一样的,不同的是SoftwareConfig中的config指定的是一段字符串,而StructuredConfig中的config指定的是一个结构化的数据,即是一个Map,这对一些使用YAML或者JSON作为配置语言的配置工具来说是有用的。 OS::Heat::SoftwareDeploymentSoftwareDeployment就是真正的将上面定义的配置信息部署到某个Server上,需要指定两个参数:一个是config,即对上面定义的SoftwareConfig或者是StructuredConfig的引用,一个是server,是对某个资源的引用,通常是一个Nova Server的ID。这里可能会有一个疑问:Heat到底是怎么把一段配置最终配置到服务器上的呢?其实创建一个SoftwareDeployment主要做了两件事:一个是在Heat的数据库中产生相应的记录,一个是会将这些配置信息上传到外部的一个Metadata服务器上,在TripleO里,这个Metadata服务器是Swift,而在将要配置的服务器上,即server里,会安装相应的Heat Agent,这个Agent会去Metadata服务器上拉取本节点的配置信息,然后进行配置,Heat Agent是在做OverCloud镜像的时候就安装进去了,这些Agent都被定义在heat-agents这个项目中。 在创建了一个SoftwareDeployment时,这个resource会进入IN_PROGRESS状态,当服务器端配置完成后,Agent会回调Heat的接口,告知配置的结果,以确定成功或者失败。 OS::Heat::StructuredDeploymentStructuredDeployment跟SoftwareDeployment的作用类似,只不过config参数引用的需要是StructuredConfig定义的资源。 OS::Heat::SoftwareDeploymentGroupSoftwareDeploymentGroup跟StructuredDeployment类似,但是它可以指定一组服务器进行配置,即指定了servers参数,来指定多个服务器。 OS::Heat::StructuredDeploymentGroupStructuredDeploymentGroup跟SoftwareDeploymentGroup类似,除了可以指定一组服务器进行配置外,config参数引用的需要是StructuredConfig定义的资源。 以上介绍的这些资源都是在TripleO中使用到的,这里仅仅是简单介绍了下作用,更多的参数以及使用方法请参考Heat的模板文档。","link":"/2017/04/23/openstack/tripleo/heat_basics.html"},{"title":"TripleO instack介绍","text":"instack是一个用来在当前系统中执行diskimage-builder格式的elements的工具。elements最初是在diskimage-builder中出现的,diskimage-builder是一个制作镜像的工具,因为定制镜像,需要安装各种各样的东西,elements就是它抽象出来的一个个功能的集合,这有点类似于ansible中role的概念,需要在镜像中加入什么功能,那么指定相应的element就可以了,diskimage-builder按顺序执行elements中的脚本,从而定制镜像,因此elements是可以分发的,每个人都可以写自己的elements,然后供别人使用。 这种抽象机制非常不错,因此也被应用到tripleo中,但是并没有使用diskimage-builder来执行elements,而是使用instack来执行,instack底层又是使用的dib-run-parts工具来执行的,并且加上了自己的一些逻辑。在每一个element中,都按照约定定义了相同的hook,如extra-data, pre-install, install, post-install,在每一个hook中放置了一些脚本,这些脚本的名称上都进行了编号,在instack执行elements时,先把所有elements中相同hook中的脚本放到同一个hook下,然后由dib-run-parts去依次执行每一个hook中的脚本,编号靠前的就先执行,通过这种机制,每个elements可以控制自己hook中的脚本执行的顺序,举例如下: 比如有两个elements,每个elements有两个hook,每个hook又有两个脚本: 123456789101112131415.├── element1│ ├── hook1│ │ ├── 01-scritp│ │ └── 03-scritp│ └── hook2│ ├── 06-script│ └── 08-script└── element2 ├── hook1 │ ├── 02-script │ └── 04-script └── hook2 ├── 05-script └── 07-script 在instack执行hook之前,先要进行合并,如下: 1234567891011.├── hook1│ ├── 01-scritp│ ├── 02-scritp│ ├── 03-scritp│ └── 04-scritp└── hook2 ├── 05-scritp ├── 06-scritp ├── 07-scritp └── 08-scritp 然后使用dib-run-parts依次去执行某个hook下的脚本,instack可以指定执行哪些hook,没有被指定的将会被跳过。instack是一个相对底层的工具,在tripleo中,被封装在instack-undercloud中,在部署undercloud时被用到。 instack使用方法如下: 12345678910111213141516171819202122232425262728293031323334[stack@undercloud ~]$ instack -husage: instack [-h] [-e [ELEMENT [ELEMENT ...]]] [-p ELEMENT_PATH [ELEMENT_PATH ...]] [-k [HOOK [HOOK ...]]] [-b [BLACKLIST [BLACKLIST ...]]] [-x [EXCLUDE_ELEMENT [EXCLUDE_ELEMENT ...]]] [-j JSON_FILE] [-d] [-i] [--dry-run] [--no-cleanup] [-l LOGFILE]Execute diskimage-builder elements on the current system.optional arguments: -h, --help show this help message and exit -e [ELEMENT [ELEMENT ...]], --element [ELEMENT [ELEMENT ...]] element(s) to execute -p ELEMENT_PATH [ELEMENT_PATH ...], --element-path ELEMENT_PATH [ELEMENT_PATH ...] element path(s) to search for elements (ELEMENTS_PATH environment variable will take precedence if defined) -k [HOOK [HOOK ...]], --hook [HOOK [HOOK ...]] hook(s) to execute for each element -b [BLACKLIST [BLACKLIST ...]], --blacklist [BLACKLIST [BLACKLIST ...]] script names, that if found, will be blacklisted and not run -x [EXCLUDE_ELEMENT [EXCLUDE_ELEMENT ...]], --exclude-element [EXCLUDE_ELEMENT [EXCLUDE_ELEMENT ...]] element names that will be excluded from running even if they are listed as dependencies -j JSON_FILE, --json-file JSON_FILE read runtime configuration from json file -d, --debug Debugging output -i, --interactive If set, prompt to continue running after a failed script. --dry-run Dry run only, don't actually modify system, prints out what would have been run. --no-cleanup Do not cleanup tmp directories -l LOGFILE, --logfile LOGFILE Logfile to log all actions 可以在命令行中直接指定elements,和相应的hook来执行,如下: 12345sudo -E instack \\ -p /usr/share/tripleo-image-elements /usr/share/diskimage-builder/elements \\ -e fedora base keystone mariadb \\ -k extra-data pre-install install post-install \\ -b 15-remove-grub 10-cloud-init 05-fstab-rootfs-label 也可以将这些选项全都配置在一个json格式的配置文件中,直接指定这些配置文件就可以了,如下: 123sudo -E instack \\ -p /usr/share/tripleo-image-elements /usr/share/diskimage-builder/elements \\ -j /usr/share/instack-undercloud/json-files/centos-7-undercloud-packages.json centos-7-undercloud-packages.json文件的内容如下: 12345678910111213141516171819202122232425262728293031323334[ { "name": "Installation", "element": [ "install-types", "undercloud-install", "enable-packages-install", "element-manifest", "puppet-stack-config" ], "hook": [ "extra-data", "pre-install", "install", "post-install" ], "exclude-element": [ "pip-and-virtualenv", "os-collect-config", "svc-map", "pip-manifest", "package-installs", "pkg-map", "puppet", "cache-url", "dib-python", "os-svc-install", "install-bin" ], "blacklist": [ "99-refresh-completed" ] }]","link":"/2017/03/06/openstack/tripleo/instack.html"},{"title":"TripleO os-apply-config介绍","text":"os-apply-config是一个配置工具,它主要是从多个json格式的配置文件中,读取配置项,然后渲染预先定制好的模板,生成最终的配置文件,然后放置到相应的位置中去。 这个json格式的配置文件,在os-apply-config中叫做metadata,就是定义了一些key-value值,用来渲染模板,os-apply-config提供了好几种方式来确定这些json文件,按照优先级如下: 通过命令行中的--metadata选项来指定多个json文件:--metadata [METADATA_FILE [METADATA_FILE ...]] 如果--metadata没有指定,那么通过环境变量OS_CONFIG_FILES来指定,每个json文件以”:”分隔 如果OS_CONFIG_FILES没有指定,那么通过命令行中的--os-config-files来指定,这个选项默认的值是OS_CONFIG_FILES_PATH环境变量指定的,这个环境变量默认的值为:/var/lib/os-collect-config/os_config_files.json 如果前面的选项都没有找到json文件,那么用/var/run/os-collect-config/os_config_files.json这个位置的json文件,这个是以前旧的配置项,要被dreprecated了 不管前面的选项有没有找到json文件,都会使用--fallback-metadata指定的json文件,这个选项默认指定了3个json文件: /var/cache/heat-cfntools/last_metadata /var/lib/heat-cfntools/cfn-init-data /var/lib/cloud/data/cfn-init-data 这些json配置文件中的配置项最终都会被合并到一个dict对象中,用来渲染模板。 os-apply-config的模板由配置项--templates指定,这个配置项默认值由以下方式确定: 由OS_CONFIG_APPLIER_TEMPLATES环境变量指定,默认为None /opt/stack/os-apply-config/templates /opt/stack/os-config-applier/templates /usr/libexec/os-apply-config/templates,该值为默认值 生成的最终的配置文件,放置的位置由--output配置项指定,默认为根目录”/“。 举个例子,有如下的文件: 12345678[root@localhost os-apply-config]# tree.├── config│ └── os_config_files.json├── output└── templates └── etc └── nova.conf templates/etc/nova.conf内容如下: 1234[database]{{#nova.db}}connection={{nova.db}}{{/nova.db}} config/os_config_files.json内容如下: 12345{ "nova":{ "db": "mysql://nova:unset@localhost/nova" }} 执行下面的命令: 123# os-apply-config -t templates/ -m config/os_config_files.json -o output/[2017/03/05 11:59:07 PM] [INFO] writing output/etc/nova.conf[2017/03/05 11:59:07 PM] [INFO] success 在output目录下就会生成相应的配置文件: 12345678910[root@localhost os-apply-config]# tree.├── config│ └── os_config_files.json├── output│ └── etc│ └── nova.conf└── templates └── etc └── nova.conf output/etc/nova.conf内容如下: 12[database]connection=mysql://nova:unset@localhost/nova 可见,使用os-apply-config可以方便的生成一组配置文件,默认的output是根目录,就会将etc等配置文件全部配置到相应的位置,这在部署undercloud和overcloud时都会被用到。 os-apply-config还有一个作用就是指定key值,然后输出对应的value值,如下: 1234# os-apply-config -t templates/ -m config/os_config_files.json --key nova --type raw{"db": "mysql://nova:unset@localhost/nova"}# os-apply-config -t templates/ -m config/os_config_files.json --key nova.db --type rawmysql://nova:unset@localhost/nova","link":"/2017/03/06/openstack/tripleo/os_apply_config.html"},{"title":"TripleO os-refresh-config介绍","text":"os-refresh-config是一个用来有序执行一组脚本的工具,它将脚本分组定义成了4个阶段: pre-configure configure post-configure migration 每个阶段对应一个目录: pre-configure.d configure.d post-configure.d migration.d 这些目录的位置默认是在/usr/libexec/os-refresh-config目录下,也可以由OS_REFRESH_CONFIG_BASE_DIR环境变量指定位置。在每个目录中放置了一些脚本,使用dib-run-parts来依次执行这些目录中的脚本,dib-run-parts是dib-utils中的工具,这个工具最初是在diskimage-builder中被使用的。由于脚本在执行过程中,会使用到各种参数,都是通过环境变量指定的,dib-run-parts在执行这些脚本之前,会先导出环境变量,这些环境变量需要被定义在environment.d目录下,dib-run-parts会先source这些环境变量。 这个工具有点类似于ansible的编排功能,通过执行这4个阶段的脚本,来完成相应的配置,在部署undercloud时,就是用它来编排整个过程的: [stack@undercloud os-refresh-config]$ tree . |-- configure.d | |-- 20-os-apply-config | |-- 30-reload-keepalived | |-- 40-hiera-datafiles | |-- 40-truncate-nova-config | `-- 50-puppet-stack-config `-- post-configure.d |-- 10-iptables |-- 80-seedstack-masquerade |-- 98-undercloud-setup `-- 99-refresh-completed","link":"/2017/03/06/openstack/tripleo/os_refresh_config.html"},{"title":"TripleO 概览","text":"OverCloud部署是要搭建起最终的OpenStack集群,本质上这个部署是利用OpenStack的各个组件,也就是UnderCloud,协调部署出来的,这也是TripleO的核心思想:OpenStack on OpenStack,这种做法其实。OverCloud部署用到的OpenStack组件,主要有以下几个: Mistral Swift Heat Ironic Nova Neutron Zaqar 整体的部署架构如下图所示: TripleO提供了CLI和GUI两个客户端工具作为部署界面,TripleO GUI的易用性还不是很好,用的比较多的还是CLI工具,CLI工具是python-tripleoclient这个项目提供的,它是python-openstackclient的一个插件,在tripleclient里封装了部署overcloud的上层逻辑,负责做些准备工作,以及调用下层的API。 Mistral在OpenStack里提供Workflow服务,把一些操作封装成一个工作流,执行这个工作流,就可以做一系列指定的操作,并且可以指定处理结果的后续操作,以及错误处理。Mistral里默认集成了OpenStack各个项目的操作,当然也可以制定自己的workflow。在TripleO里引入Mistral,是为了给TripleO提供一个统一的API,因为TripleO的部署涉及到很多项目,每个项目都有自己的API,这样一些操作逻辑就必须在客户端处理,这加重了客户端的逻辑,也使得像triple-ui这样的项目很难做。TripleO用到的workflow都写在了tripleo-common这个项目中,定义了像tripleo.plan.create, tripleo.deployment.deploy,这样的workflow,还有相应的action,客户端只要传递相应的参数,执行相应的workflow就可以做一系列指定的操作,这个操作过程是可以异步的,客户端可以异步的等待执行的结果。 Heat是整个TripleO部署中最核心的部分,Heat是一个编排服务,它的核心理念其实是实现了基础设施即代码的思想,通过使用描述式语言,将整个基础设施描述出来,执行这个描述式语言,就可以得到一个特定的结果,这是TripleO能够稳定部署的基础。在目前的实现中,TripleO制定了一个非常复杂的Heat Template来描述将要部署的整个OpenStack集群,也即OverCloud,这些Template是在tripleo-heat-templates这个项目中实现的,部署OverCloud的过程,就是执行Heat Template的过程。在TripleO的Template中,为每个角色的机器制定了不同的Template,当然也可以根据自己的需求,制定特定角色的Template,每个角色的Template又被抽象出了不同的Service,一个Service就代表将要部署的一个OpenStack服务,当然也包括其他非OpenStack服务,比如数据库,消息队列等,也可以制定自己的Service,通过组合这些不同的Service,组成不同的Role,来达到灵活部署的目的。 Ironic是裸机管理服务,在TripleO中,通过Nova和Ironic来管理部署OverCloud里用到的裸机,通过IPMI或其他管理接口,可以实现对裸机的开机,关机,重启等操作,还有通过PXE进行装机,Nova和Ironic的这些操作都被编排在了Heat的Template里。 Swift是对象存储服务,在TripleO中有很多作用,在UnderCloud里,会存储部署OverCloud里用到的镜像,在Introspection里会用来存储收集回来的各个服务器的数据,在OverCloud里则主要用来存储Heat Template,因为Template并没有写死,而是定义了一些模板变量,方便定制,这些变量的值是存储在Mistral里的environment的,当然Heat里用到的变量不仅仅是从Mistral environment里来的,这些template和environment,在TripleO里一起被称作Plan,顾名思义,就是为部署一个OverCloud而制定的一个plan。除此之外,Swift还有一个重要作用,就是用来作为UnderCloud和OverCloud的交互的纽带,在跑Heat的Template时,会在Swift里为每一个服务器创建container,里面存储了这个服务器所需要配置的全部信息,每一个服务器里会有一个agent来拉取这些配置,经过处理之后,生成相应的配置文件,这在TripleO里叫做Metadata。 Neutron为TripleO提供网络服务,在TripleO中抽象出了多种网络,比如管理网,存储网,存储管理网,租户网络等等,基本上满足了OpenStack要求的各种网络需求,在Neutron里,为每一种网络创建了相同的Network,类型都是Flat模式的网络,通过在交换机中配置不同的VLAN,将不同的网络隔离开,通过DHCP服务,可以自动分配IP,当然也可以指定固定的IP。 Zaqar在OpenStack里是一个消息队列服务,同时也可以提供消息通知服务,在TripleO的部署过程中,部署的结果,成功或者失败都可以发送相关的消息到消息队列中,其他程序可以消费这些消息进行debug,或者异步推送到前端面板做更好的展示。 综上,可以看到整个OpenStack的部署,全都是用OpenStack自己的组件部署出来的,理解OpenStack的原理才能理解TripleO的部署,这不仅没有提高OpenStack的部署难度,反而是降低了难度,因为本身OpenStack的部署就非常复杂,TripleO没有引入一个复杂的系统去部署另外一个更加复杂的系统,而是用自己的技术去解决自己的问题,把知识面控制在大家熟知的范围内,有种Stack in Stack的感觉。","link":"/2017/04/23/openstack/tripleo/overcloud.html"},{"title":"TripleO OverCloud部署解析","text":"把物理网络配置好,镜像以及裸机信息录入系统,设置好Metadata,一切准备好之后,就可以开始部署OverCloud了,部署OverCloud非常简单,只需要执行一条命令: 1234openstack overcloud deploy --templates /usr/share/openstack-tripleo-heat-templates/ \\ -r /home/stack/templates/roles/roles_data.yaml \\ -e /home/stack/templates/environments/low-memory-usage.yaml \\ -e /home/stack/templates/environments/environment.yaml 这条命令背后到底发生了什么?在前面概览中介绍了部署OverCloud的过程其实是在跑Heat Template的过程,反映到Heat里,就是要创建一个stack,创建这个stack的过程,就是在跑Heat Template的过程,所以这个命令做的大部分工作都是在为创建这个stack做准备,创建环境变量,解析Template,生成自定义的Role,传递各种参数,然后创建stack,整体的数据流如下图: 在部署OverCloud之前,首先会生成一个Plan,在前面介绍过,Heat的模板是在tripleo-heat-templates这个项目中定义的,这个里面有一些模板里面是有模板变量的,比如自定义的Role,需要根据参数进行解析,这些模板被解析完之后,会被上传到Swift里一个以将要创建的Heat Stack的名字命名的container里,默认为overcloud,在创建Heat Stack时,Heat所用到的模板,就是直接从Swift里获取的,所以这个过程大致如下: 客户端先调用Mistral里面的tripleo.plan.create_container这个workflow,在Swift里创建了一个名称为overcloud的container; 然后客户端直接调用swift接口,将原始模板上传到这个container里; 然后开始执行tripleo.plan_management.v1.create_deployment_plan这个workflow,这个workflow定义了3个action: tripleo.plan.create,将tripleo-heat-templates里的root_template和root_environment存储到Mistral的Environment里,所谓root_template就是overcloud.yaml这个文件,它是创建这个stack里的最上层的stack,这个stack里又嵌套了非常多的stack,每一个stack都负责做一块事情;root_environment则是指的overcloud-resource-registry-puppet.yaml这个文件,这个文件是Heat里用到的Environment,定义了Heat里的Resource对应的模板文件; tripleo.parameters.generate_passwords,这个action生成了各个服务的密码,即每个服务都会在Keystone里生成一个账户,后面会被配置到各个服务的配置文件中,比如Nova会创建一个名为nova的用户,它的密码就是在这个阶段生成的,这些密码也被上传到了Mistral的Environment里; tripleo.templates.process,这个action主要是处理模板,根据指定的变量渲染模板,生成本次部署特定的模板,比如生成自定义的Role,此外,再结合前面的几个action生成的结果,这个action最终生成了创建stack需要使用的参数; 上面的准备工作做好之后,就开始执行tripleo.deployment.v1.deploy_plan这个workflow,这个workflow就是要实施前面定义的Plan了,其实就是调用heat,结合在这个Plan中定义的模板以及环境变量,开始创建这个stack了,部署整个overcloud的过程就是创建这个stack的过程,这个stack又嵌套了非常多的子stack,比如创建裸机,部署系统,配置系统,这些都被封装成了stack,通过执行这一个个stack,来完成部署过程。 等Heat Stack创建完成之后,会上传overcloudrc文件到各个服务器,整个部署过程就完成了。 可以看到,创建stack的过程,也就是tripleo-heat-templates这个项目,是整个TripleO的最核心部分,它决定了最终部署出来的集群是什么样子,应该如何配置各个服务,后面我们会专门介绍下TripleO的Template。","link":"/2017/04/20/openstack/tripleo/overcloud_deploy.html"},{"title":"TripleO-Heat-Templates解析","text":"TripleO的Heat Template是整个TripleO的核心,它定义了一系列复杂的模板,运用了Heat中一些高级的语法,抽象出了一个个嵌套的stack,来完成OverCloud中安装系统,网络配置,服务配置,高可用等一系列部署操作。本节重点解析下这些Template,讲解下TripleO中是如何使用Heat来安装部署OverCloud的,该项目的地址在tripleo-heat-templates。如果对Heat的基本语法不熟悉的,请参考Heat基础知识小节。 在OverCloud部署解析小节讲过,部署OverCloud只需要一条命令就可以: 1234openstack overcloud deploy --templates /usr/share/openstack-tripleo-heat-templates/ \\ -r /home/stack/templates/roles/roles_data.yaml \\ -e /home/stack/templates/environments/low-memory-usage.yaml \\ -e /home/stack/templates/environments/environment.yaml openstack overcloud deploy命令最终是要创建一个stack,这个stack对应的上层模板文件为overcloud.yaml,为整个模板的入口,该模板文件是由overcloud.j2.yaml这个模板文件渲染过来的,此外还有overcloud-resource-registry-puppet.yaml文件,该文件指定了Heat的环境变量,主要映射了各种自定义的resource对应的模板,该文件也是由overcloud-resource-registry-puppet.j2.yaml整个模板文件渲染过来的,模板变量来自于roles_data.yaml文件,该文件定义了要部署哪种角色的节点,以及每个角色都包含哪些服务。 下面我们以定义Controller和Compute两个角色为例,来解析这些模板的关系。创建stack的过程就是创建该stack中定义的resource的过程,而这些resource有可能又嵌套了子stack,所以这些resource的创建是有一定依赖关系的,规则一般是使用depends_on显示的指定,或者是某个resource会引用到另外一个resource的输出值,这样必须先创建被依赖的resource。因此整个创建过程,我们大致可以分为三个阶段: 准备阶段在准备阶段主要是创建被依赖的resource,主要有3种: DefaultPasswords如下图,主要是创建了MySQL的Root密码,RabbitMQ Cooike等resource,这会在后面的配置阶段用到。这些resource的类型都是OS::Heat::RandomString,都是生成的随机变量。 该图有点UML的类图的概念,但不是严格意义的类图,仅仅是为了说明resource之间的关系。第一行说明的是该资源的类型,第二行是该类型资源的一个实例,第三行是该实例的属性,实心箭头表示引用,空心箭头表示依赖。 EndpointMap如下图,该resource主要是创建了各个服务的endpoint,即最终要配置到keystone中的endpoint: EndpointMap这个resource依赖于Network这个resource,Network中定义了TripleO中抽象出来的各种网络,有External, InternalApi,Storage, StorageMgmt等,该resource会在Neutron中创建相应的network以及subnet。 EndpointMap中包含了各个服务的internal, public, admin这三种endpoint,被定义在一个yaml文件中,然后由一个脚本生成heat的template,因为该heat的template较为复杂,所以写了一个脚本进行转换,该yaml文件的格式为: 12345678Aodh: Internal: net_param: AodhApi Public: net_param: Public Admin: net_param: AodhApi port: 8042 ServiceChainServiceChain主要是在Heat中创建各个服务的stack,如下图: 每个服务对应的stack中的output字段都定义了role_data值,role_data是一个dict对象,该对象包含了以下几个属性: service_name,服务的名称 monitoring_subscription,监控信息 config_settings,该服务的配置信息 service_config_settings,该服务所依赖的服务的配置信息,比如keystone, mysql step_config,该服务的puppet配置入口 在TripleO中的服务配置是采用Puppet+Hieradata的方式进行配置的,每个服务对应的stack中的role_data中的config_settings和service_config_settings最终会被转换成hieradata中的配置,然后step_config中的puppet入口代码最终会被整合到服务器中的puppet代码入口中,然后采用puppet apply的方式运行,完成该服务的配置。 下面为AodhApi这个服务的service模板: 12345678910111213141516171819202122232425262728293031outputs: role_data: description: Role data for the Aodh API service. value: service_name: aodh_api monitoring_subscription: {get_param: MonitoringSubscriptionAodhApi} config_settings: map_merge: - get_attr: [AodhBase, role_data, config_settings] - get_attr: [ApacheServiceBase, role_data, config_settings] - aodh::wsgi::apache::ssl: false aodh::wsgi::apache::servername: str_replace: template: '"%{::fqdn_$NETWORK}"' params: $NETWORK: {get_param: [ServiceNetMap, AodhApiNetwork]} aodh::api::service_name: 'httpd' aodh::api::enable_proxy_headers_parsing: true tripleo.aodh_api.firewall_rules: '128 aodh-api': dport: - 8042 - 13042 aodh::api::host: {get_param: [ServiceNetMap, AodhApiNetwork]} aodh::wsgi::apache::bind_host: {get_param: [ServiceNetMap, AodhApiNetwork]} tripleo::profile::base::aodh::api::enable_combination_alarms: {get_param: EnableCombinationAlarms} service_config_settings: get_attr: [AodhBase, role_data, service_config_settings] step_config: | include tripleo::profile::base::aodh::api TripleO中还有一个项目,puppet-tripleo,是一个Puppet的转发层,里面集成了所有服务的Puppet入口,如上面的include tripleo::profile::base::aodh::api,就是指向的aodh api的puppet入口代码,在服务器上跑该代码时,会去hieradata中找该服务的配置信息,进行配置。 创建服务器阶段在该阶段,主要是创建服务器,安装系统,并且进行系统配置,如下图: 创建服务器采用OS::Heat::ResourceGroup资源类型,一次创建多个Nova的Server,在UnderCloud上配置的Nova的Driver是Ironic,这里会调用Ironic去创建服务器,进行装机操作。在创建服务器之前,会先创建userdata,对服务器做一些初始化操作,该模板如下: 123456789101112131415161718192021Controller: type: OS::TripleO::Server metadata: os-collect-config: command: {get_param: ConfigCommand} properties: image: {get_param: controllerImage} image_update_policy: {get_param: ImageUpdatePolicy} flavor: {get_param: OvercloudControlFlavor} key_name: {get_param: KeyName} networks: - network: ctlplane user_data_format: SOFTWARE_CONFIG user_data: {get_resource: UserData} name: str_replace: template: {get_param: Hostname} params: {get_param: HostnameMap} software_config_transport: {get_param: SoftwareConfigTransport} metadata: {get_param: ServerMetadata} scheduler_hints: {get_param: ControllerSchedulerHints} OS::TripleO::Server这个resource type会被映射成OS::Nova::Server,注意上面metadata字段配置的为os-collect-config,该字段在Heat里会被处理成为userdata的一部分,在系统安装好之后,会启动os-collect-config服务,并且进行配置,该服务充当了运行在每一个服务器中的agent,它会从我们前面提到过的Metadata服务器中收集Metadata配置信息,然后进行配置,即和Heat的SoftwareDeployment配合工作,它会周期性的检查Metadata服务器中的配置信息,如果有变化,则会调用相应的程序执行这些配置。 当服务器装机完成之后,会创建UpdateDeployment来更新系统中的包,并且配置该服务器的网络,对服务器的网络配置,定义在NetworkConfig中,在服务器上,会调用os-net-config来配置网卡,可以配置bond,linux-bridge等信息。 然后在ControllerDeployment会在服务器上生成本节点的hieradata数据,为后面的配置阶段准备好配置信息。 配置阶段在该阶段主要是跑Puppet,即对各个服务进行配置,在介绍相关模板之前,先来介绍下在服务器上,是如何进行配置的。主要使用了下面几个组件: os-collect-config os-apply-config os-refresh-config heat-agent os-collect-config是一个周期运行的daemon进程,它的配置文件如下: 12345678[DEFAULT]command = os-refresh-config --timeout 14400collectors = ec2collectors = requestcollectors = local[request]metadata_url = http://10.0.141.2:8080/v1/AUTH_c1ca7a85e40e440080a610aed86a2cdc/ov-6p7e5ekmrx7-0-si2dtcgirg4m-Controller-2gftczryrcla/1695b2a0-0245-4d04-8406-1b169920fef4?temp_url_sig=8a36df7706992216c101e8ee2c88f1287a3fa2e0&temp_url_expires=2147483586 在os-collect-config中定义了3个collector,起主要作用的是request这个collector,在它的section中定义了metadata_url,它指向的是Swift中的地址,在部署时,Heat会为每一个节点在Swift中生成一个Container,该Container中保存了本节点的配置,os-collect-config使用request collector周期性的从swift中拉取配置,如果发现配置有变化,则会运行command中指定的命令,这里配置的为os-refresh-config,os-refresh-config中则指定了一系列HOOK,会按照顺序执行这些Hook,这些Hook是被直接安装好在镜像中的,存放的路径为:/usr/libexec/os-refresh-config/configure.d,有如下HOOK: 20-os-apply-config,执行os-apply-config命令,生成配置文件 20-os-net-config,执行os-net-config配置网络 25-set-network-gateway 40-hiera-datafiles,生成hieradata文件 40-truncate-nova-config 51-hosts,配置hosts文件 55-heat-config,执行heat agent 在os-apply-config主要是从模板生成配置文件,模板存放的路径为/usr/libexec/os-apply-config/templates: 1234567891011121314[heat-admin@overcloud-novacompute-0 os-apply-config]$ tree.└── templates ├── etc │ ├── os-collect-config.conf │ ├── os-collect-config.conf.oac │ ├── os-net-config │ │ └── config.json │ └── puppet │ └── hiera.yaml └── var └── run └── heat-config └── heat-config 可以看到生成了/etc/os-net-config/config.json文件,该文件保存对网卡的配置,/etc/puppet/hiera.yaml文件,该文件为hieradata的配置文件,还有/var/run/heat-config/heat-config,该文件保存从heat解析出来的针对本节点的各种配置信息。 40-hiera-datafiles会从/var/run/heat-config/heat-config读取配置信息,然后生成Hieradata数据文件: 12345678910111213-rw-r--r--. 1 root root 27454 Apr 10 13:33 all_nodes.yaml-rw-r--r--. 1 root root 75 Apr 10 13:33 bootstrap_node.yaml-rw-r--r--. 1 root root 125 Apr 10 13:33 controller_extraconfig.yaml-rw-r--r--. 1 root root 154 Apr 10 13:33 controller.yaml-rw-r--r--. 1 root root 414 Apr 10 13:33 extraconfig.yaml-rw-------. 1 root root 833 Apr 10 13:16 heat_config_ControllerDeployment_Step1.json-rw-------. 1 root root 831 Apr 10 13:18 heat_config_ControllerDeployment_Step2.json-rw-------. 1 root root 839 Apr 10 13:20 heat_config_ControllerDeployment_Step3.json-rw-------. 1 root root 839 Apr 10 13:23 heat_config_ControllerDeployment_Step4.json-rw-------. 1 root root 831 Apr 10 13:27 heat_config_ControllerDeployment_Step5.json-rw-r--r--. 1 root root 40365 Apr 10 13:33 service_configs.yaml-rw-r--r--. 1 root root 2275 Apr 10 13:33 service_names.yaml-rw-r--r--. 1 root root 1652 Apr 10 13:33 vip_data.yaml 55-heat-config会去根据SoftwareConfig中的group信息选择不同的heat-agent执行不同的hook,如果group信息为puppet,则会从/var/run/heat-config/heat-config读取step_config信息,生成puppet代码,生成的puppet代码存放的路径为:/var/lib/heat-config/heat-config-puppet,会生成一个以该节点的node——id为名称的pp文件,然后使用puppet apply执行这个pp文件;如果group信息为script,则执行相应的脚本。调用这些程序执行的结果被保存在/var/run/heat-config/deployed目录下。 明白了配置的原理之后,来看下配置阶段的模板,如下图: 这个阶段最重要的操作是执行puppet,配置各个服务,在TripleO中把Puppet的执行过程分为了5个步骤,每一个步骤依赖于前面的一个步骤执行完成,在hieradata中为每一个步骤分别生成了一个配置文件,这种把puppet分开步骤执行的设计,非常好的解决了因为各个服务的依赖问题而导致可能出现的各种问题。比如在step 2时,先安装好Ceph,而在Step 4时,去创建Ceph Pool。 从上面的过程可以看到,TripleO的整个模板部署过程非常复杂,但是也进行了很好的抽象,有很多可圈可点的地方。","link":"/2017/04/23/openstack/tripleo/tht.html"},{"title":"go-restful简析","text":"go-restful是一个golang语言实现的RESTful库,因为Kubernetes APIServer使用它实现RESTful API,这里就简单分析下。看它的文档介绍,功能还是挺强大的,主要体现在它的路由策略上,支持静态,谷歌式的自定义方法,正则表达式,以及URL内参数等,此外还支持json/xml等格式化,还有filter过滤器等,虽然有这么多功能,但是Kubernetes使用的比较简单,只用到了它最基础的功能,即路由功能,这里就重点分析下这个功能。 基础概念首先,还是借go-restful提供的一个小例子,来看下它的核心功能,示例代码见这里: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107package mainimport ( "log" "net/http" "github.com/emicklei/go-restful")// This example has the same service definition as restful-user-resource// but uses a different router (CurlyRouter) that does not use regular expressions//// POST http://localhost:8080/users// <User><Id>1</Id><Name>Melissa Raspberry</Name></User>//// GET http://localhost:8080/users/1//// PUT http://localhost:8080/users/1// <User><Id>1</Id><Name>Melissa</Name></User>//// DELETE http://localhost:8080/users/1//type User struct { Id, Name string}type UserResource struct { // normally one would use DAO (data access object) users map[string]User}func (u UserResource) Register(container *restful.Container) { ws := new(restful.WebService) ws. Path("/users"). Consumes(restful.MIME_XML, restful.MIME_JSON). Produces(restful.MIME_JSON, restful.MIME_XML) // you can specify this per route as well ws.Route(ws.GET("/{user-id}").To(u.findUser)) ws.Route(ws.POST("").To(u.updateUser)) ws.Route(ws.PUT("/{user-id}").To(u.createUser)) ws.Route(ws.DELETE("/{user-id}").To(u.removeUser)) container.Add(ws)}// GET http://localhost:8080/users/1//func (u UserResource) findUser(request *restful.Request, response *restful.Response) { id := request.PathParameter("user-id") usr := u.users[id] if len(usr.Id) == 0 { response.AddHeader("Content-Type", "text/plain") response.WriteErrorString(http.StatusNotFound, "User could not be found.") } else { response.WriteEntity(usr) }}// PUT http://localhost:8080/users/1// <User><Id>1</Id><Name>Melissa</Name></User>//func (u *UserResource) updateUser(request *restful.Request, response *restful.Response) { usr := new(User) err := request.ReadEntity(&usr) if err == nil { u.users[usr.Id] = *usr response.WriteEntity(usr) } else { response.AddHeader("Content-Type", "text/plain") response.WriteErrorString(http.StatusInternalServerError, err.Error()) }}// POST http://localhost:8080/users// <User><Id>1</Id><Name>Melissa Raspberry</Name></User>//func (u *UserResource) createUser(request *restful.Request, response *restful.Response) { usr := User{Id: request.PathParameter("user-id")} err := request.ReadEntity(&usr) if err == nil { u.users[usr.Id] = usr response.WriteHeaderAndEntity(http.StatusCreated, usr) } else { response.AddHeader("Content-Type", "text/plain") response.WriteErrorString(http.StatusInternalServerError, err.Error()) }}// DELETE http://localhost:8080/users/1//func (u *UserResource) removeUser(request *restful.Request, response *restful.Response) { id := request.PathParameter("user-id") delete(u.users, id)}func main() { wsContainer := restful.NewContainer() wsContainer.Router(restful.CurlyRouter{}) u := UserResource{map[string]User{}} u.Register(wsContainer) log.Print("start listening on localhost:8080") server := &http.Server{Addr: ":8080", Handler: wsContainer} log.Fatal(server.ListenAndServe())} 这个示例代码,就是使用go-restful的核心功能实现了一个简单的RESTful的API,实现了对User的增删查改,其中有这么几个核心概念:Container, WebService, Route。 Container可以包含多个WebService,而WebService又可以包含多个Route。 一个WebService其实代表某一个对象相关的服务,如上例中的/users,针对该/users要实现RESTful API,那么需要向其添加增删查改的路由,即Route,它是Route的集合。 每一个Route,根据Method和Path,映射到对应的方法中,即是Method/Path到Function映射关系的抽象,如上例中的ws.Route(ws.GET("/{user-id}").To(u.findUser)),就是针对/users/{user-id}该路径的GET请求,则被路由到findUser方法中进行处理。 而Container则是WebService的集合,可以向Container中添加多个WebService,而Container因为实现了ServeHTTP()方法,其本质上还是一个http Handler,可以直接用在http Server中。 内部构造简单来看看Container的内部构造: 123456789101112type Container struct { webServicesLock sync.RWMutex webServices []*WebService ServeMux *http.ServeMux isRegisteredOnRoot bool containerFilters []FilterFunction doNotRecover bool // default is true recoverHandleFunc RecoverHandleFunction serviceErrorHandleFunc ServiceErrorHandleFunction router RouteSelector // default is a CurlyRouter (RouterJSR311 is a slower alternative) contentEncodingEnabled bool // default is false} 其包含一个WebService数组,一个http.ServeMux,一个RouteSelector。 WebServiceWebService数组的作用好理解,因为它是WebService的集合,所以一定要有个数组来存Add进来的WebService: 123456789101112131415161718192021222324func (c *Container) Add(service *WebService) *Container { c.webServicesLock.Lock() defer c.webServicesLock.Unlock() // if rootPath was not set then lazy initialize it if len(service.rootPath) == 0 { service.Path("/") } // cannot have duplicate root paths for _, each := range c.webServices { if each.RootPath() == service.RootPath() { log.Printf("WebService with duplicate root path detected:['%v']", each) os.Exit(1) } } // If not registered on root then add specific mapping if !c.isRegisteredOnRoot { c.isRegisteredOnRoot = c.addHandler(service, c.ServeMux) } c.webServices = append(c.webServices, service) return c} ServeMux而http.ServeMux,则是net/http中的内容,见这里,ServeMux,可以直译成”多路器“,即可以向ServeMux中注册多个路径,以及对应的Handler方法,当请求过来时,根据最大匹配原则,请求路径跟注册路径最匹配的,则其对应的Handler来处理该请求。在go-restful中,它的作用主要是为了将Containder实现为Handler,即实现了ServeHTTP()方法: 123func (c *Container) ServeHTTP(httpWriter http.ResponseWriter, httpRequest *http.Request) { c.ServeMux.ServeHTTP(httpWriter, httpRequest)} 向ServeMux中注册方法,可以通过两种方式: 一种是通过addHandler()方法,通过向ServeMux中注册进统一的Handler,即c.dispatch(): 1234567891011121314151617181920212223func (c *Container) addHandler(service *WebService, serveMux *http.ServeMux) bool { pattern := fixedPrefixPath(service.RootPath()) // check if root path registration is needed if "/" == pattern || "" == pattern { serveMux.HandleFunc("/", c.dispatch) return true } // detect if registration already exists alreadyMapped := false for _, each := range c.webServices { if each.RootPath() == service.RootPath() { alreadyMapped = true break } } if !alreadyMapped { serveMux.HandleFunc(pattern, c.dispatch) if !strings.HasSuffix(pattern, "/") { serveMux.HandleFunc(pattern+"/", c.dispatch) } } return false} 可见这种方式,不管路径是什么,其都会由c.dispatch()作为Handler来处理请求,在c.dispatch()中,则会调用go-restful自己的路由逻辑,去WebService中寻找最匹配的Route来处理请求: 12345678910111213141516171819202122232425262728293031func (c *Container) dispatch(httpWriter http.ResponseWriter, httpRequest *http.Request) { writer := httpWriter // Find best match Route ; err is non nil if no match was found var webService *WebService var route *Route var err error func() { c.webServicesLock.RLock() defer c.webServicesLock.RUnlock() webService, route, err = c.router.SelectRoute( c.webServices, httpRequest) }() ...... // pass through filters (if any) if size := len(c.containerFilters) + len(webService.filters) + len(route.Filters); size > 0 { // compose filter chain allFilters := make([]FilterFunction, 0, size) allFilters = append(allFilters, c.containerFilters...) allFilters = append(allFilters, webService.filters...) allFilters = append(allFilters, route.Filters...) chain := FilterChain{Filters: allFilters, Target: route.Function} chain.ProcessFilter(wrappedRequest, wrappedResponse) } else { // no filters, handle request by route route.Function(wrappedRequest, wrappedResponse) }} 而另一种是通过Container的Handle()方法,直接向ServeMux中进行注册Handler: 123456789101112131415161718192021222324252627282930313233func (c *Container) Handle(pattern string, handler http.Handler) { c.ServeMux.Handle(pattern, http.HandlerFunc(func(httpWriter http.ResponseWriter, httpRequest *http.Request) { // Skip, if httpWriter is already an CompressingResponseWriter if _, ok := httpWriter.(*CompressingResponseWriter); ok { handler.ServeHTTP(httpWriter, httpRequest) return } writer := httpWriter // CompressingResponseWriter should be closed after all operations are done defer func() { if compressWriter, ok := writer.(*CompressingResponseWriter); ok { compressWriter.Close() } }() if c.contentEncodingEnabled { doCompress, encoding := wantsCompressedResponse(httpRequest) if doCompress { var err error writer, err = NewCompressingResponseWriter(httpWriter, encoding) if err != nil { log.Print("unable to install compressor: ", err) httpWriter.WriteHeader(http.StatusInternalServerError) return } } } handler.ServeHTTP(writer, httpRequest) }))} RouteSelector路由选择器,即从注册的WebService中,选择出最合适的Route来处理客户端请求,其接口定位为: 123456789type RouteSelector interface { // SelectRoute finds a Route given the input HTTP Request and a list of WebServices. // It returns a selected Route and its containing WebService or an error indicating // a problem. SelectRoute( webServices []*WebService, httpRequest *http.Request) (selectedService *WebService, selected *Route, err error)} go-restful实现了一个叫做CurlyRouter的路由器,可以支持类似"/users/{user-id}"这样的URL路径。 从上面的分析可以看出,路由的入口是由ServeMux提供的,但是可以完全不使用ServeMux提供的路由功能,而是将路由功能交给RouteSelector来实现,比如先向Container添加一个根路径为"/"的WebService,则完全禁用掉了ServeMux的路由功能。当然两者也可以配合使用,一部分路径由ServeMux提供,一部分路径由RouteSelector提供。 Kubernetes中使用方法Kubernetes中对go-restful的使用,比较基础,就使用到了其最基础的路由功能,我们来看一个简化版的Kubernetes使用go-restful构造的RESTful API的示例,下面的示例实现了如下几个API: 12345678GET /apis/apps/v1/namespaces/{namespace}/deployments/{name}POST /apis/apps/v1/namespaces/{namespace}/deploymentsGET /apis/apps/v1/namespaces/{namespace}/daemonsets/{name}POST /apis/apps/v1/namespaces/{namespace}/daemonsetsGET /apis/batch/v1beta1/namespaces/{namespace}/jobs/{name}POST /apis/batch/v1beta1/namespaces/{namespace}/jobs 代码如下: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100package mainimport ( "github.com/emicklei/go-restful" "log" "net/http" "time")type MyHandler struct { GoRestfulContainer *restful.Container name string}func NewMyHandler(name string) *MyHandler { gorestfulContainer := restful.NewContainer() gorestfulContainer.ServeMux = http.NewServeMux() gorestfulContainer.Router(restful.CurlyRouter{}) return &MyHandler{ GoRestfulContainer: gorestfulContainer, name: name, }}func (h *MyHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { h.GoRestfulContainer.Dispatch(w, req) return}func NewWebService(group string, version string) *restful.WebService { ws := new(restful.WebService) ws.Path("/apis/" + group + "/" + version) ws.Doc("API at /apis/apps/v1") ws.Consumes(restful.MIME_XML, restful.MIME_JSON) ws.Produces(restful.MIME_JSON, restful.MIME_XML) // you can specify this per route as well return ws}func registerHandler(resource string, ws *restful.WebService) { routes := []*restful.RouteBuilder{} nameParam := ws.PathParameter("name", "name of the resource").DataType("string") namespaceParam := ws.PathParameter("namespace", "object name and auth scope, such as for teams and projects").DataType("string") route := ws.GET("namespaces" + "/{namespace}/" + resource + "/{name}").To(getHandler) route.Param(namespaceParam) route.Param(nameParam) route.Writes(Foo{}) routes = append(routes, route) route2 := ws.POST("namespaces" + "/{namespace}/" + resource).To(postHandler) route2.Param(namespaceParam) routes = append(routes, route2) for _, route := range routes { ws.Route(route) }}type Foo struct { namespace string name string}func getHandler(req *restful.Request, res *restful.Response) { namespace := req.PathParameter("namespace") name := req.PathParameter("name") log.Println("GET: " + namespace + "/" + name) res.WriteEntity(Foo{namespace: namespace, name: name})}func postHandler(req *restful.Request, res *restful.Response) { namespace := req.PathParameter("namespace") log.Println("POST: " + namespace) res.WriteEntity(Foo{namespace: namespace, name: ""})}func main() { handler := NewMyHandler("foo") ws1 := NewWebService("apps", "v1") registerHandler("deployments", ws1) registerHandler("daemonsets", ws1) ws2 := NewWebService("batch", "v1beta1") registerHandler("jobs", ws2) handler.GoRestfulContainer.Add(ws1) handler.GoRestfulContainer.Add(ws2) s := &http.Server{ Addr: ":8080", Handler: handler, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, MaxHeaderBytes: 1 << 20, } log.Fatal(s.ListenAndServe())} 可见,针对每一个Group和Version,Kubernetes使用了一个WebService来组织其资源,全部添加到一个Container中,而在MyHandler的ServeHTTP()方法中,则直接调用了Container的Dispatch()方法:h.GoRestfulContainer.Dispatch(w, req),即直接使用了go-restful内置的路由功能,并没有使用到它的ServeMux的路由功能。此外,还使用到了路径参数的功能。 总结本篇简单介绍了下go-restful这个golang语言实现的构造restful api的库,以及其内部的逻辑,然后通过一个简化版的Kubernetes RESTful API的示例,来说明下Kubernetes中是如何使用go-restful的,不过貌似Kubernetes在使用go-restful时,跟其他功能一起使用时,遇到了一些兼容性问题,导致想将其替换掉,具体还见代码注释: 1234567891011121314// Director is here so that we can properly handle fall through and proxy cases.// This looks a bit bonkers, but here's what's happening. We need to have /apis handling registered in gorestful in order to have// swagger generated for compatibility. Doing that with `/apis` as a webservice, means that it forcibly 404s (no defaulting allowed)// all requests which are not /apis or /apis/. We need those calls to fall through behind goresful for proper delegation. Trying to// register for a pattern which includes everything behind it doesn't work because gorestful negotiates for verbs and content encoding// and all those things go crazy when gorestful really just needs to pass through. In addition, openapi enforces unique verb constraints// which we don't fit into and it still muddies up swagger. Trying to switch the webservices into a route doesn't work because the// containing webservice faces all the same problems listed above.// This leads to the crazy thing done here. Our mux does what we need, so we'll place it in front of gorestful. It will introspect to// decide if the route is likely to be handled by goresful and route there if needed. Otherwise, it goes to PostGoRestful mux in// order to handle "normal" paths and delegation. Hopefully no API consumers will ever have to deal with this level of detail. I think// we should consider completely removing gorestful.// Other servers should only use this opaquely to delegate to an API server.Director http.Handler","link":"/2020/09/28/golang/go-restful-overview.html"},{"title":"Keystone Fernet Token解析","text":"Token的一点历史Keystone在早期的版本中,其认证Token有好几种类型,最早期的也是当时最成熟的是UUID类型的Token,它将token以及其元数据存储在数据库中,以一个UUID来标识一个Token,这种方式一个明显的问题就是当Token量很大时,会往数据库中写大量的Token,一个中等集群,往往数据库中有十几G的数据都是Token,需要定期清理,否则会拖慢其他请求;后来发展出了基于公私钥非对称加密的PKI/PKIZ Token,这种Token不需要存数据库,Token元数据直接被编码到Token ID中,而且因为是非对称的,甚至都不需要跟Keystone交互,本地就可以完成验证以及解析Token,但是这种Token,因为Token元数据都在Token ID中,随着集群大小以及复杂度的变化,其长度也会变化,有时会变得非常长,超过了HTTP对Header的长度限制,再加上公私钥的管理也比较复杂,所以PKI体系的Token基本没有被用起来;于是就有了Fernet Token,它可以看成是UUID和PKI折中的一种方案,它有以下几个特点: Token不存数据库 Token长度不会变得很长,一般小于255个字符 采用AES-CBC对称加密算法加密Token,并且使用散列函数SHA256进行签名 Token验证需要到Keystone Server进行,不能本地验证 为了安全,进行对称加密的key,需要定期Rotate 相比UUID,它不存数据库,减少了对数据库的压力,而且使得性能有了一定的提升;相比PKI,它长度固定,而且对称加密,比较好管理,因此随着Fernet逐渐成熟,发展到Rocky版本,UUID和PKI/PKIZ这两种Token类型已经被移除掉了,Fernet现在是唯一的Token类型。 Play around with Fernet Token先来简单看看Fernet Token长啥样,以及怎么验证一个Token: 1234567891011121314151617181920[root@control1 ~]# . admin-openrc[root@control1 ~]# openstack token issue+------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+| Field | Value |+------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+| expires | 2020-11-10T15:04:48+0000 || id | gAAAAABfqVqQlgiZgky_i2mWVbknmmGRbHSZ9RGrkqPp2GNuhd_n5D7RB5uD6ngaWzr-zCdIKxDXVk9mgzDxTS7QhRH1mUpnEbMj7JPpHNU3vDXI4Zm5mgPVZqxAQOWLhmBRj_ELcnzY_RtikwoyaLk41ogvMFA6NUu7fh0eeWuipVYgsL8w9YY || project_id | 3c638b2eb36b4da6944040bb31084421 || user_id | c9c34b222cae43ef9b721ece47545431 |+------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+[root@control1 ~]# . guangyu-openrc[root@control1 ~]# openstack token issue+------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+| Field | Value |+------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+| expires | 2020-11-10T15:04:58+0000 || id | gAAAAABfqVqa85e6hC6SJ8dWM0tE0C0Ast-_NnmInVTZTM8n_XLpkBGiuoAGBIejJW3oyixZoJc4g82ezpPh_WGRBW47SkcFOsVmItAhw_GOrWofTzjPM3Oekt5Fk6bBpa8tVsT-qec8DTW6tEq2Wm2Yc4Jmw3nkX6mbMdNMR-zxeGfq8B5MJcA || project_id | e9cdf316e25d433bb69278be3339ded0 || user_id | fee9dca90b2e46dc8f31960c517a3baf |+------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ 这里,source了两个openrc文件,一个是管理员admin的,一个是普通用户guangyu的,然后分别用openstack token issue命令创建了这两个用户的token,其中id那一行就是Fernet Token,可以看到它要比一个UUID长很多,但是也不是特别长,还可以接受。接下来我们使用admin的token调用Keystone的API来验证一下guangyu的Token是否有效,这就好比一个请求带着token去请求Nova API,Nova API本身不具备验证这个Token是否有效的能力,所以它需要向Keystone去验证这个Token是否有效,验证Token的API为 GET /v3/auth/tokens: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061[root@control1 ~]# ADMIN_TOKEN="gAAAAABfqVqQlgiZgky_i2mWVbknmmGRbHSZ9RGrkqPp2GNuhd_n5D7RB5uD6ngaWzr-zCdIKxDXVk9mgzDxTS7QhRH1mUpnEbMj7JPpHNU3vDXI4Zm5mgPVZqxAQOWLhmBRj_ELcnzY_RtikwoyaLk41ogvMFA6NUu7fh0eeWuipVYgsL8w9YY"[root@control1 ~]# GUANGYU_TOKEN="gAAAAABfqVqa85e6hC6SJ8dWM0tE0C0Ast-_NnmInVTZTM8n_XLpkBGiuoAGBIejJW3oyixZoJc4g82ezpPh_WGRBW47SkcFOsVmItAhw_GOrWofTzjPM3Oekt5Fk6bBpa8tVsT-qec8DTW6tEq2Wm2Yc4Jmw3nkX6mbMdNMR-zxeGfq8B5MJcA"[root@control1 ~]# curl -H "X-Auth-Token: $ADMIN_TOKEN" -H "X-Subject-Token: $GUANGYU_TOKEN" http://10.110.105.30:35357/v3/auth/tokens | jq .{ "token": { "is_domain": false, "methods": [ "password" ], "roles": [ { "id": "26796d7d1f8447a3ab95d0d31c3bca37", "name": "reader" }, { "id": "ea022f3532ad4f6cafbc63f9a1bce8f3", "name": "creator" }, { "id": "470a11fdfb7a49b48c1a5d9524a98cf9", "name": "member" } ], "expires_at": "2020-11-10T15:04:58.000000Z", "project": { "domain": { "id": "default", "name": "Default" }, "id": "e9cdf316e25d433bb69278be3339ded0", "name": "guangyu_project" }, "catalog": [ { "endpoints": [ { "url": "http://10.110.105.30:9292", "interface": "admin", "region": "RegionOne", "region_id": "RegionOne", "id": "783d732505fb4ce58fb677b1b974f424" }, ...... } ], "user": { "password_expires_at": null, "domain": { "id": "default", "name": "Default" }, "id": "fee9dca90b2e46dc8f31960c517a3baf", "name": "guangyu" }, "audit_ids": [ "AnPMxLBlQjOZTHrd0ttwlA" ], "issued_at": "2020-11-09T15:04:58.000000Z" }} 可以看到该接口传入的两个Header,X-Auth-Token为调用API要传入的Token,这个一定是有效的合法的Token,否则会报401,而X-Subject-Token则是需要验证的Token,这个Token可能是无效的,也可能是有效的,需要在服务端进行验证才行,如果验证有效,则会将该Token所代表的详细信息返回,可以看到包含role, project, domain, catalog, user等信息,如果觉得catalog太长不想要,则可以传一个?nocatalog=true参数,返回值中就不会包含catalog了。 创建Token下面我们来看看Token是怎么创建出来的,为什么说其长度基本固定,以及它怎么做的对称加密。其关键代码如下: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071# keystone/token/token_formatters.py def create_token(self, user_id, expires_at, audit_ids, methods=None, system=None, domain_id=None, project_id=None, trust_id=None, federated_group_ids=None, identity_provider_id=None, protocol_id=None, access_token_id=None, app_cred_id=None): """Given a set of payload attributes, generate a Fernet token.""" ...... version = payload_class.version payload = payload_class.assemble( user_id, methods, system, project_id, domain_id, expires_at, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, app_cred_id ) versioned_payload = (version,) + payload serialized_payload = msgpack.packb(versioned_payload) token = self.pack(serialized_payload) # NOTE(lbragstad): We should warn against Fernet tokens that are over # 255 characters in length. This is mostly due to persisting the tokens # in a backend store of some kind that might have a limit of 255 # characters. Even though Keystone isn't storing a Fernet token # anywhere, we can't say it isn't being stored somewhere else with # those kind of backend constraints. if len(token) > 255: LOG.info('Fernet token created with length of %d ' 'characters, which exceeds 255 characters', len(token)) return token @property def crypto(self): """Return a cryptography instance. You can extend this class with a custom crypto @property to provide your own token encoding / decoding. For example, using a different cryptography library (e.g. ``python-keyczar``) or to meet arbitrary security requirements. This @property just needs to return an object that implements ``encrypt(plaintext)`` and ``decrypt(ciphertext)``. """ fernet_utils = utils.FernetUtils( CONF.fernet_tokens.key_repository, CONF.fernet_tokens.max_active_keys, 'fernet_tokens' ) keys = fernet_utils.load_keys() if not keys: raise exception.KeysNotFound() fernet_instances = [fernet.Fernet(key) for key in keys] return fernet.MultiFernet(fernet_instances) def pack(self, payload): """Pack a payload for transport as a token. :type payload: six.binary_type :rtype: six.text_type """ # base64 padding (if any) is not URL-safe return self.crypto.encrypt(payload).rstrip(b'=').decode('utf-8') 可见,参与Token生成的一些元数据,大部分由一些id组成的payload,比如user_id, project_id, domain_id等等,还有一些诸如methods, expires_at等长度基本上变化很小的元数据,然后加载用于加密签名的key,对这些数据组成的payload进行加密,签名以及base64编码,最终得到了我们前面看到的Fernet Token。 验证Token接下来,再来看下是如何验证Token的,这个token在不存数据库的情况下是如何获取其代表的信息的,其关键代码如下: 1234567891011121314151617181920212223242526272829303132333435363738394041# keystone/token/token_formatters.py def validate_token(self, token): """Validate a Fernet token and returns the payload attributes. :type token: six.text_type """ serialized_payload = self.unpack(token) versioned_payload = msgpack.unpackb(serialized_payload) version, payload = versioned_payload[0], versioned_payload[1:] ...... # rather than appearing in the payload, the creation time is encoded # into the token format itself issued_at = TokenFormatter.creation_time(token) issued_at = ks_utils.isotime(at=issued_at, subsecond=True) expires_at = timeutils.parse_isotime(expires_at) expires_at = ks_utils.isotime(at=expires_at, subsecond=True) return (user_id, methods, audit_ids, system, domain_id, project_id, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, app_cred_id, issued_at, expires_at) def unpack(self, token): """Unpack a token, and validate the payload. :type token: six.text_type :rtype: six.binary_type """ token = TokenFormatter.restore_padding(token) try: return self.crypto.decrypt(token.encode('utf-8')) except fernet.InvalidToken: raise exception.ValidationError( _('This is not a recognized Fernet token %s') % token) 可见,将token id传进来,使用key将其进行解密操作,最终还原回当初创建该token时的那些元数据,所以之所以Fernet Token可以不用存数据库,是因为这些信息本身就是保存在Token中的,经过服务端对该Token进行解密,就可以还原回来元数据信息,只是因为这些元数据大小基本不会发生变化,所以其长度可控。 Fernet Token Rotation从上面的步骤中可以看出,Fernet Token是采用对称加密的方式进行加密,所以用于加密的key的安全性就非常重要,如果被中间人将这个key破解出来,或者这个key泄露出去,那整个系统的安全性就完全暴露出去了,所以为了保证key的安全,我们定期需要更换这个key,而为了保证key认证的连续性,即在用一个Key签发的最后一个token过期之前,还不能将这个key删除掉,因为还需要用它来解密,所以需要采取渐进式的删除策略,即这里所说的Rotation。 因此会有多个key同时共存在key repo中,由配置参数max_active_keys来决定,该参数默认为3,最小为1,key repo默认的路径为:/etc/keystone/fernet-keys/,key的文件名都是以数字表示,示例如下: 12(keystone)[root@control1 /]$ ls /etc/keystone/fernet-keys/0 107 108 每个key在其生命周期中都会经历3个阶段: Primary Key,处在该阶段的key用来加密和解密token,编号最高的那个是Primary Key,如上例中的108; Secondary Key,处在该阶段的key只用来解密token,它是由Primary Key退变而来的,即它曾经是Primary Key,经历过一次rotate后,Primary Key就变成了Secondary Key,除了最高和最低编号的,其他都是Secondary Key,如上例中的107; Staged Key,该阶段的key,编号始终为0,每一次rotate,Staged Key被转变成了Primary Key,即编号由0变成了最高,然后再生成一个新的编号为0的key,所以顾名思义,Staged Key就是准备变成Primary的Key。 下面通过一个图,来看一下Token的Rotation的过程: 最开始只有0和1两个key,分别为staged 和 primary key; 经过一次rotate,编号为0的staged key,变成了编号为2的primary key,而编号为1的primary key变成了secondary key,但是编号没有变,然后新生成了一个编号为0的staged key,此时,共有0, 1, 2 三个key。 再经过一次rotate,编号为0的staged key,变成了编号为3的primary key,而编号为2的primary key变成了secondary key,但是编号没有变,原本编号为1的secondary key,则被删除掉了,然后新生成了一个编号为0的staged key,此时,共有0, 2, 3三个key; 所以从上面可以总结出规律,staged key总是编号为0,并且要变成下一个primary key,而primay key总是编号最大的那一个,它要变成下一个secondary key,而剩下的则是secondary key,是要被淘汰删除掉的key。 通过这种方式,渐进的将key进行更新,不会出现中断导致token没法被解析的情况。","link":"/2020/11/09/openstack/keystone-fernet-token.html"},{"title":"Keystone认证公共库(keystoneauth)","text":"keystoneauth是一个认证的公共库,它把和keystone进行认证的逻辑单独抽出来,提供了一个标准的认证过程,其实这个库就是把keystoneclient里的一部分和认证相关的代码拿了出来,单独作为一个库,然后就可以在各个服务的client中复用,这么做有下面的好处: 各个服务在和keystone进行认证时,就可以直接使用复用这个库,不用关心认证的逻辑了 安全相关的代码维护在一个地方,如果有问题,可以很快的进行修复 keystoneauth抽象出来3个概念: session:它封装了requests.session,并且引用了auth_plugin,通过auth_plugin拿到认证信息,然后由session向一个URL发起HTTP请求, session是可以复用的 auth_plugin:抽象出了多种认证的方式,包括keystone的v2, v3的password, token等认证,还有v3中的联合认证 loader:loader主要用来加载auth_plugin,即用来根据配置项实例化一个auth_plugin,每一个auth_plugin使用的认证参数都不一样,loader对这些参数进行了抽象,可以根据选择的plugin,来加载相应的配置项;loader还可以用来load session 举个例子: 12345678910111213from keystoneauth1.identity import v3from keystoneauth1 import sessionfrom novaclient import clientauth = v3.Password(auth_url='http://localhost:5000/v3', username='admin', password='rachel', project_name='admin', user_domain_id='default', project_domain_id='default')sess = session.Session(auth=auth)c = client.Client('2', session=sess)servers = c.servers.list()print servers 设计keystoneauth中抽象运用的非常经典,尤其是对auth_plugin的抽象,这是一个非常不错的学习素材,本节主要围绕上面的3个概念,对其设计和实现进行剖析。 Session理解Session非常重要,因为它贯穿了认证的整个过程,联系了keystonemiddleware, keystoneauth, keystoneclient等多个库。Session从功能上理解就是用来发送http请求的,它封装了requests这个库的session,并尽可能提供足够多的参数,与Session紧密相关的还有一个Adapter,它是Session的一个适配器,对Session进行简单的封装,主要是增加了几个用于过滤endpoint的属性。下面是Session相关的类图: Session中的session属性就是request.session对象,用来发送http请求,auth属性就是Auth类的对象,用来获取认证需要的信息的,Auth相关的内容在下一个小节详细介绍。这里重点介绍一下request()这个方法,这个方法就是收集header和相关的参数,然后调用requests.session发送http请求的,其原型如下: 1234567def request(self, url, method, json=None, original_ip=None, user_agent=None, redirect=None, authenticated=None, endpoint_filter=None, auth=None, requests_auth=None, raise_exc=True, allow_reauth=True, log=True, endpoint_override=None, connect_retries=0, logger=_logger, **kwargs): 其中比较重要的是url和endpoint_filter,还有authenticated这三个参数: authenticated, 当为False时,即不需要认证,那么就不会去获取认证的header信息,即X-Auth-Token endpoint_filter,这个是用来从keystone的endpoint列表中过滤出需要用到的endpoint,有下面几个过滤项: service_type service_name interface region_name url,http请求的路径,这个url可以有两种: 相对路径,即只有一个request_path,这种情况,会使用上面的endpoint_filter参数去keystone请求endpoint,然后拼接成一个完整的url地址 全路径,即是一个完整的uri地址,这种情况,就不会再去请求endpoint了,直接使用这个完整的url就可以了 为什么会有这些开关和判断逻辑呢?要知道session是复用的,比如在session中去向某一个url发送请求时,如果是相对路径,那么还要去获取endpoint,而获取endpoint这个操作也是在相同的session中做的,而获取endpoint的url就是一个全路径了,就是keystone的服务地址,获取到endpoint之后,再继续执行之前的动作,这类似于一个递归操作。 Adapter除了增加了几个本地化的属性之外,还实现了和Session一样的接口,在外界看来,Adapter其实就可以当作是一个Session了,是透明的,唯一的区别在哪里呢?区别就在那几个本地化的属性,在Adapter的request()方法中,将那几个属性组成了endpoint_filter,传递给了Session的request()方法: 1234567891011121314151617def _set_endpoint_filter_kwargs(self, kwargs): if self.service_type: kwargs.setdefault('service_type', self.service_type) if self.service_name: kwargs.setdefault('service_name', self.service_name) if self.interface: kwargs.setdefault('interface', self.interface) if self.region_name: kwargs.setdefault('region_name', self.region_name) if self.version: kwargs.setdefault('version', self.version) def request(self, url, method, **kwargs): endpoint_filter = kwargs.setdefault('endpoint_filter', {}) self._set_endpoint_filter_kwargs(endpoint_filter) ... return self.session.request(url, method, **kwargs) 这样通过这个adapter发出去的请求,就只能发送到某个固定的endpoint了,比如service_type=’identity’, interface=’admin’, version=’v3’,那通过这个adapter发出去的请求就都是请求到keystone的v3的admin地址了。为什么这么做呢?因为Session是可以复用的,多个client可能使用同一个session,但是每一个client所使用的endpoint地址不一样,也即endpoint_filter不一样,那这些信息不能保存在全局的session中,因为session是共享的,只能加一层封装,放到session的外部,也就是这个adapter了,所以可以见到多个adapter,一个session的情况。在各个client中,都会使用这个Adapter来封装Session,如果没有使用keystoneauth中的Adapter,也会自己写一个类似的,比如keystoneclient中就是自己写了一个(其实是原来就有的,只不过keystoneauth是从keystoneclient抽出去的) Auth PluginKeystone提供很多种认证的方式, 可以通过password,还可以直接通过token进行认证,并且每种还提供v2和v3版本,除此之外,还有OpenID认证,联合认证以等,这些认证方式都被keystoneauth抽象出来以插件的形式存在,可以根据自己的情况来选择相应的插件。那这些插件到底做了什么工作呢?一句话概括就是根据原始的认证参数,比如username/password,对keystone进行认证,然后获取到相关的信息,比如token, endpoint等。我们这里只对v2和v3的password和token的这几种插件进行分析。 首先我们先来看看最本质的东西,即如何获取一个token,所有的插件其实都是围绕这个主题组织代码的,获取token使用下面的接口: 123POST /v3/auth/tokens或者POST /v2.0/tokens 示例如下: 1curl -v -X POST -H "Content-Type: application/json" http://localhost:35357/v3/auth/tokens -d '{"auth":{"identity": {"methods": ["password"], "password": {"user":{"name": "admin", "password": "password", "domain": {"name": "Default"}}}}, "scope":{"project":{"name": "admin", "domain": {"name": "Default"}}}}}' | python -m json.tool 从上面的示例上,我们可以看到一个插件想要进行认证,需要两种信息:一个是认证服务的地址,即auth_url,还有一个就是认证原始信息了,username, password, project_name等,Auth Plugin就是对这些信息进行抽象组装,使用上小节讲的Session去获取token,然后就可以获得endpoint, roles等信息。其静态类图大致如下: 可以看到整个类图的核心逻辑,就是根据原始的认证信息,获取到认证之后的信息,即auth_ref,它包含了token, service_catalog, user_id, project_id等完备的信息。从父类继承下来,分为了三个模块:v2, v3, generic,v2就是使用keystone v2的接口进行认证,v3就是使用keystone v3的接口进行认证,那generic是干嘛的呢?generic就是不显示指定使用v2还是v3,而是根据传递的参数,还有当前keystone支持的API版本信息,来决定使用哪种认证方式的。 v2和v3相对来说比较简单直接,他们就是各自收集自己的参数,然后向auth_url发起认证请求,注意这里的auth_url是需要带版本信息的,比如你使用v2.Password的插件进行认证,那么传递给v2.Password的auth_url就必须得加上”/v2.0”,,比如:auth_url = "http://localhost:5000/v2.0/",因为从上面的类图中可以看到,它是直接使用auth_url拼接成的路径,如果不传版本信息,路径就不对了,就会报404了。v3也是类似。 generic就比较复杂一点了,它有个判断使用哪种认证方式的逻辑,首先它构造了一个Discover类,这个类在上面的类图上没有画出来,不过它的作用很简单,就是根据auth_url去keystone请求版本信息,然后解析这些信息,可以根据版本号进行排序,还可以获取指定版本的url,比如auth_url = "http://localhost:5000/v3",从keystone获取到的版本信息是: 12345678910111213141516171819202122suo@ubuntu:~$ curl http://localhost:35357/v3 | python -m json.tool { "version": { "id": "v3.5", "links": [ { "href": "http://localhost:35357/v3/", "rel": "self" } ], "media-types": [ { "base": "application/json", "type": "application/vnd.openstack.identity-v3+json" } ], "status": "stable", "updated": "2015-09-15T00:00:00Z" }} 当然,也可以请求根路径,即auth_url = "http://localhost:5000/",这样得到的就是一个版本信息的列表了,包括v2和v3的。这个请求也是通过session发送出去的,因为是全路径,而且authenticated设置为False,所以不需要任何认证认证信息,就可以直接发送出去。所以判断使用哪种认证方式的逻辑大致如下: 首先构造Discover,根据auth_url去keystone拿版本信息,这个auth_url可以是带版本的,也可以是不带版本的 如果因为某种原因从keystone获取版本信息失败,那么就会直接根据auth_url中带的版本信息来确定使用哪种认证方式,如果auth_url没有带版本信息,那么就报错了 如果第1步从keystone正常获取到了版本信息,那么会将解析的结果根据版本信息排序组成一个列表,然后遍历这个列表,会使用第一个符合条件的版本信息来构造认证插件,即使用第一个符合条件的版本的认证方式,这个条件是什么呢?就是不能v2和domain同时存在,因为domain是v3中的概念。比如指定了auth_url="http://localhost:5000/v2.0",然后又指定了domain_id或者domain_name等参数,这是不行的。但是指定了auth_url="http://localhost:5000/",然后指定了domain信息,那么它就会判断使用v3的认证方式了。即在满足v3认证的条件下,优先使用v3。这里的逻辑有点多,但是并不复杂,下面的代码片段描述了完整的逻辑: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455def _do_create_plugin(self, session): plugin = None try: disc = self.get_discovery(session, self.auth_url, authenticated=False) except (exceptions.DiscoveryFailure, exceptions.HttpError, exceptions.ConnectionError): LOG.warning('Discovering versions from the identity service ' 'failed when creating the password plugin. ' 'Attempting to determine version from URL.') url_parts = urlparse.urlparse(self.auth_url) path = url_parts.path.lower() if path.startswith('/v2.0'): if self._has_domain_scope: raise exceptions.DiscoveryFailure( 'Cannot use v2 authentication with domain scope') plugin = self.create_plugin(session, (2, 0), self.auth_url) elif path.startswith('/v3'): plugin = self.create_plugin(session, (3, 0), self.auth_url) else: # NOTE(jamielennox): version_data is always in oldest to newest # order. This is fine normally because we explicitly skip v2 below # if there is domain data present. With default_domain params # though we want a v3 plugin if available and fall back to v2 so we # have to process in reverse order. FIXME(jamielennox): if we ever # go for another version we should reverse this logic as we always # want to favour the newest available version. reverse = self._default_domain_id or self._default_domain_name disc_data = disc.version_data(reverse=bool(reverse)) v2_with_domain_scope = False for data in disc_data: version = data['version'] if (discover.version_match((2,), version) and self._has_domain_scope): # NOTE(jamielennox): if there are domain parameters there # is no point even trying against v2 APIs. v2_with_domain_scope = True continue plugin = self.create_plugin(session, version, data['url'], raw_status=data['raw_status']) if plugin: break if not plugin and v2_with_domain_scope: raise exceptions.DiscoveryFailure( 'Cannot use v2 authentication with domain scope') if plugin: return plugin # so there were no URLs that i could use for auth of any version. raise exceptions.DiscoveryFailure('Could not determine a suitable URL ' 'for the plugin') Loader在各个client中使用上面的两个概念就完全足够了,loader是用来做什么的呢?上面的认证插件,每一个都有对应的一个loader,即这个loader是专门用来加载这个认证插件的,并且最重要的是每一个loader都维护了一个要初始化其对应的认证插件的参数列表,使用这些参数列表,就可以实例化一个认证插件了。这些参数可以注册到配置文件中,然后loader在实例化这个认证插件时,就可以从配置文件中读取参数信息,然后实例化。这套机制主要是用在keystonemiddleware中的,即认证中间件,用在每一个服务的API中。下面是Loader相关的类图: 可以看到跟上面Auth Plugin的类图基本上是一一对应的,其用法大致如下: 1234567891011plugin_loader = loading.get_plugin_loader(plugin_name) # 根据plugin_name使用stevedore实例化一个loaderplugin_opts = [o._to_oslo_opt() for o in plugin_loader.get_options()] #获取这个loader的配置项,并且转成oslo_config格式的配置项plugin_kwargs = dict()(self._local_oslo_config or CONF).register_opts(plugin_opts, group=group) # 注册到CONF中for opt in plugin_opts: val = self._conf_get(opt.dest, group=group) #从配置文件读取配置 if val is not None: val = opt.type(val) plugin_kwargs[opt.dest] = valplugin = plugin_loader.load_from_options(**plugin_kwargs) # 实例化一个认证插件 数据流通过上面的分析,可以看到这里面的关系其实是很乱的,尤其是还有递归的操作,很容易绕进去绕不出来了,下面我们就以最开始的例子为例,分析下这个过程的数据流,以及各个组件之间是如何交互的。下面是novaclient和keystoneauth之间的关系类图: SessionClient继承自keystoneauth的Adapter,并且v2.Client引用了SessionClient,跟我们在上面在Session小节中的分析是一致的,即在每一个client中,都会使用Adapter来封装Session,目的是为了维护endpoinit_filter等参数,SessionClient中enpoint_filter分别为: service_type=”compute”,interface=”publicURL”,v2.Client中发出的请求,都是通过SessionClient来发出的,SessionClient又调用Session。 好,下面我们就让这些都“动”起来,并且以v2, v3, generic的Password插件为例,每一个都举一个例子: v2先来看v2,例子如下: 1234567891011from keystoneauth1.identity import v2from keystoneauth1 import sessionfrom novaclient import clientauth = v2.Password(auth_url='http://localhost:5000/v2.0', username='admin', password='rachel', tenant_name='admin')sess = session.Session(auth=auth)c = client.Client('2', session=sess)servers = c.servers.list()print servers 整个过程的序列图大致如下: 注意上面的Client指的是nova中的v2.Client,而SessionClient其实是一个keystoneauth.Adapter,重点是Session和v2.Password的交互: 第一步Session要通过v2.Password拿headers,然后v2.Password又通过Session去向Keystone发送请求,拿到一个token,然后v2.Password才将headers返回给Session,这里的headers就是X-Auth-Token 第二步因为Sessin要向nova发送请求,但是现在只知道部分url,即“/servers”,并不知道nova的endpoint是多少,因此又通过v2.Password去请求nova的endpoint,其实service_catalog已经包含在第一步请求的token中了,只需要用endpoint_filter过滤出nova的endpoint就行了 Session拿到了headers和endpoint,就可以向nova发送请求了 v3v3的例子如下: 12345678910111213from keystoneauth1.identity import v3from keystoneauth1 import sessionfrom novaclient import clientauth = v3.Password(auth_url='http://localhost:5000/v3', username='admin', password='rachel', project_name='admin', user_domain_id='default', project_domain_id='default')sess = session.Session(auth=auth)c = client.Client('2', session=sess)servers = c.servers.list()print servers v3和v2其实在流程上并没有什么大的区别,只是抽象不同,请求的路径不同: genericgeneric的情况稍微特殊一点,它有个discover的过程,diccover首先会去根据auth_url请求版本信息,这会向Keystone发起一次请求,然后会根据参数创建v2或者v3的认证插件,然后接下来就和v2, v3的过程是一样的了。 代码示例如下(假设keysone是支持v2和v3的): 12345678910111213from keystoneauth1.identity import genericfrom keystoneauth1 import sessionfrom novaclient import clientauth = generic.Password(auth_url='http://localhost:5000/', username='admin', password='rachel', project_name='admin', user_domain_id='default', project_domain_id='default')sess = session.Session(auth=auth)c = client.Client('2', session=sess)servers = c.servers.list()print servers 其序列图如下: 通过这个图,可以看到,Discover从Keystone获得版本信息,然后generic.Password根据这些信息,结合自己的参数决定应该使用v3的Password认证插件,于是使用新的auth_url创建了v3.Password,然后由v3.Password去跟Session和Keystone交互拿到token,也就拿到了auth_ref,之后就跟前两个一样了。 参数每一个auth plugin的参数都不一致,每个loader也分别维护了对应的plugin的参数列表,可以通过loader拿到参数列表,然后将这些参数注册到配置文件中,这是在keystonemiddleware中的做法,通常将这些配置项注册在keystone_authtoken这个section下面;也可以像上面的例子一样,直接实例化auth plugin,无论哪种做法,每个plugin对应的参数列表都是一样的。下面就整理成一个表格,方便查阅,这样我们就可以知道在使用哪种插件的时候,该配置哪种参数。 v2 v3 generic common auth_urltenant_idtenant_nametrust_id auth_urldomain_iddomain_nameproject_idproject_nameproject_domain_idproject_domain_nametrust_id auth_urldomain_iddomain_nameproject_idproject_nameproject_domain_idproject_domain_nametrust_iddefault_domain_iddefault_domain_name token token token token password usernameuser_idpassword user_iduser_nameuser_domain_iduser_domain_namepassword user_iduser_nameuser_domain_iduser_domain_namepassword 此外在loading中还有两个配置项: auth_type/auth_plugin,选择哪种认证插件(auth_plugin被标记为废弃,应该使用auth_type),目前有下面几种可以选择: password, 即generic password token, 即generic token v2password v2token v3password v3token auth_section,auth的配置项所在的section,默认为空,如果不配置的话,就是keystone_authtoken 在上面的参数中,最让人迷惑的就是auth_url了,到底应该是用5000还是35357?到底应不应该加版本信息?根据上面的分析其实很容易决断,auth_url主要是用来获取token的,使用5000和35357都可以,但是如果配置在keystone_authtoken中,是给各个服务使用的,一般都配置成admin的接口,也即35357,如果是给普通用户使用的,比如在各个client中,应该使用public的接口,即5000端口;至于版本信息,如果使用v2的认证插件的话,就必须带上”/v2.0”,如果使用v3的认证插件的话,也必须带上”v3”,如果使用generic的话,可带可不带,带的话,就需要注意信息要匹配,即v2不能和有domain信息的配置项共存,因为domain是v3中的概念,否则就没法决断该用哪个插件了,所以下面的例子都是合法的配置: v2: 123456[keystone_authtoken]auth_type=v2passwordauth_url = http://localhost:35357/v2.0tenant_name = serviceusername = glancepassword = GLANCE_PASS v3: 12345678[keystone_authtoken]auth_type = v3passwordauth_url = http://localhost:35357/v3project_domain_id = defaultuser_domain_id = defaultproject_name = serviceusername = glancepassword = GLANCE_PASS generic: 12345678[keystone_authtoken]auth_type = passwordauth_url = http://localhost:35357project_domain_id = defaultuser_domain_id = defaultproject_name = serviceusername = glancepassword = GLANCE_PASS generic v2: 123456[keystone_authtoken]auth_type = passwordauth_url = http://localhost:35357/v2.0project_name = serviceusername = glancepassword = GLANCE_PASS generic v3: 123456789[keystone_authtoken]auth_type = passwordauth_url = http://localhost:35357/v3project_domain_id = defaultuser_domain_id = defaultproject_name = serviceusername = glancepassword = GLANCE_PASS 但下面的这个是不合法的: 12345678[keystone_authtoken]auth_type = passwordauth_url = http://localhost:35357/v2.0project_domain_id = defaultuser_domain_id = defaultproject_name = serviceusername = glancepassword = GLANCE_PASS 因为auth_url中指定了v2.0,但是在下面又配置了domain等信息,让discover没法判断该使用哪种认证方式。 是不是有点乱?的确,现在的这种做法其实不太友好,不统一,有的必须有,有的必须没有,并且明明auth_type已经选择了v2password,但是在auth_url中,却仍必须要填写上v2.0,这不是重复了吗?社区有Bug在提这件事,但是目前建议还是采用generic的配置方式,auth_url配置到根目录,即不指定版本信息,然后再配置上domain信息,让它选择使用v3的认证方式。","link":"/2019/09/20/openstack/keystoneauth.html"},{"title":"OpenStack Stein-Train版本新功能介绍","text":"社区目标每个Release,社区都会定义几个社区目标,期望所有项目能够实现,这有利于对OpenStack下众多的项目能够有统一整体性,而不是各自发展各自的。OpenStack Stein到Train版本,社区定义了如下几个社区目标: Run under Python 3 by default 社区很早就开始推动Python2到Python3的升级,在Stein版,将默认的运行模式切换到了Python3,对Python2的支持,将逐渐被废弃。 Support Pre Upgrade Checks OpenStack升级一直以来是一个难题,社区在这方面也逐渐采取了很多措施,包括跨版本升级,以及在线升级,在Stein版,社区又制定了升级检查的支持,目的是在升级前,能够运行一个检查命令,检查各项资源是否满足升级的条件。 Support IPv6-Only Deployments 推动各个项目能够在纯IPv6的环境中部署。 特性概述这两个版本在功能上比较大的变化,是对资源管理,硬件加速,裸机管理方面的增强和改进,这几个方面相关的项目比较活跃。 资源管理项目Placement独立出来 Placement就是为了管理OpenStack中日益增多的除了CPU,内存等基础资源之外的各种资源而出现的,一开始是孵化在Nova API中的,现在已经比较成熟,独立成一个单独的项目,有自己独立的API和数据库,社区提供了从Nova API迁移到独立的Placement的步骤。 引进硬件加速器项目:cyborg 随着5G,AI需求的增加,在云平台中有效管理各种加速器的需求也逐渐多了起来,该项目提供了硬件和软件加速器的管理框架,软件包括dpdk/spdk, pmem等,硬件包括FPGA, GPU, ARM SoC, NVMe SSD等等,它跟Nova和Placement协调配合,可以将虚拟机调度到符合特定加速器要求的计算节点,并且和cyborg配合,将加速器配置到虚拟机中。 裸机管理 裸机管理的需求也在逐渐增多,因此Ironic项目比较活跃,Ironic中已经支持了很多硬件的驱动,最近又引进了跟IPMI协议类似的一个通用协议Redfish,以能够管理遵循Redfish协议的物理硬件,此外为了让裸机能够支持智能网卡,实现更加高级的网络功能,在这两个项目周期中,Ironic, Nova, Neutron都提供了相应的支持改进。功能盘点 CinderStein 在RBD driver中,增加了对multiattach和延迟删除(deferred deletion)的功能 Train 在将volume上传到glance中时,在做压缩以及格式转换时,添加了硬件加速的支持,如可利用 Intel QAT 进行加速,可以获得更大的压缩率,同时降低CPU的使用率,减少上传的时间 添加了 volume re-image api,该API是为了配合Nova对boot from volume无法进行rebuild的问题进行改进的,该API提供了原生的将一个volume重新加载进image数据的操作,让nova的rebuild操作变得简单 Glance glance在Rocky引入了multistore的能力,即同一个glance可以对接多个存储后端,类似于cinder的multibackend,这两个release,multistore功能得到进一步加强和稳定,到Train版,已经生产可用。 IronicStein 进一步加强Redfish协议硬件的支持,现在可以通过Redfish协议来Introspection操作,不用IPA的方式,此外还有可以通过Redfish来设置BIOS 引入了Deployment Templates功能,用户可以通过模板的方式对硬件的部署过程进行定制 添加了对智能网卡的支持,可以让物理机的网络配置更加灵活 添加了Allocatoin API,Ironic本身提供了一个根据特定条件,来选择备选机器的API,可以不依赖于外部的调度程序,这对独立使用Ironic提供了方便 Train 提供了对软RAID的配置 KeystoneStein 提供了一个limits api,用来为其他项目提供quota的全局支持,后续各个项目可以依赖此API,标准化quota的管理 提供了Json Web Token的支持,这是除了Fernet Token之外,引入的第二个Token机制 Train Keystone的API支持角色的分级,默认设置了reader, member, admin角色,主要是引入了reader只读的角色,即系统内置了只读的规则和角色,不需要后续由管理员专门设置 Keystone中关键的project, role, domain可以设置为immutable,以防止被误删,影响服务 KollaStein 添加了对Placement的部署支持 添加了对Cyborg的部署支持 支持配置一个独立的迁移网络来进行虚拟机的迁移 支持为nova_libvirt容器配置maximum files 和 processes limits ,默认值1024在使用ceph的情况下太小了 支持Nova, Neutron的滚动升级 对Docker的日志限制了大小,不会无限制增长了 Train 添加了对Masakari的部署支持 支持控制层面的服务运行在纯IPv6的环境中 增加了一个新的命令deploy-containers,可以在没有配置变更的情况下,只更新镜像,加快部署速度 支持为RabbitMQ传递额外的 rabbitmq_server_additional_erl_args 参数 支持为容器挂载额外的Volume,_extra_volumes. 添加了对CentOS 8的支持,包括操作系统和容器镜像,Train版本同时支持CentOS 7和CentOS 8 添加了一个参数 docker_disable_default_network,可以将Docker默认添加的网络和网桥禁用掉 KuryrStein 添加了对Kubernetes Network Policies的支持,通过安全组去实现的 添加了对CRI-O作为Kubernetes CRI时的支持 Train 强化了对Kubernetes Network Policies的支持 Kruyr CNI 使用Go重写,以方便部署 NeutronStein 添加了对QoS minimum bandwidth Port的支持,可以让Nova通过根据最小带宽的端口进行调度 增加了 Network Segment Range Management 功能,该功能可以让管理员对网络的段管理,比如VLAN ID,VXLAN的VNI进行更加灵活的管理 提升了批量创建端口的性能,这主要是为K8S对接Neutron的网络而改进的 Train 引进了一个新的API : extraroute-atomic,可以原子化的对路由器的路由表进行更新,以防止多个客户端同时更新而引起的竞争问题 为ML2/OVS memchanism driver添加了Smart NIC的支持,可以创建出由智能网卡作为后端的Port 支持对Provider network的segmentation ID进行修改 NovaStein 支持使用独立的Placement服务 在创建虚拟机的时候,可以指定volume_type,简化了在特定存储后端创建虚拟机的步骤,在这之前,需要先创建出指定volume type的volume,然后再从该volume创建虚拟机 用户可以用 quality-of-service minimum bandwidth 类型的端口创建虚拟机,该虚拟机会自动调度到该Port所在的计算节点 可配置单台虚拟机可以挂载的最多volume的数量,libvirt driver默认是最多挂载26个卷 现在可以通过placement api或者是nova配置文件来设置内存CPU的超配比 计算节点现在可以将自身的一些特征汇报给placement,在调度时,可以由flavor使用这些特征进行调度 热迁移的改进,在长时间热迁移没有完成的情况下,超过了超时时间,可以选择强制进行迁移,这会将虚拟机Pause,短时间内暂停虚拟机,强制迁移走 重构os-vif的代码,为网卡的offload给物理硬件提供了更加通用的框架 Train 支持为使用了 NUMA topology, pinned CPUs and/or huge pages等特性的虚拟机,进行热迁移 支持为使用SR-IOV Port的虚拟机提供热迁移 支持为挂载了 bandwidth-aware Quality of Service的Port进行冷迁移和resize 为nova-manage命令添加了更多的运维操作,比如归档已经删除的无用记录 提供了对持久化内存的支持,这对一些内存要求较高的应用提供了硬件层面的支持,比如HPC高性能计算,还有像redis, rocksdb这类内存型的数据库 支持 forbidden aggregates 功能,允许用户将一组特定的服务器用于特定的目的,不被其他虚拟机使用和调度 增强了对异构CPU服务器,进行热迁移时的功能,在cpu_mode设置为custom时,可以定义一组cpu_model,跟目的机器适配的,即可进行热迁移 OctaviaStein 支持选择不同的flavor来创建负载均衡,以前不能选择,是固定的某个flavor 支持在haproxy和后端server之间进行加密传输 添加了一个管理员API,可以查看到每个amphora负载均衡的状态信息 Train 支持配置amphora虚拟机中服务日志的offloading,允许其将日志导出到别的地方 支持创建volume-backend的amphora虚拟机 Oslooslo.config oslo.config一个比较大的改进是对后端配置引入了driver的机制,支持将配置存储在某个后端中,而不是限制在配置文件里,在Castellan项目中实现了一个oslo.config的driver,支持将一些明文的密码配置存储在Castellan中 oslo.messaging 在amqp<=2.4.0,并且oslo.messaging配置了TLS功能,会导致消息队列不稳定,见bug 1800957,amqp在2.4.1进行了修复,oslo.messaging现在依赖的最小版本的amqp是2.4.1 Placement Placement在这个release中独立出来,版本号为1.0.0,并且提供了一个文档,Upgrading from Nova to Placement,为迁移升级提供指导,尤其是数据库的迁移,要由之前的nova_api数据库,迁移到placement独立的数据库中 在2.0.0版本,nova需要强制启用placement 参考资料 https://governance.openstack.org/tc/goals/ https://releases.openstack.org/stein/highlights.html https://releases.openstack.org/train/highlights.html https://specs.openstack.org/ https://releases.openstack.org/stein/index.html https://releases.openstack.org/train/index.html 英文版cinder stein Added multiattach and deferred deletion support for the RBD driver. train Cinder now has upgrade checks that can be run to check for possible compatibility issues when upgrading to Train. When uploading qcow2 images to Glance the data can now be compressed. Leverage compression accelerator Add volume re-image API https://specs.openstack.org/openstack/cinder-specs/specs/train/add-volume-re-image-api.html glance rocky An implementation of multiple backend support, which allows operators to configure multiple stores and allows end users to direct image data to a specific store, is introduced as the EXPERIMENTAL Image Service API version 2.8 train Glance multi-store feature has been deemed stable ironic stein Adds additional interfaces for management of hardware including Redfish BIOS settings, explicit iPXE boot interface option, and additional hardware support. Promote iPXE to separate boot interface Out of Band Inspection support for redfish hardware type Increased capabilities and options for operators including deployment templates, improved parallel conductor workers and disk erasure processes, deployed node protection and descriptions, and use of local HTTP(S) servers for serving images. Expose conductor information from API Deploy Templates Smart NIC Networking Allocation API Improved options for standalone users to request allocations of bare metal nodes and submit configuration data as opposed to pre-formed configuration drives. Additionally allows for ironic to be leveraged using JSON-RPC as opposed to an AMQP message bus. For some long time standalone Ironic users have requested an ability to pick a node via API based on some criteria, and reserve it for deployment (== put instance_uuid) on it. Given a resource class and, optionally, a list of required traits, return me an available bare metal node and set instance_uuid on it to make it as reserved. train Basic support for building software RAID keystone stein The limits API now supports domains in addition to projects, so quota for resources can be allocated to top-level domains and distributed among children projects. Domain Level Unified Limit Support JSON Web Tokens are added as a new token format alongside fernet tokens, enabling support for a internet-standard format. JSON Web Tokens are asymmetrically signed and so synchronizing private keys across keystone servers is no longer required with this token format. Add JSON Web Tokens as a Non-persistent Token Provider Multiple keystone APIs now use default reader, member, and admin roles instead of a catch-all role, which reduces the need for customized policies to create read-only access for certain users. train All keystone APIs now use the default reader, member, and admin roles in their default policies. This means that it is now possible to create a user with finer-grained access to keystone APIs than was previously possible with the default policies. For example, it is possible to create an “auditor” user that can only access keystone’s GET APIs. Please be aware that depending on the default and overridden policies of other OpenStack services, such a user may still be able to access creative or destructive APIs for other services. Keystone roles, projects, and domains may now be made immutable, so that certain important resources like the default roles or service projects cannot be accidentally modified or deleted. This is managed through resource options on roles, projects, and domains. The keystone-manage bootstrap command now allows the deployer to opt into creating the default roles as immutable at deployment time, which will become the default behavior in the future. Roles that existed prior to running keystone-manage bootstrap can be made immutable via resource update. Immutable Resources kolla stein Added an image and playbooks for the OpenStack Placement service, which has been extracted from Nova into a separate project. Adds support for deploying the OpenStack Cyborg service. Cyborg is a service for managing hardware accelerators. Adds support for a dedicated migration network. This is configured via the variables migration_interface and migration_interface_address. Adds support for using a separate network for Octavia. This is configured via octavia_network_interface and octavia_network_interface_address. Adds support for configuring the maximum files and processes limits in the nova_libvirt container, via the qemu_max_files and qemu_max_processes variables. The default values for these are 32768 and 131072 respectively. This is useful when Nova uses Ceph as a backend, since the default limit of 1024 is often not enough. Implements Neutron rolling upgrade logic, applied for Neutron server, VPNaaS and FWaaS because only these projects have support for rolling upgrade database migration. Implements Nova rolling upgrade logic. Docker logs are no longer allowed to grow unbounded and have been limited to a fixed size per container. Two new variables have been added, docker_log_max_file and docker_log_max_size which default to 5 and 50MB respectively. This means that for each container, there should be no more than 250MB of Docker logs. train Introduced images and playbooks for Masakari, which supports instance High Availability, and Qinling, which provides Functions as a Service. Added support for control plane communication via IPv6. Adds a new kolla-ansible subcommand: deploy-containers. This action will only do the container comparison and deploy out new containers if that comparison detects a change is needed. This should be used to get updated container images, where no new config changes are need, deployed out quickly. It is now possible to pass RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS to RabbitMQ server’s Erlang VM via the newly introduced rabbitmq_server_additional_erl_args variable. See Kolla Ansible docs RabbitMQ section for details. Adds support for configuring additional Docker volumes for Kolla containers. These are configured via _extra_volumes. Adds support for CentOS 8 as a host Operating System and base container image. This is the only major version of CentOS supported from the Ussuri release. The Train release supports both CentOS 7 and 8 hosts, and provides a route for migration. Adds a new flag, docker_disable_default_network, which defaults to no. Docker is using 172.17.0.0/16 by default for bridge networking on docker0, and this might cause routing problems for operator networks. Setting this flag to yes will disable Docker’s bridge networking. This feature will be enabled by default from the Wallaby 12.0.0 release. kuryr stein Added support for handling and reacting to Network Policies events from kubernetes, allowing Kuryr-Kubernetes to handle security group rules on the fly based on them. Added support for K8s configured to use CRI-O, the Open Container Initiative-based implementation of Kubernetes Container Runtime Interface as container runtime. train Stabilization of the support for Kubernetes Network Policy. Kuryr CNI plugin is now rewriten to golang to make deploying it easier. neutron stein New framework for neutron-status upgrade check command is added. This framework allows adding various checks which can be run before a Neutron upgrade to ensure if the upgrade can be performed safely. Stadium and 3rd party projects can register their own checks to this new neutron-status CLI tool using entrypoints in neutron.status.upgrade.checks namespace. Support for strict minimum bandwidth based scheduling. With this feature, Nova instances can be scheduled to compute hosts that will honor the minimum bandwidth requirements of the instance as defined by QoS policies of its ports. https://docs.openstack.org/neutron/latest/admin/config-qos-min-bw.html Network Segment Range Management. This features enables cloud administrators to manage network segment ranges dynamically via a new API extension, as opposed to the previous approach of editing configuration files. This feature targets StarlingX and edge use cases, where ease of of management is paramount. Speed up Neutron port bulk creation. The targets are containers / k8s use cases, where ports are created in groups. train A new API, extraroute-atomic, has been implemented for Neutron routers. This extension enables users to add or delete individual entries to a router routing table, instead of having to update the entire table as one whole. The new API extension extraroute-atomic introduces two new member actions on routers to add/remove routes atomically on the server side. The use of these new member actions (PUT /v2.0/routers/ROUTER-ID/add_extraroutes and PUT /v2.0/routers/ROUTER-ID/remove_extraroutes) is always preferred to the old way (PUT /v2.0/routers/ROUTER-ID) when multiple clients edit the extra routes of a router since the old way is prone to race conditions between concurrent clients and therefore to possible lost updates. Support for L3 conntrack helpers has been added. Users can now configure conntrack helper target rules to be set for a router. This is accomplished by associating a conntrack_helper sub-resource to a router. https://klose911.github.io/html/iptables/state.html Add Support for Smart NIC in ML2/OVS mechanism driver, by extending the Neutron OVS mechanism driver and Neutron OVS Agent to bind the Neutron port for the baremetal host with Smart NIC. The segmentation ID of a provider network can be now modified, even with OVS ports bound. Note that, during this process, the traffic of the bound ports tagged with the former segmentation ID (external VLAN) will be mapped to the new one. This can provoke a traffic disruption while the external network VLAN is migrated to the new tag. Change the segment ID of a VLAN provider network nova stein It is now possible to run Nova with version 1.0.0 of the recently extracted placement service, hosted from its own repository. Note that install/upgrade of an extracted placement service is not yet fully implemented in all deployment tools. Operators should check with their particular deployment tool for support before proceeding. See the placement install and upgrade documentation for more details. In Stein, operators may choose to continue to run with the integrated placement service from the Nova repository, but should begin planning a migration to the extracted placement service by Train, as the removal of the integrated placement code from Nova is planned for the Train release. Users can now specify a volume type when creating servers. Boot instance specific storage backend Currently, when creating a new boot-from-volume instance, the user can only control the type of the volume by pre-creating a bootable image-backed volume with the desired type in cinder and providing it to nova during the boot process. When the user wants to boot the instance on the specified backend, this is not friendly to the user when there are multiple storage backends in the environment. As a user, I would like to specify volume type to my instances when I boot them, especially when I am doing bulk boot. The “bulk boot” means creating multiple servers in separate requests but at the same time. Users can now create servers with Neutron ports that have quality-of-service minimum bandwidth rules. Network Bandwidth resource provider Configure maximum number of volumes to attach Currently, there is a limitation in the libvirt driver restricting the maximum number of volumes to attach to a single instance to 26. Operators can now set overcommit allocation ratios using Nova configuration files or the placement API. Default allocation ratio configuration Compute driver capabilities are now automatically exposed as traits in the placement API so they can be used for scheduling via flavor extra specs and/or image properties. https://docs.openstack.org/nova/latest/admin/scheduling.html#compute-capabilities-as-traits Live-Migration force after timeout Generic os-vif datapath offloads The existing method in os-vif is to pass datapath offload metadata via a VIFPortProfileOVSRepresentor port profile object. This is currently used by the ovs reference plugin and the external agilio_ovs plugin. This spec proposes a refactor of the interface to support more VIF types and offload modes. train Live migration support for servers with a NUMA topology, pinned CPUs and/or huge pages, when using the libvirt compute driver. NUMA-aware live migration Live migration support for servers with SR-IOV ports attached when using the libvirt compute driver. Support for cold migrating and resizing servers with bandwidth-aware Quality of Service ports attached. Cold migration and resize are now supported for servers with neutron ports having resource requests. E.g. ports that have QoS minimum bandwidth rules attached. Improved operational tooling for things like archiving the database and healing instance resource allocations in Placement. nova-manage db archive_deleted_rows Support for VPMEM (Virtual Persistent Memory) when using the libvirt compute driver. This provides data persistence across power cycles at a lower cost and with much larger capacities than DRAM, especially benefitting HPC and memory databases such as redis, rocksdb, oracle, SAP HANA, and Aerospike. support virtual persistent memory Train is the first cycle where Placement is available solely from its own project and must be installed separately from Nova. Added support for forbidden aggregates which allows groups of resource providers to only be used for specific purposes, such as reserving a group of compute nodes for licensed workloads. Support filtering of allocation_candidates by forbidden aggregates https://docs.openstack.org/nova/latest/reference/isolate-aggregates.html Select CPU model from a list of CPU models octavia stein Octavia now supports load balancer “flavors”. This allows an operator to create custom load balancer “flavors” that users can select when creating a load balancer. Octavia now supports backend re-encryption of connections to member servers. Backend re-encryption allows users to configure pools to initiate TLS connections to the backend member servers. This enables load balancers to authenticate and encrypt connections from the load balancer to the backend member server. Added new tool octavia-status upgrade check. This framework allows adding various checks which can be run before a Octavia upgrade to ensure if the upgrade can be performed safely. Adds an administrator API to access per-amphora statistics train Octavia now supports Amphora log offloading. Operators can define syslog targets for the Amphora administrative logs and for the tenant load balancer flow logs. Allow creation of volume based amphora. Many deploy production use volume based instances because of more flexibility. Octavia will create volume and attach this to the amphora. oslo oslo.config Added a Castellan config driver that allows secrets to be moved from on-disk config files to any Castellan-compatible keystore. This driver lives in the Castellan project, so Castellan must be installed in order to use it. Various regulations and best practices say that passwords and other secret values should not be stored in plain text in configuration files. There are “secret store” services to manage values that should be kept secure. Castellan provides an abstraction API for accessing those services. Castellan also depends on oslo.config, which means oslo.config cannot use castellan directly. https://specs.openstack.org/openstack/oslo-specs/specs/queens/oslo-config-drivers.html oslo.messaging In combination with amqp<=2.4.0, oslo.messaging was unreliable when configured with TLS (as is generally recommended). Users would see frequent errors such as this: MessagingTimeout: Timed out waiting for a reply to message ID ae039d1695984addbfaaef032ce4fda3 Such issues would typically lead to downstream service timeouts, with no recourse available other than disabling TLS altogether (see bug 1800957). The underlying issue is fixed in amqp version 2.4.1, which is now the minimum version that oslo.messaging requires. placement stein The 1.0.0 release of Placement is the first release where the Placement code is hosted in its own repository and managed as its own OpenStack project. Because of this, the majority of changes are not user-facing. There are a small number of new features (including microversion 1.31) and bug fixes, listed below. A new document, Upgrading from Nova to Placement, has been created. It explains the steps required to upgrade to extracted Placement from Nova and to migrate data from the nova_api database to the placement_database. train The 2.0.0 release of placement is the first release where placement is available solely from its own project and must be installed separately from nova. If the extracted placement is not already in use, prior to upgrading to Train, the Stein version of placement must be installed. See Upgrading from Nova to Placement for details.","link":"/2021/11/19/openstack/openstack_rst_highlight.html"},{"title":"OpenStack Ussuri-Victoria-Wallaby版本新功能介绍","text":"社区目标每个Release,社区都会定义几个社区目标,期望所有项目能够实现,这有利于对OpenStack下众多的项目能够有统一整体性,而不是各自发展各自的。OpenStack 从Ussuir到Victoria到Wallaby版本,社区定义了如下几个社区目标: Drop Python 2.7 Support Migrate RBAC Policy Format from JSON to YAML Migrate from oslo.rootwrap to oslo.privsep 首先是移除了对Python 2.7的支持,在前几个版本,逐渐将各个项目切换到了Python 3,在U版各个项目都申明了对Python 2.7不再提供支持。其次是要将RBAC的policy文件的格式从JSON切换到YAML格式,在Q版,将默认的policy规则写到了代码中,需要定制的才需要写到配置文件中,在W版,则将policy配置文件的格式从json切换到了yaml,更加方便和友好。此外,还有将项目中需要执行root权限命令依赖的组件从oslo.rootwrap切换到了oslo.privsep,更加安全。 特性概述整体上,这几个release没有什么新的功能推出,而是在前几个版本的基础上,进行增强和改进,主要有以下几个特点: 全面拥抱Python 3,各个项目逐渐移除了对Python 2的支持 在安全层面,都陆陆续续推进了reader只读权限的API改造 在T版引入multistore之后,nova, cinder等项目都得进行适配,以支持multistore 在硬件加速,裸机管理上持续改进,添加了更多硬件支持,并且跟nova等项目进行更好的交互集成 在网络层面,新增加了ovn的ML2 memchanism driver,对ovn driver进行了很多改进,目标是后续将ovn替代ovs成为默认的driver 功能盘点CinderUssuri 进行了很多功能上的改进,包括为某个volume type设置最大最小的size,并且可以过滤出某一个时间段内的volume列表 支持glance的multistore,在将volume上传到glance时,支持指定store进行上传 Victoria 支持设置默认的volume type,并且可以为单独的某个项目设置volume type 为cinder backup开发了新的压缩算法Zstandard,之前默认的压缩算法是zlib Wallaby 添加了新的存储后端:Ceph iSCSI, Dell EMC PowerVault ME, KIOXIA Kumoscale, Open-E JovianDSS, and TOYOU ACS5000. cinder-manage命令增加了对quota的check和sync命令,用来为检查和矫正不同步的quota CyborgUssuri 由于nova和cyborg的集成已经完成,用户可以创建带加速器的虚拟机 增加了新的API,可以用来将cyborg管理的硬件列表列出来 Victoria 支持对带加速器的虚拟机进行Rebuild和Evacuate操作 支持了更多硬件加速器(Intel QAT and Inspur FPGA) Wallaby 支持对带加速器的虚拟机进行 Shelve/Unshelve 操作 支持更多的硬件加速器(Intel NIC and Inspur NVMe SSD) GlanceUssuri 增强multistore的功能,现在可以将镜像一次上传到所有的store中,并且支持在不同的store之间拷贝镜像,可以删除单个store中的镜像 在glance-store中,将s3 driver又引入进来,之前是由于没人维护,所以将s3 driver删除掉了 Victoria 增强multistore的功能,管理员可以设置允许其他用户从其他租户拷贝镜像 Glance 支持配置 Cinder的 mutibackend,即Glance的后端是Cinder时,可以指定Cinder的volume type Wallaby 支持glance-direct的上传方式,即不需要一个所有API都可以访问到的共享存储,就可以使用 Interoperable Image Import 的镜像上传方式 IronicUssuri 新增了硬件的retirement功能,可以让用户将某个node设置为retirement,新的调度请求就不会调度到这个节点上 支持了多租户模式,允许普通用户使用裸机资源 Victoria deploy steps的步骤进行了优化,分解成多个步骤,在部署的时候,可以支持RAID和BIOS的配置 Wallaby RBAC的增强,内置支持只读,普通用户,管理员三个等级的权限划分 Keystone 在Keystone的bootstrap阶段,已经默认将admin的role设置为 immutable KollaUssuri 所有的镜像,脚本,以及ansible playbook,都已经切换到Python 3,移除了对Python 2的支持 支持CentOS 8作为操作系统以及容器镜像,移除了对CentOS 7的支持,Train是唯一一个即支持CentOS 7和 CentOS 8的版本 添加了对后端API的TLS加密的支持,包括Barbican, Cinder, Glance, Heat, Horizon, Keystone, Nova and Placement 移除了对Ceph部署的支持,仅提供跟外部Ceph进行对接的逻辑 Victoria 为核心的项目添加了Docker的healthcheck 添加了对RabbitMQ的TLS加密支持 支持添加多个globals.yml,在 /etc/globals.d/目录中,可以添加多个*.yml文件,可以为特定的服务创建独立的配置文件 添加了 配置项 haproxy_host_ipv4_tcp_retries2,去配置TCP 重连的内核参数 ,修复了VIP漂移时,因为数据库连接没有及时释放而导致的服务故障 Wallaby 添加了对Prometheus 2.x的支持 添加了对CentOS Stream 8的支持,它可以作为操作系统以及容器镜像,从Wallaby开始,Kolla将仅支持CentOS Stream 8发行版,Victoria是唯一一个即支持CentOS Linux 8,又支持CentOS Stream 8的版本 为其他项目的容器添加了Docker Healthcheck 支持在同一个OpenStack集群中,部署多个MariaDB集群,以支持不同服务使用不同的数据库集群,提升集群的支撑能力 修复了一个比较严重的bug,在停掉nova_libvirt容器时,会使其上的虚拟机被Kill,见链接:LP#1941706 NeutronUssuri 新增了ovn ml2 memchanism driver,以后ovn可能取代ovs成为默认的ml2 driver 支持配置stateless的安全组 Victoria Metadata服务现在支持在IPv6环境下运行 floatingip的port forwarding功能,目前添加到了ovn driver中 Wallaby 添加了一个新的网络类型 network:routed ,支持通过BGP协议下发路由 在SR-IOV的ml2 driver中,添加了 一个新的 网卡类型 accelerator-direct,可以创建以cyborg中管理的硬件加速器作为后端的网络端口 Neutron RBAC默认也支持了只读,普通用户,管理员的权限管理 NovaUssuri 由于nova和cyborg的集成已经完成,用户可以创建带加速器的虚拟机 libvirt driver现在支持带持久化内存的虚拟机进行热迁移 RBAC的增强,内置支持只读,普通用户,管理员三个等级的权限划分 Victoria 支持在同一个虚拟机中,混用pin cpu和floating cpu,可以让CPU密集型的业务使用pin cpu,而其他业务使用floating cpu 使用Glance的multistore模式时,并且Glance的后端是RBD,nova支持 fast cloning 操作,即当在某一个sotre中,没有找到对应的镜像,那么Nova会请求Glance将镜像在store之间复制一份,避免了以前需要下载再上传的操作 Wallaby 支持运行中的虚拟机绑定 QoS minimum bandwidth 类型的Port OctaviaUssuri 支持CentOS 8作为amphora镜像 Victoria Load Balancer的监控数据现在可以上传给多个外部的系统,可以方便的跟第三方的监控系统进行集成 创建amphora虚拟机时,可以指定镜像的tag放到flavor中,支持使用不同的镜像创建amphora虚拟机 Load Balancer现在支持v2版本的PROXY协议,可以获得更好的性能 Wallaby Load Balancer现在支持gRPC协议 Load Balancer支持 Stream Control Transmission Protocol (SCTP) 负载均衡算法 参考资料 https://governance.openstack.org/tc/goals/ https://releases.openstack.org/ussuri/highlights.html https://releases.openstack.org/victoria/highlights.html https://releases.openstack.org/wallaby/highlights.html https://specs.openstack.org/ https://releases.openstack.org/ussuri/index.html https://releases.openstack.org/victoria/index.html https://releases.openstack.org/wallaby/index.html 英文版cinder ussuri Numerous improvements in current functionality, for example, the ability to set minimum and maximum sizes for volume-types; the ability to filter the volume list using time comparison operators. Support to query cinder resources filter by time comparison operators Support for Glance multistore and image data colocation when uploading a volume to the Image Service. Support Glance multiple stores Python 2 is no longer supported. The minimum version of Python that may be used with this release is Python 3.6. victoria Improved handling around the configured default volume-type and added new Block Storage API calls with microversion 3.62 that enable setting a project-level default volume-type for individual projects. Default volume type overrides Support was added to cinder backup to use the popular Zstandard compression algorithm. The cinder backup service has added support for the popular Zstandard compression algorithm. (The default is the venerable Deflate (zlib) algorithm.) Support modern compression algorithms in cinder backup wallaby Added new backend drivers: Ceph iSCSI, Dell EMC PowerVault ME, KIOXIA Kumoscale, Open-E JovianDSS, and TOYOU ACS5000. Additionally, many current drivers have added support for features exceeding the required driver functions, with revert to snapshot and backend QoS being particularly popular this cycle. The cinder-manage command now includes a new quota category with two possible actions check and sync to help administrators manage out of sync quotas on long running deployments. cyborg ussuri Users can now launch instances with accelerators managed by Cyborg, as the Nova-Cyborg integration has been completed. See accelerator operation guide to find which instance operations are supported. New APIs have been implemented to list devices managed by Cyborg and, in general, to view and manage inventory of accelerators. victoria Users can launch instances with accelerators managed by Cyborg since Ussuri release, this release two more operations * Rebuild and * Evacuate are supported. See accelerator operation guide to find all supported operations. Cyborg supported new accelerator drivers (Intel QAT and Inspur FPGA) and reached an agreement that Vendors who want to implement a new driver should at least provide a full driver report result. (Of course, providing third-party CI is more welcome.) Supported drivers https://docs.openstack.org/cyborg/latest/reference/support-matrix.html_ wallaby Users can launch instances with accelerators managed by Cyborg since Ussuri release, this release more operations such as Shelve/Unshelve are supported. See accelerator operation guide to find all supported operations. Cyborg introduces more new accelerator drivers such as Intel NIC and Inspur NVMe SSD driver which allow user to boot up a VM with such device attached. glance ussuri Enhancement in multiple stores feature, users now can import single image in multiple stores, copy existing imgae in multiple stores and delete image from single store. Introduced S3 driver for glance-store again Dropped support for python 2.7 victoria Enhancement in multiple stores feature, administrator can now set policy to allow user to copy images owned by other tenants Glance allow to configure cinder multi-stores, During upgrade from single cinder store to multiple cinder stores, legacy images location url will be updated to the new format with respect to the volume type configured in the stores. Legacy location url: cinder:// New location url: cinder:/// wallaby Glance now supports the glance-direct import method without needing shared storage common to all API workers. By telling each API worker the URL by which it can be reached directly (from the other workers), a shared staging directory can be avoided while still allowing users to upload their data for import. See the worker_self_reference_url config option for more details, as well as the Interoperable Image Import docs. ironic ussuri Support for a hardware retirement workflow to enable automation of hardware decommission in managed clouds. Multitenancy concepts and additional policy options are available for non-administrator usage of Ironic. victoria The deploy steps work has decomposed the basic deployment operation into multiple steps which can now also include steps from supported RAID and BIOS interfaces at the time of deploy. wallaby The System scoped RBAC model is now supported by Ironic along with the admin, member, and reader roles. This work has resulted in over 1500 new unit tests being added to Ironic. keystone ussuri When bootstrapping a new keystone deployment, the admin role now defaults to having the “immutable” option set, which prevents it from being accidentally deleted or modified unless the “immutable” option is deliberately removed. victoria wallaby kolla ussuri All images, scripts and Ansible playbooks now use Python 3, and support for Python 2 has been dropped. CentOS 8 is now supported as a host operating system and container image, and support for CentOS 7 has been dropped. Adds support for CentOS 8 as a host Operating System and base container image. This is the only major version of CentOS supported from the Ussuri release. The Train release supports both CentOS 7 and 8 hosts, and provides a route for migration. Added initial support for TLS encryption of backend API services, providing end-to-end encryption of API traffic. Currently Barbican, Cinder, Glance, Heat, Horizon, Keystone, Nova and Placement are supported. Support for deploying Ceph has been removed, after it was deprecated in Stein. Please use an external tool to deploy Ceph and integrate it with Kolla Ansible deployed OpenStack by following the external Ceph guide. victoria Implements container healthchecks for core OpenStack services. Docker healthchecks are periodically called scripts that check health of a running service that expose health information in docker ps output and trigger a health_status event. Healthchecks are now enabled by default and can be disabled by setting enable_container_healthchecks to no in globals.yml. Adds support for TLS encryption of RabbitMQ client-server communication. See blueprint for details. Adds configuration options to enable backend TLS encryption from HAProxy to the Nova, Ironic, and Neutron services. When used in conjunction with enabling TLS for service API endpoints, network communcation will be encrypted end to end, from client through HAProxy to the backend service. Adds support for multiple globals files. The main globals.yml file still exists. In addition to that, operators can now create a globals.d directory (next to globals.yml), where they can place any number of *.yml files, for example for specific services they want to add. Adds a new flag, docker_disable_default_network, which defaults to no. Docker is using 172.17.0.0/16 by default for bridge networking on docker0, and this might cause routing problems for operator networks. Setting this flag to yes will disable Docker’s bridge networking. This feature will be enabled by default from the Wallaby 12.0.0 release. Added a new haproxy configuration variable, haproxy_host_ipv4_tcp_retries2, which allows users to modify this kernel option. This option sets maximum number of times a TCP packet is retransmitted in established state before giving up. The default kernel value is 15, which corresponds to a duration of approximately between 13 to 30 minutes, depending on the retransmission timeout. This variable can be used to mitigate an issue with stuck connections in case of VIP failover, see bug 1917068 for details. wallaby Prometheus version 2.x deployment added. This version is enabled by default and replaces a forward-incompatible version 1.x. A variable prometheus_use_v1 can be set to yes to preserve version 1.x deployment with its data. Otherwise, Prometheus will start with a new volume, ignoring all previously collected metrics. Adds support for CentOS Stream 8 as a host Operating System and base container image. This is the only distribution of CentOS supported from the Wallaby release. The Victoria release will support both CentOS Linux 8 and CentOS Stream 8 hosts and images, and provides a route for migration. Implemented container healthchecks for following services: aodh, barbican, blazar, cinder, cloudkitty, cyborg, designate, elasticsearch, gnocchi, haproxy, ironic, kibana, magnum, manila, octavia, redis, sahara, senlin, skydive, tacker, trove, vitrage, watcher. See blueprint The Mariadb role now allows the creation of multiple clusters. This provides a benefit to operators as they are able to install and maintain several clusters at once using kolla-ansible. This is useful when deploying database clusters for cells or database clusters for services that have large demands on the database. Fixes a critical bug which caused Nova instances (VMs) using libvirtd (the default/usual choice) to get killed on libvirtd (nova_libvirt) container stop (and thus any restart - either manual or done by running Kolla Ansible). It was affecting Wallaby+ on CentOS, Ubuntu and Debian Buster (not Bullseye). If your deployment is also affected, please read the referenced Launchpad bug report, comment #22, for how to fix it without risking data loss. In short: fixing requires redeploying and this will trigger the bug so one has to first migrate important VMs away and only then redeploy empty compute nodes. LP#1941706 neutron ussuri Python 2 is no longer supported by Neutron, Python 3.6 and 3.7 are. The OVN driver is now merged into Neutron repository and is one of the in-tree Neutron ML2 drivers, like linuxbridge or openvswitch. OVN driver benefits over the openvswitch driver include for example DVR with distributed SNAT traffic, distributed DHCP and possibility to run without network nodes. Other ML2 drivers are still in-tree and are fully supported. Currently default agent is still openvswitch but our plan is to make OVN driver to be the default choice in the future. Support for stateless security groups has been added. Users can now create security group set as stateless which means that conntrack will not be used for any rule in that group. One port can only use stateless or stateful security groups. In some use cases stateless security groups will allow operator to choose for optimized datapath performance whereas stateful security groups impose extra processing on the system. victoria Metadata service is now available over IPv6. Users can now use metadata service without config drive in IPv6-only networks. Support for floating IPs port forwarding has been added to OVN backend. Support for Floating IP port forwarding has been added for the OVN backend. Users can now create port forwardings for Floating IPs when the OVN backend is used in Neutron. wallaby A new subnet of type network:routed has been added. If such a subnet is used, the IPs of that subnet will be advertized with BGP over a provider network, which itself can use segments. This basically achieves a BGP-to-the-rack feature, where the L2 connectivity can be confined to a rack only, and all external routing is done by the switches, using BGP. In this mode, it is still possible to use VXLAN connectivity between the compute nodes, and only floating IPs and router gateways are using BGP routing. Added support in SR-IOV agent for accelerator-direct VNIC type. This type represents a port that supports any kind of hardware acceleration and is provided by Cyborg (https://wiki.openstack.org/wiki/Cyborg). RFE: 1909100. accelerator-direct-physical is still not supported. Neutron now experimentally supports new API policies with the system scope and the default roles (member, reader, admin). nova ussuri Python 2 is no longer supported by Nova, Python 3.6 and 3.7 are. Support for creating servers with accelerator devices via Cyborg. The libvirt driver now supports live migration with virtual persistent memory (vPMEM), which requires QEMU as hypervisor. In virtualization layer, QEMU will copy vpmem over the network like volatile memory, due to the typical large capacity of vPMEM, it may takes longer time for live migration. The Nova policies implemented the scope concept and new default roles (admin, member, and reader) provided by keystone. Further enahanced support for moving servers with minimum bandwidth guarantees. victoria Nova supports mixing pinned and floating CPUs within the same nova server. Add the mixed instance CPU allocation policy for instance mixing with both PCPU and VCPU resources. This is useful for applications that wish to schedule the CPU intensive workload on the PCPU and the other workloads on VCPU. The mixed policy avoids the necessity of making all instance CPUs to be pinned CPUs, as a result, reduces the consuption of pinned CPUs and increases the instance density. Nova supports fast cloning of Glance images from the Ceph RBD cluster even if Glance multistore configuration is used. The libvirt RBD image backend module can now handle a Glance multistore environment where multiple RBD clusters are in use across a single Nova/Glance deployment, configured as independent Glance stores. In the case where an instance is booted with an image that does not exist in the RBD cluster that Nova is configured to use, Nova can ask Glance to copy the image from whatever store it is currently in to the one that represents its RBD cluster. To enable this feature, set [libvirt]/images_rbd_glance_store_name to tell Nova the Glance store name of the RBD cluster it uses. Libvirt RBD image backend support for glance multistore wallaby Now Nova supports attaching neutron ports with QoS minimum bandwidth rules for running servers. The libvrit driver now supports vDPA (vHost data path acceleration), a vendor neutral way to accelerate standard virtio device using software or hardware accelerator implementations. octavia ussuri Added support for CentOS 8 amphora images. victoria Load balancer statistics can now be reported to multiple statistics drivers simultaneously and supports delta metrics. This allows easier integration into external metrics system, such as a time series database. Octavia flavors for the amphora driver now support specifying the glance image tag as part of the flavor. This allows the operator to define Octavia flavors that boot alternate amphora images. Load balancer pools now support version two of the PROXY protocol. This allows passing client information to member servers when using TCP protocols. PROXYV2 improves the performance of establishing new connections using the PROXY protocol to member servers, especially when the listener is using IPv6. wallaby With the addition of ALPN and HTTP/2 support for backend pool members, Octavia now supports the gRPC protocol. gRPC enables bidirectional streaming of Protocol Buffer messages through the load balancer. Octavia now supports Stream Control Transmission Protocol (SCTP) load balancing. The addition of SCTP enables new mobile, telephony, and multimedia use cases for Octavia. Load balancers using the amphora provider will benefit from increased performance and scalability when using amphora images built with version 2.x of the HAProxy load balancing engine. Amphora instances are now supported on AArch64/ARM64 based instances. plancement ussuri Python 2.7 support has been dropped. The minimum version of Python now supported by placement is Python 3.6. wallaby The default policies provided by placement have been updated to add support for read-only roles. This is part of a broader community effort to support read-only roles and implement secure, consistent default policies. Refer to the Keystone documentation for more information on the reason for these changes.","link":"/2021/11/19/openstack/openstack_uvw_highlight.html"},{"title":"Kubernetes APIServer API Resource Installation","text":"在Kubernetes APIServer Storage 框架解析中,我们介绍了APIServer相关的存储框架,每个API资源,都有对应的REST store以及etcd store。在Kubernetes APIServer GenericAPIServer中介绍了GenericAPIServer的Handler是如何构建,API对象是如何以APIGroupInfo的形式注册进Handler中的。在Kubernetes APIServer 机制概述中简单介绍了APIServer的扩展机制,即Aggregator, APIExtensions以及KubeAPIServer这三者之间通过Delegation的方式实现了扩展。本篇文章就重点介绍下这三个”扩展对象”中的API对象资源是如何组织成APIGroupInfo的,然后怎么调用GenericAPIServer中暴露出来的安装方法进行安装的,最后盘点下当前版本的Kubernetes中,都有哪些API对象资源。 KubeAPIServer是Kubernetes内置的API对象所在的APIServer,而Aggregator和APIExtensions是Kubernetes API的两个扩展机制,对这两个扩展机制的介绍见官方文档,APIExtensions就是CRD的实现,而Aggregator是一种高级扩展,可以让Kubernetes APIServer跟外部的APIServer进行联动,这三者中,每个都包含一个GenericAPIServer,先来看下这三个对应的结构体: 12345678910111213141516171819202122232425262728293031323334353637383940# kubernetes/pkg/controlplane/instance.go// KubeAPIServertype Instance struct { GenericAPIServer *genericapiserver.GenericAPIServer ClusterAuthenticationInfo clusterauthenticationtrust.ClusterAuthenticationInfo}# kube-aggregator/pkg/apiserver/apiserver.go// Aggregatortype APIAggregator struct { GenericAPIServer *genericapiserver.GenericAPIServer delegateHandler http.Handler // proxyClientCert/Key are the client cert used to identify this proxy. Backing APIServices use // this to confirm the proxy's identity proxyClientCert []byte proxyClientKey []byte proxyTransport *http.Transport // proxyHandlers are the proxy handlers that are currently registered, keyed by apiservice.name proxyHandlers map[string]*proxyHandler // handledGroups are the groups that already have routes handledGroups sets.String ......}# apiextensions-apiserver/pkg/apiserver/apiserver.go// APIExtensionstype CustomResourceDefinitions struct { GenericAPIServer *genericapiserver.GenericAPIServer // provided for easier embedding Informers externalinformers.SharedInformerFactory} 他们各自的API对象都是安装注册到各自的GenericAPIServer中的,除了Instance中Kubernetes API内置的像pods, services这些API对象外,APIAggregator和CustomResourceDefinitions也都内置了各自的API对象,不过这些API对象也都是为了本身的扩展而设计的,APIAggregator中内置的API对象叫做apiservices,所属的组为apiregistration.k8s.io,每一个外部的APIServer都抽象为这个apiservices,注册到APIAggregator中,而apiextensions中内置的API对象就叫做customresourcedefinations,所属的组为apiextensions.k8s.io,这就是我们常说的CRD了,每一个自定义的资源,都抽象为一个CRD。 注意,这里面的名词,KubeAPIServer和Instance对应,Aggretator和APIAggregator对应,APIExtensions和CustomResourceDefinitions对应,前者是在代码中他们各自的GenericAPIServer的name,而后者是对应的结构体的名字。 实例化上面的三个结构体,就是在Kubernetes APIServer 机制概述介绍的CreateServerChain()阶段做的,通过Config->Complete->New模式被初始化出来,核心的逻辑,在New()方法中,我们重点关注下其中的安装API对象的逻辑,来分别看下。 KubeAPIServerKubeAPIServer中内置的对象分为两类,一类是Legacy的,是早期设计的API,那时候还没有分组的设计,它里面API对象的前缀统一是这样的: /api/v1,像pods, services, nodes都属于这一类,路径中不带组信息,一般我们称他们为core/legacy组,另一类就是有分组设计的了,它里面API对象的前缀都是带组和版本信息的: /apis/$GROUP_NAME/$VERSION,像deployments, daemonsets都属于这一类的,这种我们称之为named group。这两种API,在Instace的New()方法中,有不同的组织方式: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768# kubernetes/pkg/controlplane/instance.gofunc (c completedConfig) New(delegationTarget genericapiserver.DelegationTarget) (*Instance, error) { s, err := c.GenericConfig.New("kube-apiserver", delegationTarget) ...... m := &Instance{ GenericAPIServer: s, ClusterAuthenticationInfo: c.ExtraConfig.ClusterAuthenticationInfo, } ...... legacyRESTStorageProvider, err := corerest.New(corerest.Config{ GenericConfig: corerest.GenericConfig{ StorageFactory: c.ExtraConfig.StorageFactory, EventTTL: c.ExtraConfig.EventTTL, LoopbackClientConfig: c.GenericConfig.LoopbackClientConfig, ServiceAccountIssuer: c.ExtraConfig.ServiceAccountIssuer, ExtendExpiration: c.ExtraConfig.ExtendExpiration, ServiceAccountMaxExpiration: c.ExtraConfig.ServiceAccountMaxExpiration, APIAudiences: c.GenericConfig.Authentication.APIAudiences, Informers: c.ExtraConfig.VersionedInformers, }, Proxy: corerest.ProxyConfig{ Transport: c.ExtraConfig.ProxyTransport, KubeletClientConfig: c.ExtraConfig.KubeletClientConfig, }, Services: corerest.ServicesConfig{ ClusterIPRange: c.ExtraConfig.ServiceIPRange, SecondaryClusterIPRange: c.ExtraConfig.SecondaryServiceIPRange, NodePortRange: c.ExtraConfig.ServiceNodePortRange, IPRepairInterval: c.ExtraConfig.RepairServicesInterval, }, }) restStorageProviders := []RESTStorageProvider{ legacyRESTStorageProvider, apiserverinternalrest.StorageProvider{}, authenticationrest.RESTStorageProvider{Authenticator: c.GenericConfig.Authentication.Authenticator, APIAudiences: c.GenericConfig.Authentication.APIAudiences}, authorizationrest.RESTStorageProvider{Authorizer: c.GenericConfig.Authorization.Authorizer, RuleResolver: c.GenericConfig.RuleResolver}, autoscalingrest.RESTStorageProvider{}, batchrest.RESTStorageProvider{}, certificatesrest.RESTStorageProvider{}, coordinationrest.RESTStorageProvider{}, discoveryrest.StorageProvider{}, networkingrest.RESTStorageProvider{}, noderest.RESTStorageProvider{}, policyrest.RESTStorageProvider{}, rbacrest.RESTStorageProvider{Authorizer: c.GenericConfig.Authorization.Authorizer}, schedulingrest.RESTStorageProvider{}, storagerest.RESTStorageProvider{}, flowcontrolrest.RESTStorageProvider{InformerFactory: c.GenericConfig.SharedInformerFactory}, // keep apps after extensions so legacy clients resolve the extensions versions of shared resource names. // See https://github.com/kubernetes/kubernetes/issues/42392 appsrest.StorageProvider{}, admissionregistrationrest.RESTStorageProvider{Authorizer: c.GenericConfig.Authorization.Authorizer, DiscoveryClient: discoveryClientForAdmissionRegistration}, eventsrest.RESTStorageProvider{TTL: c.ExtraConfig.EventTTL}, resourcerest.RESTStorageProvider{}, } if err := m.InstallAPIs(c.ExtraConfig.APIResourceConfigSource, c.GenericConfig.RESTOptionsGetter, restStorageProviders...); err != nil { return nil, err } ......} 可以看到,针对每个group,构造了一个RESTStorageProvider结构体,包括core group也是,这些结构体都实现了下面的接口: 123456# kubernetes/pkg/controlplane/instance.gotype RESTStorageProvider interface { GroupName() string NewRESTStorage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter) (genericapiserver.APIGroupInfo, error)} NewRESTStorage() 方法很重要,它的主要作用就是构建某个Group的各种版本的各种资源的REST store,将其组装成前面介绍过的 APIGroupInfo 结构体,它传了两个参数: apiResourceConfigSource 保存了某个版本(GroupVersion)或者资源(GroupVersionResource)是否要启用的开关,因为Kubernetes的API是多版本的API,会有多个版本共存,但是并不是所有版本的API都会启用,默认只启用稳定版本的API,还有一些因为历史遗留问题而需要默认开启的beta版本的API,可以通过 --runtime-config 来配置开启哪些版本或者资源,但是需要注意的是,通过该配置项只能控制在 NewRESTStorage() 方法中定义的版本以及资源,具体可见下面的示例。 restOptionGetter就是前文讲过的用来创建 REST Store 以及 etcd store的工厂方法类的实例,其来自于 GenericConfig 中的 RESTOptionsGetter,即上面的 c.GenericConfig.RESTOptionsGetter。 各种资源的RestStorageProvider构建好之后,调用InstallAPIs(),将RESTStorageProvider列表,当做参数传进去,进行安装,先来看下这个安装API的方法: 1234567891011121314151617181920212223242526# kubernetes/pkg/controlplane/instance.gofunc (m *Instance) InstallAPIs(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter, restStorageProviders ...RESTStorageProvider) error { nonLegacy := []*genericapiserver.APIGroupInfo{} ...... for _, restStorageBuilder := range restStorageProviders { groupName := restStorageBuilder.GroupName() apiGroupInfo, err := restStorageBuilder.NewRESTStorage(apiResourceConfigSource, restOptionsGetter) ...... if len(groupName) == 0 { // the legacy group for core APIs is special that it is installed into /api via this special install method. if err := m.GenericAPIServer.InstallLegacyAPIGroup(genericapiserver.DefaultLegacyAPIPrefix, &apiGroupInfo); err != nil { return fmt.Errorf("error in registering legacy API: %w", err) } } else { // everything else goes to /apis nonLegacy = append(nonLegacy, &apiGroupInfo) } } ...... if err := m.GenericAPIServer.InstallAPIGroups(nonLegacy...); err != nil { return fmt.Errorf("error in registering group versions: %v", err) } return nil} 可以看到,通过RESTStorageProvider的NewRESTStorage()构造出 APIGroupInfo,然后分别调用了GenericAPIServer的暴露的InstallLegacyAPIGroup()和InstallAPIGroups()方法进行安装注册。下面先来看下这个 APIGroupInfo 是如何构建出来的,以core group为例: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081# kubernetes/pkg/registry/core/rest/storage_core.gofunc (c *legacyProvider) NewRESTStorage(restOptionsGetter generic.RESTOptionsGetter) (LegacyRESTStorage, genericapiserver.APIGroupInfo, error) { apiGroupInfo, err := c.GenericConfig.NewRESTStorage(apiResourceConfigSource, restOptionsGetter) ...... podStorage, err := podstore.NewStorage( restOptionsGetter, nodeStorage.KubeletConnectionInfo, c.ProxyTransport, podDisruptionClient, ) serviceRESTStorage, serviceStatusStorage, serviceRESTProxy, err := servicestore.NewREST( restOptionsGetter, c.primaryServiceClusterIPAllocator.IPFamily(), c.serviceClusterIPAllocators, c.serviceNodePortAllocator, endpointsStorage, podStorage.Pod, c.Proxy.Transport) ...... storage := apiGroupInfo.VersionedResourcesStorageMap["v1"] if resource := "pods"; apiResourceConfigSource.ResourceEnabled(corev1.SchemeGroupVersion.WithResource(resource)) { storage[resource] = podStorage.Pod storage[resource+"/attach"] = podStorage.Attach storage[resource+"/status"] = podStorage.Status storage[resource+"/log"] = podStorage.Log storage[resource+"/exec"] = podStorage.Exec storage[resource+"/portforward"] = podStorage.PortForward storage[resource+"/proxy"] = podStorage.Proxy storage[resource+"/binding"] = podStorage.Binding if podStorage.Eviction != nil { storage[resource+"/eviction"] = podStorage.Eviction } storage[resource+"/ephemeralcontainers"] = podStorage.EphemeralContainers } ...... if resource := "services"; apiResourceConfigSource.ResourceEnabled(corev1.SchemeGroupVersion.WithResource(resource)) { storage[resource] = serviceRESTStorage storage[resource+"/proxy"] = serviceRESTProxy storage[resource+"/status"] = serviceStatusStorage } ...... apiGroupInfo.VersionedResourcesStorageMap["v1"] = storage return apiGroupInfo, nil}# kubernetes/pkg/registry/core/rest/storage_core_generic.gofunc (c *GenericConfig) NewRESTStorage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter) (genericapiserver.APIGroupInfo, error) { apiGroupInfo := genericapiserver.APIGroupInfo{ PrioritizedVersions: legacyscheme.Scheme.PrioritizedVersionsForGroup(""), VersionedResourcesStorageMap: map[string]map[string]rest.Storage{}, Scheme: legacyscheme.Scheme, ParameterCodec: legacyscheme.ParameterCodec, NegotiatedSerializer: legacyscheme.Codecs, } secretStorage, err := secretstore.NewREST(restOptionsGetter) serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, nil, nil, 0, nil, nil, false) ...... if resource := "secrets"; apiResourceConfigSource.ResourceEnabled(corev1.SchemeGroupVersion.WithResource(resource)) { storage[resource] = secretStorage } if resource := "serviceaccounts"; apiResourceConfigSource.ResourceEnabled(corev1.SchemeGroupVersion.WithResource(resource)) { storage[resource] = serviceAccountStorage if serviceAccountStorage.Token != nil { storage[resource+"/token"] = serviceAccountStorage.Token } } ...... if len(storage) > 0 { apiGroupInfo.VersionedResourcesStorageMap["v1"] = storage } return apiGroupInfo, nil} 可以看到在这里面首先通过 c.GenericConfig.NewRESTStorage() 方法返回了一个APIGroupInfo,在该方法中,主要是创建在Core Group中通用的资源的REST store,比如 serviceaccounts, secrets等等,然后通过podStore.NewStorage()构造了pod及其subresource的REST store,此外还有 service, nodes等其他资源的REST store,需要注意的是,上例中 podStorage 并不是一个REST store,它只是一个包含了很多REST store的变量而已,它里面的podStorage.Pod, podStorage.Attach才是 REST store,其他资源跟此类似,然后将他们注册到到一个storage map里面,在注册时,还判断了是否要启用这个资源,最终将这个map存储到VersionedResourcesStorageMap对应的版本中。所以storage中存储的是这个Group中v1版本对应的所有的API对象资源的REST store,包括pods, services, nodes等等。 再来看一个named group中的API对象,以apps组中的对象为例: 1234567891011121314151617181920212223242526272829303132333435363738394041424344# kubernetes/pkg/registry/apps/rest/storage_apps.gofunc (p StorageProvider) NewRESTStorage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter) (genericapiserver.APIGroupInfo, bool, error) { apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(apps.GroupName, legacyscheme.Scheme, legacyscheme.ParameterCodec, legacyscheme.Codecs) // If you add a version here, be sure to add an entry in `k8s.io/kubernetes/cmd/kube-apiserver/app/aggregator.go with specific priorities. // TODO refactor the plumbing to provide the information in the APIGroupInfo if storageMap, err := p.v1Storage(apiResourceConfigSource, restOptionsGetter); err != nil { return genericapiserver.APIGroupInfo{}, err } else if len(storageMap) > 0 { apiGroupInfo.VersionedResourcesStorageMap[appsapiv1.SchemeGroupVersion.Version] = storageMap } return apiGroupInfo, nil}func (p StorageProvider) v1Storage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter) (map[string]rest.Storage, error) { storage := map[string]rest.Storage{} // deployments if resource := "deployments"; apiResourceConfigSource.ResourceEnabled(appsapiv1.SchemeGroupVersion.WithResource(resource)) { deploymentStorage, err := deploymentstore.NewStorage(restOptionsGetter) if err != nil { return storage, err } storage[resource] = deploymentStorage.Deployment storage[resource+"/status"] = deploymentStorage.Status storage[resource+"/scale"] = deploymentStorage.Scale } // statefulsets if resource := "statefulsets"; apiResourceConfigSource.ResourceEnabled(appsapiv1.SchemeGroupVersion.WithResource(resource)) { statefulSetStorage, err := statefulsetstore.NewStorage(restOptionsGetter) if err != nil { return storage, err } storage[resource] = statefulSetStorage.StatefulSet storage[resource+"/status"] = statefulSetStorage.Status storage[resource+"/scale"] = statefulSetStorage.Scale } ...... return storage, nil} 这里就可以看到,apps这个组只有一个v1版本可以用,因为它只创建了v1版本的REST store并且注册到storage map中。各种资源的NewStorage()方法的细节,这里就不介绍了,主要是构建对应API对象资源的REST store,跟Core Group类似,也在Kubernetes APIServer Storage 框架解析中介绍REST store的上层应用时有介绍过。 Aggregator在Aggregator的New()方法中,也有类似上面的逻辑: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849# kube-aggregator/pkg/apiserver/apiserver.gofunc (c completedConfig) NewWithDelegate(delegationTarget genericapiserver.DelegationTarget) (*APIAggregator, error) { ...... genericServer, err := c.GenericConfig.New("kube-aggregator", delegationTarget) ...... s := &APIAggregator{ GenericAPIServer: genericServer, delegateHandler: delegationTarget.UnprotectedHandler(), proxyClientCert: c.ExtraConfig.ProxyClientCert, proxyClientKey: c.ExtraConfig.ProxyClientKey, proxyTransport: c.ExtraConfig.ProxyTransport, proxyHandlers: map[string]*proxyHandler{}, handledGroups: sets.String{}, lister: informerFactory.Apiregistration().V1().APIServices().Lister(), APIRegistrationInformers: informerFactory, serviceResolver: c.ExtraConfig.ServiceResolver, openAPIConfig: openAPIConfig, egressSelector: c.GenericConfig.EgressSelector, } ...... apiGroupInfo := apiservicerest.NewRESTStorage(c.GenericConfig.MergedResourceConfig, c.GenericConfig.RESTOptionsGetter) if err := s.GenericAPIServer.InstallAPIGroup(&apiGroupInfo); err != nil { return nil, err } ......}# kube-aggregator/pkg/registry/apiservice/rest/storage_apiservice.gofunc NewRESTStorage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter, shouldServeBeta bool) genericapiserver.APIGroupInfo { apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(apiregistration.GroupName, aggregatorscheme.Scheme, metav1.ParameterCodec, aggregatorscheme.Codecs) storage := map[string]rest.Storage{} if resource := "apiservices"; apiResourceConfigSource.ResourceEnabled(v1.SchemeGroupVersion.WithResource(resource)) { apiServiceREST := apiservicestorage.NewREST(aggregatorscheme.Scheme, restOptionsGetter) storage[resource] = apiServiceREST storage[resource+"/status"] = apiservicestorage.NewStatusREST(aggregatorscheme.Scheme, apiServiceREST) } if len(storage) > 0 { apiGroupInfo.VersionedResourcesStorageMap["v1"] = storage } return apiGroupInfo} Aggregator中,就只有apiservices这一个API对象资源,并且也只有v1这一个版本可以用。 APIExtensions再来看看APIExtensions的New()方法,也是类似的: 12345678910111213141516171819202122232425262728# kube-aggregator/pkg/apiserver/apiserver.gofunc (c completedConfig) New(delegationTarget genericapiserver.DelegationTarget) (*CustomResourceDefinitions, error) { genericServer, err := c.GenericConfig.New("apiextensions-apiserver", delegationTarget) s := &CustomResourceDefinitions{ GenericAPIServer: genericServer, } apiResourceConfig := c.GenericConfig.MergedResourceConfig apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(apiextensions.GroupName, Scheme, metav1.ParameterCodec, Codecs) storage := map[string]rest.Storage{} // customresourcedefinitions if resource := "customresourcedefinitions"; apiResourceConfig.ResourceEnabled(v1.SchemeGroupVersion.WithResource(resource)) { customResourceDefinitionStorage, err := customresourcedefinition.NewREST(Scheme, c.GenericConfig.RESTOptionsGetter) if err != nil { return nil, err } storage[resource] = customResourceDefinitionStorage storage[resource+"/status"] = customresourcedefinition.NewStatusREST(Scheme, customResourceDefinitionStorage) } if len(storage) > 0 { apiGroupInfo.VersionedResourcesStorageMap[v1.SchemeGroupVersion.Version] = storage } if err := s.GenericAPIServer.InstallAPIGroup(&apiGroupInfo); err != nil { return nil, err }} APIExtensions中也只定义了customresourcedefinitions这一个资源,并且也只有v1这一个版本。 总结以上,分别介绍了KubeAPIServer, Aggregator和APIExtensions中各自的APIGroupInfo是如何构建的,如何调用到GenericAPIServer中的安装方法进行安装的,可以看到,不同版本的API对象,其实是分别构建了其REST store,即在数据库中独立存储的。下面来盘点下按照上述方式,看Kubernetes API中,都内置了哪些对象,当前Kubernetes最新的版本为1.19.0: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158apiextensions-apiserver* resources * /apis/apiextensions.k8s.io/ * customresourcedefinations -> GoRestfulContainer * customresourcedefinations/status -> GoRestfulContainer * /apis -> NonGoRestfulMux -> crdHandler // Handle() * /apis/ -> NonGoRestfulMux -> crdHandler // HandlePrefix(),CRD定义的自定义资源的CRUD操作都在这个Handler中操作apiserver* resources * /api/v1 -> GoRestfulContainer * pods * pods/attach * pods/status * pods/log * pods/exec * pods/portforward * pods/proxy * pods/binding * pods/eviction * pods/ephemeralcontainers * bindings * podTemplates * replicationControllers * replicationControllers/status * replicationControllers/scale * services * services/proxy * services/status * endpoints * nodes * nodes/status * nodes/proxy * events * limitRanges * resourceQuotas * resourceQuotas/status * namespaces * namespaces/status * namespaces/finalize * secrets * serviceAccounts * serviceAccounts/token * persistentVolumes * persistentVolumes/status * persistentVolumeClaims * persistentVolumeClaims/status * configMaps * componentStatuses * /apis -> GoRestfulContainer * authentication.k8s.io * tokenreviews * authorization.k8s.io * subjectaccessreviews * selfsubjectaccessreviews * localsubjectaccessreviews * selfsubjectrulesreviews * autoscaling * horizontalpodautoscalers * horizontalpodautoscalers/status * batch * v1 * jobs * jobs/status * v1beta1 * cronjobs * cronjobs/status * v2alpha1 * cronjobs * cronjobs/status * certificates.k8s.io * certificatesigningrequests * certificatesigningrequests/status * certificatesigningrequests/approval * coordination.k8s.io * leases * discovery.k8s.io * endpointslices * extensions * v1beta1 * ingresses * ingresses/status * networking.k8s.io * v1 * networkpolicies * v1beta1 * ingresses * ingresses/status * ingressclasses * node.k8s.io * v1alpha1 * runtimeclasses * v1beta1 * runtimeclasses * policy * v1beta1 * poddisruptionbudgets * poddisruptionbudgets/status * podsecuritypolicies * rbac.authorization.k8s.io * roles * rolebindings * clusterroles * clusterrolebindings * scheduling.k8s.io * priorityclasses * settings.k8s.io * podpresets * storage.k8s.io * v1alpha1 * volumeattachments * v1beta1 * storageclasses * volumeattachments * csinodes * csidrivers * v1 * storageclasses * volumeattachments * volumeattachments/status * csinodes * csidrivers * flowcontrol.apiserver.k8s.io * flowschemas * flowschemas/status * prioritylevelconfigurations * prioritylevelconfigurations/status * apps * deployments * deployments/status * deployments/scale * statefulsets * statefulsets/status * statefulsets/scale * daemonsets * daemonsets/status * replicasets * replicasets/status * replicasets/scale * controllerrevisions * admissionregistration.k8s.io * validatingwebhookconfigurations * mutatingwebhookconfigurations * events.k8s.io * eventsaggregator* resources * /apis -> GoRestfulContainer * apiregistration.k8s.io * apiservices * apiservices/status * /apis -> apisHandler -> NonGoRestfulMux * /apis/ -> apisHandler -> NonGoRestfulMux * "/apis/" + apiService.Spec.Group + "/" + apiService.Spec.Version -> proxyHandler -> NonGoRestfulMux * 在该proxyHandler中,最终将请求proxy给extension-apiserver * 在apiservice-registration-controller poststarthook中通过AddAPIService在添加APIService时,注册进proxyHandler中 * "/apis/" + apiService.Spec.Group -> groupDiscoveryHandler -> NonGoRestfulMux 可以看到当前版本的Kubernetes API已经非常丰富了,Group就有19个之多,以后内置的API对象肯定还会不断添加,再结合CRD和Aggregator进行扩展,这云原生的头把交椅真不是盖的。","link":"/2020/10/06/kubernetes/kube-apiserver-api-resource-installation.html"},{"title":"Kubernetes APIServer GenericAPIServer","text":"在Kubernetes APIServer 机制概述中我们介绍到了APIServer的本质其实是一个实现了RESTful API的WebServer,它使用golang的net/http的Server构建,并且Handler是其中非常重要的概念,此外,又简单介绍了APIServer的扩展机制,即Aggregator, APIExtensions以及KubeAPIServer这三者之间通过Delegation的方式实现了扩展。 在Kubernetes APIServer Storage 框架解析中,我们介绍了APIServer相关的存储框架,每个API对象,都有对应的REST store以及etcd store。 而本篇文章介绍到的GenericAPIServer跟上面两个内容紧密相关,它是APIServer的基础,Aggregator, APIExtensions以及KubeAPIServer每个都包含一个GenericAPIServer,他们各自的API对象都以Group的形式,注册进GenericAPIServer中,并且组织成最终的Handler,然后结合net/http Server,将APIServer运行起来,因此,掌握GenericAPIServer有助于理解APIServer的扩展机制以及运行原理,本篇文章重点介绍GenericAPIServer的以下四方面内容: Handler的构建 API对象的注册 Handler的处理 PostStartHook 其相关的代码在apiserver库中的apiserver/pkg/server/目录下。 基础知识APIGroupInfo在API对象进行注册时,都被组织成APIGroupInfo的结构体形式,这里面包含了所有注册需要的信息,其定义如下: 123456789101112131415161718# apiserver/pkg/server/genericapiserver.gotype APIGroupInfo struct { PrioritizedVersions []schema.GroupVersion // Info about the resources in this group. It's a map from version to resource to the storage. VersionedResourcesStorageMap map[string]map[string]rest.Storage OptionsExternalVersion *schema.GroupVersion MetaGroupVersion *schema.GroupVersion Scheme *runtime.Scheme NegotiatedSerializer runtime.NegotiatedSerializer ParameterCodec runtime.ParameterCodec StaticOpenAPISpec *spec.Swagger} 这里面最关键的信息就是VersionedResourcesStorageMap这个属性,如注释所说,它是一个从version映射到resource,然后再从resource映射到rest storage的两层映射,比如batch组中的cronjobs资源,有v1beta1和v2alpha1两个版本,则在该map中,则分别有两个映射,v1beta1 -> cronjobs -> cronjobs rest storage,以及v2alpha1 -> cronjobs -> cronjobs rest storage,这里说的rest storage就是之前介绍到的rest store,每一个版本的API对象,都会有自己的一个rest store,从这里就可以看到,Kubernetes对多版本是如何管理的,本质上,它把不同版本的API对象,其实当成不同的对象,有自己独立的存储。 go-restfulKubernetes使用go-restful这个第三方库对核心的RESTful API进行了实现,其基础知识以及在Kubernetes中的用法,可阅读这篇文章:go-restful简析,这里不再介绍。 NonGoRestfulMuxNonGoRestfulMux是Kubernetes APIServer中非核心API使用的RESTful框架,因为没有使用go-restful实现,因此称之为NonGoRestfulMux,其详细介绍,见Kubernetes APIServer NonGoRestfulMux。 Handler的构建这里说的Handler指的是最终net/http Server要运行的Handler,它在GenericAPIServer中被构建出来,首先我们来看下GenericAPIServer的结构体: 123456789101112131415# apiserver/pkg/server/genericapiserver.gotype GenericAPIServer struct { // SecureServingInfo holds configuration of the TLS server. SecureServingInfo *SecureServingInfo // "Outputs" // Handler holds the handlers being used by this API server Handler *APIServerHandler // delegationTarget is the next delegate in the chain. This is never nil. delegationTarget DelegationTarget ......} 一个GenericAPIServer包含的信息非常的多,上面结构体并没有列出全部属性,在这里我们只关注几个重点信息就行: SecureServingInfo *SecureServingInfo: 这里面包含的是运行APIServer需要的TLS相关的信息 Handler *APIServerHandler: 这个就是要运行APIServer需要使用到的Handler,各个API对象向APIServer中注册,说的就是向Handler注册,它是最重要的信息 delegationTarget DelegationTarget: 这个是扩展机制中用到的,指定该GenericAPIServer的delegation是谁 再来看下Handler *APIServerHandler的结构体信息: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758# apiserver/pkg/server/handler.gotype APIServerHandler struct { // FullHandlerChain is the one that is eventually served with. It should include the full filter // chain and then call the Director. FullHandlerChain http.Handler // The registered APIs. InstallAPIs uses this. Other servers probably shouldn't access this directly. GoRestfulContainer *restful.Container // NonGoRestfulMux is the final HTTP handler in the chain. // It comes after all filters and the API handling // This is where other servers can attach handler to various parts of the chain. NonGoRestfulMux *mux.PathRecorderMux // Director is here so that we can properly handle fall through and proxy cases. // This looks a bit bonkers, but here's what's happening. We need to have /apis handling registered in gorestful in order to have // swagger generated for compatibility. Doing that with `/apis` as a webservice, means that it forcibly 404s (no defaulting allowed) // all requests which are not /apis or /apis/. We need those calls to fall through behind goresful for proper delegation. Trying to // register for a pattern which includes everything behind it doesn't work because gorestful negotiates for verbs and content encoding // and all those things go crazy when gorestful really just needs to pass through. In addition, openapi enforces unique verb constraints // which we don't fit into and it still muddies up swagger. Trying to switch the webservices into a route doesn't work because the // containing webservice faces all the same problems listed above. // This leads to the crazy thing done here. Our mux does what we need, so we'll place it in front of gorestful. It will introspect to // decide if the route is likely to be handled by goresful and route there if needed. Otherwise, it goes to PostGoRestful mux in // order to handle "normal" paths and delegation. Hopefully no API consumers will ever have to deal with this level of detail. I think // we should consider completely removing gorestful. // Other servers should only use this opaquely to delegate to an API server. Director http.Handler}func NewAPIServerHandler(name string, s runtime.NegotiatedSerializer, handlerChainBuilder HandlerChainBuilderFn, notFoundHandler http.Handler) *APIServerHandler { nonGoRestfulMux := mux.NewPathRecorderMux(name) if notFoundHandler != nil { nonGoRestfulMux.NotFoundHandler(notFoundHandler) } gorestfulContainer := restful.NewContainer() gorestfulContainer.ServeMux = http.NewServeMux() gorestfulContainer.Router(restful.CurlyRouter{}) // e.g. for proxy/{kind}/{name}/{*} gorestfulContainer.RecoverHandler(func(panicReason interface{}, httpWriter http.ResponseWriter) { logStackOnRecover(s, panicReason, httpWriter) }) gorestfulContainer.ServiceErrorHandler(func(serviceErr restful.ServiceError, request *restful.Request, response *restful.Response) { serviceErrorHandler(s, serviceErr, request, response) }) director := director{ name: name, goRestfulContainer: gorestfulContainer, nonGoRestfulMux: nonGoRestfulMux, } return &APIServerHandler{ FullHandlerChain: handlerChainBuilder(director), GoRestfulContainer: gorestfulContainer, NonGoRestfulMux: nonGoRestfulMux, Director: director, }} 可以看到,APIServerHandler中包含一个go-restful构建出来的Container,GoRestfulContainer,以及一个PathRecorderMux构建出来的NonGoRestfulMux,注意,他们都是指针类型的,此外还有一个FullHandlerChain以及Director,都是对一个director结构体的引用,来看看这个结构体: 1234567891011# apiserver/pkg/server/handler.gotype director struct { name string goRestfulContainer *restful.Container nonGoRestfulMux *mux.PathRecorderMux}func (d director) ServeHTTP(w http.ResponseWriter, req *http.Request) { ......} 它里面又包含了goRestfulContainer和nonGoRestfulMux,但是注意他们也是以指针的形式作为成员变量的,并且该director还实现了ServeHTTP()方法,即director还是一个Handler。上面的APIServerHandler中也包含了GoRestfulContainer, NonGoRestfulMux的指针类型的成员变量,他们指针指向的其实是同一个实体,即在NewAPIServerHandler()方法中New出来的实体。为什么在APIServerHandler中已经有这两个变量了,还要再单独生成一个director结构体来引用这两个变量,其实这跟他们的用法有关,下面会讲到。 现在先来说下FullHandlerChain和Director的区别,他们两个都是对director的引用,区别是FullHandlerChain在director外面还包围了一层Chain,我们来看看这个Chain是什么: 1234567891011121314151617181920212223242526272829303132# apiserver/pkg/server/config.gohandlerChainBuilder := func(handler http.Handler) http.Handler { return c.BuildHandlerChainFunc(handler, c.Config)}apiServerHandler := NewAPIServerHandler(name, c.Serializer, handlerChainBuilder, delegationTarget.UnprotectedHandler()func DefaultBuildHandlerChain(apiHandler http.Handler, c *Config) http.Handler { handler := genericapifilters.WithAuthorization(apiHandler, c.Authorization.Authorizer, c.Serializer) if c.FlowControl != nil { handler = genericfilters.WithPriorityAndFairness(handler, c.LongRunningFunc, c.FlowControl) } else { handler = genericfilters.WithMaxInFlightLimit(handler, c.MaxRequestsInFlight, c.MaxMutatingRequestsInFlight, c.LongRunningFunc) } handler = genericapifilters.WithImpersonation(handler, c.Authorization.Authorizer, c.Serializer) handler = genericapifilters.WithAudit(handler, c.AuditBackend, c.AuditPolicyChecker, c.LongRunningFunc) failedHandler := genericapifilters.Unauthorized(c.Serializer) failedHandler = genericapifilters.WithFailedAuthenticationAudit(failedHandler, c.AuditBackend, c.AuditPolicyChecker) handler = genericapifilters.WithAuthentication(handler, c.Authentication.Authenticator, failedHandler, c.Authentication.APIAudiences) handler = genericfilters.WithCORS(handler, c.CorsAllowedOriginList, nil, nil, nil, "true") handler = genericfilters.WithTimeoutForNonLongRunningRequests(handler, c.LongRunningFunc, c.RequestTimeout) handler = genericfilters.WithWaitGroup(handler, c.LongRunningFunc, c.HandlerChainWaitGroup) handler = genericapifilters.WithRequestInfo(handler, c.RequestInfoResolver) if c.SecureServing != nil && !c.SecureServing.DisableHTTP2 && c.GoawayChance > 0 { handler = genericfilters.WithProbabilisticGoaway(handler, c.GoawayChance) } handler = genericapifilters.WithAuditAnnotations(handler, c.AuditBackend, c.AuditPolicyChecker) handler = genericapifilters.WithCacheControl(handler) handler = genericfilters.WithPanicRecovery(handler) return handler} 上面的BuildHandlerChainFunc默认为DefaultBuildHandlerChain(),看到该方法中传入一个Handler,然后在该Handler外面,像包洋葱一样,包了一层又一层的filter,这些filter的作用其实就是在请求到来时,在Handler真正处理之前,先要经过的一系列认证,授权,审计等等检查,如果通过了,才会由最终的Handler来处理该请求,没通过,则会报相应的错误,可见,认证授权等操作,就是在这个阶段生效的,经过一系列filter的包装,最终构建出来的Handler,就是FullHandlerChain,而director就是这个被层层包装的Handler。而Director这个成员变量,没有被filter包装,这样通过Director就可以绕过认证授权这些filter,直接由Handler进行处理。那么问题来了,难道还有请求不需要认证授权的?这个Director存在的意义是什么?的确是有请求不需要认证授权,这就涉及到APIServer的扩展机制了,后面会介绍到。 小结一下,APIServerHandler中包含4个成员变量,FullHandlerChain和Director其实是两个Handler,一个是带认证授权这些filter的,一个是不带的,都是对director的引用,而GoRestfulContainer和NonGoRestfulMux则分别是指针类型的引用,指向真正的goRestfulContainer和nonGoRestfulMux实体,同时这两个实体,又被director所引用。 从这里就大概可以看出GoRestfulContainer和NonGoRestfulMux这两个变量在这里的作用了,在上层向goRestfulContainer和nonGoRestfulMux实体中注册API对象时,就是通过调用这两个变量来对真正的实体进行操作的,如下面的示例: 1apiGroupVersion.InstallREST(s.Handler.GoRestfulContainer) 因为是指针,都指向同一个实体,这样director作为Handler也就能用到注册进来的API对象了。 API对象的注册所谓API对象的注册,其实就是向GenericAPIServer的Handler中添加各个API对象的WebService和Route,GenericAPIServer提供了InstallLegacyAPIGroup(), InstallAPIGroups(), InstallAPIGroup()这三个方法,供外部调用,向其中注册APIGroupInfo,APIGroupInfo在上面基础知识中介绍过,里面存储了这个APIGroup的version, resource以及对应的REST storage实体,上面的三个方法,最后都会调用到同一个内部函数: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374# apiserver/pkg/server/genericapiserver.gofunc (s *GenericAPIServer) installAPIResources(apiPrefix string, apiGroupInfo *APIGroupInfo, openAPIModels openapiproto.Models) error { for _, groupVersion := range apiGroupInfo.PrioritizedVersions { if len(apiGroupInfo.VersionedResourcesStorageMap[groupVersion.Version]) == 0 { klog.Warningf("Skipping API %v because it has no resources.", groupVersion) continue } apiGroupVersion := s.getAPIGroupVersion(apiGroupInfo, groupVersion, apiPrefix) if apiGroupInfo.OptionsExternalVersion != nil { apiGroupVersion.OptionsExternalVersion = apiGroupInfo.OptionsExternalVersion } apiGroupVersion.OpenAPIModels = openAPIModels apiGroupVersion.MaxRequestBodyBytes = s.maxRequestBodyBytes if err := apiGroupVersion.InstallREST(s.Handler.GoRestfulContainer); err != nil { return fmt.Errorf("unable to setup API %v: %v", apiGroupInfo, err) } } return nil}func (s *GenericAPIServer) getAPIGroupVersion(apiGroupInfo *APIGroupInfo, groupVersion schema.GroupVersion, apiPrefix string) (*genericapi.APIGroupVersion, error) { storage := make(map[string]rest.Storage) for k, v := range apiGroupInfo.VersionedResourcesStorageMap[groupVersion.Version] { if strings.ToLower(k) != k { return nil, fmt.Errorf("resource names must be lowercase only, not %q", k) } storage[k] = v } version := s.newAPIGroupVersion(apiGroupInfo, groupVersion) version.Root = apiPrefix version.Storage = storage return version, nil}func (s *GenericAPIServer) newAPIGroupVersion(apiGroupInfo *APIGroupInfo, groupVersion schema.GroupVersion) *genericapi.APIGroupVersion { allServedVersionsByResource := map[string][]string{} for version, resourcesInVersion := range apiGroupInfo.VersionedResourcesStorageMap { for resource := range resourcesInVersion { if len(groupVersion.Group) == 0 { allServedVersionsByResource[resource] = append(allServedVersionsByResource[resource], version) } else { allServedVersionsByResource[resource] = append(allServedVersionsByResource[resource], fmt.Sprintf("%s/%s", groupVersion.Group, version)) } } } return &genericapi.APIGroupVersion{ GroupVersion: groupVersion, AllServedVersionsByResource: allServedVersionsByResource, MetaGroupVersion: apiGroupInfo.MetaGroupVersion, ParameterCodec: apiGroupInfo.ParameterCodec, Serializer: apiGroupInfo.NegotiatedSerializer, Creater: apiGroupInfo.Scheme, Convertor: apiGroupInfo.Scheme, ConvertabilityChecker: apiGroupInfo.Scheme, UnsafeConvertor: runtime.UnsafeObjectConvertor(apiGroupInfo.Scheme), Defaulter: apiGroupInfo.Scheme, Typer: apiGroupInfo.Scheme, Namer: runtime.Namer(meta.NewAccessor()), EquivalentResourceRegistry: s.EquivalentResourceRegistry, Admit: s.admissionControl, MinRequestTimeout: s.minRequestTimeout, Authorizer: s.Authorizer, }} 这个函数的逻辑,就是遍历APIGroupInfo中的version,按照version的维度来进行安装API对象,即构建出来一个APIGroupVersion,将该版本的resource和REST storage存储到该结构体中,然后执行安装操作: apiGroupVersion.InstallREST(s.Handler.GoRestfulContainer);,可以看到这里传的就是上面说到的APIServerHandler中的GoRestfulContainer。 12345678910111213141516# apiserver/pkg/endpoints/groupversion.gofunc (g *APIGroupVersion) InstallREST(container *restful.Container) error { prefix := path.Join(g.Root, g.GroupVersion.Group, g.GroupVersion.Version) installer := &APIInstaller{ group: g, prefix: prefix, minRequestTimeout: g.MinRequestTimeout, } apiResources, ws, registrationErrors := installer.Install() versionDiscoveryHandler := discovery.NewAPIVersionHandler(g.Serializer, g.GroupVersion, staticLister{apiResources}) versionDiscoveryHandler.AddToWebService(ws) container.Add(ws) return utilerrors.NewAggregate(registrationErrors)} 在该方法中,又构造了一个APIInstaller结构体,将APIGroupVersion的指针传给它,由它去执行安装操作: 1234567891011121314151617181920212223242526# apiserver/pkg/endpoints/installer.gofunc (a *APIInstaller) Install() ([]metav1.APIResource, *restful.WebService, []error) { var apiResources []metav1.APIResource var errors []error ws := a.newWebService() // Register the paths in a deterministic (sorted) order to get a deterministic swagger spec. paths := make([]string, len(a.group.Storage)) var i int = 0 for path := range a.group.Storage { paths[i] = path i++ } sort.Strings(paths) for _, path := range paths { apiResource, err := a.registerResourceHandlers(path, a.group.Storage[path], ws) if err != nil { errors = append(errors, fmt.Errorf("error in registering resource: %s, %v", path, err)) } if apiResource != nil { apiResources = append(apiResources, *apiResource) } } return apiResources, ws, errors} 在这里面New了一个WebService,然后遍历group中的REST storage,将storage中的path取出来,进行排序,然后再遍历这个path数组,针对每一个path: storage向WebService中执行注册操作,即registerResourceHandlers(),这就来到了最关键的地方,这是一个非常长的函数,这里面,对Storage进行类型转换,因为Storage实现了rest store的各种接口,所以首先将其转换成getter, creater, lister, updater等类型,分别对应该API对象在数据库层面的增删查改等操作,然后构造对应的Handler,然后再创建WebService的Route,最后将其添加到WebService中,我们以getter为例,简单看下这个过程: 12345678910111213141516171819202122232425262728293031323334353637383940# apiserver/pkg/endpoints/installer.go(a *APIInstaller) registerResourceHandlers(path string, storage rest.Storage, ws *restful.WebService) { ...... getter, isGetter := storage.(rest.Getter) ...... handler = restfulGetResource(getter, exporter, reqScope) ...... route := ws.GET(action.Path).To(handler) ...... for _, route := range routes { ws.Route(route) } ......}func restfulGetResource(r rest.Getter, e rest.Exporter, scope handlers.RequestScope) restful.RouteFunction { return func(req *restful.Request, res *restful.Response) { handlers.GetResource(r, e, &scope)(res.ResponseWriter, req.Request) // 直接调用了返回的方法 }}# apiserver/pkg/endpoints/handlers/get.gofunc GetResource(r rest.Getter, e rest.Exporter, scope *RequestScope) http.HandlerFunc { return getResourceHandler(scope, func(ctx context.Context, name string, req *http.Request, trace *utiltrace.Trace) (runtime.Object, error) { ...... return r.Get(ctx, name, &options) })}func getResourceHandler(scope *RequestScope, getter getterFunc) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { namespace, name, err := scope.Namer.Name(req) result, err := getter(ctx, name, req, trace) ...... transformResponseObject(ctx, scope, req, w, http.StatusOK, outputMediaType, result) //对从数据库返回的结果进行转换以及序列化,然后返回 }} 可以看到,在Handler方法中,去调用rest.Getter的Get方法,调用rest storage去数据库中获取对应的name的资源进行返回,然后对从数据库返回的结果进行转换以及序列化,最终返回给用户,需要注意GetResource()返回的是一个方法,而在 restfulGetResource() 方法中,则通过 handlers.GetResource()() 的方式,直接调用了该方法。 在这里,我们就看到了在Kubernetes APIServer Storage 框架解析介绍的REST store是如何应用的。 此外,还需要注意一点就是 apiserver/pkg/endpoints/handlers/ 这个目录下的都是高度抽象化后的各种handler,除了上例中处理Get请求的handler之外,还有Create, Delete, Watch等操作相关的handler,它们接收各种资源的REST store,调用对应的接口,对数据库相应的进行增删查改,然后执行一些序列化操作,返回给客户端,这些handler可以说是对每个API对象操作的入口。 Handler的处理Handler构建出来,并且向其中注册了API对象,最后我们来看下,Handler是如何处理请求的,核心的逻辑,其实在上面已经介绍过,即在director的ServeHTTP()方法中: 12345678910111213141516171819202122232425262728293031323334353637383940# apiserver/pkg/server/handler.gofunc (d director) ServeHTTP(w http.ResponseWriter, req *http.Request) { path := req.URL.Path // check to see if our webservices want to claim this path for _, ws := range d.goRestfulContainer.RegisteredWebServices() { switch { case ws.RootPath() == "/apis": // if we are exactly /apis or /apis/, then we need special handling in loop. // normally these are passed to the nonGoRestfulMux, but if discovery is enabled, it will go directly. // We can't rely on a prefix match since /apis matches everything (see the big comment on Director above) if path == "/apis" || path == "/apis/" { klog.V(5).Infof("%v: %v %q satisfied by gorestful with webservice %v", d.name, req.Method, path, ws.RootPath()) // don't use servemux here because gorestful servemuxes get messed up when removing webservices // TODO fix gorestful, remove TPRs, or stop using gorestful d.goRestfulContainer.Dispatch(w, req) return } case strings.HasPrefix(path, ws.RootPath()): // ensure an exact match or a path boundary match if len(path) == len(ws.RootPath()) || path[len(ws.RootPath())] == '/' { klog.V(5).Infof("%v: %v %q satisfied by gorestful with webservice %v", d.name, req.Method, path, ws.RootPath()) // don't use servemux here because gorestful servemuxes get messed up when removing webservices // TODO fix gorestful, remove TPRs, or stop using gorestful d.goRestfulContainer.Dispatch(w, req) return } } } // if we didn't find a match, then we just skip gorestful altogether klog.V(5).Infof("%v: %v %q satisfied by nonGoRestful", d.name, req.Method, path) d.nonGoRestfulMux.ServeHTTP(w, req)}func (a *APIServerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { a.FullHandlerChain.ServeHTTP(w, r)} 首先是APIServerHandler的ServeHTTP()方法,调用了FullHandlerChain的ServeHTTP()方法,经过了层层的filter,最终到了director的ServeHTTP()方法,在该方法中,首先遍历goRestfulContainer中注册的WebService,看path跟哪个WebService中的路径匹配,如果匹配,则调用goRestfulContainer.Dispatch()处理该请求,如果都没有匹配上,则最终调用nonGoRestfulMux来处理该请求。 PostStartHook在GenericAPIServer中还有一个重要的机制,就是这个PostStartHook,它是在APIServer启动之后,执行的一些Hook函数,这些Hook函数是在APIServer创建的过程中,注册进去的,在APIServer启动之后,做一些初始化或者周期性循环的任务,来看下相关的代码: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950# apiserver/pkg/server/hooks.gofunc (s *GenericAPIServer) AddPostStartHook(name string, hook PostStartHookFunc) error { if len(name) == 0 { return fmt.Errorf("missing name") } if hook == nil { return fmt.Errorf("hook func may not be nil: %q", name) } if s.disabledPostStartHooks.Has(name) { klog.V(1).Infof("skipping %q because it was explicitly disabled", name) return nil } s.postStartHookLock.Lock() defer s.postStartHookLock.Unlock() if s.postStartHooksCalled { return fmt.Errorf("unable to add %q because PostStartHooks have already been called", name) } if postStartHook, exists := s.postStartHooks[name]; exists { // this is programmer error, but it can be hard to debug return fmt.Errorf("unable to add %q because it was already registered by: %s", name, postStartHook.originatingStack) } // done is closed when the poststarthook is finished. This is used by the health check to be able to indicate // that the poststarthook is finished done := make(chan struct{}) if err := s.AddBootSequenceHealthChecks(postStartHookHealthz{name: "poststarthook/" + name, done: done}); err != nil { return err } s.postStartHooks[name] = postStartHookEntry{hook: hook, originatingStack: string(debug.Stack()), done: done} return nil}func (s *GenericAPIServer) RunPostStartHooks(stopCh <-chan struct{}) { s.postStartHookLock.Lock() defer s.postStartHookLock.Unlock() s.postStartHooksCalled = true context := PostStartHookContext{ LoopbackClientConfig: s.LoopbackClientConfig, StopCh: stopCh, } for hookName, hookEntry := range s.postStartHooks { go runPostStartHook(hookName, hookEntry, context) }} 通过AddPostStartHook()方法向GenericAPIServer中添加Hook,然后在APIServer启动时,调用RunPostStartHooks(),遍历postStartHooks列表,使用goroutine运行每一个hook,来看一个添加PostStartHook的例子: 123456789101112131415# kubernetes/cmd/kube-apiserver/app/aggregator.goerr = aggregatorServer.GenericAPIServer.AddPostStartHook("kube-apiserver-autoregistration", func(context genericapiserver.PostStartHookContext) error { go crdRegistrationController.Run(5, context.StopCh) go func() { // let the CRD controller process the initial set of CRDs before starting the autoregistration controller. // this prevents the autoregistration controller's initial sync from deleting APIServices for CRDs that still exist. // we only need to do this if CRDs are enabled on this server. We can't use discovery because we are the source for discovery. if aggregatorConfig.GenericConfig.MergedResourceConfig.AnyVersionForGroupEnabled("apiextensions.k8s.io") { crdRegistrationController.WaitForInitialSync() } autoRegistrationController.Run(5, context.StopCh) }() return nil }) 上面就是一个周期循环的Hook,用来将crd对象,不断轮询,转换成aggregator中的apiservices对象。 总结通过上面的分析,可以看到,本质上,GenericAPIServer最核心的功能,就是对net/http Handler的构造,为了理解其过程,介绍了go-restful, NonGoRestfulMux, APIGroupInfo等基础知识,Handler构建,API对象注册的过程,以及PostStartHook,还涉及到一些以前介绍过的知识,比如REST store,在这里我们看到了其是如何应用的,除了这些核心内容,还有一些将net/http Server如何Run起来的一些内容,逻辑比较简单,前面也介绍过,这里就不再介绍了。","link":"/2020/10/05/kubernetes/kube-apiserver-genericapiserver.html"},{"title":"Kubernetes Informer机制解析","text":"Kubernetes的控制器模式是其非常重要的一个设计模式,整个Kubernetes定义的资源对象以及其状态都保存在etcd数据库中,通过apiserver对其进行增删查改,而各种各样的控制器需要从apiserver及时获取这些对象以及其当前定义的状态,然后将其应用到实际中,即将这些对象的实际状态调整为期望状态,让他们保持匹配。因此各种控制器需要和apiserver进行频繁交互,需要能够及时获取对象状态的变化,而如果简单的通过暴力轮询的话,会给apiserver造成很大的压力,且效率很低,因此,Kubernetes设计了Informer这个机制,用来作为控制器跟apiserver交互的桥梁,它主要有两方面的作用: 依赖Etcd的List&Watch机制,在本地维护了一份所关心的API对象的缓存。Etcd的Watch机制能够使客户端及时获知这些对象的状态变化,然后更新本地缓存,这样就在客户端为这些API对象维护了一份和Etcd数据库中几乎一致的数据,然后控制器等客户端就可以直接访问缓存获取对象的信息,而不用去直接访问apiserver,这一方面显著提高了性能,另一方面则大大降低了对apiserver的访问压力; 依赖Etcd的Watch机制,触发控制器等客户端注册到Informer中的事件方法。客户端可能会某些对象的某些事件感兴趣,当这些事件发生时,希望能够执行某些操作,比如通过apiserver新建了一个pod,那么kube-scheduler中的控制器收到了这个事件,然后将这个pod加入到其队列中,等待进行调度。 Kubernetes的各个组件本身就内置了非常多的控制器,而自定义的控制器也需要通过Informer跟apiserver进行交互,因此,Informer在Kubernetes中应用非常广泛,出镜率很高,本篇文章就重点分析下Informer的机制原理,以加深对其的理解。 使用方法先来看看Informer是怎么用的,以Deployment控制器为例,来看下其使用Informer的相关代码: 1. 创建InformerFactory12345678910111213// kubernetes/cmd/kube-controller-manager/app/controllermanager.gofunc CreateControllerContext(logger klog.Logger, s *config.CompletedConfig, rootClientBuilder, clientBuilder clientbuilder.ControllerClientBuilder, stop <-chan struct{}) (ControllerContext, error) { ...... sharedInformers := informers.NewSharedInformerFactory(versionedClient, ResyncPeriod(s)()) ...... ctx := ControllerContext{ ...... InformerFactory: sharedInformers, ...... } return ctx, nil} NewSharedInformerFactory()最终创建了一个sharedInformerFactory结构体,这个结构主要有两个作用: 1) 用来作为创建Informer的工厂,典型的工厂模式,在Kubernetes中这种设计模式也很常用,下面就是sharedInformerFactory中提供的创建Informer的方法,可见针对某个资源类型的Informer是个单例模式,即如果没有则先创建再返回,如果有,则直接返回,具体创建Informer的逻辑,是通过参数newFunc从外面传进来的方法: 12345678910111213141516171819202122// k8s.io/client-go/informers/factory.gofunc (f *sharedInformerFactory) InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer { f.lock.Lock() defer f.lock.Unlock() informerType := reflect.TypeOf(obj) informer, exists := f.informers[informerType] if exists { return informer } resyncPeriod, exists := f.customResync[informerType] if !exists { resyncPeriod = f.defaultResync } informer = newFunc(f.client, resyncPeriod) f.informers[informerType] = informer return informer} 2) 共享Informer,所谓共享,就是多个Controller可以共用同一个Informer,因为不同的Controller可能对同一种API对象感兴趣,这样相同的API对象,缓存就只有一份,通知机制也只有一套,大大提高了效率,减少了资源浪费。 创建出来的SharedInformerFactory实例放到了ControllerContext中,供后面使用。 2. 创建Informer123456789101112// kubernetes/cmd/kube-controller-manager/app/apps.gofunc startDeploymentController(ctx context.Context, controllerContext ControllerContext) (controller.Interface, bool, error) { dc, err := deployment.NewDeploymentController( ctx, controllerContext.InformerFactory.Apps().V1().Deployments(), controllerContext.InformerFactory.Apps().V1().ReplicaSets(), controllerContext.InformerFactory.Core().V1().Pods(), controllerContext.ClientBuilder.ClientOrDie("deployment-controller"), ) ......} 使用 InformerFactory.Apps().V1().Deployments() 这种方式,最终创建出来的是具体到某个版本的某种资源的Informer,其实是对 InformerFactory 的一个封装,如Deployment资源对应的就是deploymentInformer结构体: 12345678910111213141516// k8s.io/client-go/informers/factory.gofunc (f *sharedInformerFactory) Apps() apps.Interface { return apps.New(f, f.namespace, f.tweakListOptions)}// k8s.io/client-go/informers/apps/interface.gofunc (g *group) V1() v1.Interface { return v1.New(g.factory, g.namespace, g.tweakListOptions)}// k8s.io/client-go/informers/apps/v1/interface.gofunc (v *version) Deployments() DeploymentInformer { return &deploymentInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions}} 该结构体实现了两个方法:Informer()和Lister(): 1) Informer() 通过上面介绍到的sharedInformerFactory.InformerFor(obj, newFunc)方法获取本资源的Informer,如果不存在则调用newFunc方法创建,这里获取到的Informer才是最终的Informer,即cache.SharedIndexInformer,它是我们本篇文章的重点,相关代码如下: 12345678910111213141516171819202122232425262728293031// k8s.io/client-go/informers/apps/v1/deployment.gofunc NewFilteredDeploymentInformer(client kubernetes.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { return cache.NewSharedIndexInformer( &cache.ListWatch{ ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { if tweakListOptions != nil { tweakListOptions(&options) } return client.AppsV1().Deployments(namespace).List(context.TODO(), options) }, WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { if tweakListOptions != nil { tweakListOptions(&options) } return client.AppsV1().Deployments(namespace).Watch(context.TODO(), options) }, }, &appsv1.Deployment{}, resyncPeriod, indexers, )}func (f *deploymentInformer) defaultInformer(client kubernetes.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { return NewFilteredDeploymentInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions)}func (f *deploymentInformer) Informer() cache.SharedIndexInformer { return f.factory.InformerFor(&appsv1.Deployment{}, f.defaultInformer)} 2) Lister() 用来获取创建出来的Informer的缓存接口:Indexer,该接口可以用来查询缓存的数据。 12345// k8s.io/client-go/informers/apps/v1/deployment.gofunc (f *deploymentInformer) Lister() v1.DeploymentLister { return v1.NewDeploymentLister(f.Informer().GetIndexer())} Deployment Controller关心的API对象为Deployment, ReplicaSet, Pod,分别为这三种API对象创建了Informer。 3. 注册事件方法1234567891011121314151617181920212223242526272829303132333435363738394041424344// kubernetes/pkg/controller/deployment/deployment_controller.gofunc NewDeploymentController(ctx context.Context, dInformer appsinformers.DeploymentInformer, rsInformer appsinformers.ReplicaSetInformer, podInformer coreinformers.PodInformer, client clientset.Interface) (*DeploymentController, error) { dc := &DeploymentController{ client: client, eventBroadcaster: eventBroadcaster, eventRecorder: eventBroadcaster.NewRecorder(scheme.Scheme, v1.EventSource{Component: "deployment-controller"}), queue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "deployment"), } ...... dInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { dc.addDeployment(logger, obj) }, UpdateFunc: func(oldObj, newObj interface{}) { dc.updateDeployment(logger, oldObj, newObj) }, // This will enter the sync loop and no-op, because the deployment has been deleted from the store. DeleteFunc: func(obj interface{}) { dc.deleteDeployment(logger, obj) }, }) rsInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { dc.addReplicaSet(logger, obj) }, UpdateFunc: func(oldObj, newObj interface{}) { dc.updateReplicaSet(logger, oldObj, newObj) }, DeleteFunc: func(obj interface{}) { dc.deleteReplicaSet(logger, obj) }, }) podInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ DeleteFunc: func(obj interface{}) { dc.deletePod(logger, obj) }, }) dc.dLister = dInformer.Lister() dc.rsLister = rsInformer.Lister() dc.podLister = podInformer.Lister()} 这里,首先调用Infomer()创建出来SharedIndexInformer,然后向其中注册事件方法,这样当有对应的事件发生时,就会触发这里注册的方法去做相应的事情。其次调用Lister()获取到缓存接口,就可以通过它来查询Informer中缓存的数据了,而且Informer中缓存的数据,是可以有索引的,这样可以加快查询的速度。 4. 启动Informer1234567// kubernetes/cmd/kube-controller-manager/app/controllermanager.gofunc Run(ctx context.Context, c *config.CompletedConfig) error { ...... controllerContext.InformerFactory.Start(controllerContext.Stop) ......} 这里InformerFactory的启动,会遍历Factory中创建的所有Informer,依次将其启动。 机制解析Informer的实现都是在client-go这个库中,通过上述的工厂方法,其实最终创建出来的是一个叫做SharedIndexInformer的结构体: 123456789101112131415161718192021222324252627// k8s.io/client-go/tools/cache/shared_informer.gotype sharedIndexInformer struct { indexer Indexer controller Controller processor *sharedProcessor cacheMutationDetector MutationDetector listerWatcher ListerWatcher ......}func NewSharedIndexInformer(lw ListerWatcher, exampleObject runtime.Object, defaultEventHandlerResyncPeriod time.Duration, indexers Indexers) SharedIndexInformer { realClock := &clock.RealClock{} sharedIndexInformer := &sharedIndexInformer{ processor: &sharedProcessor{clock: realClock}, indexer: NewIndexer(DeletionHandlingMetaNamespaceKeyFunc, indexers), listerWatcher: lw, objectType: exampleObject, resyncCheckPeriod: defaultEventHandlerResyncPeriod, defaultEventHandlerResyncPeriod: defaultEventHandlerResyncPeriod, cacheMutationDetector: NewCacheMutationDetector(fmt.Sprintf("%T", exampleObject)), clock: realClock, } return sharedIndexInformer} 可以看到,在创建SharedIndexInformer时,就创建出了processor, indexer等结构,而在Informer启动时,还创建出了controller, fifo queue, reflector等结构,这些结构之间的关系如下图所示: ReflectorReflector的作用,就是通过List&Watch的方式,从apiserver获取到感兴趣的对象以及其状态,然后将其放到一个称为”Delta”的先进先出队列中。 所谓的Delta FIFO Queue,就是队列中的元素除了对象本身外,还有针对该对象的事件类型: 1234type Delta struct { Type DeltaType Object interface{}} 目前有5种Type: Added, Updated, Deleted, Replaced, Resync,所以,针对同一个对象,可能有多个Delta元素在队列中,表示对该对象做了不同的操作,比如短时间内,多次对某一个对象进行了更新操作,那么就会有多个Updated类型的Delta放入到队列中。后续队列的消费者,可以根据这些Delta的类型,来回调注册到Informer中的事件方法。 而所谓的List&Watch,就是先调用该API对象的List接口,获取到对象列表,将它们添加到队列中,Delta元素类型为Replaced,然后再调用Watch接口,持续监听该API对象的状态变化事件,将这些事件按照不同的事件类型,组成对应的Delta类型,添加到队列中,Delta元素类型有Added, Updated, Deleted三种。 此外,Informer还会周期性的发送Resync类型的Delta元素到队列中,目的是为了周期性的触发注册到Informer中的事件方法UpdateFunc,保证对象的期望状态和实际状态一致,该周期是由一个叫做resyncPeriod的参数决定的,在向Informer中添加EventHandler时,可以指定该参数,若为0的话,则关闭该功能。需要注意的是,Resync类型的Delta元素中的对象,是通过Indexer从缓存中获取到的,而不是直接从apiserver中拿的,即这里resync的,其实是”缓存”的对象的期望状态和实际状态的一致性。 根据以上Reflector的机制,可以澄清一下Kubernetes中关于控制器模式的一个常见误区,即以为控制器是不断轮询api,不停地调用List和Get,获取到对象的期望状态,其实在文章开头就说过了,这样做会给apiserver造成很大的压力,效率很低,所以才设计了Informer,依赖Etcd的Watch机制,通过事件来获知对象变化状态,建立本地缓存。即使在Informer中,也没有周期性的调用对象的List接口,正常情况下,List&Watch只会执行一次,即先执行List把数据拉过来,放入队列中,后续就进入Watch阶段。 那什么时候才会再执行List呢?其实就是异常的时候,在List或者Watch的过程中,如果有异常,比如apiserver重启了,那么Reflector就开始周期性的执行List&Watch,直到再次正常进入Watch阶段。为了在异常时段,不给apiserver造成压力,这个周期是一个称为backoff的可变的时间间隔,默认是一个指数型的间隔,即越往后重试的间隔越长,到一定时间又会重置回一开始的频率。而且,为了让不同的apiserver能够均匀负载这些Watch请求,客户端会主动断开跟apiserver的连接,这个超时时间为60秒,然后重新发起Watch请求。此外,在控制器重启过程中,也会再次执行List,所以会观察到之前已经创建好的API对象,又重新触发了一遍AddFunc方法。 从以上这些点,可以看出来,Kubernetes在性能和稳定性的提升上,还是下了很多功夫的。 Controller这里Controller的作用是通过轮询不断从队列中取出Delta元素,根据元素的类型,一方面通过Indexer更新本地的缓存,一方面调用Processor来触发注册到Informer的事件方法: 1234567// k8s.io/client-go/tools/cache/controller.gofunc (c *controller) processLoop() { for { obj, err := c.config.Queue.Pop(PopProcessFunc(c.config.Process)) }} 这里的c.config.Process是定义在shared_informer.go中的HandleDeltas()方法: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546// k8s.io/client-go/tools/cache/shared_informer.gofunc (s *sharedIndexInformer) HandleDeltas(obj interface{}) error { s.blockDeltas.Lock() defer s.blockDeltas.Unlock() // from oldest to newest for _, d := range obj.(Deltas) { switch d.Type { case Sync, Replaced, Added, Updated: s.cacheMutationDetector.AddObject(d.Object) if old, exists, err := s.indexer.Get(d.Object); err == nil && exists { if err := s.indexer.Update(d.Object); err != nil { return err } isSync := false switch { case d.Type == Sync: // Sync events are only propagated to listeners that requested resync isSync = true case d.Type == Replaced: if accessor, err := meta.Accessor(d.Object); err == nil { if oldAccessor, err := meta.Accessor(old); err == nil { // Replaced events that didn't change resourceVersion are treated as resync events // and only propagated to listeners that requested resync isSync = accessor.GetResourceVersion() == oldAccessor.GetResourceVersion() } } } s.processor.distribute(updateNotification{oldObj: old, newObj: d.Object}, isSync) } else { if err := s.indexer.Add(d.Object); err != nil { return err } s.processor.distribute(addNotification{newObj: d.Object}, false) } case Deleted: if err := s.indexer.Delete(d.Object); err != nil { return err } s.processor.distribute(deleteNotification{oldObj: d.Object}, false) } } return nil} Processer & ListenerProcesser和Listener则是触发事件方法的机制,在创建Informer时,会创建一个Processer,而在向Informer中通过调用AddEventHandler()注册事件方法时,会为每一个Handler生成一个Listener,然后将该Lisener中添加到Processer中,每一个Listener中有两个channel:addCh和nextCh。Listener通过select监听在这两个channel上,当Controller从队列中取出新的元素时,会调用processer来给它的listener发送“通知”,这个“通知”就是向addCh中添加一个元素,即add(),然后一个goroutine就会将这个元素从addCh转移到nextCh,即pop(),从而触发另一个goroutine执行注册的事件方法,即run()。 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374// k8s.io/client-go/tools/cache/shared_informer.gofunc (p *sharedProcessor) distribute(obj interface{}, sync bool) { p.listenersLock.RLock() defer p.listenersLock.RUnlock() if sync { for _, listener := range p.syncingListeners { listener.add(obj) } } else { for _, listener := range p.listeners { listener.add(obj) } }}func (p *processorListener) add(notification interface{}) { p.addCh <- notification}func (p *processorListener) pop() { defer utilruntime.HandleCrash() defer close(p.nextCh) // Tell .run() to stop var nextCh chan<- interface{} var notification interface{} for { select { case nextCh <- notification: // Notification dispatched var ok bool notification, ok = p.pendingNotifications.ReadOne() if !ok { // Nothing to pop nextCh = nil // Disable this select case } case notificationToAdd, ok := <-p.addCh: if !ok { return } if notification == nil { // No notification to pop (and pendingNotifications is empty) // Optimize the case - skip adding to pendingNotifications notification = notificationToAdd nextCh = p.nextCh } else { // There is already a notification waiting to be dispatched p.pendingNotifications.WriteOne(notificationToAdd) } } }}func (p *processorListener) run() { // this call blocks until the channel is closed. When a panic happens during the notification // we will catch it, **the offending item will be skipped!**, and after a short delay (one second) // the next notification will be attempted. This is usually better than the alternative of never // delivering again. stopCh := make(chan struct{}) wait.Until(func() { for next := range p.nextCh { switch notification := next.(type) { case updateNotification: p.handler.OnUpdate(notification.oldObj, notification.newObj) case addNotification: p.handler.OnAdd(notification.newObj) case deleteNotification: p.handler.OnDelete(notification.oldObj) default: utilruntime.HandleError(fmt.Errorf("unrecognized notification: %T", next)) } } // the only way to get here is if the p.nextCh is empty and closed close(stopCh) }, 1*time.Second, stopCh)} IndexerIndexer是对缓存进行增删查改的接口,缓存本质上就是用map构建的key:value键值对,都存在items这个map中,key为<namespace>/<name>: 123456789type threadSafeMap struct { lock sync.RWMutex items map[string]interface{} // indexers maps a name to an IndexFunc indexers Indexers // indices maps a name to an Index indices Indices} 而为了加速查询,还可以选择性的给这些缓存添加索引,索引存储在indecies中,所谓索引,就是在向缓存中添加记录时,就将其key添加到索引结构中,在查找时,可以根据索引条件,快速查找到指定的key记录,比如默认有个索引是按照namespace进行索引,可以根据快速找出属于某个namespace的某种对象,而不用去遍历所有的缓存。 Indexer对外提供了Replace(), Resync(), Add(), Update(), Delete(), List(), Get(), GetByKey(), ByIndex()等接口。 总结本篇对Kubernetes Informer的使用方法和实现原理,进行了深入分析,整体上看,Informer的设计是相当不错的,基于事件机制,一方面构建本地缓存,一方面触发事件方法,使得控制器能够快速响应和快速获取数据,此外,还有诸如共享Informer, resync, index, watch timeout等机制,使得Informer更加高效和稳定,有了Informer,控制器模式可以说是如虎添翼。 最后,其实有一个地方还没有弄明白,就是resync机制是维持的缓存和实际状态的一致性,但是etcd数据库中的对象的状态,和缓存中的对象状态,如果只依靠Watch事件机制的话,能否保证一致性,如果因为某个原因,导致某次事件没有更新到缓存中,那后续这个对象如果没有发生变化的话,就不会有事件再发出来了,而List在正常情况下,又只List一次,这样缓存中的数据就跟数据库中的数据不一致了,就可能会出问题,找了半天没找到针对这种情况的处理,不知道是别有洞天,我没发现,还是这真的是个问题,只是没人遇到过。","link":"/2020/12/11/kubernetes/kube-clientgo-informer.html"},{"title":"Kubernetes Kubelet CSI 机制解析","text":"在CSI之前,Kubernetes本身就内置了很多插件去对接第三方存储,如果内置插件不满足需求,还可以通过FlexVolume机制,去编写自己的存储插件,然后被动态的加载到Kubernetes中,也就是说Kubernetes并不存在像对接容器运行时那种代码侵入性不好维护的问题,那为什么还要再制定一套CSI机制去对接第三方存储呢?其实CSI是一个更通用的存储对接方案,它不仅仅是针对Kubernetes的,而是针对所有的容器编排系统,比如Mesos也支持CSI,而且CSI是通过RPC机制进行交互的,跟语言无关,这样就给存储厂商带来极大的便利,写一次CSI插件,就可以适配到各种容器编排系统,不用为每种容器编排系统单独开发存储插件,这在CSI协议的目标中做了清晰的描述: To define an industry standard “Container Storage Interface” (CSI) that will enable storage vendors (SP) to develop a plugin once and have it work across a number of container orchestration (CO) systems. CSI协议简介CSI协议的具体内容在这里可以查看,它规定了CO(Container Orchestration)和SP(Storage Provider)之间的交互规范: CO作为客户端,SP作为服务端,CO通过gRPC协议向SP发送请求; 协议为SP规定了两种类型的插件,一个是Controller Plugin,一个是Node Plugin; Controller Plugin一般一个集群只部署一个,它的作用主要是处理像创建/删除卷、向某节点挂载/卸载卷这种管理类型的操作,这些操作一般都是由SP向存储后端以及IaaS平台发送API请求完成的,所以一个集群部署一个即可,这些操作称之为Controller Service; Node Plugin则需要在CO集群的每个Worker节点上部署,主要用来处理卷的格式化、挂载、以及向容器中映射目录等跟具体节点和容器相关的操作,因此它要在每个节点上部署,这类操作称为Node Service; 此外这两种插件还都需要实现能够获取到本插件详细信息的操作,比如插件的名字,插件都实现了哪些功能,以方便CO根据插件的信息做出某些判断,这类操作称之为Identity Service; Controller Plugin和Node Plugin在实现上可以分开,有各自的Server,也可以合并在一起,由一个统一的Server去实现; CSI也像CRI和CNI一样,提供了一个lib库,为上面的操作定义好了相关的数据结构,为客户端实现了相关的gRPC方法,为服务端定义好了接口,被CO和SP引用和实现即可,来看下该lib库中,为服务端定义的接口: 1234567891011121314151617181920212223242526272829303132333435// IdentityServer is the server API for Identity service.type IdentityServer interface { GetPluginInfo(context.Context, *GetPluginInfoRequest) (*GetPluginInfoResponse, error) GetPluginCapabilities(context.Context, *GetPluginCapabilitiesRequest) (*GetPluginCapabilitiesResponse, error) Probe(context.Context, *ProbeRequest) (*ProbeResponse, error)}// ControllerServer is the server API for Controller service.type ControllerServer interface { CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) DeleteVolume(context.Context, *DeleteVolumeRequest) (*DeleteVolumeResponse, error) ControllerPublishVolume(context.Context, *ControllerPublishVolumeRequest) (*ControllerPublishVolumeResponse, error) ControllerUnpublishVolume(context.Context, *ControllerUnpublishVolumeRequest) (*ControllerUnpublishVolumeResponse, error) ValidateVolumeCapabilities(context.Context, *ValidateVolumeCapabilitiesRequest) (*ValidateVolumeCapabilitiesResponse, error) ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error) GetCapacity(context.Context, *GetCapacityRequest) (*GetCapacityResponse, error) ControllerGetCapabilities(context.Context, *ControllerGetCapabilitiesRequest) (*ControllerGetCapabilitiesResponse, error) CreateSnapshot(context.Context, *CreateSnapshotRequest) (*CreateSnapshotResponse, error) DeleteSnapshot(context.Context, *DeleteSnapshotRequest) (*DeleteSnapshotResponse, error) ListSnapshots(context.Context, *ListSnapshotsRequest) (*ListSnapshotsResponse, error) ControllerExpandVolume(context.Context, *ControllerExpandVolumeRequest) (*ControllerExpandVolumeResponse, error) ControllerGetVolume(context.Context, *ControllerGetVolumeRequest) (*ControllerGetVolumeResponse, error)}// NodeServer is the server API for Node service.type NodeServer interface { NodeStageVolume(context.Context, *NodeStageVolumeRequest) (*NodeStageVolumeResponse, error) NodeUnstageVolume(context.Context, *NodeUnstageVolumeRequest) (*NodeUnstageVolumeResponse, error) NodePublishVolume(context.Context, *NodePublishVolumeRequest) (*NodePublishVolumeResponse, error) NodeUnpublishVolume(context.Context, *NodeUnpublishVolumeRequest) (*NodeUnpublishVolumeResponse, error) NodeGetVolumeStats(context.Context, *NodeGetVolumeStatsRequest) (*NodeGetVolumeStatsResponse, error) NodeExpandVolume(context.Context, *NodeExpandVolumeRequest) (*NodeExpandVolumeResponse, error) NodeGetCapabilities(context.Context, *NodeGetCapabilitiesRequest) (*NodeGetCapabilitiesResponse, error) NodeGetInfo(context.Context, *NodeGetInfoRequest) (*NodeGetInfoResponse, error)} 这些接口方法中,有几个比较特殊的需要解释一下,先来看下协议中给出的一个Volume的生命周期图: 12345678910111213141516171819202122232425 CreateVolume +------------+ DeleteVolume +------------->| CREATED +--------------+ | +---+----^---+ | | Controller | | Controller v+++ Publish | | Unpublish +++|X| Volume | | Volume | |+-+ +---v----+---+ +-+ | NODE_READY | +---+----^---+ Node | | Node Stage | | Unstage Volume | | Volume +---v----+---+ | VOL_READY | +---+----^---+ Node | | Node Publish | | Unpublish Volume | | Volume +---v----+---+ | PUBLISHED | +------------+Figure 6: The lifecycle of a dynamically provisioned volume, fromcreation to destruction, when the Node Plugin advertises theSTAGE_UNSTAGE_VOLUME capability. 上图中CreateVolume和DeleteVolume都很好理解,下面几个就会比较困惑:ControllerPublishVolume,NodeStageVolume, NodePublishVolume,分别表示什么意思呢?要知道存储多种多样,有块存储、文件存储以及对象存储等等,并且有各种各样的使用方式,iSCSI、FC、RBD等等,要想设计一套针对所有情况的通用的规范,必须得足够抽象才行: ControllerPublishVolume,卷创建好后,一般都需要将其和某个节点绑定,比如IaaS平台中创建的云硬盘,需要挂载到某个虚拟机上,虚拟机中才能够看到这个块设备,这个操作是由Controller Plugin调用IaaS平台的API发起的,所以称之为ControllerPublishVolume,顾名思义就是将卷发布到某个节点。 NodeStageVolume和NodePublishVolume,是将卷发布到该节点后,这个卷最终被该节点上的容器使用,可能需要经历的两个阶段,比如IaaS平台的云硬盘,挂载到虚拟机之后,还需要经过分区,格式化,挂载到目录,并且映射进容器,才能最终被使用起来,而且有的云硬盘可能还会同时挂载到多个容器中,这些操作都是由Node Plugin完成的。NodeStageVolume相当于是一个中间过程,它主要是为NodePublishVolume做准备工作,比如IaaS平台的云硬盘,如果希望以文件系统的方式被容器使用的话,在NodeStageVolume阶段,就会将云硬盘格式化成对应文件系统,并且挂载到一个全局目录上,然后在NodePublishVolume阶段,会直接从该全局目录挂载到最终容器使用的目录上,类似于 mount -o bind dir1 dir2,为什么要设计这样一个全局目录呢?是因为考虑到会有同一个卷被多个容器使用的情况,这样格式化、挂载等操作只需要做一遍,后续多个容器使用这个卷,就只需要bind这个全局目录就行了。当然,不同的存储系统,在NodeStageVolume阶段,可能会做不同的事情,这个要根据实际情况而定。 存储插件并不需要实现所有这些方法,根据实际情况,有的可能不需要NodeStageVolume,直接就可以NodePublishVolume,这样在NodePlugin中,就可以申明不支持 STAGE_UNSTAGE_VOLUME 这个功能,那么CO在跟SP交互时,就不会发送NodeStageVolume这个请求了。 从上面的接口定义上,也可以看出CSI的设计目的主要还是为了给容器提供存储,并且进行简单的管理操作,比如打快照,扩容等,并没有其他复杂的存储管理,比如备份,恢复,要实现更复杂的存储管理,还是要依靠底层存储系统本身的机制,这些功能不同存储系统间差别还是很大的,想要做到统一抽象是不可能的,也没这个必要。 CSI协议的实现CSI协议在Kubernetes中的实现也是一个极其复杂的存在,反而内置存储插件的实现是最简单直接的,因为CSI是针对更一般的情况,要遵循接口规范,并且还要走RPC,所以其实现复杂度就多了一个维度。CSI的实现涉及到apiserver, kube-controller-manager, kubelet, csi driver, 以及csi external controller这几个组件,apiserver用来保存跟Volume相关的API对象,比如PV, PVC, StorageClass, VolumeAttach等,还有提供watch事件机制,kube-controller-manager中有几个Controller用来处理Volume的挂载、卸载、扩展以及绑定等操作,kubelet中有几个Manager用来注册CSIPlugin,以及管理本节点上Volume的mount等操作,csi driver就是真正的存储插件,也就是RPC的服务端,负责跟后端存储打交道,而csi external controller是外置的一些Controller,负责监听apiserver中Volume相关的API对象的事件,向csi driver发送请求,完成相关操作,比如创建、删除、挂载、卸载卷等等,其整体关系图如下: Kubernetes中对CSI的支持,其实是将CSI作为了一个特殊的内置存储插件来实现的,在CSI之前,就已经有一套完整的存储机制,CSI是在现有的存储框架的基础之上进行适配的,其存储框架大致如下: kube-controller-managerkube-controller-manager涉及到卷相关的主要有下面三个Controller: persistent-volume-binder,其作用是处理pv和pvc的互相bind的逻辑,通过watch pv和pvc的增/改/删事件,放入到对应的队列中,然后由worker线程消费,处理分为这么几种情况: 针对pvc,如果未绑定,则去现有的pv中找匹配的,如果没找到,则会去创建对应的pv:调用对应的存储插件去创建实际的volume,然后再创建pv对象,但是这种情况只针对in-tree的VolumePlugin,如果VolumePlugin是csi的话,则依赖于外部的controller,即external-provisioner,去创建实际的volume和pv,如果找到了匹配的pv,则进行pv和pvc的绑定; 针对pv,根据各种条件对pv和pvc进行更新,使之符合预期 attachdetach-controller,执行挂载卸载卷的操作,通过desiredStateOfWorld和actualStateOfWorld这两个数据结构,维护pod和volume的attach关系,然后在reconcile()循环中,根据dsw中记录的信息,调用对应的VolumePlugin去执行Attach或者Detach操作,如果VolumePlugin是csi的话,则在csi plugin的attach或者detach方法中,只是创建或者删除了VolumeAttachmen对象,然后会由external-attacher根据VolumeAttachment去向csi driver发送grpc请求,去执行真正的attach或者detach操作 expand-controller,扩展卷,通过watch pvc中容量的变化,向对应的VolumePlugin发送扩容请求,如果VolumePlugin是csi的话,则该Controller不做什么操作,会由外部的external-resizer controller来处理PVC的扩容操作,它监听PVC的变化,然后向csi driver发送grpc请求,最后让底层平台来做最终的扩容操作。 external-controller从上面可以看出,如果VolumePlugin是csi的话,kube-controller-manager中这几个Controller的功能会大大弱化,基本上不做什么实际性的工作,而是将具体操作交给了外部的Controller,它们的作用如下: external-provisioner,主要负责pv的创建和删除,它会watch pvc的创建、删除事件,然后向csi driver发送 CreateVolume/DeleteVolume请求,csi driver再向外部存储或者底层平台发送请求去创建或者删除卷,成功的话,再通过apiserver创建或者删除对应的pv对象; external-attacher,它的主要作用是用来管理Volume和Node的挂载/卸载操作,会watch VolumeAttachment对象的创建、更新、删除事件,根据VolumeAttachment中记录的Volume和Node的关系,向csi driver发送ControllerPublishVolume/ControllerUnpublishVolume请求,让后端存储或者是底层云平台执行挂载/卸载的操作。需要注意的是VolumeAttachment对象是在kube-controller-manager中的attachdetach-controller中维护的,所以external-attacher目前还是依赖于内部的Controller; external-resizer,它监听PVC的变化,然后向csi driver发送ControllerExpandVolume请求,最后让底层平台来做最终的扩容操作。 external-snapshotter,它是用来处理给卷打快照的逻辑的,监听VolumeSnapshot / VolumeSnapshotContent对象的变化,向csi driver发送CreateSnapshot / DeleteSnapshot / ListSnapshots等请求。 这几个外部的Controller并不在Kubernetes的代码树中,它们维护在单独的组织里,叫kubernetes-csi,这里面有很多CSI相关的项目,并且有一个文档,对Kubernetes CSI做了详细介绍,这些external controller在部署时,一般都以sidecar的形式,跟csi driver部署在同一个pod中,并且需要跟kube-controller-manager部署在同一个节点上,因为他们之间需要通过本地socket进行grpc调用,这个csi driver其实就是上面CSI协议中提到的Controller Plugin。 kubelet由于CSI是作为一个in-tree的存储插件而存在的,在Kubelet中,为了适配CSI并不需要做太大改动,还继续沿用原来的存储框架即可,在Kubernetes Kubelet机制概述中介绍过,Kubelet依赖各种各样的Manager去管理各种资源,跟CSI和Volume相关的主要有3个Manager: volumePluginMgr 管理intree和flexvolume动态发现的VolumePlugin,csi也是作为intree的一个plugin的形式存在的,所谓管理就是自动发现,注册,查找VolumePlugin。 在volumePluginMgr中,提供了各种查找其管理的VolumePlugin的方法,比如FindPluginBySpec,FindPluginByName,FindAttachablePluginBySpec等 它的代码位于kubernetes/pkg/volume/plugins.go,在kube-controller-manager和kubelet中都会通过它来找某个Volume对应的VolumePlugin flexvolume动态发现插件的默认目录:/usr/libexec/kubernetes/kubelet-plugins/volume/exec/,由配置项VolumePluginDir进行配置 pluginManager 主要是来注册本节点的CSIPlugin和DevicePlugin 这里面主要有两个loop: desiredStateOfWorldPopulator 和 reconciler 前者是通过fsnotify watch机制从插件目录发现csi的socket文件,默认路径在/var/lib/kubelet/plugins_registry/,然后将其信息添加到desiredStateOfWorld结构中; 后者会去对比actualStateOfWorld 和 desiredStateOfWorld中记录的插件注册的情况,desiredStateOfWorld是全部期望注册的插件,而actualStateOfWorld则是全部已经注册的插件,如果没注册的,则会调用operationExecutor去注册,如果插件已经被删除,则调用operationExecutor去删除注册; operationExecutor是用来执行注册方法的执行器,本质上就是通过单独的goroutine去执行注册方法,而operationGenerator则是注册方法生成器,在该注册方法中,首先通过该socket建立了一个grpc的客户端,通过向该socket发送grpc请求,即client.GetInfo(),获取到该CSI插件的信息,根据该插件的种类(CSIPlugin或者是DevicePlugin),来调用相应的handler,来进一步进行注册,首先要handler.ValidatePlugin(),然后handler.RegisterPlugin(),handler是在服务启动时,添加到pluginManager中的。 如果是CSIPlugin的话,其handler注册流程大致如下: 首先根据插件的socket文件,初始化一个csi的grpc client,用来跟csi server进行交互,csi rpc client又引用了container-storage-interface项目中定义的csi protobuffer协议的接口 发送csi.NodeGetInfo() rpc请求,获取到本节点的相关信息 //NodeGetInfo()即是CSI规范定义的接口 接下来,通过nim,即nodeInfoManager(这个是在volumePluginMgr在进行插件初始化的时候实例化的),继续进行注册,主要分为两步: 更新本节点的Node对象,添加csi相关的annotation和labels 创建或者更新本节点对应的CSINode对象,里面包含了该node的CSI插件信息,主要是包含插件的名字 volumeManager 是用来管理本node上volume和node的attach/detach操作,以及pod和volume的mount操作的 它里面同样有两个循环: DesiredStateOfWorldPopulator 周期性的从podManager中获取本node的pod列表,然后遍历pod列表,获取到每个pod的Volumes,遍历每个volume,获取到详细的信息,然后添加到desiredStateOfWorld中,desiredStateOfWorld用以下的数据结构记录本节点的所有pod的所有volume信息,包括该volume是否可挂载,可mount,以及所属的pod,而且某个volume可能属于多个pod 12345678910111213desiredStateOfWorld * volumesToMount map[v1.UniqueVolumeName]volumeToMount * volumePluginMgr *volume.VolumePluginMgrvolumeToMount * volumeName v1.UniqueVolumeName * podsToMount map[types.UniquePodName]podToMount * pluginIsAttachable bool * pluginIsDeviceMountable bool * volumeGidValue stringpodToMount * podName types.UniquePodName * pod *v1.Pod * volumeSpec *volume.Spec reconciler会周期性的从desiredStateOfWorld中获取到需要进行Attach或者Mount的Volume,然后调用OperatorExecutor来执行具体的Attach/Mount等操作 OperatorGenerator是从volume对应的VolumePlugin中获取到对应的AttachVolume/MountVolume等具体实现方法,OperatorExecutor会在goroutine中调用OperatorGenerator中的方法去执行具体的动作,其代码路径位于:kubernetes/pkg/volume/util/operationexecutor/ 需要注意的是volumeManager也可以用来管理volume和node的attach/detach操作,类似于kube-controller-manager中的attachdetach-controller的作用,但是它是通过一个开关来控制的,EnableControllerAttachDetach,该配置项默认是True,即由kube-controller-manager中的controller而不是kubelet来管理Volume的Attach和Detach操作,这样默认情况下,kubelet就只需要管pod和volume的mount操作了,也就是CSI协议中定义的NodeStageVolume和NodePublishVolume请求。 csi driver csi driver就是gRPC的服务端,也就是CSI协议中的SP(Storage Provider),如CSI协议中所说,csi driver分为Controller, Node两个角色,Controller角色的csi driver需要实现CSI协议中定义的ControllerServer接口,通常它是跟kube-controller-manager部署在一起的,并且csi external controller以sidecar的形式跟它在同一个Pod中,再通过Deployment来管理这个Pod,而Node角色的csi driver需要实现CSI协议中定义的NodeServer接口,并且通过DaemonSet的方式部署在每个Worker节点,和kubelet部署在一起。在代码实现上,csi driver可以将两种接口全都在一个RPC Server上实现,不用分开,只是不同的角色用不同的接口而已。 下面我们以Provision, Attach, Mount三个场景为例,来看下这几个组件之间是如何互相配合的: Provision创建PV的过程大致如下: 用户创建了PVC对象,external-provisioner收到该事件之后,会向csi driver发送CreateVolume的gRPC请求,然后csi driver调用后端平台的API去创建卷,然后external-provisioner再向apiserver创建对应的PV对象,之后再由persistent-volume-binder去将PVC和PV绑定起来。 AttachAttach的过场大致如下: 用户将PVC被Pod使用的关系定义提交到apiserver,attachdetach-controller收到该事件后,会创建VolumeAttachment对象,记录该关系,之后,external-attacher会收到VolumeAttachment对象创建的事件,会向csi driver发送ControllerPublishVolume的gRPC请求,由后端平台去执行挂载操作,该挂载操作是将后端平台创建的卷挂载到Pod所在的Node节点。 Mount将卷Mount到Pod的过程如下: volumeManager从apiserver处获取到本节点的Pod和Volume的绑定关系,结合本地Container实际跟Volume的绑定关系,会在本地以desiredStateOfWorld和actualStateOfWorld的数据结构记录这些关系,然后根据这些信息,向csi driver发送NodeStageVolume和NodePublishVolume gRPC请求,将没有mount的Volume进行跟Pod中的Container进行mount操作。需要注意的是,Pod和Volume进行mount,其实有两种方式,一种是以文件系统的方式,即FileSystem,被容器使用,这是最常见的使用方式,还有一种是以块设备的方式被容器使用,即Block,这两种方式的实现主要体现在csi driver对NodeStageVolume和NodePublishVolume的实现差别上。 以上只是对这几个组件在CSI协议实现上的一个非常粗的描述,重点是体现出其在整个框架中的作用,其中还有非常多的细节没有体现出来,后续需要的话,可以顺藤摸瓜再深入到细节中。 VolumePlugin机制最后再来看下Kubernetes中的VolumePlugin机制,重点是看它如何抽象的,VolumePlugin是Kubernetes中的存储插件机制,不论是内置的插件,还是FlexVolume动态发现的插件,以及CSI插件,本质上都是VolumePlugin,只不过CSIPlugin是其中一个特殊的内置VolumePlugin,CSI正是在这套框架的基础上实现的CSI的功能。 先来看下VolumePlugin的接口类图: 最核心的是VolumePlugin这个接口,它定义了一些存储插件最基本的行为,比如初始化方法,Init(), 以及创建执行Mount操作的结构体的方法,NewMounter()等,从最基础的VolumePlugin衍生出了很多具备其他功能的VolumePlugin,比如支持映射块设备的BlockVolumePlugin,支持扩容的ExpandableVolumePlugin,支持挂载卸载的AttachableVolumePlugin等等,内置或者外置的存储插件通过实现这些接口中的方法,来声明自己是哪种VolumePlugin。一般一个VolumePlugin会实现好几个这样的接口,比如内置的cinderPlugin除了实现基础的VolumePlugin中的接口之外,还实现了DeletableVolumePlugin, ProvisionableVolumePlugin, ExpandableVolumePlugin, BlockVolumePlugin等这几个接口,即cinderPlugin具备基础的挂载卸载操作,可删除可创建,可扩容,以及可挂载为块设备等功能。csiPlugin作为一个特殊的内置插件,也实现了类似的接口,只不过在其接口的实现中,不是直接调用的后端存储的接口,而是向csi driver发送CSI协议中规定的gRPC请求,让csi driver再去做具体的工作。 而像上面接口方法中定义的各种创建Mounter, Mapper, Provisioner等,都是在创建实现具体功能的实体,这些实体也遵循一定的接口规范,如下类图: 比如Mounter中的SetUp()就是要实现执行mount操作的逻辑,而Unmounter中的Teardown()则是实现执行unmount操作的逻辑,比如在cinderPlugin中定义了cinderVolumeMounter和cinderVolumeUnmounter这两个结构体,分别实现了SetUp()和TearDown()方法,来实现Cinder卷的mount和unmount操作,在cinderPlugin的VolumePlugin插件中,NewMounter()和NewUnmounter()就是分别在创建cinderVolumeMounter和cinderVolumeUnmounter结构体。 所以这是两个维度上的抽象,VolumePlugin中的各种接口定义了这个插件具备哪些功能属性,而具体的操作则是各种xxxer来实现的,最后来看下Kubelet中的volumeManager在执行mount逻辑时,是如何使用上面的各种接口方法的: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748// reconciler会周期性的从desiredStateOfWorld中获取到需要进行Attach或者Mount的Volume,然后调用OperatorExecutor来执行具体的Attach/Mount操作* rc.unmountVolumes() * // Filesystem volume case * volumePlugin, err := og.volumePluginMgr.FindPluginByName(volumeToUnmount.PluginName) * volumeUnmounter, newUnmounterErr := volumePlugin.NewUnmounter() * unmountErr := volumeUnmounter.TearDown() * // Block volume case * blockVolumePlugin, err := og.volumePluginMgr.FindMapperPluginByName(volumeToUnmount.PluginName) * blockVolumeUnmapper, newUnmapperErr := blockVolumePlugin.NewBlockVolumeUnmapper() * customBlockVolumeUnmapper, ok := blockVolumeUnmapper.(volume.CustomBlockVolumeUnmapper) * unmapErr = customBlockVolumeUnmapper.UnmapPodDevice()* rc.mountAttachVolumes() * // if volume is not attached * attachableVolumePlugin, err := og.volumePluginMgr.FindAttachablePluginBySpec(volumeToAttach.VolumeSpec) * volumeAttacher, newAttacherErr := attachableVolumePlugin.NewAttacher() * devicePath, attachErr := volumeAttacher.Attach() * // if volume is not mounted * GenerateMountVolumeFunc() // Filesystem volume case * volumePlugin, err := og.volumePluginMgr.FindPluginBySpec(volumeToMount.VolumeSpec) * volumeMounter, newMounterErr := volumePlugin.NewMounter() * attachableVolumePlugin, _ := og.volumePluginMgr.FindAttachablePluginBySpec(volumeToMount.VolumeSpec) * volumeAttacher, _ = attachableVolumePlugin.NewAttacher() * deviceMountableVolumePlugin, _ := og.volumePluginMgr.FindDeviceMountablePluginBySpec(volumeToMount.VolumeSpec) * volumeDeviceMounter, _ = deviceMountableVolumePlugin.NewDeviceMounter() * devicePath, err = volumeAttacher.WaitForAttach() * // /var/lib/kubelet/plugins/kubernetes.io/csi/pv/{pvname}/globalmount * deviceMountPath, err := volumeDeviceMounter.GetDeviceMountPath(volumeToMount.VolumeSpec) * err = volumeDeviceMounter.MountDevice(volumeToMount.VolumeSpec, devicePath, deviceMountPath) * NodeStageVolume * mountErr := volumeMounter.SetUp() * c.GetPath() // TargetPath * deviceMountPath, err = makeDeviceMountPath(c.plugin, c.spec) // StagingTargetPath * NodePublishVolume * GenerateMapVolumeFunc() // Block volume case * blockVolumePlugin, err := og.volumePluginMgr.FindMapperPluginBySpec(volumeToMount.VolumeSpec) * blockVolumeMapper, newMapperErr := blockVolumePlugin.NewBlockVolumeMapper() * attachableVolumePlugin, _ := og.volumePluginMgr.FindAttachablePluginBySpec(volumeToMount.VolumeSpec) * volumeAttacher, _ = attachableVolumePlugin.NewAttacher() * globalMapPath, err := blockVolumeMapper.GetGlobalMapPath(volumeToMount.VolumeSpec) * devicePath, err = volumeAttacher.WaitForAttach() * customBlockVolumeMapper, ok := blockVolumeMapper.(volume.CustomBlockVolumeMapper); * stagingPath, mapErr = customBlockVolumeMapper.SetUpDevice() * NodeStageVolume * pluginDevicePath, mapErr := customBlockVolumeMapper.MapPodDevice() * NodePublishVolume * volumeMapPath, volName := blockVolumeMapper.GetPodDeviceMapPath() * util.MapBlockVolume(og.blkUtil, devicePath, globalMapPath, volumeMapPath, volName, volumeToMount.Pod.UID) 从上面的代码中可以看到,调用VolumePlugin的一般过程都是先通过volumePluginMgr找到对应的VolumePlugin,然后由该VolumePlugin再创建出对应的执行者,比如Attacher, Mounter,然后再调用执行者对应的方法去执行具体的操作,比如attacher.Attach(), mounter.SetUp(),如果是CSIPlugin的话,则会发送相应的gRPC请求出去,比如在mounter.SetUp()中,发送的就是NodePublishVolume请求。 总结本篇文章重点介绍了Kubernetes是如何实现CSI的,即在原有存储插件框架的基础上,增加了CSI这个存储插件,在CO这一侧支持了CSI协议,同时依赖外置的Controller,去监听Volume相关的事件,向CSI Driver发起相应的请求,再加上kube-controller-manager中的几个Controller,相互配合,组成了CSI相关的功能。在Kubernetes支持了CSI之后,将原本内置的一些第三方厂商的插件,比如AWS EBS, GCE PD, OpenStack Cinder, Azure和VSphere中的Disk等,都标记为了Deprecated,会逐渐从Kubernetes的代码中移除,然后使用CSI机制去对接这些第三方的存储,但是一些通用技术,比如RBD,NFS,FC,ISCSI,肯定还会以内置VolumePlugin的形式存在。","link":"/2021/10/16/kubernetes/kube-kubelet-csi.html"},{"title":"Kubernetes Scheduler Scheduling Framework","text":"简介在Kubernetes Scheduler机制概览中就介绍过Scheduling Framework这个新的调度框架,它通过Plugins以及Extension Points的方式对调度框架进行了重构,通过在各个预定义的扩展点,插入Plugin的方式,扩展Scheduler的功能,核心的调度算法逻辑,也都放到了Plugin中,如果默认的调度器中的Plugin不能满足需求,可以自己写插件,但是要重新编译代码。 关于Scheduling Framework的介绍以及配置,官方这两篇文档已经介绍的很详细了: https://kubernetes.io/docs/concepts/scheduling-eviction/scheduling-framework/ https://kubernetes.io/docs/reference/scheduling/config/ 本篇文章主要来介绍下Scheduling Framework的插件机制的实现原理,以及如何写自己的插件。 实现原理在Scheduling Framework中有这么几个概念:Profile, Framework, ExtensionPoints, Registry, Plugin。Framework是最核心的结构,在Framework中定义了一些扩展点,即ExtensionPoints,每一个扩展点都包含一些Plugin,在调度时,会依次执行Framework中的ExtensionPoints中的每一个Plugin,而Profile相当于是Framework的配置文件,它配置了哪些ExtensionPoint包含哪些Plugin,而Registry则是Plugin的工厂方法集合,插件的构造方法都需要向Registry中注册。他们之间的关系如下图所示: ProfileProfile相当于是Framework的配置文件,它决定了Framework中各个ExtensionPoints包含哪些插件,通过将插件的name加入Enable/Disable列表中进行配置,相关结构如下: 123456789101112131415161718192021222324# kubernetes/pkg/scheduler/apis/config/types.gotype KubeSchedulerProfile struct { SchedulerName string Plugins *Plugins PluginConfig []PluginConfig}type Plugins struct { QueueSort *PluginSet PreFilter *PluginSet Filter *PluginSet ......}type PluginSet struct { Enabled []Plugin Disabled []Plugin}type Plugin struct { Name string Weight int32} Profile可以通过scheduler的配置文件--config file进行配置,比如官方文档给出的示例: 123456789101112apiVersion: kubescheduler.config.k8s.io/v1beta1kind: KubeSchedulerConfigurationprofiles: - plugins: score: disabled: - name: NodeResourcesLeastAllocated enabled: - name: MyCustomPluginA weight: 2 - name: MyCustomPluginB weight: 1 这个配置的意思是把score这个ExtensionPoint中默认的NodeResourcesLeastAllocated插件给禁用掉,启用自定义的两个插件。 此外,Profile还有一个特性就是它是可以定义多个的,每个Profile会对应的创建一个Framework出来,意思是可以任意组合这些Plugin创建出多种调度策略,每一个调度策略都有一个名称,创建pod时,可以指定使用哪种调度策略,Scheduler已经内置了一个叫做”default-scheduler”的Profile,如果建pod时没有指定哪种调度策略,就使用默认的,先来看下默认的调度策略,都定义了哪些plugin: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980# kubernetes/pkg/scheduler/algorithmprovider/registry.gofunc getDefaultConfig() *schedulerapi.Plugins { return &schedulerapi.Plugins{ QueueSort: &schedulerapi.PluginSet{ Enabled: []schedulerapi.Plugin{ {Name: queuesort.Name}, }, }, PreFilter: &schedulerapi.PluginSet{ Enabled: []schedulerapi.Plugin{ {Name: noderesources.FitName}, {Name: nodeports.Name}, {Name: interpodaffinity.Name}, }, }, Filter: &schedulerapi.PluginSet{ Enabled: []schedulerapi.Plugin{ {Name: nodeunschedulable.Name}, {Name: noderesources.FitName}, {Name: nodename.Name}, {Name: nodeports.Name}, {Name: nodeaffinity.Name}, {Name: volumerestrictions.Name}, {Name: tainttoleration.Name}, {Name: nodevolumelimits.EBSName}, {Name: nodevolumelimits.GCEPDName}, {Name: nodevolumelimits.CSIName}, {Name: nodevolumelimits.AzureDiskName}, {Name: volumebinding.Name}, {Name: volumezone.Name}, {Name: interpodaffinity.Name}, }, }, PreScore: &schedulerapi.PluginSet{ Enabled: []schedulerapi.Plugin{ {Name: interpodaffinity.Name}, {Name: defaultpodtopologyspread.Name}, {Name: tainttoleration.Name}, }, }, Score: &schedulerapi.PluginSet{ Enabled: []schedulerapi.Plugin{ {Name: noderesources.BalancedAllocationName, Weight: 1}, {Name: imagelocality.Name, Weight: 1}, {Name: interpodaffinity.Name, Weight: 1}, {Name: noderesources.LeastAllocatedName, Weight: 1}, {Name: nodeaffinity.Name, Weight: 1}, {Name: nodepreferavoidpods.Name, Weight: 10000}, {Name: defaultpodtopologyspread.Name, Weight: 1}, {Name: tainttoleration.Name, Weight: 1}, }, }, Reserve: &schedulerapi.PluginSet{ Enabled: []schedulerapi.Plugin{ {Name: volumebinding.Name}, }, }, Unreserve: &schedulerapi.PluginSet{ Enabled: []schedulerapi.Plugin{ {Name: volumebinding.Name}, }, }, PreBind: &schedulerapi.PluginSet{ Enabled: []schedulerapi.Plugin{ {Name: volumebinding.Name}, }, }, Bind: &schedulerapi.PluginSet{ Enabled: []schedulerapi.Plugin{ {Name: defaultbinder.Name}, }, }, PostBind: &schedulerapi.PluginSet{ Enabled: []schedulerapi.Plugin{ {Name: volumebinding.Name}, }, }, }} 如果想要指定多个Profile的话,可以参考官方文档的一个示例: 123456789101112apiVersion: kubescheduler.config.k8s.io/v1beta1kind: KubeSchedulerConfigurationprofiles: - schedulerName: default-scheduler - schedulerName: no-scoring-scheduler plugins: preScore: disabled: - name: '*' score: disabled: - name: '*' 这里除了”default-scheduler”之外,还定义了一个”no-scoring-scheduler”,它将score相关的plugin都给disable掉了,注意自定义的profile会跟”default-scheduler”做merge操作,相当于是除了没有score相关的plugin之外,其他都跟default-scheduler的配置一样。创建pod时,可以通过”.spec.schedulerName”来指定使用哪种Profile。 FrameworkFramework会使用Profile来决定往ExtensionPoints中添加哪些插件,先来看下Framework的结构体: 12345678910111213141516171819202122232425262728# kubernetes/pkg/scheduler/framework/v1alpha1/framework.gotype framework struct { registry Registry snapshotSharedLister SharedLister waitingPods *waitingPodsMap pluginNameToWeightMap map[string]int queueSortPlugins []QueueSortPlugin preFilterPlugins []PreFilterPlugin filterPlugins []FilterPlugin preScorePlugins []PreScorePlugin scorePlugins []ScorePlugin reservePlugins []ReservePlugin preBindPlugins []PreBindPlugin bindPlugins []BindPlugin postBindPlugins []PostBindPlugin unreservePlugins []UnreservePlugin permitPlugins []PermitPlugin clientSet clientset.Interface informerFactory informers.SharedInformerFactory metricsRecorder *metricsRecorder preemptHandle PreemptHandle runAllFilters bool} 像preFilterPlugins, filterPlugins这样的成员变量,就是所谓的ExtensionPoints,在创建Framework时,如果Profile中对应的插件是Enable的,那么会调用registry中对应的插件的工厂方法实例化这个插件,然后添加到这个列表中的,而framework这个结构体又实现了Framework定义的接口: 12345678910111213141516171819202122232425262728293031# kubernetes/pkg/scheduler/framework/v1alpha1/interface.gotype Framework interface { FrameworkHandle QueueSortFunc() LessFunc RunPreFilterPlugins(ctx context.Context, state *CycleState, pod *v1.Pod) *Status RunFilterPlugins(ctx context.Context, state *CycleState, pod *v1.Pod, nodeInfo *NodeInfo) PluginToStatus RunPreFilterExtensionAddPod(ctx context.Context, state *CycleState, podToSchedule *v1.Pod, podToAdd *v1.Pod, nodeInfo *NodeInfo) *Status RunPreFilterExtensionRemovePod(ctx context.Context, state *CycleState, podToSchedule *v1.Pod, podToAdd *v1.Pod, nodeInfo *NodeInfo) *Status RunPreScorePlugins(ctx context.Context, state *CycleState, pod *v1.Pod, nodes []*v1.Node) *Status RunScorePlugins(ctx context.Context, state *CycleState, pod *v1.Pod, nodes []*v1.Node) (PluginToNodeScores, *Status) RunPreBindPlugins(ctx context.Context, state *CycleState, pod *v1.Pod, nodeName string) *Status RunPostBindPlugins(ctx context.Context, state *CycleState, pod *v1.Pod, nodeName string) RunReservePlugins(ctx context.Context, state *CycleState, pod *v1.Pod, nodeName string) *Status RunUnreservePlugins(ctx context.Context, state *CycleState, pod *v1.Pod, nodeName string) RunPermitPlugins(ctx context.Context, state *CycleState, pod *v1.Pod, nodeName string) *Status WaitOnPermit(ctx context.Context, pod *v1.Pod) *Status RunBindPlugins(ctx context.Context, state *CycleState, pod *v1.Pod, nodeName string) *Status HasFilterPlugins() bool HasScorePlugins() bool ListPlugins() map[string][]config.Plugin}type FrameworkHandle interface { SnapshotSharedLister() SharedLister IterateOverWaitingPods(callback func(WaitingPod)) GetWaitingPod(uid types.UID) WaitingPod RejectWaitingPod(uid types.UID) ClientSet() clientset.Interface SharedInformerFactory() informers.SharedInformerFactory} 在调度时,会调用framework中定义的这些RunXXXPlugins()方法,在这些方法中,又依次遍历ExtensionPoints中注册的插件的方法,去执行具体的调度逻辑。 再来看下这些插件的接口是如何定义的: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051# kubernetes/pkg/scheduler/framework/v1alpha1/interface.gotype Plugin interface { Name() string}type QueueSortPlugin interface { Plugin Less(*QueuedPodInfo, *QueuedPodInfo) bool}type PreFilterPlugin interface { Plugin PreFilter(ctx context.Context, state *CycleState, p *v1.Pod) *Status PreFilterExtensions() PreFilterExtensions}type FilterPlugin interface { Plugin Filter(ctx context.Context, state *CycleState, pod *v1.Pod, nodeInfo *NodeInfo) *Status}type PreScorePlugin interface { Plugin PreScore(ctx context.Context, state *CycleState, pod *v1.Pod, nodes []*v1.Node) *Status}type ScorePlugin interface { Plugin Score(ctx context.Context, state *CycleState, p *v1.Pod, nodeName string) (int64, *Status) ScoreExtensions() ScoreExtensions}type ReservePlugin interface { Plugin Reserve(ctx context.Context, state *CycleState, p *v1.Pod, nodeName string) *Status}type PreBindPlugin interface { Plugin PreBind(ctx context.Context, state *CycleState, p *v1.Pod, nodeName string) *Status}type PostBindPlugin interface { Plugin PostBind(ctx context.Context, state *CycleState, p *v1.Pod, nodeName string)}type UnreservePlugin interface { Plugin Unreserve(ctx context.Context, state *CycleState, p *v1.Pod, nodeName string)}type PermitPlugin interface { Plugin Permit(ctx context.Context, state *CycleState, p *v1.Pod, nodeName string) (*Status, time.Duration)}type BindPlugin interface { Plugin Bind(ctx context.Context, state *CycleState, p *v1.Pod, nodeName string) *Status} 内置的插件,都在kubernetes/pkg/scheduler/framework/plugins/目录下,一个插件其实可以实现多个上述的接口,在不同的阶段执行不同的操作。自定义的插件,则需要实现对应的接口方法即可。 RegistryRegistry是用来注册创建插件实例的工厂方法的结构体,其结构如下: 1234# kubernetes/pkg/scheduler/framework/v1alpha1/registry.gotype PluginFactory = func(configuration runtime.Object, f FrameworkHandle) (Plugin, error)type Registry map[string]PluginFactory Registry中包含两种插件,一种是Kubernetes内置的插件,叫做InTreeRegistry,一种是外部自定义的插件,叫做OutOfTreeRegistry。内置的和外置的插件会在创建Scheduler时,进行合并到同一个Registry中: 123456# kubernetes/pkg/scheduler/scheduler.goregistry := frameworkplugins.NewInTreeRegistry()if err := registry.Merge(options.frameworkOutOfTreeRegistry); err != nil { return nil, err} 内置的插件,都在kubernetes/pkg/scheduler/framework/plugins/目录下,目前有如下内置的插件: 12345678910111213141516171819202122232425262728293031323334# kubernetes/pkg/scheduler/framework/plugins/registry.gofunc NewInTreeRegistry() framework.Registry { return framework.Registry{ defaultpodtopologyspread.Name: defaultpodtopologyspread.New, imagelocality.Name: imagelocality.New, tainttoleration.Name: tainttoleration.New, nodename.Name: nodename.New, nodeports.Name: nodeports.New, nodepreferavoidpods.Name: nodepreferavoidpods.New, nodeaffinity.Name: nodeaffinity.New, podtopologyspread.Name: podtopologyspread.New, nodeunschedulable.Name: nodeunschedulable.New, noderesources.FitName: noderesources.NewFit, noderesources.BalancedAllocationName: noderesources.NewBalancedAllocation, noderesources.MostAllocatedName: noderesources.NewMostAllocated, noderesources.LeastAllocatedName: noderesources.NewLeastAllocated, noderesources.RequestedToCapacityRatioName: noderesources.NewRequestedToCapacityRatio, noderesources.ResourceLimitsName: noderesources.NewResourceLimits, volumebinding.Name: volumebinding.New, volumerestrictions.Name: volumerestrictions.New, volumezone.Name: volumezone.New, nodevolumelimits.CSIName: nodevolumelimits.NewCSI, nodevolumelimits.EBSName: nodevolumelimits.NewEBS, nodevolumelimits.GCEPDName: nodevolumelimits.NewGCEPD, nodevolumelimits.AzureDiskName: nodevolumelimits.NewAzureDisk, nodevolumelimits.CinderName: nodevolumelimits.NewCinder, interpodaffinity.Name: interpodaffinity.New, nodelabel.Name: nodelabel.New, serviceaffinity.Name: serviceaffinity.New, queuesort.Name: queuesort.New, defaultbinder.Name: defaultbinder.New, }} 而外置的插件是怎么传递进来的呢?是在kube-scheduler的main方法中传进来的: 1234567891011121314# kubernetes/cmd/kube-scheduler/app/server.gofunc NewSchedulerCommand(registryOptions ...Option) *cobra.Command { ......}# kubernetes/cmd/kube-scheduler/scheduler.gofunc main() { rand.Seed(time.Now().UnixNano()) command := app.NewSchedulerCommand() ......} 但是可以看到,kubernetes中内置的main()并没有传递这个参数,因此如果想要自定义插件的话,就必须要改main()方法,将这个参数传递进去,因此就需要重新编译kube-scheduler这个组件了。而在app/server.go中,已经为自定义插件留好了口子: 1234567# kubernetes/cmd/kube-scheduler/app/server.gofunc WithPlugin(name string, factory framework.PluginFactory) Option { return func(registry framework.Registry) error { return registry.Register(name, factory) }} 需要在外面的一个项目中,编写自己的插件,主要是实现对应的插件定义的接口方法,以及该插件的工厂方法,然后重写kube-scheduler的main()方法,调用WithPlugin()方法,构造一个Option,将其传给app.NewSchedulerCommand(),然后重新编译这个kube-scheduler,再结合Profile,将自定义的插件注册到对应的ExtensionPoint中,就可以了。 社区的项目scheduler-plugins就维护了一些outoftree的插件,来看下它重写的main()方法的例子: 1234567891011121314151617func main() { rand.Seed(time.Now().UnixNano()) // Register custom plugins to the scheduler framework. // Later they can consist of scheduler profile(s) and hence // used by various kinds of workloads. command := app.NewSchedulerCommand( app.WithPlugin(capacityscheduling.Name, capacityscheduling.New), app.WithPlugin(coscheduling.Name, coscheduling.New), app.WithPlugin(noderesources.AllocatableName, noderesources.NewAllocatable), // Sample plugins below. app.WithPlugin(crossnodepreemption.Name, crossnodepreemption.New), app.WithPlugin(podstate.Name, podstate.New), app.WithPlugin(qos.Name, qos.New), ) ......} 总结本文介绍了Scheduling Framework的实现原理,以及如何去实现自定义的插件。整体上看,这个框架设计的还是相当不错的,尤其是插件机制,以及多Profile的支持,是一个很好的设计典范。不过唯一有点别扭的是自定义插件竟然需要去更改main()方法,并且需要单独编译kube-scheduler,也许有更优雅的实现方式。","link":"/2020/12/21/kubernetes/kube-scheduler-framework.html"},{"title":"Kubernetes Scheduler机制概览","text":"简介 在 Kubernetes 项目中,默认调度器的主要职责,就是为一个新创建出来的 Pod,寻找一个最合适的节点(Node)。 而这里“最合适”的含义,包括两层: 从集群所有的节点中,根据调度算法挑选出所有可以运行该 Pod 的节点; 从第一步的结果中,再根据调度算法挑选一个最符合条件的节点作为最终结果。 所以在具体的调度流程中,默认调度器会首先调用一组叫作 Predicate 的调度算法,来检查每个 Node。然后,再调用一组叫作 Priority 的调度算法,来给上一步得到的结果里的每个 Node 打分。最终的调度结果,就是得分最高的那个 Node。 以上文字来自于极客时间,张磊老师的《深入剖析Kubernetes》课程中的《十字路口上的Kubernetes默认调度器》这一章节,我觉得总结的相当到位,对我这个万事开头难来说,实在难以写出比这更精辟的开头了,就直接拿来主义了:-) 不过最近两年Scheduler做了不少的改进,尤其是为了让其有更好的扩展性,以及更好的维护,而重构的称之为Scheduling Framework的调度框架,已经淡化了以前Predicate & Priority的概念,还有webhook方式的扩展机制Scheduler Extender,取而代之的是Plugins以及Extension Points,即通过在各个预定义的扩展点,插入Plugin的方式,扩展Scheduler的功能,核心的调度算法逻辑,也都放到了Plugin中,如果默认的调度器中的Plugin不能满足需求,可以自己写插件,但是要重新编译代码。 此外,还有两个重量级组件,就是优先级队列和缓存(PriorityQueue and Cache),优先级队列保障Pod的调度顺序,结合上面的插件机制,用户是可以自定义优先级算法的,而缓存则主要是为了优化调度的性能而引入的,它缓存的主要是Pod和Node的信息,调度器的性能优化,也经历了一个很漫长的进化过程。 Scheduler架构好了,下面来看下Kubernetes Scheduler在我脑海中的画像: Informer首先就是Informer了,关于Informer这里就不做过多介绍了,可以看下这篇文章《Kubernetes Informer机制解析》,介绍的比较详细。Scheduler中的Informer是一切事件的来源,它监听了很多对象的状态,但是其中主要是Pod和Node,Pod的事件又分为两种: 当没有调度成功的pod发生增删改事件时,该pod就会被放入到优先级调度队列中。 当已经调度成功的pod发生增删改事件时,该pod就会被对应的更新到缓存中。 而当有Node的增删改事件时,会将其信息更新到缓存中,以提高调度的速度。 所以,一句话总结这里的过程:未被调度的pod放到了优先级调度队列中,而已经调度的pod和node,则更新至缓存中。 PriorityQueue这里所谓的PriorityQueue其实是由三个队列组成的: activeQ: 用来存放将要进行调度的pod,底层数据结构是用”堆”来实现的优先级队列; unschedulableQ: 用来存放调度失败的pod,底层数据结构是一个map; podBackoffQ: 用来作为activeQ和unschedulableQ之间的一个缓冲队列,也是一个用“堆”实现的优先级队列 首先是优先级的问题,Kubernetes中实现了一个Pod Priority的功能,可以给pod关联一个PriorityClass,指定一个权重值,权重越高,则该pod的优先级越高,在优先级队列中会越往前排,它就越会被优先调度。此处的队列,指的就是activeQ,它是一个用“堆”这个数据结构来实现的优先级队列,堆的排序算法,是可以通过FrameWork的插件机制来指定的,默认的排序插件就是按照pod的权重值来排序,如果没有指定权重值的话,则按照pod加入到队列中的时间戳来排序,也即退化成一个FIFO队列。 消费者从activeQ中取走一个pod进行调度,如果调度失败的话,则会将该pod放入到unschedulableQ中,而unschedulableQ中的pod又会周期性的被移到activeQ或者podBackoffQ中,等待重新被调度。 而podBackoffQ的存在,主要是为了解决优先级调度算法中存在的”无穷阻塞”或者是”饥饿”问题,即由于高优先级的pod总是被优先调度,而低优先级的pod一直得不到调度的问题。podBackoffQ也是一个优先级队列,不过它的排序算法就不是按照pod的权重值了,而是按照pod加入到unschedulableQ中的时间长短来排序的,即该pod等待被调度的时间越长,则在podBackoffQ中越排在前面,这样就防止了“无穷阻塞”的问题。Scheduler周期性的将超过了backoff时间的pod,从podBackoffQ移动到activeQ,进行再次调度。 可以看到这三个Queue互相配合,完美实现了优先级调度的功能。 CacheCache在这里的作用,主要是为了加速调度过程中查询pod和node等信息的速度,了解Informer机制的话,不免有个疑问,就是Informer其实已经在本地缓存了一份数据了,而这里为什么还要再添加一层缓存呢?其实在没有cache之前,确实是直接通过Informer获取的信息,但是Informer中缓存的数据相当于是原始数据,在调度过程中,还需要根据原始数据再实时计算出一些数据,比如该node上所有pod的request之和,或者是该node分配出去的端口号,这种实时计算的,就需要遍历一遍这个Node上所有的pod或者是container,这就会很耗费时间了,在规模环境比较大,pod数量比较多的场景下,这些还是很影响调度性能的。所以一种优化策略,就是再引入一层缓存,依赖informer的事件机制,提前在本地缓存好聚合之后的数据,这样在调度的时候,就不用再实时去计算了,时间复杂度也就从O(containers)变为了O(nodes)。 当然,这付出的代价,就是代码复杂度又变高了,该缓存的正确与否,直接影响了调度的准确性,后续为了保证调度时的一致性,又引入了snapshot机制,即每次调度pod时,都要给当前的缓存打一个快照,因为如果直接依赖缓存的话,它的数据可能随时会变化,尤其是在node和pod频繁变更时,因此通过打快照,调度前后都使用快照中的数据,保证了调度的一致性。 针对Cache的优化,社区经历了一个比较漫长的演进过程,这里面有一些很奇怪的数据结构,不了解背景的话,还真的难以理解,后面我们专门有一篇文章来盘点下那些年为提升调度性能而做的优化。 Scheduling Framework这个算是Scheduler中的重头戏了,前面已经介绍过,它主要的特点是将调度的过程,定义了几个扩展点,每个扩展点都可以注册一些插件,通过插件的方式来实现具体的调度算法,Kubernetes Scheduler中默认的调度算法,都是通过插件来实现的。整个调度过程分为两部分:Scheduling Cycle和Binding Cycle。前者是为pod找最合适node的过程,后者是将该pod和node进行绑定的过程。 Scheduling Cycle的扩展点:PreFilter, Filter, PreScore, Score, Reserve, UnReserve, Permit Binding Cycle的扩展点:PreBind, Bind, PostBind Scheduling阶段中,Filter是用来过滤符合调度条件的node的;Score则是来给符合条件的node进行打分,从中选择一个最合适的node;Reserve则是为该pod保留资源的,比如volume;Permit是Scheduling阶段最后的步骤,它类似准入规则,或者延迟准入,只有符合了准入规则的pod,才会进入Bind阶段。 Binding阶段,主要就是用来将选出来的node和pod进行绑定,即调用pod的Bind接口,将该绑定关系写入到数据库中,该过程是异步进行的。 此外,还有一个assume操作,该操作不是一个扩展点,它是在Scheduling阶段,将选出来的node提前更新到缓存中,张磊老师称之为“乐观绑定”,我觉得很形象,其主要目的是为了不阻塞调度的关键路径,因为向apiserver发起绑定请求是一个比较耗时的操作,所以Bind这个操作是异步进行的,但是又不能等到Bind成功之后,才由Informer的事件去触发更新缓存,这样的话,下一次pod调度拿到的缓存信息,很有可能是本次Pod绑定之前的数据,它认为资源还没被分配出去,这就出现了资源调度的不一致性,所以通过assume机制,提前进行缓存更新,同时,发起异步Bind操作,假如后续Bind失败了,也没有关系,Scheduler会清空该pod的缓存,然后将该pod放入到unschedulableQ进行重新调度。 总结本篇文章从整体上介绍了一下Kubernetes Scheduler的工作原理,从这4大块内容来看(Informer, PriorityQueue, Cache, Scheduling Framework),Scheduler在扩展性上、性能上都做了很多的优化工作,很多小细节考虑的很周到。在设计上,有很多值得学习的地方,翻看到早期的Scheduler代码,实现的非常简单,到现在功能这么健全,真心感觉罗马不是一天建成的,还有就是众人拾柴火焰高。 不过,从上面的设计上也可以看出,目前Scheduler的一个问题就是它只能是单点的,没法水平扩展,因为队列实现的是本地的内存队列,没有依赖外部服务,多个scheduler之间是没法共同协作的,只能通过LeaderElection机制做主备,或者是依赖deployment控制器,做单点服务管理,不过目前看scheduler的性能优化已经很不错了,单点就能支撑很大的规模,这里给出一个数据:在5000个node上调度10000个pod,每个pod的调度延迟只有6.7ms,这个性能已经能够满足绝大多数的集群规模。 附录123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119代码主线:* opts, err := options.NewOptions() * cfg, err := newDefaultComponentConfig() * o := &Options{ * ComponentConfig: *cfg, * Deprecated: &DeprecatedOptions{} * }* opts.Flags()* runCommand(cmd, opts, registryOptions...) * cc, sched, err := Setup(ctx, opts, registryOptions...) * opts.Validate() * c, err := opts.Config() * c := &schedulerappconfig.Config{} * o.ApplyTo(c); * cfg := loadConfigFromFile(o.ConfigFile) * c.ComponentConfig = *cfg * c.InformerFactory = informers.NewSharedInformerFactory(client, 0) * c.PodInformer = scheduler.NewPodInformer(client, 0) * cc := c.Complete() * sched, err := scheduler.New(cc.Client,...) * schedulerCache := internalcache.New(30*time.Second, stopEverything) * registry := frameworkplugins.NewInTreeRegistry() // type Registry map[string]PluginFactory * frameworkplugins.NewInTreeRegistry * frameworkOutOfTreeRegistry * snapshot := internalcache.NewEmptySnapshot() * profiles // KubeSchedulerProfile, default-scheduler * source := options.schedulerAlgorithmSource // DefaultProvider * sc, err := configurator.createFromProvider(*source.Provider) * r := algorithmprovider.NewRegistry() // type Registry map[string]*schedulerapi.Plugins * defaultPlugins, exist := r[providerName] // DefaultProvider, 根据scheduler algorithm provider,拿到default scheduler plugins * for i := range c.profiles { // 将default scheduler plugins和profile中指定的plugins进行合并,合并之后的plugins,赋值给profile * prof := &c.profiles[i] * plugins := &schedulerapi.Plugins{} * plugins.Append(defaultPlugins) * plugins.Apply(prof.Plugins) * prof.Plugins = plugins * } * * nominator := internalqueue.NewPodNominator() * profiles, err := profile.NewMap(c.profiles, c.buildFramework, c.recorderFactory, framework.WithPodNominator(nominator) * framework.NewFramework(r Registry, plugins *config.Plugins, ...) // 使用Registry中的工厂类创建出plugin实例,赋值到framework中 * f := &framework{ * registry: r, * informerFactory: options.snapshotSharedLister, * snapshotSharedLister: options.informerFactory, * } * pg := f.pluginsNeeded(plugins) * for name, factory := range r { * if _, ok := pg[name]; !ok { * continue * } * p, err := factory(args, f) //实例化plugin * pluginsMap[name] = p * } * for _, e := range f.getExtensionPoints(plugins) { * updatePluginList(e.slicePtr, e.plugins, pluginsMap) //赋值到framework中 * } * SharedInformerFactory() * SnapshotSharedLister() * lessFn := profiles[c.profiles[0].SchedulerName].Framework.QueueSortFunc() * podQueue := internalqueue.NewSchedulingQueue( * lessFn, * internalqueue.WithPodInitialBackoffDuration(time.Duration(c.podInitialBackoffSeconds)*time.Second), * internalqueue.WithPodMaxBackoffDuration(time.Duration(c.podMaxBackoffSeconds)*time.Second), * internalqueue.WithPodNominator(nominator), * ) * algo := core.NewGenericScheduler( * c.schedulerCache, * nominator, * c.nodeInfoSnapshot, * extenders, * c.informerFactory.Core().V1().PersistentVolumeClaims().Lister(), * GetPodDisruptionBudgetLister(c.informerFactory), * c.disablePreemption, * c.percentageOfNodesToScore, * ) * return &Scheduler{ * SchedulerCache: c.schedulerCache, * Algorithm: algo, * Profiles: profiles, * NextPod: internalqueue.MakeNextPodFunc(podQueue), * Error: MakeDefaultErrorFunc(c.client, c.informerFactory.Core().V1().Pods().Lister(), podQueue, c.schedulerCache), * StopEverything: c.StopEverything, * SchedulingQueue: podQueue, * }, nil * addAllEventHandlers(sched, informerFactory, podInformer) * Informer().AddEventHandler(handler) * listener := newProcessListener(handler) * s.processor.addListener(listener) * Run(ctx, cc, sched) * Informer.Run() * sched.Run(ctx) * sched.SchedulingQueue.Run() * wait.Until(p.flushBackoffQCompleted, 1.0*time.Second, p.stop) //将在podBackoffQ中的超过backoff时间的Pod移动到activeQ中 * wait.Until(p.flushUnschedulableQLeftover, 30*time.Second, p.stop) // 将在unschedulableQ中超过60s的pod根据其backoff time移动到activeQ或者podBackoffQ中,如果超过backoff time则移动到activeQ中,如果还在backoff的时间内,则将其移动到podBackoffQ中 * wait.UntilWithContext(ctx, sched.scheduleOne, 0) * podInfo := sched.NextPod() * scheduleResult, err := sched.Algorithm.Schedule(schedulingCycleCtx, prof, state, pod) * g.snapshot(); * g.cache.UpdateSnapshot(g.nodeInfoSnapshot) * filteredNodes, filteredNodesStatuses, err := g.findNodesThatFitPod(ctx, prof, state, pod) * s := prof.RunPreFilterPlugins(ctx, state, pod) * filtered, err := g.findNodesThatPassFilters(ctx, prof, state, pod, filteredNodesStatuses) * fits, status, err := podPassesFiltersOnNode(ctx, prof, g.podNominator, state, pod, nodeInfo) * podsAdded, stateToUse, nodeInfoToUse, err = addNominatedPods(ctx, pr, nominator, pod, state, info) * statusMap := pr.RunFilterPlugins(ctx, stateToUse, pod, nodeInfoToUse) * filtered, err = g.findNodesThatPassExtenders(pod, filtered, filteredNodesStatuses) * extender.Filter(pod, filtered) * priorityList, err := g.prioritizeNodes(ctx, prof, state, pod, filteredNodes) * preScoreStatus := prof.RunPreScorePlugins(ctx, state, pod, nodes) * scoresMap, scoreStatus := prof.RunScorePlugins(ctx, state, pod, nodes) * host, err := g.selectHost(priorityList) * prof.RunReservePlugins(schedulingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost) * err = sched.assume(assumedPod, scheduleResult.SuggestedHost) * runPermitStatus := prof.RunPermitPlugins(schedulingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost) * preBindStatus := prof.RunPreBindPlugins(bindingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost) * err := sched.bind(bindingCycleCtx, prof, assumedPod, scheduleResult.SuggestedHost, state) * bindStatus := prof.RunBindPlugins(ctx, state, assumed, targetNode) * prof.RunPostBindPlugins(bindingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost)","link":"/2020/12/19/kubernetes/kube-scheduler-overview.html"},{"title":"Kubernetes API Codec 解析","text":"概述在 Kubernetes API 多版本和序列化 这篇文章中,介绍了API多版本的功能和实现原理,其中Codec就是用来做序列化工作的,它主要用在两个地方:一个是通过HTTP协议跟客户端进行交互时,会对传输的数据进行序列化和反序列化,将字节流类型的数据转换成对应的API对象,或者是将API对象转换成对应格式的数据返回给客户端;一个是用在存储层的,即API对象存储到数据库时,也需要经过编码的,即经过序列化,默认是存储成 protobuf格式的数据,然后从数据库读出来数据时,又会反序列化为对应的API对象,下面我们来分析下Codec的实现机制。 SerializerSerializer即是将API对象以某种数据格式进行序列化和反序列化,目前支持的数据格式有三种:json, yaml, protobuf,我们先来看看相关的接口定义: 1234567891011121314151617181920212223// k8s.io/apimachinery/pkg/runtime/interfaces.go// Encoder writes objects to a serialized formtype Encoder interface { Encode(obj Object, w io.Writer) error Identifier() Identifier}// Decoder attempts to load an object from data.type Decoder interface { Decode(data []byte, defaults *schema.GroupVersionKind, into Object) (Object, *schema.GroupVersionKind, error)}// Serializer is the core interface for transforming objects into a serialized format and back.type Serializer interface { Encoder Decoder}// Codec is a Serializer that deals with the details of versioning objects. It offers the same// interface as Serializer, so this is a marker to consumers that care about the version of the objects// they receive.type Codec Serializer Encoder接口中定义的Encode()方法是要将一个API对象以某种格式编码到输出中,而Decoder接口中定义的Decode()方法则是将字节类型的数据解码成某个版本的API对象,这两个编码解码的接口组合起来形成一个新的接口,叫Serializer,同时也叫 Codec。 目前 Kubernetes 中有三种数据格式的Serializer,均实现了上面的接口,分别为json, yaml和protobuf,来看下他们的类图: 这几个Serializer定义在 k8s.io/apimachinery/pkg/runtime/serializer/ 目录下,分别实现了Json, Yaml和Protobuf数据格式的编码和解码操作,需要注意的是没有专门的Yaml Serializer的实现,因为Json跟Yaml的转换很容易,所以直接使用Json Serializer去实现了Yaml Serializer,具体json和protobuf是如何进行Encode和Decode的,这里我们不展开,这里只需要知道这几个Serializer的作用,做了什么事情即可。 CodecFactory上面的接口中,为什么要再定义一个跟Serializer同名的接口Codec呢?Codec的注释有这么一句话,说明了它的作用: Codec is a Serializer that deals with the details of versioning objects. 即Codec是专门用来处理多版本的API对象的序列化的,它除了需要做API对象的序列化操作之外,还需要做版本转换的操作,而上面介绍到的json/yaml/protobuf Serializer相对偏底层,只是做某个版本对象的序列化操作,Codec会引用Serializer做具体的序列化,然后再做版本转换。Codec既然跟对象版本有关,那肯定不同版本的API资源就要有不同的Codec了,所以我们就需要有个生产Codec的工厂类,即CodecFactory: 可以看到它实现了两个接口,NegotiatedSerializer 和 StorageSerializer,正好对应了本小节开头提到的Codec的两个作用:一个作用于HTTP,用来跟客户端交互,一个作用于存储,用来跟数据库交互,其中的 EncoderForVersion() 和 DecoderToVersion() 就是用来生产Codec的方法,来看看相关代码: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546// k8s.io/apimachinery/pkg/runtime/serializer/codec_factory.gofunc NewCodecFactory(scheme *runtime.Scheme, mutators ...CodecFactoryOptionsMutator) CodecFactory { options := CodecFactoryOptions{Pretty: true} for _, fn := range mutators { fn(&options) } // 创建了 json/yaml/protobuf 三种serializer serializers := newSerializersForScheme(scheme, json.DefaultMetaFactory, options) return newCodecFactory(scheme, serializers)}func newCodecFactory(scheme *runtime.Scheme, serializers []serializerType) CodecFactory { decoders := make([]runtime.Decoder, 0, len(serializers)) var accepts []runtime.SerializerInfo var legacySerializer runtime.Serializer for _, d := range serializers { decoders = append(decoders, d.Serializer) for _, mediaType := range d.AcceptContentTypes { ...... info := runtime.SerializerInfo{ MediaType: d.ContentType, EncodesAsText: d.EncodesAsText, Serializer: d.Serializer, PrettySerializer: d.PrettySerializer, StrictSerializer: d.StrictSerializer, } ...... accepts = append(accepts, info) if mediaType == runtime.ContentTypeJSON { legacySerializer = d.Serializer } } } ...... return CodecFactory{ scheme: scheme, universal: recognizer.NewDecoder(decoders...), accepts: accepts, legacySerializer: legacySerializer, }} 上面的代码显示了CodecFactory是如何创建出来的,比较重要的是 serializers := newSerializersForScheme(scheme, json.DefaultMetaFactory, options) 这行代码,这是去创建 json/yaml/protobuf 三种Serializer,然后在 newCodecFactory() 方法中将他们转成了 SerializerInfo 对象,最终将他们放到了CodecFactory的 accepts 属性中。还有个universal 属性,是把三种Serializer放到了一个列表里,然后组成了一个统一的decoder,即它可以解析三种格式的数据。 再来看看CodecFactory生产Codec的方法: 12345678910111213141516171819202122// k8s.io/apimachinery/pkg/runtime/serializer/codec_factory.gofunc (f CodecFactory) CodecForVersions(encoder runtime.Encoder, decoder runtime.Decoder, encode runtime.GroupVersioner, decode runtime.GroupVersioner) runtime.Codec { // TODO: these are for backcompat, remove them in the future if encode == nil { encode = runtime.DisabledGroupVersioner } if decode == nil { decode = runtime.InternalGroupVersioner } return versioning.NewDefaultingCodecForScheme(f.scheme, encoder, decoder, encode, decode)}// DecoderToVersion returns a decoder that targets the provided group version.func (f CodecFactory) DecoderToVersion(decoder runtime.Decoder, gv runtime.GroupVersioner) runtime.Decoder { return f.CodecForVersions(nil, decoder, nil, gv)}// EncoderForVersion returns an encoder that targets the provided group version.func (f CodecFactory) EncoderForVersion(encoder runtime.Encoder, gv runtime.GroupVersioner) runtime.Encoder { return f.CodecForVersions(encoder, nil, gv, nil)} 这几个函数接收的参数,都是接口类型的,Encoder和Decoder上面我们介绍过了,是用来做具体的数据格式序列化的,还有个GroupVersioner来看下: 123456// k8s.io/apimachinery/pkg/runtime/interfaces.gotype GroupVersioner interface { KindForGroupVersionKinds(kinds []schema.GroupVersionKind) (target schema.GroupVersionKind, ok bool) Identifier() string} 而实现了该接口的是一个叫 multiGroupVersioner 的结构体,位于 k8s.io/apimachinery/pkg/runtime/codec.go: 这个multiGroupVersioner的作用是什么呢?可以看看它里面的属性,有一个GroupVersion类型的target,然后有一个[]GroupKind类型的accetedGroupKinds,然后 KindForGroupVersionKinds() 方法的作用就是,当接收一个GVK列表时,看它们的GroupKind哪一个在acceptGroupKinds里,然后就会把它的Kind取出来,跟target组成一个新的GVK返回,即期望输出的Group和Version是固定的,就是target所指定的,只需要找到匹配的Kind即可,比如: 12target=mygroup/__internal, acceptedGroupKinds=mygroup/Foo, anothergroup/BarKindForGroupVersionKinds(yetanother/v1/Baz, anothergroup/v1/Bar) -> mygroup/__internal/Bar (matched preferred group/kind) 那它到底有什么用呢?要知道我们前面讲类型注册时,注册进scheme的类型,可能会对应多个GVK,即typeToGVK,multiGroupVersioner的作用就是在这,在进行版本转换时,已知一个类型,找到多个GVK时,能够唯一的确定一个GVK: 123456789// k8s.io/apimachinery/pkg/runtime/scheme.gofunc (s *Scheme) convertToVersion(copy bool, in Object, target GroupVersioner) (Object, error) { var t reflect.Type t = reflect.TypeOf(in) t = t.Elem() kinds, ok := s.typeToGVK[t] gvk, ok := target.KindForGroupVersionKinds(kinds) ......} 理解了GroupVersioner的作用,再来看看 CodecForVersions() 这几个工厂方法,就是指定了做序列化的encoder或者decoder,以及目标版本,然后构造了一个能够处理版本转换的 versioning.codec,它就是这个工厂方法生产出来的Codec,再来具体看看它: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556// k8s.io/apimachinery/pkg/runtime/serializer/versioning/versioning.gofunc NewDefaultingCodecForScheme( scheme *runtime.Scheme, encoder runtime.Encoder, decoder runtime.Decoder, encodeVersion runtime.GroupVersioner, decodeVersion runtime.GroupVersioner,) runtime.Codec { return NewCodec(encoder, decoder, runtime.UnsafeObjectConvertor(scheme), scheme, scheme, scheme, encodeVersion, decodeVersion, scheme.Name())}func NewCodec( encoder runtime.Encoder, decoder runtime.Decoder, convertor runtime.ObjectConvertor, creater runtime.ObjectCreater, typer runtime.ObjectTyper, defaulter runtime.ObjectDefaulter, encodeVersion runtime.GroupVersioner, decodeVersion runtime.GroupVersioner, originalSchemeName string,) runtime.Codec { internal := &codec{ encoder: encoder, decoder: decoder, convertor: convertor, creater: creater, typer: typer, defaulter: defaulter, encodeVersion: encodeVersion, decodeVersion: decodeVersion, identifier: identifier(encodeVersion, encoder), originalSchemeName: originalSchemeName, } return internal}type codec struct { encoder runtime.Encoder decoder runtime.Decoder convertor runtime.ObjectConvertor creater runtime.ObjectCreater typer runtime.ObjectTyper defaulter runtime.ObjectDefaulter encodeVersion runtime.GroupVersioner decodeVersion runtime.GroupVersioner identifier runtime.Identifier originalSchemeName string} codec实例中的encoder, decoder就是用来做具体序列化工作的json/yaml/protobuf Serializer,而creater, typer, defaulter均是scheme,encodeVersion, decodeVersion则是目标版本,还有convertor本质上也是scheme,只是在外面又包了一层,最终进行版本转换,调用的是scheme的UnsafeConvertToVersion()方法: 12345678910111213// k8s.io/apimachinery/pkg/runtime/helper.gotype unsafeObjectConvertor struct { *Scheme}func (c unsafeObjectConvertor) ConvertToVersion(in Object, outVersion GroupVersioner) (Object, error) { return c.Scheme.UnsafeConvertToVersion(in, outVersion)}func UnsafeObjectConvertor(scheme *Scheme) ObjectConvertor { return unsafeObjectConvertor{scheme}} 来大致看看这个codec提供的Encode和Decode方法的逻辑: 123456789101112131415161718192021222324252627282930313233// k8s.io/apimachinery/pkg/runtime/serializer/versioning/versioning.gofunc (c *codec) doEncode(obj runtime.Object, w io.Writer, memAlloc runtime.MemoryAllocator) error { ...... encodeFn := c.encoder.Encode ...... out, err := c.convertor.ConvertToVersion(obj, c.encodeVersion) ...... return encodeFn(out, w)}func (c *codec) Decode(data []byte, defaultGVK *schema.GroupVersionKind, into runtime.Object) (runtime.Object, *schema.GroupVersionKind, error) { decodeInto := into obj, gvk, err := c.decoder.Decode(data, defaultGVK, decodeInto) if into != nil { // perform defaulting if requested if c.defaulter != nil { c.defaulter.Default(obj) } // Short-circuit conversion if the into object is same object if into == obj { return into, gvk, strictDecodingErr } if err := c.convertor.Convert(obj, into, c.decodeVersion); err != nil { return nil, gvk, err } return into, gvk, strictDecodingErr } ......} 可以看到Encode时,是先进行版本转换,然后再用encoder进行序列化,而Decode时,先用decoder进行反序列化,将字节类型的数据Decode到某一个版本的API对象中,然后再对其进行赋默认值操作,还有进行版本转换,转换到目标版本,版本转换就是调用到上面提到的 unsafeObjectConvertor,它又调用scheme中注册的各种版本转换方法进行转换了。 这个Codec虽然逻辑有点绕,但是总结来说,它做的工作就是利用Serializer + Scheme,来做序列化和版本转换的工作。 Codec使用场景我们再来看看Codec是怎么使用的,前面提到过,Codec在两个地方被用到:一个是客户端通过HTTP协议跟APIServer交互时,需要进行Codec,一个是将API对象存储到数据库时,需要进行Codec,我们来分别看下这两个场景是怎么用Codec的,简单走下代码的流程即可(Code Walk-through)。 跟数据库进行交互在 Kubernetes APIServer Storage 框架解析 这篇文章中,就介绍过API对象是怎么存储到数据库中的,其中,在为每个API资源构建etcd store时,会通过 DefaultStorageFactory 来为其构建存储配置,而Codec相关的逻辑就在这: 123456789101112131415161718192021222324252627// k8s.io/apiserver/pkg/server/storage/storage_factory.gofunc (s *DefaultStorageFactory) NewConfig(groupResource schema.GroupResource) (*storagebackend.Config, error) { chosenStorageResource := s.getStorageGroupResource(groupResource) // operate on copy storageConfig := s.StorageConfig codecConfig := StorageCodecConfig{ StorageMediaType: s.DefaultMediaType, StorageSerializer: s.DefaultSerializer, } if override, ok := s.Overrides[getAllResourcesAlias(chosenStorageResource)]; ok { override.Apply(&storageConfig, &codecConfig) } if override, ok := s.Overrides[chosenStorageResource]; ok { override.Apply(&storageConfig, &codecConfig) } codecConfig.StorageVersion, err = s.ResourceEncodingConfig.StorageEncodingFor(chosenStorageResource) codecConfig.MemoryVersion, err = s.ResourceEncodingConfig.InMemoryEncodingFor(groupResource) codecConfig.Config = storageConfig storageConfig.Codec, storageConfig.EncodeVersioner, err = s.newStorageCodecFn(codecConfig) return &storageConfig, nil} codecConfig中会保存存储序列化相关的一些配置,StorageMediaType默认为 application/vnd.kubernetes.protobuf,而StorageSerializer则为 legacyscheme.Codecs,还有StorageVersion和MemoryVersion,分别表示该资源存储到数据库时使用的版本,以及加载到内存中使用的版本,我们来看看这两个方法: 123456789101112131415161718192021222324252627// k8s.io/apiserver/pkg/server/storage/resource_encoding_config.gofunc (o *DefaultResourceEncodingConfig) StorageEncodingFor(resource schema.GroupResource) (schema.GroupVersion, error) { if !o.scheme.IsGroupRegistered(resource.Group) { return schema.GroupVersion{}, fmt.Errorf("group %q is not registered in scheme", resource.Group) } resourceOverride, resourceExists := o.resources[resource] if resourceExists { return resourceOverride.ExternalResourceEncoding, nil } // return the most preferred external version for the group return o.scheme.PrioritizedVersionsForGroup(resource.Group)[0], nil}func (o *DefaultResourceEncodingConfig) InMemoryEncodingFor(resource schema.GroupResource) (schema.GroupVersion, error) { if !o.scheme.IsGroupRegistered(resource.Group) { return schema.GroupVersion{}, fmt.Errorf("group %q is not registered in scheme", resource.Group) } resourceOverride, resourceExists := o.resources[resource] if resourceExists { return resourceOverride.InternalResourceEncoding, nil } return schema.GroupVersion{Group: resource.Group, Version: runtime.APIVersionInternal}, nil} 可以看到存储使用的版本,是通过scheme的PrioritizedVersionsForGroup()方法获得的,这个方法我们在scheme中介绍过,是获取该组中所有的版本,他们会按照优先级排序,排在第一位的是优先级最高的,一般是稳定版本是优先级最高的,这里取第0个值,即取的是版本优先级最高的版本,而内存版本则是内部版本,version为 __internal,所谓内存版本是指从数据库读出来原始的数据之后,要转换成的版本。 NewConfig()最终调用 newStorageCodecFn(codecConfig) 创建了Codec,我们来看看该方法: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263// k8s.io/apiserver/pkg/server/storage/storage_codec.gofunc NewStorageCodec(opts StorageCodecConfig) (runtime.Codec, runtime.GroupVersioner, error) { mediaType, _, err := mime.ParseMediaType(opts.StorageMediaType) if err != nil { return nil, nil, fmt.Errorf("%q is not a valid mime-type", opts.StorageMediaType) } supportedMediaTypes := opts.StorageSerializer.SupportedMediaTypes() serializer, ok := runtime.SerializerInfoForMediaType(supportedMediaTypes, mediaType) if !ok { supportedMediaTypeList := make([]string, len(supportedMediaTypes)) for i, mediaType := range supportedMediaTypes { supportedMediaTypeList[i] = mediaType.MediaType } return nil, nil, fmt.Errorf("unable to find serializer for %q, supported media types: %v", mediaType, supportedMediaTypeList) } s := serializer.Serializer // Give callers the opportunity to wrap encoders and decoders. For decoders, each returned decoder will // be passed to the recognizer so that multiple decoders are available. var encoder runtime.Encoder = s if opts.EncoderDecoratorFn != nil { encoder = opts.EncoderDecoratorFn(encoder) } decoders := []runtime.Decoder{ // selected decoder as the primary s, // universal deserializer as a fallback opts.StorageSerializer.UniversalDeserializer(), // base64-wrapped universal deserializer as a last resort. // this allows reading base64-encoded protobuf, which should only exist if etcd2+protobuf was used at some point. // data written that way could exist in etcd2, or could have been migrated to etcd3. // TODO: flag this type of data if we encounter it, require migration (read to decode, write to persist using a supported encoder), and remove in 1.8 runtime.NewBase64Serializer(nil, opts.StorageSerializer.UniversalDeserializer()), } if opts.DecoderDecoratorFn != nil { decoders = opts.DecoderDecoratorFn(decoders) } encodeVersioner := runtime.NewMultiGroupVersioner( opts.StorageVersion, schema.GroupKind{Group: opts.StorageVersion.Group}, schema.GroupKind{Group: opts.MemoryVersion.Group}, ) // Ensure the storage receives the correct version. encoder = opts.StorageSerializer.EncoderForVersion( encoder, encodeVersioner, ) decoder := opts.StorageSerializer.DecoderToVersion( recognizer.NewDecoder(decoders...), runtime.NewCoercingMultiGroupVersioner( opts.MemoryVersion, schema.GroupKind{Group: opts.MemoryVersion.Group}, schema.GroupKind{Group: opts.StorageVersion.Group}, ), ) return runtime.NewCodec(encoder, decoder), encodeVersioner, nil} 这里用到的方法基本上就都是我们前面介绍过的了,首先根据MediaType拿到对应的Serializer,然后创建了GroupVersioner目标版本,目标版本分别是codecConfig中的数据库存储版本和内存版本,然后通过StorageSerializer,即Codecs,即CodecFactory,使用EncoderForVersion(), DecoderToVersion()工厂方法,构建出带版本转换的encoder和decoder,最后这两者再组装成一个新的Codec返回: 1234567891011// k8s.io/apimachinery/pkg/runtime/codec.gotype codec struct { Encoder Decoder}// NewCodec creates a Codec from an Encoder and Decoder.func NewCodec(e Encoder, d Decoder) Codec { return codec{e, d}} 这个codec就是简单的封装,只是为了对外提供统一的接口而已,它就是最终etcd store对API对象进行数据库存储和读取时,使用到的Codec了,例如下例中的codec便是这里NewCodec()创建出来的: 123456789// k8s.io/apiserver/pkg/storage/etcd3/store.gofunc decode(codec runtime.Codec, versioner storage.Versioner, value []byte, objPtr runtime.Object, rev int64) error { if _, err := conversion.EnforcePtr(objPtr); err != nil { return fmt.Errorf("unable to convert output object to pointer: %v", err) } _, _, err := codec.Decode(value, nil, objPtr) ......} 跟客户端进行交互以GET某个API对象为例,在install GET请求的Handler时,构建了一个reqScope,它里面包含了跟序列化相关的变量: 12345678910111213141516171819202122232425262728// k8s.io/apiserver/pkg/endpoints/installer.gofunc (a *APIInstaller) registerResourceHandlers(path string, storage rest.Storage, ws *restful.WebService) (*metav1.APIResource, *storageversion.ResourceInfo, error) { ...... fqKindToRegister, err := GetResourceKind(a.group.GroupVersion, storage, a.group.Typer) ...... reqScope := handlers.RequestScope{ Serializer: a.group.Serializer, // localscheme.Codecs ParameterCodec: a.group.ParameterCodec, Creater: a.group.Creater, // scheme Convertor: a.group.Convertor, // scheme Defaulter: a.group.Defaulter, // scheme Typer: a.group.Typer, // scheme UnsafeConvertor: a.group.UnsafeConvertor, // wrapper of scheme Authorizer: a.group.Authorizer, ...... Kind: fqKindToRegister, ...... } ...... switch action.Verb { case "GET": var handler restful.RouteFunction handler = restfulGetResource(getter, reqScope) ...... } ......} fqKindToRegister, Kind为该API资源所对应的GVK,是scheme根据REST storage从注册的类型中识别出来的,reqScope中的 Serializer 实际上是 localscheme.Codecs,Creater, Convertor, Defaulter, Typeer, UnsafeConvertor实际上都指向的是全局的scheme,最终,构造了GET Handler的入口函数: 123456789101112131415// k8s.io/apiserver/pkg/endpoints/handlers/get.gofunc getResourceHandler(scope *RequestScope, getter getterFunc) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { ...... namespace, name, err := scope.Namer.Name(req) ...... outputMediaType, _, err := negotiation.NegotiateOutputMediaType(req, scope.Serializer, scope) ...... result, err := getter(ctx, name, req) ...... transformResponseObject(ctx, scope, req, w, http.StatusOK, outputMediaType, result) }} getter()是从数据库中获取到对应name的API对象,并且进行了版本转换,转换成了内部版本,然后在 transformResponseObject() 方法中,又会根据outPutMediaType,以及scope中的Kind, Serializer等,将其转换为目标版本: 1234567// vendor/k8s.io/apiserver/pkg/endpoints/handlers/response.gofunc transformResponseObject(ctx context.Context, scope *RequestScope, req *http.Request, w http.ResponseWriter, statusCode int, mediaType negotiation.MediaTypeOptions, result runtime.Object) { ...... kind, serializer, _ := targetEncodingForTransform(scope, mediaType, req) responsewriters.WriteObjectNegotiated(serializer, scope, kind.GroupVersion(), w, req, statusCode, obj, false)} kind即为目标GVK,serializer即为codecs,实际来自scope中的Serializer。 12345678910111213141516171819202122// k8s.io/apiserver/pkg/endpoints/handlers/responsewriters/writers.gofunc WriteObjectNegotiated(s runtime.NegotiatedSerializer, restrictions negotiation.EndpointRestrictions, gv schema.GroupVersion, w http.ResponseWriter, req *http.Request, statusCode int, object runtime.Object, listGVKInContentType bool) { ...... mediaType, serializer, err := negotiation.NegotiateOutputMediaType(req, s, restrictions) encoder := s.EncoderForVersion(serializer.Serializer, gv) request.TrackSerializeResponseObjectLatency(req.Context(), func() { if listGVKInContentType { SerializeObject(generateMediaTypeWithGVK(serializer.MediaType, mediaType.Convert), encoder, w, req, statusCode, object) } else { SerializeObject(serializer.MediaType, encoder, w, req, statusCode, object) } })}func SerializeObject(mediaType string, encoder runtime.Encoder, hw http.ResponseWriter, req *http.Request, statusCode int, object runtime.Object) { ...... err := encoder.Encode(object, w) ......} encoder := s.EncoderForVersion() 则是调用了CodecFactory的工厂方法,创建了一个versioning.codec,然后调用 encoder.Encode() 进行序列化以及版本转换,然后将结果输出到HTTP ResponseWriter中,返回给客户端。 总结Codec承担着序列化的工作,除了做序列化,还承担着调用scheme的逻辑进行版本转换的工作,所以Codec其实也是实现API多版本的重要机制,跟Scheme可以说是相辅相成。本篇文章分析了Codec的实现原理,本质上Codec是一个工厂方法类,它会为各个API资源进行版本转换和序列化创建一个实例,然后会用在两个场景上,一个是通过HTTP协议跟客户端进行交互,一个是跟数据库进行交互,同时本篇文章也对Codec在这两个场景的相关代码进行了简单的梳理。 如果想更好的理解Codec在这两个场景的作用,就需要了解APIServer的框架以及存储框架,推荐阅读下列文章: 在Kubernetes APIServer 机制概述中我们介绍到了APIServer的本质其实是一个实现了RESTful API的WebServer,它使用golang的net/http的Server构建,并且Handler是其中非常重要的概念,此外,又简单介绍了APIServer的扩展机制,即Aggregator, APIExtensions以及KubeAPIServer这三者之间通过Delegation的方式实现了扩展。 在Kubernetes APIServer Storage 框架解析中,我们介绍了APIServer相关的存储框架,每个API对象,都有对应的REST store以及etcd store,它们是如何存储进数据库的。 在Kubernetes APIServer GenericAPIServer中介绍了GenericAPIServer的作用,以及它的Handler是如何构建,API对象是如何以APIGroupInfo的形式注册进Handler中的,以及PostStartHook的机制。 在Kubernetes APIServer API Resource Installation中,介绍了KubeAPIServer, Aggregator, APIExtensions中的API对象资源是如何构建成REST Store,并且组织成APIGroupInfo,然后注册进GenericAPIServer中的,然后又盘点了下当前版本的Kubernetes中都有哪些API对象资源。","link":"/2023/11/12/kubernetes/kube-versioning-codec.html"},{"title":"Kubernetes API Scheme 解析","text":"概述在 Kubernetes API 多版本和序列化 这篇文章中,介绍了API多版本的功能和实现原理,其中Scheme就是其实现原理的一项重要机制,在平时的开发中也经常会遇到,本篇文章就对其进行下分析。 Scheme起到了一个类型(Type)注册中心的作用,在API Server内部,全局只有一个Scheme实例,各个版本的API资源,会将他们的类型,注册到Scheme中来,同时,也会将如何进行类型转换的方法注册到Scheme中来,后续在Handler中进行版本转换以及序列化时,则会使用Scheme中注册的类型创建对应版本的对象,以及使用注册的类型转换的方法对不同版本的对象进行转换。 什么是类型所以,理解什么是类型,即Type,很关键,我觉得可以简单的将类型理解为一个Go Struct的定义,就是各种API资源的结构体定义,可以从这个类型直接创建出来该结构体的实例,而不用直接使用该结构体去创建,这到底是怎么实现的呢?答案就是反射,即Reflect。 关于反射,这里不过多解释,建议提前阅读下官方的这篇博客,The Laws of Reflection,比较清晰。这里我们就举个简单的小例子来实际感受下: 123456789101112131415161718192021222324252627282930313233343536// 目录结构.├── go.mod├── main.go├── types.go// types.gopackage maintype Foo struct { X1 string X2 string}// main.gopackage mainimport ( "fmt" "reflect")func main() { f := &Foo{} t := reflect.TypeOf(f).Elem() fmt.Println(t) // main.Foo fmt.Println(t.Name()) // Foo v, _ := reflect.New(t).Interface().(Foo) v.X1 = "nice" v.X2 = "woce" fmt.Println(v) //{nice woce} fv := Foo{X1: "nice", X2: "woce"} fmt.Println(fv) //{nice woce}} 可以看到在types.go中定义了一个Foo结构体,有两个属性X1和X2,然后在main方法中,先创建了一个空的Foo实例,将其指针赋值给 f,然后通过 reflect.TypeOf(f).Elem() 得到的值 t 就是Foo结构体的 类型,有了这个类型,就可以通过 reflect.New(t).Interface() 创建一个该类型的实例,但是这得到的只是一个interface类型的实例,还需要将其转换成具体的Foo类型的实例才能使用,这样就相当于创建了一个Foo结构体的实例 v,跟下面的 fv 直接使用Foo结构体创建的实例其实是等价的。 所以,反射其实还是挺好理解的,就是给一个变量,能够通过反射,知道该变量的类型以及具体的值,很多语言里都有反射的机制,像最熟悉的Python,可以通过 getattr()、setattr()方法去获取、设置某个变量的属性,还有通过 __import__() 方法动态的根据一个字符串路径去导入一个模块。 某种程度上,Go反射里面的 类型 Type 其实就跟Python里的 __import__() 有异曲同工之处,知道了一个字符串,就可以导入一个模块,知道了一个类型,就可以去实例化一个它的对象,所以Scheme就是这样一个类型注册中心,把所有的API资源结构体的类型全注册进来,需要时,就找到对应资源的类型,然后实例化一个它的对象。 我们再来把上面的例子稍微扩展一下,用简单的例子模拟下Scheme中的用法: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105// 目录结构.├── go.mod├── main.go├── meta│ └── types.go├── types.go├── v1│ └── types.go└── v2 └── types.go// meta/types.gopackage metatype Status struct { X1 string}// types.gopackage mainimport ( "testgo/meta")type Foo struct { X1 string X2 string Status meta.Status}// v1/types.gopackage v1import ( "testgo/meta")type Foo struct { X1 string Status meta.Status}// v2/types.gopackage v2import ( "testgo/meta")type Foo struct { X1 string X2 string Status meta.Status}// main.gopackage mainimport ( "fmt" "reflect" "testgo/v1" "testgo/v2" "testgo/meta")func main() { f := &Foo{} t := reflect.TypeOf(f).Elem() fmt.Println(t) // main.Foo fmt.Println(t.Name()) // Foo v, _ := reflect.New(t).Interface().(Foo) v.X1 = "nice" v.X2 = "woce" v.Status = meta.Status{X1: "tace"} fmt.Println(v) //{nice woce {tace}} fv := Foo{X1: "nice", X2: "woce", Status: meta.Status{X1: "tace"}} fmt.Println(fv) //{nice woce {tace}} f1 := &v1.Foo{} t1 := reflect.TypeOf(f1).Elem() fmt.Println(t1) // v1.Foo fmt.Println(t1.Name()) // Foo f2 := &v2.Foo{} t2 := reflect.TypeOf(f2).Elem() fmt.Println(t2) // v2.Foo fmt.Println(t2.Name()) // Foo fmt.Println(t1 == t2) // false s1 := &meta.Status{} s2 := &meta.Status{} t3 := reflect.TypeOf(s1).Elem() t4 := reflect.TypeOf(s2).Elem() fmt.Println(t3 == t4) // true} 上例中,在原来的基础上,又添加了一个meta.Status结构体,并且添加了v1, v2版本的Foo,而且给每个版本的Foo都加了一个meta.Status属性,然后分别获得了他们的类型:t, t1, t2, t3, t4,从上面的 t1 == t2 为 false,可以判断t1和t2是两个不同的类型,虽然他们都叫Foo,而 t3 == t4 为true,说明他们是同一个类型,虽然是从两个对象上获取的类型,所以本质上,每一个结构体的定义,就对应着一个类型,不论这个结构体定义在哪里,只要我们知道了它的类型,就能够实例化它。 而在Kubernetes中,类型一般是比较复杂的,一个API资源类型会定义很多个字段,而且类型是分版本的,而版本又分内部版本和外部版本,所以这个类型就是多版本API的基础。回过头来看看上一个小节提到的 FlowScheme 示例,k8s.io/api/flowcontrol/v1beta2/types.go 和 k8s.io/api/flowcontrol/v1beta3/types.go 中定义的Struct就是外部版本的类型,并且从上面的分析可以知道,v1beta2 中的 FlowSchema 和 v1beta3 中的 FlowSchema 其实是两个类型,属于不同的版本,虽然他们的名字一样,但是他们里面的属性可能会有差别,而且他们是定义在单独的第三方库 k8s.io/api 中的,可以独立发布,方便客户端进行引用,而 kubernetes/pkg/apis/flowcontrol/types.go 中定义的Struct则是内部版本的类型,因为只在Kubernetes内部使用到,所以放到了Kubernetes代码目录树内,是Kubernetes本身的一部分。在Kubernetes中,所有的内部版本的类型,都放到了 kubernetes/pkg/apis/ 目录下,而所有的外部版本的类型,都放到了 k8s.io/api 项目中,然后都以组的方式进行分类管理。 理解了类型,我们就比较好理解Scheme了,是时候祭出Scheme的类图,来近距离看看它了: 它的核心代码位于 k8s.io/apimachinery/pkg/runtime/scheme.go 中,k8s.io/apimachinery 也是一个第三方库,跟 k8s.io/api 类似,都是为了方便客户端开发引用,所以才从Kubernetes主代码树里剥离出来的,可以看到Scheme还是一个比较复杂的结构体,属性虽然不多,但是方法很多,而且实现了很多接口,我们来介绍几个比较主要的内容,先忽略一些不重要的信息,否则内容太多。 类型注册首先最最重要的就是 gvkToType 和 typeToGVK 这两个map了,他们就是存放注册进来的类型的,通过下面的 AddKnownTypes() 和 AddKnownTypeWithName()方法注册进来,在该方法中,就调用了上面示例中提到的 reflect.TypeOf(f).Elem() 方法去获取一个对象的类型,我们先来看看这个方法: 12345678910111213141516171819202122232425262728293031323334func (s *Scheme) AddKnownTypes(gv schema.GroupVersion, types ...Object) { s.addObservedVersion(gv) for _, obj := range types { t := reflect.TypeOf(obj) if t.Kind() != reflect.Pointer { panic("All types must be pointers to structs.") } t = t.Elem() s.AddKnownTypeWithName(gv.WithKind(t.Name()), obj) }}func (s *Scheme) AddKnownTypeWithName(gvk schema.GroupVersionKind, obj Object) { ...... t := reflect.TypeOf(obj) ...... if t.Kind() != reflect.Pointer { panic("All types must be pointers to structs.") } t = t.Elem() if t.Kind() != reflect.Struct { panic("All types must be pointers to structs.") } ...... s.gvkToType[gvk] = t for _, existingGvk := range s.typeToGVK[t] { if existingGvk == gvk { return } } s.typeToGVK[t] = append(s.typeToGVK[t], gvk) ......} 可以看到从obj中解析出该对象的Type(类型)之后,会将Type与GVK的对应关系分别存到 gvkToType 和 typeToGVK 两个map中,gvkToType是 GroupVersionKind 到 reflect.Type 的映射,即给出一个GVK,那就能找到它对应的类型,而且是有且仅有一个类型与GVK相对,比如GVK为 GroupVersionKind{Group: "flowcontrol.apiserver.k8s.io", Version: "v1beta2", Kind: "FlowSchema"},那它对应到的类型(Type)就是定义在 k8s.io/api/flowcontrol/v1beta2/types.go 中的 FlowSchema 结构体,而 typeToGVK 则正好反过来,是类型到GVK的映射,但是这个不一样的是GVK是一个列表,即一个类型(Type)可能对应多个GVK,这个该怎么理解呢?其实这个的意思是,一个类型可能被多个GVK引用,比如一些公用的类型,像WatchEvent, ListOptions等,所以,GVK和Type是这样一个对应关系: 根据某个GVK能找到唯一的一个Type,但是根据Type找GVK,可能会有多个GVK的情况,这种一般都是公共的元数据的资源类型,其他的API资源类型基本上都是一对一的关系。 与之相关的,是下面两个方法: 12345678910func (s *Scheme) ObjectKinds(obj Object) ([]schema.GroupVersionKind, bool, error) { ...... v, err := conversion.EnforcePtr(obj) ...... t := v.Type() ...... gvks, ok := s.typeToGVK[t] ...... return gvks, unversionedType, nil} ObjectKinds()方法是根据一个对象的类型去 typeToGVK 中找它对应的GVK,返回的是一个GVK列表。 123456func (s *Scheme) New(kind schema.GroupVersionKind) (Object, error) { if t, exists := s.gvkToType[kind]; exists { return reflect.New(t).Interface().(Object), nil } ......} New()方法则是根据一个GVK去 gvkToType 中找到它对应的Type,然后通过 reflect.New() 方法去实例化一个它的对象。 所以各个版本的API资源,都会将自己的GVK和Type通过 AddKnownTypes() 注册到Scheme中,后续可以通过 ObjectKinds() 、New() 等方法去使用它们。我们还是以 FlowSchema 为例,来看看各个API资源是怎么注册其类型的: 1234567891011121314151617181920212223242526// k8s.io/api/flowcontrol/v1beta2/register.go// GroupName is the name of api groupconst GroupName = "flowcontrol.apiserver.k8s.io"// SchemeGroupVersion is group version used to register these objectsvar SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: "v1beta2"}var ( // SchemeBuilder installs the api group to a scheme SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) // AddToScheme adds api to a scheme AddToScheme = SchemeBuilder.AddToScheme)// Adds the list of known types to the given scheme.func addKnownTypes(scheme *runtime.Scheme) error { scheme.AddKnownTypes(SchemeGroupVersion, &FlowSchema{}, &FlowSchemaList{}, &PriorityLevelConfiguration{}, &PriorityLevelConfigurationList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil} 主要关注下 addKnownTypes()方法即可,注意它的参数,是一个指针类型的 scheme,前面我们讲过,APIServer全局只有一个Scheme,这里即引用的全局的Scheme实例的指针,将本版本的API资源类型注册到Scheme中。这里展示的v1beta2版本的,v1beta3还有内部版本,都是类似的,他们的对应目录下都有一个 register.go 用来向Scheme中注册本版本的API资源类型。 类型转换方法注册如前所述,Scheme还有一个重要功能,就是可以将不同版本的API对象进行互相转换,这个转换是在 内部版本 和 外部版本 之间进行的,所以各个API资源都将外部版本的API资源类型如何跟内部版本类型进行转换的方法注册到Scheme中,即上面类图中的converer *convertion.Converter属性, 在Converter内部维护了一个map,key是以[source, dest]为组合的一对儿relect.Type,value则是类型转换方法,即给定了一对儿类型,就可以找到一个怎么从源类型转换到目的类型的方法。 Scheme提供了以下两个方法进行类型转换方法的注册: 1234567func (s *Scheme) AddConversionFunc(a, b interface{}, fn conversion.ConversionFunc) error { return s.converter.RegisterUntypedConversionFunc(a, b, fn)}func (s *Scheme) AddGeneratedConversionFunc(a, b interface{}, fn conversion.ConversionFunc) error { return s.converter.RegisterGeneratedUntypedConversionFunc(a, b, fn)} 然后提供了 Convert()、ConvertToVersion()、UnsafeConvertToVersion()等方法调用注册进来的类型转换方法对某一对儿特定的类型进行转换。那问题来了,这类型到底是怎么转换的呢?我们还是来看个示例,还是以 FlowSchema 为例,来看看它的类型转换方法: 12345678910111213// kubernetes/pkg/apis/flowcontrol/v1beta2/zz_generated.conversion.gofunc RegisterConversions(s *runtime.Scheme) error { ...... if err := s.AddGeneratedConversionFunc((*v1beta2.FlowSchema)(nil), (*flowcontrol.FlowSchema)(nil), func(a, b interface{}, scope conversion.Scope) error { return Convert_v1beta2_FlowSchema_To_flowcontrol_FlowSchema(a.(*v1beta2.FlowSchema), b.(*flowcontrol.FlowSchema), scope) }) ...... if err := s.AddGeneratedConversionFunc((*flowcontrol.FlowSchema)(nil), (*v1beta2.FlowSchema)(nil), func(a, b interface{}, scope conversion.Scope) error { return Convert_flowcontrol_FlowSchema_To_v1beta2_FlowSchema(a.(*flowcontrol.FlowSchema), b.(*v1beta2.FlowSchema), scope) }) ......} 可以看到这里也是引用的Scheme的指针,通过调用scheme的 AddGeneratedConversionFunc() 方法,注册了两个类型转换方法,即 v1beta2.FlowSchema 这个外部版本的类型与 flowcontrol.FlowSchema 这个内部版本的类型之间的互相转换,而跟踪到最后,发现其实这个类型转换方法就是很简单的两个对象之间属性的赋值,就是把源类型对象的属性值取出来,赋值给目的类型对象的对应属性: 12345678910111213141516171819202122232425262728293031// kubernetes/pkg/apis/flowcontrol/v1beta2/zz_generated.conversion.gofunc autoConvert_v1beta2_FlowSchema_To_flowcontrol_FlowSchema(in *v1beta2.FlowSchema, out *flowcontrol.FlowSchema, s conversion.Scope) error { out.ObjectMeta = in.ObjectMeta if err := Convert_v1beta2_FlowSchemaSpec_To_flowcontrol_FlowSchemaSpec(&in.Spec, &out.Spec, s); err != nil { return err } if err := Convert_v1beta2_FlowSchemaStatus_To_flowcontrol_FlowSchemaStatus(&in.Status, &out.Status, s); err != nil { return err } return nil}......func autoConvert_v1beta2_FlowSchemaSpec_To_flowcontrol_FlowSchemaSpec(in *v1beta2.FlowSchemaSpec, out *flowcontrol.FlowSchemaSpec, s conversion.Scope) error { if err := Convert_v1beta2_PriorityLevelConfigurationReference_To_flowcontrol_PriorityLevelConfigurationReference(&in.PriorityLevelConfiguration, &out.PriorityLevelConfiguration, s); err != nil { return err } out.MatchingPrecedence = in.MatchingPrecedence out.DistinguisherMethod = (*flowcontrol.FlowDistinguisherMethod)(unsafe.Pointer(in.DistinguisherMethod)) out.Rules = *(*[]flowcontrol.PolicyRulesWithSubjects)(unsafe.Pointer(&in.Rules)) return nil}......func autoConvert_v1beta2_FlowSchemaStatus_To_flowcontrol_FlowSchemaStatus(in *v1beta2.FlowSchemaStatus, out *flowcontrol.FlowSchemaStatus, s conversion.Scope) error { out.Conditions = *(*[]flowcontrol.FlowSchemaCondition)(unsafe.Pointer(&in.Conditions)) return nil} 这些转换方法都位于 zz_generated.conversion.go 这个文件中,这个文件及其内容都是根据types.go中的类型定义自动生成的,因为这种类型转换的逻辑很简单,但是代码量又大,完全可以让它自动生成。但是如前文所说,现在Kubernetes的API都趋于稳定了,beta版和稳定版之间几乎没有差异,所以外部版本跟内部版本之间的转换就是很简单的属性赋值,但是如果内外版本的属性有不一致的情况,在转换时还是要特殊处理下的,可能会忽略掉某些属性,或者是把某些属性放到别的字段去,这种特殊的情况,就需要开发者来特别指定,而不能自动生成了,比如跟 FlowSchema 在同一个组中的 LimitedPriorityLevelConfiguration 资源就有这种情况: 123456789// k8s.io/api/flowcontrol/v1beta2/types.gotype LimitedPriorityLevelConfiguration struct { AssuredConcurrencyShares int32 `json:"assuredConcurrencyShares" protobuf:"varint,1,opt,name=assuredConcurrencyShares"` LimitResponse LimitResponse `json:"limitResponse,omitempty" protobuf:"bytes,2,opt,name=limitResponse"` ......} 1234567// k8s.io/api/flowcontrol/v1beta3/types.gotype LimitedPriorityLevelConfiguration struct { NominalConcurrencyShares int32 `json:"nominalConcurrencyShares" protobuf:"varint,1,opt,name=nominalConcurrencyShares"` LimitResponse LimitResponse `json:"limitResponse,omitempty" protobuf:"bytes,2,opt,name=limitResponse"` ......} v1beta2和v1beta3的字段名发生了改变,从v1beta2中的 AssuredConcurrencyShares 改成了 v1beta3中的 NominalConcurrencyShares,那这种情况,内部版本是什么样的呢? 123456// kubernets/pkg/apis/flowcontrol/types.gotype LimitedPriorityLevelConfiguration struct { NominalConcurrencyShares int32 LimitResponse LimitResponse} 可以看到内部版本,其实是跟v1beta3版本的字段保持一致的,即是跟最新版本的类型保持一致的。那这种情况的类型该怎么转换呢? 1234567891011121314151617181920// kubernetes/pkg/apis/flowcontrol/v1beta2/conversion.gofunc Convert_v1beta2_LimitedPriorityLevelConfiguration_To_flowcontrol_LimitedPriorityLevelConfiguration(in *v1beta2.LimitedPriorityLevelConfiguration, out *flowcontrol.LimitedPriorityLevelConfiguration, s conversion.Scope) error { if err := autoConvert_v1beta2_LimitedPriorityLevelConfiguration_To_flowcontrol_LimitedPriorityLevelConfiguration(in, out, nil); err != nil { return err } out.NominalConcurrencyShares = in.AssuredConcurrencyShares return nil}func Convert_flowcontrol_LimitedPriorityLevelConfiguration_To_v1beta2_LimitedPriorityLevelConfiguration(in *flowcontrol.LimitedPriorityLevelConfiguration, out *v1beta2.LimitedPriorityLevelConfiguration, s conversion.Scope) error { if err := autoConvert_flowcontrol_LimitedPriorityLevelConfiguration_To_v1beta2_LimitedPriorityLevelConfiguration(in, out, nil); err != nil { return err } out.AssuredConcurrencyShares = in.NominalConcurrencyShares return nil} 可以看到在v1beta2目录中,单独定义了一个conversion.go,它定义了两个方法指定了内部版本和外部版本进行转换时,这两个属性该怎么去处理,就是简单的把两个值互相赋值下,而这两个方法又会被 zz_generated.conversion.go 中的转换方法所引用。而v1beta3的外部版本跟内部版本字段是一样的,所以是不需要额外做转换的工作的,所以可以看到v1beta3目录中,并没有convertion.go文件。 版本优先级注册Scheme中还有一个比较重要的点,就是版本优先级,一个组中可能会有很多个版本,开发者期望用户使用什么版本,以及期望某个API对象存储到数据库时,使用哪个版本的数据结构,都是通过这个版本优先级来确定的。在Scheme中,versionPriority 这个map就是用来存储某个组的版本优先级的,可以看到value是一个[]string,即某个组有几个版本都以字符串的形式存放到这个value中,而且优先级越高的,越在前面,即排在第一位的,就是版本优先级最高的。 比如 flowcontrol API组就通过scheme的 SetVersionPriority() 方法注册进去 v1beta3, v1beta2, v1beta1, v1alpha1 四个版本,而排在第一位的v1beta3是优先级最高的: 1234// kubernetes/pkg/apis/flowcontrol/install/install.goscheme.SetVersionPriority(flowcontrolv1beta3.SchemeGroupVersion, flowcontrolv1beta2.SchemeGroupVersion, flowcontrolv1beta1.SchemeGroupVersion, flowcontrolv1alpha1.SchemeGroupVersion) 然后可以通过 PrioritizedVersionsForGroup() 方法去获取某个组的所有版本优先级,比如在API自动发现时,当用户请求某个组的根路径时,会返回该组支持的所有版本,并且有个 preferredVersion 字段,告诉用户建议使用哪个版本,如下例: 1234567891011121314151617181920# curl http://127.0.0.1:8001/apis/flowcontrol.apiserver.k8s.io/{ "kind": "APIGroup", "apiVersion": "v1", "name": "flowcontrol.apiserver.k8s.io", "versions": [ { "groupVersion": "flowcontrol.apiserver.k8s.io/v1beta3", "version": "v1beta3" }, { "groupVersion": "flowcontrol.apiserver.k8s.io/v1beta2", "version": "v1beta2" } ], "preferredVersion": { "groupVersion": "flowcontrol.apiserver.k8s.io/v1beta3", "version": "v1beta3" }} 这里的 perferredVersion 显示为 v1beta3,就是由上面设置的版本优先级来决定的。此外,还有当存储某个对象时,需要获取到该类资源所在组的最高优先级的版本,去存储该版本的数据结构,也是通过 PrioritizedVersionsForGroup() 这个方法来获取的: 123456789101112131415// k8s.io/apiserver/pkg/server/storage/resource_encoding_config.gofunc (o *DefaultResourceEncodingConfig) StorageEncodingFor(resource schema.GroupResource) (schema.GroupVersion, error) { if !o.scheme.IsGroupRegistered(resource.Group) { return schema.GroupVersion{}, fmt.Errorf("group %q is not registered in scheme", resource.Group) } resourceOverride, resourceExists := o.resources[resource] if resourceExists { return resourceOverride.ExternalResourceEncoding, nil } // return the most preferred external version for the group return o.scheme.PrioritizedVersionsForGroup(resource.Group)[0], nil} OK,以上就是Scheme的核心内容了,基本上Scheme实现的几个接口:ObjectTyper, ObjectCreater, ObjectConvertor,我们都有介绍过了,还有一个 ObjectDefaulter 是用来设置默认值的,此处不太重要,略去不提。 最后,我们还是以 FlowControl 为例,结合它的代码目录树结构,来整体回顾下: // kubernetes/pkg/apis/flowcontrol // k8s.io/api/flowcontrol 曾经有很长一段时间lost在这个代码目录中,看着这些版本还有代码,不知道他们是干什么的,为什么有的在这,有的在那?为什么会有一些zz_开头的文件?为什么 types.go 在好几个地方都定义了?现在终于搞清楚了,我们就结合这个目录树的结构,来对上面介绍的Scheme内容进行一次简单的回顾总结: 首先就是API资源类型有多版本的,而且分内部版本和外部版本的,外部版本定义在 k8s.io/api 这个第三方库中,而内部版本定义在 kubernetes/pkg/apis 本身的代码目录树中; 每个版本中都有一个 types.go 文件,它定义了各个版的API资源类型,需要注意内部版本的类型是直接位于flowcontrol/目录下的,并没有一个 internal/ 这样一个目录结构; 跟 types.go 在一起的,还有个 register.go,就是用来向Scheme中注册本版本的资源类型的; zz_generated.deepcopy.go中定义了内部版本的API资源类型的深拷贝方法,即安全的拷贝一个对象,在进行类型转换等地方会用到; 在 kubernetes/pkg/apis/flowcontrol/ 目录下除了有内部版本的类型定义之外,还分了很多版本目录,里面定义了各个版本跟内部版本如何进行转换的方法以及本版本的默认值方法,以 kubernetes/pkg/apis/flowcontrol/v1beta2 目录下文件为例,介绍下各个文件的作用: zz_generated.conversion.go 是根据types.go自动生成的内部版本与本版本的类型的转换方法,这里面还包含了向 scheme 中注册类型转换方法的入口; conversion.go 是针对特殊的字段由开发者编写的类型转换方法; zz_generated.defaults.go 是自动生成的默认值方法; defaults.go 是针对特殊字段单独设置的默认值方法; register.go 是用来向scheme中注册默认值方法的; 再来以 k8s.io/api/flowcontrol/v1beta2/ 目录下的文件为例,介绍下各个文件的作用: types.go 定义了外部版本的API资源类型; register.go 是向scheme中注册本版本的API资源类型; generated.proto 是根据类型自动生成的 protobuf 的定义文件; generated.pb.go 则是根据 generated.proto 定义文件自动生成的对应的go代码,当客户端跟kubernetes api走gRPC通信时,就使用protobuf格式的数据,就会用到这里的代码; zz_generated.deepcopy.go 则是定义的本版本的API资源类型的深拷贝方法; 在 kubernetes/pkg/apis/flowcontrol/install 目录下还有个 install.go 文件,它里面就是类型注册,以及版本优先级注册的入口: 1234567891011121314func init() { Install(legacyscheme.Scheme)}// Install registers the API group and adds types to a schemefunc Install(scheme *runtime.Scheme) { utilruntime.Must(flowcontrol.AddToScheme(scheme)) utilruntime.Must(flowcontrolv1alpha1.AddToScheme(scheme)) utilruntime.Must(flowcontrolv1beta1.AddToScheme(scheme)) utilruntime.Must(flowcontrolv1beta2.AddToScheme(scheme)) utilruntime.Must(flowcontrolv1beta3.AddToScheme(scheme)) utilruntime.Must(scheme.SetVersionPriority(flowcontrolv1beta3.SchemeGroupVersion, flowcontrolv1beta2.SchemeGroupVersion, flowcontrolv1beta1.SchemeGroupVersion, flowcontrolv1alpha1.SchemeGroupVersion))} 通过 init() 方法,即在启动时,就会向scheme中去注册各种版本的API资源类型,以及设置版本优先级。 OK,说了这么多,那Scheme到底在哪呢?前面说的都是引用它的指针,最后有请我们的主角隆重登场: 1234567891011121314151617// kubernetes/pkg/api/legacyscheme/scheme.govar ( // Scheme is the default instance of runtime.Scheme to which types in the Kubernetes API are already registered. // NOTE: If you are copying this file to start a new api group, STOP! Copy the // extensions group instead. This Scheme is special and should appear ONLY in // the api group, unless you really know what you're doing. // TODO(lavalamp): make the above error impossible. Scheme = runtime.NewScheme() // Codecs provides access to encoding and decoding for the scheme Codecs = serializer.NewCodecFactory(Scheme) // ParameterCodec handles versioning of objects that are converted to query parameters. ParameterCodec = runtime.NewParameterCodec(Scheme)) 总结本篇文章从源码角度介绍了下Scheme的功能作用以及实现机制,由于Scheme比较抽象,想解释比较抽象的东西,最好的办法就是通过举例去解释它,所以本篇文章通过举例的方式,介绍了什么是类型,类型是如何注册的,类型转换方法是如何注册的,以及版本优先级的注册,基本上把Scheme最核心的功能分析了下,然后结合分析,介绍了下在开发中经常遇到的各个文件的作用。 通过这些系列分析文章,我觉得Kubernetes的代码写的还是相当不错的,尤其是真的做到了 Do not repeat your self,基本上把所有共性的逻辑都抽象出来作为公共逻辑,每个API资源,只需要实现自己相关的代码就可以了,因此,开发一个新的API变得比较简单,不需要你去实现数据库的增删查改逻辑,也不需要去关心如何进行序列化,也不用关心如何向APIServer中注册Handler,只需要定义好各个版本的数据结构,即Kubernetes中所说的类型(Type),以及各个版本跟内部版本之间如何进行转换的逻辑,然后创建好该API相关的REST Store,再用工具自动生成一些必要的代码,最终注册到相应的地方即可,相比很多应用开发一个新的API,需要从前到后,添加很多耦合代码来说,Kubernetes做的真的不错。 当然了,这种抽象,带来的一个问题,就是复杂性增加了好几个维度,尤其是Golang的,这种看似无面向对象实际又有面向对象的机制,你定义了一个接口,没法直观的判断谁实现了这些接口,也没法直观的看出来一个结构体实现了哪些接口,不像Java,C++,Python这种面向对象的语言那样清晰,Golang比较隐晦。","link":"/2023/11/11/kubernetes/kube-versioning-scheme.html"},{"title":"Kubernetes API 多版本和序列化","text":"前言三年前在分析Kubernete APIServer时,就经常遇到两个东西,一个是Scheme,一个是Codec,当时对它们并不是很理解,也没有去细究,但是后来越来越多的能够遇见它们,尤其是在做Kubernetes API相关的开发时,Scheme的出镜率很高,于是查了下资料才知道,原来他们跟Kubernetes的API多版本和序列化有关,而API多版本又属于Kubernetes API的重要特性,它跟一般应用的多版本API还不太一样,有它自己的特色,因此搞懂它的相关概念和实现原理是相当有必要的。从前面介绍apiserver的系列文章上也可以看到,因为Kubernetes要处理很多种资源,可以说是包罗万象,所以它做了很多的抽象,而API多版本则是在这些抽象之上再度抽象的艺术,因此还是比较难以理解的,但是这些设计都是随着时间的推移逐渐演进出来的,有它的合理性,甚至当理解了它的机制之后,会发现它的设计之美,仿佛是一件艺术品。毕加索说,艺术是揭示真理的谎言,就让我们拨开API多版本的层层迷雾,去探寻下它的本质。 功能介绍Kubernetes API多版本这个特性跟很多其他应用的多版本API很不一样,首先就是它有分组的概念,即Group,因为Kubernetes有很多的资源,不太好统一管理,所以采取分而治之的方式,以组的方式去管理,而它的多版本是跟组关联的,即一个组可以同时有多个版本,即Version,而组内的资源种类,即Kind,通过多版本的方式去迭代进化,这三者在Kubernetes中经常合起来表示某个版本的某种资源,即 GroupVersionKind,简称 GVK;其次就是多版本之间的资源对象可以互相转换,这个是什么意思呢?即底层数据是同一份数据,但是根据调用API的版本不同,可以转换成对应版本的资源对象,比如有一个资源它现在有三个版本的API同时存在:v1, v1beta1, v1beta2,你通过调用v1beta1版本的API,创建了一个该对象,那你通过v1, v1beta1, v1beta2的API,均可以将该对象读出来,或者做其他的操作,那这个特性有什么用呢?谁会去把v1beta1版本的对象转成v1版本来用呢?这是我看到这个特性时,脑海中的第一个问题,通过阅读官方文档可以知道,其实这个特性完全是为了保持API的兼容而设计的,正常情况下,没有人会混着版本去用,它发挥作用的地方主要在升级迭代时,要知道随着API的迭代开发,API会逐渐GA进入到稳定版本,那beta版,以及alpha版则会在某一个阶段被移除,这时候,你用beta版API创建的资源对象,仍然能够用稳定版API来操作,这样就实现了无缝升级。 我们来举个例子体验下,在1.28版本的Kubernetes中,有一个用来做流控的API,叫FlowSchema,目前有两个版本,v1beta2和v1beta3,我们以v1beta2创建一个FlowSchema对象,然后使用v1beta2和v1beta3分别将这个对象读出来: 1. 使用v1beta2 api创建该对象: 1234567891011121314151617181920apiVersion: flowcontrol.apiserver.k8s.io/v1beta2kind: FlowSchemametadata: name: testspec: matchingPrecedence: 1000 priorityLevelConfiguration: name: exempt rules: - nonResourceRules: - nonResourceURLs: - "/healthz" - "/livez" - "/readyz" verbs: - "*" subjects: - kind: Group group: name: "system:unauthenticated" 1kubectl apply -f flowschema.yml 2. 使用v1beta2读取: 12345kubectl get flowschema.v1beta2.flowcontrol.apiserver.k8s.io test -o yaml或者curl -H "Accept: application/yaml" http://127.0.0.1:8001/apis/flowcontrol.apiserver.k8s.io/v1beta2/flowschemas/test 输出: 123456789101112131415161718192021222324252627282930313233343536Warning: flowcontrol.apiserver.k8s.io/v1beta2 FlowSchema is deprecated in v1.26+, unavailable in v1.29+; use flowcontrol.apiserver.k8s.io/v1beta3 FlowSchemaapiVersion: flowcontrol.apiserver.k8s.io/v1beta2kind: FlowSchemametadata: annotations: kubectl.kubernetes.io/last-applied-configuration: | {"apiVersion":"flowcontrol.apiserver.k8s.io/v1beta2","kind":"FlowSchema","metadata":{"annotations":{},"name":"test"},"spec":{"matchingPrecedence":1000,"priorityLevelConfiguration":{"name":"exempt"},"rules":[{"nonResourceRules":[{"nonResourceURLs":["/healthz","/livez","/readyz"],"verbs":["*"]}],"subjects":[{"group":{"name":"system:unauthenticated"},"kind":"Group"}]}]}} creationTimestamp: "2023-11-05T02:17:34Z" generation: 2 name: test resourceVersion: "52473" uid: d70bf2e9-3773-4cab-ae4d-b5e060009bebspec: matchingPrecedence: 1000 priorityLevelConfiguration: name: exempt rules: - nonResourceRules: - nonResourceURLs: - /healthz - /livez - /readyz verbs: - '*' subjects: - group: name: system:unauthenticated kind: Groupstatus: conditions: - lastTransitionTime: "2023-11-05T02:25:45Z" message: This FlowSchema references the PriorityLevelConfiguration object named "exempt" and it exists reason: Found status: "False" type: Dangling 3. 使用v1beta3读取: 12345kubectl get flowschema.v1beta3.flowcontrol.apiserver.k8s.io test -o yaml或者curl -H "Accept: application/yaml" http://127.0.0.1:8001/apis/flowcontrol.apiserver.k8s.io/v1beta3/flowschemas/test 输出: 123456789101112131415161718192021222324252627282930313233343536Warning: flowcontrol.apiserver.k8s.io/v1beta3 FlowSchema is deprecated in v1.29+, unavailable in v1.32+apiVersion: flowcontrol.apiserver.k8s.io/v1beta3kind: FlowSchemametadata: annotations: kubectl.kubernetes.io/last-applied-configuration: | {"apiVersion":"flowcontrol.apiserver.k8s.io/v1beta2","kind":"FlowSchema","metadata":{"annotations":{},"name":"test"},"spec":{"matchingPrecedence":1000,"priorityLevelConfiguration":{"name":"exempt"},"rules":[{"nonResourceRules":[{"nonResourceURLs":["/healthz","/livez","/readyz"],"verbs":["*"]}],"subjects":[{"group":{"name":"system:unauthenticated"},"kind":"Group"}]}]}} creationTimestamp: "2023-11-05T02:17:34Z" generation: 2 name: test resourceVersion: "52473" uid: d70bf2e9-3773-4cab-ae4d-b5e060009bebspec: matchingPrecedence: 1000 priorityLevelConfiguration: name: exempt rules: - nonResourceRules: - nonResourceURLs: - /healthz - /livez - /readyz verbs: - '*' subjects: - group: name: system:unauthenticated kind: Groupstatus: conditions: - lastTransitionTime: "2023-11-05T02:25:45Z" message: This FlowSchema references the PriorityLevelConfiguration object named "exempt" and it exists reason: Found status: "False" type: Dangling 可以看到,使用两个版本读出来的对象,几乎完全一样,除了apiVersion字段有区别,metadata字段中的resourceVersion和uid字段都一样,说明他们其实是同一个对象,我们可以直接查看etcd的数据库来确认下: 1234567891011121314151617181920212223242526# etcdctl --endpoints http://127.0.0.1:2379 get / --prefix --keys-only | grep test/registry/flowschemas/test# etcdctl --endpoints http://127.0.0.1:2379 get /registry/flowschemas/test/registry/flowschemas/testk8s2$flowcontrol.apiserver.k8s.io/v1beta3FlowSchematest"*$d70bf2e9-3773-4cab-ae4d-b5e060009beb2bB0kubectl.kubernetes.io/last-applied-configuration{"apiVersion":"flowcontrol.apiserver.k8s.io/v1beta2","kind":"FlowSchema","metadata":{"annotations":{},"name":"test"},"spec":{"matchingPrecedence":1000,"priorityLevelConfiguration":{"name":"exempt"},"rules":[{"nonResourceRules":[{"nonResourceURLs":["/healthz","/livez","/readyz"],"verbs":["*"]}],"subjects":[{"group":{"name":"system:unauthenticated"},"kind":"Group"}]}]}},api-priority-and-fairness-config-consumer-v1Apply$flowcontrol.apiserver.k8s.io/v1betaFieldsV1:{"f:status":{"f:conditions":{"k:{\\"type\\":\\"Dangling\\"}":{".":{},"f:lastTransitionTime":{},"f:message":{},"f:reason":{},"f:status":{},"f:type":{}}}}}Bstatuskubectl-client-side-applyUpdate$flowcontrol.apiserver.k8s.io/v1betaFieldsV1:{"f:metadata":{"f:annotations":{".":{},"f:kubectl.kubernetes.io/last-applied-configuration":{}}},"f:spec":{"f:matchingPrecedence":{},"f:priorityLevelConfiguration":{"f:name":{}},"f:rules":{}}}BRexempt"C!Groupsystem:unauthenticated*/healthz2/livez2/readyzDanglingFal"Found*]This FlowSchema references the PriorityLevelConfiguration object named "exempt" and it exists" 可以看到存到数据库中实际上只有一条记录,但是却可以读出来两个不同的版本,说明在调用不同版本的API读取对象时,肯定是经历了某种转换。 这个例子其实还不太好,因为两个版本的 FlowSchema 对象的字段是完全一样的,如果两个版本之间有字段的差异,可能更能说明问题,但是由于现在 Kubernetes 发展的已经很成熟了,各个API都已经趋于成熟,都逐渐的把beta版的API给移除了,或者beta版跟ga版几乎没有差异,beta的存在仅仅是为了能够兼容一下旧版本的应用,还有一些组是有alpha版本的,但是它是用来孵化新功能的,还没有成熟到进入beta或者ga的阶段,所以在beta或者ga版本的API中,还不存在alpha版中的对象,可以使用较旧版本的Kubernetes,肯定会有同一个对象在不同版本中同时存在的情况,而且可能会有字段的差异,或者关注下当前版本的alpha功能,未来肯定会经历beta, ga的迭代。 通过上面的例子,我们大概感受了下API多版本的功能,此外,还有个多协议的功能点,即用户可以选择API返回的数据的格式,比如上例中,我们通过命令行的-o选项或者api请求的Accept Header参数,来指定了希望返回yaml格式的数据,于是apiserver会将API资源给序列化成yaml格式返回给客户端,除了yaml格式,apiserver还支持序列化成json以及protobuf格式,例如: 1curl -H "Accept: application/json" http://127.0.0.1:8001/apis/flowcontrol.apiserver.k8s.io/v1beta3/flowschemas/test 1234567891011121314151617181920212223curl -H "Accept: application/vnd.kubernetes.protobuf" http://127.0.0.1:8001/apis/flowcontrol.apiserver.k8s.io/v1beta3/flowschemas/testk8s2$flowcontrol.apiserver.k8s.io/v1beta3FlowSchematest"*$d70bf2e9-3773-4cab-ae4d-b5e060009beb252473bB0kubectl.kubernetes.io/last-applied-configuration{"apiVersion":"flowcontrol.apiserver.k8s.io/v1beta2","kind":"FlowSchema","metadata":{"annotations":{},"name":"test"},"spec":{"matchingPrecedence":1000,"priorityLevelConfiguration":{"name":"exempt"},"rules":[{"nonResourceRules":[{"nonResourceURLs":["/healthz","/livez","/readyz"],"verbs":["*"]}],"subjects":[{"group":{"name":"system:unauthenticated"},"kind":"Group"}]}]}},api-priority-and-fairness-config-consumer-v1Apply$flowcontrol.apiserver.k8s.io/v1betaFieldsV1:{"f:status":{"f:conditions":{"k:{\\"type\\":\\"Dangling\\"}":{".":{},"f:lastTransitionTime":{},"f:message":{},"f:reason":{},"f:status":{},"f:type":{}}}}}Bstatuskubectl-client-side-applyUpdate$flowcontrol.apiserver.k8s.io/v1betaFieldsV1:{"f:metadata":{"f:annotations":{".":{},"f:kubectl.kubernetes.io/last-applied-configuration":{}}},"f:spec":{"f:matchingPrecedence":{},"f:priorityLevelConfiguration":{"f:name":{}},"f:rules":{}}}BRexempt"C!Groupsystem:unauthenticated*/healthz2/livez2/readyzDanglingFal"Found*]This FlowSchema references the PriorityLevelConfiguration object named "exempt" and it exists" 从上面的功能介绍来看,Kubernetes能够做到这个程度的API兼容,真的是很良心的,难怪它会一统江湖,Kubernetes社区将API的兼容性看得很重要,除了每个API都有一个进化迭代的过程之外,一旦API进入到GA稳定版阶段,后续对它的修改一定是不能破坏兼容性的,关于API兼容性的更多内容,可以阅读下社区的这个文档:Changing the API OK,看了上面的功能介绍和示例,我们心里可能会有一些困惑: 不同版本之间是如何转换的?有两个版本还好说,那如果是有很多版本,难道要两两组合下吗,这会不会太傻了? 实际存储到etcd中的是什么版本的?上面的例子中,是通过v1beta2 API存进去的对象,可是通过etcdctl查看数据库中的数据,怎么好像是存储的v1beta3版本的? 实际存储到etcd中的数据是什么格式的?从上面的例子中,可以看到,使用etcdctl和curl protobuf格式查看到的数据,好像长的是一样的,难道直接是存储的protobuf格式的? 好,带着这些问题,我们进入下一个小节,来看看它的实现原理。 原理介绍主要来说说版本是怎么转换的,为了方便各个版本之间互相转换,APIServer引入了一个内部版本的概念,每个API对象都有一个对应的内部版本,它是一个特殊的版本,是在APIServer内部对各个API对象进行处理时使用的数据结构(Struct),而不是使用的某个具体版本的数据结构(Struct),当该API对象跟外部交互时,则会从内部版本转换成对应的具体版本,我们称之为外部版本,这个“外部”其实包含两个地方:一个是通过HTTP协议跟客户端交互时,会将其转换成客户端请求的版本,一个是将该对象存储到数据库时,数据库会将其保存成某个版本的数据结构(Struct)。这个内部版本,有点类似于中间版本的概念,不论你请求的是哪个版本,都是那个版本跟内部版本之间互相转换,具体版本之间是不会直接进行转换的,这样就将一个网状的结构,转换成了星状的结构,减少了数据处理的维度,每个版本的API对象,只需要申明自己怎么跟内部版本进行转换就可以了。 还有就是将某个对象存储到数据库时,并不是存储的内部版本的数据结构,而是存储的某个具体版本的数据结构,一般是该API对象最稳定版本的数据结构,比如某个API对象同时有两个版本,v1和v1beta1,那么不论通过哪个版本请求过来的,最终存储到数据库中的,都是v1版本对应的数据结构,反过来,当你从数据库读出来对应的数据之后,APIServer则首先会将其从v1版本转换成内部版本,然后再进行其他的处理。当然,也有可能存储的不是最稳定的版本,而是某个中间版本,比如v1, v1beta1, v1beta2,可能它存的是v1beta2版本的数据结构,即次稳定版本,这种情况,一般都处在升级迭代的过程中,保证应用的兼容性,经历几个版本迭代之后,还是最终切到v1版本的数据结构上去。 OK,我们还是以上面的FlowSchema为例,来看看它不同版本之间转换的一个过程,先来看看FlowSchema各个版本的数据结构定义: v1beta2的数据结构: 1234567891011121314151617181920212223242526272829303132333435363738394041# k8s.io/api/flowcontrol/v1beta2/types.gotype FlowSchema struct { metav1.TypeMeta `json:",inline"` // `metadata` is the standard object's metadata. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata // +optional metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` // `spec` is the specification of the desired behavior of a FlowSchema. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status // +optional Spec FlowSchemaSpec `json:"spec,omitempty" protobuf:"bytes,2,opt,name=spec"` // `status` is the current status of a FlowSchema. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status // +optional Status FlowSchemaStatus `json:"status,omitempty" protobuf:"bytes,3,opt,name=status"`}// FlowSchemaSpec describes how the FlowSchema's specification looks like.type FlowSchemaSpec struct { // `priorityLevelConfiguration` should reference a PriorityLevelConfiguration in the cluster. If the reference cannot // be resolved, the FlowSchema will be ignored and marked as invalid in its status. // Required. PriorityLevelConfiguration PriorityLevelConfigurationReference `json:"priorityLevelConfiguration" protobuf:"bytes,1,opt,name=priorityLevelConfiguration"` // `matchingPrecedence` is used to choose among the FlowSchemas that match a given request. The chosen // FlowSchema is among those with the numerically lowest (which we take to be logically highest) // MatchingPrecedence. Each MatchingPrecedence value must be ranged in [1,10000]. // Note that if the precedence is not specified, it will be set to 1000 as default. // +optional MatchingPrecedence int32 `json:"matchingPrecedence" protobuf:"varint,2,opt,name=matchingPrecedence"` // `distinguisherMethod` defines how to compute the flow distinguisher for requests that match this schema. // `nil` specifies that the distinguisher is disabled and thus will always be the empty string. // +optional DistinguisherMethod *FlowDistinguisherMethod `json:"distinguisherMethod,omitempty" protobuf:"bytes,3,opt,name=distinguisherMethod"` // `rules` describes which requests will match this flow schema. This FlowSchema matches a request if and only if // at least one member of rules matches the request. // if it is an empty slice, there will be no requests matching the FlowSchema. // +listType=atomic // +optional Rules []PolicyRulesWithSubjects `json:"rules,omitempty" protobuf:"bytes,4,rep,name=rules"`} v1beta3的数据结构: 12345678910111213141516171819202122232425262728293031323334353637383940# k8s.io/api/flowcontrol/v1beta3/types.gotype FlowSchema struct { metav1.TypeMeta `json:",inline"` // `metadata` is the standard object's metadata. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata // +optional metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` // `spec` is the specification of the desired behavior of a FlowSchema. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status // +optional Spec FlowSchemaSpec `json:"spec,omitempty" protobuf:"bytes,2,opt,name=spec"` // `status` is the current status of a FlowSchema. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status // +optional Status FlowSchemaStatus `json:"status,omitempty" protobuf:"bytes,3,opt,name=status"`}type FlowSchemaSpec struct { // `priorityLevelConfiguration` should reference a PriorityLevelConfiguration in the cluster. If the reference cannot // be resolved, the FlowSchema will be ignored and marked as invalid in its status. // Required. PriorityLevelConfiguration PriorityLevelConfigurationReference `json:"priorityLevelConfiguration" protobuf:"bytes,1,opt,name=priorityLevelConfiguration"` // `matchingPrecedence` is used to choose among the FlowSchemas that match a given request. The chosen // FlowSchema is among those with the numerically lowest (which we take to be logically highest) // MatchingPrecedence. Each MatchingPrecedence value must be ranged in [1,10000]. // Note that if the precedence is not specified, it will be set to 1000 as default. // +optional MatchingPrecedence int32 `json:"matchingPrecedence" protobuf:"varint,2,opt,name=matchingPrecedence"` // `distinguisherMethod` defines how to compute the flow distinguisher for requests that match this schema. // `nil` specifies that the distinguisher is disabled and thus will always be the empty string. // +optional DistinguisherMethod *FlowDistinguisherMethod `json:"distinguisherMethod,omitempty" protobuf:"bytes,3,opt,name=distinguisherMethod"` // `rules` describes which requests will match this flow schema. This FlowSchema matches a request if and only if // at least one member of rules matches the request. // if it is an empty slice, there will be no requests matching the FlowSchema. // +listType=atomic // +optional Rules []PolicyRulesWithSubjects `json:"rules,omitempty" protobuf:"bytes,4,rep,name=rules"`} 内部结构 12345678910111213141516171819202122232425262728293031323334353637383940# kubernetes/pkg/apis/flowcontrol/types.gotype FlowSchema struct { metav1.TypeMeta // `metadata` is the standard object's metadata. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata // +optional metav1.ObjectMeta // `spec` is the specification of the desired behavior of a FlowSchema. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status // +optional Spec FlowSchemaSpec // `status` is the current status of a FlowSchema. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status // +optional Status FlowSchemaStatus}type FlowSchemaSpec struct { // `priorityLevelConfiguration` should reference a PriorityLevelConfiguration in the cluster. If the reference cannot // be resolved, the FlowSchema will be ignored and marked as invalid in its status. // Required. PriorityLevelConfiguration PriorityLevelConfigurationReference // `matchingPrecedence` is used to choose among the FlowSchemas that match a given request. The chosen // FlowSchema is among those with the numerically lowest (which we take to be logically highest) // MatchingPrecedence. Each MatchingPrecedence value must be ranged in [1,10000]. // Note that if the precedence is not specified, it will be set to 1000 as default. // +optional MatchingPrecedence int32 // `distinguisherMethod` defines how to compute the flow distinguisher for requests that match this schema. // `nil` specifies that the distinguisher is disabled and thus will always be the empty string. // +optional DistinguisherMethod *FlowDistinguisherMethod // `rules` describes which requests will match this flow schema. This FlowSchema matches a request if and only if // at least one member of rules matches the request. // if it is an empty slice, there will be no requests matching the FlowSchema. // +listType=set // +optional Rules []PolicyRulesWithSubjects} 从上面的代码可以看到,v1beta2和v1beta3目前的数据结构是一模一样的,并且都位于k8s.io/api这个第三方库中,只是所在的目录不同而已,而内部版本的数据结构的字段跟他们也是一样的,只是没有带用来做序列化的tag,并且内部结构是位于kubernetes本身的代码目录树中的,并没有以第三方库的形式暴露出去。 OK,我们先来看下创建过程中的版本转换以及序列化过程,如下图: HTTP请求到了APIServer,会由该资源API对应的Handler来处理,第一步就是根据HTTP请求的Content-Type Header中标记的数据类型,比如是json还是protobuf,来将字节类型的 req.body 反序列化为对应版本的数据结构的对象实例,第二步,又会将具体版本的对象转换成内部版本的对象,第三步,在存数据库的时候,又将内部版本的对象转换成了稳定版本的数据结构的对象实例,并且将其序列化为protobuf格式的数据,将其存到数据库中。在存数据库的时候,默认是使用protobuf格式,可以通过配置项 --storage-media-type 来更改存储的格式,支持 json/yaml/protobuf 三种格式。 再来看看读取的过程,如下图: 读的过程,其实正好跟写的过程相反,当请求某一个版本的API对象时,首先会从数据库中读出字节类型的数据,然后将其反序列化为内部数据结构的对象实例,然后再转换成对应的版本,然后再根据请求中的Accept Header来决定将其序列化为哪种数据格式,返回给客户端。 在创建时,因为要把创建结果返回给客户端,其实也走了一遍读的流程,跟单独去读过程是类似的,上面没有再画出来了。 总结本篇文章介绍了API多版本功能及其实现原理,在实现原理中,最核心的就是引入了一个内部版本的概念,让各个版本跟内部版本之间进行转换,从而能够使用一份数据,去服务多个版本。 所以,从上面的原理分析可以看到,这个API多版本的重点,是如何在多个版本之间进行转换以及进行序列化,这就涉及到代码层面的分析了,也就是Scheme和Codec发挥作用的地方,有兴趣的可以继续看下Scheme和Codec的分析文章: Kubernetes API Scheme 解析 Kubernetes API Codec 解析","link":"/2023/10/28/kubernetes/kube-versioning.html"},{"title":"OwnCloud On Kubernetes On OpenStack","text":"背景研究kubernetes有一段时间了,k8s作为容器编排领域的标准,相比传统架构,在应用发布,运行,维护上具有颠覆性的变革,这场变革以“云原生”为口号,如火如荼的发展起来。可以想象,在不远的将来,大家的应用都以标准的形式,运行在以k8s为代表容器平台上,k8s让devops真正融合在了一起,尤其对运维,k8s定义了对应用的标准运维方式,平台替人做了很多toil的运维工作,通过各种机制保障应用的可用性,这对运维来说,是最激动人心的。在IaaS平台上构建起来的容器平台,还可以通过API的方式消费IaaS平台的网络和存储等资源,真正将IaaS平台的弹性、灵活性利用起来,容器平台将作为最接近应用的基础平台,是一个公司非常重要的IT基础设施,这一如当年的Linux给业界带来的变革。 K8S相比OpenStack来说,我感觉其复杂度有过之而无不及,毕竟都是基础平台,功能强大,就意味着复杂,光是把里面的概念搞清楚,就需要花费一些时间,为了更深刻的理解这些概念,最好的办法,就是动手做,本篇就以OwnCloud在Kubernetes上的部署为例,掌握下Kubernetes涉及到的一些核心概念,OwnCloud是一个开源的文件共享系统,说通俗点,就是一个“网盘”系统,之所以选择用它做实验,主要有以下几个原因: 最近想给公司搭建一个内部网盘,让大家可以把一些资料集中到一起,方便存储和共享 OwnCloud是一个典型的LMAP架构的应用,会用到负载均衡,代理,数据库,Web Server等,具有代表性 OwnCloud有一个非常不错的helm chart,可以用来学习 OwnCloud还可以跟S3对接,将数据存储到对象存储中,这个可以用来跑我们的Ceph RGW的业务 此外,本次测试使用的Kubernetes是运行在OpenStack平台上,PaaS和IaaS的结合,看看会擦出怎么样的火花。 环境准备首先是有一个OpenStack环境,为了更好的跟Kubernetes结合,这个OpenStack平台有以下特点: OpenStack后端接的Ceph,这是为了让k8s通过storageclass功能,消费OpenStack平台的块存储资源 部署了负载均衡功能,使用的octavia,模式是active standby模式,这是为了测试k8s的load balancer类型的service 提供VLAN类型的网络,因为k8s本身的网络就已经有了封装,其底层的网络就尽量让其简单 还部署了Ceph的RGW功能,后面可以让owncloud和rgw进行对接 然后是Kubernetes环境,有如下特点: Kubernetes平台是起在OpenStack里的虚拟机 虚拟机运行在VLAN网络里,共有6台,3个master节点,3个node节点 Kubernetes使用kubespary部署,这是一个部署生产环境使用的ansible项目,功能很强大,几乎支持k8s的各种功能 Kubernetes的网络使用calico的ipip模式,这是kubespray的默认网络模式 Kubernetes的service proxy mode使用ipvs Kubernetes和OpenStack进行了对接,即k8s平台的cloud provider是openstack,这样k8s就可以消费openstack的api,使用其上的资源了,本次测试主要是使用块存储和负载均衡。 Kubernetes部署了nginx ingress controller和helm,ingress通过load balancer service暴露出去,因为owncloud是使用helm部署的,而且也支持ingress的方式,所以部署上了这两个功能。 创建了一个叫cinder的storageclass,跟openstack的cinder进行了集成 架构规划本次测试,目的是尽量让其接近生产环境,owncloud是一个LAMP架构的应用,数据库使用MariaDB,采用主备模式,并且数据库的部署也是通过helm的方式,owncloud(Apache+PHP)则被ingress代理,ingress又通过load balancer类型的service暴露出去,其整体架构如下图: node-1, node-2, node-3分别是kubernetes平台里的三个worker node,是openstack里的三个虚拟机;LB是load balancer类型的service,会对应的在openstack平台里创建出来octavia的lb;LB通过round robin的方式,通过ingress的service的nodeport,代理ingress实例,ingress是replica为1的deployment;Apache是跑在bitnami提供的owncloud镜像里的,里面包含了apache+php,是我们的app程序,也是一个replica为1的deployment,其又被ingress所代理;最后是数据库,数据库是一个主从架构的集群,使用statefulset运行,数据库又通过默认的ClusterIP类型的service的方式暴露给集群内部使用。 Helm Chart本次测试使用到两个helm chart,mariadb和owncloud,owncloud里面依赖了mariadb chart,部署owncloud chart时,会自动安装mariadb的chart,为了解耦,我们分开部署,把mariadb当成external db去部署,owncloud里也支持external database。 MariaDB的chart里面构成如下: 数据库实例使用statefulset部署,主从的replica都为1 通过service将数据库实例暴露给集群 数据库配置使用configmap保存 数据库密码使用secret保存 数据库使用到的数据目录,通过storageclass和pvc的形式进行申明挂载 OwnCloud的chart构成如下: owncloud的app实例使用deployment部署,replica为1 owncloud deployment通过service暴露给集群内部 ingress代理了owncloud service owncloud的密码,使用secret保存 使用到了两个pvc,一个是apache的数据目录,一个是owncloud的数据目录 综上,以上两个chart,有statefulset, deployment, service, cofigmap, secret, storageclass, pvc, ingress等概念,基本上包含了kubernetes里面最主要的核心概念。具体细节,可以直接看代码,这里就不展开讨论了。 部署测试因为helm已经做了很好的封装,在前置条件都准备好的前提下,部署其实是非常简单的,我们只需要定制helm使用到的参数即可。本次测试,使用到的参数如下: 部署mariadb,使用到如下参数:123456789101112131415161718# cat mariadb-values.yamlrootUser: password: passworddb: user: owncloud password: password name: owncloudmaster: persistence: storageClass: cinder size: 50Gislave: persistence: storageClass: cinder size: 50Gi 这里的重点是使用之前已经预定义好的cinder storageclass,为master和slave分配存储资源。 部署owncloud,使用到如下参数:12345678910111213141516171819202122232425262728293031# cat owncloud-values.yamlingress: enabled: true hosts: - name: owncloud.ustack.com tls: falseowncloudHost: owncloud.ustack.comowncloudUsername: adminowncloudPassword: adminowncloudEmail: admin@unitedstack.comexternalDatabase: host: 10.233.26.90 user: owncloud password: password database: owncloudmariadb: enabled: falseservice: type: ClusterIPpersistence: apache: storageClass: cinder size: 10Gi owncloud: storageClass: cinder size: 100Gi 这里的重点是开启了ingress,它默认是关闭的,ingress的host写上域名,数据库使用的是external database,填写上mariadb的service clusterip,owncloud的service类型定义成ClusterIP,默认是Load Balancer,为的是让它被ingress代理,最后分配两个存储资源。 部署只需要两条命令就可以搞定: 12helm install -f mariadb-values.yaml stable/mariadb --name mariadbhelm install -f owncloud-values.yaml stable/owncloud --name owncloud 然后可以通过如下命令查看部署状态: 12345helm listhelm status mariadbhelm status owncloudkubectl get pods -w --namespace default -l release=mariadbkubectl get pods -w --namespace default -l release=owncloud 观察到pod都是running并且ready之后,说明部署完成,在本地为lb的vip做域名解析到owncloud.ustack.com,然后访问 http://owncloud.ustack.com 即可。 遇到问题在测试的时候,遇到两个问题: externalDatabase里面的host不能写成service对应的域名,即 mariadb.default.svc.cluster.local,使用域名的话,在owncloud的pod启动时,会卡在数据库初始化上,观察了下,这个初始化,是使用nodejs写的,还不清楚为什么卡住,换成service的clusterip,就好了,理论上,应该填写service对应的域名,会比较好些。 owncloud这里的初始化好像没什么作用,我们已经在externalDatabase里传了数据库用户名和密码等信息,然后owncloud的用户名和密码,把这些当成环境变量传给了容器,但是在访问url时,还是会让你在界面上填写这些参数,而且进pod里看这些配置,都是没有配置上的,得在面板填写,安装之后,才会生成,感觉它这个初始化的脚本有问题。 概念解析像service, ingress, pv, pvc这些概念相近,很容易让人迷惑,下面结合本次测试,对这些概念做一个梳理,确定下关键点,其次,最让人感兴趣的就是OpenStack里面资源是如何被Kubernetes使用的,这个测试主要用到了两种OpenStack资源,一个是cinder的块存储,一个是octavia的lb。 1. StorageClass, PVC, PV上面的每一个pvc,都对应的创建了一个pv,每一个pv都对应一个cinder云硬盘,并且被挂载到了pod所在的node上: 1234567[root@kube-node-3 ~]# lsblkNAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTvda 253:0 0 80G 0 disk└─vda1 253:1 0 80G 0 part /vdb 253:16 0 50G 0 disk /var/lib/kubelet/pods/b994a0b2-5941-11e9-8b54-fa163e23db6f/volumes/kubernetes.io~cinder/pvc-b992cdd5-5941-11e9-8b54-fa163e23db6fvdc 253:32 0 100G 0 disk /var/lib/kubelet/pods/898d489a-5949-11e9-8b54-fa163e23db6f/volumes/kubernetes.io~cinder/pvc-897de9b3-5949-11e9-8b54-fa163e23db6fvdd 253:48 0 10G 0 disk /var/lib/kubelet/pods/898d489a-5949-11e9-8b54-fa163e23db6f/volumes/kubernetes.io~cinder/pvc-897cabc0-5949-11e9-8b54-fa163e23db6f 12345678910111213[root@kube-master-1 ~]# kubectl get pvcNAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGEdata-mariadb-master-0 Bound pvc-b98a678c-5941-11e9-8b54-fa163e23db6f 50Gi RWO cinder 5h10mdata-mariadb-slave-0 Bound pvc-b992cdd5-5941-11e9-8b54-fa163e23db6f 50Gi RWO cinder 5h10mowncloud-owncloud-apache Bound pvc-897cabc0-5949-11e9-8b54-fa163e23db6f 10Gi RWO cinder 4h15mowncloud-owncloud-owncloud Bound pvc-897de9b3-5949-11e9-8b54-fa163e23db6f 100Gi RWO cinder 4h15m[root@kube-master-1 ~]# kubectl get pvNAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGEpvc-897cabc0-5949-11e9-8b54-fa163e23db6f 10Gi RWO Delete Bound default/owncloud-owncloud-apache cinder 4h15mpvc-897de9b3-5949-11e9-8b54-fa163e23db6f 100Gi RWO Delete Bound default/owncloud-owncloud-owncloud cinder 4h14mpvc-b98a678c-5941-11e9-8b54-fa163e23db6f 50Gi RWO Delete Bound default/data-mariadb-master-0 cinder 5h10mpvc-b992cdd5-5941-11e9-8b54-fa163e23db6f 50Gi RWO Delete Bound default/data-mariadb-slave-0 cinder 5h10m StorageClass是最高度的抽象,它定义了要使用的存储的具体类型,比如是使用块存储,还是nfs,而pvc则是storageclass的消费者,pvc只关心需要多少存储资源,至于这个存储资源,怎么来,是由storageclass来定义的,pv则是具体的创建出来的存储资源了,pv会和对应的pvc进行绑定。 2. Ingress上面创建的ingress对象,最终表现出来的是对应的在ingress nginx的nginx.conf中添加了配置: 1234567891011121314151617## start server owncloud.ustack.com server { server_name owncloud.ustack.com ; listen 80; set $proxy_upstream_name "-"; location / { set $namespace "default"; set $ingress_name "owncloud.ustack.com-owncloud"; set $service_name "owncloud-owncloud"; set $service_port "80"; set $location_path "/"; ...## end server owncloud.ustack.com 添加完配置之后,nginx会自动通过lua进行重载,使配置生效,可见,ingress的工作其实非常轻量级。Ingress其实就是一个反向代理,代理后端的service,之前一直理解的是每一个ingress都会创建一个nginx实例,其实并不是,而是所有的service都使用同一个nginx,通过host参数,来为每一个service在nginx.conf中添加vhost,然后对其进行代理,这样做的好处显而易见,就是可以通过一个统一的入口,代理后端的多个服务,ingress有点类似于是service的service,明白这一点,是理解ingress的关键。 Ingress的另一个关键点是,ingress其实也是一个集群内对象,默认是不对集群外暴露的,而如果你想将ingress暴露出去,还是要通过service的方式进行,即给ingress之前再加一个service,可以是nodeport,或者是lb。 1234567[root@kube-master-1 ~]# kubectl get ingressNAME HOSTS ADDRESS PORTS AGEowncloud.ustack.com-owncloud owncloud.ustack.com 10.0.127.3 80 4h26m[root@kube-master-1 ~]# kubectl get svc -n ingress-nginxNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEingress-nginx LoadBalancer 10.233.43.201 10.0.127.3 80:30827/TCP,443:31659/TCP 13h 本例使用lb类型的service将ingress暴露到集群外,而lb则是使用的openstack的lb功能。 3. ServiceService这个概念在Kubernetes中,是使用最广泛的一个概念了,从服务的角度来讲,每一个服务都需要对外暴露一个统一的入口,比如本例中的数据库,owncloud等。Service本质上,其实也是一个代理,代理后端的pod,有3种代理模式:userspace, iptables, ipvs,其中ipvs是通过lvs实现的,在大规模场景下,会简化管理,提高性能。在本例中,使用ipvs模式,可以通过如下命令看到ipvs的代理效果: 12345678[root@kube-master-1 ~]# ipvsadm -lnIP Virtual Server version 1.2.1 (size=4096)Prot LocalAddress:Port Scheduler Flags -> RemoteAddress:Port Forward Weight ActiveConn InActConnTCP 172.17.0.1:31659 rr -> 10.233.71.130:443 Masq 1 0 0TCP 10.0.127.3:80 rr -> 10.233.71.130:80 Masq 1 0 0 此外,service对集群外进行暴露,通过有两种方式,一个是nodeport,一个是load balancer,nodeport只在kubernetes集群内部就可以实现,通过iptables+ipvs规则实现,效果就是可以通过任一一个节点的IP地址+自动分配的node port,就可以访问到这个service,如上例中31659就是一个node port,需要注意的一个关键点是,这些规则会在每一个k8s的节点添加,由于节点之间是内网联通的,因此访问任一一个节点的ip地址,都可以访问到这个service。而load balancer类型的service,则需要依赖集群外部的load balancer服务去实现,这种通常是需要有cloud provider的,即依赖于底层IaaS平台的负载均衡服务,为其创建LB,然后通过node port将节点添加到lb的member中,其中的关键点是,lb类型的service,是构建在nodeport基础上的,它是先建立了node port,然后通过node port,将node添加到lb的后端服务中,如本例中,将ingress通过lb service进行暴露的方式,在OpenStack LB服务中,会看到如下的内容: 123[root@kube-master-1 ~]# kubectl get svc -n ingress-nginxNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEingress-nginx LoadBalancer 10.233.43.201 10.0.127.3 80:30827/TCP,443:31659/TCP 13h 可以看到,ingress nginx controller通过两个listener,将80和443端口暴露出去,对应的有两pool,每个pool中有3个member,分别就是3个work node,而member中,又通过service的node port,将其添加到pool的member中,这样就完美实现了OpenStack为Kubernetes中的Ingress对象提供负载均衡的功能。 此外,还有statefulset, deployment等概念,这里就不再阐述了,比较好理解,官方文档解释的也比较清楚。 总结本文通过对OwnCloud在Kubernetes上进行部署为例,介绍了Kubernetes中的几个重点概念,同时,也展示了Kubernetes在OpenStack平台上展示出来的威力,真心感觉,构建在IaaS平台之上的PaaS才是未来的主流发展方向,IaaS为PaaS提供了各种资源,而PaaS又像最终的应用提供了编排能力,以及对资源进行了进一步的抽象,IaaS应该由像Kubernetes这样的PaaS平台通过API的方式进行消费,而不是由人直接消费,这才是运维自动化的核心。 附录 openstack cinder storage class 123456789# cat cinder-storage-class.yamlkind: StorageClassapiVersion: storage.k8s.io/v1metadata: name: cinderprovisioner: kubernetes.io/cinderparameters: availability: nova openstack loadbalancer service for ingress nginx 1234567891011121314151617181920212223242526# cat openstack-lb-ingress.yamlkind: ServiceapiVersion: v1metadata: name: ingress-nginx namespace: ingress-nginx labels: app.kubernetes.io/name: ingress-nginx app.kubernetes.io/part-of: ingress-nginx annotations: service.beta.kubernetes.io/openstack-internal-load-balancer: "true"spec: #externalTrafficPolicy: Local type: LoadBalancer selector: app.kubernetes.io/name: ingress-nginx app.kubernetes.io/part-of: ingress-nginx ports: - name: http port: 80 targetPort: http - name: https port: 443 targetPort: https---","link":"/2019/04/08/kubernetes/owncloudon-kubernetes-on-openstack.html"},{"title":"TripleO Introspection 介绍","text":"Introspection是指能够通过程序自动的收集服务器的物理信息,比如CPU型号,网卡个数,Mac地址,磁盘大小以及个数等等,这个功能非常有用,试想一下,在部署几十台或者更多节点时,要手动收集每一台服务器的物理信息,这该是多么痛苦的一件事,而且还很容易出错,如果能将这个过程自动化,不仅可以提高效率,减少错误,还能够实现一些高级功能,比如服务器自动发现,自动注册等等。 介绍本节主要来介绍下TripleO是如何实现Introspection的,主要涉及到4个项目: ironic ironic_python_agent ironic_inspector swift 这4个项目的逻辑关系如下图: Ironic是对裸机进行管理的项目,可以控制裸机的整个生命周期,开机,关机,重启等等; Ironic-Python-Agent(IPA)是通过ramdisk的方式运行在裸机里的Python程序,通过让服务器从PXE启动,加载ironic_python_agent镜像到ramdisk,从而启动IPA的,因为它是直接在物理服务器上,所以这为收集物理机信息提供了前提条件;Ramdisk是一种将内存当做磁盘使用的技术,使得系统运行在物理机的内存里,并没有写入磁盘;IPA内部实现了插件式的架构,可以指定多个collector进行收集,满足各种收集需求; Ironic-Inspector是整个introspection的主体,它提供了API去触发introspection操作,并且提供回调接口给IPA用,然后将收集来的数据进行处理,并且将部分信息主动更新Ironic中的node信息;对收集来的数据进行处理,Ironic-Inspector也采取了插件式的架构,指定了一系列Hook进行处理,比如检查一些数据的有效性,重组数据,更新Ironic中的node属性等等; Ironic-Inspector将收集来的数据存储到Swift中; 操作Introspection操作非常简单,首先需要将node置为manageable状态: 1[stack@pre4-undercloud ~]$ openstack baremetal node manage 5a58fe7c-f04a-43dc-a9ba-48c6da1abced 然后执行: 1[stack@pre4-undercloud ~]$ openstack baremetal introspection start 5a58fe7c-f04a-43dc-a9ba-48c6da1abced 这样就触发了一个introspection操作,introspection是一个异步操作,可以通过下面的命令查询当前的introspection状态: 1234567[stack@pre4-undercloud ~]$ openstack baremetal introspection status 5a58fe7c-f04a-43dc-a9ba-48c6da1abced+----------+-------+| Field | Value |+----------+-------+| error | None || finished | True |+----------+-------+ 等introspection完成后,可以对比下前后该node的属性变化: 1234567891011121314151617181920212223242526272829[stack@pre4-undercloud templates]$ openstack baremetal node show 5a58fe7c-f04a-43dc-a9ba-48c6da1abced+------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+| Field | Value |+------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+| console_enabled | False || created_at | 2017-04-16T07:17:40+00:00 || driver | pxe_ipmitool || driver_info | {u'deploy-ramdisk': u'0a601990-89eb-4678-b460-ebbae1be54d1', u'ipmi_address': u'10.0.108.120', u'ipmi_username': u'root', u'deploy_kernel': u'38b34314-e3a9-410b-b435-8495b110ab41', u'ipmi_password': u'******'} || driver_internal_info | {} || extra | {} || inspection_finished_at | None || inspection_started_at | None || instance_info | {} || instance_uuid | None || last_error | None || maintenance | False || maintenance_reason | None || name | rack2-4-compute || ports | [{u'href': u'http://10.0.161.2:6385/v1/nodes/5a58fe7c-f04a-43dc-a9ba-48c6da1abced/ports', u'rel': u'self'}, {u'href': u'http://10.0.161.2:6385/nodes/5a58fe7c-f04a-43dc-a9ba-48c6da1abced/ports', u'rel': u'bookmark'}] || power_state | power off || properties | {} || provision_state | manageable || provision_updated_at | 2017-04-16T07:32:02+00:00 || reservation | None || target_power_state | None || target_provision_state | None || updated_at | 2017-04-16T07:32:02+00:00 || uuid | 5a58fe7c-f04a-43dc-a9ba-48c6da1abced |+------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ 1234567891011121314151617181920212223242526272829[stack@pre4-undercloud templates]$ openstack baremetal node show 5a58fe7c-f04a-43dc-a9ba-48c6da1abced+------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+| Field | Value |+------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+| console_enabled | False || created_at | 2017-04-16T07:17:40+00:00 || driver | pxe_ipmitool || driver_info | {u'deploy-ramdisk': u'0a601990-89eb-4678-b460-ebbae1be54d1', u'ipmi_address': u'10.0.108.120', u'ipmi_username': u'root', u'deploy_kernel': u'38b34314-e3a9-410b-b435-8495b110ab41', u'ipmi_password': u'******'} || driver_internal_info | {} || extra | {u'hardware_swift_object': u'extra_hardware-5a58fe7c-f04a-43dc-a9ba-48c6da1abced'} || inspection_finished_at | None || inspection_started_at | None || instance_info | {} || instance_uuid | None || last_error | None || maintenance | False || maintenance_reason | None || name | rack2-4-compute || ports | [{u'href': u'http://10.0.161.2:6385/v1/nodes/5a58fe7c-f04a-43dc-a9ba-48c6da1abced/ports', u'rel': u'self'}, {u'href': u'http://10.0.161.2:6385/nodes/5a58fe7c-f04a-43dc-a9ba-48c6da1abced/ports', u'rel': u'bookmark'}] || power_state | power off || properties | {u'memory_mb': u'65536', u'cpu_arch': u'x86_64', u'local_gb': u'277', u'cpus': u'40', u'capabilities': u'cpu_txt:true,cpu_aes:true,cpu_hugepages_1g:true,cpu_hugepages:true,cpu_vt:true'} || provision_state | manageable || provision_updated_at | 2017-04-16T07:32:02+00:00 || reservation | None || target_power_state | None || target_provision_state | None || updated_at | 2017-04-16T07:38:57+00:00 || uuid | 5a58fe7c-f04a-43dc-a9ba-48c6da1abced |+------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ 可以看到node中自动填充上了一些内存,cpu,磁盘大小的属性。可以通过如下命令查看收集的该node的所有信息: 1[stack@pre4-undercloud ~]$ openstack baremetal introspection data save 5a58fe7c-f04a-43dc-a9ba-48c6da1abced 因为收集来的数据是保存在Swift里的,该命令就是从swift中下载下来的。可以通过swift的命令查看到这些数据: 1[stack@pre4-undercloud ~]$ swift list ironic-inspector TripleO也提供了批量introspection的操作,但是因为ironic-inspector只提供了对一台机器进行introspection的接口,所以批量操作被集成到了Mistral的workflow中,该命令如下: 1[stack@pre4-undercloud ~]$ openstack baremetal introspection bulk start 数据流可以看到introspection就一条命令就完成了,这条命令背后发生了什么?我们来梳理下它的数据流,该过程大概分为2个阶段: 第一阶段客户端发出命令后,该命令就是简单调用了ironic-inspector中的POST /v1/introspection/<node_id>接口,其数据流如下: ironic-inspector向ironic发送了两条指令,一个是设置该node从PXE启动,一个是重启该node,这样这个命令就执行完了。 第二阶段node重启之后,从PXE启动,IPA被加载进ramdisk开始执行,其内部数据流如下: IPA通过collector插件收集本机的物理信息,收集完成之后,回调由ironic-inspector提供的回调接口:POST /v1/continue 将收集到的数据回传给ironic-inspector,ironic-inspector收到数据之后,就开始通过Hook去处理数据,Hook的执行分为两个阶段,在_run_pre_hooks中主要进行一些检查工作,在_run_post_hooks中主要是处理数据,然后更新ironic中的node属性,随后将处理之后的数据保存到Swift中,然后调用ironic接口将该node关机,完成introspection。","link":"/2017/04/16/openstack/tripleo/introspection.html"},{"title":"TripleO-Quickstart使用","text":"有好几年没有写博客了,这次趁着周末有时间,花了一天的时间写了一篇。在公司做了三年的开发,之后转到运维做了将近一年的运维,然后最近又从运维转到了DevOps,终于脱离了苦逼的运维岁月,想想那些年背的锅,辛酸苦楚只能自己体会,这些年开发与运维的经历,真的是我人生宝贵的财富,也为我现在转向DevOps奠定了坚实的基础。转到DevOps之后,负责的第一个重要的事情,就是OpenStack的部署工具,说的大一点,就是要解决OpenStack的交付问题,之前公司没有经验,交付存在各种问题,尤其影响到后面的继续维护,而部署工具是交付的重要一环,因此公司经过多次争论,决定使用社区的TripleO,放弃之前自己开发的部署工具,因此我承担起了对TripleO的开发维护工作,今天介绍的就是TripleO中的一个工具:tripleo-quickstart。 介绍TripleO-Quckstart是一个用来快速搭建TripleO测试环境的Ansible程序。在部署生产环境时,会用到instack-undercloud这个项目来部署undercloud,在该项目中有一个叫做instack-virt-setup的脚本,用来搭建测试tripleo用的虚拟环境,该脚本在Ocata版本将会被废弃,tripleo-quickstart就是用来取代它的,tripleo-quickstart通过一系列playbook,不仅可以用来搭建虚拟环境,还能在其上部署undercloud和overcloud,而且支持多种部署模式,部署完成之后,还能跑多种测试,检验部署结果,quickstart已经形成了一个完整的体系,而且使用方便,现在已经被社区用来跑TripleO的CI程序。 和tripleo-quickstart相关的还有一个叫做tripleo-quickstart-extras的项目,它是tripleo-quickstart功能的扩展,其实tripleo-quickstart本身只包含搭建虚拟测试环境的功能,包括环境检查,网络/存储/虚拟机的建立等等,而具体的搭建undercloud,overcloud的功能则是在tripleo-quickstart-extras中实现的,也包括测试验证,这些功能都被抽象成了ansible中的role。其实tripleo-quickstart-extras对tripleo-quickstart是没有依赖关系的,只要有环境,tripleo-quickstart-extras就用来部署undercloud/overcloud,不论是否是虚拟环境,因此在部署生产环境时也可以使用tripleo-quickstart-extras。其实本身tripleo的部署步骤就不复杂,tripleo-quickstart-extras只是将其中一些需要手动执行的命令编排了一下,让部署变得更加简单高效了。 TripleO-Quickstart默认定义很多的部署模式,主要有以下几种: minimal,只部署一台controller和一台compute节点 ha,部署三台controller和一台compute节点,三台controller的HA使用pacemaker管理 ha_big,部署三台controller和三台compute,三台controller的HA使用pacemaker管理 ceph,支持部署Ceph的Storage节点 其实这些都是通过简单的配置文件来设置的,tripleo-quickstart底层已经进行了很好的抽象,上层只需要简单的定义就可以支持多种部署模式了。 TripeO-Quickstart-Extras中还包含了很多验证部署结果的playbook,对部署完的集群功能是否正常进行有效验证,目前实现的主要有下面几种: validate-simple,简单验证,通过执行一系列OpenStack命令,比如创建网络,建虚拟机,创建stack,来验证基本的功能是否OK validate-tempest,功能验证,通过对overcloud来跑tempest测试来进行API层面的全面验证 validate-ha,验证HA功能,通过关停一些节点和服务来验证HA的功能 validate-undercloud,通过重装undercloud或者是跑一些常用OpenStack命令来验证undercloud功能 TripleO-Quickstart使用方便,环境要求简单,可以用来搭建开发测试环境,并且适合新手快速上手TripleO和OpenStack。 解析TripeleO-Quickstart是使用Ansible Playbook进行编排的,抽象出了一系列role,role中包含了各种具体的task,然后通过playbook对指定host进行编排,Ansible程序写的非常好,是一个很好的典范,这里对整个流程做下解析,方便加深对其的掌握,理解其原理。下图为执行quickstart过程中一些主要的步骤: 整个过程大体可以分为4个阶段,上图中分别用4种颜色标识: 第一个阶段,即浅红色部分,主要工作是清理上一次跑quickstart留下的环境,其中的non_root_user是在裸机上创建的一个用户,默认为stack,具有sudo权限,之后在裸机上执行的所有操作都是以这个用户的身份完成的。 第二个阶段,即浅黄色部分,是环境的准备阶段,因为是要在一台裸机上装虚拟机测试TripleO,所以要安装kvm,libvirt等包,而且要创建虚拟机使用的volume pool和libvirt network,下载镜像,然后定义undercloud和overcloud虚拟机,并且启动undercloud虚拟机等待下一个阶段使用。需要注意的是,这里仅仅下载了undercloud的镜像,因为undercloud镜像中已经包含了overcloud镜像,所以不需要额外下载。 第三个阶段,即浅绿色部分,是部署阶段,首先部署undercloud,然后部署overcloud,并且生成相应的rc文件,方便后面使用。因为本身tripleo本身就用了OpenStack中的很多组件,尤其是heat,已经进行了非常高度的抽象,因此部署步骤就很简单,quickstart将部署的命令都封装在了相应的脚本里,使用时,从ansible的模板生成,填上相应参数,传到undercloud的机器里执行。 第四个阶段,即浅蓝色部分,是验证阶段,验证OverCloud部署是否正常,验证HA是否正常,跑tempest进行功能验证等等。 这四个阶段,前两个阶段的步骤基本上都是在物理机上执行的,属于环境准备阶段,搭建起了TripleO的基本环境,这部分功能是在tripleo-quickstart中实现的,后两个阶段基本上都是在undercloud上执行的,是实际的部署验证阶段,这部分功能是在tripleo-quickstart-extras中实现的。 部署TripleO-Quickstart的部署要求必须有一台物理服务器,在quickstart中称作VIRTHOST,因为要建多台虚拟机,所以配置至少是16G内存,32G更好,对网络没有特殊要求,能通公网就行。本次测试所使用的物理服务器为64G内存,CPU型号为Intel(R) Xeon(R) CPU E5-2620 v3 @ 2.40GHz,是在Softlayer上申请的一台物理机。Softlayer在2013年被IBM收购,被整合到IBM的Bluemix中,Softlayer除了提供多种云服务外,也是唯一一家提供裸机服务的公有云,使用体验非常不错。 除了有一台物理服务器外,还需要有一个客户端机器,能够ssh到物理服务器,操作系统需要是RedHat系的,在quickstart中称为localhost,需要将quickstart的程序放到localhost中,然后ssh到物理服务器上执行相应的ansible程序。 本次测试将会部署一个HA模式的TripleO环境,有3个控制节点和1个计算节点,控制节点同时充当网络节点,quickstart目前默认部署Newton版本OpenStack,没有部署Ceph,存储使用本地存储。执行过程如下: 1234[root@localhost ~] export VIRTHOST='my_test_machine.example.com'[root@localhost ~] wget https://raw.githubusercontent.com/openstack/tripleo-quickstart/master/quickstart.sh[root@localhost ~] bash quickstart.sh --install-deps[root@localhost ~] bash ./quickstart.sh --tags all --config ~/.quickstart/tripleo-quickstart/config/general_config/ha.yml $VIRTHOST 在quickstart中,基本上为每一个task都通过tag做了分类,可以通过–tags来选择执行某些task,上面使用all即执行所有的task,也就是要搭建一个完整的环境。使用–config可以指定部署模式,这里选择的是ha模式,此外还有多种模式可以选择,该配置文件还定义了一些其他参数,比如虚拟机的配置,是否跑tempest等。因为在安装过程中要去装各种包,而且要下载undercloud的镜像,国外的网络环境较好,会遇到比较少的坑。 等待一杯咖啡的时间,一个具备HA的OpenStack虚拟环境就部署好了,包含三个控制节点,一个计算节点。 由于在OpenStack中最复杂最难懂的就是网络了,尤其是在虚拟环境中,网络拓扑要比物理环境还要复杂,而且是在一台物理服务器上安装一个具备完整功能的OpenStack环境,因此,下面重点介绍下用tripleo-quickstart部署出来的集群的网络拓扑,如下图: 在物理服务器上,通过libvirt network建立了两个linux bridge,分别为brext和brovc,brext用来桥接undercloud,brovc用来桥接overcloud节点,undercloud虚拟机有两个网卡,分别桥接在brext和brovc上。brext提供了访问公网的能力,brovc将多个overcloud和undercloud节点连接在一起。 Undercloud是一个单机版的OpenStack,安装了Nova, Neutron, Ironic, Heat等组件,Ironic通过使用pxe_ssh driver来管理overcloud节点,Neutron为overcloud节点提供网络环境,Heat则在TripleO中被用来编排,创建整个overcloud的stack。默认的TripleO会在undercloud中创建下面几个网络: 1234567891011[stack@undercloud ~]$ neutron net-list+--------------------------------------+--------------+------------------------------------------------------+| id | name | subnets |+--------------------------------------+--------------+------------------------------------------------------+| 0a5f9e61-7f2a-4d1b-9b44-4f82a1412ef4 | internal_api | 0fe319dd-ec16-47c5-b039-707373b7875c 172.16.2.0/24 || 305526f7-d0bf-49b5-947a-d25778836cba | tenant | a71b5b1c-5f52-4efd-af21-705192bf2a70 172.16.0.0/24 || 9e0930f3-8180-4fa3-a955-73aca0134795 | storage_mgmt | e596d585-7c12-471e-89de-eea48cabc0df 172.16.3.0/24 || be6f07e5-e212-4e9e-bc7b-0edd4eb11b13 | ctlplane | ad8a6a4d-c5a3-4c44-a17d-1d1a9341f33b 192.168.24.0/24 || f303022b-907e-4ce6-a2c0-c1ce7fabe9ac | external | 4a47bc1b-a7f2-45a4-bd40-2f3032198b34 10.0.0.0/24 || fcdff6b1-43d2-4787-b458-368b48b97a73 | storage | 6de1e1b3-4972-49b1-ae19-20e163af010c 172.16.1.0/24 |+--------------------------------------+--------------+------------------------------------------------------+ 这几个网络都是Flat模式的网络,internal_api在overcloud中被用来作为内部API交互的网络,tenant是SDN网络,storage_mgmt是存储管理网络,ctlplane是管理网络,即ssh网络,external是外部网络,storage是存储网。 在上面的网络中,只有ctlplane的子网开启了DHCP功能,为overcloud的管理网络分配IP,因此在undercloud中有一个DHCP的namespace,通过veth tap桥接在br-int ovs网桥上。 external网络是用来开放API和面板的,跟internal_api对应,在overcloud中,API和面板分别绑定在了internal_api和external网络上,在undercloud的br-ctlplane ovs网桥上,创建了一个vlan10的port,并且在undercloud中加上了相应的路由信息,这样在undercloud中就可以直接通过这个网络访问overcloud中的API了: 123[stack@undercloud ~]$ ip rdefault via 192.168.23.1 dev eth010.0.0.0/24 dev vlan10 proto kernel scope link src 10.0.0.1 在overcloud中,通过在br-ex ovs网桥上绑定多个带tag的port,并且每个port分配了IP,来模拟多个网络,分别对应undercloud中创建的网络。在br-ex中的每个port都带着vlan tag,模拟交换机的access口,通过不同的vlan互相隔离。 控制节点和计算节点之间的SDN网络,即tenant网络,在上面图中使用黄色标注,即vlan50,通过vxlan建立隧道,使用vlan50上的ip作为对端IP。 其他就是标准的Neutron网络了。 附录 undercloud br-ctlplane流表 12345[stack@undercloud ~]$ sudo ovs-ofctl dump-flows br-ctlplaneNXST_FLOW reply (xid=0x4):cookie=0xa382b4ab161c4f5a, duration=287114.883s, table=0, n_packets=640, n_bytes=63449, idle_age=65534, hard_age=65534, priority=4,in_port=2,dl_vlan=1 actions=strip_vlan,NORMALcookie=0xa382b4ab161c4f5a, duration=287154.063s, table=0, n_packets=3, n_bytes=258, idle_age=65534, hard_age=65534, priority=2,in_port=2 actions=dropcookie=0xa382b4ab161c4f5a, duration=287154.285s, table=0, n_packets=9441009, n_bytes=29995798300, idle_age=0, hard_age=65534, priority=0 actions=NORMAL undercloud br-int流表 1234567[stack@undercloud ~]$ sudo ovs-ofctl dump-flows br-intNXST_FLOW reply (xid=0x4):cookie=0xbca7bd29346f9fa9, duration=287162.954s, table=0, n_packets=581299, n_bytes=31344498, idle_age=0, hard_age=65534, priority=3,in_port=1,vlan_tci=0x0000/0x1fff actions=mod_vlan_vid:1,NORMALcookie=0xbca7bd29346f9fa9, duration=287202.139s, table=0, n_packets=94718, n_bytes=7492743, idle_age=707, hard_age=65534, priority=2,in_port=1 actions=dropcookie=0xbca7bd29346f9fa9, duration=287202.890s, table=0, n_packets=643, n_bytes=63707, idle_age=65534, hard_age=65534, priority=0 actions=NORMALcookie=0xbca7bd29346f9fa9, duration=287202.892s, table=23, n_packets=0, n_bytes=0, idle_age=65534, hard_age=65534, priority=0 actions=dropcookie=0xbca7bd29346f9fa9, duration=287202.889s, table=24, n_packets=0, n_bytes=0, idle_age=65534, hard_age=65534, priority=0 actions=drop controll_0 br-tun流表 123456789101112131415161718[root@overcloud-controller-1 ~]# ovs-ofctl dump-flows br-tunNXST_FLOW reply (xid=0x4):cookie=0x8513f3cebfb2fa84, duration=276881.519s, table=0, n_packets=583649, n_bytes=31994786, idle_age=0, hard_age=65534, priority=1,in_port=1 actions=resubmit(,2)cookie=0x8513f3cebfb2fa84, duration=276869.341s, table=0, n_packets=9, n_bytes=670, idle_age=65534, hard_age=65534, priority=1,in_port=2 actions=resubmit(,4)cookie=0x8513f3cebfb2fa84, duration=276869.338s, table=0, n_packets=4, n_bytes=280, idle_age=65534, hard_age=65534, priority=1,in_port=4 actions=resubmit(,4)cookie=0x8513f3cebfb2fa84, duration=276869.335s, table=0, n_packets=40902, n_bytes=3189452, idle_age=3, hard_age=65534, priority=1,in_port=3 actions=resubmit(,4)cookie=0x8513f3cebfb2fa84, duration=276881.518s, table=0, n_packets=0, n_bytes=0, idle_age=65534, hard_age=65534, priority=0 actions=dropcookie=0x8513f3cebfb2fa84, duration=276881.516s, table=2, n_packets=40074, n_bytes=2719568, idle_age=3, hard_age=65534, priority=0,dl_dst=00:00:00:00:00:00/01:00:00:00:00:00 actions=resubmit(,20)cookie=0x8513f3cebfb2fa84, duration=276881.515s, table=2, n_packets=543575, n_bytes=29275218, idle_age=0, hard_age=65534, priority=0,dl_dst=01:00:00:00:00:00/01:00:00:00:00:00 actions=resubmit(,22)cookie=0x8513f3cebfb2fa84, duration=276881.514s, table=3, n_packets=0, n_bytes=0, idle_age=65534, hard_age=65534, priority=0 actions=dropcookie=0x8513f3cebfb2fa84, duration=239847.949s, table=4, n_packets=40889, n_bytes=3188008, idle_age=3, hard_age=65534, priority=1,tun_id=0x5c actions=mod_vlan_vid:4,resubmit(,10)cookie=0x8513f3cebfb2fa84, duration=276881.513s, table=4, n_packets=6, n_bytes=460, idle_age=65534, hard_age=65534, priority=0 actions=dropcookie=0x8513f3cebfb2fa84, duration=276881.401s, table=6, n_packets=0, n_bytes=0, idle_age=65534, hard_age=65534, priority=0 actions=dropcookie=0x8513f3cebfb2fa84, duration=276881.399s, table=10, n_packets=40909, n_bytes=3189942, idle_age=3, hard_age=65534, priority=1 actions=learn(table=20,hard_timeout=300,priority=1,cookie=0x8513f3cebfb2fa84,NXM_OF_VLAN_TCI[0..11],NXM_OF_ETH_DST[]=NXM_OF_ETH_SRC[],load:0->NXM_OF_VLAN_TCI[],load:NXM_NX_TUN_ID[]->NXM_NX_TUN_ID[],output:OXM_OF_IN_PORT[]),output:1cookie=0x8513f3cebfb2fa84, duration=35533.393s, table=20, n_packets=17409, n_bytes=1251681, hard_timeout=300, idle_age=3, hard_age=2, priority=1,vlan_tci=0x0004/0x0fff,dl_dst=fa:16:3e:6b:d6:95 actions=load:0->NXM_OF_VLAN_TCI[],load:0x5c->NXM_NX_TUN_ID[],output:3cookie=0x8513f3cebfb2fa84, duration=276881.398s, table=20, n_packets=53, n_bytes=3850, idle_age=65534, hard_age=65534, priority=0 actions=resubmit(,22)cookie=0x8513f3cebfb2fa84, duration=239847.950s, table=22, n_packets=30, n_bytes=1316, idle_age=65534, hard_age=65534, priority=1,dl_vlan=4 actions=strip_vlan,load:0x5c->NXM_NX_TUN_ID[],output:3,output:4,output:2cookie=0x8513f3cebfb2fa84, duration=276881.397s, table=22, n_packets=543596, n_bytes=29277612, idle_age=0, hard_age=65534, priority=0 actions=drop contorll_0 br-int流表 1234567[root@overcloud-controller-1 ~]# ovs-ofctl dump-flows br-intNXST_FLOW reply (xid=0x4):cookie=0xbb0786bfe2f662c0, duration=265938.161s, table=0, n_packets=577089, n_bytes=31597998, idle_age=0, hard_age=65534, priority=3,in_port=1,vlan_tci=0x0000/0x1fff actions=mod_vlan_vid:3,NORMALcookie=0xbb0786bfe2f662c0, duration=276928.381s, table=0, n_packets=106165, n_bytes=7751821, idle_age=885, hard_age=65534, priority=2,in_port=1 actions=dropcookie=0xbb0786bfe2f662c0, duration=276928.545s, table=0, n_packets=120741, n_bytes=9002846, idle_age=4, hard_age=65534, priority=0 actions=NORMALcookie=0xbb0786bfe2f662c0, duration=276928.547s, table=23, n_packets=0, n_bytes=0, idle_age=65534, hard_age=65534, priority=0 actions=dropcookie=0xbb0786bfe2f662c0, duration=276928.544s, table=24, n_packets=0, n_bytes=0, idle_age=65534, hard_age=65534, priority=0 actions=drop controll_0 br-ex流表 12345[root@overcloud-controller-1 ~]# ovs-ofctl dump-flows br-exNXST_FLOW reply (xid=0x4):cookie=0xafa8d606ada81d76, duration=265988.920s, table=0, n_packets=39286, n_bytes=3071764, idle_age=5, hard_age=65534, priority=4,in_port=7,dl_vlan=3 actions=strip_vlan,NORMALcookie=0xafa8d606ada81d76, duration=276979.138s, table=0, n_packets=1265, n_bytes=66286, idle_age=21267, hard_age=65534, priority=2,in_port=7 actions=dropcookie=0xafa8d606ada81d76, duration=276979.143s, table=0, n_packets=489065150, n_bytes=98446193899, idle_age=0, hard_age=65534, priority=0 actions=NORMAL compute_0 br-tun流表 123456789101112131415161718[root@overcloud-novacompute-0 ~]# ovs-ofctl dump-flows br-tunNXST_FLOW reply (xid=0x4):cookie=0x85eb64bc3e0f6ac1, duration=277743.277s, table=0, n_packets=46825, n_bytes=3485282, idle_age=1, hard_age=65534, priority=1,in_port=1 actions=resubmit(,2)cookie=0x85eb64bc3e0f6ac1, duration=277059.640s, table=0, n_packets=142, n_bytes=23696, idle_age=21319, hard_age=65534, priority=1,in_port=3 actions=resubmit(,4)cookie=0x85eb64bc3e0f6ac1, duration=277023.273s, table=0, n_packets=40094, n_bytes=2719688, idle_age=1, hard_age=65534, priority=1,in_port=4 actions=resubmit(,4)cookie=0x85eb64bc3e0f6ac1, duration=233719.067s, table=0, n_packets=62, n_bytes=5192, idle_age=65534, hard_age=65534, priority=1,in_port=2 actions=resubmit(,4)cookie=0x85eb64bc3e0f6ac1, duration=277743.276s, table=0, n_packets=0, n_bytes=0, idle_age=65534, hard_age=65534, priority=0 actions=dropcookie=0x85eb64bc3e0f6ac1, duration=277743.274s, table=2, n_packets=40113, n_bytes=3161002, idle_age=1, hard_age=65534, priority=0,dl_dst=00:00:00:00:00:00/01:00:00:00:00:00 actions=resubmit(,20)cookie=0x85eb64bc3e0f6ac1, duration=277743.273s, table=2, n_packets=6712, n_bytes=324280, idle_age=65534, hard_age=65534, priority=0,dl_dst=01:00:00:00:00:00/01:00:00:00:00:00 actions=resubmit(,22)cookie=0x85eb64bc3e0f6ac1, duration=277743.272s, table=3, n_packets=0, n_bytes=0, idle_age=65534, hard_age=65534, priority=0 actions=dropcookie=0x85eb64bc3e0f6ac1, duration=197945.765s, table=4, n_packets=39836, n_bytes=2683592, idle_age=1, hard_age=65534, priority=1,tun_id=0x5c actions=mod_vlan_vid:6,resubmit(,10)cookie=0x85eb64bc3e0f6ac1, duration=277743.271s, table=4, n_packets=15, n_bytes=1090, idle_age=65534, hard_age=65534, priority=0 actions=dropcookie=0x85eb64bc3e0f6ac1, duration=277743.270s, table=6, n_packets=0, n_bytes=0, idle_age=65534, hard_age=65534, priority=0 actions=dropcookie=0x85eb64bc3e0f6ac1, duration=277743.269s, table=10, n_packets=40283, n_bytes=2747486, idle_age=1, hard_age=65534, priority=1 actions=learn(table=20,hard_timeout=300,priority=1,cookie=0x85eb64bc3e0f6ac1,NXM_OF_VLAN_TCI[0..11],NXM_OF_ETH_DST[]=NXM_OF_ETH_SRC[],load:0->NXM_OF_VLAN_TCI[],load:NXM_NX_TUN_ID[]->NXM_NX_TUN_ID[],output:OXM_OF_IN_PORT[]),output:1cookie=0x85eb64bc3e0f6ac1, duration=35687.268s, table=20, n_packets=17405, n_bytes=1695793, hard_timeout=300, idle_age=1, hard_age=1, priority=1,vlan_tci=0x0006/0x0fff,dl_dst=fa:16:3e:ea:0e:8f actions=load:0->NXM_OF_VLAN_TCI[],load:0x5c->NXM_NX_TUN_ID[],output:4cookie=0x85eb64bc3e0f6ac1, duration=277743.268s, table=20, n_packets=112, n_bytes=11204, idle_age=21324, hard_age=65534, priority=0 actions=resubmit(,22)cookie=0x85eb64bc3e0f6ac1, duration=197945.766s, table=22, n_packets=122, n_bytes=12480, idle_age=21324, hard_age=65534, priority=1,dl_vlan=6 actions=strip_vlan,load:0x5c->NXM_NX_TUN_ID[],output:4,output:3,output:2cookie=0x85eb64bc3e0f6ac1, duration=277743.267s, table=22, n_packets=5678, n_bytes=276452, idle_age=65534, hard_age=65534, priority=0 actions=drop compute_0 br-int流表 123456789101112[root@overcloud-novacompute-0 ~]# ovs-ofctl dump-flows br-intNXST_FLOW reply (xid=0x4):cookie=0xa765c80babd2ebcb, duration=197996.437s, table=0, n_packets=0, n_bytes=0, idle_age=65534, hard_age=65534, priority=10,icmp6,in_port=8,icmp_type=136 actions=resubmit(,24)cookie=0xa765c80babd2ebcb, duration=197996.435s, table=0, n_packets=7167, n_bytes=301014, idle_age=8, hard_age=65534, priority=10,arp,in_port=8 actions=resubmit(,24)cookie=0xa765c80babd2ebcb, duration=277794.862s, table=0, n_packets=648145, n_bytes=37251497, idle_age=1, hard_age=65534, priority=2,in_port=1 actions=dropcookie=0xa765c80babd2ebcb, duration=197996.441s, table=0, n_packets=32551, n_bytes=2813463, idle_age=3, hard_age=65534, priority=9,in_port=8 actions=resubmit(,25)cookie=0xa765c80babd2ebcb, duration=277794.916s, table=0, n_packets=40319, n_bytes=2750270, idle_age=3, hard_age=65534, priority=0 actions=NORMALcookie=0xa765c80babd2ebcb, duration=277794.918s, table=23, n_packets=0, n_bytes=0, idle_age=65534, hard_age=65534, priority=0 actions=dropcookie=0xa765c80babd2ebcb, duration=197996.439s, table=24, n_packets=0, n_bytes=0, idle_age=65534, hard_age=65534, priority=2,icmp6,in_port=8,icmp_type=136,nd_target=fe80::f816:3eff:fe6b:d695 actions=NORMALcookie=0xa765c80babd2ebcb, duration=197996.436s, table=24, n_packets=7155, n_bytes=300510, idle_age=8, hard_age=65534, priority=2,arp,in_port=8,arp_spa=192.168.100.104 actions=resubmit(,25)cookie=0xa765c80babd2ebcb, duration=277794.915s, table=24, n_packets=18, n_bytes=756, idle_age=21373, hard_age=65534, priority=0 actions=dropcookie=0xa765c80babd2ebcb, duration=197996.443s, table=25, n_packets=39706, n_bytes=3113973, idle_age=3, hard_age=65534, priority=2,in_port=8,dl_src=fa:16:3e:6b:d6:95 actions=NORMAL compute_0 br-ex流表 1234[root@overcloud-novacompute-0 ~]# ovs-ofctl dump-flows br-exNXST_FLOW reply (xid=0x4):cookie=0xaff6936771f75f35, duration=277836.775s, table=0, n_packets=1199, n_bytes=62346, idle_age=21417, hard_age=65534, priority=2,in_port=5 actions=dropcookie=0xaff6936771f75f35, duration=277836.795s, table=0, n_packets=6286307, n_bytes=3591027442, idle_age=0, hard_age=65534, priority=0 actions=NORMAL","link":"/2017/02/25/openstack/tripleo/quickstart.html"},{"title":"TripleO UnderCloud 安装原理","text":"本节介绍UnderCloud的安装原理,UnderCloud主要是依赖于OpenStack本身的功能来安装OverCloud,OverCloud就是最终要交付的OpenStack环境。UnderCloud是一个单机版的OpenStack环境,主要安装的组件有nova, glance, keystone, neutron, heat, ironic, swift,还可以选择性的安装telemetry, mistral, zaqar等组件。 安装undercloud只使用一个简单的命令: openstack undercloud install就可以了,这个命令是python-tripleoclient提供的,它是python-openstackclient的一个插件,除了有安装undercloud的命令外,还有安装overcloud的命令,而在安装undercloud时,python-tripleoclient只是简单的调用了instack-undercloud中提供的命令: instack-install-undercloud,instack-undercloud就是专门用来安装和升级undercloud的,在instack-undercloud中,又使用了 instack + os-refresh-config + puppet来部署undercloud,所以这些组件的依赖关系如下: 123456789.└── python-openstackclient └── python-tripleoclient └── instack-undercloud ├── instack └── os-refresh-config ├── os-apply-config ├── puppet └── os-cloud-config instack和os-refresh-config的具体细节请参见本章“依赖组件”一节中的内容,instack里主要应用了dib elements来定制当前系统,在instack-undercloud中,指定了多个elements来执行,主要是为后面安装undercloud做些准备工作,比如生成os-refresh-config需要的脚本,生成hieradata等等;而os-refresh-config则在安装undercloud过程中起到了整体的编排作用,它会去调用os-apply-config配置当前系统,跑puppet安装OpenStack组件,然后建立安装overcloud使用的网络等等;puppet是在os-refresh-config过程中被执行的,使用puppet apply在本地执行puppet代码,安装undercloud用到的各个OpenStack组件;在安装完成OpenStack各个组件后,os-refresh-config会去调用os-cloud-config提供的命令setup-neutron去建立管理网络。 下面来详细介绍下instack-undercloud安装undercloud中的主要步骤: 1. 生成环境变量在安装undercloud之前,先要在stack用户的home目录下创建一个undercloud.conf配置文件,在该文件中定义了安装undercloud需要用的配置项,因为在安装时,本质上是在跑各种脚本,在脚本中会用到各种变量,这些变量的值需要从环境变量中获取,因此instack-install-undercloud命令先要将读取undercloud.conf中的配置项,然后将其转化为环境变量,方便后面的脚本使用,当然,脚本使用的环境变量不仅仅是在这个阶段生成的,在各个elements中,也定义了各种环境变量,在执行elemenets之前,会先被导出来。在安装undercloud时,生成的环境变量见附录1. 2. 生成Metadata配置文件在后面的步骤中会执行os-apply-config,os-apply-config需要用到一个json格式的metadata配置文件,用来渲染模板,生成系统中的配置,这个json格式的metadata配置文件就是在这个阶段生成的,里面包括了hieradata的配置,neutron的配置,os-net-config的配置等等,该文件的保存路径为:/var/lib/heat-cfntools/cfn-init-data,可能是由于历史原因,是以cfn命名的,这也是os-apply-config最低优先级去找的配置文件,该文件的示例请参见附录2. 3. 执行instack在instack-undercloud中指定了一些elements去执行,这些elements分别来自不同的项目,有tripleo-image-elements中定义的,有instack-undercloud中定义的,还有diskimage-builder中定义的,这些信息都被配置在一个json格式的配置文件中,执行的命令如下: 123sudo -E instack \\ -p /usr/share/tripleo-puppet-elements:/usr/share/instack-undercloud:/usr/share/tripleo-image-elements:/usr/share/diskimage-builder/elements \\ -j /usr/share/instack-undercloud/json-files/centos-7-undercloud-packages.json centos-7-undercloud-packages.json文件的内容如下: 12345678910111213141516171819202122232425262728293031323334[ { "name": "Installation", "element": [ "install-types", "undercloud-install", "enable-packages-install", "element-manifest", "puppet-stack-config" ], "hook": [ "extra-data", "pre-install", "install", "post-install" ], "exclude-element": [ "pip-and-virtualenv", "os-collect-config", "svc-map", "pip-manifest", "package-installs", "pkg-map", "puppet", "cache-url", "dib-python", "os-svc-install", "install-bin" ], "blacklist": [ "99-refresh-completed" ] }] 即指定了instack-types, undercloud-install, enable-packages-install, element-manifest, puppet-stack-config这几个elements,因为每一个elements都有依赖,所以最终处理完依赖,要执行的elements全量为: install-types element-manifest manifests source-repositories puppet-modules hiera enable-packages-install os-apply-config os-refresh-config undercloud-install puppet-stack-config 每一个elements都包含了一些hook,instack的配置文件指定了只执行extra-data, pre-install, install, post-install这4个hook,这些hook以及hook中的脚本请参见附录3,合并之后的hook请参见附录4。 instack通过执行这些elements,大概做了几下几件事情: 从package安装各个项目的puppet代码,因为还支持从源码安装puppet代码 生成puppet的入口代码:puppet-stack-config.pp,以及生成puppet hieradata:puppet-stack-config.yaml 生成os-apply-config使用的模板文件 生成os-refresh-config使用的脚本 4. 执行os-refresh-config在上一步中已经生成了os-refresh-config所需要使用的脚本,是通过os-refresh-config这个element生成的,这些脚本分散在每一个element中,每一个element除了包含instack执行过程中的hook外,还包含了os-refresh-config和os-apply-config需要用到的hook,通过os-refresh-config和os-apply-config这两个elements将这些hook合并到一起,如下: 123456789101112131415161718192021222324252627282930▾ os-apply-config/ ▾ etc/ ▾ os-net-config/ config.json ▾ puppet/ ▾ hieradata/ CentOS.yaml RedHat.yaml hiera.yaml ▾ root/ stackrc stackrc.oac tripleo-undercloud-passwords tripleo-undercloud-passwords.oac ▾ var/ ▾ opt/ ▾ undercloud-stack/ masquerade▾ os-refresh-config/ ▾ configure.d/ 20-os-apply-config* 30-reload-keepalived* 40-hiera-datafiles* 40-truncate-nova-config* 50-puppet-stack-config* ▾ post-configure.d/ 10-iptables* 80-seedstack-masquerade* 98-undercloud-setup* 99-refresh-completed* 在生成的os-refresh-config中,只包含了configure和post-configure两个hook,因此先执行configure.d中的脚本,然后执行post-configure.d中的脚本,其中比较重要的是如下几个脚本: 在20-os-apply-config中执行了os-apply-config命令,将os-apply-config中的使用json metadata文件渲染模板,然后将生成的配置文件放置到对应的位置上去 50-puppet-stack-config就是执行puppet代码了,是通过puppet apply的方式执行: 1puppet apply --detailed-exitcodes /etc/puppet/manifests/puppet-stack-config.pp 这一步就是安装undercloud需要使用到的各个OpenStack组件了。 98-undercloud-setup,在安装好OpenStack组件之后,调用os-cloud-config提供的命令setup-neutron去创建安装OverCloud需要使用的管理网络。此外,如果enable了mistral功能,还会去创建workbook。 除此之外,就是配置一些iptables规则,比如允许ip forwarding,可以让overcloud节点能访问外网,配置169.254.169.254的NAT规则,打通虚拟机访问metadata的通道等等,最终建立出来的undercloud网络拓扑如下: 经过上面这几步,就完成了undercloud的安装,整体来看undercloud采用了脚本+puppet的方式进行安装,安装过程非常复杂,定制化主要也是写elements,需要非常了解其中的原理才能定制undercloud,在现在的master分支,也就是Pike版本的TripleO中,采用了新的方法去安装undercloud,即也采用heat去部署,见这里,不再依赖instack-undercloud中的各种elements,这样undercloud和overcloud的安装方法就统一了。 附录1安装undercloud时,生成的环境变量示例: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153{ 'UNDERCLOUD_HORIZON_SECRET_KEY':'51a4c2aee7cfa93efee549c2a1bd48e9a3501494', 'TARGET_ROOT':'/', 'UNDERCLOUD_IRONIC_PASSWORD':'7558a0701f802a84faa625e0bc563170a97aa6cb', 'INSPECTION_COLLECTORS':'default,extra-hardware,logs', 'ENABLE_MISTRAL':'True', 'SHELL':'/bin/bash', 'UNDERCLOUD_ENDPOINT_SWIFT_ADMIN':'http://192.168.24.1:8080', 'UNDERCLOUD_ENDPOINT_GLANCE_ADMIN':'http://192.168.24.1:9292', 'UNDERCLOUD_CEILOMETER_SNMPD_PASSWORD':'7e698c4fdb9a0eccc0a079c456c7a32a816120df', 'UNDERCLOUD_ENDPOINT_NOVA_ADMIN':'http://192.168.24.1:8774/v2.1', 'NODE_DIST':'centos7', 'HISTSIZE':'1000', 'UNDERCLOUD_DEBUG':'True', 'UNDERCLOUD_DB_PASSWORD':'f2f25d1df2f05b810d7819bcfea45803185df833', 'INSPECTION_RUNBENCH':'False', 'SERVICE_PRINCIPAL':'', 'UNDERCLOUD_SWIFT_HASH_SUFFIX':'b3c4eef1928b9b84427c06f2a1acc5d7f1b2f198', 'UNDERCLOUD_ENDPOINT_IRONIC_INTERNAL':'http://192.168.24.1:6385', 'XDG_RUNTIME_DIR':'/run/user/1000', 'TRIPLEO_INSTALL_USER':'stack', 'UNDERCLOUD_ENDPOINT_KEYSTONE_PUBLIC':'https://192.168.24.2:13000', 'INSPECTION_KERNEL_ARGS':'ipa-debug=1 ipa-inspection-dhcp-all-interfaces=1 ipa-collect-lldp=1', 'STORE_EVENTS':'False', 'XDG_SESSION_ID':'198', 'UNDERCLOUD_ADMIN_VIP':'192.168.24.3', 'UNDERCLOUD_ENDPOINT_CEILOMETER_ADMIN':'http://192.168.24.1:8777', 'JSONFILE':'/usr/share/instack-undercloud/json-files/centos-7-undercloud-packages.json', 'HOSTNAME':'undercloud.localdomain', 'SELINUX_LEVEL_REQUESTED':'', 'UNDERCLOUD_ENDPOINT_AODH_PUBLIC':'https://192.168.24.2:13042', 'UNDERCLOUD_ENDPOINT_AODH_INTERNAL':'http://192.168.24.1:8042', 'UNDERCLOUD_ENDPOINT_MISTRAL_PUBLIC':'https://192.168.24.2:13989/v2', 'DIB_INIT_SYSTEM':'systemd', 'INSPECTION_INTERFACE':'br-ctlplane', 'UNDERCLOUD_ENDPOINT_KEYSTONE_INTERNAL':'http://192.168.24.1:5000', 'MAIL':'/var/spool/mail/stack', 'TRIPLEO_UNDERCLOUD_PASSWORD_FILE':'/home/stack/undercloud-passwords.conf', 'SCHEDULER_MAX_ATTEMPTS':'30', 'UNDERCLOUD_ENDPOINT_HEAT_INTERNAL':'http://192.168.24.1:8004/v1/%(tenant_id)s', 'UNDERCLOUD_ENDPOINT_AODH_ADMIN':'http://192.168.24.1:8042', 'UNDERCLOUD_HEAT_PASSWORD':'561b715ce0f8ffe0c1482eeaed3df09cd2590a91', 'LOCAL_INTERFACE':'eth1', 'LESSOPEN':'||/usr/bin/lesspipe.sh %s', 'MASQUERADE_NETWORK':'192.168.24.0/24', 'USER':'root', 'UNDERCLOUD_ENDPOINT_HEAT_PUBLIC':'https://192.168.24.2:13004/v1/%(tenant_id)s', 'UNDERCLOUD_HEAT_STACK_DOMAIN_ADMIN_PASSWORD':'38e14254ddf98458ba09dcf600d936d3631a28b7', 'UNDERCLOUD_ENDPOINT_NEUTRON_ADMIN':'http://192.168.24.1:9696', 'SHLVL':'3', 'UNDERCLOUD_ENDPOINT_ZAQAR_ADMIN':'http://192.168.24.1:8888', 'UNDERCLOUD_ENDPOINT_CEILOMETER_PUBLIC':'https://192.168.24.2:13777', 'UNDERCLOUD_ENDPOINT_KEYSTONE_ADMIN':'http://192.168.24.1:35357', 'SUDO_USER':'stack', 'UNDERCLOUD_ENDPOINT_IRONIC_INSPECTOR_INTERNAL':'http://192.168.24.1:5050', 'ELEMENTS_PATH':'/usr/share/tripleo-puppet-elements:/usr/share/instack-undercloud:/usr/share/tripleo-image-elements:/usr/share/diskimage-builder/elements', 'ENABLE_VALIDATIONS':'True', 'UNDERCLOUD_ENDPOINT_ZAQAR_WEBSOCKET_INTERNAL':'ws://192.168.24.1:9000', 'UNDERCLOUD_ADMIN_PASSWORD':'78ad5a42318e7d41e5a2e0f1f9600375ba985b16', 'LOCAL_MTU':'1500', 'TMP_MOUNT_PATH':'/tmp/instack.gbtlM6/mnt', 'UNDERCLOUD_ENDPOINT_MISTRAL_INTERNAL':'http://192.168.24.1:8989/v2', 'SSH_CONNECTION':'192.168.23.1 37621 192.168.23.47 22', 'IMAGE_PATH':'.', 'GUESTFISH_OUTPUT':'\\\\e[0m', 'UNDERCLOUD_ENDPOINT_SWIFT_INTERNAL':'http://192.168.24.1:8080/v1/AUTH_%(tenant_id)s', 'GENERATE_SERVICE_CERTIFICATE':'True', 'UNDERCLOUD_CEILOMETER_METERING_SECRET':'989d08bc16f00298e7b0cf9fd396d5477a46c959', 'DIB_IMAGE_CACHE':'/root/.cache/image-create', 'DIB_DEFAULT_INSTALLTYPE':'package', 'IPXE_ENABLED':'True', 'UNDERCLOUD_ENDPOINT_IRONIC_INSPECTOR_PUBLIC':'https://192.168.24.2:13050', 'SELINUX_USE_CURRENT_RANGE':'', 'CLEAN_NODES':'False', 'UNDERCLOUD_ENDPOINT_NOVA_PUBLIC':'https://192.168.24.2:13774/v2.1', 'HOME':'/root', 'UNDERCLOUD_ENDPOINT_ZAQAR_INTERNAL':'http://192.168.24.1:8888', 'UNDERCLOUD_ENDPOINT_IRONIC_INSPECTOR_ADMIN':'http://192.168.24.1:5050', 'GUESTFISH_INIT':'\\\\e[1;34m', 'LANG':'en_US.utf8', 'UNDERCLOUD_SERVICE_CERTIFICATE':'/etc/pki/tls/certs/undercloud-192.168.24.2.pem', 'ENABLE_TELEMETRY':'True', 'DHCP_START':'192.168.24.5', 'IMAGE_NAME':'instack', 'DIB_OFFLINE':'', 'ENABLE_UI':'True', '_':'/usr/bin/python', 'NET_CONFIG_OVERRIDE':'', 'PUBLIC_INTERFACE_IP':'192.168.24.1/24', 'UNDERCLOUD_ENDPOINT_IRONIC_ADMIN':'http://192.168.24.1:6385', 'USERNAME':'root', 'UNDERCLOUD_ENDPOINT_HEAT_ADMIN':'http://192.168.24.1:8004/v1/%(tenant_id)s', 'LOCAL_IP':'192.168.24.1', 'SELINUX_ROLE_REQUESTED':'', 'UNDERCLOUD_ENDPOINT_GLANCE_INTERNAL':'http://192.168.24.1:9292', 'SUDO_GID':'1000', 'UNDERCLOUD_ENDPOINT_IRONIC_PUBLIC':'https://192.168.24.2:13385', '_LIB':'/usr/share/diskimage-builder/lib', 'SSH_TTY':'/dev/pts/1', 'DHCP_END':'192.168.24.30', 'UNDERCLOUD_ENDPOINT_NEUTRON_INTERNAL':'http://192.168.24.1:9696', 'UNDERCLOUD_ENDPOINT_ZAQAR_WEBSOCKET_ADMIN':'ws://192.168.24.1:9000', 'UNDERCLOUD_RABBIT_COOKIE':'274d7049a7cdd3be29759776b6054907be3cb410', 'NETWORK_GATEWAY':'192.168.24.1', 'UNDERCLOUD_ENDPOINT_NOVA_INTERNAL':'http://192.168.24.1:8774/v2.1', 'INSPECTION_ENABLE_UEFI':'True', 'TRIPLEO_UNDERCLOUD_CONF_FILE':'/home/stack/undercloud.conf', 'CERTIFICATE_GENERATION_CA':'local', 'HIERADATA_OVERRIDE':'quickstart-hieradata-overrides.yaml', 'UNDERCLOUD_NEUTRON_PASSWORD':'4bde341a0b0183b0b7d40d71a9f4f7d78b62f163', 'ENABLE_ZAQAR':'True', 'SSH_CLIENT':'192.168.23.1 37621 22', 'UNDERCLOUD_ENDPOINT_MISTRAL_ADMIN':'http://192.168.24.1:8989/v2', 'LOGNAME':'root', 'UNDERCLOUD_CEILOMETER_PASSWORD':'457c7fb371fe0228e41484de9b77d7515146c3c1', 'PATH':'/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/bin:/tmp/tmp0o4uKR/bin', 'UNDERCLOUD_ENDPOINT_SWIFT_PUBLIC':'https://192.168.24.2:13808/v1/AUTH_%(tenant_id)s', 'INSPECTION_IPRANGE':'192.168.24.100,192.168.24.120', 'GUESTFISH_RESTORE':'\\\\e[0m', 'MEMBER_ROLE_EXISTS':'False', 'UNDERCLOUD_ENDPOINT_ZAQAR_PUBLIC':'https://192.168.24.2:13888', 'TERM':'screen', 'ENABLE_TEMPEST':'True', 'ARCH':'amd64', 'IMAGE_ELEMENT':u'element-manifest enable-packages-install hiera install-types manifests os-apply-config os-refresh-config puppet-modules puppet-stack-config source-repositories undercloud-install', 'UNDERCLOUD_ZAQAR_PASSWORD':'a563879e04e18c7c4cf3f2b4e66637b1d84b9e10', 'DIB_ARGS':"['/bin/instack', '-p', '/usr/share/tripleo-puppet-elements:/usr/share/instack-undercloud:/usr/share/tripleo-image-elements:/usr/share/diskimage-builder/elements', '-j', '/usr/share/instack-undercloud/json-files/centos-7-undercloud-packages.json']", 'UNDERCLOUD_AODH_PASSWORD':'9b4aacbb5ea53d59946e22152e4296e9e157fb8b', 'UNDERCLOUD_ADMIN_TOKEN':'f4d890602dc406c76cf3c156f3a8c374a2028b8b', 'UNDERCLOUD_ENDPOINT_ZAQAR_WEBSOCKET_PUBLIC':'wss://192.168.24.2:9000', 'UNDERCLOUD_CEILOMETER_SNMPD_USER':'ro_snmp_user', 'GUESTFISH_PS1':'\\\\[\\\\e[1;32m\\\\]><fs>\\\\[\\\\e[0;31m\\\\] ', 'UNDERCLOUD_SWIFT_PASSWORD':'6646c8abbc69900be43ad9aeb2fb9bcde084b83c', 'UNDERCLOUD_ENDPOINT_GLANCE_PUBLIC':'https://192.168.24.2:13292', 'SUDO_UID':'1000', 'UNDERCLOUD_ENDPOINT_CEILOMETER_INTERNAL':'http://192.168.24.1:8777', 'TMP_HOOKS_PATH':'/tmp/tmp0o4uKR', 'UNDERCLOUD_ENDPOINT_NEUTRON_PUBLIC':'https://192.168.24.2:13696', 'UNDERCLOUD_HAPROXY_STATS_PASSWORD':'cfe22a516eb260a515368b42ecdafd0c31a4eab0', 'SUDO_COMMAND':'/bin/instack -p /usr/share/tripleo-puppet-elements:/usr/share/instack-undercloud:/usr/share/tripleo-image-elements:/usr/share/diskimage-builder/elements -j /usr/share/instack-undercloud/json-files/centos-7-undercloud-packages.json', 'UNDERCLOUD_GLANCE_PASSWORD':'432b0417d7e43907e9df98fae3b509306932275c', 'UNDERCLOUD_RABBIT_PASSWORD':'3f63076c9b2ec748d7021311acc0a4fcfcbf1b30', 'UNDERCLOUD_MISTRAL_PASSWORD':'a4e093153909d0d57c627e3b3b704c286fee2fa0', 'UNDERCLOUD_NOVA_PASSWORD':'54fca7b5d7905511604f69de11d67d4144bb669f', 'NETWORK_CIDR':'192.168.24.0/24', 'HISTCONTROL':'ignoredups', 'PWD':'/home/stack', 'UNDERCLOUD_PUBLIC_VIP':'192.168.24.2', 'INSPECTION_EXTRAS':'True', 'UNDERCLOUD_HEAT_ENCRYPTION_KEY':'9f0926190c3bd6c45203bb645af27039', 'UNDERCLOUD_HOSTNAME':'None', 'UNDERCLOUD_RABBIT_USERNAME':'44422a8e431da7e68defea8712790cf69bca46b1'} 附录2os-apply-config使用的metadata配置文件示例: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859{ "hiera": { "hierarchy": [ "quickstart-hieradata-overrides", "\\"%{::operatingsystem}\\"", "\\"%{::osfamily}\\"", "puppet-stack-config" ]}, "local-ip": "192.168.24.1", "masquerade_networks": ["192.168.24.0/24"], "service_certificate": "/etc/pki/tls/certs/undercloud-192.168.24.2.pem", "public_vip": "192.168.24.2", "neutron": { "dhcp_start": "192.168.24.5", "dhcp_end": "192.168.24.30", "network_cidr": "192.168.24.0/24", "network_gateway": "192.168.24.1" }, "inspection": { "interface": "", "iprange": "", "runbench": "" }, "os_net_config": { "network_config": [ { "type": "ovs_bridge", "name": "br-ctlplane", "ovs_extra": [ "br-set-external-id br-ctlplane bridge-id br-ctlplane" ], "members": [ { "type": "interface", "name": "eth1", "primary": "true", "mtu": 1500 } ], "addresses": [ { "ip_netmask": "192.168.24.1/24" } ], "mtu": 1500 } ] }, "keystone": { "host": "192.168.24.1" }, "ironic": { "service-password": "7558a0701f802a84faa625e0bc563170a97aa6cb" }, "bootstrap_host": { "bootstrap_nodeid": "undercloud", "nodeid": "undercloud" }} 附录3instack-undercloud中使用到的elements以及其中hook和脚本示例: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485* install-types(diskimage-builder) * extra-data.d * 99-enable-install-types * The base element enables the chosen install type by symlinking the correct hook scripts under install.d directly* element-manifest(diskimage-builder) * extra-data.d * 75-inject-element-manifest* manifests(diskimage-builder) * extra-data.d * 20-manifest-dir * environment.d * 14-manifests * cleanup.d * 01-copy-manifests-dir* source-repositories(diskimage-builder) * extra-data.d * 98-source-repositories* puppet-modules(tripleo-puppet-elements) * install.d/ * puppet-modules-package-install/ * 75-puppet-modules-package * puppet-modules-source-install/ * 75-puppet-modules-source * 根据install_type会将对应目录下的脚本link到install.d目录下 * environment.d/ * 01-puppet-module-pins.sh * 02-puppet-modules-install-types.sh * source-repository-puppet-modules* hiera(tripleo-puppet-elements) * 10-hiera-disable * 40-hiera-datafiles * install.d/ * 10-hiera-yaml-symlink * 11-hiera-orc-install * os-apply-config/ * etc/puppet/hiera.yaml* enable-packages-install(tripleo-image-elements) * environment.d/ * 01-export-install-types.bash* os-apply-config(tripleo-image-elements) * environment.d/ * 10-os-apply-config-venv-dir.bash * install.d/ * 11-create-template-root * 99-install-config-templates * os-apply-config-source-install/ * 10-os-apply-config * os-refresh-config/ * configure.d/ * 20-os-apply-config* os-refresh-config(tripleo-image-elements) * install.d/ * 99-os-refresh-config-install-scripts * os-refresh-config-source-install/ * 10-os-refresh-config * os-refresh-config/ * post-configure.d/ * 99-refresh-completed* undercloud-install(instack-undercloud) * os-apply-config/ * etc/ * root/ * var/ * os-refresh-config/ * configure.d/ * 30-reload-keepalived * post-configure.d/ * 80-seedstack-masquerade * 98-undercloud-setup* puppet-stack-config(instack-undercloud) * extra-data.d/ * 10-install-git * install.d/ * 02-puppet-stack-config * 10-puppet-stack-config-puppet-module * os-apply-config/ * etc/ * os-refresh-config/ * configure.d/ * 50-puppet-stack-config * post-configure.d/ * 10-iptables 附录4instack-undercloud中使用到的elements以及其中hook和脚本,在instack中被合并之后的示例: 1234567891011121314151617181920212223242526272829▾ cleanup.d/ 01-copy-manifests-dir*▾ environment.d/ 01-export-install-types.bash 02-puppet-modules-install-types.sh 10-os-apply-config-venv-dir.bash 14-manifests▾ extra-data.d/ 10-install-git* 20-manifest-dir* 75-inject-element-manifest* 98-source-repositories* 99-enable-install-types*▾ install.d/ ▾ os-apply-config-source-install/ 10-os-apply-config* ▾ os-refresh-config-source-install/ 10-os-refresh-config* ▾ puppet-modules-package-install/ 75-puppet-modules-package* ▾ puppet-modules-source-install/ 75-puppet-modules-source* 02-puppet-stack-config* 10-hiera-yaml-symlink* 10-puppet-stack-config-puppet-module* 11-create-template-root* 99-install-config-templates* 99-os-refresh-config-install-scripts* package-installs-hiera","link":"/2017/03/09/openstack/tripleo/undercloud.html"},{"title":"深度解析CephX原理—调节NTP时钟的困境","text":"背景我们知道CephX是Ceph中的认证机制,防止系统被未授权客户端访问,以及防止被中间人攻击。之所以会去研究CephX,是因为近期有一个客户有一个需求,就是要调整整个Ceph集群的时钟,跟公司内部的一个NTP Server保持时间同步,该客户有多套Ceph集群,有大有小,大的集群有上千个OSD,客户端有上万个。Ceph集群都是5年前搭建的,运行的版本还是0.94.7的Hammer版,现在跑着公司的核心业务,目前NTP使用的是Ceph集群内部的一个NTP Server,这些集群跟要切换到的NTP Server的时钟最多有差30多分钟的,而且是落后30分钟,调整时钟,相当于是将整个集群往前调快时间,而且一定不能影响业务。 我们都知道Ceph是一个对集群时钟要求很高的系统,当集群时间不同步时,会出现很多问题,严重的,会直接影响客户端的正常IO,在我们多年的运维经历中,遇到过一两次这样的问题。其中CephX就是这样一个对时间要求比较高的子系统,因为其内部会维护很多认证需要的secret,都有过期时间,而且是分散在整个集群中的,即客户端,osd,mon等都依赖这些secret,当集群内部时钟不一致时,组件之间判断secret的过期时间就会出现紊乱,从而产生认证错误,即bad auth,情况好点的,可能会导致osd卡很久无法正常启动,严重的,会直接导致客户端产生大量的slow request,而且会block很长时间,影响客户端IO。 跟时钟密切相关的还有另外一套子系统,即heartbeat心跳,该机制是判断集群健康状态的生命线,当集群时钟不同步,会对心跳判断组件是否正常产生影响,会导致osd被误报为down,触发pg peering,以及osd flapping等异常现象,影响集群的正常IO。 本来给客户的建议是依赖ntpd的机制,让其慢慢往前调节时间,但是由于时钟差的有点太多,这个时钟同步太漫长了,可能要花费数个月的时间,才会同步30分钟,客户无法接受这个方案,寻求有没有快一些的方案,或者能否一步到位的。于是我们就开始了漫漫征程,开始做测试,研究代码,于是有了这篇文章。 测试为了尽量能够暴露问题,我们做的测试环境,是一个准生产环境,是一个内部的开发测试环境,对可用性没有生产环境那么高,但是环境很大,客户端压力也很大,有将近900个osd,5000台虚拟机,15000个云硬盘。 方案一因为一开始我们并没有考虑到时钟会对cephx产生影响,只考虑了heartbeat的影响,所以一开始提出的测试方案就是先停掉服务,然后同步时间,然后再启动服务。先从mon开始做,因为3个mon是高可用的,所以挨个做mon节点,停mon,同步时间,启动mon,三个mon做好之后,再逐个节点做osd,停某个节点的所有osd,同步时间,启动osd。 但是在做完3个mon节点,在做第一个osd节点的过程中,时间大概有1个多小时,观察到集群产生了大量的slow request,大概持续了十几分钟,才消失,ceph health detail 发现slow request发生在了很多osd上,不是某个osd。事后分析日志,发现产生slow request的osd只发生在已经做过变更的OSD上,没有做过变更的OSD,并没有产生slow request,而且产生slow request的osd上,在故障时间点,都报出了大量的如下日志: 1232019-11-16 22:58:04.552670 7f326b1d9700 0 auth: could not find secret_id=324822019-11-16 22:58:04.552679 7f326b1d9700 0 cephx: verify_authorizer could not get service secret for service osd secret_id=324822019-11-16 22:58:04.552687 7f326b1d9700 0 -- 55.17.0.24:6802/3888789 >> 55.17.0.76:0/3305335554 pipe(0xe9ef000 sd=1112 :6802 s=0 pgs=0 cs=0 l=1 c=0xe8223c0).accept: got bad authorizer 看样子,是因为osd没有找到某个secret,导致客户端来的请求,认证没有通过,本次请求失败,客户端不断反复请求,从而产生了大量slow request,而且有的请求卡到了400秒,这个故障时间已经严重影响到了正常业务。 分析cephx的认证机制,为了防止中间人进行攻击,用来加密认证信息的secret并不是使用永久的secret,而是使用的会周期性删除的secret用来做认证,ceph中叫做RotatingSecrets,在一个RotatingSecrets中,始终保持有3个secret,如下图: current是当前正在使用的secret,previous是之前使用的secret,next是下一个将要使用的secret,当current过期了之后,previous会被删除,current就变成了previous,next就变成了current,然后会再生成一个新的next。客户端也会不断的renew自己的认证信息,该认证信息,在ceph里叫做ticket,ticket里指明了客户端需要使用哪个secret来进行认证,即上面日志中的secret_id,之前使用的是previous,过一定周期之后,会自己切换成current,previous的存在,是为了保证客户端能够在较大的时间窗口内,renew自己使用的ticket,更换secret。 rotating secret的过期时间默认是1个小时,即1个小时rotating一次,因为我们本次调整的时间是30多分钟,而且是向前调整,即加快时间,这会导致一部分osd的current的secret被提前过期掉,导致触发rotate操作,所以previous就被提前删除了,然后导致很多还在使用这个previous的客户端出现了认证问题,影响了业务,直到客户端触发renew操作,切换了secret之后,才会恢复正常。 控制rotating secret过期时间的一个参数,叫做 auth_service_ticket_ttl,单位为秒,默认 60*60,即1个小时。于是我们调整了测试方案,计划加长这个参数,让rotating secret的过期时间放慢,给客户端足够的时间进行切换,所以有了方案二。 方案二调整 auth_service_ticket_ttl,该参数控制rotating secret的过期时间,默认为1个小时,即3600秒,可以将该参数调整为2小时,即7200秒,延长secret的过期时间。延长一个小时,这样在时间往前调整30分钟的情况下,肯定可以有足够的时间buffer让客户端来更新自己使用的secret,该方案的大致步骤如下: 动态调整mon和osd的ttl参数 12ceph tell mon.* injectargs --auth_service_ticket_ttl 7200ceph tell osd.* injectargs --auth_service_ticket_ttl 7200 修改ceph.conf的配置,添加下面的配置项: 12[global]auth_service_ticket_ttl = 7200 等待2小时,等待secret以及ticket renew 开始按照以前的操作重启mon和osd 然而在执行完步骤1和2,在步骤3的等待2小时的过程中,发现大概过了1个小时的时间,就观察到有大量的slow request产生了,查看产生slow request的osd的日志,发现竟然还是因为认证失败导致的,即又产生了跟方案一中一样的故障现象,即大量的bad auth。 这是怎么回事呢?理论上,加长了ttl,不是应该rotating secret的速度放慢了吗?而且为什么总是过了一段时间,即一个小时左右,才会产生slow request?这里面肯定还有什么机制没有搞清楚,所以带着这些问题,我们来仔细分析下CephX的认证原理。 原理众所周知,Ceph是一个去中心化的存储系统,意味着客户端会直接跟最终的存储设备直接交互,具体的,即客户端会直接跟osd交互,而客户端和osd可能都有成百上千,他们之间的交互认证该如何设计呢?Ceph参考了Kerberos的设计思路,引入了认证中心的概念,即由Mon充当认证中心,客户端和OSD在启动以及后续的运行过程中,都会不断跟Mon进行交互,Mon会向客户端和OSD签发他们能够彼此进行认证的标识和密钥,客户端带着这个标识,去向OSD进行认证,OSD使用这个标识去找到对应的密钥,如果能够使用这个密钥成功解密客户端发来的认证信息,并且比对相关的信息正确,则认为认证成功,可以进行后续的数据传输操作。 上述的过程,被Ceph实现为了一个名为CephX的协议,为了更好的理解它,我们先介绍该协议中几个重要的概念: keyring,这个概念我们比较熟悉,在部署Ceph的时候,这个是必须生成的,每一个OSD和客户端都会有一个对应的keyring,可以用 ceph auth list 命令看到,里面包含了一个密钥和该客户端相应的权限信息,keyring在CephX中称为permanent secret,即是固定不变的密钥,它的主要作用其实是在建立客户端和Mon进行连接认证时所用,当认证通过后,后续的数据传输阶段就不再使用了,除非重新进行认证连接。 secret,这里指的既是上面提到的rotating secrets,即 临时的secret,每一个rotating secrets中包含了3个secret: previous, current, next,每一个secret是有一定有效期的,当过了有效期,会被逐渐rotate掉,即会被逐渐删除掉,这个动态的secret的设计,主要是用来数据传输阶段,防止中间人进行攻击而设计的。rotating secrets由mon进行生成和维护,然后下发给osd,供osd在后续跟客户端的数据传输过程中,进行认证使用,在CephX中,也被称为service secret。 ticket,主要是用来标识客户端信息的,客户端初次向mon发起认证请求时,如果认证通过,mon会签发给该客户端一个ticket,该ticket中包含客户端的标识,过期时间,以及session key,mon会使用当前的secret,来加密ticket,然后将当前的secret的id附在ticket中,发送给客户端,客户端对ticket是透明的,即客户端不需要知道ticket的具体内容,该内容只是在客户端向osd发起请求时,由OSD端根据secret id,找到对应的secret,进行认证使用的。需要注意的是,ticket跟secret一样,都是有有效期的,都不是固定的,都在周期性的变化。 session key,它的主要作用,是在认证通过之后,用来加密传输数据的校验码的,Ceph在传输数据的时候,并没有对传输的数据进行加密,仅仅是对传输的数据,计算了CRC循环校验码,然后对CRC用session key进行了加密,数据发送到OSD后,OSD对CRC进行解密,若解密成功,再按相同的校验算法进行一遍CRC的校验,如果匹配,则可认为数据传输正确,该方法主要是为了防止数据被中间人篡改,并不能起到加密的作用。 在CephX的协议中,定义了4种服务: Auth, Mon, OSD, MDS。Auth即认证服务,由Mon提供,即Mon作为认证中心,对客户端提供跟其他服务认证相关的服务;Mon服务即是提供Monitor本身具有的功能的服务,如向客户端提供monmap等;OSD服务就是为客户端提供最终读写操作的服务;MDS是提供文件系统相关的服务。客户端在启动的时候,首先会向Mon发起认证请求,经过多次交互,认证通过后,会从Mon那里拿到相应的认证信息,即ticket,然后就可以带着认证信息,向最终服务发起请求,这些服务端会去验证认证信息,如果通过,则可以进行后续操作。每一个客户端都通过MonClient这个类来提供跟认证相关的逻辑。 下图为一个客户端从发起认证,到最后跟OSD进行数据交互的时序图: 可以看到,整个过程还是非常复杂的,大概分为三个阶段: 客户端跟Mon进行认证上图中的1-11步,都是客户端在跟Mon进行认证交互,经过多次交互,客户端建立起自己的认证体系,主要是从Mon获取到了针对各个服务的ticket,因此需要注意,客户端维护的不是一个ticket,而是多个ticket,即针对每一个服务有一个ticket。客户端需要哪些ticket,是在客户端启动的时候,由上图中的第一步,即set_want_keys()函数就确定好了的,比如RadosClient,因为它要读写数据,会跟Mon和OSD都交互,因此就会设置自己的want keys为: Mon, OSD,如果是打开了cephx认证,还会默认将Auth添加到want keys中,因此一个RadosClient需要三个ticket。 关于这部分的实现方式,Ceph中运用的非常经典,值得好好推敲,每一个客户端都维护了want, have, need这三个无符号整形变量,即uint32,用这三个变量的位操作来标识当前客户端对某种类型ticket的需求状态: want,就是客户端期待有什么类型的服务 need,就是客户端有了某种类型的服务,但是需要更新或者是还没有某种类型的服务,需要去获取的 have,就是已经有了某种类型的服务,还在有效期内 Ceph中定义了如下的类型变量: 123456#define CEPH_ENTITY_TYPE_MON 0x01#define CEPH_ENTITY_TYPE_MDS 0x02#define CEPH_ENTITY_TYPE_OSD 0x04#define CEPH_ENTITY_TYPE_CLIENT 0x08#define CEPH_ENTITY_TYPE_AUTH 0x20#define CEPH_ENTITY_TYPE_ANY 0xFF 比如 set_want_keys(CEPH_ENTITY_TYPE_MON | CEPH_ENTITY_TYPE_OSD) 就是将want中通过 want |= service_id 位或操作,将want中的相应位置为1,代表需要某一个类型的服务。在整个认证过程中,会用到如下的组合: 12337(10进制)= CEPH_ENTITY_TYPE_AUTH | CEPH_ENTITY_TYPE_OSD | CEPH_ENTITY_TYPE_MON32(10进制)= CEPH_ENTITY_TYPE_AUTH5(10进制) = CEPH_ENTITY_TYPE_OSD | CEPH_ENTITY_TYPE_MON 因此在客户端和Mon进行认证的过程中,会看到如下的debug日志: 1cephx: validate_tickets want 37 have 32 need 5 表示的意思就是当前客户端希望有AUTH, MON, OSD这3个服务的ticket,当前已经有了AUTH服务的ticket,但是还需要OSD和MON的ticket。通过这种机制,客户端在初次认证,以及后续的ticket更新过程中,可以高效的维护自己的ticket状态,没有的就向Mon去获取,有了的快过期的了,就向Mon去申请新的ticket。 关于ticket,内部由一个叫做 CephXTicketHandler 的结构体对某一个ticket进行封装,而 CephXTicketHandler 又被一个叫做 CephXTicketManager的结构体进行管理,比如获取针对某一个类型服务的TicketHandler,或者是验证被它管理的TicketHandler是否过期等。CephXTicketHandler结构体的内容如下: 123456CephXTicketHandler uint32_t service_id; CryptoKey session_key; CephXTicketBlob ticket; // opaque to us utime_t renew_after, expires; bool have_key_flag 123CephXTicketBlob uint64_t secret_id bufferlist blob //用service_secret(即rotating secret)加密过的CephXServiceTicketInfo ticket是由Mon签发给客户端的,即ticket在Mon端就确定好了该ticket的过期时间,重新renew的时间,为其生成了一个随机字符串session_key,然后通过对应服务的service_secret(即rotating secret,Mon针对每一类型的服务,是有单独的secret的)对ticket的内容进行加密,最终发送给客户端的。 上图中,4-6步,获取到了AUTH服务的ticket,在此基础上,再通过7-11步获取到了MON和OSD服务的ticket。 客户端跟OSD进行认证上图12-16步,是在客户端在跟Mon交互认证完成之后,跟OSD进行认证交互的过程,即客户端通过Messenger跟OSD建立连接的过程中,需要先通过认证才可以。实现逻辑位于客户端的Pipe::connect(),以及服务端的Pipe::accept()函数中,在客户端通过build_authorizer()函数将OSD服务对应的ticket封装在一个叫CephXAuthorizer的结构体中: 1234567891011auth.h / AuthAuthorizer __u32 protocol bufferlist bl CryptoKey session_key virtual bool verify_reply(bufferlist::iterrator& reply) = 0CephXAuthorizer : public AuthAuthorizer uint64_t nonce bool build_authorizer(); bool verify_reply(bufferlist::iterator& reply);CephXAuthorize uint64_t nonce 并使用session_key加密这个CephXAuthorize,然后发送给OSD服务端,OSD服务端通过verify_authorizer()方法验证该CephXAuthorizer,验证的过程首先会使用对应的service_secret对ticke进行解密,然后再用session_key解密出CephXAuthorize,若这两步都解密成功,则认证通过,客户端跟OSD建立起了Messenger连接。 这里需要说明一点的是OSD端的service_secret是在OSD启动的时候,从Mon端获取到的,后续OSD会不断的检查自己的service_secret是否过期,如果过期,则会去Mon再重新申请新的secret,而ticket是在Mon端使用对应类型服务的secret进行签发,下发给客户端的,因此在这里OSD可以使用自己的secret对ticket进行验证。 客户端跟OSD进行数据交互即上图中的17-18步,在客户端跟OSD建立起连接之后,就可以传输数据了,通过Messenger的writer()和reader()实现。如前所述,Ceph在传输数据的时候,并没有对传输的数据进行加密,仅仅是对传输的数据,计算了CRC循环校验码,然后对CRC用session key进行了加密,数据发送到OSD后,OSD对CRC进行解密,若解密成功,再按相同的校验算法进行一遍CRC的校验,如果匹配,则可认为数据传输正确。该部分逻辑,是在CephXSessionHandler中的sign_message()和check_message_signature()方法实现的。 通过以上的认证交互过程,最终,客户端和服务端整体的状态图如下: Mon在启动的时候,会为每种服务生成一组rotating secrets,即分别为Auth, Mon, OSD, MDS各生成一组rotating secrets,因为这些rotating secrets都是临时的,都有过期时间,Auth服务的过期周期由 auth_mon_ticket_ttl 参数控制,默认为12小时,而其他服务的过期时间则由 auth_service_ticket_ttl 控制,默认为1小时,然后Mon会在它的tick()函数中,不断的检查这些服务的secrets是否过期,如果过期,则进行rotate操作,删掉previous,生成新的next。此外各个entity(包含client和osd)的keyring会注册进mon中,即Mon是能知道整个集群的所有entity的keyring的。 OSD在启动的时候,也会作为一个客户端向Mon进行认证,但是因为它同时也是服务端,所以额外的,它会获取到OSD类型的rotating secrets,保存到自己的内存中,后续其他客户端来进行认证的时候,OSD就会使用这一组rotating secrets对客户端进行认证。因此,需要注意,所有的相同类型的服务端,获取到的rotating secrets都是同一份,都是同Mon获取到的同一份secrets,即所有OSD中的rotating secrets都是一样的。类似的,OSD也会在本地的tick()函数中,不断的check自己的rotating secrets是否过期,即将current的secret的过期时间跟本地的时间比较,如果过期,则向Mon发起申请新的secret的请求。 客户端在启动的时候,会向Mon进行认证,就像前面时序图中描述的流程那样,经过认证,获取到了对应的ticket,需要注意的是,因为客户端要跟多个服务端进行交互,针对每种服务端,客户端会向Mon各申请一个ticket,维持在自己的内存中,并且在客户端的tick()函数中,不断的check这些ticket是否过期,ticket在过期之前会提前去申请,即客户端会不断的将ticket中的renew_after字段跟本地的时间进行比较,renew_after的值为过期时间减去ttl/4,即 renew_after = expires; renew_after -= ((double)msg_a.validity.sec() / 4);,即如果是OSD服务,则是提前15分钟进行renew操作,如果达到了需要renew的时间点,则会向Mon发起申请新的ticket的请求。 客户端的认证过程,可以通过rados bench命令打开debug_auth选项,来看下这个过程: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687[root@portal ~]# rados bench 1 write -p rbd --debug_auth=20 2>&12019-12-22 21:29:05.905869 7f4bf11f97c0 5 adding auth protocol: cephx2019-12-22 21:29:05.905890 7f4bf11f97c0 5 adding auth protocol: none2019-12-22 21:29:05.906674 7f4bf11f97c0 2 auth: KeyRing::load: loaded key file /etc/ceph/ceph.client.admin.keyring2019-12-22 21:29:05.911570 7f4be9833700 10 cephx: set_have_need_key no handler for service mon2019-12-22 21:29:05.911574 7f4be9833700 10 cephx: set_have_need_key no handler for service osd2019-12-22 21:29:05.911576 7f4be9833700 10 cephx: set_have_need_key no handler for service auth2019-12-22 21:29:05.911577 7f4be9833700 10 cephx: validate_tickets want 37 have 0 need 372019-12-22 21:29:05.911592 7f4be9833700 10 cephx client: handle_response ret = 02019-12-22 21:29:05.911595 7f4be9833700 10 cephx client: got initial server challenge 161838221598017401302019-12-22 21:29:05.911604 7f4be9833700 10 cephx client: validate_tickets: want=37 need=37 have=02019-12-22 21:29:05.911608 7f4be9833700 10 cephx: set_have_need_key no handler for service mon2019-12-22 21:29:05.911609 7f4be9833700 10 cephx: set_have_need_key no handler for service osd2019-12-22 21:29:05.911610 7f4be9833700 10 cephx: set_have_need_key no handler for service auth2019-12-22 21:29:05.911611 7f4be9833700 10 cephx: validate_tickets want 37 have 0 need 372019-12-22 21:29:05.911613 7f4be9833700 10 cephx client: want=37 need=37 have=02019-12-22 21:29:05.911625 7f4be9833700 10 cephx client: build_request2019-12-22 21:29:05.911790 7f4be9833700 10 cephx client: get auth session key: client_challenge 44004998920339741902019-12-22 21:29:05.913189 7f4be9833700 10 cephx client: handle_response ret = 02019-12-22 21:29:05.913211 7f4be9833700 10 cephx client: get_auth_session_key2019-12-22 21:29:05.913261 7f4be9833700 10 cephx: verify_service_ticket_reply got 1 keys2019-12-22 21:29:05.913265 7f4be9833700 10 cephx: got key for service_id auth2019-12-22 21:29:05.913355 7f4be9833700 10 cephx: ticket.secret_id=262019-12-22 21:29:05.913359 7f4be9833700 10 cephx: verify_service_ticket_reply service auth secret_id 26 session_key AQChb/9dolqXNhAAsM5vOGg5aQBXnugVuG4xfQ== validity=43200.0000002019-12-22 21:29:05.913392 7f4be9833700 10 cephx: ticket expires=2019-12-23 09:29:05.913391 renew_after=2019-12-23 06:29:05.9133912019-12-22 21:29:05.913415 7f4be9833700 10 cephx client: want=37 need=37 have=02019-12-22 21:29:05.913419 7f4be9833700 10 cephx: set_have_need_key no handler for service mon2019-12-22 21:29:05.913421 7f4be9833700 10 cephx: set_have_need_key no handler for service osd2019-12-22 21:29:05.913423 7f4be9833700 10 cephx: validate_tickets want 37 have 32 need 52019-12-22 21:29:05.913428 7f4be9833700 10 cephx client: validate_tickets: want=37 need=5 have=322019-12-22 21:29:05.913430 7f4be9833700 10 cephx: set_have_need_key no handler for service mon2019-12-22 21:29:05.913431 7f4be9833700 10 cephx: set_have_need_key no handler for service osd2019-12-22 21:29:05.913433 7f4be9833700 10 cephx: validate_tickets want 37 have 32 need 52019-12-22 21:29:05.913435 7f4be9833700 10 cephx client: want=37 need=5 have=322019-12-22 21:29:05.913437 7f4be9833700 10 cephx client: build_request2019-12-22 21:29:05.913438 7f4be9833700 10 cephx client: get service keys: want=37 need=5 have=322019-12-22 21:29:05.915099 7f4be9833700 10 cephx client: handle_response ret = 02019-12-22 21:29:05.915122 7f4be9833700 10 cephx client: get_principal_session_key session_key AQChb/9dolqXNhAAsM5vOGg5aQBXnugVuG4xfQ==2019-12-22 21:29:05.915152 7f4be9833700 10 cephx: verify_service_ticket_reply got 2 keys2019-12-22 21:29:05.915155 7f4be9833700 10 cephx: got key for service_id mon2019-12-22 21:29:05.915257 7f4be9833700 10 cephx: ticket.secret_id=3062019-12-22 21:29:05.915262 7f4be9833700 10 cephx: verify_service_ticket_reply service mon secret_id 306 session_key AQChb/9deKi0NhAA1J1cXy1CF04h/DMFVMK7AA== validity=3600.0000002019-12-22 21:29:05.915282 7f4be9833700 10 cephx: ticket expires=2019-12-22 22:29:05.915282 renew_after=2019-12-22 22:14:05.9152822019-12-22 21:29:05.915300 7f4be9833700 10 cephx: got key for service_id osd2019-12-22 21:29:05.915347 7f4be9833700 10 cephx: ticket.secret_id=3062019-12-22 21:29:05.915350 7f4be9833700 10 cephx: verify_service_ticket_reply service osd secret_id 306 session_key AQChb/9dHUq1NhAAb05jjL3MuFG43BPtN1o3MQ== validity=3600.0000002019-12-22 21:29:05.915388 7f4be9833700 10 cephx: ticket expires=2019-12-22 22:29:05.915388 renew_after=2019-12-22 22:14:05.9153882019-12-22 21:29:05.915402 7f4be9833700 10 cephx: validate_tickets want 37 have 37 need 02019-12-22 21:29:05.915455 7f4be9833700 10 cephx: validate_tickets want 37 have 37 need 02019-12-22 21:29:05.915460 7f4be9833700 20 cephx client: need_tickets: want=37 need=0 have=37 Maintaining 16 concurrent writes of 4194304 bytes for up to 1 seconds or 0 objects Object prefix: benchmark_data_portal.novalocal_2425 sec Cur ops started finished avg MB/s cur MB/s last lat avg lat 0 0 0 0 0 0 - 02019-12-22 21:29:05.997921 7f4bd0ee9700 10 cephx client: build_authorizer for service osd2019-12-22 21:29:05.998301 7f4be01ec700 10 cephx client: build_authorizer for service osd2019-12-22 21:29:05.998483 7f4be02ed700 10 cephx client: build_authorizer for service osd2019-12-22 21:29:05.999290 7f4bd0ce7700 10 cephx client: build_authorizer for service osd2019-12-22 21:29:05.999423 7f4bd0de8700 10 cephx client: build_authorizer for service osd2019-12-22 21:29:05.999465 7f4bd0fea700 10 cephx client: build_authorizer for service osd2019-12-22 21:29:05.999610 7f4bd08e3700 10 cephx client: build_authorizer for service osd2019-12-22 21:29:05.999803 7f4bd0be6700 10 cephx client: build_authorizer for service osd2019-12-22 21:29:05.999845 7f4bd0ae5700 10 cephx client: build_authorizer for service osd2019-12-22 21:29:05.999970 7f4bd09e4700 10 cephx client: build_authorizer for service osd2019-12-22 21:29:06.000290 7f4be02ed700 10 In get_auth_session_handler for protocol 22019-12-22 21:29:06.000405 7f4be01ec700 10 In get_auth_session_handler for protocol 22019-12-22 21:29:06.000515 7f4bd07e2700 10 cephx client: build_authorizer for service osd2019-12-22 21:29:06.000718 7f4bd0be6700 10 In get_auth_session_handler for protocol 22019-12-22 21:29:06.000835 7f4bd0ee9700 10 In get_auth_session_handler for protocol 22019-12-22 21:29:06.000861 7f4bd0ae5700 10 In get_auth_session_handler for protocol 22019-12-22 21:29:06.001076 7f4bd0ce7700 10 In get_auth_session_handler for protocol 22019-12-22 21:29:06.001087 7f4bd0de8700 10 In get_auth_session_handler for protocol 22019-12-22 21:29:06.001169 7f4bd0fea700 10 In get_auth_session_handler for protocol 22019-12-22 21:29:06.001189 7f4bd08e3700 10 In get_auth_session_handler for protocol 22019-12-22 21:29:06.005952 7f4bd08e3700 10 _calc_signature seq 1 front_crc_ = 982752836 middle_crc = 0 data_crc = 2579955314 sig = 26776071220768890252019-12-22 21:29:06.005986 7f4bd08e3700 20 Putting signature in client message(seq # 1): sig = 26776071220768890252019-12-22 21:29:06.006038 7f4be02ed700 10 _calc_signature seq 1 front_crc_ = 1958135064 middle_crc = 0 data_crc = 1709381963 sig = 64705411395317534132019-12-22 21:29:06.006054 7f4be02ed700 20 Putting signature in client message(seq # 1): sig = 64705411395317534132019-12-22 21:29:06.008327 7f4bd0ee9700 10 _calc_signature seq 1 front_crc_ = 4203873926 middle_crc = 0 data_crc = 1161138004 sig = 95617923552732755472019-12-22 21:29:06.008338 7f4bd0ee9700 20 Putting signature in client message(seq # 1): sig = 95617923552732755472019-12-22 21:29:06.009089 7f4be01ec700 10 _calc_signature seq 1 front_crc_ = 4064301437 middle_crc = 0 data_crc = 2925238792 sig = 130706631027119635362019-12-22 21:29:06.009102 7f4be01ec700 20 Putting signature in client message(seq # 1): sig = 130706631027119635362019-12-22 21:29:06.010759 7f4bd0fea700 10 _calc_signature seq 1 front_crc_ = 107120958 middle_crc = 0 data_crc = 4152256828 sig = 4310355302291843112019-12-22 21:29:06.010775 7f4bd0fea700 20 Putting signature in client message(seq # 1): sig = 4310355302291843112019-12-22 21:29:06.011970 7f4bd0ce7700 10 _calc_signature seq 1 front_crc_ = 3667142757 middle_crc = 0 data_crc = 3618176803 sig = 71874233742641819642019-12-22 21:29:06.011982 7f4bd0ce7700 20 Putting signature in client message(seq # 1): sig = 71874233742641819642019-12-22 21:29:06.014112 7f4bd0be6700 10 _calc_signature seq 1 front_crc_ = 1614519995 middle_crc = 0 data_crc = 4025923126 sig = 13220504417320496121 原因分析了解了以上原理,就可以分析在 测试 中的两个方案为什么会出现问题了,在客户端数量很多的情况下,会不断的有客户端的ticket过期,然后再申请新的ticket,而此时如果动态加上了ttl的时间,那么会有一部分客户端申请到的ticket的过期时间变长了,即renew变慢了,而此时osd的rotating secret,因为共有3个secret: previous, current, next,由于其是在改变ttl参数之前就已经生成了的,其过期时间是已经确定了的,即1小时rotate一个,所以其rotate的速度并没有相应的变慢,所以会存在一个时间窗口,当1小时后,current过期了,previous就会被rotate掉了,而此时还在用previous的客户端并没有提前去申请新的ticket,而导致这部分客户端去访问对应的osd时,出现了bad auth。 与之类似的,调整NTP时钟,因为是往前调,相当于加快了mon的rotate secret的时间,osd的rotating secret的过期时间也对应加快了,从而osd向mon申请了新的rotating secret,但此时一部分客户端还在使用之前申请的ticket,其renew的时间并没有发生变化,还是按以前的时间在renew,相当于是部分客户端ticket renew变慢了,从而出现了一个窗口,导致osd找不到客户端发来的secret_id了,从而出现了bad auth。 所以,基于以上原理,重启mon和osd,都无法避免bad auth,唯一不影响业务的方案,就是减小调整时间的幅度,因为客户端会提前15分钟进行renew,申请新的ticket,所以最大的调整窗口期为15分钟,为了保险起见,可以调整为10分钟,即每一个小时,将整个集群的时间往前调整10分钟。同时,为了确保心跳不导致osd down,可以将该10分钟在每个小时内,分多次进行调整,比如每分钟调整10秒钟,1小时调整60次,将集群慢慢向前进行同步。 最终方案按照上述思路,发现在调整5秒的情况下,都有osd down,并且是osd进程挂掉了,如下: 首先是报错: 1heartbeat_map reset_timeout osd_op_tp thread had timed out after 4 意思是在4秒后,op线程超时报错了,该超时是因为在0.94.7版本的ceph代码中在,有部分硬编码,超时时间不能超过4秒,相关逻辑如下: 在pqueue为空的情况下,即队列中没有pg,会reset_timeout为4秒,并且是硬编码的,不能修改,因为我们往前调整5秒,正好超过了这个硬编码的timeout,所以报这个错了,因为是硬编码没法修改,目前我们调时间只能调4秒以内了,因此安全的方案是以2秒为单位往前调整。 此外,上面的报错日志中,发生了osd挂掉的情况,查看日志,发现是check_ops_in_flight()中的相关逻辑,出现了异常导致退出,看日志是被发送signal信号退出的,并不是因为超时,或者是超时自杀,看代码逻辑,可能是因为往前调整时间,导致了ceph代码中,有空指针等程序异常,没有被ceph catch到,导致异常退出了。跟该逻辑相关的配置参数是 osd_op_complaint_time,该参数来确定一个io被判断为slow request的范围,观察客户环境,ssd介质的OSD配置的1秒,sata介质的配置的是5秒,而默认值是30秒,将该值调整为10秒之后,没有再发生OSD down的情况。 因此,根据上面的测试以及分析结果,最终的方案,就是调整osd_op_complaint_time的时间为10秒,然后以2秒为粒度,向前调整时间,1个小时最多不要超过10分钟。 总结本篇文章,从客户的一个变更需求入手,分析了CephX的原理,虽然实现方式非常复杂,但是理清了各个组件之间的关系,整体逻辑还是比较好理解的,然后根据此原理,给出了不影响业务的切实可行的变更方案,但是也可以看到,为了不影响业务,变更时间也将会非常长,如果调整30分钟的话,1分钟调整2秒,1小时只能调整2分钟,30分钟,至少需要花费15个小时,因此,建议各位运维同仁,把 集群NTP时钟 这一项加到你的checklist中,在集群刚开始搭建的时候,就最好确定好集群的时钟,多花一分钟问下领导NTP的事,会为自己免去至少一个月的痛苦时光,边开飞机边修飞机的事情,尽量少干吧。","link":"/2019/12/15/ceph/cephx.html"},{"title":"UMI框架解析","text":"背景最近打算自己写一个运维管理平台,给我们内部使用,对于一个运维+后端程序员来说,写前端无疑是最大的挑战了,前端的知识栈真是庞大又杂乱,只掌握了最基础的html+css+js来说是远远不够的,前端发展了这么几十年,每一个领域都有一大堆的标准、组件、框架,比如css有less、sass等扩展,js领域又有react, vue等框架,还有typescript这种带类型的js,光ts的语法就可以复杂到令人发指,此外,还有很多现成的ui库,像阿里的antd,国外的mui,本以为react就已经是学习的终点了,但是还有umi这种基于react的前端框架,这还没完,还有再上一层的基于umi的antd pro框架,一层接一层,而且很多公司还在热衷于创造新的框架,这些各个方面各个维度的框架组件,多到让新踏入这个领域的小白无所适从。然而虽然学习成本增加了,但是这种越来越上层的框架组件,是无数前辈大佬的经验总结,能够让前端开发变得快速高效标准,尤其是对我这种小白来说,遵循这些优秀的框架标准,就可以继承这些经验,少走很多弯路,专注于做业务逻辑的开发。 阿里开源的umi就是这样基于react的框架,它把涉及到前端开发的几乎所有方面都纳入到这个框架的管理范围内,比如路由、打包、国际化、状态管理、依赖管理等等,有了统一的标准,就可以让团队协作开发变得高效顺畅,由于它包罗万象,因此它的实现方式也很有特点,即全插件化,一个个功能都是通过插件来实现的,即使是umi最核心的功能也是通过插件实现的,开发人员也可以开发自己的插件,去满足特定需求。这套框架实现的相当不错,对我这种新手来说,简直就是福音,可以直接继承大厂的开发经验,使用上最前沿的开发技术,以最正确的姿势去做我想做的事情,但是umi的文档写的还是差那么点意思,对新手来说不太友好,如果不懂代码的话,看懂文档还是很困难的,尤其是那一堆插件,每个插件的作用是什么,插件该怎么使用,光看文档还是云里雾里。所以,代码就是最好的文档,本篇文章主要是分析下umi的插件实现原理,这样再去分析各种插件的功能时,就比较清晰了。 介绍其实umi在官方文档中,对插件有一个比较详细的介绍:开发插件,介绍了核心概念以及一些基本操作和原理,建议阅读。关于umi的插件机制,有一点要先明确,就是umi插件是分两种的:编译时插件和运行时插件,所谓编译指的是umi将项目源代码打包成实际运行的代码的过程,这是个后端行为,是在nodejs运行的,在这个过程中,umi使用各种各样的编译时插件去对编译过程进行定义,产生出最终的运行代码;而运行时插件,指的是在编译过程中,会由编译插件生成很多的临时文件,这些临时文件也是通过插件方式来组织的,这些临时文件,再加上自己编写的项目代码,就是最终要运行在浏览器的前端代码,这是个前端行为,很多编译时插件做的主要事情就是生成运行时插件。umi中所有的功能都是以插件的形式实现的,正如文档所说,通过插件,可以实现修改代码打包配置,修改启动代码,约定目录结构,修改 HTML 等丰富的功能,本篇文章也主要介绍编译时插件。 先来看看下一个典型的编译时插件长什么样子: 1234567891011121314151617import { IApi } from 'umi'; export default (api: IApi) => { api.describe({ key: 'changeFavicon', config: { schema(joi) { return joi.string(); }, }, enableBy: api.EnableBy.config }); api.modifyConfig((memo)=>{ memo.favicon = api.userConfig.changeFavicon; return memo; });}; 这是来自上面umi官方文档的一个例子,这个插件的作用是根据用户配置的 changeFavicon 值来更改配置中的 favicon,(一个很简单且没有实际用途的例子)。插件都是直接export一个方法,在该方法中,通过api接口,调用各种api方法注册自己的具体实现逻辑。 再来看一个生成的umi项目的示例: 123456789101112131415[root@kubernetes src]# ls -al .umi/total 16drwxr-xr-x 8 root root 202 Aug 7 18:44 .drwxr-xr-x 10 root root 154 Aug 7 18:44 ..drwxr-xr-x 2 root root 143 Aug 7 18:44 core-rw-r--r-- 1 root root 1440 Aug 7 18:44 exports.tsdrwxr-xr-x 2 root root 60 Aug 7 18:44 plugin-accessdrwxr-xr-x 2 root root 70 Aug 7 18:44 plugin-initialStatedrwxr-xr-x 2 root root 173 Aug 7 18:44 plugin-layoutdrwxr-xr-x 2 root root 58 Aug 7 18:44 plugin-modeldrwxr-xr-x 2 root root 58 Aug 7 18:44 plugin-request-rw-r--r-- 1 root root 643 Aug 7 18:44 tsconfig.json-rw-r--r-- 1 root root 2564 Aug 7 18:44 typings.d.ts-rw-r--r-- 1 root root 1689 Aug 7 18:44 umi.ts[root@kubernetes src]# 使用umi提供的命令,新建了一个umi项目之后,会看到在src/目录下有一个.umi/的目录,该目录以及里面的文件就是使用的编译时插件生成的,该目录中的umi.ts就是整个项目的入口文件,plugin-*/这些就是运行时插件,而其他目录和文件,都是umi整个框架约定的目录结构,比如pages/里面存放的是路由相关的代码,services/是跟后端服务打交道的业务逻辑代码等等,关于这些目录的作用,umi文档 目录结构 做了比较详细的介绍。 实现本文分析所使用的版本是umi刚发布的4.x,它的核心实现在 umi/package/core/ 这个目录中,这个 core/ 可以理解成umi这种插件架构的微内核,微内核就是确保这套插件架构能够运行良好的最小实现,可以猜测它的设计思想是在致敬linux kernel的微内核架构(RESPECT:)。 umi的整个微内核本身实现的并不太复杂,下面是它核心的类图结构: 首先来简单介绍下这几个类: Service: 它是umi中最核心的类,它里面维护了各种数组,用来存放注册进来的插件、方法、命令、Hook等,并且对注册进来的插件进行初始化,以及提供了调用注册它里面的各种对象的方法。 Plugin: 它是对插件的抽象,每一个注册到Service中的插件都会为其实例化一个Plugin,而插件分两种类型,一种是preset,是一个插件集,它可以引用很多其他插件,甚至还可以再引用其他插件集,主要是方便对插件进行管理,一种是plugin,就是真正的插件了。 PluginAPI: 它就是umi文档中介绍到的插件API,里面提供了各种各样的方法(包括核心方法和扩展方法)可以被插件来调用,将插件中的具体实现方法注册到Service对应的数组中,在初始化每一个插件时,也会同时初始化一个PluginAPI,用来作为插件跟Service之间交互的桥梁。 Hook: 所谓hook就是umi在一些特定的位置设置了一些锚点,比如onStart, onCheck等,在这些锚点上,可以注册进很多的hook方法,当程序执行到这个锚点时,就会调用该锚点上的所有hook方法,每个插件都可以在这些锚点上注册自己的方法,去实现自己的一些相关功能。 接下来分析下每个类中的关键点,这些关键点是我们理解这个微内核的关键: Hook这个类的实现比较简单,关键信息是key和fn,fn是这个hook的具体实现方法,在被调用时,通过key找到这个fn,然后执行这个fn。 Pluginpath变量记录该插件的具体路径,id和key分别是两种插件的标识。 apply方法的定义如下: 1234567891011121314151617181920212223export class Plugin { apply: Function; this.apply = () => { register.register({ implementor: esbuild, exts: ['.ts', '.mjs'], }); register.clearFiles(); let ret; try { ret = require(this.path); } catch (e: any) { throw new Error( `Register ${this.type} ${this.path} failed, since ${e.message}`, ); } finally { register.restore(); } // use the default member for es modules return ret.__esModule ? ret.default : ret; };} 它的作用就是根据字符串类型的path把这个插件给导入进来,成为一个可执行的模块。 此外,还有一个重要的方法,getPluginsAndPresets(),该方法是静态的,意味着可以直接通过类名来调用,而不用实例化,其实就是一个独立的方法: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546static getPluginsAndPresets(opts: { cwd: string; pkg: any; userConfig: any; plugins?: string[]; presets?: string[]; prefix: string; }) { function get(type: 'plugin' | 'preset') { const types = `${type}s` as 'plugins' | 'presets'; return [ // opts ...(opts[types] || []), // 从方法参数中 // env ...(process.env[`${opts.prefix}_${types}`.toUpperCase()] || '') //从环境变量中 .split(',') .filter(Boolean), ...(opts.userConfig[types] || []), // 从用户配置中 ].map((path) => { assert( typeof path === 'string', `Invalid plugin ${path}, it must be string.`, ); let resolved; try { resolved = resolve.sync(path, { basedir: opts.cwd, extensions: ['.tsx', '.ts', '.mjs', '.jsx', '.js'], }); } catch (_e) { throw new Error(`Invalid plugin ${path}, can not be resolved.`); } return new Plugin({ path: resolved, type, cwd: opts.cwd, }); }); } return { presets: get('preset'), plugins: get('plugin'), }; } 该方法从三个地方去找插件: 从方法参数中,即外面调用该方法时,会传入plugins和presets参数; 从环境变量中,即 UMI_PLUGINS 和 UMI_PRESETS 指定的 plugins 和 presets; 从用户配置中,即umi配置文件中指定的plugins和presets; 找到之后,针对每一个插件,实例化一个Plugin对象,最终返回一个plugins和presets的插件列表。 PluginAPIPluginAPI是Plugin和Service之间的桥梁,我们在编写插件时,主要就是跟PluginAPI打交道,通过PluginAPI提供的各种方法,将相关的功能函数注册到Service对应的数组中去。这些方法分成两种:核心方法和扩展方法,核心方法是指PluginAPI提供的最基础的API,比如描述该插件信息的 describe() 方法,还有将各种对象注册到Service中的 register-*() 等方法,而扩展方法则是通过插件的形式注册到Service中的 pluginMethods 列表中的,而真实对外的PluginAPI实际上是一个PluginAPI的代理,在调用其方法时,会先去Service中的 pluginMethods 中找是否有对应的方法,没有的话,再去PluginAPI中找,代码如下: 1234567891011121314151617181920 static proxyPluginAPI(opts: { pluginAPI: PluginAPI; service: Service; serviceProps: string[]; staticProps: Record<string, any>; }) { return new Proxy(opts.pluginAPI, { get: (target, prop: string) => { if (opts.service.pluginMethods[prop]) { return opts.service.pluginMethods[prop].fn; } ...... // @ts-ignore return target[prop]; }, }); }} 使用 PluginAPI提供的 registerMethods() 方法,就可以向Service中注册扩展方法: 1234567891011121314151617181920registerMethod(opts: { name: string; fn?: Function }) { assert( !this.service.pluginMethods[opts.name], `api.registerMethod() failed, method ${opts.name} is already exist.`, ); this.service.pluginMethods[opts.name] = { plugin: this.plugin, fn: opts.fn || // 这里不能用 arrow function,this 需指向执行此方法的 PluginAPI // 否则 pluginId 会不会,导致不能正确 skip plugin function (fn: Function | Object) { // @ts-ignore this.register({ key: opts.name, ...(lodash.isPlainObject(fn) ? (fn as any) : { fn }), }); }, }; } 可以看到该方法的作用就是向 pluginMethods 对象中添加方法fn,key为方法名,但是需要注意的是 registerMethod() 的 fn 参数是可选的,即可以只指定一个方法名 name 参数,而不指定 fn 参数, 这种情况下,会注册一个默认的方法,而这个方法的定义是输入另一个 fn 参数,然后将该 fn 通过PluginAPI的 register() 方法注册到Service中,我们先来看看这个 register() 方法: 12345678910register(opts: Omit<IHookOpts, 'plugin'>) { assert( this.service.stage <= ServiceStage.initPlugins, 'api.register() should not be called after plugin register stage.', ); this.service.hooks[opts.key] ||= []; this.service.hooks[opts.key].push( new Hook({ ...opts, plugin: this.plugin }), ); } 可以看到它的作用就是将 opts 中的 fn, key 等参数组成一个Hook对象,然后将该Hook对象添加到Service中的hooks列表中。所以如果 registerMethod() 方法不传 fn 参数的话,那么它注册到Service中的都是同一个 fn,即默认的 fn,它的作用就是向Service中注册hook。来看一个例子: 12345678910111213141516// umi/package/core/src/service/servicePlugin.tsimport { PluginAPI } from './pluginAPI';export default (api: PluginAPI) => { [ 'onCheck', 'onStart', 'modifyAppData', 'modifyConfig', 'modifyDefaultConfig', 'modifyPaths', ].forEach((name) => { api.registerMethod({ name }); });}; 上面就是一个umi内置的核心插件,可以看到它就是调用了 registerMethod(),但是只传递了 name 参数,注册了好几个扩展方法到Service的 pluginMethods 数组中,这些扩展方法的实现都是上面默认的方法,然后再通过PluginAPI的代理,就可以在别的插件中,这样来调用扩展方法了: 12345678export default (api: IApi) => { api.onCheck(async () => { ...... }); });} 这样就将async () => {} 这个方法注册到 onCheck 这个hook列表中了,如果在其他插件中也调用 api.onCheck() 方法,则会向 onCheck hook列表中追加hook,这样就会在 onCheck hook列表中,有很多hook了。 所以通过 registerMethod() 方法注册到 Service pluginMethods 中的方法,通过PluginAPI代理,对外就表现为PluginAPI的API,或者叫插件API,或者叫扩展方法,而这些方法本质上的作用就是向各种Hook列表中注册hook,所以PluginAPI除了自己类中那几个基础的API之外,其它API都是通过扩展方法的方式注册进来的,之后再调用这些扩展方法,也并没有做什么实际的动作,只是简单执行了注册Hook的逻辑。所以这个地方挺绕的,如果把方法名改下我觉得就比较好理解了,比如 onCheck 改为 registerOnCheck,但是注册到service hooks中的key仍然为onCheck,让别人知道 api.onCheck() 仍然是在做注册的事情,比如: 123api.registerMethod( {"registerOnCheck"} ); //注册完这个method之后,就可以被api调用了api.registerOnCheck(async () = {}); //向onCheck hook列表中注册Hookservice.hooks["onCheck"] = [Hook]; 那这些注册进来的Hook在哪又是怎么被使用呢?这就是Service里面的逻辑了。 ServiceService就是一个集大成者了,首先它里面存放了上面介绍到的各种数组,主要有以下几种: commands: 是用来存放所有注册到umi中的命令的,要知道umi中一切都是插件,命令也不例外,比如dev, build等都有对应的插件; plugins:是存放所有注册进来的经过Plugin类实例化过之后的插件的,其中preset也作为一种特殊的插件被存储进来; pluginMethods: 是用来存放插件API的扩展方法的,它以方法名为Key,方法的实现为Value; hooks: 用来存放hook的数组,以方法名为key,value是一个hook列表,相同方法名的hook注册到同一个列表中,形式如:{"hook1": [HookA, HookB], "hook2": [HookC, HookD]} 然后就是它启动服务的 run() 方法了,在该方法中大致步骤如下: 读取用户配置; 从各个地方找插件; 进行插件的初始化; 调用启动服务的各种hook方法,比如onCheck, onStart等; 执行相应command对应的方法,比如 dev, build等; 其关键步骤如下: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061async run(opts: { name: string; args?: any }) { ...... // 从各个地方查找插件 const { plugins, presets } = Plugin.getPluginsAndPresets({ cwd: this.cwd, pkg, plugins: [require.resolve('./generatePlugin')].concat( this.opts.plugins || [], ), presets: [require.resolve('./servicePlugin')].concat( this.opts.presets || [], ), userConfig: this.userConfig, prefix, }); ...... // 注册presets和plugins const presetPlugins: Plugin[] = []; while (presets.length) { await this.initPreset({ preset: presets.shift()!, presets, plugins: presetPlugins, }); } plugins.unshift(...presetPlugins); // unshift() 插入到数组的最前面 while (plugins.length) { await this.initPlugin({ plugin: plugins.shift()!, plugins }); } const command = this.commands[name]; ...... // 调用各种hook this.paths = await this.applyPlugins({ key: 'modifyPaths', initialValue: paths, }); ...... await this.applyPlugins({ key: 'onCheck', }); await this.applyPlugins({ key: 'onStart', }); // 执行command对应的方法 let ret = await command.fn({ args }); ......} 1. 获取插件首先就是调用 Plugin.getPluginsAndPresets() 从各个地方找插件,这个方法在上面Plugin小节就介绍过,此处还通过参数分别传递了一个preset和一个plugin,./servicePlugin 这个里面就是去注册一些核心的插件API到Service中,比如onCheck, onStart等,而./generatePlugin则是注册一个generate命令,这些都是最基础的方法,会被其他插件调用到的,所以放到了这里来进行注册。 2. 插件初始化获取到插件之后,接着就会对presets和plugins进行初始化,首先会从presets中获取到它里面包含的所有的plugins,然后将presets中的plugins添加到Service本身中的plugins数组中,需要注意的是,presets中的plugins会被插入到数组的最前面。然后再依次遍历Service中的plugins中的Plugin,挨个进行初始化。 初始化是调用initPlugin()方法对某个Plugin进行初始化的,在该方法中,会为该Plugin创建PluginAPI对象,然后再为该PluginAPI对象创建代理,然后导入并执行该插件,其主要逻辑如下: 123456789101112131415161718async initPlugin(opts: { plugin: Plugin; presets?: Plugin[]; plugins: Plugin[]; }) { this.plugins[opts.plugin.id] = opts.plugin; const pluginAPI = new PluginAPI({ plugin: opts.plugin, service: this, }); const proxyPluginAPI = PluginAPI.proxyPluginAPI({ service: this, pluginAPI, ...... }); let ret = await opts.plugin.apply()(proxyPluginAPI); ...... } 其中 opts.plugin.apply()(proxyPluginAPI) 是最关键的,apply()方法在上面Plugin小节就介绍过,是将Plugin的模块导入进来,导入进来之后,就直接传递了 proxyPluginAPI 参数进行执行,所以这次我们再来看看上面的插件示例,是不是就可以理解插件为什么要这么写了: 1234567891011121314151617import { IApi } from 'umi'; export default (api: IApi) => { api.describe({ key: 'changeFavicon', config: { schema(joi) { return joi.string(); }, }, enableBy: api.EnableBy.config }); api.modifyConfig((memo)=>{ memo.favicon = api.userConfig.changeFavicon; return memo; });}; 这里插件传递的参数api,其实就是proxyPluginAPI,然后所谓的导入插件并执行,其实就是在执行api.describe()和api.modifyConfig()等插件API的方法,而这些方法就是上面PluginAPI小节介绍的,是插件API的核心方法或者是扩展方法,是用来向Service中注册各种Hook的。 3. 调用hook接下来就是调用onCheck, onStart等hook去执行启动的相关任务了,调用hook是通过Service提供的applyPlugins()来实现的,其关键逻辑如下: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051// overload, for apply event synchronously applyPlugins<T>(opts: { key: string; type?: ApplyPluginsType.event; initialValue?: any; args?: any; sync: true; }): typeof opts.initialValue | T; applyPlugins<T>(opts: { key: string; type?: ApplyPluginsType; initialValue?: any; args?: any; }): Promise<typeof opts.initialValue | T>; applyPlugins<T>(opts: { key: string; type?: ApplyPluginsType; initialValue?: any; args?: any; sync?: boolean; }): Promise<typeof opts.initialValue | T> | (typeof opts.initialValue | T) { const hooks = this.hooks[opts.key] || []; let type = opts.type; switch (type) { case ApplyPluginsType.add: for (const hook of hooks) { if (!this.isPluginEnable(hook)) continue; tAdd.tapPromise( { name: hook.plugin.key, stage: hook.stage || 0, before: hook.before, }, async (memo: any) => { const dateStart = new Date(); const items = await hook.fn(opts.args); hook.plugin.time.hooks[opts.key] ||= []; hook.plugin.time.hooks[opts.key].push( new Date().getTime() - dateStart.getTime(), ); return memo.concat(items); }, ); } case ApplyPluginsType.modify: ...... case ApplyPluginsType.event: ...... } 这里涉及到typescript的知识点,叫 overloads,这个有点类似于Java, C++等语言的多态,就是同一个方法名,但是可以接受不同的参数,返回不同的值。可以看到在方法的实现里面,首先会从hooks对象中取出对应key的hook列表,然后遍历这个hook列表,去执行每个hook中的fn方法,而这些fn方法就是之前各种插件注册进来的自己插件的相关逻辑。当然,在其他地方,比如其他的插件里,也可以通过调用 applyPlugins() 方法来执行注册到Service中的某一个hook。 4. 执行command最后一步就是执行对应的command,这些command也是通过插件注册到Service中的commands数组中的,通过name找到这个command,然后执行其中的fn: 12const command = this.commands[name];let ret = await command.fn({ args }); 内置插件了解了上面介绍到的“微内核”的实现原理,我们来大概看下umi内置的插件都有哪些,是怎么传递进去的。首先就是 core Service 中传递进去的plugin,上面在介绍 Service run() 方法时,也提到了,core中的Service会从各个地方去找插件: 123456789101112131415async run(opts: { name: string; args?: any }) { // 从各个地方查找插件 const { plugins, presets } = Plugin.getPluginsAndPresets({ cwd: this.cwd, pkg, plugins: [require.resolve('./generatePlugin')].concat( this.opts.plugins || [], ), presets: [require.resolve('./servicePlugin')].concat( this.opts.presets || [], ), userConfig: this.userConfig, prefix, });} generatePlugin 和 servicePlugin 这两个就是core中内置的plugin和preset,提供最基础的功能,比如在 servicePlugin 中提供了 onCheck, onStart, modifyConfig 等基础的插件API,而在 generatePlugin 中提供了umi的generate命令的实现。 这两个就是core层面内置的插件了,在往上一层,就是在 umi/package/umi/src/service/service.ts 中的 Service中,它继承了 core中的Service,在这个umi Service这个子类的构造方法中,又传递了presets和plugins: 123456789101112131415161718192021222324export class Service extends CoreService { constructor(opts?: any) { debugger; process.env.UMI_DIR = dirname(require.resolve('../../package')); const cwd = getCwd(); // Why? // plugin import from umi but don't explicitly depend on it // and we may also have old umi installed // ref: https://github.com/umijs/umi/issues/8342#issuecomment-1182654076 require('./requireHook'); super({ ...opts, env: process.env.NODE_ENV, cwd, defaultConfigFiles: DEFAULT_CONFIG_FILES, frameworkName: FRAMEWORK_NAME, presets: [require.resolve('@umijs/preset-umi'), ...(opts?.presets || [])], plugins: [ existsSync(join(cwd, 'plugin.ts')) && join(cwd, 'plugin.ts'), existsSync(join(cwd, 'plugin.js')) && join(cwd, 'plugin.js'), ].filter(Boolean), }); }} 可以看到它传递了 @umijs/preset-umi 这个模块中的 preset,以及umi项目根目录中的 plugin.ts,作为presets和plugins参数,关于umi项目根目录的这个plugin.ts,在umi的官方文档中有介绍,这可能是一个项目要实现自己的插件,最简单方便的方式了吧,直接在项目根目录放一个这个文件就好了,然后我们来看看这个 @umijs/preset-umi preset中都包含了哪些plugin: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455export default () => { return { plugins: [ // registerMethods require.resolve('./registerMethods'), // features require.resolve('./features/appData/appData'), require.resolve('./features/check/check'), require.resolve('./features/clientLoader/clientLoader'), require.resolve('./features/configPlugins/configPlugins'), require.resolve('./features/crossorigin/crossorigin'), require.resolve('./features/depsOnDemand/depsOnDemand'), require.resolve('./features/devTool/devTool'), require.resolve('./features/esmi/esmi'), require.resolve('./features/favicons/favicons'), require.resolve('./features/mock/mock'), require.resolve('./features/polyfill/polyfill'), require.resolve('./features/polyfill/publicPathPolyfill'), require.resolve('./features/routePrefetch/routePrefetch'), require.resolve('./features/ssr/ssr'), require.resolve('./features/terminal/terminal'), require.resolve('./features/tmpFiles/tmpFiles'), require.resolve('./features/tmpFiles/configTypes'), require.resolve('./features/transform/transform'), require.resolve('./features/lowImport/lowImport'), require.resolve('./features/vite/vite'), require.resolve('./features/apiRoute/apiRoute'), require.resolve('./features/monorepo/redirect'), require.resolve('./features/clickToComponent/clickToComponent'), // commands require.resolve('./commands/build'), require.resolve('./commands/config/config'), require.resolve('./commands/dev/dev'), require.resolve('./commands/help'), require.resolve('./commands/lint'), require.resolve('./commands/setup'), require.resolve('./commands/version'), require.resolve('./commands/generators/page'), require.resolve('./commands/generators/prettier'), require.resolve('./commands/generators/tsconfig'), require.resolve('./commands/generators/jest'), require.resolve('./commands/generators/tailwindcss'), require.resolve('./commands/generators/dva'), require.resolve('./commands/generators/component'), require.resolve('./commands/generators/mock'), require.resolve('./commands/generators/cypress'), require.resolve('./commands/generators/api'), require.resolve('./commands/plugin'), require.resolve('./commands/verify-commit'), require.resolve('./commands/preview'), ], };}; 这个里面的plugin分为三类,第一类就是通过 registerMethod() 方法,注册了一堆 methods 进去,这些 methods 都会变成 插件API,即PluginAPI,的扩展方法,umi官方给出了插件API的文档,对里面每个扩展方法的作用做了介绍:插件API,所以了解了上面插件实现的原理,就可以容易读懂这个文档了,就可以happy的照着文档去开发插件了。 剩下两个,一个是features,一个是commands,features就是内置的一些实用性的插件了,这些插件也都是相对通用基础的一些插件,而commands是注册进去了很多umi的命令行插件,这两个先不细说了。 这样在执行 umi dev, umi build 等命令时,就会先去实例化umi中的这个Service,会将这些插件,以及 core Service 中的插件一起注册进去,这些内置插件就构成了umi这个项目本身提供的一些基础功能了,而在 umi 4.x 中,又新出了一个概念,叫做 Umi Max,名字听起来挺玄乎的,但实际上,它非常简单,就是在umi内置插件的基础上,又额外加了一些插件,这些插件是阿里蚂蚁集团内部根据工程实践经验,积累出来的一套插件,将他们集成到了一起,叫做 Umi Max,让你有种开箱即用,拎包入住的体验,那max又是怎么将插件传递进去的呢?非常简单: 12345678// umi/package/max/src/cli.tsimport { run } from 'umi';run({ presets: [require.resolve('./preset')],}).catch((e) => { console.error(e); process.exit(1);}); 而这个run()方法来自于umi的cli: 1234567// umi/package/umi/src/cli/cli.tsexport async function run(opts?: IOpts) { if (opts?.presets) { process.env.UMI_PRESETS = opts.presets.join(','); }} 即 Umi Max 中的插件是通过 UMI_PRESETS 环境变量传递进去的,这样在上面介绍到的 Plugin.getPluginsAndPresets() 方法中,就可以从该环境变量中获取到max的preset了,那来看看max的注册进来的preset,都有哪些插件: 123456789101112131415161718192021222324// umi/packages/max/src/preset.tsexport default () => { return { plugins: [ require.resolve('@umijs/plugins/dist/access'), require.resolve('@umijs/plugins/dist/analytics'), require.resolve('@umijs/plugins/dist/antd'), require.resolve('@umijs/plugins/dist/dva'), require.resolve('@umijs/plugins/dist/initial-state'), require.resolve('@umijs/plugins/dist/layout'), require.resolve('@umijs/plugins/dist/locale'), require.resolve('@umijs/plugins/dist/mf'), require.resolve('@umijs/plugins/dist/model'), require.resolve('@umijs/plugins/dist/moment2dayjs'), require.resolve('@umijs/plugins/dist/qiankun'), require.resolve('@umijs/plugins/dist/request'), require.resolve('@umijs/plugins/dist/tailwindcss'), require.resolve('./plugins/maxAlias'), require.resolve('./plugins/maxAppData'), require.resolve('./plugins/maxChecker'), ], };}; 大部分都是 @umijs/plugins 这个项目中的插件,包含权限管理access、布局layout、UI组件antd、数据流管理dva、国际化locale等等,这些就是平时前端开发会经常用到的,相比umi内置的基础的插件更上层的一些功能插件了。 总结本篇文章大致介绍了umi这个框架的实现原理,梳理了下它的脉络,重点在于理清楚它的插件机制是如何实现的,方便给插件开发者以及使用者在umi原理层面有个认知,以能够更胸有成竹的做umi相关的开发,做到知其然且知其所以然。从umi这个“微内核”架构的实现原理上来看,这个设计还是相当不错的,代码质量也非常的高,真的做到了“一切即插件”,开发粒度可粗可细,并且提供了开箱即用的各种高级功能插件,规范和提升开发效率,真的是非常赞的一个框架。","link":"/2022/08/07/umi/umi.html"},{"title":"Keystone Federated Identity with Google Saml App","text":"背景介绍作为一个私有云平台,能够和其他账户中心打通,使用外部平台的账户体系,这是一个非常有用的功能,尤其是作为企业中的私有云,是企业中众多平台的一部分,搞多套账户体系必定增加管理成本,能够和现有的账户体系对接,账户统一管理,是一个成熟企业的标志。OpenStack作为最流行的开源IaaS私有云平台,也是具备这种能力的,Keystone可以直接和LDAP等账户中心对接,也可以通过SAML2和OpenID Connect等协议,和外部账户中心进行联合认证,即实现类似SSO的功能。 本文档主要是来介绍下Keystone作为SP(服务提供商)通过SAML2协议跟Google的账户中心(SAML Apps)进行SSO对接的一些基本原理、配置方法以及一些关键点,关于Keystone的联合认证,网上已经有大量的资料可以参考: https://docs.openstack.org/security-guide/identity/federated-keystone.html http://wsfdl.com/openstack/2016/01/14/Keystone-Federation-Identity-with-SAML2.html https://docs.openstack.org/keystone/pike/advanced-topics/federation/federated_identity.html#keystone-as-an-identity-provider-idp https://docs.openstack.org/keystone/latest/admin/federation/introduction.html Keystone很早就开始支持联合认证,到现在发展已经比较成熟了,目前支持两种联合认证方式: SAML:Security Assertion Markup Language OpenID Connect SAML是一种基于XML的认证技术,OpenID Connect则使用JSON/REST,更加简单易用;SAML只支持Web应用,而OpenID Connect还支持移动应用。关于SAML和OpenID Connect可以参考下面两篇文档: https://www.cnblogs.com/shuidao/p/3463947.html https://developers.google.com/identity/protocols/OpenIDConnect 本文档主要是测试Keystone作为SP使用SMAL2协议跟Google的SAML Apps进行WebSSO对接,Google SAML Apps是谷歌的G-Suite里面提供的一个支持SAML协议的IDP服务,G-Suite还提供用户目录,里面有用户和组的管理,SAML Apps就是通过SAML协议可以将用户目录暴露出去,让第三方应用过来对接,所以,最终达到的效果就是从Horizon的登录界面,跳转到Google的账户中心进行登录,然后跳转回Horizon,登录到面板中。需要注意的是,本次测试只是测试Web SSO,即基于浏览器的单点登录,其实Keystone还支持不通过浏览器的SSO,即直接可以通过API从IDP那里认证,然后拿到SP端的Keystone Token,这部分内容不在我们本次的测试范围内,因此配置上跟官方文档上的一些配置有些出入。 基本原理Keystone早期只支持作为SP,后来也增加了作为IDP的支持,作为SP,Kesytone没有自己去开发相关的功能,而是依赖于第三方工具以及对应的Apache HTTPD中的模块,比如针对SAML协议的SSO,使用的是Shibboleth以及对应的Apache模块mod_shib,他们可以作为Keystone和IDP的一个中间件,解析SAML请求,处理SSO的逻辑,而Keystone本身以扩展的形式实现了Federation API,可以增删查改SP和IDP,可以定义某个IDP中的用户和本地的用户如何映射的mapping规则,因为Keystone并不是Web前端,所以还需要Horizon来处理部分WebSSO的逻辑,整体的流程如下图所示: 步骤1,2 浏览器重定向到 /v3/auth/OS-FEDERATION/websso/saml2,这个地址是websso的特定地址,它被配置到httpd的vhost中,作为特殊路径被shibboleth处理; 步骤3,4,httpd接受到请求之后,拦截到该特殊路径,会将该请求交给shibboleth去处理,shibboleth是有一个独立进程的,它可以处理SAML相关的请求,在这一步,它会将SP端的信息,以SAML协议的XML格式,经过私钥加密,发送给IDP端,内容叫做AuthnRequest,如下所示: 12345<?xml version="1.0" ?><samlp:AuthnRequest AssertionConsumerServiceURL="https://sp.ustack.com:15000/Shibboleth.sso/SAML2/POST" Destination="https://accounts.google.com/o/saml2/idp?idpid=C03vf5z0r" ID="_b4a92cde99677bb242fcd35241cf37a5" IssueInstant="2019-05-25T15:35:53Z" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Version="2.0" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"> <saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">https://sp.ustack.com:15000/Shibboleth.sso</saml:Issuer> <samlp:NameIDPolicy AllowCreate="1"/></samlp:AuthnRequest> 这里面有三个比较重要的SP端信息: AssertionConsumerServiceURL:简称ACS,如名字所表达的意思,是在IDP端认证完成之后,IDP端需要向SP端发送响应,该属性指定了SP端处理来响应的地址,该地址是真实有效的,是由Shibboleth提供的,因为Google要求ACS地址必须是https的,所以在SP这端必须配置上https; ProtocolBinding:SP端是可以提供多种协议的ACS的,每种协议都有对应的称之为Binding的路径来处理,比如常见的HTTP POST,HTTP Redirect,此处,使用的就是HTTP POST,即IDP端处理完请求,往SP端发送响应时,是通过向SP端的ACS URL发送POST请求来完成的; Issuer:SP端的Entity ID,即SP端的唯一标志符,通常用URL表示,这个在SP和IDP端一定要保持一致。 以上SP端的SAML XML格式的信息,会被Shibboleth经过IDP端提供的X509的证书进行加密,然后放到SAMLRequest参数中,以Query String的形式,重定向到IDP。至于SP端是怎么知道IDP在哪里,以及IDP的证书是如何提供的,则是通过IDP提供的一个Metadata地址或者文件,配置在SP的Shibboleth里面的,下面的示例会介绍到。 步骤5,6,7则是在步骤4重定向到IDP之后,带着SamlRequest,进入到了IDP的处理逻辑,此处在IDP做的事情,无疑就是输入用户名和密码,进行登录认证,认证完成之后,将IDP端经过认证的用户的信息,封装成SAML XML格式的信息,即SamlResponse,通过SamlRequest中指定的SP端的回调信息,将返回值传输回SP,在本例中,使用的是HTTP POST,将SamlResponse经过加密,放到POST的body中,向SP端的ACS URL发送POST请求,此处的重点是SamlResponse,它是一个XML格式的信息,里面就包含了SAML的精髓,即Assertion,在本例中,其SamlResponse如下: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354<?xml version="1.0" ?><saml2p:Response Destination="https://sp.ustack.com:15000/Shibboleth.sso/SAML2/POST" ID="_95d7aed5878b2ff466a79a1d2661ae5e" InResponseTo="_4d4f20f39a145d412c1854236e5ea5e1" IssueInstant="2019-05-14T13:48:34.590Z" Version="2.0" xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"> <saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">https://accounts.google.com/o/saml2?idpid=C03vf5z0r</saml2:Issuer> <saml2p:Status> <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/> </saml2p:Status> <saml2:Assertion ID="_c463b606831db5bbd08b53ebd6988acc" IssueInstant="2019-05-14T13:48:34.590Z" Version="2.0" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"> <saml2:Issuer>https://accounts.google.com/o/saml2?idpid=C03vf5z0r</saml2:Issuer> <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"> <ds:SignedInfo> <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/> <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/> <ds:Reference URI="#_c463b606831db5bbd08b53ebd6988acc"> <ds:Transforms> <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/> <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/> </ds:Transforms> <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/> <ds:DigestValue>zE5x7BttL/P14aGZ9dicTvMXc9/gspW9icb3Yp2wU6w=</ds:DigestValue> </ds:Reference> </ds:SignedInfo> <ds:SignatureValue>My7ENgwJbo0mG+NxyCFYqYcQWZfqHURnioOfeY2JCTZCmaNUIZFmZHTBSnt23kF9b8COt9CZiD6WQr6z4uFFoFbAn8RPTk5Z2dUQc1koZbEbFftQlA$=</ds:SignatureValue> <ds:KeyInfo> <ds:X509Data> <ds:X509SubjectName>ST=California,C=US,OU=Google For Work,CN=Google,L=Mountain View,O=Google Inc.</ds:X509SubjectName> <ds:X509Certificate>MIIDdDCCAlygAwIBAgIGAWqyN85bMA0GCSqGSIb3DQEBCwUAMHsxFDASBgNVBAoTC0dvb2dsZSBJZPEdDSRaXHwUYY8N2S/ch$BDyRQsE0z/eYOFdnk+fGox</ds:X509Certificate> </ds:X509Data> </ds:KeyInfo> </ds:Signature> <saml2:Subject> <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">suo@hackerain.github.io</saml2:NameID> <saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"> <saml2:SubjectConfirmationData InResponseTo="_4d4f20f39a145d412c1854236e5ea5e1" NotOnOrAfter="2019-05-14T13:53:34.590Z" Recip$ent="https://sp.ustack.com:15000/Shibboleth.sso/SAML2/POST"/> </saml2:SubjectConfirmation> </saml2:Subject> <saml2:Conditions NotBefore="2019-05-14T13:43:34.590Z" NotOnOrAfter="2019-05-14T13:53:34.590Z"> <saml2:AudienceRestriction> <saml2:Audience>https://sp.ustack.com:15000/Shibboleth.sso</saml2:Audience> </saml2:AudienceRestriction> </saml2:Conditions> <saml2:AttributeStatement> <saml2:Attribute Name="openstack_user"> <saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">Guangyu</saml2:AttributeValue> </saml2:Attribute> </saml2:AttributeStatement> <saml2:AuthnStatement AuthnInstant="2019-05-14T13:29:24.000Z" SessionIndex="_c463b606831db5bbd08b53ebd6988acc"> <saml2:AuthnContext> <saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml2:AuthnContextClassRef> </saml2:AuthnContext> </saml2:AuthnStatement> </saml2:Assertion></saml2p:Response> 可见SamlResponse包含了非常多的信息,重点是Assertion中包含的信息,有签名信息,证书信息,以及一些IDP端关于用户的一些属性,比如例子中的AttributeStatement中所包含的属性:openstack_user,它映射的值,就是IDP里面存储的用户名:Guangyu 步骤8,9,10则是在SP HTTPD接受到IDP发来的响应之后,进行的一些处理逻辑。做的事情就是解析SamlResponse,检查返回值的合法性,然后提取里面用户的属性,将用户属性以Session的形式保存起来,然后再重定向到 /v3/auth/OS-FEDERATION/websso/saml2 ,这里最重要的是将用户的信息以Session形式保存下来,默认是保存到Shibboleth进程的内存中的,有了Session其实就意味着用户”登录“成功了,在Shibboleth中,可以通过浏览器访问https://host:port/Shibboleth.sso/Session路径来查看保存的Session,如本例中,浏览器访问 https://sp.ustack.com:15000/Shibboleth.sso/Session 会看到如下的信息: 1234567891011MiscellaneousSession Expiration (barring inactivity): 437 minute(s)Client Address: 10.0.80.71SSO Protocol: urn:oasis:names:tc:SAML:2.0:protocolIdentity Provider: https://accounts.google.com/o/saml2?idpid=C03vf5z0rAuthentication Time: 2019-05-24T02:30:07.000ZAuthentication Context Class: urn:oasis:names:tc:SAML:2.0:ac:classes:unspecifiedAuthentication Context Decl: (none)Attributesopenstack_user: Guangyu 在第10步中,Shibbloleth处理完POST请求,会再次重定向到 /v3/auth/OS-FEDERATION/websso/saml2,我们知道,在之前的第2步中,也向这个地址重定向过,并且被Shibboleth拦截,从而跳向了IDP进行认证,那么这里的再次重定向,跟之前那次有什么不一样呢?最主要的区别,是针对该浏览器,服务器端已经保存了用户的Session信息,重定向时,浏览器是带着cookie来访问该地址的,cookie中保存了sessionid,服务端查看该sessionid在服务端已经保存,并且没有过期,那么它就认为其是一个合法用户,不会对其进行拦截,从而让其走正常的流程,可以看看Apache Httpd Keystone Vhost中的这段配置: 12345678<Location ~ "/v3/auth/OS-FEDERATION/websso/saml2"> AuthType shibboleth Require valid-user ShibRequestSetting requireSession 1 ShibRequireSession On ShibExportAssertion Off LogLevel debug</Location> 针对该路径,认证方式是shibboleth,而原语 Require valid-user 意思是要求是一个合法用户,才可以访问该路径,如果不合法,则需要向Shibboleth请求一个Session,从而走向了我们之前说的逻辑,而一旦获取到了Session,则成为一个valid user,下次再访问这个用户,就可以正常访问这个路径了,并且在访问这个路径时,Session信息,会以环境变量的形式,传递到该路径对应的API接口中。 因此步骤11,12,13其实是正常的访问Keystone的请求,而 /v3/auth/OS-FEDERATION/websso/saml2 则是Keystone实现的一个API,即 GET /v3/auth/OS-FEDERATION/websso/{protocol_id}?origin=https%3A//horizon.example.com,它做的事情,就是根据protocol_id,找到配置在keystone配置文件中对应protocol的RemoteID属性名,RemoteID其实就是IDP的Entity ID,IDP的信息是提前通过API录入到Keystone的数据库中的,根据RemoteID的属性名,就可以由Session传入的环境变量中找到对应的RemoteID: 1234567891011121314151617181920212223242526在keystone.conf中有如下配置:[saml2]remote_id_attribute = Shib-Identity-Provider在Shibboleth的Session中有如下属性:Identity Provider: https://accounts.google.com/o/saml2?idpid=C03vf5z0r该属性会变成环境变量传给该接口,属性的名字就变成了:Shib-Identity-Provider: https://accounts.google.com/o/saml2?idpid=C03vf5z0r而在Keystone中已经提前录入了IDP的信息:[root@mon01 ~]# openstack identity provider show google-idp+-------------+-----------------------------------------------------+| Field | Value |+-------------+-----------------------------------------------------+| description | None || domain_id | 7c1efdfe963045d19635330e27a4608d || enabled | True || id | google-idp || remote_ids | https://accounts.google.com/o/saml2?idpid=C03vf5z0r |+-------------+-----------------------------------------------------+ 根据以上关系,可以通过RemoteID找到对应的IDP,然后根据request中的环境变量信息,以及idp_id和protocol_id,就可以获取到一个unscoped的token,而获取这个token的过程,其实就是进入到了Keystone处理Federation认证最核心的逻辑:**Mapping**,即Keystone事先定义好了一组Remote User和Local User的一个Mapping规则,Remote User说的就是IDP中的用户信息,它保存在本次请求的环境变量中,而Local User就是在SP端的Keystone用户信息,Mapping规则,就是定义了Remote User的什么属性映射成Local User的什么属性,Keystone会对应的在数据库中创建Local User。Keystone Federation认证的成熟主要体现在Mapping规则的成熟,目前实现的Mapping规则还是非常强大的,几乎可以完成任何你想要的定制,Mapping规则的格式如下: 12345678910111213141516171819{ "rules": [ { "local": [ { <user> [<group>] [<project>] } ], "remote": [ { <match> [<condition>] } ] } ]} 通过命令行创建一个mapping: 1$ openstack mapping create --rules rules.json google-idp-mapping 根据mapping规则,创建好Local User,然后会获取到一个unscoped的token,该token被放到一个sso_callback_template.html 表单中,重定向到origin指定的地址,而该origin指定的地址就是Horizon来处理websso登录的地址,即:/auth/websso/。 步骤14,15即是Horizon的处理逻辑了,浏览器带着unscoped token,访问Horizon的/auth/websso路径,Horizon会用unscoped token再去请求该token所代表用户的Project信息,如果有跟该用户关联的project,则会再去请求一个scoped token,然后再经过一次重定向,登录到Horizon面板中。 可以看到整个WebSSO的流程还是很复杂的,但是重点有两方面: 一个重点是Shibboleth的处理流程,以及配置方法,Shibboleth很强大,不仅可以作为SP,还可以作为IDP,它是OpenSAML协议的一个实现,有C++和Java两个版本,其具有非常复杂的配置项,官方文档地址在 Service Provider 3,理解Shibboleth的概念以及配置项是关键; 另外一个重点就是Keystone里面的 Federation API,作为SP,它要在自己的数据库中记录IDP的信息,以及相应的Protocol, Mapping等,Mapping规则很强大,可以使用正则对Remote进行匹配,映射到的Local可以关联到user, group以及project上,甚至可以自动创建project,将该user和该project进行关联,让该user直接获取到scoped token,关于Mapping的更详细使用方法,见官方文档:Mapping 测试步骤环境准备 OpenStack环境,Rocky版本 申请一个G-Suite账号,地址在:https://admin.google.com 域名和有效证书,因为Google作为IDP端是需要回调SP的接口的,都是走的互联网,Google要求必须是https的接口 配置Google SAML App登录G-Suite,在Apps->SAML Apps中,新建Saml App,选择自定义App,一个Saml App就代表着一个SAML的IDP,其中有几步操作是重点: 1. 获取IDP的Metadata文件 下载Option 2中的IDP metadata文件,这个文件会在配置SP端时用到,里面包含了IDP的信息,该文件名字是GoogleIDPMetadata-xxx.xxx.xml。同时也要注意Option 1中的两个信息,分别为IDP的SSO URL,以及IDP的Entity ID,SSO URL即为SP重定向要的地址。 2. 填写SP端的信息 在IDP端要输入SP端的两个必填信息,一个是SP端的ACS URL,如上面原理中所介绍,该URL是SP端提供的用来处理IDP认证通过后,接受回调的接口,这里必须配置成https,因为SP端需要有有效证书,以及配置域名。本例中,ACS URL是:https://sp.ustack.com:15000/Shibboleth.sso/SAML2/POST,另一个必填信息是SP端的Entity ID,这个值没有特殊要求,但是需要和SP端配置的Entity ID保持一致,否则会SP端会匹配不到对应的ACS地址来处理IDP的回调请求。 3. 配置属性Mapping 这个Mapping的意思就是要将IDP端的哪些属性暴露给SP,可选的有很多,G-Suite有一个用户目录,可以管理用户和组,里面有很多属性。最左边的属性名是SP端支持的属性,右边两个下拉框是IDP端可以提供哪些属性,如本例中,SP端支持openstack_user这个属性,它map的IDP的属性则是First Name。 配置Keystone作为SP1. 配置Keystone运行在Apache上如上所述,因为Federation依赖Apache的模块,所以Keystone需要以wsgi的方式运行在Apache中,具体配置这里不再描述,参考官方配置文档,一般现在OpenStack自动化工具都支持这种部署方式。 2. 安装配置Shibboleth这一步比较重要,需要安装shibboleth,不过因为官方文档上介绍的都是使用ubuntu系统,我们OpenStack使用的系统是CentOS 7,所以配置和官方文档上的步骤略有不同。 2.1 配置shibboleth的yum源 12345678910vim /etc/yum.repos.d/shibboleth.repo[shibboleth]name=Shibboleth (CentOS_7)# Please report any problems to https://issues.shibboleth.nettype=rpm-mdmirrorlist=https://shibboleth.net/cgi-bin/mirrorlist.cgi/CentOS_7gpgcheck=1gpgkey=https://download.opensuse.org/repositories/security:/shibboleth/CentOS_7/repodata/repomd.xml.keyenabled=1 2.2 安装shibboleth 1yum install shibboleth 安装完shibboleth,mod_shib模块就自动加载好了,可以通过如下命令查看: 12[root@keystone ~]# httpd -M | grep shib mod_shib (shared) 2.3 编辑shibboleth2.xml文件 /etc/shibboleth/shibboleth2.xml 是shibboleth的主要配置文件,最简单的配置内容如下: 1234567891011121314151617181920212223242526272829303132333435363738394041424344<SPConfig xmlns="urn:mace:shibboleth:2.0:native:sp:config" xmlns:conf="urn:mace:shibboleth:2.0:native:sp:config" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" clockSkew="180"> <ApplicationDefaults entityID="https://sp.ustack.com:15000/Shibboleth.sso" REMOTE_USER="eppn persistent-id targeted-id"> <Sessions lifetime="28800" timeout="3600" relayState="ss:mem" checkAddress="false" handlerSSL="false" cookieProps="http"> <SSO entityID="https://accounts.google.com/o/saml2?idpid=C03vf5z0r"> SAML2 </SSO> <Logout>SAML2 Local</Logout> <Handler type="MetadataGenerator" Location="/Metadata" signing="false"/> <Handler type="Status" Location="/Status" acl="127.0.0.1 ::1"/> <Handler type="Session" Location="/Session" showAttributeValues="true"/> <Handler type="DiscoveryFeed" Location="/DiscoFeed"/> </Sessions> <Errors supportContact="root@localhost" helpLocation="/about.html" styleSheet="/shibboleth-sp/main.css"/> <MetadataProvider type="XML" file="GoogleIDPMetadata-hackerain.github.io.xml"/> <AttributeExtractor type="XML" validate="true" reloadChanges="false" path="attribute-map.xml"/> <AttributeResolver type="Query" subjectMatch="true"/> <AttributeFilter type="XML" validate="true" path="attribute-policy.xml"/> <CredentialResolver type="File" key="sp-key.pem" certificate="sp-cert.pem"/> </ApplicationDefaults> <SecurityPolicyProvider type="XML" validate="true" path="security-policy.xml"/> <ProtocolProvider type="XML" validate="true" reloadChanges="false" path="protocols.xml"/></SPConfig> 其中有几个信息是比较重要的,在前面的原理部分也进行过讲解: ApplicationDefaults中的 entityID 属性即为SP端的Entity ID,需要和IDP里面配置的SP Entity ID保持一致。 Session表示Shibboleth对一个Session的处理逻辑,都在这个里面定义 SSO,是处理SSO的核心逻辑,它里面定义了当有请求过来时,由谁如何进行处理的逻辑,实际上,就是之前讲到的ACS,Assertion Consumer Service,其实SSO是一个简写,它等于如下的配置: 1234567<SessionInitiator type="SAML2" attr1="xry" attr2="abc"/><md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="/SAML2/POST" /><md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST-SimpleSign" Location="/SAML2/POST-SimpleSign" /><md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="/SAML2/Artifact" /><md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:PAOS" Location="/SAML2/ECP" /><md:ArtifactResolutionService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="/Artifact/SOAP" /> 即SSO中定义了多个ACS,每种ACS都支持一种特定的协议来接受请求,来处理SSO的逻辑,比如本例中使用的就是 HTTP POST协议,在Google IDP中,配置的也是这个ACS的地址,也即Location属性所指定的内容。 SSO不仅支持SAML2,还支持SAML1,一般都会写到SSO的值里面,此外entityID需要填写上IDP的Entity ID,即当有某个IDP的请求过来时,由哪个SSO进行处理,Shibboleth也是支持多个IDP的。 MetadataProvider,指定了IDP的metadata信息,可以直接指定一个本地文件,也可以是网络文件,本例中直接指定的是本地文件,即在前面从Google下载下来的GoogleIDPMetadata-xxx.xxx.xml,内容如下: 123456789101112131415<?xml version="1.0" encoding="UTF-8" standalone="no"?> <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://accounts.google.com/o/saml2?idpid=C03vf5z0r" validUntil="2024-05-11T17:22:42.000Z"> <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"> <md:KeyDescriptor use="signing"> <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#"> <ds:X509Data> <ds:X509Certificate>MIIDdDCCAlygAwIBAgIGAWqyN85bMA0GCSqGSIb3DQEBCwUAMHsxFDASBgNVBAoTC0dvb2dsZSBJ</ds:X509Certificate> </ds:X509Data> </ds:KeyInfo> </md:KeyDescriptor> <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat> <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://accounts.google.com/o/saml2/idp?idpid=C03vf5z0r"/> <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://accounts.google.com/o/saml2/idp?idpid=C03vf5z0r"/> </md:IDPSSODescriptor> </md:EntityDescriptor> 可见,这里面包含了IDP的信息,有IDP的Entity ID,X509 Key,以及最重要的信息 SingleSignOnService,即IDP提供的SSO信息,当SP端重定向到IDP去进行登录验证的时候,就是重定向到这个属性所指定的地址,Google这里其实提供了两种方式,一种是重定向,一种是发送POST请求,都可以达到目的。 AttributeExtractor,它指定了一个配置文件attribute-map.xml,这里面定义了SP端支持的所有属性名,在前面设置IDP的时候,需要创建属性Mapping,配置SP的属性的时候,其依据就是来自于这个attribute-map.xml,所以,为了演示方便,我们添加几个自定义的属性到attribute-map.xml中,见2.4小节。 其他一些属性暂时不用设置,不过他们各自有各自的用途,更多内容参考Shibboleth的配置文档。 2.4 配置attribute-map.xml 打开 /etc/shibboleth/attribute-map.xml,在文件末尾添加以下内容: 12345<Attribute name="openstack_user" id="openstack_user"/><Attribute name="openstack_roles" id="openstack_roles"/><Attribute name="openstack_project" id="openstack_project"/><Attribute name="openstack_user_domain" id="openstack_user_domain"/><Attribute name="openstack_project_domain" id="openstack_project_domain"/> 其意义见上面2.3小节的AttributeExtractor的解释。 2.5 启动shibboleth 1$ systemctl start shibd 3. 配置Keystone3.1 配置wsgi-keystone.conf 1234567891011121314151617181920212223242526<VirtualHost *:5000> ServerName "https://sp.ustack.com" ...</VirtualHost><Location ~ "/v3/auth/OS-FEDERATION/websso/saml2"> AuthType shibboleth Require valid-user ShibRequestSetting requireSession 1 ShibRequireSession On ShibExportAssertion Off LogLevel debug</Location><LocationMatch /v3/auth/OS-FEDERATION/identity_providers/.*?/protocols/saml2/websso> AuthType shibboleth Require valid-user ShibRequestSetting requireSession 1 ShibRequireSession On ShibExportAssertion Off LogLevel debug</LocationMatch><Location /Shibboleth.sso> SetHandler shib</Location> 三个Location可以写在VirtualHost里面,也可以写在外面,写在外面就是全局生效,否则只在VirtualHost中生效。第一个Location是对 /v3/auth/OS-FEDERATION/websso/saml2,这个路径进行保护,详见《基本原理-第5小节》所讲述内容,拦截该请求,交由Shibboleth进行处理;第二个Location则是在有多个IDP时,会对该路径进行拦截,其他跟第一个Location一样;第三个Location,则是对请求 /Shibboleth.sso 路径的请求,全部由Shibboleth进行处理,是shibboleth的接口入口处。 至于VirtualHost中要配置 ServerName "https://sp.ustack.com" 是因为本例中的https是配置在haproxy上的,即反向代理上,后端真正的服务并没有配置https,因此在Shibboleth中构建AuthnRequest时(详细参见《基本原理-第2小节》),在拼接ACS URL时,为了让其能够拼接成https的格式,需要在VirtualHost中配置上 ServerName "https://sp.ustack.com",若在IDP里填写的ACS URL 和 AuthnRequest中生成的ACS URL 不匹配的话,会报类似的错误: 122019-05-26 21:09:18 ERROR OpenSAML.MessageDecoder.SAML2POST [2] [default]: POST targeted at (https://sp.ustack.com:15000/Shibboleth.sso/SAML2/POST), but delivered to (http://sp.ustack.com:15000/Shibboleth.sso/SAML2/POST)2019-05-26 21:09:18 WARN Shibboleth.SSO.SAML2 [2] [default]: error processing incoming assertion: SAML message delivered with POST to incorrect server URL. 3.2 配置keystone.conf 12345678[auth]methods = password,token,saml2,external #添加进saml2的认证方式[federation]trusted_dashboard = https://sp.ustack.com:19999/auth/websso/[saml2]remote_id_attribute = Shib-Identity-Provider trusted_dashboard 这个配置项的作用是在《基本原理-第5小节》获取到unscoped token时会用到,只有当origin参数所指定的host在trusted_dashboard所指定的列表中时,keystone才会为其创建token,并且重定向回该host中,否则会认为是不可信的地址,从而拒绝该请求。 remote_id_attribute参数的作用,也请参看《基本原理-第5小节》。 4. 配置HorizonHorizon的配置文件在:/etc/openstack-dashboard/local_settings,进行如下配置: 123456789101112131415SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')OPENSTACK_KEYSTONE_URL = "https://sp.ustack.com:15000/v3"WEBSSO_ENABLED = TrueWEBSSO_INITIAL_CHOICE = "saml2"WEBSSO_CHOICES = ( ("credentials", _("Keystone Credentials")), ("saml2", _("Security Assertion Markup Language")),)#WEBSSO_IDP_MAPPING = {# "saml2": ("google-idp", "saml2"),#} SECURE_PROXY_SSL_HEADER 这个参数的配置需要注意,因为horizon也是运行在反向代理后面的,本身horizon是感知不到https配置的,但是因为生成跳转用的origin参数时,需要让其是https的,所以需要配置这个参数,让django去获取url时,能够获取成https。 WEBSSO_IDP_MAPPING的作用则是在有多个IDP时使用的,如果使用该参数的话,horizon第一跳的地址就不是 /v3/auth/OS-FEDERATION/websso/saml2了,而是 /v3/auth/OS-FEDERATION/identity_providers/{idp_id}/protocol/{protocol_id}/websso 即多了idp_id这个参数,跟3.1小节的LocationMatch匹配。 5. 在Keystone中创建Federation对象这里Federation对象指的是IDP,Protocol,Mapping,Project,Group等对象,在整个SSO中都会用到。 5.1 创建Identity对象 12345$ openstack domain create federated_domain$ openstack project create federated_project --domain federated_domain$ openstack group create federated_users$ openstack role add --group federated_users --domain federated_domain Member$ openstack role add --group federated_users --project federated_project Member 该例子中创建了domain, project, group,并且为该group在domain和project分别赋予了Member权限的role,这样只要加到这个group的user,就可以在这个domain和project中有Member权限了。 5.2 创建Federation对象 1234567891011121314151617181920212223242526272829$ openstack identity provider create --remote-id https://accounts.google.com/o/saml2?idpid=C03vf5z0r google-idp$ cat > rules.json <<EOF[ { "local": [ { "user": { "name": "{0}" }, "group": { "domain": { "name": "federated_domain" }, "name": "federated_users" } } ], "remote": [ { "type": "openstack_user" } ] }]EOF$ openstack mapping create --rules rules.json google-idp-mapping$ openstack federation protocol create saml2 --mapping google-idp-mapping --identity-provider google-idp 以上创建了idp, mapping, protocol,protocol起到将mapping规则和idp连接起来的作用,即针对某种协议的Federation认证请求,某个IDP中的Remote用户应该如何映射成本地的Local用户。其中最关键还是mapping规则,本例中是将remote中的openstack_user映射成federated_domain domain中的federated_users group中的用户,用户名就是openstack_user属性对应的值,如果想要在映射的同时,为每一个用户自动创建一个project,并赋予相应的Role,那就需要用到local中的project规则了。 6. 配置过程完毕,检查效果 在登录页面选择SAML登录方式: 跳转到Google的登录认证页面: 输入用户名和密码后,经过若干次跳转,登录到Horizon界面中,并且用户名是在G-Suite中创建的用户: 总结本文档重点介绍了Keystone作为SP跟Google通过SAML协议进行对接的原理和方法,对整个流程进行了下梳理,并且对本次测试用到的配置项结合原理进行了解释,以期弄清楚这些配置项的含义,由于SSO的过程较复杂,因此解释内容较多,但是限于篇幅,并没有对一些点再进行深入,比如Shibboleth的各种配置,以及Keystone中Mapping的各种规则,这些内容以及变化较多,若要深入使用,还需要进一步研究。 通过本次测试,发现Keystone作为SP,进行Federation认证,已经比较成熟了,基本上该有的功能都有,不是一个鸡肋的功能,但是如果Keystone作为IDP的话,其实还没有实现像Google SAML App这样完备的功能,至少没有提供Web端的登录认证界面,以及对SAML请求的响应和处理,这些都是作为IDP缺少的,因此,在目前的版本中,如果想要实现IDP功能的话,还需要基于Keystone做开发,可以将Keystone作为一个IDP的后端,毕竟它有用户管理以及相应的IDP API,IDP的前端则独立开发,提供Web界面,以及对SAML请求的处理和响应。","link":"/2019/05/16/openstack/keystoneto-google-saml2.html"},{"title":"Kubernetes APIServer Storage 框架解析","text":"Kubernetes使用etcd作为后端存储,etcd是一个分布式的,高可靠性的键值存储系统,跟传统的平台系统不同,Kubernetes把所有的数据都存储到了kv数据库中,而没有像OpenStack一样使用像MySQL这种关系型数据库,做这种选型的原因,我想可能一方面是由于Kubernetes中存储的数据,关系性不是很强,更多的是类似配置管理这类数据,一方面是由于etcd的特性,像效率比较高的gRPC接口、支持事务以及Kubernetes严重依赖的Watch机制等,能够通过单一数据库就满足它的需求,不用再引入其他组件实现类似功能,简化了架构的复杂性。 本篇文章主要来介绍下Kubernetes APIServer是如何跟存储打交道的,不涉及存储底层的细节,只到存储接口层,即主要介绍Kubernetes的存储框架是怎么样的,如何做的抽象,它里面的资源是如何存到etcd里面去的。在介绍具体的流程机制之前,我们先来介绍下Kubernetes里面几个相关的抽象,从顶层看下是如何做的设计。 顶层抽象资源、类别以及对象在API中抽象出来资源(Resource)、类别(Kind)以及对象(Object)这几个概念,其相关的结构如下: 12345678910111213141516171819202122232425262728293031type GroupVersionResource struct { Group string Version string Resource string}type GroupVersionKind struct { Group string Version string Kind string}type GroupResource struct { Group string Resource string}type GroupKind struct { Group string Kind string}type Object interface { GetObjectKind() schema.ObjectKind DeepCopyObject() Object}type ObjectKind interface { SetGroupVersionKind(kind GroupVersionKind) GroupVersionKind() GroupVersionKind} 我们知道Kubernetes中的API对象都是带版本以及分组的,比如/apis/networking.k8s.io/v1beta1/ingresses,/apis是前缀,networking.k8s.io就是组(Group),v1beta1就是版本(Version),ingresses就是上面提到的资源(Resource)或者类别(Kind),至于Object则是对API对象的抽象接口,具体的API对象则都实现了这些接口,在golang里,实现了这些接口的结构体,都可以用这个type xxx interface来统一表示,类似于父类的概念,所以Object可以代表所有实现了它的接口的对象,通常作为方法的参数或者返回值。可见,这三个概念其实都代表的是同一个意思,都是对像pod, service, ingress等这些API对象的抽象,但是表现形式不同,用途也不一样……:-(所以之前说的Kubernetes代码复杂就复杂在这些地方,抽象的云里雾里的:-) 这些结构体和接口定义在apimachinery这个库中,这个库可以说是Kubernetes中最高层的抽象,除了上面说的Resource, Kind, Object,还有各种类型定义、序列化、类型转换之类的抽象,都是会被其他的库引用到的一些结构体或者方法。 etcd存储接口所谓etcd存储接口是对etcd数据库的增删查改的抽象,其定义在apiserver的apiserver/pkg/storage/interfaces.go文件中: 1234567891011121314type Interface interface { Versioner() Versioner Create(ctx context.Context, key string, obj, out runtime.Object, ttl uint64) error Delete(ctx context.Context, key string, out runtime.Object, preconditions *Preconditions, validateDeletion ValidateObjectFunc) error Watch(ctx context.Context, key string, resourceVersion string, p SelectionPredicate) (watch.Interface, error) WatchList(ctx context.Context, key string, resourceVersion string, p SelectionPredicate) (watch.Interface, error) Get(ctx context.Context, key string, resourceVersion string, objPtr runtime.Object, ignoreNotFound bool) error GetToList(ctx context.Context, key string, resourceVersion string, p SelectionPredicate, listObj runtime.Object) error List(ctx context.Context, key string, resourceVersion string, p SelectionPredicate, listObj runtime.Object) error GuaranteedUpdate( ctx context.Context, key string, ptrToType runtime.Object, ignoreNotFound bool, precondtions *Preconditions, tryUpdate UpdateFunc, suggestion ...runtime.Object) error Count(key string) (int64, error)} 可以看到,跟我们传统的数据库应用不同的地方在于,它的接口比较少,只有这么几个,比如Create()方法,一般我们写应用程序,要存一个东西,都会有类似CreateXXX()这样的方法,比如CreatePerson(person Person),就是保存一个Person对象到数据库中,然后会有一堆这样的CreateXXX()方法来对不同的对象进行存储,但是这里Create()方法定义的,则是一个高度抽象的方法,obj是要存进去的对象,即上面说到的Object,至于这个对象具体是什么,其实是看方法调用者构造了一个什么对象传给它,key则是其键值,实际上etcd3 store在实现这些接口时,会将obj进行编码,即序列化,然后再存到数据库中,这种方法大大减少了数据库层的代码量,也充分利用了kv数据库的特性。 此外,apiserver 这个库是将构建APIServer的一些通用代码抽出来,独立构成了一个库,以便代码复用,可以给第三方应用构建扩展使用,Kubernetes APIServer的实现大量依赖了该库。 REST存储接口Kubernetes API是RESTful API,它的每一种REST资源,比如pod, service, ingress,在APIServer中,都有一个Store与之对应,通过实现统一的接口,来实现对REST资源的增删改查等操作,这些统一的接口定义在 apiserver/pkg/registry/rest/rest.go 文件中,列举几个: 123456789101112131415161718192021222324252627282930313233343536// Storage is a generic interface for RESTful storage services.// Resources which are exported to the RESTful API of apiserver need to implement this interface. It is expected// that objects may implement any of the below interfaces.type Storage interface { // New returns an empty object that can be used with Create and Update after request data has been put into it. // This object must be a pointer type for use with Codec.DecodeInto([]byte, runtime.Object) New() runtime.Object // Destroy cleans up its resources on shutdown. // Destroy has to be implemented in thread-safe way and be prepared // for being called more than once. Destroy()}// Getter is an object that can retrieve a named RESTful resource.type Getter interface { Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error)}// Creater is an object that can create an instance of a RESTful object.type Creater interface { New() runtime.Object Create(ctx context.Context, obj runtime.Object, createValidation ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error)}// GracefulDeleter knows how to pass deletion options to allow delayed deletion of a// RESTful object.type GracefulDeleter interface { Delete(ctx context.Context, name string, deleteValidation ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error)}// Watcher should be implemented by all Storage objects that// want to offer the ability to watch for changes through the watch api.type Watcher interface { Watch(ctx context.Context, options *metainternalversion.ListOptions) (watch.Interface, error)} 即针对某一个API对象的REST操作,会由对应的Store中的方法进行处理,这个Store又引用了实现了etcd存储接口的Store,从而可以对数据库进行操作。 底层实现上一小节,主要介绍了两类存储接口,一类是针对etcd的存储接口,一类是针对REST的存储接口,下面我们来分别说一下实现这两类接口的方法和结构体。 etcd存储接口实现针对etcd存储接口的实现,在apiserver/pkg/storage/etcd3/store.go 这个文件中,最主要的结构体为: 12345678910111213type store struct { client *clientv3.Client // getOpts contains additional options that should be passed // to all Get() calls. getOps []clientv3.OpOption codec runtime.Codec versioner storage.Versioner transformer value.Transformer pathPrefix string watcher *watcher pagingEnabled bool leaseManager *leaseManager} 可见该结构体最重要的属性为client,即直接调用到了etcd的client库,通过该client可以对etcd进行操作,在该结构体上,还实现了apiserver/pkg/storage/interfaces.go中定义的接口,比如: 123456789101112131415161718192021222324func (s *store) Get(ctx context.Context, key string, resourceVersion string, out runtime.Object, ignoreNotFound bool){ getResp, err := s.client.KV.Get(ctx, key, s.getOps...) kv := getResp.Kvs[0] data, _, err := s.transformer.TransformFromStorage(kv.Value, authenticatedDataString(key)) return decode(s.codec, s.versioner, data, out, kv.ModRevision)}func (s *store) Create(ctx context.Context, key string, obj, out runtime.Object, ttl uint64){ data, err := runtime.Encode(s.codec, obj) newData, err := s.transformer.TransformToStorage(data, authenticatedDataString(key)) txnResp, err := s.client.KV.Txn(ctx).If( notFound(key), ).Then( clientv3.OpPut(key, string(newData), opts...), ).Commit() if out != nil { putResp := txnResp.Responses[0].GetResponsePut() return decode(s.codec, s.versioner, data, out, putResp.Header.Revision) }} 以上代码为简化代码,忽略了一些不重要的逻辑,且只列出GET和Create两个方法,其他未列出。可以看到GET方法,通过一个string类型的key,从etcd中取出了对应的value,然后通过decode进行解码,将数据解码到out这个Object中,然后将其返回。而CREATE方法,则反过来,先将obj进行编码,然后通过etcd client将数据通过事务的方式保存到etcd中。这个编码解码的过程,就是常说的序列化的过程。 REST存储接口实现REST存储接口的实现在 apiserver/pkg/registry/generic/retistry/store.go 这个文件中,定义了如下的结构体: 123456789101112type Store struct { NewFunc func() runtime.Object NewListFunc func() runtime.Object KeyFunc func(ctx context.Context, name string) (string, error) ObjectNameFunc func(obj runtime.Object) (string, error) ...... Storage DryRunnableStorage // etcd3.store, implement the storage.Interface DestroyFunc func() StorageVersioner runtime.GroupVersioner} 该结构体实现了上面小节中REST存储定义的各种接口: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647func (e *Store) New() runtime.Object { return e.NewFunc()}func (e *Store) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { obj := e.NewFunc() key, err := e.KeyFunc(ctx, name) if err != nil { return nil, err } if err := e.Storage.Get(ctx, key, options.ResourceVersion, obj, false); err != nil { return nil, storeerr.InterpretGetError(err, e.qualifiedResourceFromContext(ctx), name) } if e.Decorator != nil { if err := e.Decorator(obj); err != nil { return nil, err } } return obj, nil}func (e *Store) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { if err := rest.BeforeCreate(e.CreateStrategy, ctx, obj); err != nil { return nil, err } if createValidation != nil { if err := createValidation(ctx, obj.DeepCopyObject()); err != nil { return nil, err } } name, err := e.ObjectNameFunc(obj) key, err := e.KeyFunc(ctx, name) qualifiedResource := e.qualifiedResourceFromContext(ctx) ttl, err := e.calculateTTL(obj, 0, false) out := e.NewFunc() if err := e.Storage.Create(ctx, key, obj, out, ttl, dryrun.IsDryRun(options.DryRun)); err != nil { ...... } ...... return out, nil} 在Store结构体中,有一个非常重要的成员 Storage DryRunnableStorage,它即是对etcd store的引用,其定义如下: 12345678type DryRunnableStorage struct { Storage storage.Interface Codec runtime.Codec}func (s *DryRunnableStorage) Get(ctx context.Context, key string, resourceVersion string, objPtr runtime.Object, ignoreNotFound bool) error { return s.Storage.Get(ctx, key, resourceVersion, objPtr, ignoreNotFound)} 它的成员Storage storage.Interface即是上面小节中提到的etcd存储接口,之所以中间又套了一层DryRunnableStorage主要是为了编写单元测试方便,针对真实写数据库的操作,可以让它DryRun,而不实际写数据库。 至此,我们知道Kubernetes中定义了两种Store,分别是针对REST资源的Store,以及针对etcd数据库的Store,包括他们各自实现的接口方法,后文我们将他们称为REST store以及etcd store,这两个store之间是引用的关系是REST store引用了etcd store。上层实例化一个REST store,则会同时实例化一个etcd store,用于对数据库的增删查改操作,即上面代码中的e.Storage.Create(),e.Storage.Get()等,即是调用etcd store去读写数据库。 上层应用这里所说的上层应用,指的是Kubernetes中是如何使用上面说到的REST store和etcd store这两个Store的。 etcd存储本小节主要是介绍下如何构建出etcd store实体的,首先来看下最上面的代码逻辑: 12345678910111213141516# cmd/kube-apiserver/app/server.gobuildGenericConfig() { genericConfig = genenricapiserver.NewConfig() // 下面三行代码,构造了一个StorageFactoryConfig,给它的各个属性赋值 storageFactoryConfig := kubeapiserver.NewStorageFactoryConfig() storageFactoryConfig.APIResourceConfig = genericConfig.MergedResourceConfig completedStorageFactoryConfig, err := storageFactoryConfig.Complete(s.Etcd) // 从StorageFactoryConfig New了一个DefaultStorageFactory storageFactory, lastErr = completedStorageFactoryConfig.New() // 使用storageFactory构造了一个StorageFactoryRestOptionsFactory,赋值给genericConfig的RESTOptionsGetter属性 s.Etcd.ApplyWithStorageFactoryTo(storageFactory, genericConfig)} buildGenericConfig()就是在Kubernetes APIServer 机制概述中介绍过的CreateServerChain阶段,构建的配置项,将和APIServer相关的很多通用的配置项都集合在这个里面,这里我们只关注和存储相关的配置项,即上面列出的那几行代码,其实这几行代码,体现了两个在Kubernetes中非常常见的设计模式,一个是Config->Complete->New模式,一个是Factory工厂模式。 所谓Config->Complete->New模式,即首先构建一个Config,即配置项,然后通过Complete()方法进一步补充完善该Config,然后从该Config创建出真正的实体,创建该实体相关的信息,都在该Config中。在上面的例子中,StorageFactoryConfig,就是Config,然后通过Complete()方法,从s.Etcd中再进一步获取相关信息,补充完善该Config,得到completedStorageFactoryConfig,然后再调用New()方法,就可以得到真正的实体,这里就是storageFactory。 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778# kubernetes/cmd/kube-apiserver/app/options/options.gos.Etcd = genericoptions.NewEtcdOptions(storagebackend.NewDefaultConfig(kubeoptions.DefaultEtcdPathPrefix, nil))# k8s.io/apiserver/pkg/server/options/etcd.gotype EtcdOptions struct { StorageConfig storagebackend.Config // 在newEtcdOptions时就赋值进去的 EncryptionProviderConfigFilepath string EtcdServersOverrides []string DefaultStorageMediaType string DeleteCollectionWorkers int EnableGarbageCollection bool EnableWatchCache bool DefaultWatchCacheSize int WatchCacheSizes []string}# kubernetes/pkg/kubeapiserver/default_storage_factory_builder.go// NewStorageFactoryConfig returns a new StorageFactoryConfig set up with necessary resource overrides.func NewStorageFactoryConfig() *StorageFactoryConfig { resources := []schema.GroupVersionResource{ batch.Resource("cronjobs").WithVersion("v1beta1"), networking.Resource("ingresses").WithVersion("v1beta1"), networking.Resource("ingressclasses").WithVersion("v1beta1"), apisstorage.Resource("csidrivers").WithVersion("v1beta1"), } return &StorageFactoryConfig{ Serializer: legacyscheme.Codecs, DefaultResourceEncoding: serverstorage.NewDefaultResourceEncodingConfig(legacyscheme.Scheme), ResourceEncodingOverrides: resources, }}// StorageFactoryConfig is a configuration for creating storage factory.type StorageFactoryConfig struct { StorageConfig storagebackend.Config APIResourceConfig *serverstorage.ResourceConfig DefaultResourceEncoding *serverstorage.DefaultResourceEncodingConfig DefaultStorageMediaType string Serializer runtime.StorageSerializer ResourceEncodingOverrides []schema.GroupVersionResource EtcdServersOverrides []string EncryptionProviderConfigFilepath string}// Complete completes the StorageFactoryConfig with provided etcdOptions returning completedStorageFactoryConfig.func (c *StorageFactoryConfig) Complete(etcdOptions *serveroptions.EtcdOptions) (*completedStorageFactoryConfig, error) { c.StorageConfig = etcdOptions.StorageConfig // 从etcdOptions获取初始的StorageConfig c.DefaultStorageMediaType = etcdOptions.DefaultStorageMediaType c.EtcdServersOverrides = etcdOptions.EtcdServersOverrides c.EncryptionProviderConfigFilepath = etcdOptions.EncryptionProviderConfigFilepath return &completedStorageFactoryConfig{c}, nil}type completedStorageFactoryConfig struct { *StorageFactoryConfig}// New returns a new storage factory created from the completed storage factory configuration.func (c *completedStorageFactoryConfig) New() (*serverstorage.DefaultStorageFactory, error) { resourceEncodingConfig := resourceconfig.MergeResourceEncodingConfigs(c.DefaultResourceEncoding, c.ResourceEncodingOverrides) storageFactory := serverstorage.NewDefaultStorageFactory( c.StorageConfig, c.DefaultStorageMediaType, c.Serializer, resourceEncodingConfig, c.APIResourceConfig, SpecialDefaultResourcePrefixes) ...... return storageFactory, nil} 通过上面的方式,创建出来DefaultStorageFactory实例,然后就到了上面说到的Factory工厂模式,顾名思义,就是从工厂中生产出类似的实体,而它生产的实体,就是针对某个资源resource的存储配置StorageConfig: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071# k8s.io/apiserver/pkg/server/storage/storage_factory.gotype StorageFactory interface { NewConfig(groupResource schema.GroupResource) (*storagebackend.Config, error) ResourcePrefix(groupResource schema.GroupResource) string Backends() []Backend}## DefaultStorageFactory实现了StorageFactory Interfacetype DefaultStorageFactory struct { StorageConfig storagebackend.Config Overrides map[schema.GroupResource]groupResourceOverrides DefaultResourcePrefixes map[schema.GroupResource]string DefaultMediaType string DefaultSerializer runtime.StorageSerializer ResourceEncodingConfig ResourceEncodingConfig APIResourceConfigSource APIResourceConfigSource newStorageCodecFn func(opts StorageCodecConfig) (codec runtime.Codec, encodeVersioner runtime.GroupVersioner, err error)}func NewDefaultStorageFactory( config storagebackend.Config, defaultMediaType string, defaultSerializer runtime.StorageSerializer, resourceEncodingConfig ResourceEncodingConfig, resourceConfig APIResourceConfigSource, specialDefaultResourcePrefixes map[schema.GroupResource]string,) *DefaultStorageFactory { config.Paging = utilfeature.DefaultFeatureGate.Enabled(features.APIListChunking) if len(defaultMediaType) == 0 { defaultMediaType = runtime.ContentTypeJSON } return &DefaultStorageFactory{ StorageConfig: config, Overrides: map[schema.GroupResource]groupResourceOverrides{}, DefaultMediaType: defaultMediaType, DefaultSerializer: defaultSerializer, ResourceEncodingConfig: resourceEncodingConfig, APIResourceConfigSource: resourceConfig, DefaultResourcePrefixes: specialDefaultResourcePrefixes, newStorageCodecFn: NewStorageCodec, }}func (s *DefaultStorageFactory) NewConfig(groupResource schema.GroupResource) (*storagebackend.Config, error) { chosenStorageResource := s.getStorageGroupResource(groupResource) // operate on copy storageConfig := s.StorageConfig codecConfig := StorageCodecConfig{ StorageMediaType: s.DefaultMediaType, StorageSerializer: s.DefaultSerializer, } if override, ok := s.Overrides[getAllResourcesAlias(chosenStorageResource)]; ok { override.Apply(&storageConfig, &codecConfig) } if override, ok := s.Overrides[chosenStorageResource]; ok { override.Apply(&storageConfig, &codecConfig) } codecConfig.StorageVersion, err = s.ResourceEncodingConfig.StorageEncodingFor(chosenStorageResource) codecConfig.MemoryVersion, err = s.ResourceEncodingConfig.InMemoryEncodingFor(groupResource) codecConfig.Config = storageConfig storageConfig.Codec, storageConfig.EncodeVersioner, err = s.newStorageCodecFn(codecConfig) return &storageConfig, nil} 上面的NewConfig()是为某个resource创建出对应的存储配置,即StorageConfig,该配置中包含了etcd的连接、序列化等信息,即每种资源resource都有自己的一套存储配置,例如可以通过--etcd-servers-overrides配置项,来给某个单独的resource指定不同的后端存储,这种机制跟常规的应用很不一样,一般的应用访问数据库,都会有一个集中的数据库配置,所有的资源都是使用的一套配置,但是这里却细化到了按照资源种类去配置存储,这虽然看起来很灵活强大,但是不免有过度设计的嫌疑,谁会去把同一个集群里的资源分开存放到不同的数据库呢?或者有什么资源是需要有特殊的编解码方式的?至少我没有见过这种使用场景,Anyway,稍微吐槽下,还是转入正题吧。StorageConfig 即 storagebackend.Config,其初始值是来自于 EtcdOptions,然后传给 StorageFactoryConfig,StorageFactoryConfig 再传给 StorageFactory,StorageFactory在给某个resource创建存储配置时,把StorageFactory中的StorageConfig复制了一份,再给它赋值了其他一些属性,比如负责编解码的Codec等,构成了针对某种资源特定的配置,StorageConfig对应的结构体定义如下: 123456789101112# k8s.io/apiserver/pkg/storage/storagebackend/config.gotype Config struct { Type string Prefix string Transport TransportConfig Paging bool Codec runtime.Codec EncodeVersioner runtime.GroupVersioner CompactionInterval time.Duration CountMetricPollPeriod time.Duration} 最终,s.Etcd.ApplyWithStorageFactoryTo()则是将上面构建出来的storageFactory构建出另外一个结构体StorageFactoryRestOptionsFactory,然后将其赋值给genericConfig的RESTOptionsGetter属性: 123456789101112131415161718192021# apiserver/pkg/server/options/etcd.go(s *EtcdOptions)ApplyWithStorageFactoryTo(factory serverstorage.StorageFactory, c *server.Config){ c.RESTOptionsGetter = &StorageFactoryRestOptionsFactory{Options: *s, StorageFactory: factory}}type StorageFactoryRestOptionsFactory struct { Options EtcdOptions StorageFactory serverstorage.StorageFactory}(f *StorageFactoryRestOptionsFactory) GetRESTOptions(resource schema.GroupResource) (generic.RESTOptions, error){ // 拿到该resource对应的storageconfig,每个resource可以有不同的storage配置,主要设置上storageConfig的codec和encodeversioner storageConfig, err := f.StorageFactory.NewConfig(resource) ret := generic.RESTOptions{ StorageConfig: storageConfig, Decorator: generic.UndecoratedStorage, // decorator->factory->store 最终拿到了Storage.Interface ResourcePrefix: f.StorageFactory.ResourcePrefix(resource), } return ret} StorageFactoryRestOptionsFactory实现了一个非常重要的方法:GetRESTOptions(),在这个里面,首先通过上面介绍到的StorageFactory的NewConfig()方法来创建出针对某一种resource的存储配置项,然后构建了一个RESTOptions的结构体,里面包含了另外一个重要的成员:Decorator,其定义如下: 1234567891011121314151617# apiserver/pkg/registry/generic/storage_decorator.gofunc UndecoratedStorage( config *storagebackend.Config, resourcePrefix string, keyFunc func(obj runtime.Object) (string, error), newFunc func() runtime.Object, newListFunc func() runtime.Object, getAttrsFunc storage.AttrFunc, trigger storage.IndexerFuncs, indexers *cache.Indexers) (storage.Interface, factory.DestroyFunc, error) { return NewRawStorage(config)}func NewRawStorage(config *storagebackend.Config) (storage.Interface, factory.DestroyFunc, error) { return factory.Create(*config)} 注意,UndecoratedStorage是一个方法,所以 Decorator也是一个方法,该方法的作用就是要使用前面创建出来的StorageConfig来创建一个etcd store出来,可以看到这里传了很多参数进去,但是实际上只用了 config 这一个参数,其他参数可能以后会用到,在 NewRawStorage() 方法中又用到工厂模式,使用StorageConfig中的信息,来创建最终的Store,其流程如下: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364# apiserver/pkg/storage/storagebackend/factory/factory.gofunc Create(c storagebackend.Config) (storage.Interface, DestroyFunc, error) { switch c.Type { case storagebackend.StorageTypeUnset, storagebackend.StorageTypeETCD3: return newETCD3Storage(c) default: return nil, nil, fmt.Errorf("unknown storage type: %s", c.Type) }}# apiserver/pkg/storage/storagebackend/factory/etcd3.gofunc newETCD3Storage(c storagebackend.Config) (storage.Interface, DestroyFunc, error) { client, err := newETCD3Client(c.Transport) return etcd3.New(client, c.Codec, c.Prefix, transformer, c.Paging), destroyFunc, nil}func newETCD3Client(c storagebackend.TransportConfig) (*clientv3.Client, error) { cfg := clientv3.Config{ DialTimeout: dialTimeout, DialKeepAliveTime: keepaliveTime, DialKeepAliveTimeout: keepaliveTimeout, DialOptions: dialOptions, Endpoints: c.ServerList, TLS: tlsConfig, } return clientv3.New(cfg)}# apiserver/pkg/storage/etcd3/store.go// Implement storage.Interface in apiserver/pkg/storage/interfaces.gotype store struct { client *clientv3.Client getOps []clientv3.OpOption codec runtime.Codec versioner storage.Versioner transformer value.Transformer pathPrefix string watcher *watcher pagingEnabled bool leaseManager *leaseManager}func New(c *clientv3.Client, codec runtime.Codec, prefix string, transformer value.Transformer, pagingEnabled bool) storage.Interface { return newStore(c, pagingEnabled, codec, prefix, transformer)}func newStore(c *clientv3.Client, pagingEnabled bool, codec runtime.Codec, prefix string, transformer value.Transformer) *store { versioner := APIObjectVersioner{} result := &store{ client: c, codec: codec, versioner: versioner, transformer: transformer, pagingEnabled: pagingEnabled, pathPrefix: path.Join("/", prefix), watcher: newWatcher(c, codec, versioner, transformer), leaseManager: newDefaultLeaseManager(c), } return result} 经过了山路十八弯,终于看到创建出来了etcd store,即上面newETCD3Storage()方法内的逻辑,先创建了一个etcd client,然后将该client传给newStore(),构建出etcd store结构体,可以通过client跟etcd打交道,同时序列化等信息存在codec等变量里。 综上,可以看到,虽然实现非常复杂,但是使用起来还是很简单的,因为实际上构建出来的StorageFactory是放到了GenericConfig中的RESTOptionsGetter,而GenericConfig是在CreateServerChain阶段就提前构建好的,因此,只需要向下面这样使用,就可以得到一个etcd store: 12345opts := genericConfig.RESTOptionsGetter.GetRESTOptions(resource)store := opts.Decorator( opts.StorageConfig, ...) REST存储在APIServer中,每一个API对象,都有一个REST Store与之对应,Kubernetes内置的API对象的REST store的相关逻辑,都位于kubernetes/pkg/registry/目录下,我们以pod为例,来说明下REST Store是如何构建出来的: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152# pkg/registry/core/pod/storage/storage.goimport ( genericregistry "k8s.io/apiserver/pkg/registry/generic/registry")type REST struct { *genericregistry.Store proxyTransport http.RoundTripper}type BindingREST struct { store *genericregistry.Store}type StatusREST struct { store *genericregistry.Store}func NewStorage(optsGetter generic.RESTOptionsGetter, k client.ConnectionInfoGetter...) { store := &genericregistry.Store{ NewFunc: func() runtime.Object { return &api.Pod{} }, NewListFunc: func() runtime.Object { return &api.PodList{} }, ...... } options := &generic.StoreOptions{ RESTOptions: optsGetter } ...... store.CompleteWithOptions(options) ...... statusStore := *store statusStore.UpdateStrategy = registrypod.StatusStrategy statusStore.ResetFieldsStrategy = registrypod.StatusStrategy bindingREST := &BindingREST{store: store} ...... return PodStorage{ Pod: &REST{store, proxyTransport}, Binding: &BindingREST{store: store}, ...... Status: &StatusREST{store: &statusStore}, ...... }} 上面的 genericregistry.Store 就是 REST存储接口实现 介绍到的 REST store,一个Pod有很多种 REST store,比如上例中的 REST, BindingREST, StatusREST都是,这些类型都继承自 genericregistry.Store,注意该方法接受了一个参数optsGetter generic.RESTOptionsGetter,这个就是在上面etcd 存储应用中,介绍到的存储到genericConfig的RESTOptionsGetter,我们来看看这里是怎么使用这个genericConfig.RESTOptionsGetter的: 12345678910111213141516171819202122232425262728293031// 每一种resource都是由这个Store组成的,它实现了rest的各种接口,而它里面又包含了一个Storage属性,是对etcd的一个封装,实现了在数据库层面的各种增删查改的接口,即实现了storage.Interface// Implement rest Interfaces in apiserver/pkg/registry/rest/rest.go// like Getter, Lister, Creater, Updater, Patcher, Watcher etc.type Store struct { NewFunc func() runtime.Object NewListFunc func() runtime.Object KeyFunc func(ctx context.Context, name string) (string, error) ObjectNameFunc func(obj runtime.Object) (string, error) ...... Storage DryRunnableStorage // etcd3.store, implement the storage.Interface DestroyFunc func() StorageVersioner runtime.GroupVersioner}(e *Store) CompleteWithOptions(options *generic.StoreOptions){ opts, err := options.RESTOptions.GetRESTOptions(e.DefaultQualifiedResource) e.Storage.Codec = opts.StorageConfig.Codec e.Storage.Storage, e.DestroyFunc, err = opts.Decorator( opts.StorageConfig, prefix, keyFunc, e.NewFunc, e.NewListFunc, attrFunc, options.TriggerFunc, options.Indexers, ) e.StorageVersioner = opts.StorageConfig.EncodeVersioner} 即在store.CompleteWithOptions(options)方法中,调用了GetRESTOptions()方法获取到存储配置信息,然后再调用Decorator()方法创建出etcd store实体,存储到DryRunnableStorage中。 总结本文主要梳理了API对象存储相关的两个层面的存储接口以及其实现和应用,即REST store和etcd store,在脑海中建立起来的整体画像应该是,每一个API对象,都有对应的REST store和etcd store,这两者之间是引用的关系,REST store引用etcd store来操作数据库etcd,REST store是面向RESTful api侧, etcd store则面向数据库侧。","link":"/2020/09/19/kubernetes/kube-apiserver-storage-overview.html"},{"title":"Kubernetes controller-runtime 介绍","text":"我们在做CRD开发时,除了要写CRD定义之外,最重要的是实现CRD对应的Controller,这样CRD才能真正有用,而不论什么CRD,它的Controller的逻辑框架是大致一样的,主要就是监听CRD资源的变化事件,然后触发Reconcile逻辑去执行对应的动作,确保实际状态跟CRD的定义状态保持一致,此外,还有一些其他通用功能,比如监控、选主、Wehbook等,这种开发范式现在称之为Operator,在万物皆可CRD的云原生时代,这种通用需求早已被剥离出来,成为单独的第三方库,即controller-runtime,而operator-sdk也封装的是 controller-runtime,方便进行operator的开发,可见其重要性,本篇文章就对 controller-runtime 的概念、原理以及核心逻辑进行下介绍。 我们就以 kubebuilder 上的这个controller-runtime的架构图来说吧: Manager顾名思义,Manager就是起到一个管理的作用,是一个集大成者,它管理的范围包括:高可用(HA,即主备模式的leader election)、监控、用来做Admission的Webhook、针对API资源(i.e. CRD)的Controller、以及用来跟Kubernetes集群交互的Client和Cache,还有服务的启停等等,我们来看看它的定义: 1234567891011121314// pkg/manager/internal.gotype controllerManager struct { sync.Mutex started bool stopProcedureEngaged *int64 errChan chan error runnables *runnables // cluster holds a variety of methods to interact with a cluster. Required. cluster cluster.Cluster ......} Manager属性有很多,这里我们重点关注两个属性:runnables 和 cluster. runnablesManager将所有需要长期运行的任务抽象出来一个叫做 Runnable 的概念,下面为它的接口定义: 12345678// pkg/manager/manager.gotype Runnable interface { // Start starts running the component. The component will stop running // when the context is closed. Start blocks until the context is closed or // an error occurs. Start(context.Context) error} 很简单,即实现了Start()方法的结构体即是一个Runnable,而且这个方法是需要阻塞住的,直到服务停止,即Runnable是一些需要长期运行的任务,比如Webhook Server, Controller等都实现了Start()方法,通过这层抽象,方便Manager对这些任务进行统一管理。而Manager中的属性runnables是分了好几个类,并且通过组的形式对这些Runnable进行管理: 123456789101112// pkg/manager/runnable_group.gotype runnables struct { Webhooks *runnableGroup Caches *runnableGroup LeaderElection *runnableGroup Others *runnableGroup}type runnableGroup struct {}func (r *runnableGroup) Add(rn Runnable, ready runnableCheck) error {}func (r *runnableGroup) Start(ctx context.Context) error {} 可以看到分了 Webhooks, Caches, LeaderElection, Others 4个组,runnableGroup提供了Add()方法向组中添加Runnable,提供了Start()方法去启动组内所有Runnable,而Manager也提供了Add()方法,向runnables中添加runnable: 12345678910111213141516// pkg/manager/internal.go// Add sets dependencies on i, and adds it to the list of Runnables to start.func (cm *controllerManager) Add(r Runnable) error { cm.Lock() defer cm.Unlock() return cm.add(r)}func (cm *controllerManager) add(r Runnable) error { // Set dependencies on the object if err := cm.SetFields(r); err != nil { return err } return cm.runnables.Add(r)} 最后在Manager启动的时候,会分别启动runnables中的runnable组: 123456789101112131415161718192021222324252627// pkg/manager/internal.gofunc (cm *controllerManager) Start(ctx context.Context) (err error) { ...... if err := cm.runnables.Webhooks.Start(cm.internalCtx); err != nil { if !errors.Is(err, wait.ErrWaitTimeout) { return err } } // Start and wait for caches. if err := cm.runnables.Caches.Start(cm.internalCtx); err != nil { if !errors.Is(err, wait.ErrWaitTimeout) { return err } } // Start the non-leaderelection Runnables after the cache has synced. if err := cm.runnables.Others.Start(cm.internalCtx); err != nil { if !errors.Is(err, wait.ErrWaitTimeout) { return err } } ......} clusterManager又抽象出来一个概念,叫做cluster,它封装了跟一个Kubernetes集群进行交互的逻辑,我们来看下它的定义: 12345678910111213141516171819// pkg/cluster/internal.gotype cluster struct { // config is the rest.config used to talk to the apiserver. Required. config *rest.Config // scheme is the scheme injected into Controllers, EventHandlers, Sources and Predicates. Defaults // to scheme.scheme. scheme *runtime.Scheme cache cache.Cache // client is the client injected into Controllers (and EventHandlers, Sources and Predicates). client client.Client // apiReader is the reader that will make requests to the api server and not the cache. apiReader client.Reader ......} cluster本身没做什么事情,它主要依赖两个属性:client和cache,cluster只是对他们的简单封装。 cachecache是依赖于Informer机制为API资源构建的缓存机制,即通过List&Watch机制,将所关心的API资源缓存到本地,这样在Controller中去List或者Get某个资源对象时,可以直接从缓存拿数据,提高性能。下面来看下这个cache的定义: 12345678910111213141516171819202122232425262728293031// pkg/cache/informer_cache.gotype informerCache struct { *internal.InformersMap}// pkg/cache/internal/deleg_map.gotype InformersMap struct { structured *specificInformersMap unstructured *specificInformersMap metadata *specificInformersMap Scheme *runtime.Scheme}// pkg/cache/internal/informers_map.gotype specificInformersMap struct { ...... // informersByGVK is the cache of informers keyed by groupVersionKind informersByGVK map[schema.GroupVersionKind]*MapEntry ......}type MapEntry struct { // Informer is the cached informer Informer cache.SharedIndexInformer // CacheReader wraps Informer and implements the CacheReader interface for a single type Reader CacheReader} 可以看到这个cache本质上就是几个Informer的Map,分成了structured, unstructured, metadata这三类,而map的value,即是client-go中的 SharedIndexInformer,这里稍微解释下structured和unstructured,它们其实还有另外两个名字,叫typed和untyped,typed是在scheme中注册过的已知的资源类型,比如Pod这种,而untyped/unstructured则表示未知的类型,或者叫做通用的类型,可以用它来表示任何类型,apimachinery中有专门的类型 Unstructured 来表示它,并且提供了丰富的方法对其进行操作。 关于Informer,推荐:Kubernetes Informer机制解析 关于unstructured/untyped,推荐:K8S中为什么需要Unstructured对象。 关于structured/typed,推荐:Kubernetes API Scheme 解析 informerCache提供了Get和List方法用来从缓存中获取对象,实际上是调用的对应的Informer对象的Indexer接口从Informer的缓存中拿的数据: 1234// pkg/cache/informer_cache.gofunc (ip *informerCache) Get(ctx context.Context, key client.ObjectKey, out client.Object, opts ...client.GetOption) error {}func (ip *informerCache) List(ctx context.Context, out client.ObjectList, opts ...client.ListOption) error {} 需要注意的是,缓存的构建,即针对某类资源(GVK)的Informer,是以lazy的方式构建的,即在GET时,如果map中没有对应的Informer才去创建,这样避免去创建一些无用的informer,浪费资源。 client这里的client是通过cluster的options.NewClient()创建出来的,即用什么方法创建client是可以配置的,默认为下面的方法: 1234567891011121314151617181920212223242526// pkg/cluster/cluster.go// DefaultNewClient creates the default caching client, that will never cache Unstructured.func DefaultNewClient(cache cache.Cache, config *rest.Config, options client.Options, uncachedObjects ...client.Object) (client.Client, error) { return ClientBuilderWithOptions(ClientOptions{})(cache, config, options, uncachedObjects...)}// ClientBuilderWithOptions returns a Client constructor that will build a client// honoring the options argumentfunc ClientBuilderWithOptions(options ClientOptions) NewClientFunc { return func(cache cache.Cache, config *rest.Config, clientOpts client.Options, uncachedObjects ...client.Object) (client.Client, error) { options.UncachedObjects = append(options.UncachedObjects, uncachedObjects...) c, err := client.New(config, clientOpts) if err != nil { return nil, err } return client.NewDelegatingClient(client.NewDelegatingClientInput{ CacheReader: cache, Client: c, UncachedObjects: options.UncachedObjects, CacheUnstructured: options.CacheUnstructured, }) }} 这里先通过 client.New() 创建出来一个client,这个client是直接跟apiserver交互的client,然后再结合上一小节中的cache,分别赋值给CacheReader和Client属性,最终创建出来一个 delegatingClient,它就是一个具备缓存能力的client了,如果是需要直接跟apiserver交互的话,不走缓存的话,就调用Client属性,如果是需要从缓存中读取数据的话,就调用CacheReader属性,并且还可以选择配置哪些对象不缓存,以及是否缓存Unstructured的对象。 cluster也实现了Runnable接口的Start()方法,所以它也是一个runnable,在Manager启动时,会将cluster也添加到它的runnables里面去,cluster的Start()做的事情就是去启动它里面cache中的各个informer,如下: 12345678910111213141516171819// pkg/manager/internal.gofunc (cm *controllerManager) Start(ctx context.Context) (err error) { ...... // Add the cluster runnable. if err := cm.add(cm.cluster); err != nil { return fmt.Errorf("failed to add cluster to runnables: %w", err) } ......}// pkg/cluster/internal.gofunc (c *cluster) Start(ctx context.Context) error { defer c.recorderProvider.Stop(ctx) return c.cache.Start(ctx)} 以上就是Manager中的核心概念了,抽象出来runnable, cluster, client, cache等概念,其中最重要的是依赖informer机制构造的cache,以及依赖cache构造的client。 Manager构造的cache, client等,是要被runnable使用的,所以在向Manager添加runnable时,这些属性会被注入到runnable中,Manager提供了SetFields()方法来做这件事: 12345678910111213141516171819// pkg/manager/internal.gofunc (cm *controllerManager) add(r Runnable) error { // Set dependencies on the object if err := cm.SetFields(r); err != nil { return err } return cm.runnables.Add(r)}// Deprecated: use the equivalent Options field to set a field. This method will be removed in v0.10.func (cm *controllerManager) SetFields(i interface{}) error { if err := cm.cluster.SetFields(i); err != nil { return err } ...... return nil} 1234567891011121314151617181920212223// pkg/cluster/internal.gofunc (c *cluster) SetFields(i interface{}) error { if _, err := inject.ConfigInto(c.config, i); err != nil { return err } if _, err := inject.ClientInto(c.client, i); err != nil { return err } if _, err := inject.APIReaderInto(c.apiReader, i); err != nil { return err } if _, err := inject.SchemeInto(c.scheme, i); err != nil { return err } if _, err := inject.CacheInto(c.cache, i); err != nil { return err } if _, err := inject.MapperInto(c.mapper, i); err != nil { return err } return nil} ControllerController就是我们常说的控制器了,本质上它还是依赖于Informer机制的List&Watch功能,能够及时获知到api对象的变化事件,然后触发注册到informer中的事件处理回调函数,去做对应的动作,只不过这里的Controller又抽象出来三个概念,即上图中的event, predicate, reconciler,我们来看下它的定义: 12345678910111213141516171819202122232425262728293031323334353637383940// pkg/internal/controller/controller.gotype Controller struct { // Name is used to uniquely identify a Controller in tracing, logging and monitoring. Name is required. Name string // MaxConcurrentReconciles is the maximum number of concurrent Reconciles which can be run. Defaults to 1. MaxConcurrentReconciles int // Reconciler is a function that can be called at any time with the Name / Namespace of an object and // ensures that the state of the system matches the state specified in the object. // Defaults to the DefaultReconcileFunc. Do reconcile.Reconciler // MakeQueue constructs the queue for this controller once the controller is ready to start. // This exists because the standard Kubernetes workqueues start themselves immediately, which // leads to goroutine leaks if something calls controller.New repeatedly. MakeQueue func() workqueue.RateLimitingInterface // Queue is an listeningQueue that listens for events from Informers and adds object keys to // the Queue for processing Queue workqueue.RateLimitingInterface // SetFields is used to inject dependencies into other objects such as Sources, EventHandlers and Predicates // Deprecated: the caller should handle injected fields itself. SetFields func(i interface{}) error ...... // startWatches maintains a list of sources, handlers, and predicates to start when the controller is started. startWatches []watchDescription ......}// watchDescription contains all the information necessary to start a watch.type watchDescription struct { src source.Source handler handler.EventHandler predicates []predicate.Predicate} 先来看下watchDescription这个结构体,它里面包含了Source,EventHandler以及Predicates列表,source表示事件的来源,可以是集群内事件,即API对象的增删改事件,也可以是集群外事件,比如Github Webhook回调,EventHandler是事件回调函数,当有事件发生时,被触发执行的函数,Predicate是事件过滤器,用于过滤出有用的事件。 然后是属性Do,就是reconciler,即实际的动作执行者,属性Queue是一个限速队列,用来存放从Informer中接收到的事件,startWatches是一个watchDescription列表,说明一个controller可以同时对多个source进行处理,只是reconciler只能是一个统一的处理方法。 它们之间的关系是:首先将事件回调handler注册到对应的informer中,然后通过list&watch机制收到关于某一类资源的增删改事件,当有事件发生时,对应的handler方法会被触发执行,这些事件有些可能没有用或者controller不关心的,可以通过定义predicates来进行过滤,过滤完的事件,会放到队列Queue中,这个队列是带限速功能的,controller会有worker线程,消费这个队列,从队列中拿到事件,交给reconciler来处理,即去执行最终的动作。 Source我们来看下这个source,它其实是一个接口,定义如下: 12345type Source interface { // Start is internal and should be called only by the Controller to register an EventHandler with the Informer // to enqueue reconcile.Requests. Start(context.Context, handler.EventHandler, workqueue.RateLimitingInterface, ...predicate.Predicate) error} 只有一个Start()方法,如注释所说,它做的事情就是注册EventHandler到Informer中,即向Informer中注册事件回调函数,可以看到这个方法除了事件回调函数之外,还有workqueue以及predicate两个参数,这个workqueue就是上面提到的带限速功能的Queue。 实现了该接口的有两个结构体:Kind和Channel,Kind是用来处理集群内的事件,比如Pod, Deployment的增删改,而Channel则是用来处理集群外的事件,比如Github的Webhook回调,我们主要来看下Kind的定义以及它的Start()方法: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748// pkg/source/source.gotype Kind struct { // Type is the type of object to watch. e.g. &v1.Pod{} Type client.Object // cache used to watch APIs cache cache.Cache // started may contain an error if one was encountered during startup. If its closed and does not // contain an error, startup and syncing finished. started chan error startCancel func()}func (ks *Kind) Start(ctx context.Context, handler handler.EventHandler, queue workqueue.RateLimitingInterface, prct ...predicate.Predicate) error { ...... // cache.GetInformer will block until its context is cancelled if the cache was already started and it can not // sync that informer (most commonly due to RBAC issues). ctx, ks.startCancel = context.WithCancel(ctx) ks.started = make(chan error) go func() { var ( i cache.Informer lastErr error ) // Tries to get an informer until it returns true, // an error or the specified context is cancelled or expired. if err := wait.PollImmediateUntilWithContext(ctx, 10*time.Second, func(ctx context.Context) (bool, error) { // Lookup the Informer from the Cache and add an EventHandler which populates the Queue i, lastErr = ks.cache.GetInformer(ctx, ks.Type) ...... return true, nil }); ...... _, err := i.AddEventHandler(internal.EventHandler{Queue: queue, EventHandler: handler, Predicates: prct}) ...... if !ks.cache.WaitForCacheSync(ctx) { // Would be great to return something more informative here ks.started <- errors.New("cache did not sync") } close(ks.started) }() return nil} Kind中有Type和cache两个属性,Type就是我们要监听的某个资源类型,cache就是上文中提到的cache,里面是API资源的Informer Map,看它的Start()逻辑,跟我们所说的一样,就是从cache中拿到对应类型的Informer,然后向其中注册EventHandler。 EventHandler再来看下这个EventHandler: 12345678910111213141516171819202122232425262728293031// pkg/source/internal/eventsource.gotype EventHandler struct { EventHandler handler.EventHandler Queue workqueue.RateLimitingInterface Predicates []predicate.Predicate}func (e EventHandler) OnAdd(obj interface{}) { c := event.CreateEvent{} // Pull Object out of the object if o, ok := obj.(client.Object); ok { c.Object = o } else { log.Error(nil, "OnAdd missing Object", "object", obj, "type", fmt.Sprintf("%T", obj)) return } for _, p := range e.Predicates { if !p.Create(c) { return } } // Invoke create handler e.EventHandler.Create(c, e.Queue)}func (e EventHandler) OnUpdate(oldObj, newObj interface{}) {}func (e EventHandler) OnDelete(obj interface{}) {} 可以看到这个EventHandler有三个方法,分别对应增删改的三种事件,比如监听到该对象的新增事件时,OnAdd()函数就会被触发,将对应对象obj作为参数传进来,然后会经过predicates的过滤,最终调用Kind.Start()传进来的EventHandler的Create()方法,去进一步处理,这里EventHandler中的EventHandler它的定义如下: 12345678910111213141516// pkg/handler/eventhandler.gotype EventHandler interface { // Create is called in response to an create event - e.g. Pod Creation. Create(event.CreateEvent, workqueue.RateLimitingInterface) // Update is called in response to an update event - e.g. Pod Updated. Update(event.UpdateEvent, workqueue.RateLimitingInterface) // Delete is called in response to a delete event - e.g. Pod Deleted. Delete(event.DeleteEvent, workqueue.RateLimitingInterface) // Generic is called in response to an event of an unknown type or a synthetic event triggered as a cron or // external trigger request - e.g. reconcile Autoscaling, or a Webhook. Generic(event.GenericEvent, workqueue.RateLimitingInterface)} 是对不同事件的对应处理方法,这个事件回调函数,就需要用户自己定义了,controller-runtime也内置了一个Handler,用来将事件转换成Request对象,然后添加到队列中: 123456789101112131415// pkg/handler/enqueue.gotype EnqueueRequestForObject struct{}func (e *EnqueueRequestForObject) Create(evt event.CreateEvent, q workqueue.RateLimitingInterface) { if evt.Object == nil { enqueueLog.Error(nil, "CreateEvent received with no metadata", "event", evt) return } q.Add(reconcile.Request{NamespacedName: types.NamespacedName{ Name: evt.Object.GetName(), Namespace: evt.Object.GetNamespace(), }})}...... 你也可以定义自己的EventHandler,或者封装EnqueueRequestForObject去扩展自己的EventHandler,比如除了入队列之外,还添加上日志等等。 Predicate再来看下 Predicate,其定义如下: 123456789101112131415// pkg/predicate/predicate.gotype Predicate interface { // Create returns true if the Create event should be processed Create(event.CreateEvent) bool // Delete returns true if the Delete event should be processed Delete(event.DeleteEvent) bool // Update returns true if the Update event should be processed Update(event.UpdateEvent) bool // Generic returns true if the Generic event should be processed Generic(event.GenericEvent) bool} 定义了针对增删改事件的过滤方法,需要知道的是我们上面传的predicates列表中的predicate之间的关系是可以有or, and, not等逻辑关系的,比如下例: 123predicates := []ctrlpredicate.Predicate{ ctrlpredicate.Or(ctrlpredicate.GenerationChangedPredicate{}, libpredicate.NoGenerationPredicate{}), } 两个predicates之间是“或”的关系。 然后,Controller对外提供了Watch()方法,用来向Controller中注册Souce, EventHandler, Predicates: 123456789101112131415161718192021222324252627282930// pkg/internal/controller/controller.gofunc (c *Controller) Watch(src source.Source, evthdler handler.EventHandler, prct ...predicate.Predicate) error { c.mu.Lock() defer c.mu.Unlock() // Inject Cache into arguments if err := c.SetFields(src); err != nil { return err } if err := c.SetFields(evthdler); err != nil { return err } for _, pr := range prct { if err := c.SetFields(pr); err != nil { return err } } // Controller hasn't started yet, store the watches locally and return. // // These watches are going to be held on the controller struct until the manager or user calls Start(...). if !c.Started { c.startWatches = append(c.startWatches, watchDescription{src: src, handler: evthdler, predicates: prct}) return nil } c.LogConstructor(nil).Info("Starting EventSource", "source", src) return src.Start(c.ctx, evthdler, c.Queue, prct...)} 可以看到如果该Controller还没有启动,则将source, eventhandler, predicates组成watchDescription对象,然后添加到startWatches列表中,随后在Controller启动时,会遍历startWatches中的source进行启动,如果Controller已经启动,则会直接启动source,而所谓启动source,即上面source小节说的,向Informer中注册EventHandler。 最后来看下Controller中的启动逻辑: 12345678910111213// pkg/internal/controller/controller.gofunc (c *Controller) Start(ctx context.Context) error { ...... for _, watch := range c.startWatches { c.LogConstructor(nil).Info("Starting EventSource", "source", fmt.Sprintf("%s", watch.src)) if err := watch.src.Start(ctx, watch.handler, c.Queue, watch.predicates...); err != nil { return err } } ......} ReconcilerController在启动时,会起若干Reconcile的线程用来监听消息队列,然后触发Reconcile逻辑: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556// pkg/internal/controller/controller.gofunc (c *Controller) Start(ctx context.Context) error { ...... c.LogConstructor(nil).Info("Starting workers", "worker count", c.MaxConcurrentReconciles) wg.Add(c.MaxConcurrentReconciles) for i := 0; i < c.MaxConcurrentReconciles; i++ { go func() { defer wg.Done() // Run a worker thread that just dequeues items, processes them, and marks them done. // It enforces that the reconcileHandler is never invoked concurrently with the same object. for c.processNextWorkItem(ctx) { } }() } c.Started = true ......}func (c *Controller) processNextWorkItem(ctx context.Context) bool { obj, shutdown := c.Queue.Get() if shutdown { // Stop working return false } // We call Done here so the workqueue knows we have finished // processing this item. We also must remember to call Forget if we // do not want this work item being re-queued. For example, we do // not call Forget if a transient error occurs, instead the item is // put back on the workqueue and attempted again after a back-off // period. defer c.Queue.Done(obj) ctrlmetrics.ActiveWorkers.WithLabelValues(c.Name).Add(1) defer ctrlmetrics.ActiveWorkers.WithLabelValues(c.Name).Add(-1) c.reconcileHandler(ctx, obj) return true}func (c *Controller) reconcileHandler(ctx context.Context, obj interface{}) { // Make sure that the object is a valid request. req, ok := obj.(reconcile.Request) ...... // RunInformersAndControllers the syncHandler, passing it the Namespace/Name string of the // resource to be synced. result, err := c.Reconcile(ctx, req) ......}func (c *Controller) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) { ...... return c.Do.Reconcile(ctx, req)} Reconciler的接口定义如下: 12345678// pkg/reconcile/reconcile.gotype Reconciler interface { // Reconcile performs a full reconciliation for the object referred to by the Request. // The Controller will requeue the Request to be processed again if an error is non-nil or // Result.Requeue is true, otherwise upon completion it will remove the work from the queue. Reconcile(context.Context, Request) (Result, error)} CRD的开发者,最主要的任务就是去开发这个Reconcile()逻辑了,定义好Reconciler之后,会在创建Controller时,传递进来,最终保存到Controller.Do变量中。 Builder为了方便用户创建Controller,controller-runtime还提供了一个Builder结构体以及相关方法用来方便的构建Controller以及向其中注册监听对象(Source)和事件回调函数(EventHandler),其定义如下: 123456789101112// pkg/builder/controller.gotype Builder struct { forInput ForInput ownsInput []OwnsInput watchesInput []WatchesInput mgr manager.Manager globalPredicates []predicate.Predicate ctrl controller.Controller ctrlOptions controller.Options name string} forInput记录要监听哪个CRD,ownsInput记录这个CRD拥有哪些对象,比如deployment, service等,是否要监听该CRD直接拥有的对象的相关事件,watchesInput表示是否要直接监听某些source和eventhandler,是一个low-level的注册事件的方法,一般优先用前两个,Builder对应的提供了For(), Owns(), Watches()方法,用来进行设置这三个属性,例如For(): 1234567891011// pkg/builder/controller.gofunc (blder *Builder) For(object client.Object, opts ...ForOption) *Builder { input := ForInput{object: object} for _, opt := range opts { opt.ApplyToFor(&input) } blder.forInput = input return blder} 接下来就是创建Controller以及对上面的事件源调用Watch了: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152// pkg/builder/controller.gofunc (blder *Builder) Complete(r reconcile.Reconciler) error { _, err := blder.Build(r) return err}func (blder *Builder) Build(r reconcile.Reconciler) (controller.Controller, error) { ...... // Set the ControllerManagedBy if err := blder.doController(r); err != nil { return nil, err } // Set the Watch if err := blder.doWatch(); err != nil { return nil, err } return blder.ctrl, nil}func (blder *Builder) doController(r reconcile.Reconciler) error { globalOpts := blder.mgr.GetControllerOptions() ctrlOptions := blder.ctrlOptions if ctrlOptions.Reconciler == nil { ctrlOptions.Reconciler = r } ...... blder.ctrl, err = newController(controllerName, blder.mgr, ctrlOptions)}func (blder *Builder) doWatch() error { // Reconcile type if blder.forInput.object != nil { typeForSrc, err := blder.project(blder.forInput.object, blder.forInput.objectProjection) if err != nil { return err } src := &source.Kind{Type: typeForSrc} hdler := &handler.EnqueueRequestForObject{} allPredicates := append(blder.globalPredicates, blder.forInput.predicates...) if err := blder.ctrl.Watch(src, hdler, allPredicates...); err != nil { return err } } ......} 这样,开发者只需要创建一个Builder,然后指定要监听的对象,Builder就会自动去创建Controller, Source, EventHandler, Predicate等,然后对这些对象进行Watch,开发者只需要专注于编写Reconcile逻辑就可以了。 总结以上大致过了下Controller相关的概念和逻辑,我们来总结下,Manager起管理的作用,并且为运行在其中的runnable提供跟Kubernetes集群交互的Client和Cache,Controller则依赖Informer机制对事件源进行监听,注册事件处理回调函数,触发Reconcile逻辑进行业务处理,Controller对外暴露了Watch()接口用来向Controller中注册事件回调,开发者需要开发的主要就是Reconcile逻辑,其他基本上都由controller-runtime封装好了,我们来简单举个例子,看下controller-runtime典型的应用场景: 首先,要创建manager: 1mgr, err := manager.New(cfg, options) 其次,定义Reconciler: 12345678910111213141516171819202122232425262728// AnsibleOperatorReconciler - object to reconcile runner requeststype AnsibleOperatorReconciler struct { GVK schema.GroupVersionKind Runner runner.Runner Client client.Client APIReader client.Reader EventHandlers []events.EventHandler ReconcilePeriod time.Duration ManageStatus bool AnsibleDebugLogs bool WatchAnnotationsChanges bool}// Reconcile - handle the event.func (r *AnsibleOperatorReconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { }//nolint:gocycloaor := &AnsibleOperatorReconciler{ Client: mgr.GetClient(), GVK: options.GVK, Runner: options.Runner, EventHandlers: eventHandlers, ReconcilePeriod: options.ReconcilePeriod, ManageStatus: options.ManageStatus, AnsibleDebugLogs: options.AnsibleDebugLogs, APIReader: mgr.GetAPIReader(), WatchAnnotationsChanges: options.WatchAnnotationsChanges,} 然后,创建Controller: 12345c, err := controller.New(fmt.Sprintf("%v-controller", strings.ToLower(options.GVK.Kind)), mgr, controller.Options{ Reconciler: aor, MaxConcurrentReconciles: options.MaxConcurrentReconciles, }) 再定义predicates等,调用Watch()注册事件回调函数: 12345predicates := []ctrlpredicate.Predicate{ ctrlpredicate.Or(ctrlpredicate.GenerationChangedPredicate{}, libpredicate.NoGenerationPredicate{}),}err = c.Watch(&source.Kind{Type: u}, &handler.LoggingEnqueueRequestForObject{}, predicates...) 最后,启动Manager: 1mgr.Start(signals.SetupSignalHandler())","link":"/2023/11/18/kubernetes/kube-controller-runtime.html"},{"title":"Kubernetes Kubelet 机制概述","text":"距离上一篇文章,已经过去了将近9个月的时间,2021年第一篇文章,竟然是到8月份了,真没想到这个kubelet竟然拖了我这么长时间。研究api以及scheduler的日夜还历历在目,不知不觉就过了这么长时间,现在突然写起来,恍如隔世的感觉,这一方面说明kubelet相比其他组件确实要更复杂一些,另一方面说明最近这一段时间我有些懈怠了,感觉有50%的时间在忙其他事情,25%的时间在研究kubelet,然后25%的时间在懈怠。不过还好,经过这么长时间断断续续的研究,记了很多笔记,梳理清楚了其大致脉络,对kubelet有了一个比较全面的认知,尤其是跟框架有关的,比如CRI,CNI,CSI等各种Plugin机制,知道了这些框架的原理,不论是做插件开发还是运维,都能够按图索骥,快速找到问题所在,然后再深入到具体的细节中。 其实Kubernetes跟OpenStack在资源管理这个层面上非常类似,都需要涉及到最基础的计算、网络、存储以及各种外设这些资源的管理,在计算上,OpenStack是各种虚拟机,而Kubernetes是各种容器,而这两种计算形态的不同,从本质上决定了OpenStack和Kubernetes的不同,由于容器的易封装、轻量级的特点,逐渐演化出了云原生、微服务等新形式的业务形态,而虚拟机主要还是面向传统的业务形态,OpenStack中的Nova项目通过插件机制可以支持各种虚拟化方案,比如Qemu/KVM, Xen, HyperV, 甚至还有VMWare,当然最常用的还是KVM虚拟化方案,而Kubernetes则通过CRI协议对接各种容器方案,比如最常用的docker, cri-o,还有rkt, kata container等等;至于网络,Kubernetes本身并没有实现什么具体的网络方案,而是仅仅要求Pod之间网络是可以连通的,因此Kubernetes就依赖于第三方提供的网络方案,而第三方的网络方案通过CNI协议跟Container Runtime进行交互,这其实跟OpenStack也很类似,OpenStack的Neutron项目就抽象了二层三层的网络概念供虚拟机使用,而具体的实现则依赖于底层的SDN方案,通常一个成熟的SDN方案,既有面向IaaS的,也有面向PaaS的,他们都有对应的协议标准,所以可以在某一个网络方案上同时对这两者提供服务;至于存储,更是如此,一般存储都以三种形式体现:块存储,文件存储,以及对象存储,每一种形式的存储都有很多协议去实现,比如块存储就有FC、ISCSI、RBD等协议,文件存储有NFS、CephFS等,对象存储则主要是是S3或者是Swift,有的存储系统会同时提供这三种形式的存储,有的则专门只提供一种存储,OpenStack通过Cinder, Manila等项目对接多种存储后端提供不同的存储类型,而Kubernetes则依赖于CSI协议跟第三方存储进行交互。来看看Kubernetes通过各种接口协议跟外部资源整合的图: 所以从资源管理的角度来说,Kubernetes和OpenStack是存在某些功能上的重叠的,存在一定的竞争关系,Kubernetes完全可以在没有IaaS的环境下使用,直接部署在物理机上,但是两者的定位不同,Kubernetes更偏向于应用侧,侧重于怎么使用资源,而OpenStack等IaaS平台则更侧重于对底层各种硬件资源的统一管理,这在资源隔离上差别很明显,Kubernetes在网络和计算隔离上明显不如OpenStack等IaaS平台彻底,所以更通常的做法是将Kubernetes部署在IaaS平台上,甚至是跨多个云平台部署,充分利用IaaS平台的隔离性和弹性,这样Kubernetes作为IaaS平台的资源消费者而存在,不用去管底层硬件的复杂性和多样性,并且将IaaS平台的使用者由多变的人切换到固定的程序,这对IaaS平台来说会更具确定性和稳定性,所以这两者应该是合作共生的关系,而不是取代的关系,各自在各自的领域里做自己擅长的事情。 Kubernetes对各种资源的使用,则主要依赖于抽象出来的三种接口协议,即CRI, CNI和CSI,在Kubernetes经典的Controller-Loop模型中,kubelet是最终的动作执行者,它部署在每个worker节点,负责当前节点Pod相关的资源生命周期管理,通过这三个接口协议跟远端的资源服务提供者进行交互。通过CRI,向远端的计算资源提供者(容器运行时,Container Runtime)申请对应的容器资源,但是在创建容器之前,先要准备容器所在网络环境,即SandBox,所谓SandBox,其实就是网络命名空间,比如是一个network namespace或者是一个虚拟机,以及在其中的网络设备和相关的网络信息,而这些网络信息则是容器运行时(Container Runtime)通过CNI接口向远端的网络资源提供者申请的,包括IP地址,路由以及DNS等信息,将这些信息配置到网络命名空间中,SandBox就准备好了,然后就可以在其中创建容器了,在同一个SandBox中可以创建多个容器,它们共享同一个网络命名空间,这些就组成了所谓的Pod;Kubelet再通过CSI接口,向远端的存储资源提供者申请对应的存储资源,根据存储类型,可能需要挂载或者格式化成文件系统供Pod使用;这里面有点特殊的就是CNI,kubelet没有直接通过CNI跟网络资源提供者交互,而是由Container Runtime来做这件事,kubelet只需要通过CRI向Container Runtime发送请求,即可获得相关的网络信息。他们之间的关系如下图: CRI和CSI这两者都是使用gRPC进行的远程过程调用,gRPC是一个高性能、开源、通用的RPC框架,由Google推出,基于HTTP2协议标准设计开发,默认采用Protocol Buffers数据序列化协议,支持多种开发语言,在gRPC客户端可以直接调用不同服务器上的远程程序,使用姿势看起来就像调用本地程序一样,很容易去构建分布式应用和服务。CRI和CSI都对应的提供了一些lib库,在这些库中定义好了客户端和服务端的接口,并且实现了客户端的相关代码逻辑,以及服务端的部分逻辑,作为客户端在使用CRI和CSI时,可以直接引用这些库,向对应的服务资源提供者发送rpc请求,作为服务端,可以引用这些库,更标准和快速的实现服务端的相关逻辑。至于CNI,它就不是通过gRPC的方式了,而是由很多二进制可执行文件组成的网络插件,被Container Runtime调用执行,每个网络插件对应的实现相关的网络功能,CNI也有对应的lib库,针对它的协议,封装了一些公共代码,可以用来方便构建自己的网络插件。 Kubelet实现对Pod以及各种外部资源的管理,主要依赖两个机制:一个是SyncLoop,一个是各种各样的Manager。在SyncLoop中,kubelet会从几个特定的事件来源处,获取到关于Pod的事件,比如通过informer机制从apiserver处获取到的Pod的增删改事件,这些事件触发kubelet根据Pod的期望状态对本节点的Pod做出相应操作,比如新建一个Pod,或者给Pod添加一个新的存储等等,除了apiserver的事件,还有每隔1秒获取到的定期执行sync的事件,周期性的sync事件确保Pod的实际状态跟期望状态是一致的,在Kubelet的实现中,每一个Pod都对应的建了一个worker线程,在该线程中处理对该Pod的更新操作,同一个Pod不能并发进行更新,但是不同Pod是可以并发进行操作的;而各种各样的Manager则负责各种对象以及资源的管理,它们互相配合,形成一个有机的整体,是kubelet各种功能的实现者,比如secretManager/configMapManager等,它们负责从apiserver处通过reflector机制将本节点Pod绑定的secret和configmap缓存到本地,containerManager负责管理container所需要使用到的资源,比如qos, cpu, memory, device等,statusManager负责Pod状态的持续维护,会周期性的将缓存中的pod status通过apiserver更新到数据库中,volumePluginManager负责管理内置(intree)和动态发现的(flexvolume dynamic)的存储插件,csi就是作为intree的一个plugin的形式存在的,volumeManager则是负责管理本节点上的pod/volume/node的attach和mount操作的,等等这些Manager就好比人体的各种器官一样,每个器官负责一个或多个功能,各种器官协调组成一个健康的个体。整体上看,kubelet的架构图如下: SyncLoop负责Pod的增删改等操作,通过不断轮询,维护Pod这个主体跟期望状态一致,而各种Manager其实是一个个小的Loop,实现了跟Pod相关的某方面的功能,比如维护Pod在本地的缓存,以及Pod的状态的维护,Pod使用计算资源的维护,Pod使用存储资源的维护等等,这些相互配合,共同完成了kubelet完整的功能,所以,未来可能随着需求的变化,会不断有新的Manager被引入,旧的Manager被淘汰,但是总体的架构方式应该不会发生什么太大的变化。 下面梳理了下当前master分支,也就是1.21版本,SyncLoop的大致脉络,以及kubelet中各种Manager的作用简介: SyncLoop脉络123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125NewKubeletCommand()* Run() * run() * PreInitRuntimeService() * runDockershim() * // DockerService继承自CRIService,包含RuntimeServiceServer 和 ImageServiceServer 两个Interface * ds := dockershim.NewDockerService() * // 创建了一个grpc.Server,并将DockerService注册到grpc.Server中 * dockerServer := dockerremote.NewDockerServer(remoteRuntimeEndpoint, ds) * dockerServer.Start() * // 实现了grpc.Server的客户端 * kubeDeps.RemoteRuntimeService = remote.NewRemoteRuntimeService(remoteRuntimeEndpoint, ...) * kubeDeps.RemoteImageService = remote.NewRemoteImageService(remoteImageEndpoint, ...) * RunKubelet() * k := createAndInitKubelet() * NewMainKubelet() * klet := &Kubelet{} * runtime, err := kuberuntime.NewKubeGenericRuntimeManager() * klet.containerRuntime = runtime * klet.streamingRuntime = runtime * klet.runner = runtime * startKubelet(k) * go k.Run(podCfg.Updates()) * go kl.cloudResourceSyncManager.Run(wait.NeverStop) * go kl.volumeManager.Run(kl.sourcesReady, wait.NeverStop) * go vm.volumePluginMgr.Run(stopCh) // start informer for CSIDriver * go vm.desiredStateOfWorldPopulator.Run(sourcesReady, stopCh) * go vm.reconciler.Run(stopCh) * go wait.Until(kl.syncNodeStatus, kl.nodeStatusUpdateFrequency, wait.NeverStop) * kl.registerWithAPIServer() // 向apiserver创建Node对象 * kl.updateNodeStatus() * kl.tryUpdateNodeStatus(i) * kl.setNodeStatus(node) * defaultNodeStatusFuncs() // 更新node status的各个方面 * NodeAddress() * MachineInfo() * Images() * ....... * updatedNode, _, err := nodeutil.PatchNodeStatus(kl.heartbeatClient.CoreV1(), types.NodeName(kl.nodeName), originalNode, node) * go kl.fastStatusUpdateOnce() * for{ * kl.updateRuntimeUp() * kl.syncNodeStatus() * } * go kl.nodeLeaseController.Run(wait.NeverStop) * go wait.Until(kl.updateRuntimeUp, 5*time.Second, wait.NeverStop) * 检查networkReady和runtimeReady,并且将状态设置到runtimeState中 * kl.oneTimeInitializer.Do(kl.initializeRuntimeDependentModules) * kl.cadvisor.Start() * kl.containerManager.Start(node, kl.GetActivePods, kl.sourcesReady, kl.statusManager, kl.runtimeService) * cm.cpuManager.Start() * cm.memoryManager.Start() * cm.setupNode(activePods) // container_manager_linux.go * if CgroupsPerQOS * cm.createNodeAllocatableCgroups() // node_container_manager_linux.go * cm.qosContainerManager.Start(cm.getNodeAllocatableAbsolute, activePods) // qos_container_manager_linux.go * go m.UpdateCgroups() * cm.enforceNodeAllocatableCgroups() // node_container_manager_linux.go * cm.deviceManager.Start() * kl.evictionManager.Start(kl.StatsProvider, kl.GetActivePods, kl.podResourcesAreReclaimed, evictionMonitoringPeriod) * kl.containerLogManager.Start() * kl.pluginManager.AddHandler(pluginwatcherapi.CSIPlugin, plugincache.PluginHandler(csi.PluginHandler)) * kl.pluginManager.AddHandler(pluginwatcherapi.DevicePlugin, kl.containerManager.GetPluginRegistrationHandler()) * go kl.pluginManager.Run(kl.sourcesReady, wait.NeverStop) * err = kl.shutdownManager.Start() * go wait.Until(kl.podKiller.PerformPodKillingWork, 1*time.Second, wait.NeverStop) * kl.statusManager.Start() * kl.probeManager.Start() * kl.runtimeClassManager.Start(wait.NeverStop) * kl.pleg.Start() * kl.syncLoop(updates, kl) * for{ * kl.syncLoopIteration(updates, handler, syncTicker.C, housekeepingTicker.C, plegCh) * HandlePodAdditions() * HandlePodUpdates() * HandlePodRemoves() * HandlePodSyncs() * // 1. Compute sandbox and container changes. * // 2. Kill pod sandbox if necessary. * // 3. Kill any containers that should not be running. * // 4. Create sandbox if necessary. * // 5. Create ephemeral containers. * // 6. Create init containers. * // 7. Create normal containers * syncPod(o syncPodOptions) // kubelet.go, 在podWorkers中执行这个syncPod * pcm := kl.containerManager.NewPodContainerManager() * if CgroupsPerQOS * return podContainerManagerImpl * return podContainerManagerNoop * kl.containerManager.UpdateQOSCgroups() * cm.qosContainerManager.UpdateCgroups() * pcm.EnsureExists(pod) * kl.containerRuntime.SyncPod(pod, podStatus, pullSecrets, kl.backOff) * // 1. Compute sandbox and container changes. * podContainerChanges := m.computePodActions(pod, podStatus) * // 2. Kill pod sandbox if necessary. * m.killContainersWithSyncResult(pod, runningPod, gracePeriodOverride) * m.runtimeService.StopPodSandbox(podSandbox.ID.ID) * // 3. Kill any containers that should not be running. * m.killContainer(pod,...) * // 4. Create sandbox if necessary. * // pod的网络是在建sandbox时建立的,sandbox可以理解成linux network namespace或者vm,即准备一个隔离环境 * m.createPodSandbox(pod, podContainerChanges.Attempt) * m.runtimeService.RunPodSandbox(podSandboxConfig, runtimeHandler) * // 5. Create ephemeral containers. * doBackOff() * startContainer() // kuberuntime_container.go * // * pull the image * m.imagePuller.EnsureImageExists(pod, container, pullSecrets, podSandboxConfig) * // * create the container * m.runtimeService.CreateContainer(podSandboxID, containerConfig, podSandboxConfig) * // * start the container * m.runtimeService.StartContainer(containerID) * // * run the post start lifecycle hooks (if applicable) * m.runner.Run(kubeContainerID, pod, container, container.Lifecycle.PostStart) * // 6. Create init containers. * doBackOff() * startContainer() // kuberuntime_container.go * // 7. Create normal containers. * doBackOff() * startContainer() // kuberuntime_container.go * } * go k.ListenAndServe(kubeCfg, kubeDeps.TLSOptions, kubeDeps.Auth, enableCAdvisorJSONEndpoints) * go k.ListenAndServeReadOnly(net.ParseIP(kubeCfg.Address), uint(kubeCfg.ReadOnlyPort), enableCAdvisorJSONEndpoints) * go k.ListenAndServePodResources() 各种Manager123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193* cloudResourceSyncManager * pkg/kubelet/cloudresource/cloud_request_manager.go * 从cloud provider周期性的同步instnace列表到本地* secretManager * pkg/kubelet/secret/secret_manager.go * NewWatchingSecretManager * 当一个pod在本节点注册时,会将该pod绑定的secret通过reflector机制缓存到本地* configMapManager * pkg/kubelet/configmap/configmap_manager.go * NewWatchingConfigMapManager * 当一个Pod在本节点注册时,会将该pod绑定的configmap通过reflector机制缓存到本地* livenessManager * pkg/kubelet/prober/results/results_manager.go * Manager * set/get某个container在本地缓存中的的liveness状态* startupManager * pkg/kubelet/prober/results/results_manager.go * Manager * set/get某个container在本地缓存中的startup缓存状态* podCache * pkg/kubelet/container/cache.go * cache * 各个pods的PodStatus缓存* podManager * pkg/kubelet/pod/pod_manager.go * basicManager * Kubelet relies on the pod manager as the source of truth for the desired state. * 管理pod在本地的映射,从podUID或者是podFullName到pod的映射关系 * mirrorPod是static pod在apiserver中的代表对象,static pod就是从file, http等source创建的pod,不是从apiserver中直接创建的,这种pod会对应的在apiserver中创建一个mirror pod,跟static pod对应* statusManager * pkg/kubelet/status/status_manager.go * manager * pod status状态的管理,同时维护了一份本地的缓存,并且有一个周期性的sync任务,将缓存中的pod status跟通过apiserver跟数据库中的pod status进行同步 * 实现了GetPodStatus()、SetPodStatus()、SetContainerReadiness()等方法 * 这里有一个很经典的chan的用法,异步的实现缓存和数据库的同步* resourceAnalyzer * pkg/kubelet/server/stats/resource_analyzer.go * resourceAnalyzer{fsResourceAnalyzer, SummaryProvider} * SummaryProvider提供该node节点的cpu/内存/磁盘/网络/pods等信息,而fsResourceAnalyzer,则提供每个pod的volume信息,并且通过每个pod的周期性循环任务,将该节点上所有pod的volume信息缓存到本地statCache中* dockerLegacyService * DockerService* runtimeService * remoteRuntimeService,实现了k8s.io/cri-api项目中定义的RuntimeService接口,而它又是调用了k8s.io/cri-api中定义的runtimeServiceClient,它客户端实现了grpc的调用。 * grpc.Server的客户端,用来跟remote runtime service发送请求* containerLogManager* containerManager * 路径:kubernetes/pkg/kubelet/cm/* * 该manager并不是针对container本身的管理,而更多的是管理container所需要使用的资源的管理,比如qos, cpu, memory, device等 * cgroupManager * 跟cgroup交互的manager,通过它来对cgroup进行更新,被qosContainerManager引用 * qosContainerManager * 设置/kubepods.slice/kubepods-burstable.slice 和 /kubepods.slice/kubepods-besteffort.slice级别的cgroup * besteffort级别的cpu.shares设置为固定值2,burstable级别的cpu.shares设置为该节点的所有pods的request cpu之和 * 此外还有hugepage, memory的设置,这里设置的是总的qos,并不是单个pod的 * 有一个周期性循环的任务在不断更新这两个cgroup * podContainerManager * 设置pod级别的cgroup,包括cpu, memory, hugepage, pids * container级别的cgroup规则是由cri runtime下发的,kubelet没有直接下发 * cpuManager * 可以为containder设置静态cpuset,即进行CPU绑定 * The static policy allows containers in Guaranteed pods with integer CPU requests access to exclusive CPUs on the node. * https://kubernetes.io/docs/tasks/administer-cluster/cpu-management-policies/ * memoryManager * 跟topologyManager一起配合使用 * deviceManager * topologyManager * 能够让container感知各种外设(gpu/sr-iov nic)和CPU,与NUMA node的关系,可以将设备绑定到同一个NUMA node上,以提高性能。 * https://kubernetes.io/blog/2020/04/01/kubernetes-1-18-feature-topoloy-manager-beta/* containerRuntime * kubeGenericRuntimeManager,实现了container/runtime.go中定义的Runtime接口 * Kubelet的Runtime Manager,通过runtimeService来向remote runtime进行交互,各个manager要跟runtime交互,都是通过该接口* streamingRuntime* runner* runtimeCache * runtimeCache {pods []*Pod} * pod的本地缓存,从runtime里面拿到pods列表,更新到本地缓存中* StatsProvider* pleg(Pod Lifecycle Event Generator) * GenericPLEG * https://github.com/kubernetes/community/blob/master/contributors/design-proposals/node/pod-lifecycle-event-generator.md * https://developers.redhat.com/blog/2019/11/13/pod-lifecycle-event-generator-understanding-the-pleg-is-not-healthy-issue-in-kubernetes/ * 它的作用是周期性从container runtime中获取到pods/containers列表,即relist,跟之前的状态进行比较,如果发生变化,则生成一个对应的event* runtimeState * 检查container runtime的storage/network/runtime的状态* containerGC * 清理不正常的container* containerDeletor * podContainerDeletor * 通过channel的方式异步删除container,即外部调用deleteContainersInPod()方法,将待删除的container id放入到channel中,然后调用runtime中的DeleteContainer()方法,向runtime发送删除请求* imageManager * realImageGCManager * 根据镜像占用的文件系统的百分比,删除没有用的镜像* serverCertificateManager * 动态rotate kubelet server certificate* probeManager * 针对每一个Pod中的每一个container的startup/readiness/liveness这三种probe,分别建一个周期性循环的worker任务,不断通过其定义的probe条件进行检查,将检查的结果更新到startupManager/readinessManager/livenessManager中,这三个Manager是同一个类型的Manager,通过其Set()方法,将probe的结果放到updates channel中,probeManager周期性的从readniessManager和startupManager的Updates channel中读取result,然后通过statusManager的SetContainerReadiness()和SetContainerStartup()方法将结果同步到statusManager中,然后statusManager再将结果异步同步到apiserver * 以上的逻辑涉及到probeManager,statusManager, startupManager, readniessManager, livenessManager这几个manager的相互协作。* tokenManager * 获取ServiceAccountToken,缓存到本地,通过GetServiceAccountToken()先从cache中找token,如果过期了,则从TokenRequest API中重新获取新的token* volumePluginMgr * VolumePluginMgr * plugins map[string]VolumePlugin * probedPlugins map[string]VolumePlugin * 管理intree和flexvolume dynamic的VolumePlugin,csi也是作为intree的一个plugin的形式存在的,所谓管理就是自动发现,注册,查找VolumePlugin。 * 在volumeManager中,会根据各种条件查找注册到volumePluginMgr中的VolumePlugin * flexvolume动态发现插件的默认目录:/usr/libexec/kubernetes/kubelet-plugins/volume/exec/,由配置项VolumePluginDir进行配置* pluginManager * 主要是来注册CSIPlugin和DevicePlugin * 这里面主要有两个loop: desiredStateOfWorldPopulator 和 reconciler * 前者是通过fsnotify watch机制从插件目录发现csi的socket文件,默认路径在/var/lib/kubelet/plugins_registry/,然后将其信息添加到desiredStateOfWorld结构中; * 后者会去对比actualStateOfWorld 和 desiredStateOfWorld中记录的插件注册的情况,desiredStateOfWorld是全部期望注册的插件,而actualStateOfWorld则是全部已经注册的插件,如果没注册的,则会调用operationExecutor去注册,如果需要插件已经没删除,则调用operationExecutor去删除注册; * operationExecutor是用来执行注册方法的执行器,本质上就是通过goroutine去执行注册方法,而operationGenerator则是注册方法生成器,在该注册方法中,首先通过该socket建立了一个grpc的客户端,通过向该socket发送grpc请求,即client.GetInfo(),获取到该CSI插件的信息,根据该插件的种类(CSIPlugin或者是DevicePlugin),来调用相应的handler,来进一步进行注册,首先要handler.ValidatePlugin(),然后handler.RegisterPlugin(),handler是在服务启动时,添加到pluginManager中的。 * 如果是CSIPlugin的话,其注册流程大致如下: * 主要代码逻辑在 kubernetes/pkg/volume/csi/ 路径下 * 首先根据插件的socket文件,初始化一个csi的grpc client,用来跟csi server进行交互 * csi rpc client又引用了container-storage-interface项目中定义的csi protobuffer协议的接口 * 发送csi.NodeGetInfo() rpc请求,获取到本节点的相关信息 //NodeGetInfo()即是CSI规范定义的接口 * 接下来,通过nim,即nodeInfoManager(这个是在volumePluginMgr在进行插件初始化的时候实例化的),继续进行注册,主要分为两步: * 更新本节点的Node对象,添加csi相关的annotation和labels * 创建或者更新本节点对应的CSINode对象,里面包含了该node的CSI插件信息,主要是包含插件的名字* volumeManager * 是用来管理本node上的pod/volume/node的attach 和 mount 操作的 * DesiredStateOfWorldPopulator 周期性的从podManager中获取本node的Pod列表,然后遍历pod列表,获取到每个pod的Volumes,遍历每个volume,获取到详细的信息,然后添加到desiredStateOfWorld中,desiredStateOfWorld用以下的数据结构记录本节点的所有pod的所有volume信息,包括该volume是否可挂载,可mount,以及所属的pod,而且某个volume可能属于多个pod * desiredStateOfWorld * volumesToMount map[v1.UniqueVolumeName]volumeToMount * volumePluginMgr *volume.VolumePluginMgr * volumeToMount * volumeName v1.UniqueVolumeName * podsToMount map[types.UniquePodName]podToMount * pluginIsAttachable bool * pluginIsDeviceMountable bool * volumeGidValue string * podToMount * podName types.UniquePodName * pod *v1.Pod * volumeSpec *volume.Spec * OperatorGenerator是从volume对应的VolumePlugin中获取到对应的AttachVolume/MountVolume等具体实现方法 * OperatorExecutor会在goroutine中调用OperatorGenerator中的方法去执行具体的动作 * reconciler会周期性的从desiredStateOfWorld中获取到需要进行Attach或者Mount的Volume,然后调用OperatorExecutor来执行具体的Attach/Mount操作 * rc.unmountVolumes() * // Filesystem volume case * volumePlugin, err := og.volumePluginMgr.FindPluginByName(volumeToUnmount.PluginName) * volumeUnmounter, newUnmounterErr :=volumePlugin.NewUnmounter() * unmountErr := volumeUnmounter.TearDown() * // Block volume case * blockVolumePlugin, err := og.volumePluginMgr.FindMapperPluginByName(volumeToUnmount.PluginName) * blockVolumeUnmapper, newUnmapperErr := blockVolumePlugin.NewBlockVolumeUnmapper() * customBlockVolumeUnmapper, ok := blockVolumeUnmapper.(volume.CustomBlockVolumeUnmapper) * unmapErr = customBlockVolumeUnmapper.UnmapPodDevice() * rc.mountAttachVolumes() * // if volume is not attached * attachableVolumePlugin, err := og.volumePluginMgr.FindAttachablePluginBySpec(volumeToAttach.VolumeSpec) * volumeAttacher, newAttacherErr := attachableVolumePlugin.NewAttacher() * devicePath, attachErr := volumeAttacher.Attach() * // if volume is not mounted * // Filesystem volume case * volumePlugin, err := og.volumePluginMgr.FindPluginBySpec(volumeToMount.VolumeSpec) * volumeMounter, newMounterErr := volumePlugin.NewMounter() * attachableVolumePlugin, _ := og.volumePluginMgr.FindAttachablePluginBySpec(volumeToMount.VolumeSpec) * volumeAttacher, _ = attachableVolumePlugin.NewAttacher() * deviceMountableVolumePlugin, _ := og.volumePluginMgr.FindDeviceMountablePluginBySpec(volumeToMount.VolumeSpec) * volumeDeviceMounter, _ = deviceMountableVolumePlugin.NewDeviceMounter() * devicePath, err = volumeAttacher.WaitForAttach() * err = volumeDeviceMounter.MountDevice() * mountErr := volumeMounter.SetUp() * // Block volume case * blockVolumePlugin, err := og.volumePluginMgr.FindMapperPluginBySpec(volumeToMount.VolumeSpec) * blockVolumeMapper, newMapperErr := blockVolumePlugin.NewBlockVolumeMapper() * attachableVolumePlugin, _ := og.volumePluginMgr.FindAttachablePluginBySpec(volumeToMount.VolumeSpec) * volumeAttacher, _ = attachableVolumePlugin.NewAttacher() * devicePath, err = volumeAttacher.WaitForAttach() * customBlockVolumeMapper, ok := blockVolumeMapper.(volume.CustomBlockVolumeMapper); * stagingPath, mapErr = customBlockVolumeMapper.SetUpDevice() * pluginDevicePath, mapErr := customBlockVolumeMapper.MapPodDevice() * rc.unmountDetachDevices() * 所以,具体的Attach/Mount逻辑是在对应的VolumePlugin中实现的* podWorkers * klet.podWorkers = newPodWorkers(klet.syncPod, kubeDeps.Recorder, klet.workQueue, klet.resyncInterval, backOffPeriod, klet.podCache) * podUpdates map[types.UID]chan UpdatePodOptions // 每个pod对应了一个chan,里面保存了针对该pod的更新选项 * 每一个pod有一个loop,当有对该pod的更新操作时,该loop就会被触发执行syncPod()方法,每个pod同时只能有一个syncPod()动作在执行* podKiller * 删除pod* evictionManager * Eviction,就是当节点的内存,磁盘,pid这几个资源压力大时,会选择性的将一些Pod kill掉,以保持资源足够维持系统稳定* admitHandlers * 在kubelet创建pod时,会依次调用admit handler去检查该pod是否符合创建条件,如果不符合的话,则会返回相应的错误信息* softAdmitHandlers * softAdmithandlers are applied to the pod after it is admitted by the Kubelet, but before it is run. * A pod rejected by a softAdmitHandler will be left in a Pending state indefinitely.* nodeLeaseController * node的心跳机制,每个node都在apiserver中创建了一个Lease对象,每隔10秒,kubelet就会更新它对应的Lease对象,类似于租约续期 * 除了Lease这种心跳机制,还有NodeStatus,kubelet也会周期性的更新NodeStatus,不过这个间稍长,默认是5分钟* shutdownManager","link":"/2021/08/15/kubernetes/kube-kubelet-overview.html"},{"title":"Kubernetes APIServer扩展机制原理","text":"终于来到了这一篇,APIServer的扩展机制,前面介绍的那几篇,可以说都是在给这篇铺平道路,先来回顾下: 在Kubernetes APIServer 机制概述中我们介绍到了APIServer的本质其实是一个实现了RESTful API的WebServer,它使用golang的net/http的Server构建,并且Handler是其中非常重要的概念,此外,又简单介绍了APIServer的扩展机制,即Aggregator, APIExtensions以及KubeAPIServer这三者之间通过Delegation的方式实现了扩展。 在Kubernetes APIServer Storage 框架解析中,我们介绍了APIServer相关的存储框架,每个API对象,都有对应的REST store以及etcd store,它们是如何存储进数据库的。 在Kubernetes APIServer GenericAPIServer中介绍了GenericAPIServer的作用,以及它的Handler是如何构建,API对象是如何以APIGroupInfo的形式注册进Handler中的,以及PostStartHook的机制。 在Kubernetes APIServer API Resource Installation中,介绍了KubeAPIServer, Aggregator, APIExtensions中的API对象资源是如何构建成REST Store,并且组织成APIGroupInfo,然后注册进GenericAPIServer中的,然后又盘点了下当前版本的Kubernetes中都有哪些API对象资源。 在上面的基础知识之上,我们来分析下APIServer的扩展机制是如何实现的。之所以要花这么大的力气去分析它实现的原理,主要原因还是在于它的应用实在是太广泛了,尤其是CRD + Operator这种扩展模式,逐渐变成了很多软件在云原生时代运行的标准模式,我们在使用这些机制去扩展它的功能的时候,如果能够理解其实现原理,犹如庖丁解牛,心中是十分有底气的。 Kubernetes已经逐渐变成这场云原生运动的事实标准,未来的应用可能都在Kubernetes上开发以及运行,未来交付一个软件,除了交付运行时之外,还可以一起打包交付该软件的运维能力,安装/部署/备份/更新/升级等等,仿佛有一个机器人在默默地干着运维的工作,保证软件正常运行,作为一个运维er,这种场景想想就刺激,但是理想是美好的,现实中还得有一段漫长的脚踏实地的发展以及被人们慢慢接受的过程。现在国外的,像红帽的OpenShift,凭借其强大的产品研发和设计能力,走在这场云原生运动的前列,新发布的OpenShift 4.0中,本身就大量使用CRD + Operator,而且将其设计到应用市场中,鼓励开发者使用这种模式发布自己的应用;而国内的,当属阿里云了,在今年7月,发布了云原生架构白皮书,扛起了国内云原生运动的大旗,阿里云大牛如云,又占据了国内云计算市场的半壁江山,有非常大的优势做好这件事情。 Delegation之前已经简单介绍过多次,APIServer的扩展机制,是由Aggregator, KubeAPIServer, APIExtensions,这三者通过Delegation的方式实现的,这三者本质上都是APIServer,KubeAPIServer是Kubernetes内置的API对象所在的APIServer,而Aggregator和APIExtensions是Kubernetes API的两个扩展机制使用的APIServer,对这两个扩展机制的介绍见官方文档,APIExtensions就是CRD的实现,而Aggregator是一种高级扩展,可以让Kubernetes APIServer跟外部的APIServer进行联动,这三者中,每个都包含一个GenericAPIServer,真正delegation的其实是这三个GenericAPIServer,这一小节,我们先来理一下,这三者之间Delegation的关系。 还是先来看下这三个对应的结构体: 12345678910111213141516171819202122232425262728# kubernetes/pkg/controlplane/instance.go// KubeAPIServertype Instance struct { GenericAPIServer *genericapiserver.GenericAPIServer ClusterAuthenticationInfo clusterauthenticationtrust.ClusterAuthenticationInfo}# kube-aggregator/pkg/apiserver/apiserver.go// Aggregatortype APIAggregator struct { GenericAPIServer *genericapiserver.GenericAPIServer delegateHandler http.Handler // proxyHandlers are the proxy handlers that are currently registered, keyed by apiservice.name proxyHandlers map[string]*proxyHandler ......}# apiextensions-apiserver/pkg/apiserver/apiserver.go// APIExtensionstype CustomResourceDefinitions struct { GenericAPIServer *genericapiserver.GenericAPIServer // provided for easier embedding Informers externalinformers.SharedInformerFactory} 可以看到每个都包含了一个GenericAPIServer指针类型的成员变量,他们是在Config->Complete->New模式的New()方法中被创建出来: 1234567891011121314151617181920212223242526272829303132333435363738394041# kubernetes/pkg/controlplane/instance.gofunc (c completedConfig) New(delegationTarget genericapiserver.DelegationTarget) (*Master, error) { s, err := c.GenericConfig.New("kube-apiserver", delegationTarget) ...... m := &Instance{ GenericAPIServer: s, ClusterAuthenticationInfo: c.ExtraConfig.ClusterAuthenticationInfo, } ......}# kube-aggregator/pkg/apiserver/apiserver.gofunc (c completedConfig) NewWithDelegate(delegationTarget genericapiserver.DelegationTarget) (*APIAggregator, error) { genericServer, err := c.GenericConfig.New("kube-aggregator", delegationTarget) s := &APIAggregator{ GenericAPIServer: genericServer, delegateHandler: delegationTarget.UnprotectedHandler(), proxyClientCert: c.ExtraConfig.ProxyClientCert, proxyClientKey: c.ExtraConfig.ProxyClientKey, proxyTransport: c.ExtraConfig.ProxyTransport, proxyHandlers: map[string]*proxyHandler{}, handledGroups: sets.String{}, lister: informerFactory.Apiregistration().V1().APIServices().Lister(), APIRegistrationInformers: informerFactory, serviceResolver: c.ExtraConfig.ServiceResolver, openAPIConfig: openAPIConfig, egressSelector: c.GenericConfig.EgressSelector, }}# kube-aggregator/pkg/apiserver/apiserver.gofunc (c completedConfig) New(delegationTarget genericapiserver.DelegationTarget) (*CustomResourceDefinitions, error) { genericServer, err := c.GenericConfig.New("apiextensions-apiserver", delegationTarget) s := &CustomResourceDefinitions{ GenericAPIServer: genericServer, }} 可以看到,在New()方法中,每一个都通过GenericAPIServer的New()方法创建了一个GenericAPIServer,传了两个参数进去,一个是该GenericAPIServer的name,另外一个是DelegationTarget,即该GenericAPIServer的Delegation是谁,来看下这个New()方法: 123456789101112131415# apiserver/pkg/server/config.gofunc (c completedConfig) New(name string, delegationTarget DelegationTarget) (*GenericAPIServer, error) { handlerChainBuilder := func(handler http.Handler) http.Handler { return c.BuildHandlerChainFunc(handler, c.Config) } apiServerHandler := NewAPIServerHandler(name, c.Serializer, handlerChainBuilder, delegationTarget.UnprotectedHandler()) s := &GenericAPIServer{ ...... delegationTarget: delegationTarget, ...... } return s, nil} 看到从参数传进来的delegationTarget被赋值给GenericAPIServer的delegationTarget属性。 再来看下,上面三个APIServer是怎么被创建出来的,是在CreateServerChain()阶段,依次调用了上面的New()方法,创建出来这三个APIServer: 1234567891011121314151617181920212223242526# kubernetes/cmd/kube-apiserver/app/server.go// CreateServerChain creates the apiservers connected via delegation.func CreateServerChain(config CompletedConfig) (*aggregatorapiserver.APIAggregator, error) { notFoundHandler := notfoundhandler.New(config.ControlPlane.GenericConfig.Serializer, genericapifilters.NoMuxAndDiscoveryIncompleteKey) apiExtensionsServer, err := config.ApiExtensions.New(genericapiserver.NewEmptyDelegateWithCustomHandler(notFoundHandler)) if err != nil { return nil, err } crdAPIEnabled := config.ApiExtensions.GenericConfig.MergedResourceConfig.ResourceEnabled(apiextensionsv1.SchemeGroupVersion.WithResource("customresourcedefinitions")) kubeAPIServer, err := config.ControlPlane.New(apiExtensionsServer.GenericAPIServer) if err != nil { return nil, err } // aggregator comes last in the chain aggregatorServer, err := createAggregatorServer(config.Aggregator, kubeAPIServer.GenericAPIServer, apiExtensionsServer.Informers, crdAPIEnabled) if err != nil { // we don't need special handling for innerStopCh because the aggregator server doesn't create any go routines return nil, err } return aggregatorServer, nil} 分别通过各自config的New()方法创建出来APIServer,注意一下这三个APIServer出创建的顺序,以及delegationTarget参数的传递,可以看到,首先创建的是APIExtensionsServer,它的delegationTarget传的是一个空的Delegate,即什么都不做,继而将APIExtensionsServer的GenericAPIServer,作为delegationTarget传给了config.ControlPlane.New(),创建出了KubeAPIServer,再然后,将kubeAPIServer的GenericAPIServer作为delegationTarget传给了createAggregatorServer(),创建出了aggregatorServer,注意,最终CreateServerChain()这个方法返回的也只有aggregatorServer,所以他们之间delegation的关系为: Aggregator -> KubeAPIServer -> APIExtensions,如下图所示: 从上面可以看出,Aggregator其实是一个非常重要的存在,CreateServerChain()最终返回的是一个Aggregator APIServer,并且Aggregator是Delegation这个Chain最开头的APIServer,的确,Aggregator有一个内置的API资源,叫做apiservices,用来表示一个外部的API服务,在创建AggregatorServer时,KubeAPIServer和APIExtensions中的资源组,即GroupVersion,会被转换成Aggregator APIService对象,注册到Aggregator中,并且整个APIServer的入口,其实是Aggregator的GenericAPIServer,下面我们来看看这些是怎么实现的。 kube-apiserver-autoregistration在aggregator启动的时候,即在createAggregatorServer()方法中,crd和apiserver中定义的资源组(GroupVersion),会通过kube-apiserver-autoregistration poststarthook,被转换成APIService,然后注册进aggregator中,比如将GroupVersion{Group: "apps", Version: "v1"}转成APIService{Spec: v1.APIServiceSpec{Group: "apps", Version" "v1"}},存进数据库中。apiserver中的对象资源因为是k8s内置的,是固定的,所以只需要在启动的时候,注册一次就可以了,但是CRD中的资源,是用户自定义的,可能随时增删改,所以需要不断的进行更新同步。相关代码如下: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354# kubernetes/cmd/kube-apiserver/app/aggregator.gofunc createAggregatorServer(aggregatorConfig aggregatorapiserver.CompletedConfig, delegateAPIServer genericapiserver.DelegationTarget, apiExtensionInformers apiextensionsinformers.SharedInformerFactory, crdAPIEnabled bool) (*aggregatorapiserver.APIAggregator, error) { aggregatorServer, err := aggregatorConfig.NewWithDelegate(delegateAPIServer) if err != nil { return nil, err } // create controllers for auto-registration apiRegistrationClient, err := apiregistrationclient.NewForConfig(aggregatorConfig.GenericConfig.LoopbackClientConfig) if err != nil { return nil, err } // autoRegisterController中有一个queue和一个map,map用来存储APIService的具体对象,而queue只用来存储APIService的name autoRegistrationController := autoregister.NewAutoRegisterController(aggregatorServer.APIRegistrationInformers.Apiregistration().V1().APIServices(), apiRegistrationClient) // 将apiserver中的GroupVersion,转换成APIService,添加到autoRegistrationController的queue和map中,这个只执行一次 apiServices := apiServicesToRegister(delegateAPIServer, autoRegistrationController) crdRegistrationController := crdregistration.NewCRDRegistrationController( apiExtensionInformers.Apiextensions().V1().CustomResourceDefinitions(), autoRegistrationController) // Imbue all builtin group-priorities onto the aggregated discovery if aggregatorConfig.GenericConfig.AggregatedDiscoveryGroupManager != nil { for gv, entry := range apiVersionPriorities { aggregatorConfig.GenericConfig.AggregatedDiscoveryGroupManager.SetGroupVersionPriority(metav1.GroupVersion(gv), int(entry.group), int(entry.version)) } } err = aggregatorServer.GenericAPIServer.AddPostStartHook("kube-apiserver-autoregistration", func(context genericapiserver.PostStartHookContext) error { // 通过不断循环,将crd中的GroupVersion,转换成APIService,添加到autoRegistrationController的queue和map中 go crdRegistrationController.Run(5, context.StopCh) go func() { // let the CRD controller process the initial set of CRDs before starting the autoregistration controller. // this prevents the autoregistration controller's initial sync from deleting APIServices for CRDs that still exist. // we only need to do this if CRDs are enabled on this server. We can't use discovery because we are the source for discovery. if crdAPIEnabled { klog.Infof("waiting for initial CRD sync...") crdRegistrationController.WaitForInitialSync() klog.Infof("initial CRD sync complete...") } else { klog.Infof("CRD API not enabled, starting APIService registration without waiting for initial CRD sync") } // 通过不断的轮询,将queue中的APIService取出,通过apiservice的API,添加或者更新到etcd数据库中,固化下来。 autoRegistrationController.Run(5, context.StopCh) }() return nil }) ...... return aggregatorServer, nil} 这个转换过程,主要是通过两个Controller来实现的: crdRegistrationController和autoRegistrationController,这里就体现了Kubernetes中非常核心的设计模式,Controller-Loop模式,即不断从API中获取对象定义,然后按照API对象的定义,执行对应的操作,确保API对象定义和实际的效果是相符的,这种API也叫做declarative api,即申明式API。 autoRegistrationController中定义了一个队列,用来保存添加进来的APIService对象,这些APIService,可能是KubeAPIServer或者APIExtensions APIServer转换过来的,也可能是通过APIService的API直接添加进来的,然后在kube-apiserver-autoregistration PostStartHook中,启动这个Controller,通过不断轮询,将队列中的APIService取出,然后调用apiservice对应的API,将他们添加或者更新到etcd数据库中,固化下来。 crdRegistrationController则是将APIExtensions APIServer中定义的CRD对象转换成APIService,注册到autoRegistrationController的队列中,然后在kube-apiserver-autoregistration PostStartHook中,启动这个Controller,通过不断轮询CRD的API,将CRD中的GroupVersion,转换成APIService,添加到autoRegistrationController的队列中。除了APIExtensions APIServer,还有KubeAPIServer,因为它里面的对象资源是内置的,不会动态发生变化,所以,在apiServicesToRegister()方法中只进行一次转换,然后注册进autoRegistrationController队列中。 但是需要注意由APIExtensions和KubeAPIServer转换过来的APIService是特殊的,叫做local APIService,来看看APIService的定义: 123456789101112131415# kube-aggregator/pkg/apis/apiregistration/v1/types.gotype APIServiceSpec struct { // Service is a reference to the service for this API server. It must communicate // on port 443. // If the Service is nil, that means the handling for the API groupversion is handled locally on this server. // The call will simply delegate to the normal handler chain to be fulfilled. // +optional Service *ServiceReference `json:"service,omitempty" protobuf:"bytes,1,opt,name=service"` // Group is the API group name this server hosts Group string `json:"group,omitempty" protobuf:"bytes,2,opt,name=group"` // Version is the API version this server hosts. For example, "v1" Version string `json:"version,omitempty" protobuf:"bytes,3,opt,name=version"` ......} 看上面的Service属性的注释可以看到,Service属性如果是nil的话,则表明该APIService是一个local APIService,local APIService的请求,将会delegate给对应的handler来处理,来看看APIExtensions和KubeAPIServer中的GroupVersion是怎么转换成local APIService的: 1234567891011121314151617181920# kubernetes/cmd/kube-apiserver/app/aggregator.gofunc makeAPIService(gv schema.GroupVersion) *v1.APIService { apiServicePriority, ok := apiVersionPriorities[gv] if !ok { // if we aren't found, then we shouldn't register ourselves because it could result in a CRD group version // being permanently stuck in the APIServices list. klog.Infof("Skipping APIService creation for %v", gv) return nil } return &v1.APIService{ ObjectMeta: metav1.ObjectMeta{Name: gv.Version + "." + gv.Group}, Spec: v1.APIServiceSpec{ Group: gv.Group, Version: gv.Version, GroupPriorityMinimum: apiServicePriority.group, VersionPriority: apiServicePriority.version, }, }} 可以看到,构建APIServiceSpec时,并没有传Service属性,不传的话,默认为nil,这样的APIService,则会由本地的APIServer进行处理,其实也就是KubeAPIServer或者是APIExtension APIServer,而不会被Aggregator给proxy出去,这个后面看下Aggregator的proxy策略就知道了。 apiservice-registration-controller这个Controller的作用,则是通过轮询数据库中的APIService对象,为每个APIService构建Handler,并且向Aggregator中的GenericAPIServer注册的过程,来看下它的定义: 12345678910111213141516171819202122232425262728293031323334353637# kube-aggregator/pkg/apiserver/apiservice_controller.gotype APIHandlerManager interface { AddAPIService(apiService *v1.APIService) error RemoveAPIService(apiServiceName string)}type APIServiceRegistrationController struct { apiHandlerManager APIHandlerManager apiServiceLister listers.APIServiceLister apiServiceSynced cache.InformerSynced // To allow injection for testing. syncFn func(key string) error queue workqueue.RateLimitingInterface}func NewAPIServiceRegistrationController(apiServiceInformer informers.APIServiceInformer, apiHandlerManager APIHandlerManager) *APIServiceRegistrationController { c := &APIServiceRegistrationController{ apiHandlerManager: apiHandlerManager, apiServiceLister: apiServiceInformer.Lister(), apiServiceSynced: apiServiceInformer.Informer().HasSynced, queue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "APIServiceRegistrationController"), } apiServiceInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: c.addAPIService, UpdateFunc: c.updateAPIService, DeleteFunc: c.deleteAPIService, }) c.syncFn = c.sync return c} 这个Controller是在APIAggregator的New()方法中被创建,并且添加到poststarthook中: 12345678910111213141516171819202122# kube-aggregator/pkg/apiserver/apiserver.gofunc (c completedConfig) NewWithDelegate(delegationTarget genericapiserver.DelegationTarget) (*APIAggregator, error) { s := &APIAggregator{ GenericAPIServer: genericServer, delegateHandler: delegationTarget.UnprotectedHandler() ...... } ...... apiserviceRegistrationController := NewAPIServiceRegistrationController(informerFactory.Apiregistration().V1().APIServices(), s) ...... s.GenericAPIServer.AddPostStartHookOrDie("apiservice-registration-controller", func(context genericapiserver.PostStartHookContext) error { handlerSyncedCh := make(chan struct{}) go apiserviceRegistrationController.Run(context.StopCh, handlerSyncedCh) select { case <-context.StopCh: case <-handlerSyncedCh: } return nil }) ......} 可以看到上面APIServiceRegistrationController中有一个apiHandlerManager的成员变量,其实它就是APIAggregator,通过NewAPIServiceRegistrationController()方法构建Controller的时候,传参过去的,而在APIAggregator中,则实现了为APIService构建Handler,并且注册进GenericAPIServer中的逻辑: 1234567891011121314151617181920212223242526272829303132333435363738# kube-aggregator/pkg/apiserver/apiserver.gofunc (s *APIAggregator) AddAPIService(apiService *v1.APIService) error { // if the proxyHandler already exists, it needs to be updated. The aggregation bits do not // since they are wired against listers because they require multiple resources to respond if proxyHandler, exists := s.proxyHandlers[apiService.Name]; exists { proxyHandler.updateAPIService(apiService) if s.openAPIAggregationController != nil { s.openAPIAggregationController.UpdateAPIService(proxyHandler, apiService) } return nil } proxyPath := "/apis/" + apiService.Spec.Group + "/" + apiService.Spec.Version // v1. is a special case for the legacy API. It proxies to a wider set of endpoints. if apiService.Name == legacyAPIServiceName { proxyPath = "/api" } // register the proxy handler proxyHandler := &proxyHandler{ localDelegate: s.delegateHandler, proxyClientCert: s.proxyClientCert, proxyClientKey: s.proxyClientKey, proxyTransport: s.proxyTransport, serviceResolver: s.serviceResolver, egressSelector: s.egressSelector, } proxyHandler.updateAPIService(apiService) if s.openAPIAggregationController != nil { s.openAPIAggregationController.AddAPIService(proxyHandler, apiService) } s.proxyHandlers[apiService.Name] = proxyHandler s.GenericAPIServer.Handler.NonGoRestfulMux.Handle(proxyPath, proxyHandler) s.GenericAPIServer.Handler.NonGoRestfulMux.UnlistedHandlePrefix(proxyPath+"/", proxyHandler) ......} 在AddAPIService()方法中,以该APIServcie的Group和Version构建了路径:proxyPath := "/apis/" + apiService.Spec.Group + "/" + apiService.Spec.Version,然后构建了一个proxyHandler的Handler,然后将path: proxyHandler注册进了Aggregator的GenericAPIServer中的NonGoRestfulMux中,完成了Handler的注册。与之对应的还有个RemoveAPIService()用来将一个APIService的Handler从Aggregator中移除。 下面来看看该Controller启动之后的逻辑: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354# kube-aggregator/pkg/apiserver/apiservice_controller.gofunc (c *APIServiceRegistrationController) Run(stopCh <-chan struct{}, handlerSyncedCh chan<- struct{}) { defer utilruntime.HandleCrash() defer c.queue.ShutDown() klog.Infof("Starting APIServiceRegistrationController") defer klog.Infof("Shutting down APIServiceRegistrationController") if !controllers.WaitForCacheSync("APIServiceRegistrationController", stopCh, c.apiServiceSynced) { return } /// initially sync all APIServices to make sure the proxy handler is complete if err := wait.PollImmediateUntil(time.Second, func() (bool, error) { services, err := c.apiServiceLister.List(labels.Everything()) if err != nil { utilruntime.HandleError(fmt.Errorf("failed to initially list APIServices: %v", err)) return false, nil } for _, s := range services { if err := c.apiHandlerManager.AddAPIService(s); err != nil { utilruntime.HandleError(fmt.Errorf("failed to initially sync APIService %s: %v", s.Name, err)) return false, nil } } return true, nil }, stopCh); err == wait.ErrWaitTimeout { utilruntime.HandleError(fmt.Errorf("timed out waiting for proxy handler to initialize")) return } else if err != nil { panic(fmt.Errorf("unexpected error: %v", err)) } close(handlerSyncedCh) // only start one worker thread since its a slow moving API and the aggregation server adding bits // aren't threadsafe go wait.Until(c.runWorker, time.Second, stopCh) <-stopCh}func (c *APIServiceRegistrationController) sync(key string) error { apiService, err := c.apiServiceLister.Get(key) if apierrors.IsNotFound(err) { c.apiHandlerManager.RemoveAPIService(key) return nil } if err != nil { return err } return c.apiHandlerManager.AddAPIService(apiService)} 在PostStartHook中,将该Controller启动之后,即调用Run()方法,首先会通过APIService API拿到当前所有的APIService对象,然后调用Aggregator的AddAPIService()方法,将现有的APIService都注册进GenericAPIServer中。之后,就通过Informer机制,不断轮询APIService API,当监测到有APIService对象的增删改时,则会调用sync()方法,从API中获取到该APIService对象,然后将其从GenericAPIServer的Handler中增加或者删除。 以上,就是这两个poststarthook中的几个Controller的逻辑,我们画张图,总结一下: Aggregator proxyHandler下面我们来重点关注一下Aggregator中的proxyHandler的proxy逻辑,但是首先来看下proxyHandler的构建逻辑: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687# kube-aggregator/pkg/apiserver/apiserver.gofunc (s *APIAggregator) AddAPIService(apiService *v1.APIService) error { ...... proxyHandler := &proxyHandler{ localDelegate: s.delegateHandler, proxyClientCert: s.proxyClientCert, proxyClientKey: s.proxyClientKey, proxyTransport: s.proxyTransport, serviceResolver: s.serviceResolver, egressSelector: s.egressSelector, } proxyHandler.updateAPIService(apiService) ......}# kube-aggregator/pkg/apiserver/handler_proxy.gotype proxyHandler struct { // localDelegate is used to satisfy local APIServices localDelegate http.Handler // proxyClientCert/Key are the client cert used to identify this proxy. Backing APIServices use // this to confirm the proxy's identity proxyClientCert []byte proxyClientKey []byte proxyTransport *http.Transport // Endpoints based routing to map from cluster IP to routable IP serviceResolver ServiceResolver handlingInfo atomic.Value // egressSelector selects the proper egress dialer to communicate with the custom apiserver // overwrites proxyTransport dialer if not nil egressSelector *egressselector.EgressSelector}type proxyHandlingInfo struct { // local indicates that this APIService is locally satisfied local bool // name is the name of the APIService name string // restConfig holds the information for building a roundtripper restConfig *restclient.Config // transportBuildingError is an error produced while building the transport. If this // is non-nil, it will be reported to clients. transportBuildingError error // proxyRoundTripper is the re-useable portion of the transport. It does not vary with any request. proxyRoundTripper http.RoundTripper // serviceName is the name of the service this handler proxies to serviceName string // namespace is the namespace the service lives in serviceNamespace string // serviceAvailable indicates this APIService is available or not serviceAvailable bool // servicePort is the port of the service this handler proxies to servicePort int32}func (r *proxyHandler) updateAPIService(apiService *apiregistrationv1api.APIService) { if apiService.Spec.Service == nil { r.handlingInfo.Store(proxyHandlingInfo{local: true}) return } newInfo := proxyHandlingInfo{ name: apiService.Name, restConfig: &restclient.Config{ TLSClientConfig: restclient.TLSClientConfig{ Insecure: apiService.Spec.InsecureSkipTLSVerify, ServerName: apiService.Spec.Service.Name + "." + apiService.Spec.Service.Namespace + ".svc", CertData: r.proxyClientCert, KeyData: r.proxyClientKey, CAData: apiService.Spec.CABundle, }, }, serviceName: apiService.Spec.Service.Name, serviceNamespace: apiService.Spec.Service.Namespace, servicePort: *apiService.Spec.Service.Port, serviceAvailable: apiregistrationv1apihelper.IsAPIServiceConditionTrue(apiService, apiregistrationv1api.Available), } ...... newInfo.proxyRoundTripper, newInfo.transportBuildingError = restclient.TransportFor(newInfo.restConfig) r.handlingInfo.Store(newInfo)} proxyHandler中有一个localDelegate成员变量,是一个http.Handler,它从APIAggregator的delegateHandler属性赋值过来,这个值又是在APIAggregator的New()方法传进来的: 12345s := &APIAggregator{ GenericAPIServer: genericServer, delegateHandler: delegationTarget.UnprotectedHandler() ......} 它是APIAggregator的delegationTarget的UnprotectedHandler()创建的,那这个UnprotectedHandler()是什么呢?来看下这个方法: 1234func (s *GenericAPIServer) UnprotectedHandler() http.Handler { // when we delegate, we need the server we're delegating to choose whether or not to use gorestful return s.Handler.Director} 可以看到这个就是GenericAPIServer中的Handler的Director成员变量,该Handler中,还有一个成员变量是FullHandlerChain: 123456APIServerHandler{ FullHandlerChain: handlerChainBuilder(director), GoRestfulContainer: gorestfulContainer, NonGoRestfulMux: nonGoRestfulMux, Director: director,} 这个内容,我们在Kubernetes APIServer GenericAPIServer有过介绍,FullHandlerChain在director外面包了很多filter,用来做认证授权这些操作,而Director则直接是director,即没有认证授权这些操作,director也是一个Handler,所以这里把它叫做UnprotectedHandler,意思是可以不经过认证授权,就可以直接被该Handler处理。前面在Delegation小节,我们介绍过,Aggregator的delegationTarget是KubeAPIServer,所以这里的Director其实就是KubeAPIServer的APIServerHandler中的Director,这样做的意图也很明显,就是当请求从Aggregator delegate给KubeAPIServer时,就不需要再认证了,因为请求在达到Aggregator的Handler时,就已经经过认证了。 除了这个localDelegate成员变量,还有一个需要关注的就是handlingInfo,它是一个原子变量,通过Store/Load对其内容进行存储和读取,在updateAPIService()方法中,将APIService转换成proxyHandlingInfo结构体,然后存储到handlingInfo变量中。注意,该方法的第一行内容,如果APIServiceSpec的Service为nil,则将proxyHandlingInfo的local属性置为true,意思是这是个local APIService,即前面介绍过的,KubeAPIServer和APIExtensins转换来的APIService,都是local APIService。 了解了以上内容,再来看下proxyHandler的处理逻辑: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374# kube-aggregator/pkg/apiserver/handler_proxy.gofunc (r *proxyHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { value := r.handlingInfo.Load() if value == nil { r.localDelegate.ServeHTTP(w, req) return } handlingInfo := value.(proxyHandlingInfo) if handlingInfo.local { if r.localDelegate == nil { http.Error(w, "", http.StatusNotFound) return } r.localDelegate.ServeHTTP(w, req) return } if !handlingInfo.serviceAvailable { proxyError(w, req, "service unavailable", http.StatusServiceUnavailable) return } if handlingInfo.transportBuildingError != nil { proxyError(w, req, handlingInfo.transportBuildingError.Error(), http.StatusInternalServerError) return } user, ok := genericapirequest.UserFrom(req.Context()) if !ok { proxyError(w, req, "missing user", http.StatusInternalServerError) return } // write a new location based on the existing request pointed at the target service location := &url.URL{} location.Scheme = "https" rloc, err := r.serviceResolver.ResolveEndpoint(handlingInfo.serviceNamespace, handlingInfo.serviceName, handlingInfo.servicePort) if err != nil { klog.Errorf("error resolving %s/%s: %v", handlingInfo.serviceNamespace, handlingInfo.serviceName, err) proxyError(w, req, "service unavailable", http.StatusServiceUnavailable) return } location.Host = rloc.Host location.Path = req.URL.Path location.RawQuery = req.URL.Query().Encode() newReq, cancelFn := newRequestForProxy(location, req) defer cancelFn() if handlingInfo.proxyRoundTripper == nil { proxyError(w, req, "", http.StatusNotFound) return } // we need to wrap the roundtripper in another roundtripper which will apply the front proxy headers proxyRoundTripper, upgrade, err := maybeWrapForConnectionUpgrades(handlingInfo.restConfig, handlingInfo.proxyRoundTripper, req) if err != nil { proxyError(w, req, err.Error(), http.StatusInternalServerError) return } proxyRoundTripper = transport.NewAuthProxyRoundTripper(user.GetName(), user.GetGroups(), user.GetExtra(), proxyRoundTripper) // if we are upgrading, then the upgrade path tries to use this request with the TLS config we provide, but it does // NOT use the roundtripper. Its a direct call that bypasses the round tripper. This means that we have to // attach the "correct" user headers to the request ahead of time. After the initial upgrade, we'll be back // at the roundtripper flow, so we only have to muck with this request, but we do have to do it. if upgrade { transport.SetAuthProxyHeaders(newReq, user.GetName(), user.GetGroups(), user.GetExtra()) } handler := proxy.NewUpgradeAwareHandler(location, proxyRoundTripper, true, upgrade, &responder{w: w}) handler.ServeHTTP(w, newReq)} 可以看到该方法整体逻辑还是比较简单的,最开始判断handlingInfo是否为local的,如果是,则由localDelegate去处理该请求,即由KubeAPIServer的Handler去处理,这样就将请求给delegate出去了,如果不是,则说明该请求应该是被外部的APIServer去处理,然后会使用handlingInfo中的APIService信息,构建一个proxy handler,将该请求给proxy出去,这个proxy handler的具体细节,这里就不展开了。 现在,我们来梳理下Aggregator到KubeAPIServer的delegation逻辑,在前面Delegation小节,我们介绍过CreateServerChain()最终构建出来一个aggregatorServer,aggregatorServer的delegationTarget是KubeAPIServer,而KubeAPIServer的delegationTarget又是APIExtensions APIServer,当Kubernetes APIServer最终运行起来时,其实运行的是aggregatorServer中的GenericAPIServer的启动逻辑,向net/http Server注册的Handler,其实是Aggregator中的GenericAPIServer中的Handler,所以整个API的入口,其实是Aggregator的Handler,又因为前面通过那两个poststarthook,将KubeAPIServer和APIExtensions APIServer中的API对象资源,转换成了Aggregator的APIService,并且为每一个APIService注册了proxyHandler来处理请求,并且是注册到NonGoRestfulMux中的,所以,当任何请求到APIServer时,都是由Aggregator中的GenericAPIServer中的Handler中的NonGoRestfulMux来处理的,但是在这之前,是要先经过包围在Handler外的filter处理的,即经过认证授权等操作,再进入到NonGoRestfulMux的处理逻辑,然后NonGoRestfulMux又根据路径路由到对应的proxyHandler来处理,即进入了上面proxyHandler的ServeHTTP()方法,如果发现对应的APIService是local的,就需要将该请求delegate给它的delegationTarget来进一步处理,即KubeAPIServer的GenericAPIServer,这样就到了KubeAPIServer的GenericAPIServer的Handler的处理逻辑了。 KubeAPIServer Handler经过上面的分析,请求被Aggregator delegate到了KubeAPIServer的GenericAPIServer的Handler进行处理,注意是UnprotectedHandler,也即Director对应的Handler,即delegate的请求,是不经过认证授权的,到了这里,就比较好理解了: 123456789101112131415161718192021222324252627282930313233343536# apiserver/pkg/server/handler.gofunc (d director) ServeHTTP(w http.ResponseWriter, req *http.Request) { path := req.URL.Path // check to see if our webservices want to claim this path for _, ws := range d.goRestfulContainer.RegisteredWebServices() { switch { case ws.RootPath() == "/apis": // if we are exactly /apis or /apis/, then we need special handling in loop. // normally these are passed to the nonGoRestfulMux, but if discovery is enabled, it will go directly. // We can't rely on a prefix match since /apis matches everything (see the big comment on Director above) if path == "/apis" || path == "/apis/" { klog.V(5).Infof("%v: %v %q satisfied by gorestful with webservice %v", d.name, req.Method, path, ws.RootPath()) // don't use servemux here because gorestful servemuxes get messed up when removing webservices // TODO fix gorestful, remove TPRs, or stop using gorestful d.goRestfulContainer.Dispatch(w, req) return } case strings.HasPrefix(path, ws.RootPath()): // ensure an exact match or a path boundary match if len(path) == len(ws.RootPath()) || path[len(ws.RootPath())] == '/' { klog.V(5).Infof("%v: %v %q satisfied by gorestful with webservice %v", d.name, req.Method, path, ws.RootPath()) // don't use servemux here because gorestful servemuxes get messed up when removing webservices // TODO fix gorestful, remove TPRs, or stop using gorestful d.goRestfulContainer.Dispatch(w, req) return } } } // if we didn't find a match, then we just skip gorestful altogether klog.V(5).Infof("%v: %v %q satisfied by nonGoRestful", d.name, req.Method, path) d.nonGoRestfulMux.ServeHTTP(w, req)} 因为KubeAPIServer中的API对象资源,都是注册到goRestfulContainer中的,所以,这些API对象,都是由goRestfulContainer进行路由,找到对应的WebService进行处理,即上面的d.goRestfulContainer.Dispatch()方法,如果goRestfulContainer没有匹配到对应的路径,则由nonGoRestfulMux来进一步处理。那KubeAPIServer又是怎么Delegate给APIExtensions APIServer的呢?其实是通过nonGoRestfulMux的NotFoundHandler来实现的。 1234567891011121314151617181920# apiserver/pkg/server/config.gofunc (c completedConfig) New(name string, delegationTarget DelegationTarget) (*GenericAPIServer, error) { ...... handlerChainBuilder := func(handler http.Handler) http.Handler { return c.BuildHandlerChainFunc(handler, c.Config) } apiServerHandler := NewAPIServerHandler(name, c.Serializer, handlerChainBuilder, delegationTarget.UnprotectedHandler()) ......}# apiserver/pkg/server/handler.gofunc NewAPIServerHandler(name string, s runtime.NegotiatedSerializer, handlerChainBuilder HandlerChainBuilderFn, notFoundHandler http.Handler) *APIServerHandler { nonGoRestfulMux := mux.NewPathRecorderMux(name) if notFoundHandler != nil { nonGoRestfulMux.NotFoundHandler(notFoundHandler) } ......} 可以看到,GenericAPIServer的New()方法中,将delegationTarget.UnprotectedHandler(),即Director,传递给NewAPIServerHandler(),对应的参数为notFoundHandler,并通过nonGoRestfulMux的NotFoundHandler()方法注册进去,这个notFoundHandler的作用,就是在所有的注册到nonGoRestfulMux中的路径都没有匹配到时,则由该Handler进行处理,在Kubernetes APIServer NonGoRestfulMux中有过介绍,而在KubeAPIServer中,这个notFoundHandler就是注册的APIExtensions APIServer中的GenericAPIServer中的Handler中的Director。 所以,再结合上面的director的处理逻辑,goRestfulContainer没有匹配到路径,则进入到nonGoRestfulMux,nonGoRestfulMux再没有匹配到路径,则进入到notFoundHander进行处理,这就进入到了APIExtensions APIServer的Handler处理逻辑了。 APIExtensions crdHandler最后到了APIExtensions的GenericAPIServer的Handler,我们先来看下它是如何构建的,在CustomResourceDefinitions的New()方法中: 12345678910111213141516171819202122232425262728293031323334353637# apiextensions-apiserver/pkg/apiserver/apiserver.gofunc (c completedConfig) New(delegationTarget genericapiserver.DelegationTarget) (*CustomResourceDefinitions, error) { genericServer, err := c.GenericConfig.New("apiextensions-apiserver", delegationTarget) s := &CustomResourceDefinitions{ GenericAPIServer: genericServer, } ...... delegateHandler := delegationTarget.UnprotectedHandler() if delegateHandler == nil { delegateHandler = http.NotFoundHandler() } crdHandler, err := NewCustomResourceDefinitionHandler( versionDiscoveryHandler, groupDiscoveryHandler, s.Informers.Apiextensions().V1().CustomResourceDefinitions(), delegateHandler, c.ExtraConfig.CRDRESTOptionsGetter, c.GenericConfig.AdmissionControl, establishingController, c.ExtraConfig.ServiceResolver, c.ExtraConfig.AuthResolverWrapper, c.ExtraConfig.MasterCount, s.GenericAPIServer.Authorizer, c.GenericConfig.RequestTimeout, time.Duration(c.GenericConfig.MinRequestTimeout)*time.Second, apiGroupInfo.StaticOpenAPISpec, c.GenericConfig.MaxRequestBodyBytes, ) if err != nil { return nil, err } s.GenericAPIServer.Handler.NonGoRestfulMux.Handle("/apis", crdHandler) s.GenericAPIServer.Handler.NonGoRestfulMux.HandlePrefix("/apis/", crdHandler)} 可以看到,它构建了一个crdHandler,然后将其注册到了CustomResourceDefinitions.GenericAPIServer.Handler.NonGoRestfulMux中,注册的路径为"/apis"或者是以"/apis/"开头的路径。这样当请求自定义的资源时,就会由注册到GenericAPIServer中的NonGoRestfulMux中的crdHandler进行处理,该crdHandler的ServeHTTP()方法又是一个非常复杂的实现,此外还定义了很多Controller,添加到poststarthook中,来对CRD资源进行处理,这里就不介绍了。 PostStartHook Delegation最后,关于PostStartHook还有一个特殊点需要介绍下,KubeAPIServer, APIExtensions APIServer和Aggregator这三个APIServer delegation之后,注册在他们中的poststarthook其实也被delegate了,看下GenericAPIServer的New()方法有下面的代码: 123456789101112# apiserver/pkg/server/config.gofunc (c completedConfig) New(name string, delegationTarget DelegationTarget) (*GenericAPIServer, error) { // first add poststarthooks from delegated targets for k, v := range delegationTarget.PostStartHooks() { s.postStartHooks[k] = v } for k, v := range delegationTarget.PreShutdownHooks() { s.preShutdownHooks[k] = v }} 即当前的GenericAPIServer把它的delegationTarget的PostStartHook添加到了自己的postStartHooks中来,即KubeAPIServer包含了APIExtensions的PostStartHooks,而Aggregator又包含了KubeAPIServer的PostStartHooks,这样,这三者中所有的PostStartHooks就都注册到了Aggregator中,又因为服务启动时,是运行的Aggregator的GenericAPIServer,通过这种方式,将所有的poststarthooks启动起来,而不是分别运行的这三个APIServer中的poststarthooks,所以这些poststarthooks只被启动了一次,并没有重复运行。下面是我盘点的当前版本的Kubernetes中的PostStartHooks: 12345678910111213141516171819202122232425262728293031* APIAggregator * GenericAPIServer // kube-aggregator * delegationTarget // Master.GenericAPIServer * Handler // APIServerHandler * NonGoRestfulMux * NotFoundHandler // Master.GenericAPIServer.Handler.Director * GoRestfulContainer * FullHandlerChain * Director * postStartHooks * // CustomResourceDefinitions * start-apiextensions-informers * start-apiextensions-controllers * crd-informer-synced * // Config * start-kube-apiserver-admission-initializer * // Generic * generic-apiserver-start-informers * priority-and-fairness-config-consumer * // Master * bootstrap-controller * scheduling/bootstrap-system-priority-classes * priority-and-fairness-config-producer * rbac/bootstrap-roles * start-cluster-authentication-info-controller * // Aggregator * start-kube-aggregator-informers * apiservice-registration-controller // 将APIService转换成proxyHandler,注册进NonGoRestfulMux * AddAPIService() * apiservice-status-available-controller * kube-apiserver-autoregistration // 将apiserver+crd中的groupversion通过API注册进apiservice 总结本篇文章在前面文章的基础上,梳理了APIServer的扩展机制,只是梳理了大致脉络,很多无关的细节都省略了,可能还是要结合代码和前面的文章,才能好理解一些,最后我们还是来总结一下: 在aggregator启动的时候,即在createAggregatorServer()方法中,crd和apiserver中定义的资源组(GroupVersion),会通过kube-apiserver-autoregistration poststarthook,被转换成APIService,然后注册进aggregator中,比如将GroupVersion{group: “apps”, version: “v1”}转成APIService,存进数据库中。apiserver中的资源因为是k8s内置的,是固定的,所以只需要在启动的时候,注册一次就可以了,但是CRD中的资源,因为可能会变动,所以需要不断的进行更新同步。 crd + apiserver + aggregator中的资源,都会转换成APIService,然后在apiservice-registration-controller poststarthook 中,以path: proxyHandler的形式,注册进aggregator的GenericAPIServer的NonGoRestfulMux中,只不过crd + apiserver中的资源被转换成的APIService只是形式上的转换,即只包含APIService的name和Kind,apiService.Spec.Service是nil,即是local的资源,这类资源,aggregator并不会proxy给外部的apiextension-apiserver,而是会让delegateHandler去处理,aggregator的delegateTarget是apiserver的Instance.GenericAPIServer,即将请求传给了kube-apiserver去处理。剩下的真正的APIService,则被proxy出去,由外部的APIServer去处理。 整个apiserver的入口是aggregator,run的是aggregator的genericapiserver,请求先到aggregator的GenericAPIServer的Handler,经过一系列handler chain之后,最终到达了director的handler,然后再由其根据path进行分发,因为apiserver+crd中的资源,都以APIService注册进aggregator中的GenericAPIServer中的NonGoRestfulMux,所以根据path,先分发到aggregator中对应的proxyHandler,然后发现是local的,于是再让delegateHandler进行处理,这样就进入到了kube-apiserver的Instance.GenericAPIServer.Handler.Director处理逻辑,k8s内置的标准的资源对象,都是在这里进行处理的,如果在Instance的GenericAPIServer中没匹配到对应的path,则会进入到nonGoRestfulMux的NotFoundHandler中进行处理,这样就进入到了CustomResourceDefinitions.GenericAPIServer.Handler.Director中,CRD的资源都是在这里进行处理的。 从上面的实现机制来看,感觉又有点过度设计的嫌疑,费了很大劲把KubeAPIServer和CRD中的资源都转成了APIService,但是实际上并没有多大的作用,仅仅是判断下是否为local就给delegate出去了,而且把Pod等这类内置的资源转成APIService也跟APIService的定义不符合,APIService的定义应该是一个API服务,要转也是把KubeAPIService以及APIExtension这种级别的APIServer给转成APIService才算合理,所以这种实现方式稍微有点诡异,让人很费解。","link":"/2020/10/08/kubernetes/kube-apiserver-extensions.html"}],"tags":[{"name":"ceph","slug":"ceph","link":"/tags/ceph/"},{"name":"elasticsearch","slug":"elasticsearch","link":"/tags/elasticsearch/"},{"name":"operations","slug":"operations","link":"/tags/operations/"},{"name":"python","slug":"python","link":"/tags/python/"},{"name":"openstack","slug":"openstack","link":"/tags/openstack/"},{"name":"kubernetes","slug":"kubernetes","link":"/tags/kubernetes/"},{"name":"umi","slug":"umi","link":"/tags/umi/"}],"categories":[],"pages":[{"title":"About Me","text":"运维人,自由开发者,聚焦开源云计算基础设施技术。 欢迎加入《开源云计算基础设施》知识星球,在这里可以探讨Linux, OpenStack, Kubernetes, Ceph等开源云计算技术:","link":"/about/index.html"}]}