持续交付这个概念最早在2006年敏捷大会上被提出,经过多年的发展,目前已成为很多技术团队提升研发效能的必经之路。通过建设部署流水线,打通从代码开发到功能交付的整个环节,以自动化的方式完成构建、测试、集成、发布等一系列行为,最终实现向用户持续高效地交付价值。
流水线引擎作为支撑部署流水线的底座,它的好坏直接影响着部署流水线建设的水平。业界通常的做法是通过Jenkins、GitlabCI等开源工具(或公有云产品)进行搭建,这是一条能帮助业务快速落地持续交付的道路,美团早期也是采用搭建Jenkins的方式来快速支撑业务。
但随着越来越多业务开始做持续交付的建设,这种“短平快”方式的弊端逐渐显现。比如,工具建设没有统一的标准,各业务都需要去了解整个工具链的细节,建设成本高、水平参差不齐,很少有业务能搭建完整的部署流水线。同时,业务每天的构建量都在快速增长,逐渐超过Jenkins等开源工具所能承受的极限,在交付高峰期任务严重排队、服务不可用现象频出,严重影响着业务交付的顺畅度。
美团在流水线引擎的建设层面大概经历了几个阶段。在2019年以前,主要围绕Jenkins进行优化,2019年开始正式立项打造自研的流水线引擎,大致的历程如下:
经过3年左右的建设打磨,流水线引擎完成了服务端的基建统一,涵盖到店、到家、大众点评、美团优选、美团平台、自动配送车、基础研发平台等几乎所有的业务,支持Java、C++、NodeJS、Golang等多种语言。在性能和稳定性方面,引擎每日支撑近十万次的流水线执行量(作业调度峰值每小时达上万次),系统成功率保持在99.99%以上(排除业务代码自身原因和第三方工具的问题)。
下面我们主要介绍下我们在自研引擎建设上遇到的挑战以及对应的解决方案。
1)什么是流水线
我们可以把流水线的执行看作是对代码一步步加工,最终交付到线上的过程。根据业务定义的顺序关系,依次执行相应的加工或质量校验行为(如构建、代码扫描、接口测试、部署工具等),整个执行过程类似一个有向无环图。
图1 流水线概念
2)基本概念
1)调度效率瓶颈
对调度时间相对敏感,流水线大部分是短时作业(作业持续数十秒到分钟不等),如果调度时间过长,业务能明显感知到流水线执行变慢了。我们需要保证作业调度时间在一个可控的范围内,避免出现调度瓶颈。
2)资源分配问题
对于作业系统来说,作业数通常都是大于资源数的(真实部署情况,资源不是无限的),作业积压是系统设计时必须考虑的问题。如何在有限的资源下,尽可能提高作业的吞吐能力,同时降低在资源不足情况时造成对核心业务场景的影响。
3)工具差异化问题
公司内不同业务的差异化大,涉及的质效类工具众多,如何设计一个合适的插件化架构,满足不同工具的接入需求。
1)拆分调度决策与资源分配,解决调度效率瓶颈
从上述分析,一个作业的实际调度耗时 = 单个作业的调度耗时 * 待调度的作业数。因为单个作业的调度耗时会受具体的业务逻辑影响,不确定性大,优化空间有限。而串行调度问题相对明确,在作业调度时间和数量不可控的情况下,是一个合适的优化方向。
关于串行调度,业界常见的做法是按照业务线维度拆分多个集群,分摊总的调度压力。但这种方式存在的问题是资源分配不具备灵活性,很容易出现资源的分配不均,在整体资源不足时,无法从全局上考虑高优作业的资源分配。并且,多集群管理(新增集群/拆分现有集群)也是不小的运维负担。
进一步分析,串行调度主要是为了避免资源竞争问题,获得相对最优的资源。这对于流水线场景(作业量大于资源量且都是短时作业),资源最优解不是强诉求。并且,资源量的并发度相对作业量更可控,根据作业执行快慢不同,我们通过主动拉取作业的方式,控制拉取的数量和频率,从而有效降低了资源竞争的情况。
最终,我们在设计上采取了调度决策与资源分配分离的模式:
在这种模式下,作业调度、资源分配都具备水平扩展能力,拥有更高的性能和系统可用性。也利于作业调度的逻辑能够独立演进,便于开发、测试以及灰度上线。
2)引入资源池管理模式,实现资源的灵活分配
考虑到不是所有资源都由引擎管理,我们引入资源池的概念来屏蔽不同资源方式的差异,每个资源池代表一类资源的集合,不同资源池的资源管理方式可以是多样化的。通过该方式,我们将资源分配的问题简化为作业与资源池的匹配问题,根据作业的实际情况,合理设置不同的资源池大小,并配合监控手段对资源池进行动态调整。
在具体措施上,我们选择“标签”的方式建立作业与资源池的匹配关系,通过从作业与资源两个维度来满足上述条件。
3)引入组件的分层设计,满足工具差异化需求
为了保持工具接入的自由度,引擎提供了作业维度最基本的操作接口(拉取作业、查询作业状态、上报作业结果),不同工具可以根据作业接口形式实现定制化的组件开发。
组件开发主要涉及①实现业务逻辑和②确定交付方式两部分工作,而与引擎的系统交互相对是标准的。我们根据组件执行过程进行分层设计,拆分出业务逻辑、系统交互与执行资源三层。在向引擎屏蔽工具实现细节的同时,可以更好地满足多样化的接入场景。
图2 流水线架构
1)调度过程
下面,我们以一个简单的流水线调度示例(源码检出 - [并行:代码扫描,构建] - 部署),来介绍调度设计中各模块的协作过程。
图3 调度过程
大致逻辑如下:
整个过程中,任务中心作为一个分布式存储服务,统一维护流水线和作业的状态信息,以API方式与其他模块进行交互。而决策者和Worker通过监听作业状态的变化执行相应的逻辑。
2)作业状态流转
下面是一个作业完整的状态机,我们通过作业决策、拉取、ACK以及结果上报一系列事件,最终完成作业从初始状态向完结状态的流转过程。
状态机在接收某种状态转移的事件(Event)后,将当前状态转移至下一个状态(Transition),并执行相应的转移动作(Action)。
图4 状态机
在实际场景中,由于调度过程涉及链路长、各环节稳定性无法完全保证,容易产生因异常情况导致状态不流转的情况。为此,在设计上利用数据库保证状态变更的正确性,同时为非完结状态作业设立相应的补偿机制,确保任一环节异常后作业可以恢复正确流转。
我们重点从作业决策和作业拉取这两个关键过程来看状态流转过程可能出现的问题,以及在设计上是如何解决的。
作业决策过程:任务中心接收调度作业的决策,将可调度的作业从unstart变为pending状态,同时将作业加入等待队列,等待被拉取。
图5 状态机-决策
未收到决策事件:由于决策者服务自身的问题或网络原因,导致决策事件的请求失败,作业长时间处于未调度状态。
重复决策:由于网络延迟、消息重试现象可能出现多个决策者同时决策同一个作业,产生作业转移的并发问题。
状态变更过程异常:由于存在异构数据库,状态变更和加入队列可能存在数据不一致,导致作业无法被正常调度。
作业拉取过程:任务中心根据Worker拉取作业的事件请求,从等待队列中获取待调度作业,将作业的状态从pending变更为scheduled,并返回给Worker。
图6 状态机-ACK
作业丢失问题:这里存在两种情况,①作业从队列中移除,但在状态将要变更时异常了;②作业从队列中移除,也正确变更了状态。但由于poll请求连接超时,未正常返回给Worker。
作业被多个Worker拉取:Worker在接收到作业后,遇到长时间的GC,导致状态流转回pending状态,在Worker恢复后,可能出现作业已分配到另一个Worker上。
3)决策过程
决策过程是从所有未启动的作业中筛选出可以被调度的作业,通过一定的顺序将其提交给任务中心,等待被资源拉取的过程。整个筛选过程可以分为串并行顺序、条件过滤、优先级设置三部分。
图7 决策过程
图8 串并行决策
1)整体方案
我们采用多队列的设计,结合标签建立作业队列与资源池的匹配关系,以保障不同队列资源的有效划分,在出现队列积压、资源池故障、无可扩资源等情况时,最大限度地降低影响范围,避免所有作业全局排队等待的现象。
图9 资源池架构
2)模型关系
图10 资源池模型对象
作业队列与标签的关系:队列与标签采用1对1的关系,降低业务理解和运维成本。
标签与资源池的关系:标签和资源池采用多对多的关系,主要从资源整体利用率和对核心队列的资源可用性保障考虑。
3)标签设计
标签的目的是建立资源(池)与作业(队列)的匹配关系。在设计上,为便于标签管理和后期维护,我们采用二维标签的形式,通过组件和流水线两个维度,共同决定一个作业所属标签及对应的资源。
注:每个维度都会设一个other的默认值用来兜底,用于处理无资源划分需求的场景。
图11 标签设计
4)队列拆分设计
根据作业所属标签不同拆分出多个队列,保证每个队列的独立性,降低作业积压的影响范围。整个拆分过程可以分为入队和出队两部分:
图12 队列拉取设计
1)分层架构
图13 组件架构设计
2)标准的交互流程设计
在系统交互层,组件与引擎交互的过程中,有两个环节是确定的,①组件作业的状态机流转,这涉及到组件执行的整个生命周期管理,若允许存在不同的状态流转关系,整个管理过程会十分混乱;②引擎对外提供的接口范围,从服务间解耦的角度,对外提供的接口主要是组件作业维度的接口操作,不应该耦合任何组件内部的实现细节。
结合作业状态机 + 引擎提供的接口,确定了组件执行基本的系统交互流程。利用模版模式,抽象出init()
、run()
、queryResult()
、uploadArtifacts()
等必要方法供业务实现,整个交互流程则由系统统一处理,业务无需关心。
图14 组件标准流程设计
3)扩展基础能力
组件执行除了正常的执行流程外,随着业务场景的丰富,还会涉及组件中止、回调(人工审批场景)等操作,这些操作的引入势必会改变原先的交互流程。为了不增加额外的交互复杂度,在拉取作业环节,增加作业的事件类型(运行、中止、回调等事件),Worker根据拉取到的不同事件,执行相应的扩展逻辑。同时,引入新的扩展也不会影响到已有的交互流程。
图15 组件扩展能力设计
基于上述扩展,我们可能更好地将一些通用能力下沉到Daemon Thread层。如结果查询流程,通过守护线程的方式,取消了原先同步等待的查询限制,这对于需要异步化处理的场景(如组件作业逻辑已执行完,仅在等待外部平台接口返回结果)可以提前释放资源,提高资源执行的利用率。并且,当执行资源故障重启后,结果查询线程会自动恢复待处理异步作业。这部分能力的支持在业务层是透明的,不改变整个交互流程。
4)引入适配器
业务虽可以通过必要方法完成自定义组件,但这些方法过于基础,业务在一些特定场景下实现成本较高。如对于组件支持Shell的脚本化调用,业务其实仅需提供可执行的Shell即可,通用约定的方式,其他必要方法的实现都可以交由系统完成。
针对业务个性化的处理,采用适配器模式,通用引入不同Command(ShellCommand、xxCommand)来默认实现特定场景下的必要方法,降低业务的开发成本。同时,保持系统侧流程的一致性,通过动态注入 Command的方式,防止对业务个性化处理的耦合。
图16 组件适配器设计
5)效果
目前已支持Shell组件、服务组件、容器组件等多种接入方式,平台上已提供数百个组件,组件开发方涉及数十个业务线。组件库覆盖源码域、构建域、测试域、部署域、人工审批域等多个环节,打通了研发过程所涉及的各个基础工具。
图17 组件库
耿杰、春晖、志远等,来自研发质量与效率部研发平台团队。