持续集成之.gitlab-ci.yml篇(下)

距离上篇《持续集成之.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又是另外一个了。

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配置里。这样有什么优点呢?

  1. 减少重复配置。可以讲,我们大部分团队的工程几乎都是一样的流水线步骤,前端一个,Node.js一个,Java一个,其它编程语言一个,用手指头是可以数过来的。当我提取出通用的模板以后,各工程只需要配置不同的环境变量即可。
  2. 高内聚,低耦合。将共性的内容集中在模板里,开发人员不需要关注CI的具体细节。比如以前的镜像构建、服务部署,我们自定义了不同的脚本文件,给出一段模板代码,让各工程复制到.gitlab-ci.yml配置里,这样我们的脚本如果有了破坏性修改,必须让每个工程都修改一遍。现在有了模板引用,我只需要更新模板代码,各工程是无感知的。

include分为几种。

  1. 当前工程:
yaml 复制代码
include:
  - local: '/templates/.gitlab-ci-template.yml'

或者更短些的写法:

yaml 复制代码
include: '.gitlab-ci-production.yml'
  1. 当前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'
  1. 远程文件,可以是任意一个HTTP的GET请求:
yaml 复制代码
include:
  - remote: 'https://gitlab.com/example-project/-/raw/main/.gitlab-ci.yml'
  1. GitLab自带的模板
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是个强大的工具,我们可以利用它实现工作流程的自动化,摆脱繁琐的重复工作,提高软件开发的效率与质量。希望能对你有所帮助,感谢阅读!

相关推荐
qq_433716953 天前
编写第一个 Appium 测试脚本:从安装到运行!
自动化测试·软件测试·jmeter·ci/cd·职场和发展·appium·jenkins
睡觉谁叫3 天前
Cargo deny安装指路
c++·后端·ci/cd·rust·跨端
公西雒5 天前
关于在GitLab的CI/CD中用docker buildx本地化多架构打包dotnet应用的问题
ci/cd·docker·gitlab·qemu·dotnet
小蜜蜂爱编程6 天前
gitlab ci/cd搭建及使用笔记
笔记·ci/cd·gitlab
杨鹏飞乀6 天前
GitLab基于Drone搭建持续集成(CI/CD)
ci/cd·gitlab·drone
IT-民工211108 天前
CI/CD 实践总结
运维·ci/cd·自动化
蚊子不吸吸8 天前
DevOps开发运维简述
linux·运维·ci/cd·oracle·kubernetes·gitlab·devops
老攀呀9 天前
CI/CD 的概念
ci/cd
aklry9 天前
CI_CD
ci/cd
flying robot13 天前
GitHub Actions的 CI/CD
ci/cd·github