GitLab CI/CD 这支“开关”没打开之前,很多人以为持续集成是 Jenkins 的专利,或者觉得搭建一套流水线需要运维同学单独部署一套平台。实际上,GitLab 从 8.0 开始就把 CI/CD 能力直接内建在代码仓库里,你不需要额外安装什么 Web 服务,只要在项目根目录放一个.gitlab-ci.yml文件,剩下的任务调度、状态回传、日志展示、产物归档全部由 GitLab 实例自己消化。作为一个写代码的人,最直接的感受就是:代码提交、测试、构建、部署这些动作第一次和版本管理长在了一起。
这篇是“CICD详解”系列第九篇,我把 GitLab 从零到一的使用路径掰开揉碎讲一遍。内容面对的是那些听说过 GitLab CI/CD 但没真正跑通过一条流水线的读者,重点放在“简单使用”但又不至于变成纯点点点的操作。我会从 Runner 的架构认知开始,讲到.gitlab-ci.yml的最小配置,再到串并行调度、产物传递、私有化部署场景下的常见坑,全部是基于我在真实项目里跑流水线的理解,不是文档搬家的那种写法。
1. 先想清楚一件事:GitLab 的流水线到底跑在哪里
1.1 代码仓库不是执行器
很多第一次接触 GitLab CI/CD 的人会下意识认为,流水线的执行发生在 GitLab 服务本身,.gitlab-ci.yml里写了script之后,GitLab 后端就会跑这些命令。这个认知是错的。GitLab 的设计里有一个核心角色叫Runner,它是一个独立于 GitLab 服务之外的程序,可以装在自己的服务器上、装在开发机上、装在 Kubernetes 集群里,甚至装在你的笔记本电脑上。
Runner 负责真正执行.gitlab-ci.yml里定义的作业。GitLab 服务端只负责调度和展示:你提交代码后,GitLab 会解析仓库里的配置文件,判断哪些 job 需要跑、按什么顺序跑、是否有依赖关系,然后把一个个 job 分配给当前可用的 Runner。Runner 收到任务后,在执行环境里运行具体的命令,再把日志和状态传回 GitLab。
这个“服务端调度 + Runner 执行”的分离设计,是理解 GitLab CI/CD 一切行为的基础。为什么你的流水线偶尔卡在 pending?大概率不是 GitLab 挂了,而是没有可用 Runner 来接任务。为什么同一个 job 在不同时刻跑出来的产物可能不一致?因为两次可能被分给了不同环境的 Runner。想清楚这一点,后面很多疑问都会迎刃而解。
1.2 三种 Runner 层级和两种执行器逻辑
Runner 在 GitLab 里有三种注册层级:
- Shared Runner:注册在 GitLab 实例级别,实例上的所有项目都能使用。适合做通用构建资源池。
- Group Runner:注册在某个用户组下,组内项目共享。这是多数中型团队推荐的层级,既隔离了团队资源,又避免了每个项目单独维护 Runner。
- Project Runner:注册在单个项目下,只有该项目专属使用。适合有特殊环境要求、不希望被其他项目挤占资源的场景。
层级选择的逻辑并不复杂:共享越广,资源利用率越高,但环境差异和排队概率也越大;隔离越细,可控性越好,但维护成本直线上升。我自己在项目里的习惯是,通用 Java 构建任务放 Group Runner,涉及特殊硬件或者固定测试环境的时候才用 Project Runner。
Runner 还有一个维度是执行器,也就是它用什么方式来跑 job。最常用的三种:
| 执行器 | 运行方式 | 适合场景 | 缺点 |
|---|---|---|---|
| shell | 直接在 Runner 主机的 shell 里执行命令 | 构建流程简单、环境依赖少 | 环境隔离差,多个 job 可能互相污染 |
| docker | 每次启动一个新容器来执行 job | 绝大多数标准构建/测试任务 | 需要 Runner 主机支持 Docker,镜像拉取耗时 |
| kubernetes | 动态创建 Pod 执行 | 大规模、弹性调度需求 | 配置复杂,资源掌控要求高 |
简单使用阶段,我强烈建议直接上docker executor。原因不复杂:每次 job 都是干净环境,以同一个基础镜像为起点,命令执行完容器销毁,不会在 Runner 主机上积累垃圾依赖,Jobs 之间互不干扰。这也是后面我们将讲到的一个注册命令里面带一堆参数的原因。
2. 从注册 Runner 到构建机准备:跑通第一条流水线的完整链路
2.1 Runner 安装和注册的关键动作
安装 Runner 最简单的方式是直接用官方提供的二进制包。以 Debian/Ubuntu 为例:
curl -L "https://packages.gitlab.com/install/repositories/gitlab/gitlab-runner/script.deb.sh" | sudo bash sudo apt-get install gitlab-runner安装完之后,需要把 Runner 注册到 GitLab 实例。注册时需要用到两个关键参数:GitLab 实例的 URL 和项目(或实例/组)的注册令牌。项目里可以在Settings → CI/CD → Runners页面看到这些信息。
sudo gitlab-runner register \ --url https://gitlab.example.com \ --token glrt-xxxx \ --executor docker \ --docker-image alpine:latest \ --description "docker-runner-01" \ --docker-privileged false这里逐个说明参数含义:
--url:你的 GitLab 服务地址。私有化部署的朋友最容易在这写错,注意不要带/v4、不要带项目路径,只要域名根地址。--token:注册令牌。Project Runner 是项目专属的,Group Runner 是组内共用的,权限范围差距大,要如实填写。--executor docker:执行器类型。这里选 docker 意味着每一个 job 都会在容器中执行。--docker-image:默认镜像,job 里如果没有显式指定 image,就使用这个。--docker-privileged false:非特权模式,安全一些。只有当你明确要用 Docker-in-Docker 场景时才需要开 true。
注册成功之后,在配置管理页面能看到 Runner 显示为绿色可用状态。记住这条命令,它是你整个 GitLab CI/CD 使用生涯里最重要的一步,后面出问题也大都在这里埋下伏笔。
2.2 Runner 的并发和并发安全的真相
Runner 注册完成只是第一步。接下来一个容易被忽略的参数是config.toml里的concurrent设置。默认情况下,一个 Runner 同一时间只能执行一个 job。如果团队中小伙伴同时提交代码、触发多个流水线,后面的 job 全部排队。这个排队不是我们希望的“按顺序来”,而是纯粹的阻塞——第一个任务跑完之前,其他任务连容器都不会被创建。
/etc/gitlab-runner/config.toml里既有全局的并发数,也有每个 Runner 自己的并发数:
concurrent = 4 [[runners]] name = "docker-runner-01" limit = 2这里的经验是:不要盲目把并发数调到很大。docker executor 每个 job 会拉镜像、起容器、跑命令,都消耗宿主机 CPU 和内存。我在某次内部压测中发现,一台 4 核 8G 的机器,Runner 并发调到 6 以后,很容易出现容器启动超时和 OOM。更稳妥的做法是先按 CPU 核心数的一半起步,观察一段时间再慢慢加。
如果你只是想“简单使用”GitLab CI/CD,在团队规模不超过 10 人时,一个 2 并发的 Docker Runner 完全够用。等出现排队,再考虑你需不需要加机器,而不是憋着调并发。
2.3 验证 Runner 可用性的关键操作
Runner 注册完成后,最直接的一次验证是用一个最简单的 job 来测试。在项目根目录创建.gitlab-ci.yml:
stages: - test smoke-test: stage: test script: - echo "hello gitlab ci" - python3 --version提交推送到远端。正常情况下,在项目的CI/CD → Jobs页面能看到这条 job 被 Runner 领取,状态从 pending 变成 running,最后 success。如果一直停在 pending,先回 Runner 页面看状态是否为绿色,再检查 Runner 主机是否能连通 GitLab 实例。这里的连通指的不只是 ICMP 层面的 ping,而是 Runner 的 HTTP 请求能不能到达 GitLab 的 API 端点。很多时报错都是因为网络策略阻断了连接,尤其在私有化部署环境里。
这一条流水线跑通,意味着整个环境链路已经打通了。接下来才有资格讨论.gitlab-ci.yml里怎么写、怎么写好。
3. 一次性吃透 .gitlab-ci.yml:从最小可运行配置到常用关键字
3.1 最小的骨架和 job 概念
.gitlab-ci.yml是 GitLab CI/CD 的配置中心。它的核心概念是job。job 是你希望流水线执行的一个单元,一个 job 至少包含一个script段。多个 job 组合起来就是整个流水线,job 之间可以用stages来安排顺序。
一个稍微接近真实的骨架:
stages: - build - test - deploy variables: APP_NAME: demo-app BUILD_ENV: production cache: paths: - node_modules/ compile-job: stage: build image: node:18 script: - npm ci - npm run build artifacts: paths: - dist/ expire_in: 7 days tags: - docker unit-test: stage: test image: node:18 script: - npm run test dependencies: - compile-job先解释stages。它定义了流水线的阶段顺序,默认情况下同阶段内的 job 可以并行执行,不同阶段之间是串行的。前面阶段全部成功后,后面阶段才会开始。如果某一条 job 失败,默认行为是后面的阶段不会执行。这就是 GitLab CI/CD 作为一条流水线的基本门槛逻辑。
再说variables。变量可以在全局定义,也可以在 job 内部重写,甚至可以在脚本里动态定义并传递。变量的作用域优先级是:job 内定义 > 全局定义 > 预定义变量。这个优先级的记忆方式很直白——离 job 越近的定义越权威。
3.2 预定义变量:流水线给你的免费礼物
GitLab 提供了一套非常丰富的预定义变量,这些变量在每一个 job 里天然可用,不需要你手动设置:
| 变量 | 含义 |
|---|---|
CI_COMMIT_SHA | 当前流水线对应的 commit SHA |
CI_COMMIT_REF_NAME | 分支名或 tag 名 |
CI_PROJECT_NAME | 项目名 |
CI_PROJECT_DIR | 项目在 Runner 上的路径 |
CI_JOB_NAME | 当前 job 名称 |
CI_JOB_TOKEN | 当前 job 的临时令牌,用于 GitLab API 鉴权 |
CI_PIPELINE_ID | 流水线唯一 ID |
CI_REGISTRY_USER/CI_REGISTRY_PASSWORD | 用于往 GitLab Container Registry 推送镜像 |
我最常用的是$CI_COMMIT_SHA,比如在部署脚本里,会把当前 commit 写入远程服务器的版本文件;而$CI_JOB_TOKEN用在需要调用 GitLab API 的场景,比如自动创建 MR 或者给某个 commit 打标签。
使用预定义变量时注意一个细节:在script里使用直接用$VAR就行,但如果把变量塞进某些程序的命令行参数,最好用双引号包住,避免变量内含空格时被 shell 拆词。
3.3 image、artifacts、cache 这三个关键字必须分清楚
初学者最容易混的是cache和artifacts,二者都涉及文件的保存和传递,但作用完全不同。
artifacts:是 job 的产物。它会把指定路径的文件打包上传到 GitLab,作为后续 job 的输入。比如构建 job 产出的dist/目录,测试 job 依赖它来跑埋点校验。artifacts按 job 和流水线维度保存,可以设定过期时间。cache:是构建依赖的缓存,用来加速。比如前端项目里的node_modules/,如果每次 npm ci 都从零装依赖会非常慢,用 cache 机制可以把 node_modules 缓存下来,下次直接复用。cache 是按 key 来区分的,默认情况下每个 job 有独立的 cache,也可以自己指定cache:key来控制共享范围。
用一句话总结记忆:artifacts 是“我要交给下一个 job 的东西”,cache 是“我为了省时间想保留的东西”。前者必须存在,后者只是优化手段。
image关键字则指定 job 运行时的容器镜像。docker executor 模式下,每个 job 会基于这个镜像创建容器。没有写image的 job 使用 Runner 注册时的默认镜像;如果用 shell executor,image关键字无效,命令直接在宿主机执行。
这里再给你们一个真实经验的提醒:dependencies关键字可以控制某个 job 依赖哪个上游 job 的 artifacts。默认情况下,某条 job 会自动下载所有前面阶段产生的 artifacts,条件允许你尽可能指定dependencies,这样不但能减少数据传递的时间,还能避免不同 job 之间因为下载了不必要的产物而产生意外冲突。
3.4 rules 和 only/except:什么时候跑、什么时候不跑
控制 job 的触发条件是流水线里最有意思的部分。旧版本里大家习惯用only/except,现在新版推荐用rules,它更灵活,支持条件组合。简单的例子:
deploy: stage: deploy script: - ./deploy.sh rules: - if: '$CI_COMMIT_BRANCH == "main"' when: manual - if: '$CI_COMMIT_TAG'这个规则的意思是:当提交发生在 main 分支时,deploy job 会进入等待状态,需要人工点击才执行;当提交方式是打 tag 时,deploy job 自动执行。二者都不满足的提交不会进入这个 job。
rules的判断顺序是从上往下一个一个匹配,第一个命中的规则生效。所以写规则时要把最具体的条件放在前面,避免被后面的宽泛条件提前吞掉。这是一个极易踩坑的地方,我见过不少同事把规则顺序搞反,结果所有分支都触发了生产部署。
4. 流水线里常见的三种作业调度模式:全并行、串行茶馆和带人工卡点的部署
4.1 全并行测试矩阵
当你的代码需要在一组不同的环境里跑同一套测试时,最简单的做法是定义多个同样的 job,只是参数不同。但 GitLab CI/CD 出了更优雅的写法——parallel。在 job 里使用parallel: 5,可以让同一个 job 被拆成 5 个并行实例执行,它们有相同的硬件资源和脚本,只有签名不同。
更精细的做法是配合变量矩阵:
unit-test: stage: test image: python:3.11 parallel: matrix: - PYTHON_VERSION: ["3.10", "3.11", "3.12"] script: - python -m pytest这种写法可以在同一个 job 配置里提起多组测试变量,不需要复制粘贴整套 yaml。流水线图表上会显示多个小卡片,一眼就能看出哪个 Python 版本挂了。
4.2 串行门禁:测试不过就不让去部署
在真实项目里,流水线不是越快越好,而是要在关键节点关好门。最简单的门禁结构是 build → test → deploy,test 不通过,deploy 不启动。这个逻辑已经隐含在 GitLab 的 stage 里了。
但我不建议把所有验证都塞进同一个 stage。比如你的项目既有单元测试,又有静态代码扫描,还有安全扫描,从设计上应该拆成多个 stage 串起来,而不是全部并行在一个 stage 里跑。分线的意义在于:如果安全扫描失败,你希望它挡住后面的部署了,而不是和单元测试的结果混在一起,等跑完才发现,还得从头来过。
4.3 人工卡点:让部署主动权回到人类手里
生产环境的部署应当谨慎。GitLab 里有一种when: manual的 job,它会生成一个按钮,只有有权限的人员点击后才会继续执行。
deploy-prod: stage: deploy script: - export RELEASE_ENV=production - ./release.sh rules: - if: '$CI_COMMIT_BRANCH == "main"' when: manual environment: name: production加上environment关键字之后,流水线里的部署就不再只是一串脚本了,GitLab 环境页面会记录每一次部署对应到哪个 commit、部署时间、部署人。配合Settings → CI/CD → Protected Environments里设置部署权限,就能做到:只有指定的角色可以点这个部署按钮。这个能力对生产环境的价值极高——部署动作不依赖哪个人恰好记得刷一遍命令,而是固化成流水线里的一个可审计、可追溯的步骤。
这里要提醒从 Jenkins 转过来的网友:GitLab 的手动执行不是在页面上点一个“Build Now”,而是 job 本身有自己的生命周期。你点按钮的那个瞬间只是触发它,后续该分配 Runner 就分配 Runner,该拉镜像就拉镜像。所以对慢 job 记得设置合理的timeout,避免它挂着占用 Runner。
5. 五个我在真实项目里踩过的 GitLab 坑
5.1 共享 Runner 被不安全的 job 拖垮
用 Group Runner 跑团队里所有项目是一件省事但又有风险的事。曾有一次,组里的某人提交了一个带docker build的 job,他的构建脚本里没有清理镜像,每次构建都在 Runner 宿主机上留下一堆中间层镜像。仓库积累了几十 G 的垃圾,后面别人的 job 全都因为磁盘空间不足而失败。
这个问题的排查费了不少劲:明明自己的页面显示 job stuck,可 Runner 列表里状态是绿色的。最后登到 Runner 主机上一看,/var/lib/docker爆掉了。
解决思路是双管齐下:一方面在 Runner 主机上配置定期清理机制,比如 GitLab 官方容器镜像自带的gitlab-runner定时清理机制,另一方面在敏感 job 的script末尾加清理步骤,把构建时产生的临时镜像和容器显式删掉。如果你是那个团队里管 Runner 的人,我想说:不要以为 Runner 装完就一劳永逸了,它和服务器上的其他进程一样需要维护和治理。
5.2 cache key 不生效,导致每次从零构建
有段时间内部前端项目一直很慢,流水线平均耗时 8 分钟,而本地相同配置只要 40 秒。排查后发现,cache 配置里虽然写了 node_modules,但没指定key,GitLab 对每个 job 默认以分支号作为 cache key 的一部分,不同分支之间缓存不共享,长期不合并其他分支的 feature 分支全部得从零装依赖。
修复后的配置:
cache: key: files: - package-lock.json paths: - node_modules/这个cache:key:files做得非常聪明:它拿package-lock.json的文件内容做哈希作为 cache key。结构一致则 key 相同,所有分支、所有 job 共享同一套 node_modules 缓存;一旦依赖锁定文件变化,key 失效,自动重新构建缓存。这样既保证缓存有效性,又最大化复用率,是前端项目里标准的 cache 配置方式。
5.3 Docker executor 里的 Docker:一条隐藏的歧途
很多人想在 GitLab Runner 里直接 rundocker build,于是用--docker-privileged true开启了特权模式,然后在 job 的 script 里执行 docker 命令,发现报错找不到 docker socket。这个坑在 Linux 环境下几乎人人都会踩。
典型的错误信息是Cannot connect to the Docker daemon at unix:///var/run/docker.sock。新手上路最常犯的错是在脚本开头加了句apk add docker,然后失败,因为容器里那个 docker 客户端不是用来调宿主机 daemon 的。
正确解法有两个——要么使用 GitLab 官方提供的docker:dind服务在同一 job 里运行一个内部的 docker daemon,要么把宿主机的 docker socket 通过 volume 挂载到 job 容器里(/var/run/docker.sock:/var/run/docker.sock)。前者隔离性更好、较安全;后者的执行效率高、配置简单,但意味着 job 容器实际上拿到了宿主机的 docker 控制权,安全性要自己想清楚。在“简单使用”的阶段,我劝你别开 privileged,老老实实在模板里加一个 docker 登录步骤,或者直接把镜像构建放到更合适的专用流水线去。
5.4 分支合并瞬间的 pipeline 重复
GitLab 的基础触发逻辑是:每次 push 即触发流水线。当一个 MR 尚未合并,目标分支上来了一个新的提交,MR 的源分支可能又会 push 一次,导致同一个改动在多个上下文里产生流水线。如果这些流水线都在跑部署 job,很可能出现部署竞态:两条流水线同时尝试往同一个环境部署不同的版本。
解决方法是在较新版本里用workflow:rules控制整个流水线的触发范围,或者明确用rules过滤掉部分 job 的触发场景。我在线上团队里有一条不成文的规矩:部署类 job 的 rules 永远比构建类的严一档,宁可少触发一次,也不能并发部署。
5.5 artifacts 文件命中率低的反直觉建议
有时你在artifacts里写了dist/,但 GitLab 上传的包一直很大,明明 dist 里只有几个文件。这是因为artifact默认会把从 Runner 工作目录到dist/路径之间的所有相对路径原样打包,而 Runner 工作目录又往往是一个很大的 checkout 目录,里面包含了.git本身。
如果你确定产物不需要保留历史版本,且部署脚本可以直接拉取仓库,那么可以把artifacts的路径写得更精确,或者压根就不上传某些不需要的文件。否则,产物包体积膨胀,传到下一个 job 以及从 GitLab 下载都要花费额外时间,得不偿失。
6. 从“简单使用”到“顺手用”:三个立竿见影的进阶习惯
6.1 把常见的 job 抽成 include 模板
当多个项目要用同一个构建/测试脚本时,复制一份.gitlab-ci.yml不是长期办法。GitLab 的include机制允许你在一个项目的仓库里引用另一个仓库的 YAML 片段:
include: - project: 'devops/templates' ref: main file: '/ci-templates/frontend-build.yml'这样,前端项目的构建、测试步骤就统一由仓库devops/templates维护。团队在模板里修复一个 bug,所有引用它的项目都会立刻生效。这就是流水线配置从“复制粘贴”进化到“共享治理”的关键一步——运维要更新的不再是一个个项目,而是一份模板文件。
6.2 合理设置流水线超时,别让任务挂着吃资源
每个 job 可以单独设置执行超时时间,优先级从高到低是 job 级 > Runner 级 > 实例级。我在实际使用中都会给构建 job 设置一个相对宽裕但有限的值,比如timeout: 30 minutes。四小时、八小时这种超时值不是为 CI 任务准备的,更像是在给不正常的任务开绿卡。如果某个 job 遇到死锁,Runner 被它占住,后面整个团队都会被拖累。从运维的角度出发,宁可让一个偶尔因为编译太慢而超时的 job 失败重跑,也不要去承担一个卡死的 job 无限期占资源的风险。
6.3 CI/CD 变量尽量用受保护的变量,不要裸写在 .gitlab-ci.yml 里
把数据库密码、云厂商密钥、部署令牌直接硬编码到.gitlab-ci.yml,相当于把生产环境的钥匙贴在办公室门口。GitLab 的Settings → CI/CD → Variables页面提供了两种变量类型:普通变量和受保护变量。受保护变量只在受保护的分支或 tag 上触发流水线时才会被注入。简单说,你永远不要指望 feature 分支上的流水线能读到生产环境的密钥。
真正安全的设计是:开发分支能读开发变量的值、主分支只能读到预发布变量、生产环境密钥只在 tag 发布时出现,并且开启掩蔽,保证它在日志里永远不显示明文。这些设置都是 CI/CD 的“水电基础设施”,花十分钟做公道。
结束语
GitLab CI/CD 的“简单使用”最迷人的地方,是上手成本比我们想象中低。一个文件、一条注册命令、一台能装 docker 的机器,流程就能转起来。但随着流水线越来越多、覆盖的项目越来越杂,它对你的要求也会慢慢从一个“会写 yaml 的开发者”变成一个“会治理流水线的基础设施管理者”。我希望这篇博客可以把你的起点拉得足够高,少走那些我撞得直冒烟的弯路。
个人的小经验是:第一次跑通 GitLab CI/CD 之后,与其急着把公司所有项目都接进来,不如先用一个低风险的项目做试验,把构建、测试、部署三个阶段的配置都跑一遍,踩够姿势,再大规模铺开。流水线一旦成为团队默认的开发习惯,后续的优化才有真正的意义。这一篇讲的是门路,后面的系列还会往变量注入、多环境部署、父子管道这些方向走得更深,你们先跑起来,咱们下一篇见。