距离上篇《持续集成之.gitlab-ci.yml篇(上)》已经过去5年了,现在(2023年7月)我们公司用的GitLab社区版也已经更新到v16.0.4,增加了许多新的功能,而在GitLab CI这部分,也有了不少改进。本文将为你介绍下有哪些我觉得实用的特性。
镜像与服务
image
时至今日,我们团队已经全线使用K8S+Docker来部署和运维服务,本地启动数据库或者中间件Docker更是不二之选,完全摆脱了被各种软件依赖、环境变量支配的恐惧。掌握Docker,应该是开发人员一项必备的技能。
我们自建了基于Helm图的GitLab Runner注册服务,也就是将上节在每台机器上手动注册Runner,进行更深层次的封装,普通开发者只需要输入GitLab仓库地址就能使用:
每个Job其实都是依赖于镜像运行的,只不过Node.js是一个镜像,Rust又是另外一个了。
而不同的Job,可以使用不同的镜像:
在具体的Job里覆盖全局image即可:
yaml
docker:
image: buildah:1.30.0
variables:
IS_INJECT_VERSION: "true"
script:
- docker_build --build-arg GIT_REVISION=${CI_COMMIT_SHA}
services
通常来说,我们的容器使用镜像(image)就可以了,services 关键字是image的一个补充,它可以指定其它镜像并创建另一个容器,两个容器之间可以相互访问并进行通信。
services虽然可以运行任何应用程序,但最常见的用例是运行数据库容器,比如MySQL、PostgreSQL、Redis等,后端代码进行集成测试时会非常有用。
下面用Deno与Redis举个简单的例子。
新建一个redisTest.ts:
typescript
import { connect } from "https://deno.land/x/redis@v0.26.0/mod.ts";
const redis = await connect({
port: 6379,
hostname: "localhost",
});
const ok = await redis.set("test", "aa");
console.log("set ok: ", ok);
const val = await redis.get("test");
console.log("get val", val);
配置.gitlab-ci.yml:
typescript
test:
stage: test
image: denoland/deno:alpine-1.33.1
services:
- redis:latest
script:
- deno run -A ./redisTest.ts
流水线中运行:
需要注意的一点是,services与主容器是通过网络连接的,并不会注入软件脚本,像以下这种情况是不被允许的:
yaml
job:
services:
- php:7
- node:latest
- golang:1.10
image: alpine:3.7
script:
- php -v
- node -v
- go version
更详细的用法请自行去看官方文档,这里就不赘述了。
触发条件与工作流
when
我们可以使用when来控制任务的触发时机,也就是运行条件。默认是on_success。
when | 描述 |
---|---|
on_success | 仅当较早阶段的所有作业未失败或具有 allow_failure: true 时才运行该作业。 |
on_failure | 仅当较早阶段至少有一个作业失败时才运行该作业。较早阶段中带有 allow_failure: true 的作业始终被视为成功。 |
never | 不管较早阶段的作业状态如何,都不运行该作业。只能在 rules 部分或 workflow:rules 中使用。 |
always | 不管较早阶段的作业状态如何,都运行该作业。也可以在 workflow:rules 中使用。 |
manual | 仅在手动触发时运行该作业。 |
delayed | 延迟指定的持续时间执行作业。 |
手动触发任务
通常来说,一个线上正式使用的业务,是不允许随便变更的,这时可以考虑将最后一步部署环节(deploy)修改为手动触发,只需要添加when: manual就可以了。
还是以上面的任务为例:
yaml
production:
stage: deploy
when: manual
script:
- echo "Running production..."
流水线中deploy这一步变为需要手动操作,在合适的时机点击即可(我经常下班回家后用手机操作):
处理失败作业
我们可以使用on_failure来触发失败的作业。
yaml
stages:
- build
- cleanup_build
build_job:
stage: build
script:
- echo "build failed"
- exit 1
cleanup_build_job:
stage: cleanup_build
script:
- echo "cleanup build when failed"
when: on_failure
如果build失败了,cleanup_build就会执行;反之,如果build成功了,cleanup_build会跳过: 你可能会觉得这种方式不够优雅,本来应该是一个任务的事情,要拆分成2个。所以还有其它的方案:
yaml
job:
script:
- false || exit_code=$?
- if [ $exit_code -ne 0 ]; then echo "Previous command failed"; fi;
好吧,你可能更不喜欢这个。
所以GitLab提供了一个环境变量CI_JOB_STATUS,它有三个值:success、failed和canceled。使用:
yaml
test_job:
# ...
after_script:
- >
if [ $CI_JOB_STATUS == 'success' ]; then
echo 'This will only run on success'
else
echo 'This will only run when job failed or is cancelled'
fi
如果只处理失败:
yaml
stages:
- test
test:
stage: test
script:
- exit(1)
after_script:
- >
if [ $CI_JOB_STATUS == 'failed' -o $CI_JOB_STATUS == 'running' ]; then
echo 'This will only run when job failed or is cancelled'
fi
但是CI_JOB_STATUS有bug,GitLab 15.8才修复,而且要求Runner对应的镜像也得更新。
比如我们原来用的是gitlab/gitlab-runner:alpine-v15.1.1,现在也得升级,具体版本号可以到hub.docker.com/r/gitlab/gi...里查找。
如果一时不容易更新,有个折衷的方案,在任务成功后,手动添加一个文件,在after_script里判断这个文件是否存在,有则成功,无则失败。
yaml
test:
stage: test
script:
- echo "success"
- touch SUCCESS # 必须在成功后添加
after_script:
- >
if [[ -f "SUCCESS" ]]; then echo "success"; else echo "fail"; fi
rules
rules是对only/except的补充,二者是个竞争关系,同一个Job里只能有一个。而且官方现在推荐使用rules。
以前only/except是这样的:
yaml
job1:
script: echo "job1"
only:
- branches # 所有分支
- tags # 所有标签
job2:
script: echo "job2"
except: # 排除以下条件
- main
- /^stable-branch.*$/
- schedules
常用的关键词有api、branches、chat、external、external_pull_requests、merge_requests、pipelines、pushes、schedules、tags、triggers、web,覆盖了所有的流水线作业。
现在改用rules后:
yaml
job1:
script: echo "job1"
rules:
- if: $CI_COMMIT_BRANCH
- if: $CI_COMMIT_TAG
job2:
script: echo "job2"
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: never
- if: $CI_COMMIT_BRANCH =~ /^stable-branch.*$/
when: never
- if: $CI_PIPELINE_SOURCE == "schedule" # 由计划任务触发
when: never
再介绍几个常用的判断条件:
yaml
job3:
script: echo "job3"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # 默认分支
- if: $CI_COMMIT_REF_PROTECTED == "true" # 受保护的分支
- if: $CI_COMMIT_TITLE =~ /Merge branch.*/ # 提交信息
受保护的分支在『设置-仓库』里可以配置。 除了rules:if
外,rules:changes
也是比较有用的特性:
yaml
build:
script: echo "build"
rules:
- changes:
- packages/cli/*
workflow
workflow翻译过来就是工作流,需要与rules结合来控制流水线的运行:
yaml
workflow:
rules:
- if: $CI_COMMIT_TITLE =~ /-draft$/
when: never
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
也可以控制不同条件下使用不同的环境变量:
yaml
variables:
DEPLOY_VARIABLE: "default-deploy"
workflow:
rules:
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
variables:
DEPLOY_VARIABLE: "deploy-production" # Override globally-defined DEPLOY_VARIABLE
- if: $CI_COMMIT_REF_NAME =~ /feature/
variables:
IS_A_FEATURE: "true" # Define a new variable.
- when: always # Run the pipeline in other cases
needs
不同于原来我们必须使用stages定义作业的执行顺序,自从GitLab 12.2起,我们可以使用needs乱序执行作业。
你可以忽略阶段排序并运行一些作业,而无需等待其它作业完成;多个阶段的作业可以同时运行。
这是一个例子:
yaml
linux:build:
stage: build
script: echo "Building linux..."
mac:build:
stage: build
script: echo "Building mac..."
lint:
stage: test
needs: []
script: echo "Linting..."
linux:rspec:
stage: test
needs: ["linux:build"]
script: echo "Running rspec on linux..."
mac:rspec:
stage: test
needs: ["mac:build"]
script: echo "Running rspec on mac..."
production:
stage: deploy
script: echo "Running production..."
可视化为有向无环图: 这是执行阶段: 可以看出,一开始就执行了3个Job,它们之间没有依赖关系,所以立即就执行了。 但我们可以随心所欲地写stage了吗?不是的。 细心的你可能发现了,只有build和test是第一个执行的,而deploy也没有任何依赖,却是最后才执行。 如果你把deploy修改为deploy2:
yaml
production:
stage: deploy2
script: echo "Running production..."
你会看到编辑器里报错:
也就是说,needs这种开放的模式,只适合这几个默认的stage:.pre, build, test, deploy, .post。从我们的实验效果上看,build、test属于第一梯队,如果没有依赖关系,会立即执行,而deploy属于最后一个环节,需要等前面的job都结束了才会运行。
所以,如果你的任务流比较复杂的话,还是老老实实定义stages顺序吧。
工件与缓存
artifacts(工件)与cache(缓存)不同的一点是,当作业完成后,工件将发送到GitLab,如果工件大小小于最大工件大小,则可以在GitLab界面中下载工件。cache则只能控制,在Web上是看不到的。
默认情况下,后续阶段的作业会自动下载先前阶段作业创建的所有工件。你可以通过依赖项来控制作业中的工件下载行为。
使用needs关键字时,作业只能从needs配置中定义的作业下载工件。
举个例子,构建产物这一步,假设产物目录是dist,这是cache的用法:
yaml
build:
stage: build
script:
- npm run build
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
- dist
cache默认的策略是既拉又推(pull-push),需要下载上一步(npm install)生成的node_modules,又要推送这一步生成的dist目录。
改为工件则是这样的:
yaml
build:
stage: build
script:
- npm run build
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
policy: pull
artifacts:
paths:
- dist
expire_in: 1 day
由于下一步部署环境只需要dist目录,所以工件只需要存储dist目录就可以了,cache则将策略修改为pull,以避免浪费推送时间。
在GitLab这条作业的流水线里,如果任务成功了,会看到右侧有『作业产物』的出现,可以点击下载:
也可以点击浏览,能看到目录的内容:
这样有个好处是,下一步骤可以直接使用工件,不需要下拉缓存。
使用工件最大的优点就是可视化,缺点是会占用GitLab的空间,所以使用时需要注意,不是非常有必要的话,过期时间不要设置太长。
过期时间(expire_in)的写法有点儿像自然语言,比如:
- '42'
- 42 seconds
- 3 mins 4 sec
- 2 hrs 20 min
- 2h20min
- 6 mos 1 day
- 47 yrs 6 mos and 4d
- 3 weeks and 2 days
- never
此外,最好手动关闭『保留最近成功作业的产物』,不知道为什么GitLab不直接遵循expire_in的配置,而搞一个更高级别的配置项。 这是一个过期的页面:
逻辑复用
extends
extends是对之前锚点的优化,可读性更强了。
比如之前锚点代码是这么写的:
yaml
.job_template: &job_configuration # Hidden yaml configuration that defines an anchor named 'job_configuration'
script: rake test
stage: test
only:
refs:
- branches
test1:
<<: *job_configuration # Add the contents of the 'job_configuration' alias
script:
- test1 project
only:
variables:
- $RSPEC
合并结果为:
yaml
test1:
script:
- test1 project
stage: test
only:
variables:
- "$RSPEC"
而现在是这样写的:
yaml
.tests:
script: rake test
stage: test
only:
refs:
- branches
rspec:
extends: .tests
script: test1 project
only:
variables:
- $RSPEC
extends与锚点细微的差别在于,它会基于keys进行某种深度合并。上例的合并效果为:
yaml
rspec:
script: test1 project
stage: test
only:
refs:
- branches
variables:
- "$RSPEC"
还有一种复用规则的方式是!reference
,其中的差别请自行体会:
yaml
.default_rules:
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
when: never
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
job1:
rules:
- !reference [.default_rules, rules]
script:
- echo "This job runs for the default branch, but not schedules."
include
include是GitLab 11.4起增加的一个我认为最有用的特性,它可以引入外部的YAML文件到.gitlab-ci.yml
配置里。这样有什么优点呢?
- 减少重复配置。可以讲,我们大部分团队的工程几乎都是一样的流水线步骤,前端一个,Node.js一个,Java一个,其它编程语言一个,用手指头是可以数过来的。当我提取出通用的模板以后,各工程只需要配置不同的环境变量即可。
- 高内聚,低耦合。将共性的内容集中在模板里,开发人员不需要关注CI的具体细节。比如以前的镜像构建、服务部署,我们自定义了不同的脚本文件,给出一段模板代码,让各工程复制到
.gitlab-ci.yml
配置里,这样我们的脚本如果有了破坏性修改,必须让每个工程都修改一遍。现在有了模板引用,我只需要更新模板代码,各工程是无感知的。
include分为几种。
- 当前工程:
yaml
include:
- local: '/templates/.gitlab-ci-template.yml'
或者更短些的写法:
yaml
include: '.gitlab-ci-production.yml'
- 当前GitLab上的其它工程,也就是我上面说的模板工程:
yaml
include:
- project: 'my-group/my-project'
file: '/templates/.gitlab-ci-template.yml'
- project: 'my-group/my-subgroup/my-project-2'
file:
- '/templates/.builds.yml'
- '/templates/.tests.yml'
还可以使用ref来指定分支、标签或SHA值:
yaml
include:
- project: 'my-group/my-project'
ref: main # Git branch
file: '/templates/.gitlab-ci-template.yml'
- project: 'my-group/my-project'
ref: v1.0.0 # Git Tag
file: '/templates/.gitlab-ci-template.yml'
- project: 'my-group/my-project'
ref: 787123b47f14b552955ca2786bc9542ae66fee5b # Git SHA
file: '/templates/.gitlab-ci-template.yml'
- 远程文件,可以是任意一个HTTP的GET请求:
yaml
include:
- remote: 'https://gitlab.com/example-project/-/raw/main/.gitlab-ci.yml'
yaml
# File sourced from the GitLab template collection
include:
- template: Auto-DevOps.gitlab-ci.yml
模板非常多,多到我实在没耐心细看,有兴趣的同学学习下是可以的:
以上四种就覆盖了所有的引用场景了,我们是新建了一个模板工程ci-templates,让所有工程都可以引入。
比如我们现在一个前端工程的.gitlab-ci.yml
文件是这样的:
yaml
variables:
CI_DOCKER_PROJECT: xx
CI_DOCKER_REPO: xx-web
CI_UPGRADE_HOSTNAME: xx.xx.com
NPM_TYPE: "pnpm"
CI_ENABLE_TEST: "true" # 开启单元测试
include:
- project: "spacex/ci-templates"
file: "npm.gitlab-ci.yml"
它在打了tag后就有了以下几个Job,经过单元测试、构建产物、构建镜像,最终部署上线。
而主分支推送后会部署到测试环境:
管道触发
最后说一个多项目管道(trigger),也就是从一个项目中,能触发另一个项目的下游管道。当然,触发上游管道的用户必须能够启动下游项目的管道才行。
父项目配置:
yaml
trigger_job:
trigger:
project: test1/child # 子项目的GitLab路径
子项目配置:
yaml
build-job:
stage: build
rules:
- if: $CI_PIPELINE_SOURCE == "pipeline"
script:
- echo "Compiling the code..."
- echo "This is from child project"
父项目中可以看到:
子项目中同样能看到流水线:
最后的最后,我看到文档里说下游管道通过配置可以拿到上游的工件:
yaml
test:
stage: test
script:
- cat artifact.txt
needs:
- project: my/upstream_project
job: build_artifacts
ref: $UPSTREAM_REF
artifacts: true
正好有个项目有这需求,就想玩个骚操作,试了半天发现不行,最后在这个讨论里gitlab.com/gitlab-org/...来看,这是收费版本才能使用,我不配。。。
果然,后来我们的GitLab更新后,文档里也加上了高级标志:
总结
本文在上篇的基础上,详细介绍了GitLab CI流水线一些有用的特性,如镜像(image)与服务(services),使用when、rules、workflow、needs等管理触发条件与工作流,合理使用工件与缓存,利用extends、include复用逻辑,使用trigger触发下游任务等。
GitLab CI是个强大的工具,我们可以利用它实现工作流程的自动化,摆脱繁琐的重复工作,提高软件开发的效率与质量。希望能对你有所帮助,感谢阅读!