作者:耿杰春晖致远经过近3年的建设打磨,美团流水线引擎已经完成了服务端的基础设施统一,支持近万次流水线执行,系统成功率保持在99.99%以上。一、背景持续交付的概念最早是在2006年的敏捷大会上提出的,经过多年的发展,已经成为很多技术团队提高研发效率的必由之路。通过构建部署流水线,打通从代码开发到功能交付的全链路,自动化完成构建、测试、集成、发布等一系列行为,最终实现持续高效交付对用户的价值得以实现。管道引擎作为支撑部署流水线的基础,直接影响部署流水线建设的水平。业界通常的做法是使用Jenkins、GitlabCI等开源工具(或公有云产品)来构建。这是一条可以帮助业务快速实现持续交付的道路。美团在早期也是通过搭建Jenkins的方式来快速支撑业务的。然而,随着越来越多的业务开始构建持续交付,这种“短平快”方式的弊端也逐渐显现。比如工具构建没有统一的标准,每个业务都需要了解整个工具链的细节。建设成本高,水平参差不齐。很少有企业可以构建完整的部署管道。与此同时,业务每天的构建量也在快速增长,逐渐超过了Jenkins等开源工具所能承受的极限。交付高峰期,任务排队严重,服务频繁不可用,严重影响业务交付的顺畅性。美团在管道引擎的建设上大概经历了几个阶段。2019年之前,主要专注于优化Jenkins。2019年正式立项,打造自研流水线引擎。大致流程如下:第一阶段(2014-2015):搭建Jenkins统一集群,解决业务接入常见问题(如单点登录、码仓集成、消息通知、执行机动态伸缩等。)降低企业建设成本。第二阶段(2016-2018):拆分多个Jenkins集群,解决业务增长带来的单个集群性能瓶颈。最多有十几个集群,这些集群通常按照业务线的维度划分,由业务自己搭建。但随着时间的推移,集群的拆分和管理难度越来越大,Jenkins安全隐患频频出现,给平台造成了很大的运维负担。第三阶段(2019年至今):为彻底解决引擎单机瓶颈和重复工具构建问题,我们开始研发分布式流水线引擎(美团内部项目名称为Pipeline),并逐渐在底层收敛每个业务所依赖的基础设施。经过3年左右的建设和打磨,流水线引擎已经完成了服务端基础设施的统一,几乎覆盖了门店配送、宅配、大众点评、美团优选、美团平台、自动配送车、基础研发等所有业务平台。支持Java、C++、NodeJS、Golang等语言。在性能和稳定性方面,引擎每天支持近10万次流水线执行(作业调度峰值达到每小时数万次),系统成功率保持在99.99%以上(不含业务代码原因)本身和第三方工具)问题)。下面我们主要介绍一下我们在自研发动机建设中遇到的挑战以及相应的解决方案。二、问题与思路1、业务介绍1)什么是流水线我们可以把流水线的执行看作是一步步处理代码,最终交付到流水线的过程。根据业务定义的顺序关系,依次执行相应的处理或质量验证行为(如构建、代码扫描、接口测试、部署工具等)。整个执行过程类似于有向无环图。图1流水线概念2)基本概念组件:考虑到代码复用和业务共享,我们将某个工具的运行行为封装成一个组件,代表一个特定的处理或验证行为。通过组件,业务可以方便地使用集成质量工具(如静态代码扫描、安全漏洞分析等),减少在同一个工具上重复开发的成本;对于不符合需求的场景,商家可以定制一个新的组件。ComponentJob:表示一个组件的运行实例。资源:为组件作业分配的可执行环境。流水线排列:表示流水线中不同组件的执行顺序。Engine:负责调度所有组件作业,为其分配相应的执行资源,确保流水线按预期完成执行。2.主要挑战1)调度效率瓶颈对调度时间比较敏感。大多数流水线都是短期作业(作业持续几十秒到几分钟)。如果调度时间过长,业务可以明显感知流水线执行变慢。.我们需要保证作业调度时间在可控范围内,避免调度瓶颈。考虑到业务场景,调度逻辑具有一定的业务复杂度(如组件的串并判断、优先级抢占、降级跳过、上次结果复用等),不仅是作业和资源的匹配计算,还耗时的作业调度有一定的业务开销。该引擎支持公司每天近10万次的执行量。在高峰量的情况下,并发调度的工作量很大。常见的开源工具(Jenkins/GitLabCI/Tekton等)均采用单机调度模式,作业串行调度,容易出现调度瓶颈。2)资源分配问题对于操作系统来说,作业的数量通常大于资源的数量(实际部署情况,资源不是无限的),系统设计时必须考虑作业积压。如何在资源有限的情况下最大化作业的吞吐量,同时减少资源不足时对核心业务场景的影响。如果只靠动态扩容,很容易在资源不足,作业排队等待的时候扩容失败。尤其对于依赖流水线进行研发卡控的业务,这会直接阻塞业务的上线流程。考虑到执行耗时,大部分资源都被预先部署,以缩短资源申请和应用启动的准备时间。对于预部署的资源,如何进行有效的划分,既可以保证每一类资源都有一定的配额,又可以避免部分资源利用率低,影响作业的整体吞吐量。并不是所有的工具执行资源都由引擎来管理(例如发布系统和部署任务的资源管理是分开的),作业的资源分配需要考虑不同的资源管理方式。3)工具差异化问题公司不同业务差异较大,涉及的质量和效率工具较多。如何设计合适的插件架构来满足不同工具的接入需求。不同工具的实现形式差异很大。有的工具有独立的平台,可以通过接口集成,有的只是一段代码,需要提供相应的运行环境。面对不同的接入形式,引擎如何屏蔽不同工具带来的差异,让业务在布局流水线时无需关注工具的实现细节。随着业务场景的不断丰富,组件执行也会涉及到人机交互(审批场景)、支持重试、异步处理、故障恢复等能力。如何扩展这些能力,最大限度地减少对系统的影响,降低实现的复杂度。3、解决思路1)拆分调度决策和资源分配,解决调度效率瓶颈由以上分析,一个作业的实际调度时间=单个作业的调度时间*需要调度的作业数。因为单个作业的调度时间会受到具体业务逻辑的影响,不确定性大,优化空间有限。串行调度问题比较明确,在作业调度时间和数量不可控的情况下是一个合适的优化方向。对于串行调度,业界普遍的做法是按照业务线维度拆分多个集群,分担总调度压力。但是这种方式的问题是资源分配不灵活,容易出现资源分配不均的情况。在整体资源不足的情况下,不可能从全局的角度来考虑优质岗位的资源配置。而且,多集群管理(新建集群/拆分现有集群)也是不小的运维负担。进一步分析可知,串行调度主要是为了避免资源竞争,获得相对最优的资源。对于流水线场景(工作量大于资源量,都是短期作业),最优资源方案的吸引力不强。而且,资源的并发比作业的数量更可控。根据作业执行的快慢,主动拉取作业,控制拉取的次数和频率,有效减少资源竞争。最终我们采用了调度决策和资源分配分离的设计:调度决策:负责计算可以调度的作业,提交决策,等待合适的资源执行。该模块专门横向扩展,分担调度决策的压力。资源分配:负责维护工作和资源之间的关系。通过主动拉取作业,资源可以从任意实例拉取作业,取消了原来串行资源分配的单点限制。在这种模式下,作业调度和资源分配可以水平扩展,具有更高的性能和系统可用性。也有利于作业调度逻辑的自主演化,便于开发、测试、灰度上线。2)引入资源池管理模式,实现资源的灵活配置。考虑到并非所有资源都由引擎管理,我们引入资源池的概念来屏蔽不同资源方式的差异。每个资源池代表一种资源的集合,不同的资源池资源可以有多种管理方式。这样,我们将资源分配问题简化为作业与资源池的匹配问题。我们可以根据作业的实际情况,合理设置不同资源池的大小,并通过监控方式动态调整资源池。在具体措施上,我们选择“标签”的方式建立职位与资源池的匹配关系,从职位和资源两个维度满足上述条件。在作业端,根据标签属性将作业拆分到不同的作业队列,并引入优先级的概念,保证每个队列中的作业按照优先级拉取,避免优质作业排在后面积压时无法及时处理,阻塞业务研发进程。在资源端,结合资源的实际场景,提供三种不同的资源池管理方式,解决不同资源类型的配额和使用问题。A。预置公共资源,这些资源会提前在资源池上扩展,主要处理高频业务使用和时效性强的组件操作。在资源配额和利用率方面,根据历史情况和资源池的实时监控,动态调整不同资源池的大小。b.按需资源主要针对公共资源环境不满足,业务需要定制资源环境的情况。考虑到这部分作业体量不大,直接采用实时扩容的方式,相对于预置资源的方式。,以更好地利用资源。C。外部平台上的资源。这些资源的管理平台比我们更有经验。该平台通过控制从引擎中提取作业的频率和数量来自行管理作业的吞吐量。3)引入组件层次化设计,满足工具差异化需求。为了保持工具访问的自由度,引擎在作业维度(拉取作业、查询作业状态、上报作业结果)提供了最基本的操作接口。不同的工具可以根据作业界面的形式实现定制化的组件开发。组件开发主要涉及两部分:①实现业务逻辑和②确定交付方式,而系统与引擎的交互相对规范。我们按照组件执行流程进行分层设计,分为三层:业务逻辑、系统交互和执行资源。在对引擎屏蔽工具实现细节的同时,更能满足多样化的接入场景。对组件开发者透明的系统交互层,根据引擎提供的接口制定统一的流程交互标准,屏蔽引擎对不同组件的实现差异。执行资源层主要解决工具运行方式的差异,通过支持多种组件交付形式(如镜像、插件安装、独立服务等),满足工具和引擎的不同集成方式。业务逻辑层,针对不同的业务开发场景,采用多种适配方案,满足不同的业务开发需求。3.总体架构图2流水线架构触发器:作为流水线的触发器入口,管理着多种触发源和触发规则(PullRequest、GitPush、API触发器、定时触发器等)。任务中心:管理流水线构建过程中的运行实例,提供流水线运行、暂停、重试、组件作业结果上报等操作。DecisionMaker:对所有等待调度的作业进行决策,并将决策结果同步到任务中心,从而改变作业状态。Worker:负责从任务中心拉取可执行的作业,并为作业分配具体的执行资源。组件SDK:作为执行组件业务逻辑的shell,负责实际调用组件,完成组件初始化和状态同步的系统交互。4.核心设计要点1.作业调度设计1)调度流程下面,我们以流水线调度的一个简单例子(源码检出-【并行:代码扫描、构建】-部署)来介绍各个模块在调度中的协作设计过程。图3中调度流程的大致逻辑如下:①当管道构建被触发时,系统会在任务中心创建所有需要编排执行的组件作业。并将工作状态的变化以事件的形式通知给决策者进行决策。②决策者接收到决策事件,根据决策算法计算出可以调度的作业,向任务中心提交作业的状态变更请求。③任务中心收到decision请求,完成job状态改变(job状态变为decired),同时加入对应的等待队列。④Worker通过长轮询在等待队列中拉取与自己匹配的作业,开始执行作业,执行完成后将结果上报给任务中心。⑤任务中心根据Worker上报的作业执行结果改变作业状态,同时向决策者发起下一轮决策。⑥如此反复,直到pipeline下的所有job都执行完或者出现job失败,对pipeline做出最终决定,结束本次执行。在整个过程中,任务中心作为一个分布式存储服务,统一维护管道和作业的状态信息,并通过API与其他模块进行交互。决策者和Worker通过监控工作状态的变化来执行相应的逻辑。2)作业状态流程下面是一个完整的作业状态机。我们通过作业决策、拉取、ACK、结果上报一系列事件,最终完成作业从初始状态到完成状态的流程。状态机接收到一个状态转移事件(Event)后,将当前状态转移到下一个状态(Transition),并执行相应的转移动作(Action)。图4状态机在实际场景中,由于调度过程中涉及较长的链路,无法充分保证各链路的稳定性,很容易因异常情况导致状态不流动。为此,在设计中使用数据库来保证状态变化的正确性,并对未完成状态的操作设置相应的补偿机制,保证在任何一个环节出现异常后,操作都能恢复正确的流程。我们重点关注工作决策和工作拉动这两个关键过程,看看状态流过程中可能出现的问题,以及如何在设计中解决这些问题。①作业决策过程:任务中心收到调度作业的决策,将可调度作业从unstart状态变为pending状态,同时将作业加入等待队列,等待被拉取。图5状态机-DecisionNotReceivedDecisionEvent:由于决策者服务本身问题或网络原因,请求decisionevent失败,job长期处于未调度状态。解决方案:引入时序监控机制,对没有进程状态作业且处于未完成状态的流水线进行重新决策,避免决策服务出现短期异常导致的决策失败。重复决策:由于网络延迟和消息重试,多个决策者可能同时对同一个作业进行决策,导致作业转移的并发问题。解决方案:增加pending状态表示job已经决定,通过数据库的乐观锁机制改变状态,保证只有一个decision会真正生效。异常状态变化过程:由于异构数据库的存在,状态变化和队列添加之间可能存在数据不一致,导致作业无法正常调度。解决方案:采用最终一致性方案,允许在调度上有短暂的延迟。采用先改库再加入队列的操作顺序。使用补偿机制定时监控队列头部的作业信息。如果处于pending状态的作业早于队列头部的作业,则重新入队。②作业拉取过程:任务中心根据拉取作业的worker的事件请求,从等待队列中获取待调度的作业,将作业的状态由pending变为scheduled,返回给worker。图6状态机-ACK作业丢失问题:这里有两种情况,①作业从队列中移除,但状态即将发生变化时出现异常;②作业从队列中移除,状态也正确改变。但是由于poll请求连接超时,没有正常返回给Worker。解决方案:前者通过作业决策环节pending状态的作业补偿机制重新加入队列;后者在状态发生变化时,为调度的作业添加ACK机制。如果超时后仍未确认,则状态会流回pending状态。等待重新获取。作业被多个Worker拉取:Worker收到作业后,遇到长时间的GC,导致状态流回到pending状态。Worker康复后,工作可能会被分配给另一个Worker。解决方案:使用数据库乐观锁机制,保证只有一个Worker更新成功,记录job与Worker的关系,方便Job的挂起和Worker失败后的恢复操作。3)决策过程决策过程是从所有未启动的作业中筛选出可以调度的作业,按照一定的顺序提交给任务中心,等待资源拉取的过程。整个筛选过程可以分为串并序、条件过滤、优先级设置三个部分。图7决策过程的串行和并行序列:相对于DAG中复杂的寻路场景,流水线场景相对清晰。是通过开发、测试、集成、上线等一系列阶段逐步对代码进行处理和验证的过程。阶段是严格串行的,为了执行效率,阶段内会有串行和并行执行。这里通过模型设计,将DAG调度问题转化为作业序列问题,引入运行顺序的概念,为每个组件作业设置特定的执行顺序,并根据顺序快速筛选出下一批作业ofcurrentlyexecutedjobs仅大于当前作业。如果并行执行,只需要将作业的顺序设置为相同即可。图8串行并行决策条件过滤:随着业务场景的扩展,并不是所有作业都需要调度资源真正执行。比如对于某类耗时组件,在代码和组件参数不变的情况下,可以直接复用上一次的执行结果,或者在系统层面,当某类工具出现异常时,可以将组件跳过和降级。对于这样的情况,在作业真正提交给任务中心之前,会增加一层条件判断(条件分为全局设置的系统条件和用户条件)。这些条件以责任链的形式依次进行匹配和过滤。分别向任务中心提交决定。优先级设置:从系统整体来看,当作业出现积压时,业务更关心核心场景下整个流水线能否尽快执行,而不是单个作业的排队。因此,除了在优先级设置上采用基于时间戳的相对公平的策略外,引入流水线类型的权重值(如发布流水线>自测流水线;手动触发>定时执行),保证核心场景流水线-可以尽快安排相关工作。.2.资源池划分设计1)总体方案采用多队列设计,结合标签建立作业队列与资源池的匹配关系,保证资源在不同队列中的有效划分。在资源等情况下,尽量缩小影响范围,避免所有作业全局排队。图9资源池架构2)模型关系图10资源池模型对象①作业队列与标签的关系:队列与标签采用一对一的关系,减少业务理解和运维成本。当队列积压时,可以快速定位到某个标签没有资源。当标签资源不足时,也能快速判断受影响的具体队列情况。②标签与资源池的关系:标签与资源池采用多对多的关系,主要考虑资源的整体利用率和核心队列的资源可用性保证。对于一些作业量较小的队列,单独分配一个资源池会导致大部分时间资源处于空闲状态,资源利用率较低。通过为资源池分配多个标签,我们不仅可以保证队列有一定的资源配额,还可以处理其他标签的作业,提高资源利用率。对于核心场景的队列,通常会将标签资源分配到多个资源池中,以保证资源冗余,降低单个资源池整体故障的影响。3)标签设计标签的目的是建立资源(池)和作业(队列)之间的匹配关系。在设计上,为了便于标签管理和后期维护,我们采用二维标签的形式,通过组件和流水线两个维度共同确定一个作业的标签和对应的资源。第一个维度:组件维度,一般对资源进行划分。结合组件的业务覆盖、作业执行量、对机器和环境(如SSD、Dev环境等)的特殊要求,标记需要独立资源的组件,划分不同的公共资源池(每个公共资源池执行一个或者更多类型的组件作业),并在引擎层面统一分发,保证所有作业都能正常运行。第二个维度:流水线维度,按业务场景划分。结合业务对资源隔离/作业积压敏感性的需求,按需划分。一些希望资源完全独立的服务,会从所有公共资源池中切分出来;有些资源只需要在一些核心场景下得到保障,根据链路中涉及的组件,从一些公共资源池中选择性地划分。实现业务隔离和资源利用的平衡。注意:每个维度都会设置一个默认值other来覆盖底线,用于处理没有资源划分需求的场景。图11标签设计4)队列拆分设计根据作业的不同标签拆分多个队列,保证各个队列的独立性,减少作业积压的影响。整个拆分过程可以分为enqueue和dequeue两部分:enqueue过程:通过计算job在component和pipeline两个维度的属性值来确定job对应的label。结合模型关系中标签和队列(1:1)的关系,按需为每个标签创建一个队列,存储标签作业,对不同队列之间的作业做独占处理,简化出队的实现复杂度。出队流程:队列拆分后,由于标签和资源池的关系(多对多),一个资源池的jobpullrequest往往会涉及到多个队列。为了拉取效率,采用轮询的方式对单个队列依次进行出队操作,直到达到本次请求的作业数上限或者所有可选队列为空,返回结果。这种方式可以避免同时锁定多个队列,在pre-stage环节随机排序多个tag,降低多个请求同时操作一个队列的竞争概率。图12队列拉取设计3.组件分层设计1)分层架构图13组件架构设计业务层:引入适配层以满足组件开发中多样化的需求场景,同时避免上层差异对下层的污染。系统交互层:建立统一的流程标准,保证引擎和组件交互流程的一致性,方便非功能性系统优化的统一处理。执行资源层:提供多种资源策略,对上层屏蔽不同资源类型的差异。2)标准交互流程设计在系统交互层,组件与引擎交互过程中,确定了两个环节。①组件运行的状态机流程涉及组件执行的全生命周期管理。如果任其存在状态流关系不同,整个管理流程会很混乱;②引擎提供的接口范围,从服务间解耦的角度,对外提供的接口主要是组件操作维度的接口操作,不应耦合到任何组件细节的内部实现。结合作业状态机+引擎提供的接口,确定组件执行的是基本的系统交互流程。使用模板方式抽象出业务实现所需的init()、run()、queryResult()、uploadArtifacts()等方法,整个交互过程由系统统一处理,业务不需要照顾。图14组件标准流程设计3)扩展基础能力除了组件执行的正常执行流程外,随着业务场景的丰富,还会涉及到组件挂起、回调(人工审批场景)等操作。这些操作的引入必然会改变原有的交互流程。为了不增加额外的交互复杂度,在拉取作业链接中添加作业事件类型(running、aborting、callback等事件),Worker根据拉取的不同事件执行相应的扩展逻辑。同时,引入新的扩展不会影响现有的交互流程。图15Component扩展能力设计基于以上扩展,我们或许可以更好地将一些通用能力下沉到DaemonThread层。例如在结果查询过程中,通过守护线程的方式取消了原来同步等待的查询限制,对于需要异步处理的场景(比如组件job逻辑已经执行完,只等待),可以提前释放资源供对外平台接口返回结果),以提高资源执行的利用率。而且,当执行资源失败重启时,结果查询线程会自动恢复挂起的异步作业。这部分能力的支持在业务层是透明的,不需要改变整个交互流程。4)适配器业务的引入虽然可以通过必要的方法完成自定义组件,但是这些方法过于基础,在某些特定场景下业务实现成本比较高。例如,对于支持shell脚本调用的组件,业务实际上只需要提供一个可执行的shell,其他必要的方法的实现可以由系统按照普遍约定的方式完成。针对个性化业务的处理,采用适配器模式,一般引入不同的Command(ShellCommand、xxCommand),默认实现特定场景下的必要方法,降低业务开发成本。同时保持系统端流程的一致性,通过动态注入Commands防止个性化业务处理的耦合。图16组件适配器设计5)效果目前支持Shell组件、服务组件、容器组件等多种访问方式。平台已提供上百个组件,组件开发者涉及数十条业务线。组件库涵盖源代码域、构建域、测试域、部署域、人工审批域等多个环节,打通了研发过程中涉及的各种基础工具。图17组件库五、后续规划借助Serverless等云原生技术,探索更轻量级、更高效的资源管理方案,提供更精细化的资源策略,从资源弹性、启动加速、业务加速三个方面提供业务服务环境隔离。更好的资源托管能力。面向组件开发者,提供从开发、上线到运营的一站式开发管理平台,降低组件开发和运营成本,让更多的工具方和个人开发者参与进来,共同打造丰富多样的业务场景,形成良性的组件运行生态。
