掌门持续交付流水线大规模实践

CD 平台是掌门的持续交付系统,流水线功能自 2020 年 3 月在 CD 平台上正式开放,目前已稳定运营一年多,超过 900 个应用启用了流水线功能,应用接入率超 60%,下图展示了流水线功能的关键指标。可以说流水线已经成为了 CD 平台的一个核心能力,极大地帮助研发提升了持续交付的效率。

背景

2019 年 3 月掌门完成自主持续交付系统 CD 平台的研发,并在 2019 年 9 月将掌门主流技术栈的应用全面接入,实现了发布的收口。我们实现了应用从创建、上线、迭代到最终下线的生命周期管理,为每个独立应用提供了从构建到发布的权限、审批、审计等流程管理。随着公司业务和研发规模的不断发展,CD 平台上每天产生 1800+ 新的版本包,全环境执行的发布数量有 1400+。

在引入流水线功能以前,以上这些操作都需要平台用户在页面上手工操作,伴随着规模的不断扩大,大量的手工操作成了用户的负担,对效率的负面影响也愈加变得明显。我们发现用户对流水线需求在逐渐增大,同时作为效能团队,在通过工具、流程保证了高质量的发布之后,我们的关切点也很自然地回到增效上。我们希望减少 CD 平台对研发日常开发的干扰,让研发流程更顺畅自然。

于是流水线被提上议程。我们需要实现一种流水线来支持 GitOps,减少用户对 CD 平台的操作依赖,实现流程无侵入的流水线交付。研发人员只需要将代码提交到 GitLab,随即便能触发自动的 CD 流程。下面介绍我们的实现方案。

流水线实现方案

以下将从方案选型,架构介绍,扩展性说明,UI 交互和流程闭环 5 个维度来详细介绍实现方案。

方案选型

彼时掌门主要的运行环境还是虚机,同时也开始同步推进容器化的基础设施升级。

考虑到虚机在很长一段时间内依然会掌门基础设施的重要组成部分,我们没有采纳纯粹的云原生方案。另外在参考了一些公司的成熟实践后,我们也结合掌门研发的现状梳理了对流水线的功能需求。按照灵活程度的不同,我们把流水线分为基于模板和自定义两种类型,前者能更好地配合规范的落地且对使用者而言更易上手,后者为使用者开放了更强大的能力和自由度但也会导致较高的学习门槛。最终我们在易用性、功能、维护性间做了平衡,决定提供更符合掌门实际需求的模板流水线方案。

我们并不打算从头写一个 workflow 引擎,毕竟业界已经有太多成熟的轮子可供选择,我们最后选择了 Jenkins。首先 Jenkins Pipeline 功能于我们已然够用,Groovy 也是相对比较易上手的语言。更重要的是 CD 平台的整套构建系统是基于 Jenkins 打造的,这意味着我们已经有一个 Jenkins 集群,也积累了较为丰富的运维管理经验。

架构介绍

解决了 workflow 引擎的问题,我们就可以将工作聚焦到上层设计,下图展示了流水线功能的分层设计架构,业务逻辑相关的模块和抽象层实现由 CD 平台自己实现,流水线的执行由 Jenkins 负责。抽象层用于将流水线和 workflow 引擎的解耦,让我们复用 Jenkins 能力的同时保留了其他扩展性,方便以后尝试引入更适合容器的云原生流水线方案。

抽象层的核心是渲染模块和同步模块,渲染模块需要将输入转化成后端能识别的脚本,同步模块需要对接后端的 API,所以这两个模块需要为对应的流水线后端进行适配,在我们的场景里就是 Jenkins。模板和变量确定一个流水线的行为,通过渲染模块生成 Jenkins Pipeline 脚本,同步模块负责将脚本文件同步至 Jenkins 服务生成一个 Jenkins Pipeline。理论上,如果要引入新的流水线引擎,便需要在渲染和同步模块增加对应后端的适配。抽象层设计上也符合 infrastructure-as-code 的原则,本质上即便我们新搭建一套 Jenkins 集群,基于“流水线脚本”就能迅速自动化还原出所有的流水线任务。

每一个流水线都对应 Jenkins 上的一个 pipeline 类型的任务,为了方便管理,我们按 #{app-id}-pipeline-#{pipeline-id} 做为 Jenkins project 的命名规范。这套流程也充分考虑了后期更新的需求,尤其是在后期流水线规模庞大了之后,每次的模板更新出错都可能造成大范围的执行失败,目前我们可以针对一些特定应用打灰度标记,在模板的上线环节引入灰度过程,保证全量发布前有个小范围的灰度验证,对于错误版本能及时回滚。

扩展性说明

以下展示了一个经典的 Java 应用 Git 触发 => 打包 => FAT 发布 流水线对应的 Jenkins Pipeline 脚本,从代码里可以看出:

  1. Pipeline 脚本只做 workflow 的编排,不负责具体步骤的实现

  2. 基于 CD 平台接口来推进整个 workflow 的执行

因为核心逻辑不在 Pipeline 脚本中实现,理论上只要支持 workflow 编排的工具,便都能作为我们的流水线引擎。另外具体的执行逻辑由 CD 端实现,即复用了 CD 的原子能力,也让流水线脚本自身彻底解耦,虽然随着 CD 平台的演进我们在原子能力上做了很多的调整和优化,但是流水线始终能很顺畅地工作,两组开发人员在迭代各自功能时不需要有所顾虑。

// JENKINS PIPELINE EXAMPLE: Build => FAT Deploy  
// predefined variables, value assignment made by CD pipeline renderer  
def pipeline_id = 3182  
def app_id = 13207  
def fat_group_id = 13032  
def api_url = "http://{cd-platform}/api/v1"  
...  
  
// variables will be used by following pipeline steps  
def pkg_id  
def build_result  
def pipeline_activity_id  
def fat_deploy_id  
def fat_deploy_status  
  
  
pipeline {  
    agent {label "pipeline"}  
    stages {  
        stage('build') {  
            steps {  
                // call cd to create package  
                script {  
                    def payload = """  
                        {"create_username":"$creator", "pipeline_id":"$pipeline_id", "application_id":"$app_id" ...}  
                    """  
                    def jsonResponse = postRestCall("$api_url/packages/", payload)  
                    pkg_id = jsonResponse.data.id  
                }  
            }  
        }  
        stage('log execution') {  
            steps {  
                retry(3) {  
                    // call cd to create a pipeline activity for tracing pipeline execution  
                    script {  
                        def body = """  
                            {"pipeline_id": "$pipeline_id", "package_id": "$pkg_id", ...}  
                        """  
                        def jsonResponse = postRestCall("$api_url/pipelines/$pipeline_id/activities/", payload)  
                        if (jsonResponse.http_code != 201) {  
                            error "call cd error"  
                        }  
                        pipeline_activity_id = jsonResponse.data.id  
                    }  
                    sleep(3)  
                }  
                echo "log pipeline activity successful:)"  
            }  
        }  
        stage('check build status') {  
            steps {  
                // polling cd api to check build result  
                timeout(time: 10, unit: 'MINUTES') {  
                    retry(20) {  
                        script {  
                            sleep(20)  
                            def jsonResponse = getRestCall("$api_url/packages/$pkg_id/", payload)  
                            buildChecker(jsonResponse)  
                        }  
                    }  
                }  
                script {  
                    if (build_result == 'FAILURE') {  
                        error('Stop early by building package failure…')  
                    }  
                }  
            }  
        }  
        stage('deploy') {  
            steps {  
                // call cd to deploy package on specific fat group  
                script {  
                    def version_name = sh(returnStdout: true, script: "date '+CI-%Y%m%d%H%M%S'").trim()  
                    def payload = """  
                        {"app_id": $app_id, package_id": $pkg_id, "group_id": $fat_group_id, "pipeline_activity_id": $pipeline_activity_id ...}  
                    """  
                    def jsonResponse = postRestCall("$api_url/deployments/", payload)  
                    fat_deploy_id = jsonResponse.data.id  
                }  
            }  
        }  
        stage('check deploy status') {  
            steps {  
                // polling cd api to check deploy status  
                timeout(8) {  
                    waitUntil {  
                        sleep(15)  
                        script {  
                            def jsonResponse = getRestCall("$api_url/deployments/${fat_deploy_id}/")  
                            fat_deploy_status = jsonResponse.data.tars_status  
                              
                            return (fat_deploy_status == "SUCCESS" || "$fat_deploy_status".endsWith("FAILURE") || fat_deploy_status == "REVOKED")   
                        }  
                    }  
                }  
            }  
        }  
    ...  
}

UI 交互

我们的用户交互界面与底层架构基本一脉相承,得益于底层架构的简单清晰,交互逻辑也做到了简洁明了。创建流水线有两步操作,分别对应上图中的模板和变量,不同的模板支持不同的变量设置。当一个流水线创建成功之后,模板就锁定了,但是变量部分依然可以通过编辑功能进行修改。

在流水线详情页,除了可以编辑变量,还可以查看流水线的执行记录,可以查看具体的执行步骤,查看哪个步骤导致执行失败。CD 上除了独立的流水线入口,得益于平台的自研属性,我们可以将流水线功能与 CD 平台的持续交付做进一步整合,比如通过流水线触发的打包和发布记录都可以在 CD 平台的相关功能模块直接进行展示,再比如操作的权限和审计功能都可以沿用原有功能等。

流程闭环

流水线所有的操作入口,包括流水线的创建、配置、执行记录的查看等都在 CD 平台上完成,不对外暴露 Jenkins,对用户而言,使用的服务依然是一站式的。以一个经典的流水线执行流程为例,用户所有的流水线管理都在 CD 平台上发生。当用户通过 CD 平台设置好流水线后,便能把自己从 CD 上的手工操作中解放出来。下图的这个例子中,用户只需要跟 Git 交互,然后关注流水线执行结果。通常只有收到流水线执行异常的通知,才会到 CD 上看具体的错误原因,这样便减少了打包、发布等操作对开发过程的干扰。

流水线功能推广

酒香也怕巷子深,即便我们对于流水线功能充满了信心,但是要推广用户接受并高频使用依然要做出很多额外的努力。

在功能对外开放之前,我们在团队内部就已经试用了一段不短的时间。我们要求研发效能自己的 Java 和 H5 应用在日常开发中坚持使用流水线功能,我们很快就确认了流水线对效能的提升,同时基于团队内部反馈,我们也对底层逻辑进行了不少优化。可以说最初的信心正是来自于早期的吃狗粮经历。下图是我们开放功能后的每周流水线任务执行次数统计趋势图,我把整个推广过程可以分为 4 个阶段:种子用户期,冷启动期,稳定增长期和爆发期。

种子用户期

作为效能团队,我们格外重视对用户的赋能,对功能的自助化是我们一贯的追求和原则。仅仅底层健壮还不够,必须降低用户的使用门槛,所以我们组织了一批早期用户进行试用。这个时期除了解决一些未发现的 Bug,主要是打磨交互逻辑和界面细节。非常幸运 CD 平台一直有一批核心用户乐于给我们提建议,积极地充当小白鼠,也能宽容我们的一些不足,对于这一点我们一直心存感激。

冷启动期

一个月后,我们认为功能正式毕业,开始筹备公司层面的推广。我们将项目纳入进 OKR,并设定了 KR 目标:提升流水线功能覆盖率到 60%。

冷启动期应该是最 hardcore 的阶段。大部分业务线研发总是对新功能比较谨慎,想着等大规模使用后再来接入。彼时的我们正处在发布系统到 CD 平台转型阶段,而流水线功能落地恰好成为我们是否具备平台属性的试金石。

在团队之前完成的如全公司统一接入发布系统,经典网络到 VPC 网络迁移等项目时,虽然我们也做了大量减轻用户负担的工具开发,但根本上还是靠自上而下的行政式命令得以完成。这种方式虽保证了项目实施的高效,但也容易让业务线积累抵触的情绪,使用上必须有所克制。

我们首先尝试“宣讲会”的方式进行功能推介,效果并不理想,一来参加“宣讲会”的人数有限,二来听完后回去主动使用的比例也不高。真正起效果的还是“笨”方法,让团队成员扮演客服的角色在「钉钉发布支持群」里推荐和引导,同时拜托种子用户在部门内帮助我们推广,虽然见效依然不快,但是使用量确实在逐步增长。这个时期我们特别关注启用流水线后又将功能关闭的 case,主动跟进摸排原因后常常会有新的启发。

稳定增长期

经历了“冷启动阶段”,团队积累了跟用户推广的经验,总结出较好的操作文档和用户案例,功能上也得到进一步完善,来到了“稳定增长期”。

因为 CD 平台本身就是强入口,我们便在平台“广告位”上增加了更多的曝光。这个时期用户已经展现出比较强的主动使用意向,标志之一就是新应用使用流水线的比例非常高,所以我们的工作重心不再是推广,而是做好内功的修炼。流水线功能导致了 Jenkins 集群的任务量上涨了几倍,所以我们投人大量的精力来完善整套 Jenkins 服务,包括扩容、完善监控告警、优化任务编排等等。类似的还有 GitLab 服务。

有段时间似乎到了增长的天花板,应用接入率指标的增长在逐步放缓,而我们距离的 60% 的 KR 目标依然很远。不过也有一些非常积极正面的信号:

  • 每周流水线任务的执行数量非常健康

  • 我们抽样调研的用户都认为功能很好地帮助了他们,他们非常愿意推荐给其他同事使用

所以一定有被我们忽略掉的细节。分部门统计后,我们发现各部门间在指标上有非常大的差异,如下图所示,背后的原因对我们很有启发:基础架构组和业务线的应用的开发模式不一样;不同事业部的内部规范也不一样:比如分支策略不一样,还有些部门测试在操作上扮演了更重要的角色。这些发现有助于我们功能完善和推广策略优化,另外我们发现公司内部有一个数量不小的“僵尸应用”,这类应用已经失去了迭代的活力,但是生产上依然有流量不能下线,所以需要从我们的统计基数里去掉。解决完这些问题,我们迎来了“爆发期”。

爆发期

我们又迎来了一波增长曲线,周任务执行次数的峰值不断被刷新,我们也顺利完成了 60% 应用的接入目标,在新建的应用里启用流水线功能的比例能超过 90%。

伴随着流水线使用规模的不断扩大,功能自身还展现出强大的生命力,掌门内部的需求都能做到比较好的支持。目前接入的应用包括 Java、H5、NodeJS、Android,支持事件触发和定时任务触发两类,流水线类型也从最早的只专注于构建和发布得到了扩充,针对不同应用类型提供不同的模版,用户在充分体会到流水线的优点以后,会主动给我们提需求,比如我们基于流水线实现的“生产发布成功后合并分支”和“light merge”功能便是基于用户早期的反馈而提炼出来的。

总结与展望

没有两个公司的流水线是完全一样的,从一开始,我们的目标就是设计适用于掌门自身研发体系的流水线。基于这个目标,我们对流水线功能做了调整和裁剪,并不去追求功能的最大集,这更有助于我们在设计方案时有的放矢。另外一个体会就是流水线能不能做好,底层原子能力的建设非常重要,可能正是由于我们早期没匆忙上线流水线,而是花了极大的耐心和精力去打磨构建、发布等原子能力,反而让后期实现流水线变得更为顺利。我们的底层架构非常简洁清晰,这也有利于我们设计出更简单的用户界面,而简单的用户界面又让我们在推广时受益。

相比功能本身,我们更看重团队在推广过程中得到的成长。相较于行业内其他的研发效能团队,团队成员相对更年轻,年轻人总是更痴迷于功能开发而容易忘记提升效率的初衷,导致对功能落地缺乏积极性和持续的反思总结。特别是对于一个长周期项目,一定要首先建立起有效的推进机制,比如专门的用户支持群、CD 平台的用户反馈中心,持续的核心数据指标跟踪等。同时作为服务型团队,一定要深刻理解工作的职责是协助用户解决问题。以我们支持的一类流水线为例:Git 触发 => 打包 => FAT 发布 => UAT 发布 + FAT 回滚,这里的 FAT 回滚直觉上是反人类的,但背后的原因却有其合理性。因为应用的 FAT 环境只有一套,我们的发布规范上又要求所有上生产的版本包必须是沿着 FAT => UAT => 生产 的路径发布上去的,所以用户就希望在上线过程中,将 FAT 环境短暂出让给待上线的版本,结束后再重新把 FAT 环境部署为开发中的版本。在我们支持该类型流水线后,发现使用量颇为可观。

目前掌门内部已经有成熟的子环境方案支持同一套微服务体系内的链路隔离,理论上基于此方案是可以彻底解决上述流水线涉及的问题,但是由于掌门有大量的 socket 长连接应用,导致全场景的子环境隔离方案依然有一些改造工作要持续推进。

如今流水线功能已经深度融入 CD 平台,本身具备了持续进化的长效机制。不过,现在的流水线功能本身也依然有很大的优化空间,主要有:

  • 与外部系统对接不够灵活(可以考虑在每个阶段提供 webhook 对接)。

  • 不支持对流程自定义编排。

  • 随着流水线任务越来越多,执行流水线的 Jenkins 集群规模变大,任务的调度效率不佳,高峰期可能出现大量任务等待的问题。

  • Jenkins 集群未容器化,导致需要按峰值设置容量,造成资源浪费。

  • 流水线按应用粒度设计,不支持跨应用的流程编排。

在合适的时间,我们会启动这些优化项。

我们团队负责掌门研发效能平台的建设,服务于掌门所有研发人员。作为掌门研发体系的核心组成部分,致力于保障高质量的交付和研发效率的提升。当前 CD 平台已经在 CI/CD 和监控告警形成闭环,今后我们会沿着需求、设计、开发、构建、验证、发布这个路径进行更广的探索,打造真正的研发效能平台。

文章来源:掌门技术,点击查看原文

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值