作者简介
周光明,携程高级技术经理,目前负责携程 CI/CD 系统,致力于通过技术手段提高公司研发质量与效率, 对 Docker,K8s,Gitlab,Jenkins 等 DevOps 技术有浓厚的兴趣,Ruby 语言狂热爱好者。
*本文来自周光明在DevOps国际峰会上的分享*
一、携程持续交付
我们目前有 8000 多个应用,研发人员 3000 多位,每天在各个环境上部署的次数有 6000 多次,持续交付对于我们来说是一个非常重要的能力。
持续交付的意义,首先是效率的提升。部署是一个很麻烦的事情,如果是有多个环境需要部署,部署的难度也会直线上升。这时候如果有一个工具去做这样的事情,研发人员就可以将更多的精力投入到研发它的功能上面,让产品的迭代更加迅速。
第二是质量保障,我们在持续交付的过程中穿插了一些代码扫描、单测或者集成测试的过程,可以让整个产品的质量在交付过程中得到很好的保障,也可以让我们在交付的时候更加有信心。
第三是安全可靠,如果没有机器就要人工跑上去进行部署,会对线上系统增加很多误操作的隐患。
第四是团队协作,传统交付模型从产品讨论到上线需要经过很长时间,有可能出现一个现象,在开发阶段的时候开发人员在闷头写代码,测试人员没有什么事情做,到了测试阶段这个现象又会反过来。如果我们采用小步快走的方式,可以让各个团队之间的协作更加紧密和紧凑。
最后是流程更加透明,因为我们使用的是统一的规范、统一的工具,在交付过程当中的每个细节都可以被暴露出来。不管谁多写一个 bug 或者少写一个单测都会被系统记录下来。
下图是目前我们简单的交付流程,首先是研发人员 Push 代码,扫描单测集成测试,再将结果反馈,之后创建一个版本,版本是什么概念待会再说。
创建版本之后进行打包,再部署到测试环境,部署成功之后我们会通知周边的自动化测试平台或者性能平台,项目测试人员、QA根据测试结果进行审批工作,就可以将项目部署到下一个测试环境或者生产环境当中。
对于研发阶段来说,我们目前主要推崇的分支管理模型是 Master 分支和 Feature 分支,多个Feature分支可以同时进行功能的开发,并且可以被临时合并到一个分支。之所以这样做,是因为这样可以让代码的冲突在合并之前就暴露出来并提前解决。
如果这个时候线上有一些紧密的 bug 要修复,也可以通过Hotfix 分支提交代码,Hotfix 分支被 Merge 回 Master 之后也会被 Merge 到上面提到的临时分支中。
接下来解释一下刚才提到的版本概念,我们知道很多开源软件是使用Git Tags作为开源版本的,因为一个 Git Tag 可以快速找到仓库某一时刻的内容。如果一些项目比较复杂,可能会有一些其它代码的依赖。因此我们可以将源码构建打包成一个版本,打包的东西就会比使用Git Tag 更加准确。
因为有些代码依赖,不是一个特定版本号,可能是一个范围。上个月打包出来的结果和今天打包出来的结果就会不一致,那么部署就增加很多不稳定的因素。
第三点是在一些比较特殊的项目里面,除了语言依赖之外,还会有环境依赖。
在容器没有出现之前,我们将环境依赖的过程写到项目原码的脚本,通过部署运行多个程序安装那些依赖。但是有了容器之后,我们就可以将环境的依赖也作为版本的因素,容器就可以很好地帮我们解决这个问题。
因此一个明确版本的概念,对于交付来说也是相当重要的。
下面介绍一下我们这些年的部署模型的演进。
2015年之前我们使用虚拟机做单机多应用的部署。对于运维来说成本相当高,2015年我们重新做了一个发布系统,也是主推单机单应用的部署模型。2016年开始研究容器,但是将容器作为一个虚拟机的方式,我们叫它“胖容器”,以这样的方式部署应用。
整个部署过程,总体来说还是有一些复杂的。我将几个比较关键的概念整理出来。
首先是Group,一组暴露同一服务的集合,对于单机单应用,我们可以简单理解是一组机器,Group也是我们部署的基本单元。
第二是拉入拉出,Group 中中的某个成员是否接受流量和请求,流量可能来自SLB或者消息系统推送的消息。
第三是堡垒机,是指生产环境Group中第一台被发布验证的机器,有点像金丝雀部署模型中金丝雀的角色。
第四是点火,是指应用初始化、预热、加载数据等过程,我们认为点火成功才是应用部署成功的一个最终状态。
第五是分批,我们将同一个Group分成多个批次进行滚动部署,减少线上变更对于线上的影响。
第六是降级,刚才也提到降级的事情,比如我们的发布需要对应用进行拉入拉出,如果这个时候SLB 出现了故障,并且有应用需要紧急发布,我们可以通过降级的方式忽略拉入拉出,虽然会丢失一些线上流量,但是可以保证应用被成功的部署到生产上,因此也减少了线上的损失。
第七是刹车,刹车是如果线上的部署失败的机器大于一个比例,我们都会停止部署行为,人工排查到底是什么原因,是需要回滚还是修改Bug之后打另外一个版本进行修复。
第八是回滚,回滚的概念就不用多说了,我们需要稳定的符合预期的回滚逻辑。
部署过程,首先是拉出堡垒机,部署堡垒机成功之后需要点火,进行测试验证后拉入堡垒机,堡垒机会作为一台正常机器进行工作。
这些都没问题之后我们再将剩余批次进行滚动部署,滚动部署的时候发现部署失败的机器比较多就需要进行刹车判断。
目前我们的 PaaS 平台上支持了测试和生产多个环境的资源管理,这些资源当中既有容器,也有虚拟机,甚至还有物理机的管理,因此我们的后端需要对接 OpenStack、Mesos 等管理平台。
目前我们可以将资源放在私有云的多个数据中心,也可以将资源放在像 AWS 公有云之上。
下面说一下环境管理,对于功能测试我们有一个FAT的环境,FAT又分成多个FAT子环境,可以满足用户同时进行多个功能测试的需求。在FAT之上有一个FWS环境,它是一个更加稳定的FAT环境。
对于性能测试也是有多套性能测试环境。FAT环境部署成功之后,需要 QA 人员的测试验收,才可以将应用发布到UAT环境,UAT是一个相对更加接近生产的测试环境。最后是生产环境。
我们可以看到刚才的流程图上很大一部分工作是通过统一构建平台实现的,接下来介绍下统一构建平台。相信很多同学是Jenkins用户或者爱好者,先说说Jenkins。
首先 Jenkins 非常的方便,一个 War包就可以轻松搞定部署这件事情。Jenkins已经发展了很多年,非常的成熟稳定,插件也非常丰富,基本上满足各种各样的需求。当然这些来自社区活跃人员,强大的 Pipeline 可以将配置转化成代码,也是大大增强了我们的生产力。
但 Jenkins 也不是完美的,也有一些问题,其中就有单点故障和单机性能的问题。我们是怎么看待和解决这两个问题的?
先是单点故障,很多团队都是采用一主一备的 Jenkins 模式,出现故障的时候需要以切换的方式将故障转移,稍微成熟一点的团队会用Keepalived+Virtual IP 保证 HA。还有可以将Jenkins打包放在Mesos或K8S上面,也可以购买CloudBees服务,比较省心一点。
解决了单点故障的问题,Jenkins Master的上线总归是有限的,随着业务的增长每天的数量越来越成为负担。官方提供了几个维度拆分Jenkins Master的方式,分别是从环境、组织结构、产品线、插件可制定性、人员访问权限控制、出现故障时的影响等几个方面,分析了它的利弊。当然每个团队各自的情况不一样,需要根据各自的情况作出决策。
拆分了之后,要预估一下 Jenkins Master 的单台机器的承载能力,这边也提供一个比较有意思的公式。
根据研发人员的数量预估Job、Master 和 executors 的数量,根据这个公式大概推导有多少个Job和Master。我们每天大概是20000次构建数量,包括编译打包镜像等任务,管理了40000多个Jobs ,这些Jobs 跑在几十个 Jenkins Master上。
我们选择自己做一个平台满足大量的运维工作,自己动手丰衣足食。
先看一下构建系统的整体架构,也是一个比较简单、比较传统的架构模型,在上层封装了一层API层,负责各种类型的构建请求,在Worker层,将每种构建类型调度到不同的Jenkins Master上。调度到Jenkins Master之后,就是Jenkins Master发挥自己能力的时候了。
接下来看下Worker层处理了哪些事情。有些同学可能会疑惑,为什么我们有这么多Jobs?最早的时候我们不是按照这样的方式,是按照每种类型一个Job,这样的好处是可以维护比较少的Job。但是这样也有一些缺点,比如说我要更新Job配置的时候,影响范围太大,可能几千个应用都是依赖一个Job。
还有就是大家都共用一个Job,构建现场的保留也会成问题。当前面一个构建失败以后,下一个构建就会把前面的构建 Workspace 冲掉。此外保留了 Workspace 之后,也可以加快代码下载的速度。
当一个Job创建的时候或者每次构建任务进来的时候,我们都会对比当前Job的配置是不是最新的可用的,如果不是就将它更新。如果目前线上没有这个Job,我们就会根据配置模版创建一个新的Job,有了这个机制就可以将Jenkins Master作为一个没有状态的服务来看待。
接下来我们参考了Labeling模型,可以根据标签匹配找到满足条件的Master。也可以把Job与Master标签进行匹配。做到这些之后下面的工作就会比较容易,我们在系统中同时注册了多个Master,势必有多台Master满足构建条件。
此外我们还在Master上配置了容量配比,比如说新建Job时按照Master容量可以承接多少数量。最后是故障转移,当构建任务进来的时候看之前用过的Master是不是健康的,如果健康会优先把它调度到这台上面,如果不健康会选择另外条件满足的Master。我们也会做Master的检测,如果某台Master不健康会拉出整个Master集群。
我们在多个维度做了监控和告警,第一个维度是操作系统层面的,也就是一些常规的指标,像CPU之类的。
第二个是应用系统层面的,包括API层的可用性、Worker可用性、Jenkins可用性等等。
第三个是业务逻辑层面的,主要检测的是比如说每一个构建队列是否堵塞,系统容量是否达到瓶颈,因为我们对每台Master都做了容量预估,希望当有大面积的构建请求进来的时候,可以提早知道进行扩容。
除此之外还有Pipeline关键Step是否超时,进行容器调度的时候,是不是创建时间比我们预期的要长等,接下来会细讲如何进行容器调度。
这是构建系统的简单界面,首页包含了目前各种状态的构建数量,还有一些简单的统计。比如构建系统中所有Job的列表情况,包括它之前构建了多少次、它是什么类型的。以及构建的任务情况,当前Jenkins Master线上集群的情况,包括支持的类型情况、监控指标等等。
接下来是我们如何使用K8S进行Jenkins管理。
首先是Jenkins集成的演进,跟我们刚才看到的应用集成演进是类似的,但是时间上面稍微比他们快一些,因为在公司级别技术演进的时候经常会使用Jenkins作为一个试验田,因为它也比较合适。
接下来主要讲以下两个方面,第一是Slave弹性调度,第二是Workspace的问题,我们看一下为什么存在这两个问题以及如何处理好。
下面是单日构建数量以及容量数量趋势图。每一种类型根据调用频繁程度和特性,配置出合适的Podidle Minutes参数,控制Slave保留时间。
可以看到构建数量趋势明显比容器数量趋势缓和一些。我们的容器没有实时的创建和销毁,这样既满足我们对弹性调度的要求,也不会让整个系统的性能受到太大的影响。
既然实现了弹性调度,对于每个Slave创建的时间我们是特别关心的,因为它不像静态Slave可以有请求进来就可以直接拿来用。但是我们发现采用弹性调度的方式之后,Slave的创建逻辑并不总是符合预期。
举个例子,当在空闲的时候,Jenkins可以创建Slave并且正常执行构建任务,但是隔了几秒钟又有一个新的任务进来了就需要等一段时间。不知道有多少同学发现过这个问题?
我们首先梳理了一遍调度的逻辑,通过改变上面的几个参数,是可以达到目的的。接下来介绍一下几个参数的逻辑,有些人可能是对这个逻辑比较清楚的。
首先是initialDelay,它是动态调度等待连接静态Slave的时间参数,在我们集群的Jenkins Master是没有任何静态Slave的,所以我们将这个参数设置成0。
第二个参数是Decay,它是Jenkins负载统计公式中指数移动平均值中的平滑指数,我刚才说的现象是因为Jenkins在内部维护多个负载情况的序列,这些序列的数据有等待队列数量、各种状态的数量等等。它们的值是通过EMA公式计算出来的,比如说history0是前一时刻的值,等待队列数量就是0。
这个时候来了一个数量就是1,假设decay是0.2,那么计算出来的值负载值就是0.8。我们可以观察到decay的值越大,当前负载越接近实际值。因为我们不希望Jenkins的保守创建逻辑增加整体的构建时间,因此需要让负载统计中的值更加接近于当前的实际情况。但是这个值也不能太小,如果太小Jenkins就过于敏感,有一个请求过来就帮你创建,还没创建好下一次还会创建一个,这样浪费很多资源。
第三个公式是Jenkins判断是否创建Slave的不等式,不等式右边为什么是1-m呢?m是作为一个参数来用的,如果根据EMA的值计算,它是永远不会等于1的,只是会无限接近于1,因此我们需要一个偏移量控制它是不是应该创建Slave。
m的公式也和上面三个参数有关的,最后的参数是totalSnapshot,这是当前可以用的 Excutor 数量,经过调整这三个参数可以很好的工作。
经过长时间的观察,我们发现这样一个调整对于创建参数是比较平稳的,也没有太大波动。创建一个Slave大概是20多秒时间,因为采用的调度方式不是立即创建和消毁,所以每天大概有几十个创建时间,相对于每天的构建数量是可以被接受的。
长时间运行之后,我们还会发现有个别的Slave的创建时间会超过5分钟,这是为什么呢?一开始我也不知道,所以我们又重新梳理了一下整个创建流程,也是找到了其中的原因。Jenkins的调度逻辑是通过一个轮训逻辑做的,遍历Labels。
如果在系统当中这个Labels是没有出现过的,它要创建一个新的Labels,它是不会更新Labels集合的。但是Jenkins每隔5分钟会更新一次Labels集合,最后我们在创建中Label时主动调用reset方法解决这个问题。
目前已经运行了差不多一年时间,后来刚刚看到那些问题再也没有出现过了。
接下来讲一下Workspace保留问题,什么是Workspace保留问题?Workspace对于整个系统来说是非常重要的。首先用户排障需要现场,另外我们通过复用Workspace可以减少下载源码的次数。
但是一旦Workspace消毁之后,之前没有做任何处理,在这个Slave上面所有数据就跟着一起被消毁了。我们首先想到的方式是将上面的数据挂载到Slave上面,消毁也不会有影响,下一次重新被挂载进去。
但是这样只解决了一个问题,Slave不会被删,但是通过Jenkins Master怎么看?我们目前的做法是让Slave与Master在同一个Node且共享同一Workspace,通过Master查看Workspace的能力,查看上面运行的Slave Workspace。
但是我们遇到一个问题,一个Job同一时间只能在一个Master上面运行,因为我不可能把一个目录同时给两个Master,这样可能会产生无法预期的结果。
解决这个问题的方法是,通过上层来做一个调度,将之前Jenkins Master控制并发的这一阶段放在系统上面。因为Job已经拆分得很细了,因此对于单个Job的并发需求来说不算大。
接下来再介绍一下我们是如何通过StatefulSet管理Jenkins Master的,是不是可以通过StatefulSet维护Jenkins Master集群,因为我们希望更加自动化,所以解决了以下两个问题。
第一是Job会对之前成功运行的Jenkins Master有亲和性,它会默认跑到之前运行过的Master,因此我们为了尽可能的使用之前的Workspace,需要将Master pod尽可能固定在Node上面。
我们做了一个调度器,创建出来的Master pod后面有序号,可以很好的映射到每一台机器上面。
第二是我们需要将上面的目录既挂载在Master,又挂载到Slave上面,因此创建集群的时候需要事先将挂载目录准备好,所以我们也是开发了一个插件CHostpath Volume Driver。除了这些之外,还将Java依赖的东西也是放在上面,每次创建Slave的时候也可以挂载到Slave上面,可以提高构建的性能。
有了这些之后,再看一下整个集群创建的流程,其实只要维护一个StatefulSet就可以了。首先是创建一个StatefulSet,后面创建、更新或者扩容的时候都要创建一个Pod,再创建出Volume,再创建容器,运行Entrypoint。因为刚才提到Jenkins Job的配置,将Jenkins Master的配置放在上面也是支持版本的控制。
下载下来到本地初始化目录,因为初始化之后有些配置需要根据当前机器的情况做修改,比如说IP,最后启动Jenkins,向构建系统注册实例,StatefulSet更新完之后只需要在系统里面拉入就可以对外配比服务了,中间不需要做过多手工干预。
上面大概介绍了持续交付、构建平台、Jenkins on K8S 使用实践,接下来说一下问题与改进。
第一是多环境应用镜像问题,我们现在是一个环境有一个镜像,为什么会这样?因为在早些年没有配置中心的时候,配置是写在代码里的,在发布的时候会根据每个环境将配置重新做修改,再打成包,再打成镜像。
当然这样就会造成比较多的问题,比如环境之间的差异,因为一个应用的发布最好以镜像作为版本,这样就会作为测试环境与其它测试环境不一致,也会降低发布效率。
第二是资源混部,我刚刚说的整个构建系统的调度,Master是非常必要的,我们希望未来构建系统的机器在业务比较紧张的时候,可以使用其它的业务计算资源,在晚上构建空闲的时候可以腾出来给其他的业务跑Job,这样也可以提升整个数据中心的资源利用率。
【推荐阅读】
前端如何实现业务解耦,携程酒店查询首页的1.0到3.0
揭秘 Vue 3.0 最具潜力的 API
每天十亿级数据更新,秒出查询结果,ClickHouse在携程酒店的应用
强化学习在携程酒店推荐排序中的应用探索
携程机票Sketch插件开发实践