ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

GitLab CI/CD从零到一:核心概念与实战配置指南

GitLab CI/CD从零到一:核心概念与实战配置指南

直接跑题了扯概念没用,先把GitLab怎么用跑通才是正事。这个系列写到第九篇,前面的内容一直在讲持续集成、持续交付的理论,从这篇开始落到具体工具上。我选GitLab作为第一个实操对象,原因很直接:它把代码仓库、CI/CD执行器、制品管理、环境管理全塞进了一个平台里,中小团队想搭一套完整的持续交付链路,GitLab几乎是上手成本最低的选择。这篇就围绕一个主题——“gitlab简单使用”,把我自己从零开始接入GitLab CI/CD的完整过程、配置细节、踩过的坑整理出来,适合刚接触CI/CD、想快速落地一套流水线的人参考。

1. 先搞清楚:GitLab在CICD链路里到底扮演什么角色

1.1 为什么第一步是GitLab而不是别的工具

很多人在做CI/CD选型时,第一反应是Jenkins、GitHub Actions、CircleCI这些,GitLab反而不在首选名单里。但如果你把整条链路拆开看,GitLab的优势就非常明显了。它本身是一个代码托管平台,天然拥有代码的“上游”——也就是仓库。代码从提交到触发构建,再到跑测试、出产物、部署,整个过程中的数据源、触发源都在GitLab手里捏着。这意味着你不需要像Jenkins方案那样,单独部署一套服务、配置Webhook、维护节点和插件,也不需要在不同平台之间来回切换上下文。

我自己的经历很能说明问题。早前在某个小团队(某创业公司)里,代码托管用的是某代码托管平台,CI用Jenkins,两者靠Webhook联动。听起来没什么问题,但实际维护会发现很多碎片化的工作:Webhook偶尔断掉不通知了、Jenkins凭证过期了、构建节点上的环境和开发机不一致……每一次排查都要在两个系统之间来回跳。后来换到GitLab一家人方案之后,这些跨系统的问题直接消失了一半——不是问题不存在了,而是被统一到了同一个平台、同一套权限体系、同一个配置模型里。

对于团队规模不大、没有专职运维、又想迅速把CI/CD落地的场景,GitLab这样的一体化平台是最省心的起点。这也是我把这一篇定位为“简单使用”的原因——它的核心使用门槛不在于功能复杂,而在于你需要理解它那一套配置模型和几个关键概念,理解之后,日常场景覆盖足够了。

1.2 从代码提交到部署,数据是怎么流动的

先把整条链路在脑子里过一遍。开发者在本地提交代码,推送到GitLab仓库;GitLab检测到分支上的新提交;根据仓库根目录下的.gitlab-ci.yml文件内容,GitLab创建一个Pipeline(流水线);流水线被分配给某个空闲的Runner(执行器);Runner去仓库拉取代码,在隔离环境中按配置依次执行构建、测试、部署等任务;任务执行结果和日志回传到GitLab显示在页面上。整个流程不需要你手动点任何按钮。

这条链路里有一个关键点:GitLab本身不执行任何构建任务,它只负责“调度”和“展示”。真正干活的是Runner,Runner可以装在任何机器上——本机、服务器、容器、Kubernetes集群,都可以。GitLab把任务通过HTTPS长连接推给Runner,Runner执行完再把结果上报回来。这个模型的灵活之处在于:Runner是注册制的,你可以注册多台Runner,它们可以跑在不同环境里,构建、测试、部署各用各的执行器,完全解耦。

理解了这条数据流,后面所有配置都围绕一个核心文件展开——.gitlab-ci.yml。这个文件放在仓库根目录,定义了在什么时机、用什么环境、执行哪些命令。接下来的章节全部围绕它展开。

2. 上手前必须吃透的5个核心概念

2.1 项目(Project)不只是代码仓库

GitLab里的Project和你想的“一个代码仓库”不完全一样。它既有代码存储的功能,也包含了Issues、Merge Request、Milestone、CI/CD配置入口、制品仓库等一系列配套设施。你可以把它理解为一个“软件交付单元”,代码、协作、自动化构建都在同一个Project上下文里,天然地拥有了统一的权限模型。

这一点在实际使用中非常有用。比如你要限制谁可以推代码、谁可以合并、谁可以触发部署,直接配置Project的成员角色就行。再比如部署环境变量、部署密钥、Runner绑定关系,也都是挂在Project级别的。相比“代码仓库只管代码、CI平台另管一套权限”的模式,这种设计会把很多安全问题前置解决掉。

创建项目很简单:登录GitLab后点“新建项目”,选择“创建空白项目”,填名称和可见级别就行。不过我建议从一开始就考虑好可见性。内部项目意味着公司内任何人可看可拉,私有项目则只对指定成员开放。中小团队建议用内部项目,避免后面因为权限问题反复调整。

2.2 Runner:真正干活的执行者

Runner是整条流水线里唯一真正跑脚本的组件。它有三种作用域:共享Runner(整个GitLab实例中的所有项目可用)、分组Runner(某个分组下的项目可用)、项目Runner(只服务于指定项目)。共享Runner适合个人折腾、统一维护;生产环境建议优先用分组Runner或项目Runner,隔离性更好。

Runner本身需要安装在某个环境里,安装后通过注册与GitLab服务器建立连接。注册时你会拿到一个URL和一个Token,这个Token在GitLab项目页(或管理员页面)里可以找到,后面实操章节我会细讲。Runner有几个重要的运行配置:

  • concurrent:允许同时执行的任务数,默认值是1,意味着同一时间只跑一个任务,其他任务排队。
  • executor:执行器类型,决定了脚本在什么环境里跑,常见的有shell(直接在Runner宿主机上执行)和docker(每次拉镜像起容器执行)。
  • tags:Runner的标签,注册时可以指定,任务配置里写哪个标签,就分配给带这个标签的Runner。

这条Tag机制是整个调度里最容易踩坑的地方。任务写得再复杂,Runner的tag对不上,Pipeline就永远卡在pending状态。后面排查章节我会再强调一次。

2.3 Pipeline与Stage:流水线的骨架

一次提交触发的完整流程叫Pipeline。Pipeline由若干Stage(阶段)组成,Stage之间是串行关系:前一个阶段全部成功后,后一个阶段才开始。每个Stage内部可以并行执行多个Job(任务)。典型的阶段设计是build -> test -> deploy,构建完成后才跑测试,测试通过后才能部署。

这种流水线设计本质上是在约束“顺序”。构建、测试、部署天然有先后依赖,乱序执行没有意义。同时它也在提供“并行度”——一条流水线里,同一个Stage的多个Job可以并行跑,比如不同模块的单元测试可以同时执行,节省大量时间。关于如何判断一个Pipeline是不是健康,我的经验是:看哪个Stage经常失败、哪个Job总是排队,比看整体绿不绿更有价值。总绿色不代表没问题——有可能你压根没把关键测试放进去。

2.4 Job:最小的执行单元

Pipeline里的每个Job是真正会去执行脚本的最小单元。Job必须包含至少一个script——也就是一串Shell命令。它还可以指定自己的image(Docker镜像)、stage(所属阶段)、tags(匹配的Runner标签)、only/except(执行条件)、artifacts(产物保存)等属性。

如果只用一句话记住Job的定义:

Job = 一个名字 + 在哪个阶段执行 + 要跑什么命令 + 可选的其他行为限制。

这就好比流水线上的一个工位:名字是这个工位的编号,阶段是这个工位位于第几道工序,命令是这个工位具体做什么操作。理解了这一层,gitlab-ci.yml里的配置就没有任何神秘感了。

2.5.gitlab-ci.yml:整个流水线的唯一入口

GitLab的CI/CD配置全部集中在一个YAML文件里:.gitlab-ci.yml。GitLab在代码提交后自动识别仓库根目录的这个文件,根据文件内容生成对应的Pipeline。所以,你在GitLab页面上能操作的一切CI相关动作,本质上都在“编写/修改这个YAML文件”——页面操作只是结果展示。

一个最小的完整示例如下:

stages: - build - test build-job: stage: build script: - echo "Building..." tags: - my-runner test-job: stage: test script: - echo "Testing..." tags: - my-runner

这个配置声明了两个阶段、两个Job。提交后就能看到一条流水线,先是build-job,完成后再跑test-job。这里面最值得注意的一个细节是:Stage串行,同Stage的Job并行。也就是说,如果test阶段有10个Job,它们会一起排队并行执行,而不是挨个跑完再跑下一个。

3. 从零配置一套能跑的GitLab CI/CD

3.1 安装Runner并完成注册

Runner的安装可以分两种场景:第一种是装在个人电脑或服务器上,自给自足;第二种是装进Kubernetes集群里,按任务动态创建Pod。这里只讲最简单、最适合起步的shell执行器和docker执行器的安装注册过程。

以Linux服务器为例,安装Runner的步骤大致如下:

# 下载GitLab Runner二进制(根据你的系统架构选择版本) sudo curl -L --output /usr/local/bin/gitlab-runner \ "https://packages.gitlab.com/runner/gitlab-runner/packages/el/8/gitlab-runner-16.0.0-1.x86_64.rpm" chmod +x /usr/local/bin/gitlab-runner # 创建GitLab CI用户 sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner \ --shell /bin/bash # 安装并启动服务 sudo gitlab-runner install --user=gitlab-runner sudo gitlab-runner start

安装完成之后需要做一步至关重要的操作——注册。注册前要先去GitLab项目页面拿两个关键信息:GitLab实例的URL和注册Token。路径是:项目 → 设置 → CI/CD → Runner → 展开“项目Runner”区域。

拿到之后,执行注册命令:

sudo gitlab-runner register \ --url "http://你的GitLab地址/" \ --token "粘贴你的注册Token" \ --description "my-first-runner" \ --executor "shell" \ --tag-list "my-runner"

执行完这个命令,Runner就接入GitLab了。回到页面上刷新一下,Runner状态会从灰色变成绿色,说明连接正常、可以接收任务。

这里有一个非常关键的细节:执行器选择要慎重。shell执行器直接在Runner宿主机上执行命令,适合初期练习,但宿主机环境会被任务脚本污染——你装了Java,另一个任务可能覆盖了Python版本;跑依赖安装可能改了全局的包管理状态。docker执行器则每次拉取指定镜像、起一个干净容器、执行完销毁,隔离性非常好。我个人的建议是:如果机器上能装Docker,就直接用docker执行器,不要用shell执行器走弯路。docker执行器的注册只需要把--executor "shell"换成--executor "docker"再加一个--docker-image "alpine"指定默认镜像即可,成本几乎一样,换来的隔离性差别巨大。

3.2 第一个Pipeline:构建、测试、部署一条龙

Runner就绪之后,接下来就是写.gitlab-ci.yml了。一个标准的、可落地的三段式流水线,我直接给出一份能跑通的参考配置:

stages: - build - test - deploy variables: APP_NAME: demo-app APP_ENV: staging build-job: stage: build image: node:18-alpine script: - npm ci - npm run build artifacts: paths: - dist/ tags: - my-runner test-job: stage: test image: node:18-alpine script: - npm ci - npm run test:unit tags: - my-runner deploy-job: stage: deploy image: alpine:latest before_script: - apk add --no-cache openssh-client script: - scp -r dist/ user@server:/var/www/html/ only: - main tags: - my-runner

这份配置对应了三个关键思想:一是依赖安装和构建命令都放在Docker容器里执行,宿主机保持干净的运行环境;二是artifacts参数把构建产物dist/保留并传给后续Job使用;三是部署这个 Job 加了only: main,也就是只有主分支提交才会触发部署,日常功能分支不会误报——这是规范里非常重要的一个分支策略。

部署命令里出现了before_script,这个字段会在Job的script之前执行,适合放一些准备工作,比如安装SSH客户端、加载环境变量。整个Job的运行分三步:先是拉镜像,再执行before_script里的准备命令,最后执行script里的核心命令。

3.3 常用关键词和它们的行为边界

配置写多了你会发现,.gitlab-ci.yml的关键词其实就十几二十个,但组合起来能表达的行为千变万化。我把自己日常使用频率最高的几个列出来,配合说明和注意事项:

关键词作用注意事项
stages定义所有阶段及执行顺序必须保持自上而下的依赖顺序
scriptJob要执行的命令列表任意一条命令返回非0,Job即失败
before_script在script之前执行的准备命令常用于安装工具、加载密钥
after_script即使Job失败也会执行常用于收尾清理、归档日志
image指定执行任务时使用的Docker镜像仅在docker执行器下生效
tags指定分配给带对应标签的Runnertag不匹配,Job会一直pending
only/except控制Job在何种条件下执行支持分支名、标签、合并请求等
rulesonly/except的增强版支持更复杂的条件表达式与when
artifacts保存Job产物并传递给下游Job默认保留时间有限,需按需设置
cache缓存依赖目录,加速后续Job和artifacts有区别,容易混淆
variables定义变量,供所有Job使用支持API方式动态注入
when控制Job执行结果的处理策略如on_success、on_failure、always
retry失败后自动重试指定次数适合偶发抖动型任务
timeout限制Job最大执行时间防止任务卡住无限占用Runner

这里重点讲两个最容易混淆的:cache和artifacts。很多初学者分不清它们。简单说:artifacts是“成品”——构建出来要传给下一个Job或者供下载的产物;cache是“半成品”——为了下次构建更快,把依赖目录临时缓存下来。它们都占用Runner机器的存储空间,但生命周期不同。artifacts默认在Pipeline结束后保留几天,cache则按缓存策略在后续Pipeline中复用。别把不该缓存的大文件放进cache,磁盘会被撑爆的。

rules是较后期版本引入的增强用法,可以取代大部分only/except的场景。举个典型例子:

deploy-prod: stage: deploy script: - echo "Deploy to prod" rules: - if: '$CI_COMMIT_BRANCH == "main"' when: manual - when: never

这段配置的意思是:只有主分支才会出现这个Job,并且是需要手动点击执行,其他分支根本不创建这个Job——用when: never直接跳过。这种写法在需要“人工确认再上生产环境”的流程里非常实用。实际部署场景里,我几乎不会让代码推送到某个分支就直接自动上生产,而是会加这样一步手动确认环节。

4. 实测下来最常见的坑和排查手册

4.1 Runner状态与Job排队的隐蔽问题

最糟糕的情况不是任务失败,而是任务压根没开始跑——Pipeline一直停留在pending状态。这是GitLab CI/CD新手遇到最多的问题,没有之一。排查思路按优先级排列如下:

  1. 看Runner有没有绿色在线:项目页面 → 设置 → CI/CD → Runner,如果Runner是灰色,说明它和GitLab的连接断开了,去Runner所在机器看看服务是否存活。
  2. 看Job用了什么tags:Job配置里写了tags: [xxx],但Runner注册时没给那个tag,队列就永远匹配不上。这个错误在本地可以把Runner不设tags,把Job的tags字段也去掉,或者注册Runner时把tag写成一致的字符串。
  3. 看并发满了没有:Runner配置里concurrent = 1,之前有条任务卡住了还没结束,后面所有任务都排队。可以临时把并发数调大,或者去页面取消那些卡住的任务。
  4. 看Runner是否处于暂停状态:页面上的 Runner 如果带“暂停”标识,需要恢复。多见于手动误操作或部署时的自动维护。

我这个排查顺序是按照出现概率排的:tags不匹配最多,Runner离线其次,并发占满第三。

4.2 脚本执行环境的隐性不一致

在本地跑得好好的命令,到Runner上就报错。这个问题在shell执行器下尤其常见。根因是:本地环境是开发机,Runner环境是服务器,两者的Node版本、Java版本、系统包管理工具、环境变量、当前用户权限全部可能有差异。一个典型的例子是:本地用nvm管理Node版本,终端里自动加载,而Runner以gitlab-runner用户执行任务,压根没有nvm环境,node命令找不到。

要解决这个问题,有两个方向。一是统一镜像环境:用image: node:18-alpine指定Docker镜像跑任务,镜像里的环境是固定不变的,从源头消除环境差异。二是让Runner用户环境干净化:在Runner机器上为CI单独创建用户,只装必要的工具,不继承开发机上的任何默认配置。我强烈建议前者——一旦流水线完全容器化,本地环境怎么折腾都不影响任务结果。

还有一个隐蔽问题:当前用户权限。Runner以独立用户运行时,有些目录没有写权限。比如npm全局安装、写入/usr/local、读取/root/.ssh等,都会失败。我的做法是给CI用户授予只针对构建目录的写权限,部署阶段依赖的SSH密钥单独放到Runner用户自己的目录下,不要把root用户的密钥复制过去。权限最小化原则同样适用于CI系统。

4.3 部署任务里SSH认证失败

部署场景里需要一个从Runner到目标服务器的SSH通道,这个环节最容易出问题。先解释一个概念:如果Runner是Docker执行器,它每执行一次任务都是新起的容器,容器里可能连SSH客户端都没有,更不用说存放密钥。所以部署任务通常要做三件事:安装SSH客户端、准备密钥文件、修改密钥权限。

我在部署任务里用的是这样的组合:

deploy-job: stage: deploy image: alpine:latest before_script: - apk add --no-cache openssh-client - mkdir -p ~/.ssh - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh-keyscan -H server-host >> ~/.ssh/known_hosts script: - scp -r dist/ user@server-host:/var/www/html/

这段配置里三个细节值得说明。密钥来自$SSH_PRIVATE_KEY这个变量——你在GitLab项目的“设置 → CI/CD → 变量”里录入,录入时勾选“保护”和“掩码”,这样密钥内容不会在日志里直接暴露。chmod 600是必须的,OpenSSH对密钥文件权限要求严格,权限太松会直接拒绝使用。ssh-keyscan把目标服务器指纹写入known_hosts,避免首次连接时的交互式确认——非交互环境里这个确认会直接导致任务失败。

实际发生过刚配置好的密钥连接失败,看日志报Permission denied (publickey),排查下来发现是变量里的密钥尾部多了个换行,GitLab变量保存时有时候会带上看不见的字符。处理方式是录入密钥前先查看一下内容,确保首尾没有多余空白行。这是真实踩过的坑。

4.4 构建缓慢和缓存策略误区

任务跑起来了,也没报错,就是慢。这时候大部分人第一时间想到加Runner机器配置,但更常见的原因是依赖安装每次都重复执行、网络拉取慢、或者Docker镜像每次都重新拉取。我的判断顺序是:

  1. 检查是不是每次都重新拉了个很大的基础镜像。Docker执行器每次任务都要拉取image,如果用的是node:18这种几百MB的镜像,每一个Job都要消耗时间。可以在Runner配置里加pull_policy调整策略,或者使用本地已有缓存。
  2. 检查依赖缓存是否生效。npm ci比npm install更严格也更快。缓存方面,给依赖目录配上cache可以明显加速:
cache: key: "$CI_PROJECT_NAME-$CI_COMMIT_REF_SLUG" paths: - node_modules/

这里要注意一点:这个cache的key改成带分支名的形式,不同分支之间不会互相污染缓存。如果不加key,默认情况下同一个项目所有Job共享缓存,很容易出现“A分支改了依赖,B分支受牵连”的怪问题。

  1. 看网络阻塞点。Docker Hub在大陆的访问速度慢是老问题,解决方案是配置镜像加速器,或者提前把镜像拉到Runner所在机器上,再改成pull_policy: if-not-present,让Runner优先用本地镜像。

4.5 生命周期结束的Job为什么还在跑

还有一种很迷惑的现象:在GitLab页面上手动点了Job的取消/停止,但Runner机器的进程还在跑,好像没收到停止信号。这通常是Runner版本和GitLab版本差异太大导致的兼容性问题。GitLab会尝试让Runner主动杀掉对应进程,但如果Runner的版本太老,实现不了这个信号机制。

解决思路很简单:保持Runner与GitLab版本接近。Runner的更新频率很高,每次GitLab大版本升级后,Runner也同步升一下级。我见过某团队长期不更新Runner,结果某天升级GitLab后Runner直接连不上服务,所有Pipeline报500错误。升级Runner很快,一条命令的事:

sudo gitlab-runner stop sudo apt update sudo apt install --only-upgrade gitlab-runner sudo gitlab-runner start

升级时间不要选在业务高峰期,停Runner那几十秒内,新的任务无法被调度,正在执行的任务也会中断。升级前先在页面上把Runner暂停,等所有排队Job清空后再停服务升级,最后恢复上线。

5. 一点实操心得

GitLab CI/CD的“简单使用”可以总结成一句话:先把Runner跑起来,再写好.gitlab-ci.yml,后面的细化都是锦上添花。但有几个原则是必须从一开始就立住的:CI脚本永远给最小权限、Runner环境永远保持干净、密钥永远走GitLab变量而不是写死在仓库里。看似是技术规范,背后其实是运维安全意识。每次遇到“本地可以怎么到Runner就不行”这类问题,不急着怀疑GitLab,先检查环境差异。环境不一致是CI里九成问题的根源。用Docker执行器把这些差异抹平,多半就消停了。搭建这套流程时耐心点,多观察几轮Pipeline日志,很多问题在日志里写得明明白白,比依赖记忆靠谱得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进