为什么我放弃了传统方案,改用 GitLab CI/CD
一切始于一次凌晨两点的部署事故
去年我们团队还在用“传统方案”发布代码:开发在本地打包,通过一个维护了三年的 Shell 脚本把构建产物 scp 到服务器上,然后重启服务。脚本是三年前离职的老王写的,没人敢动。某个周五晚上,一个同事忘改配置文件就发了包,生产环境挂了两个小时。我凌晨两点被电话叫醒,挂掉电话的那一刻,我下定决心:必须搞一套像样的 CI/CD。
对比了一圈 Jenkins、CircleCI 和 GitLab CI/CD 之后,我选了 GitLab。原因很简单:我们代码仓库本来就在 GitLab 私有部署上,CI/CD 是自带的,不用再维护一台 Jenkins 服务器,也不用担心源码传到第三方。
下面是我从零到上线的完整过程,包括我踩过的坑。
第一步:从最简单的流水线开始
GitLab CI/CD 的核心就是在仓库根目录放一个 .gitlab-ci.yml。我的第一版非常简陋:
stages:
- build
- deploy
build:
stage: build
script:
- mvn clean package -DskipTests
deploy:
stage: deploy
script:
- ./deploy.sh
第一次提交后我满怀期待地打开 Pipelines 页面,结果卡在 pending。折腾了半小时才明白:流水线需要一个 Runner 来执行。我们自建的 GitLab 不会自动带 Runner。
于是我在一台内网机器上装了个 Runner:
# 安装(Debian/Ubuntu)
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash
sudo apt install gitlab-runner
# 注册到 GitLab 实例
sudo gitlab-runner register \
--url https://gitlab.内网地址.com \
--token glrt-xxxxxxxx \
--executor shell
这里我第一次踩坑:我一开始选了 shell executor,构建环境全靠这台机器上碰巧装了什么。同事 A 装了 JDK 17,流水线就能跑;后来机器重装成 JDK 11,流水线莫名其妙就挂了。教训是:除非有特殊原因,老老实实用 Docker executor,让构建环境声明式地写在配置里。
第二步:用镜像锁定环境
改成 Docker executor 之后,配置变成:
build:
stage: build
image: maven:3.9-eclipse-temurin-17
script:
- mvn clean package -DskipTests
artifacts:
paths:
- target/*.jar
expire_in: 1 week
惊喜之一:artifacts 可以在 job 之间传文件,deploy 阶段直接就能拿到 jar 包,不用再传一次。expire_in: 1 week 控制存储占用,不然默认一直保存会吃掉不少磁盘。
第三步:把测试纳入流水线
原来传统方案最大的问题就是“测试靠自觉”。我把测试强制塞进了流水线:
test:
stage: test
image: maven:3.9-eclipse-temurin-17
script:
- mvn test
coverage: '/Total.*?([0-9]{1,3})%/'
再配合合并请求设置里的 "Pipelines must succeed",测试不过就不许合并。第一次强制推行时同事有怨言——因为有些“祖传测试”本来就一直是挂的。花了一周修掉那些僵尸测试之后,大家对红色叉号变得非常敏感,这反而是件好事。
第四步:部署与敏感信息
部署需要服务器的 SSH 私钥。我一开始图省事把私钥直接写进了 yml 文件,好在提交前被同事拦住了。正确做法是用 GitLab 的 CI/CD Variables(Settings → CI/CD → Variables):
- 添加
DEPLOY_SSH_KEY,类型选 File,勾选 Protected(只在受保护分支可用) - 流水线里通过文件路径读取
部署 job 用 SSH 执行:
deploy-staging:
stage: deploy
image: alpine:3.19
before_script:
- apk add openssh-client
- chmod 600 "$DEPLOY_SSH_KEY"
script:
- scp -i "$DEPLOY_SSH_KEY" -o StrictHostKeyChecking=no target/app.jar user@staging:/opt/app/
- ssh -i "$DEPLOY_SSH_KEY" user@staging "systemctl restart app"
environment:
name: staging
url: https://staging.example.com
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
environment 关键词值得一提:它会在 GitLab 里生成环境视图,谁在什么时候部署了什么版本一目了然,回滚也能一键操作。这是我以前 Shell 脚本方案完全做不到的。
rules 控制了只在 develop 分支部署到 staging。生产环境我用的是手动触发:
deploy-production:
stage: deploy
script:
- ./scripts/deploy_prod.sh
environment: production
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual
第五步:提速——缓存是关键
流水线跑顺了之后大家开始嫌慢,每次下载 Maven 依赖要四分钟。加上缓存:
build:
cache:
key: maven-repo
paths:
- .m2/repository
variables:
MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository"
构建时间从 6 分钟降到了 2 分钟。另外我还用 rules:changes 给无关的 job 做了条件触发——前端目录没改就不跑前端构建,流水线平均又省了 40 秒。
几个月的实战心得
真实收益:
- 部署从“人肉+老王脚本”变成点一下按钮,出错率大幅下降
- 每次变更都有流水线日志,出了问题能翻历史,审计有据可查
- 新人入职当天就能发起部署,不需要任何口口相传的隐式知识
踩过的坑:
- Runner 磁盘要定期清理,Docker 镜像层和 artifacts 会悄悄把磁盘吃满。我后来加了个每周清理的 cron。
- 自建 Runner 只有一台时,多个流水线会排队。现在我们有两台,用标签分开跑构建和部署。
include拆分配置文件很好用,但我们拆得太碎,十几个 yml 互相引用,新人根本看不懂。三个以内的文件就够了。- 敏感信息务必用 Protected/Masked variables,并且定期轮换。我们发生过一次离职同事仍能读到变量的事故,之后把权限收紧了。
诚实说局限性: .gitlab-ci.yml 的调试体验一般,语法错误要提交了才能发现(本地可以用 gitlab-ci-local 或 gitlab-runner exec 缓解,但不是完美方案)。复杂矩阵构建不如一些专业工具灵活。免费版的功能限制(比如部分安全扫描)对大团队来说可能是个坎。
但总的来说,把部署从“玄学”变成“工程”,是我去年做的最值的一件事。如果你还在用几台服务器加一堆 Shell 脚本发版,哪怕先从最简单的 build + test 流水线开始,也值得迈出这一步。