美团平台的工程效能CI/CD之流水线引擎的建设实践(上)

发表于:2022-10-24 09:56

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:耿杰 春晖 志远    来源:美团技术团队

  1. 背景
  持续交付这个概念最早在2006年敏捷大会上被提出,经过多年的发展,目前已成为很多技术团队提升研发效能的必经之路。通过建设部署流水线,打通从代码开发到功能交付的整个环节,以自动化的方式完成构建、测试、集成、发布等一系列行为,最终实现向用户持续高效地交付价值。
  流水线引擎作为支撑部署流水线的底座,它的好坏直接影响着部署流水线建设的水平。业界通常的做法是通过Jenkins、GitlabCI等开源工具(或公有云产品)进行搭建,这是一条能帮助业务快速落地持续交付的道路,美团早期也是采用搭建Jenkins的方式来快速支撑业务。
  但随着越来越多业务开始做持续交付的建设,这种“短平快”方式的弊端逐渐显现。比如,工具建设没有统一的标准,各业务都需要去了解整个工具链的细节,建设成本高、水平参差不齐,很少有业务能搭建完整的部署流水线。同时,业务每天的构建量都在快速增长,逐渐超过Jenkins等开源工具所能承受的极限,在交付高峰期任务严重排队、服务不可用现象频出,严重影响着业务交付的顺畅度。
  美团在流水线引擎的建设层面大概经历了几个阶段。在2019年以前,主要围绕Jenkins进行优化,2019年开始正式立项打造自研的流水线引擎,大致的历程如下:
  第一阶段(2014-2015):搭建Jenkins统一集群,解决业务接入的通用问题(如单点登录、代码仓库集成、消息通知、执行机的动态扩缩等),降低业务的建设成本。
  第二阶段(2016-2018):拆分多个Jenkins集群,解决业务增长导致单集群性能瓶颈。最多时有十几个集群,这些集群通常是按业务线维度划分,并由业务自行建设。但随着时间的推移,集群的拆分管理难度越来越大,Jenkins安全隐患频出,对平台方造成了很大的运维负担。
  第三阶段(2019-至今):为了彻底解决引擎单机瓶颈和工具重复建设问题,我们开始自研分布式流水线引擎(美团内部项目名称为Pipeline),并逐步收敛各业务依赖的底层基建。
  经过3年左右的建设打磨,流水线引擎完成了服务端的基建统一,涵盖到店、到家、大众点评、美团优选、美团平台、自动配送车、基础研发平台等几乎所有的业务,支持Java、C++、NodeJS、Golang等多种语言。在性能和稳定性方面,引擎每日支撑近十万次的流水线执行量(作业调度峰值每小时达上万次),系统成功率保持在99.99%以上(排除业务代码自身原因和第三方工具的问题)。
  下面我们主要介绍下我们在自研引擎建设上遇到的挑战以及对应的解决方案。
  2. 问题及思路
  2.1 业务介绍
  1)什么是流水线
  我们可以把流水线的执行看作是对代码一步步加工,最终交付到线上的过程。根据业务定义的顺序关系,依次执行相应的加工或质量校验行为(如构建、代码扫描、接口测试、部署工具等),整个执行过程类似一个有向无环图。
图1 流水线概念
  2)基本概念
  组件:出于代码复用和业务共享的考虑,我们将某一工具的操作行为封装成一个组件,表示对于一项具体的加工或校验行为。通过组件方式,业务可以便捷地使用已集成的质量工具(如静态代码扫描、安全漏洞分析等),减少在同一工具上的重复开发成本;对于不满足需求的场景,业务可以自定义一个新的组件。
  组件作业:表示组件的一次运行实例。
  资源:为组件作业分配的一个可执行环境。
  流水线编排:表示流水线中不同组件执行的先后顺序。
  引擎:负责调度所有的组件作业,为其分配相应的执行资源,保证流水线执行按预期完成。
  2.2 主要挑战
  1)调度效率瓶颈
  对调度时间相对敏感,流水线大部分是短时作业(作业持续数十秒到分钟不等),如果调度时间过长,业务能明显感知到流水线执行变慢了。我们需要保证作业调度时间在一个可控的范围内,避免出现调度瓶颈。
  从业务场景考虑,调度逻辑存在一定的业务复杂性(如组件串并行判断、优先级抢占、降级跳过、复用上一次结果等),不仅仅是作业与资源的匹配计算,作业调度耗时存在一定的业务开销。
  引擎支撑公司每天近十万次的执行量,峰值量情况下,并发调度的作业量大,常见的开源工具(Jenkins/GitLab CI/Tekton等)都是采用单体调度模式,作业是串行调度的,容易出现调度瓶颈。
  2)资源分配问题
  对于作业系统来说,作业数通常都是大于资源数的(真实部署情况,资源不是无限的),作业积压是系统设计时必须考虑的问题。如何在有限的资源下,尽可能提高作业的吞吐能力,同时降低在资源不足情况时造成对核心业务场景的影响。
  如果只依靠动态扩容,容易出现资源不足时无法扩容、作业排队等待的情况。特别是对于依赖流水线做研发卡控的业务,这会直接阻塞业务的上线流程。
  出于执行耗时的考虑,大部分资源采用预部署的方式,缩短资源申请和应用启动的准备时间。而对于预部署的资源,如何进行有效划分,既保证每类资源都有一定配额,同时也避免出现部分资源利用率过低,影响作业整体的吞吐能力。
  不是所有工具的执行资源都由引擎管理(如发布系统,部署任务的资源管理是单独的),在作业的资源分配上,还需要考虑不同的资源管理方式。
  3)工具差异化问题
  公司内不同业务的差异化大,涉及的质效类工具众多,如何设计一个合适的插件化架构,满足不同工具的接入需求。
  不同工具实现形式差异化大,有些工具有独立的平台,可以通过接口方式进行集成,有些仅仅是一段代码片段,还需要提供相应的运行环境。面对不同的接入形态,引擎如何屏蔽不同工具带来的差异,使业务在编排流水线时不用关注到工具的实现细节。
  随着业务场景的不断丰富,组件执行还会涉及人工交互(审批场景)、支持重试、异步处理、故障恢复等能力,这些能力的扩展如何尽可能减少对系统的冲击,降低实现的复杂度。
  2.3 解决思路
  1)拆分调度决策与资源分配,解决调度效率瓶颈
  从上述分析,一个作业的实际调度耗时 = 单个作业的调度耗时 * 待调度的作业数。因为单个作业的调度耗时会受具体的业务逻辑影响,不确定性大,优化空间有限。而串行调度问题相对明确,在作业调度时间和数量不可控的情况下,是一个合适的优化方向。
  关于串行调度,业界常见的做法是按照业务线维度拆分多个集群,分摊总的调度压力。但这种方式存在的问题是资源分配不具备灵活性,很容易出现资源的分配不均,在整体资源不足时,无法从全局上考虑高优作业的资源分配。并且,多集群管理(新增集群/拆分现有集群)也是不小的运维负担。
  进一步分析,串行调度主要是为了避免资源竞争问题,获得相对最优的资源。这对于流水线场景(作业量大于资源量且都是短时作业),资源最优解不是强诉求。并且,资源量的并发度相对作业量更可控,根据作业执行快慢不同,我们通过主动拉取作业的方式,控制拉取的数量和频率,从而有效降低了资源竞争的情况。
  最终,我们在设计上采取了调度决策与资源分配分离的模式:
  调度决策:负责计算出可以调度的作业,提交决策,等待合适的资源来执行。该模块具体水平扩展,分担调度决策的压力。
  资源分配:负责维护作业与资源的关系,通过主动拉取作业的方式,资源可以向任意的实例拉取作业,取消了原先串行分配资源的单点限制。
  在这种模式下,作业调度、资源分配都具备水平扩展能力,拥有更高的性能和系统可用性。也利于作业调度的逻辑能够独立演进,便于开发、测试以及灰度上线。
  2)引入资源池管理模式,实现资源的灵活分配
  考虑到不是所有资源都由引擎管理,我们引入资源池的概念来屏蔽不同资源方式的差异,每个资源池代表一类资源的集合,不同资源池的资源管理方式可以是多样化的。通过该方式,我们将资源分配的问题简化为作业与资源池的匹配问题,根据作业的实际情况,合理设置不同的资源池大小,并配合监控手段对资源池进行动态调整。
  在具体措施上,我们选择“标签”的方式建立作业与资源池的匹配关系,通过从作业与资源两个维度来满足上述条件。
  在作业端,作业基于标签属性拆分到不同的作业队列,并引入优先级概念,保证每个队列中作业按优先级高低被拉取到,避免在积压时,高优作业排在后面无法被及时处理,阻塞业务研发流程。
  在资源端,结合资源的实际场景,提供三种不同的资源池管理方式,以解决不同资源类型的配额和利用率问题。
  预置的公共资源,这部分资源会提前在资源池上扩容出来,主要应对业务高频使用的且对时间敏感的组件作业。在资源配额和利用率上,根据资源池的历史情况和实时监控,动态调整不同资源池的大小。
  按需使用的资源,主要针对公共资源环境不满足的情况,业务需要自定义资源环境,考虑到这部分作业的体量不大,直接采用实时扩容的方式,相比预置资源的方式,可以获得更好的资源利用率。
  外部平台的资源,这些资源的管理平台方比我们更有经验,平台方通过控制向引擎拉取作业的频率和数量,自行管理作业的吞吐情况。
  3)引入组件的分层设计,满足工具差异化需求
  为了保持工具接入的自由度,引擎提供了作业维度最基本的操作接口(拉取作业、查询作业状态、上报作业结果),不同工具可以根据作业接口形式实现定制化的组件开发。
  组件开发主要涉及①实现业务逻辑和②确定交付方式两部分工作,而与引擎的系统交互相对是标准的。我们根据组件执行过程进行分层设计,拆分出业务逻辑、系统交互与执行资源三层。在向引擎屏蔽工具实现细节的同时,可以更好地满足多样化的接入场景。
  系统交互层,该层相对组件开发者是透明的,根据引擎提供的接口制定统一的流程交互标准,以向引擎屏蔽不同组件的实现差异。
  执行资源层,主要解决工具运行方式的差异化,通过支持多种组件交付形式(如镜像、插件安装、独立服务)满足工具与引擎的不同集成方式。
  业务逻辑层,针对业务不同的开发场景,采用多种适配器的选择,来满足业务不同的开发诉求。
  3. 整体架构
图2 流水线架构
  触发器:作为流水线的触发入口,管理多种触发源及触发规则(Pull Request、Git Push、API 触发、定时触发等)。
  任务中心:管理流水线构建过程中的运行实例,提供流水线运行、中止、重试、组件作业结果上报等操作。
  决策者:对所有等待调度的作业进行决策,并将决策结果同步给任务中心,由任务中心进行作业状态的变更。
  Worker:负责向任务中心拉取可执行的作业,并为作业分配具体的执行资源。
  组件SDK:作为执行组件业务逻辑的壳,负责真正调起组件,完成组件初始化与状态同步的系统交互。
  4. 核心设计点
  4.1 作业调度设计
  1)调度过程
  下面,我们以一个简单的流水线调度示例(源码检出 - [并行:代码扫描,构建] - 部署),来介绍调度设计中各模块的协作过程。
图3 调度过程
  大致逻辑如下:
  当触发流水线构建后,系统会在任务中心创建该编排所要执行的所有组件作业。并且将作业状态的变化以事件方式通知决策者进行决策。
  决策者接收决策事件,根据决策算法计算出可被调度的作业,向任务中心提交作业的状态变更请求。
  任务中心接收决策请求,完成作业状态变更(作业状态变更为已决策),同时加入相应的等待队列。
  Worker 通过长轮询方式拉取到和自己匹配的等待队列的作业,开始执行作业,执行完成后将结果上报给任务中心。
  任务中心根据Worker上报的作业执行结果变更作业状态,同时向决策者发起下一轮决策。
  以此反复,直至流水线下所有作业都已执行完成或出现作业失败的情况,对流水线进行最终决策,结束本次执行。
  整个过程中,任务中心作为一个分布式存储服务,统一维护流水线和作业的状态信息,以API方式与其他模块进行交互。而决策者和Worker通过监听作业状态的变化执行相应的逻辑。
  2)作业状态流转
  下面是一个作业完整的状态机,我们通过作业决策、拉取、ACK以及结果上报一系列事件,最终完成作业从初始状态向完结状态的流转过程。
  状态机在接收某种状态转移的事件(Event)后,将当前状态转移至下一个状态(Transition),并执行相应的转移动作(Action)。
图4 状态机
  在实际场景中,由于调度过程涉及链路长、各环节稳定性无法完全保证,容易产生因异常情况导致状态不流转的情况。为此,在设计上利用数据库保证状态变更的正确性,同时为非完结状态作业设立相应的补偿机制,确保任一环节异常后作业可以恢复正确流转。
  我们重点从作业决策和作业拉取这两个关键过程来看状态流转过程可能出现的问题,以及在设计上是如何解决的。
  作业决策过程:任务中心接收调度作业的决策,将可调度的作业从unstart变为pending状态,同时将作业加入等待队列,等待被拉取。
图5 状态机-决策
  未收到决策事件:由于决策者服务自身的问题或网络原因,导致决策事件的请求失败,作业长时间处于未调度状态。
  解决方案:引入定时监测的机制,对于无过程状态作业且处于未完结状态的流水线进行重新决策,避免决策服务短时间异常导致决策失败。
  重复决策:由于网络延迟、消息重试现象可能出现多个决策者同时决策同一个作业,产生作业转移的并发问题。
  解决方案:增加pending的状态表示作业已被决策到,并通过数据库乐观锁机制进行状态变更,保证仅有一个决策会真正生效。
  状态变更过程异常:由于存在异构数据库,状态变更和加入队列可能存在数据不一致,导致作业无法被正常调度。
  解决方案:采用最终一致性的方案,允许调度的短暂延迟。采用先变更数据库,再加入队列的操作顺序。利用补偿机制,定时监测队列队首的作业信息,若pending状态下的作业有早于队首作业的,进行重新入队操作。
  作业拉取过程:任务中心根据Worker拉取作业的事件请求,从等待队列中获取待调度作业,将作业的状态从pending变更为scheduled,并返回给Worker。
图6 状态机-ACK
  作业丢失问题:这里存在两种情况,①作业从队列中移除,但在状态将要变更时异常了;②作业从队列中移除,也正确变更了状态。但由于poll请求连接超时,未正常返回给Worker。
  解决方案:前者通过作业决策环节中对pending状态的作业补偿机制,重新加入队列;后者对于状态已变更的情况,已调度的作业增加ACK机制,若超时未确认,状态会流转回pending状态,等待被重新拉取。
  作业被多个Worker拉取:Worker在接收到作业后,遇到长时间的GC,导致状态流转回pending状态,在Worker恢复后,可能出现作业已分配到另一个Worker上。
  解决方案:通过数据库乐观锁机制保证仅有一个Worker更新成功,并记录作业与Worker的关系,便于对作业进行中止以及Worker故障后的恢复操作。
  3)决策过程
  决策过程是从所有未启动的作业中筛选出可以被调度的作业,通过一定的顺序将其提交给任务中心,等待被资源拉取的过程。整个筛选过程可以分为串并行顺序、条件过滤、优先级设置三部分。
图7 决策过程
  串并行顺序:相对于DAG中复杂的寻路场景,流水线场景比较明确,是将代码逐步加工验证,通过开发、测试、集成、上线等一系列阶段的过程。阶段间是严格串行的,阶段内出于执行效率的考虑,会存在串并行执行的情况。这里通过模型设计,将DAG的调度问题转变成作业的先后次序问题,引入run order概念,为每个组件作业设置具体的执行次序,根据当前已执行作业的次序,快速筛选出下一批次序仅大于当前的作业,若并行执行,仅需将作业的次序设置成相同即可。
图8 串并行决策
  条件过滤:随着业务场景扩展,不是所有的作业都需要调度资源,进行真正的执行。如某类耗时的组件,在代码和组件参数都不变的情况下,可以直接复用上一次的执行结果,或者在系统层面针对某类工具异常时进行组件跳过的降级操作。针对这类情况,在作业真正提交给任务中心之前,会增加一层条件判断(条件分为全局设置的系统条件以及用户条件),这些条件以责任链形式进行依次匹配过滤,根据匹配到的条件单独向任务中心提交决策。
  优先级设置:从系统全局考虑,在作业出现积压时,业务更关心核心场景下整条流水线是否能尽早执行完成,而不是单个作业的排队情况。所以,在优先级设置上除了基于时间戳的相对公平策略外,引入流水线类型的权重值(如发布流水线>自测流水线;人工触发>定时执行),保证核心场景流水线相关作业能够尽早被调度到。
  本文内容不用于商业目的,如涉及知识产权问题,请权利人联系51Testing小编(021-64471599-8017),我们将立即处理
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号