本文以npm typescript的monorepo项目为例,介绍了如何使用Drone的YAML Endpoint功能实现一个集中管理、生成构建配置的Drone Config Provider,方便项目以几乎为0的成本接入CI/CD。并粗略比较了Drone与JenkinsX和运行在k8s之上的Jenkins。
即刻后端服务早已运行在k8s之上,k8s带来的高可用和水平扩展能力,使开发者无需关心机器配置就可以快速发布和伸缩服务。然而我们的Jenkins却一直难以享受到这种便利,一直跑在一台8核心单机上,闲的时候(如午饭、下午茶、晚饭)没有负载,忙的时候多个服务同时构建就要排队。
JenkinsX 首先尝试使用JenkinsX,部署非常简单,本地安装好jx命令行工具,配置好kubectl,使用:
jx install
就可以根据交互式创建出一个运行在k8s中的JenkinsX了。装完后发现JenkinsX和Jenkins除了名字相近之外并没有什么关系,甚至连UI都没有,考虑到我们的CI/CD不只后端使用,只能放弃JenkinsX。
Jenkins with k8s 然后尝试Jenkins使用k8s节点进行构建,在这种模式下,Jenkins master是否部署在k8s中无所谓,构建任务运行在k8s中。本以为原来项目中的Jenkinsfile稍微改动就可以适配,但后来发现诸如npm registry鉴权、docker in docker构建、volume挂载等问题接踵而至,Jenkinsfile本质是groovy dsl,虽然支持声明式写法,但稍微有点复杂的逻辑都不得不使用脚本式写法,团队中只有少数人能够看懂并修改。并且Jenkinsfile调试起来也不太方便,我们认为这不是一个好的解决方案。
Drone Drone生而为docker设计,使用更加简洁的yaml作为配置文件,并支持 请求外部服务,动态生成配置文件 。对于一个普通的npm typescript项目,我们使用的配置文件.drone.yml如下:
kind: pipeline name: example-service trigger: branch: - master steps: - name: npm-auth image: robertstettner/drone-npm-auth settings: username: username password: password email: username@iftech.io registry: https://npm.yourcompany.com scope: '@jike' - name: install image: node:10 commands: - npm ci - name: lint image: node:10 commands: - npm run lint - name: compile image: node:10 commands: - npm run compile - name: test image: node:10 commands: - npm t - name: ecr-build-push image: plugins/ecr settings: repo: example.ecr.region.amazonaws.com/example-service registry: example.ecr.region.amazonaws.com region: region create_repository: true tags: - ${DRONE_SOURCE_BRANCH//\//-}-${DRONE_COMMIT:0:7} - name: notify image: plugins/webhook settings: urls: http://jkdpy-dashboard-svc.infra:8000/api/images/ content_type: application/x-www-form-urlencoded template: | image_name=example.ecr.region.amazonaws.com/example-service:${DRONE_COMMIT:0:7}&\ branch_name=${DRONE_SOURCE_BRANCH}&\ service_name=example-service&\ commit_message=${DRONE_COMMIT_MESSAGE} services: - name: kafka image: spotify/kafka ports: - 9092 - name: redis image: redis ports: - 6379 - name: mongo image: mongo ports: - 27017
即使没有看过drone的文档,也不难理解这个构建任务:这是一个名为example-service的任务,首先获得私有npm访问权限,然后clean install、lint、compile、test,再构建生产环境镜像发布到ecr上,最后触发一个自定义的webhook。测试时需要启动三个临时服务kafka、redis和mongo。文件中诸如${}是模板变量,还支持shell字符串操作,${DRONE_COMMIT:0:7}表示取commit hash的前7个字符。steps中的每一步都会启动一个docker,而且plugin、service也都是以docker的形式提供。
如果是monorepo项目,可以使用 drone-config-changeset-conditional 插件,.drone.yml写成如下形式:
kind: pipeline name: a-service steps: ... trigger: changeset: includes: - a-service/* --- kind: pipeline name: b-service steps: ... trigger: changeset: includes: - b-service/*
当a-service目录下的文件发生变化时,构建a-service;当b-service目录下的文件发生变化时,构建b-service
看起来已经够用了,但仍有些不足:
npm registry的password会跟随.drone.yml存在每个项目中,泄露风险较高 假如registy、ecr或webhook的endpoint变了,每个项目都要改一遍 monorepo类型项目的配置文件会写的很长 Drone YAML Endpoint 为了解决这些问题,就需要使用DRONE_YAML_ENDPOINT环境变量给drone指定一个动态生产yaml的服务地址,官方的Jsonnet Extension就是这样实现的。
每当一个构建开始前,Drone会向DRONE_YAML_ENDPOINT发送一个post请求,并带上以下参数:
interface Payload { build: { id: number repo_id: number trigger: string number: number status: string event: string action: string link: string timestamp: number message: string before: string after: string ref: string source_repo: string source: string target: string author_login: string author_name: string author_email: string author_avatar: string sender: string started: number finished: number created: number updated: number version: number } repo: { id: number uid: string user_id: number namespace: string name: string slug: string scm: string git_http_url: string git_ssh_url: string link: string default_branch: string private: boolean visibility: string active: boolean config_path: string trusted: boolean protected: boolean ignore_forks: boolean ignore_pull_requests: boolean timeout: number counter: number synced: number created: number updated: number version: number } }
这是一段typescript代码,由于没有官方文档,这些类型是根据调试整理推断出的。
请求返回的Data字段中的字符串,即是生成的YAML:
{ "Data": "kind: pipeline\nname: example-service..." }
基于Drone的自定义YAML Endpoint功能,实现了Drone Config Provider服务。
Drone Config Provider 我们后端的npm项目还算比较规范,基本都是同一个脚手架fork出来的,一般package.json都会使用一些固定的script和dependence:
{ "name": "example-service", "scripts": { "start": "node dist/src/app.js", "compile": "tsc", "lint": "tslint --project .", "clean": "rm -rf dist", "dev": "tsc -w & NODE_ENV=dev PORT=3000 nodemon dist/src/app.js", "test": "ava" }, "dependencies": { "config": "3.2.2", "ioredis": "4.14.0", "lodash": "4.17.15", "mongoose": "5.6.9", "request": "2.88.0", "request-promise-native": "1.0.7", "source-map-support": "0.5.13" }, ... }
因此可以通过package.json中的scripts来生成steps,根据dependencies生成services:如果scripts中含有lint,就npm run lint,如果有compile就npm run compile。如果dependencies中包含mongoose、mongodb等,就生成一个mongo service,等等。
为了区分不同的项目不同的生成策略,我们通过自定义.drone.yml中的kind来区分不同的策略,最终实现了只需在项目根目录中新建文件.drone.yml:
kind: npm-service
只要项目中有这样一个文件,每次构建触发时,就可以根据该项目的package.json生成一份YAML,密码等敏感信息也可以不用再分散在各个项目中,如果需要修改构建参数,也可以统一修改。
我们的npm monorepo都是采用了一级目录来区分不同的子repo,目录结构大致如下:
. ├── Jenkinsfile ├── README.md ├── a-service ├── b-service └── c-service
对于monorepo,使用不同的kind来区分:
kind: npm-monorepo
每次构建触发时,根据payload里的commit信息,使用 Github的compareCommits API 来检测此次提交更改的目录,只构建修改过的子repo,就可以简单一行代码给整个repo中的所有子repo接入CI/CD。
同时构建a-service和b-service,c-service没有改动,不构建 Drone Config Provider项目被设计为可以方便增加不同kind,只需增加一个文件,就可以多处理一种新的构建类型。如果某个项目十分特殊,则可以像原来一样使用kind: pipeline完全自定义构建流程。
至此一个几乎不用配置就可以接入的CI/CD就搭建完成了,说的玄乎,其实只是利用了Drone的自定义YAML Endpoint特性,并得益于我们团队较为规范的项目代码,package.json的scripts和dependencies都比较统一,以及我们一直使用的k8s。在此感谢同事们较高的代码品味和良好的编程习惯,感谢基础设施team提供的稳定的k8s服务。
参考资料: