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 脚本,从代码里可以看出:
-
Pipeline 脚本只做 workflow 的编排,不负责具体步骤的实现
-
基于 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")
}
}
}
}
}
...
}
我们的用户交互界面与底层架构基本一脉相承,得益于底层架构的简单清晰,交互逻辑也做到了简洁明了。创建流水线有两步操作,分别对应上图中的模板和变量,不同的模板支持不同的变量设置。当一个流水线创建成功之后,模板就锁定了,但是变量部分依然可以通过编辑功能进行修改。
在流水线详情页,除了可以编辑变量,还可以查看流水线的执行记录,可以查看具体的执行步骤,查看哪个步骤导致执行失败。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 目标依然很远。不过也有一些非常积极正面的信号: