一、为什么需要 CI/CD
第一次带团队做发布时,我们的流程是:本地 mvn package → 把 jar 包 scp 到服务器 → 杀掉旧进程 → 启动新进程。一次发布要 20 分钟,且三个人发布的姿势各不相同。某次线上事故的根源,正是某同事本地 JDK 版本比生产环境高了一个小版本,导致一个语法特性在服务器上直接崩溃。
CI/CD 的核心价值不是"快",而是确定性:无论谁触发、在哪台机器触发,构建与部署的结果都完全一致。GitLab CI/CD 把这套确定性固化成了仓库里的一个 .gitlab-ci.yml 文件。
经验法则:凡是需要"人工记住步骤"的发布流程,迟早会出事故。把步骤写进流水线,事故率至少下降一个数量级。
二、核心概念:Pipeline / Stage / Job
GitLab CI 的三层模型必须先在脑子里建清楚:
Pipeline(一次提交触发的完整流程)
├── Stage: build (阶段,按顺序执行)
│ ├── Job: compile (任务,同一 Stage 内并行)
│ └── Job: lint
├── Stage: test
│ ├── Job: unit-test
│ └── Job: integration-test
└── Stage: deploy
├── Job: deploy-staging
└── Job: deploy-prod
- Job 是最小执行单元,对应 Runner 上跑的一段脚本。
- Stage 定义执行顺序,前一个 Stage 全部成功后才会进入下一个。
- Pipeline 是一次 Push 或 Merge Request 触发的所有 Job 集合。
一个最朴素的 .gitlab-ci.yml:
stages:
- build
- test
- deploy
compile:
stage: build
script:
- echo "编译中..."
- make build
artifacts:
paths:
- dist/
unit-test:
stage: test
script:
- make test
deploy-staging:
stage: deploy
script:
- ./deploy.sh staging
only:
- main
三、Runner 注册:流水线的发动机
GitLab 本身不执行任务,真正干活的是 Runner。它可以是共享 Runner(GitLab.com 提供),也可以是你自己部署的私有 Runner。
私有 Runner 注册流程(以 Shell Executor 为例):
# 安装 gitlab-runner
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh" | sudo bash
sudo yum install gitlab-runner -y
# 注册 Runner 到项目
sudo gitlab-runner register \
--url "https://gitlab.com/" \
--registration-token "YOUR_PROJECT_TOKEN" \
--description "prod-runner-01" \
--executor "shell" \
--tag-list "prod,docker" \
--run-untagged="false"
注册后几个关键配置(在 /etc/gitlab-runner/config.toml):
[[runners]]
name = "prod-runner-01"
url = "https://gitlab.com/"
token = "xxxx"
executor = "shell"
# 限制并发,避免把机器打满
limit = 4
[runners.cache]
# 用 S3 或本地目录做跨 Job 缓存
Type = "local"
Path = "/cache"
避坑 1:Shell Executor 默认以 gitlab-runner 用户执行,很多命令需要 sudo。不要图省事把 Runner 配成 root 执行器——这是严重的安全风险。正确做法是用 sudoers 给该用户放开特定命令:
# /etc/sudoers.d/gitlab-runner
gitlab-runner ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp, /usr/bin/docker
四、缓存与产物:速度的分水岭
CI 慢,90% 是因为依赖每次重新下载。GitLab 提供两种"中间产物"机制,很多人分不清:
| 机制 | 作用域 | 典型用途 |
|---|---|---|
cache |
跨 Pipeline 复用(不保证存在) | node_modules、maven .m2 |
artifacts |
跨 Job 传递(保证存在,有保留期) | 编译产物、测试报告 |
variables:
MAVEN_OPTS: "-Dmaven.repo.local=.m2"
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .m2/
- node_modules/
build:
stage: build
script:
- npm ci
- npm run build
artifacts:
expire_in: 1 week
paths:
- dist/
避坑 2:cache 在多 Runner 环境下默认不共享(除非用分布式缓存后端)。如果你有两台 Runner 且任务可能漂移到不同机器,本地缓存会"有时命中有时不命中"。生产环境建议配置 S3 兼容的分布式缓存:
[runners.cache.s3]
ServerAddress = "oss.example.com"
BucketName = "gitlab-cache"
BucketLocation = "cn-hangzhou"
五、环境变量与 Secrets:别把密码写进仓库
.gitlab-ci.yml 会被提交到仓库,所以任何密钥都不能硬编码。GitLab 的项目设置 → CI/CD → Variables 里配置加密变量,流水线中直接以环境变量读取:
deploy-prod:
stage: deploy
script:
- ssh -o StrictHostKeyChecking=no deploy@$PROD_HOST "cd /app && ./update.sh"
only:
- main
environment:
name: production
对于更敏感的场景(如云厂商 AK/SK),建议接 Vault 或 GitLab 的 Masked Variable(变量值在生产日志里会被自动打码成 [masked])。
避坑 3:Masked Variable 要求变量值必须是单行、且匹配特定正则(不能包含空格、斜杠等)。如果你的密钥含特殊字符导致无法 Mask,改用文件型变量(file: true),GitLab 会把内容写入临时文件并把路径存入变量:
# 变量类型选 File,名为 KUBE_CONFIG
deploy-k8s:
script:
- export KUBECONFIG="$KUBE_CONFIG"
- kubectl apply -f manifests/
六、Docker 构建:dind 模式
现代部署几乎都围绕镜像。在 Runner 里构建镜像最干净的方式是 Docker-in-Docker(dind):
build-image:
stage: build
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: "/certs"
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
避坑 4:dind 默认需要 --privileged 模式,生产环境有安全隐患。替代方案是用 Kaniko(无需特权,纯用户态构建)或 buildah:
build-kaniko:
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
script:
- /kaniko/executor --context $CI_PROJECT_DIR --destination $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
七、多环境部署:用 environment 管理
GitLab 的 environment 关键字能把部署和"环境"关联,在 GitLab 界面上形成可视化部署历史,还支持一键回滚:
deploy-staging:
stage: deploy
script: ./deploy.sh staging
environment:
name: staging
url: https://staging.example.com
only: [develop]
deploy-prod:
stage: deploy
script: ./deploy.sh prod
environment:
name: production
url: https://example.com
when: manual # 生产环境手动点击触发,防误发
only: [main]
when: manual 是生产发布的护身符——它强制一次人工确认,避免 main 分支的任何提交自动流向线上。
八、生产级模板:多阶段 + 并行测试
下面是一个我实际在用的完整模板,整合了缓存、并行测试、镜像构建、多环境部署:
stages: [build, test, package, deploy]
variables:
IMAGE_TAG: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
cache:
key: $CI_COMMIT_REF_SLUG
paths: [node_modules/]
build:
stage: build
image: node:20
script:
- npm ci
- npm run build
artifacts:
paths: [dist/]
expire_in: 1 day
test-unit:
stage: test
image: node:20
script: [npm run test:unit]
coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
test-e2e:
stage: test
image: cypress/base:20
script: [npm run test:e2e]
needs: [build] # 依赖 build 的产物,但可与 test-unit 并行
package:
stage: package
image: docker:24
services: [docker:24-dind]
script:
- docker build -t $IMAGE_TAG .
- docker push $IMAGE_TAG
needs: [test-unit, test-e2e]
deploy-staging:
stage: deploy
script: ./deploy.sh staging
environment: { name: staging }
needs: [package]
deploy-prod:
stage: deploy
script: ./deploy.sh prod
environment: { name: production }
when: manual
needs: [package]
only: [main]
needs 关键字打破了默认的 Stage 顺序依赖——test-e2e 只等 build 完成即可与 test-unit 并行,整体流水线耗时从 12 分钟压到 5 分钟。
九、避坑清单(血泪总结)
| # | 坑 | 现象 | 解决 |
|---|---|---|---|
| 1 | Runner 并发打满 | 任务长时间 pending |
config.toml 设 limit,或扩容 Runner |
| 2 | 缓存不命中 | 每次都重装依赖 | 用分布式缓存或固定 cache:key |
| 3 | 密钥泄露 | 日志打印密码 | 变量设为 Masked,用文件型变量 |
| 4 | dind 特权风险 | 安全审计不过 | 换 Kaniko / buildah |
| 5 | 生产误发 | 提交即上线 | 生产 Job 加 when: manual |
| 6 | 大仓库克隆慢 | 流水线卡在 clone | 设 GIT_DEPTH: "1" 浅克隆 |
| 7 | 产物过大 | 存储爆满 | artifacts:expire_in 设保留期 |
| 8 | Windows 路径 | 脚本在 Win Runner 失败 | 用 extends 统一 tags 隔离平台 |
十、小结
GitLab CI/CD 真正难的不是语法,而是把团队里那些"只有老王知道"的发布步骤,翻译成所有人都能复现的声明式配置。当你第一次看到一次 Push 自动走完"构建→测试→出镜像→部署预发→人工确认→生产上线"全流程,且任何人都无需记一条命令时,你就理解了 DevOps 的精髓。
下一步建议:把 when: manual 的生产发布接上蓝绿部署或金丝雀发布,让回滚从"手工 ssh 救火"变成"点一下按钮"。

