极狐 GitLab Runner 是极狐 GitLab CI/CD 执行的利器,能够帮助完成 CI/CD Pipeline Job 的执行。
目前极狐 GitLab Runner 是一个开源项目,以 Golang 编写。
极狐 Gitlab 有个不错的特性,就是你可以使用自己的极狐 Gitlab CI Runner。可是,如果你没有自己的 CI Runner 该怎么办呢?别担心,我们可以自己写一个。`[]~( ̄▽ ̄)~*`
在这篇文章里,我们会:
当然,如果你习惯直接看代码,欢迎访问极狐GitLab仓库。如果喜欢,欢迎留个 star。
Here we go!
打蛇打七寸,极狐 GitLab Runner 最核心的任务是这些:
接下来我们按顺序捋一捋各个核心任务,同时观察 Runner 是怎么和极狐 GitLab 交互的。为了行文简明,下文的 API 请求和返回的内容有所精简。
如果你用过自托管的极狐 GitLab Runner,你应该熟悉这个页面:
用户在这个页面获取注册 token,然后通过gitlab-runner register
命令把 Runner 实例注册到极狐 GitLab。这个注册过程本质上是在调用接口POST /api/v4/runners
,其 body 形如:
{
"description": "一段用户自己提供的描述",
"info": {
"architecture": "amd64", # runner的架构
"features": { # runner具备的特性,极狐GitLab可能会拒绝不具备某些特性的runner注册
"trace_checksum": true, # 是否支持计算上传日志的checksum
"trace_reset": true,
"trace_size": true
},
"name": "gitlab-runner",
"platform": "linux",
"revision": "f98d0f26",
"version": "15.2.0~beta.60.gf98d0f26"
},
"locked": true,
"maintenance_note": "用户提供的维护备注",
"paused": false,
"run_untagged": true,
"token": "my-registration-token" #极狐GitLab提供的注册token
}
如果注册 token 无效,极狐 GitLab 会返回403 For
{
"id": 2, # Runner在极狐GitLab这边的全局编号
"token": "bKzi84WitiHSN4N4TYU6", # runner的鉴权token
"token_expires_at": null # 据我观察,这个字段对应的功能没有做
}
Runner 只关心其中的 token,它代表了 runner 的身份,同时作为共享密钥参与后面的 API 调用的鉴权。这个 token 会连同其他设置被保存到文件~/.gitlab-runner/config.toml
中。
Runner 在设定中有个最大并行工作数,在目前执行的工作数目小于设定值时,它会轮询POST /api/v4/jobs/request
以获取工作,传入的 body 很像注册时的 body,形如:
{
"info": {
"architecture": "amd64", # runner的架构
"executor": "docker", # runner使用的执行器
"features": { # runner具备的特性,例如,如果一个runner不支持上传产物,那么需要上传产物的工作就不会调度到它身上。
"artifacts": true,
"artifacts_exclude": true,
"cache": true,
"cancelable": true,
"image": true
},
"name": "gitlab-runner",
"platform": "linux",
"revision": "f98d0f26",
"shell": "bash",
"version": "15.2.0~beta.60.gf98d0f26"
},
"last_update": "d8a43f53bb125ec6599d778b9969a601", # 游标
"token": "bKzi84WitiHSN4N4TYU6" # 前面注册时拿到的token
}
如果没有要执行的工作,极狐 GitLab 会返回状态码204 No Content
,Header 中会有游标,形如X-Gitlab-Last-Update: 2794e577289a38db0df0e93e3215f597
,供下次请求传入。
游标其实是个随机字符串,请求进入极狐 GitLab 的前置代理(名为 Workhorse)时,代理会检查 Runner 提交的游标是否和 Redis 中的游标一致,如果一致就让 Runner 等着(long poll),不一致就把请求原样代理到极狐 GitLab 后端。Redis 中的游标的更新由后端维护,在变更时会通过Redis Pub/Sub通知到 Workhorse. 工作的选取在后端实现为一个复杂的 SQL 查询。
在有新工作需要执行时,极狐 GitLab 会返回201 Created
,其 body 形如:
{
"allow_git_fetch": true,
"artifacts": null, # 要上传的产物
"cache": [], # 要使用的缓存
"credentials": [
{
"password": "jTruJD4xwEtAZo1hwtAp", # 用来拉取代码、上传日志、上报执行结果的通用密钥
"type": "registry",
"url": "gitlab.example.com",
"username": "gitlab-ci-token" # 用户名是固定的
}
],
"dependencies": [],
"features": {
"failure_reasons": [ # 服务端可接受的工作错误原因
"unknown_failure",
"script_failure"
]
},
"git_info": {
"before_sha": "6b55b6ffd17b57a2ec0cf8e7d7c66ff709343528",
"depth": 20, # 克隆深度
"ref": "master", # 目标分支/tag
"ref_type": "branch",
"refspecs": [
"+refs/pipelines/52:refs/pipelines/52",
"+refs/heads/master:refs/remotes/origin/master"
],
"repo_url": "http://gitlab-ci-token:jTruJD4xwEtAZo1hwtAp@gitlab.example.com/flightjs/Flight.git",
"sha": "cb4717728e8f885558a4e0bb28c58288b8bf4746" # commit hash
},
"id": 823, # 工作id,是后面很多API调用的重要参数
"image": null,
"job_info": {
"id": 823,
"name": "build-job",
"project_id": 6,
"project_name": "Flight",
"stage": "build"
},
"services": [],
"steps": [ # 要执行的脚本
{
"allow_failure": false,
"name": "script",
"script": [ # 脚本内容,每项对应 .gitlab-ci.yml 中的一个数组元素
"echo \"sleeping 1\"",
"sleep 5",
"echo \"sleeping 2\"",
"sleep 5"
],
"timeout": 3600, # 脚本最大执行超时
"when": "on_success"
}
],
"token": "jTruJD4xwEtAZo1hwtAp", # job凭据,用来鉴权后面的API调用
"variables": [ # job执行时的环境变量,用户自己定义的环境变量也会放在这里
{
"key": "CI_JOB_ID",
"masked": false,
"public": true,
"value": "823"
},
{
"key": "CI_JOB_URL",
"masked": false,
"public": true,
"value": "http://gitlab.example.com/flightjs/Flight/-/jobs/823"
},
{
"key": "CI_JOB_TOKEN",
"masked": true,
"public": false,
"value": "jTruJD4xwEtAZo1hwtAp"
}
]
}
为了让 CI 的执行稳定、可重复,Runner 执行的环境需要一定程度的隔离,执行环境的准备、脚本的执行由Executor负责,聊几个常见的:
Shell:
Docker 或 k8s:
VirtualBox 或 Docker Machine:
所有的 Executor 都提供必须的 API 供极狐 GitLab Runner 调用:
克隆仓库其实就是在环境中执行一个git clone
,所需参数在上一步“拉取工作”中获得:
git clone -b [分支/tag名] --single-branch --depth [克隆深度] https://gitlab-ci-token:job-token@gitlab.example.com/user/repo.git [克隆目的地文件夹名称]
所有要执行的工作都会被 Runner 编排成几个脚本文本,发给 Executor 执行,编排时会考虑 Executor 里的脚本执行环境是哪一个(bash/Powershell)。环境变量会放在编排的脚本最前面,例如对于 bash 环境,环境变量在脚本中使用export
声明。
说个有趣的,你在 CI log 里看到的,标识为绿色的接下来要执行的语句是 Runner 在编排脚本时用echo
命令+终端颜色控制符输出的,类似这样:
echo -e $'\x1b[32;1m$ date\x1b[0;m' # 打印出绿色的 $ date
date # 真正执行 date 命令
执行器的标准输出和标准错误会被 Runner 捕获,存放在/tmp
临时文件中。job 执行结束前,Runner 会周期性地调用接口PATCH /api/v4/jobs/{job_id}/trace
增量上传日志,请求的 header 形如:
Host: gitlab.example.com
User-Agent: gitlab-runner 15.2.0~beta.60.gf98d0f26 (main; go1.18.3; linux/amd64)
Content-Length: 314 # 这个增量的长度
Content-Range: 0-313 # 这个增量在全部日志中的位置
Content-Type: text/plain
Job-Token: jTruJD4xwEtAZo1hwtAp
Accept-Encoding: gzip
body 里就是这批增量上传的日志,本例形如:
\x1b[0KRunning with gitlab-runner 15.2.0~beta.60.gf98d0f26 (f98d0f26)\x1b[0;m
\x1b[0K on rockgiant-1 bKzi84Wi\x1b[0;m
section_start:1663398416:prepare_executor
\x1b[0K\x1b[0K\x1b[36;1mPreparing the "docker" executor\x1b[0;m\x1b[0;m
\x1b[0KUsing Docker executor with image ubuntu:bionic ...\x1b[0;m
\x1b[0KPulling docker image ubuntu:bionic ...\x1b[0;m
下一次上传日志时,新请求的Content-Range
和Content-Length
的内容同样会对应请求 body 的信息。
极狐 GitLab 在成功接受请求后会返回202 Accepted
,返回的 header 中有一些有意思的值:
Job-Status: running # job运行状态
Range: 0-1899 # 当前收到的字节范围,每次都是0-n这个形式
X-Gitlab-Trace-Update-Interval: 60 # runner最低上报间隔,单位秒
这里有一个有意思的优化,当 CI log 页面有用户正在观看时,X-Gitlab-Trace-Update-Interval
的值会是 3,即 Runner 应该 3 秒就增量上报一次日志,这样用户才能更实时地看到最新进展。
在用户定义的脚本执行成功或失败后,Runner 会做两件事:
PUT /api/v4/jobs/{job_id}
更新 job 的状态。
一个成功的 job 对应的 HTTP body 形如:
{
"checksum": "crc32:4a182676", # 所有日志的CRC32校验,用来让服务端确定所有日志都已经成功上传
"info": { ... }, # 这个字段已经在前面见过很多次了,内容从略
"output": {
"bytesize": 1899, # 日志的总字节数
"checksum": "crc32:4a182676" # 同checksum
},
"state": "success", # job执行结果
"token": "jTruJD4xwEtAZo1hwtAp" # job凭据
}
一个失败的 job 对应的 HTTP body 形如:
{
"checksum": "crc32:f67200bc",
"exit_code": 42, # 用户脚本的退出码
"failure_reason": "script_failure", # 错误原因,从一个与服务端约定的列表里选
"info": { ... }, # 这个字段已经在前面见过很多次了,内容从略
"output": {
"bytesize": 1723,
"checksum": "crc32:f67200bc"
},
"state": "failed", # job执行结果
"token": "Lx1oBNfw2e9xhZvNKsdX"
}
极狐 GitLab 后端在成功接受状态更新请求后会返回200 OK
,Runner 的工作就结束了。
有时,服务端没准备好接受状态更新(日志的处理是异步的,还没落盘),此时会返回202 Accepted
,header 里的X-GitLab-Trace-Update-Interval
会告知 Runner 在下次尝试之前的等待时间(类似指数退避),Runner 会一直重发请求,直到服务端返回200 OK
或者超过最大重试次数。
整体来看,上述流程是这样子的:
OK,我们已经把极狐 GitLab Runner 的核心任务捋了一遍了,现在该打开 IDE,写我们自己的 Runner 啦!
我喜欢吃蛋挞,我们就叫我们的 DIY Runner “蛋挞” 吧,英文名Tart
.
画个 Logo,这样看上去比较像一个正经项目:
再打开编程祖师娘 Ada Lovelace 的画像拜一拜接受祝福,万事俱备,开工大吉!
和极狐 GitLab Runner 一样,蛋挞也是个命令行程序,主要功能有:
用上spf13/cobra,我们可以很快把命令行本体捏出来:
$ tart
An educational purpose, unofficial Gitlab Runner.
Usage:
tart [command]
Available Commands:
completion Generate the autocompletion script for the specified shell
help Help about any command
register Register self to Gitlab and print TOML config into stdout
run Listen and run CI jobs
single Listen, wait and run a single CI job, then exit
version Print version and exit
Flags:
--config string Path to the config file (default "tart.toml")
-h, --help help for tart
Use "tart [command] --help" for more information about a command.
构建隔离执行环境可能是 Runner 的一个最重要的任务了,理想的执行环境应该有这些特征:
分析现有的极狐 GitLab Runner 的 Executor 各自满足了上述哪些特征就作为留给读者的练习了。
既然蛋挞是我们自己的 Runner,我们有充分的自由,让我们选择 Firecracker 来构建执行环境吧。
Firecracker是亚马逊云服务(AWS)开发和开源的虚拟机管理器,特点是轻量,它依靠KVM实现,通过模拟尽可能少的硬件以及跳过 BIOS 启动,可以在不到一秒内启动一台具有终端输入输出的虚拟机,并且每台虚拟机的额外内存开销不大于 5MB,AWS 使用 Firecracker 来构建自己的函数计算服务 Lambda 和无服务器托管服务 Fargate。
启动一台能供 CI 使用的 MicroVM(Firecracker 对虚拟机的称呼)需要三个依赖:
/
及其下属内容)。
你可以查看蛋挞对它们的具体实现,其中,根文件系统值得说道一下。
还记得我们梳理的极狐 GitLab Runner Executor 的必备 API 吗?虽然蛋挞并不直接仿写极狐 GitLab Runner 的 Executor,但是这三个操作仍然是必要的:
让每个虚拟机都在根文件系统的副本上操作可以提供资源隔离和可重复性。
Firecracker 提供的终端只有一个输入和输出,操作自由度不够,这意味着我们在虚拟机里需要一个 agent,脚本交给它去执行,输出和退出码由它转交给蛋挞。思来想去,我们最常用的 agent 恐怕是 ssh 了:
sshd 会调用虚拟机本地的 bash 运行蛋挞提供的脚本,这正是我们想要的。
这步不难,极狐 GitLab 提供的用户脚本是一个字符串数组,环境变量是一个对象数组:
set -euo pipefail
,这样执行会在遇到错误的时候停下来;git clone
和cd
到仓库目录;export
环境变量,每个一行,其中环境变量的值需要 escape;set +x
,这样 bash 就会把接下来要执行的每个命令写到标准输出了;\n
.
脚本交给 sshd 后就可以执行了,标准输出和标准错误会被蛋挞实时收集写到本地临时文件中,另有一个进程会把它周期性地增量上传到极狐 GitLab。
脚本执行结束后,sshd 会返回退出码,蛋挞会视情况上报 job 成功或失败。
既然蛋挞是用来运行 CI 任务的,我们就找点任务来让它运行,比如……它自己的 CI?
让我们为蛋挞写一个.gitlab-ci.yml
:
variables:
# speed up go dependency downloading
GOPROXY: "https://goproxy.cn,direct"
# we have go and build-essential pre-installed
our-exceiting-job:
script:
- echo "run test"
- go test ./...
- echo "build tart"
- make
- echo "run tart"
- cd bin
- ./tart
- ./tart version
把蛋挞注册为仓库的 CI Runner 后,禁用 shared runner(确保任务调度到蛋挞上),触发一次 CI 执行,看上去效果还不错!
对了,我还埋了一个小彩蛋与大家分享,如果你在星期四使用蛋挞运行 CI job,将会有一个神秘惊喜!点击👉即可访问蛋挞代码仓库
2014 年~2015 年,GitLab Runner 有很多活跃的第三方实现,其中Kamil Trzciński基于 Go 的GitLab CI Multi-purpose Runner实现被 GitLab 相中,替代了 GitLab 自己基于 Ruby 的实现,成为了我们今天看到的极狐 GitLab Runner. 那时 Kamil Trzciński 还在 Polidea 工作,因此极狐 GitLab CI Multi-purpose Runner 是一个社区贡献。开源真是奇妙。