我接触过不少 15-50 人的中小研发团队,聊到 CI/CD 时最常见的反应是:"知道它有用,但一直没顾上搭,现在还是手动构建、手动跑测试、手动上传服务器。"
然后我问他们:每次手动发布花多少时间?
答案通常在 20 到 40 分钟之间。按每周发布两次算,一个 10 人团队一年花在手动发布上的时间将近 300 个小时------相当于一个半月的人力。
这篇文章不讲概念,直接带你从零搭一条可用的 GitLab CI/CD 流水线。全程有代码、有配置、有避坑提示------照着做,今天下班前你的项目就能跑通第一条自动化流水线。
阅读前提:你的代码已经托管在 GitLab(SaaS 或 Self-Managed 均可),项目根目录下有一个可构建和可测试的代码库。

0. 前置准备:安装并注册 GitLab Runner
这是最多人被卡住的一步:.gitlab-ci.yml 写好了,推上去发现流水线一直显示 pending------因为根本没有 Runner 来执行它。
0.1 安装 Runner
选一台 Linux 服务器(2 核 4G 就能跑),执行以下命令:
bash
# 添加 GitLab 官方仓库
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
# 安装
sudo apt-get install gitlab-runner
# 验证安装
gitlab-runner --version
如果是 Docker 环境,直接用官方镜像更省事:
bash
docker run -d --name gitlab-runner \
--restart always \
-v /srv/gitlab-runner/config:/etc/gitlab-runner \
-v /var/run/docker.sock:/var/run/docker.sock \
gitlab/gitlab-runner:latest
0.2 注册 Runner
安装完成后,你需要把 Runner 注册到你的 GitLab 项目或 Group。在 GitLab 项目的 Settings → CI/CD → Runners 页面找到 Registration Token,然后执行:
bash
sudo gitlab-runner register
交互式命令行会依次问你:
- GitLab 实例 URL(SaaS 填
https://gitlab.com,私有部署填你自己的地址) - Registration Token(从项目设置页面复制)
- Runner 描述(随意填写,如"dev-server-runner")
- Runner 标签(建议填
docker,linux,后面.gitlab-ci.yml里的tags字段会用到) - Executor 类型------选
docker(中小团队最省心的选择,不需要在 Runner 服务器上装 Node.js/Java/Python 环境)
注册成功后,回到 GitLab 项目的 Runners 页面,你应该看到一个绿灯标记的 Runner。此时再推 .gitlab-ci.yml,流水线就能跑起来了。
避坑 :Executor 别选
shell。虽然配置简单,但 Runner 服务器上装的各种语言环境会和你的本地环境不一样,最终又回到"在我机器上能跑啊"的困境。Docker Executor 用镜像保证环境一致性,这是 CI/CD 可靠性的基石。
1. 流水线设计:先把图纸画好
动手写 .gitlab-ci.yml 之前,先想清楚你的流水线要分几步。中小团队不需要一步到位搭出大厂的 DevOps 全景图,以下四个阶段是性价比最高的起点:
代码推送 → 自动构建 → 自动测试 → 自动部署到测试环境
对应的 GitLab Pipeline Stages:
yaml
stages:
- build # 编译/安装依赖
- test # 单元测试 + 代码规范检查
- deploy # 部署到测试环境
三个 Stage,分别对应三个问题:能不能编译过?测试有没有挂?有没有自动部署到测试环境让 QA 验证?
这三个问题自动化之后,团队协作会发生一个微妙的变化------测试人员不再需要等开发手动部署,"代码推上去几分钟后就能测"变成了默认状态。有人把这种状态叫做"持续集成的基本尊严",我觉得挺贴切。
2. 第一步:Build 阶段
Build 阶段的目标很简单------确保每一次代码提交都能成功编译,不出现"在我本地能跑啊"的经典对话。
yaml
stages:
- build
- test
- deploy
variables:
NODE_VERSION: "18"
before_script:
- docker pull node:${NODE_VERSION}-alpine
build_job:
stage: build
image: node:${NODE_VERSION}-alpine
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 hour
only:
- merge_requests
- main
- develop
几个关键配置的解释:
npm ci 而不是 npm install :npm ci 严格按 package-lock.json 安装依赖,不会偷偷升级版本号。CI 环境里用 npm install 最大的坑就是------某个依赖的 patch 版本升级了但你不知道,线上环境和 CI 环境的行为不一致,排查到崩溃。
artifacts :Build 产出的 dist/ 目录作为制品传递给下游 Job。设置 expire_in: 1 hour 是因为制品只需要在本次流水线内有效,存太久浪费存储空间。
only:限制触发条件------只有合并请求、main 分支和 develop 分支的推送才触发 Build。避免每个 feature 分支的每次 push 都跑一遍完整流水线。
技术栈适配:Java Maven 项目
如果你的项目是 Java + Maven 技术栈,Build 阶段改成这样:
yaml
build_java:
stage: build
image: maven:3.9-eclipse-temurin-17
script:
- mvn clean compile -DskipTests
artifacts:
paths:
- target/*.jar
expire_in: 1 hour
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .m2/repository/
only:
- merge_requests
- main
Maven 特有注意点 :-DskipTests 在 Build 阶段跳过测试(测试交给 Test 阶段的单独 Job 做),.m2/repository/ 缓存能大幅减少每次拉依赖的时间------Java 项目的依赖体积通常比 Node.js 大一个数量级,不缓存的话每次构建要多等好几分钟。
技术栈适配:Python 项目
yaml
build_python:
stage: build
image: python:3.11-slim
script:
- pip install -r requirements.txt
- python -m compileall .
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .cache/pip/
only:
- merge_requests
- main
Python 项目在 CI 里不需要像 Node.js / Java 那样的显式编译步骤,compileall 主要是做语法检查------确保所有 .py 文件没有语法错误。真正的价值在 Test 阶段的 pytest + flake8。
3. 第二步:Test 阶段------不只是跑测试
很多团队的 Test 阶段只做一件事:npm test。够用吗?够。但你可以用几乎零额外成本多做两件事,让自动化测试的价值翻倍。
yaml
# 单元测试
unit_test:
stage: test
image: node:${NODE_VERSION}-alpine
script:
- npm ci
- npm run test -- --coverage
coverage: '/All files\s+\|\s+(\d+\.?\d+)/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
only:
- merge_requests
- main
# 代码规范与安全检查
lint:
stage: test
image: node:${NODE_VERSION}-alpine
script:
- npm ci
- npm run lint
allow_failure: false
only:
- merge_requests
- main
coverage 正则提取:GitLab 会自动从测试输出中抓取覆盖率数字,显示在 MR 页面上。比如"覆盖率 87% → 85%",一眼看出这次改动是提升了还是拉低了测试覆盖。这个数字在 Code Review 时是一种无形的压力------"你加的代码,测试覆盖掉了 2%,加一个吧?"
allow_failure: false 在 lint job 上:ESLint 挂了,Pipeline 直接标红。这个配置的关键在于------它把代码风格从"建议"变成了"强制"。团队不再需要有人在 Code Review 里反复提醒"这里少了一个分号",机器人替你挡了。
4. 第三步:Docker 镜像构建(可选但推荐)
如果你的部署方式是基于 Docker 的,这一步不能省:
yaml
docker_build:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker build -t ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA} .
- docker push ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}
only:
- main
镜像标签用 Commit SHA :别用 latest。latest 标签在回滚的时候毫无用处------你根本不知道上一次部署的 latest 对应哪个版本。用 Commit SHA 的好处是可追溯------任何一个镜像都能在 Git 历史里找到对应的代码版本,出问题的时候一行 git show 就能查清楚。
5. 第四步:Deploy 到测试环境
测试环境部署是整个流水线的最后一步,也是最容易"自动化了一半"的一步------流水线跑完了,部署还得手动点一下。
yaml
deploy_staging:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
script:
- |
ssh -o StrictHostKeyChecking=no deploy@staging-server << 'EOF'
cd /app/project-name
docker pull ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}
docker stop project-staging || true
docker rm project-staging || true
docker run -d --name project-staging \
-p 3000:3000 \
${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}
docker image prune -f
EOF
environment:
name: staging
url: https://staging.your-domain.com
only:
- main
environment.name 和 url:配置了这两个字段之后,GitLab 会在项目的 Environments 页面生成一个入口------测试人员点一下就能直接打开测试环境,不用在群里反复问"测试环境的地址是什么来着"。
安全提醒 :生产环境的 SSH 私钥一定要存在 GitLab CI/CD Variables 里,类型选"Masked",不要硬编码在 .gitlab-ci.yml 里。
进阶:用 docker-compose 替代裸 SSH 命令
SSH + 手动 docker run 在只有一个容器的项目里够用,但一旦你的服务依赖了 MySQL、Redis 等外部容器,裸命令就会迅速失控。升级方案是用 docker-compose:
在项目根目录维护一份 docker-compose.staging.yml:
yaml
version: '3.8'
services:
app:
image: ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}
ports:
- "3000:3000"
environment:
- DB_HOST=db
- REDIS_HOST=redis
depends_on:
- db
- redis
db:
image: postgres:15-alpine
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
pgdata:
然后 Deploy Job 简化成:
yaml
deploy_staging:
stage: deploy
script:
- scp docker-compose.staging.yml deploy@staging-server:/app/
- ssh deploy@staging-server "cd /app && docker compose -f docker-compose.staging.yml up -d --remove-orphans"
这样做的好处是:依赖的服务(DB、Redis)声明在 compose 文件里,CI 脚本不关心环境细节,任何环境切换只需要换一份 compose 文件。
进阶:往 Kubernetes 迁移的提示
如果团队在未来半年有 K8s 迁移计划,建议在现阶段就把部署参数(镜像地址、端口、环境变量)写成 GitLab Variables 而非硬编码。迁移 K8s 时只需要把 Deployment Job 的 script 替换成 kubectl apply,其他部分保持不变。一步到位的 K8s 对中小团队来说过重,但做好参数化能让未来的升级路径非常平滑。
6. 完整配置一览
把上面的模块拼起来,一条最小可用的流水线长这样:
yaml
stages:
- build
- test
- deploy
variables:
NODE_VERSION: "18"
# ========== Build ==========
build_job:
stage: build
image: node:${NODE_VERSION}-alpine
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 hour
only:
- merge_requests
- main
# ========== Test ==========
unit_test:
stage: test
image: node:${NODE_VERSION}-alpine
script:
- npm ci
- npm run test -- --coverage
coverage: '/All files\s+\|\s+(\d+\.?\d+)/'
only:
- merge_requests
- main
lint:
stage: test
image: node:${NODE_VERSION}-alpine
script:
- npm ci
- npm run lint
allow_failure: false
only:
- merge_requests
- main
# ========== Deploy ==========
deploy_staging:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
script:
- |
ssh -o StrictHostKeyChecking=no deploy@staging-server << 'EOF'
cd /app/project-name
docker pull ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}
docker stop project-staging || true
docker rm project-staging || true
docker run -d --name project-staging -p 3000:3000 ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}
EOF
environment:
name: staging
url: https://staging.your-domain.com
only:
- main
7. 配合项目管理工具,让流水线真正闭环
一条 CI/CD 流水线搭好了,代码提交→构建→测试→部署的链路自动跑起来了。但这里还有一个断点:开发怎么知道自己的提交有没有被部署?测试怎么知道哪个版本可以去测了?
这个断点靠流水线本身填不上,需要项目管理工具来补位。
以国内使用广泛的禅道为例。禅道支持 GitLab 集成------在禅道后台配置好 GitLab 的 Webhook 之后,开发提交代码时只要在 Commit Message 中引用禅道的任务 ID(比如 fix bug #1234),GitLab 流水线状态会自动同步到禅道的对应任务详情里。
具体效果是:测试人员在禅道里打开一个 Bug,看到提交记录、Pipeline 状态、部署的镜像版本全挂在下面------不需要切换到 GitLab 再切换回来。项目管理软件和 CI/CD 工具之间的这道信息断点被打通了。
配置不复杂------在禅道的"DevOps 集成"设置中填入 GitLab 的 API Token 和项目 ID,勾选"同步 Pipeline 状态"和"同步 Commit 记录"即可。Webhook 模式比轮询模式延迟更低,建议直接选 Webhook。
工具链的理想状态不是"每个工具都最强",而是"工具之间的信息流动不需要人手动搬运"。
8. 常见踩坑与解法
坑 1:npm ci 在 CI 里跑得特别慢
解法:GitLab CI 支持 cache 配置。把 node_modules 目录缓存起来,下次 Job 直接复用:
yaml
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
坑 2:同一个流水线跑了两遍
原因:你同时触发了 merge_requests 和 branch push 两条规则。解法:加 except 排除重复触发,或者用 GitLab 15+ 的 workflow:rules。
yaml
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
坑 3:部署脚本报错"权限不足"
SSH 部署时最常见的问题。排查顺序:①确认 CI Variables 里 SSH 私钥配置正确,格式是完整的 -----BEGIN RSA PRIVATE KEY----- 开头;②确认目标服务器的 ~/.ssh/authorized_keys 里有对应的公钥;③在 CI 脚本里加一行 ssh -v 诊断。
❓ FAQ
Q1:GitLab CI/CD 和 Jenkins 怎么选?小团队用哪个合适?
小团队优先选 GitLab CI/CD。原因简单:代码已经在 GitLab 上了,CI/CD 配置文件和代码放在同一个仓库里,不需要额外部署一台 Jenkins 服务器。Jenkins 的灵活性更强,但小团队很少有那种"灵活到需要 Jenkins"的复杂场景。先把 GitLab CI 用起来,真有搞不定的需求了再考虑 Jenkins。
Q2:流水线跑一次要好几分钟,怎么加速?
三个见效最快的手段:①配置 cache 缓存依赖和构建产物;②把可以并行的 Job 放到同一个 Stage------GitLab 会自动并行执行同 Stage 的 Job;③在 Docker Runner 上配置镜像预拉取,省掉每次 docker pull 的时间。
Q3:生产环境部署要不要也全自动化?
看阶段。团队 DevOps 成熟度不够的时候,生产环境部署建议保留"手动触发"------在 .gitlab-ci.yml 里给 production Stage 加 when: manual。流水线跑到生产这一步自动暂停,由指定负责人点击确认后再执行。等团队在测试环境的自动部署上积累了至少三个月的信心,再考虑全自动。
Q4:多人同时 push,流水线怎么排队?
GitLab 默认按项目设置并发数。免费版一般是 1 个并发 Runner,后面的 Pipeline 自动排队。团队超过 10 人以后,建议至少配置 2-3 个 GitLab Runner,或者用 GitLab 提供的共享 Runner。可以在项目的 Settings → CI/CD → Runners 里看到当前可用的 Runner 数量和状态。
Q5:禅道和 GitLab 集成后,任务状态能自动更新吗?
可以。禅道和 GitLab 的集成支持双向同步。配置 Webhook 之后,GitLab 的 Pipeline 状态(通过/失败/进行中)会自动更新到禅道关联任务的"DevOps 信息"区域。同时,你可以在禅道任务详情里直接看到关联的 Commit 列表和 Merge Request 状态。配置路径:禅道后台 → DevOps 集成 → 添加 GitLab 服务器 → 填入 API Token → 勾选需要同步的项目 → 测试连接。整个过程大约 10 分钟。
本文 CI/CD 配置示例基于 GitLab 15.x 版本和 Node.js 18 环境。不同技术栈的构建命令和 Docker 镜像请自行替换,Pipeline 结构和 Stage 划分思路是通用的。