找回密码
立即注册
搜索
发回帖 发新帖

6682

积分

1

好友

838

主题
发表于 昨天 23:13 | 查看: 4| 回复: 0

一、背景:流水线"能跑"和"敢用"之间隔着什么

大部分团队的第一版 .gitlab-ci.yml 长这样:

stages:
  - build
  - test
  - deploy

build:
  stage: build
  script:
    - mvn package
  artifacts:
    paths:
      - target/*.jar

test:
  stage: test
  script:
    - mvn test

deploy:
  stage: deploy
  script:
    - scp target/*.jar root@prod:/opt/app/
    - ssh root@prod "systemctl restart app"
  only:
    - main

这段配置能跑,也确实把"手工部署"变成了"点一下按钮"。但它离"敢用"还有很远的距离:

问题一:串行长龙。 build → test → deploy 严格串行,一个 40 分钟的单测把所有后续阶段堵死。开发者提了 MR,等 50 分钟才看到结果,于是开始绕过流水线直接合并。

问题二:环境不一致。 mvn package 在 Runner 宿主机上跑,依赖宿主机的 JDK 版本、Maven 版本、~/.m2 缓存。今天能构建,明天 Runner 换台机器就构建不出来。

问题三:没有制品概念。 scp 直接传 jar,传到一半网络断了,生产上就是半个文件。没有版本号、没有校验、没有回滚依据。出了问题只能"重新打一次包",但打出来的包和上次不一定一样。

问题四:发布没有闸门。 only: main 意味着任何人 push 到 main 就直接上生产。没有审批、没有灰度、没有验证、没有回滚。

问题五:凭证裸奔。 ssh root@prod 用的密钥要么在 Runner 的 ~/.ssh,要么在 CI 变量里明文存着。一旦 Runner 被攻破,生产环境全线失守。

这篇文章要解决的就是从"能跑"到"敢用"的这段距离。核心不是堆功能,而是把流水线当成一个有明确契约的系统来设计:每个阶段输入什么、输出什么、失败怎么办、谁能触发、怎么回滚。

二、环境假设与版本差异

项 假设 说明
GitLab 17.x / 18.x / 19.x(自建或极狐) 版本差异见下表
Runner GitLab Runner 17+,docker executor shell executor 只用于特殊场景
容器运行时 Docker 27+ 或 containerd 影响 dind / kaniko 选择
编排 Kubernetes 1.29+ 部署章节用 K8s 举例,非 K8s 环境原理相同
镜像仓库 GitLab Container Registry 或 Harbor 私有仓库需配置认证
代码托管 单仓库(monorepo 或多仓库均可) 多仓库用 trigger 串

版本差异,这些直接影响 YAML 写法:

特性 引入版本 说明
rules 替代 only/except 12.3 起主推 新项目一律用 rules,only/except 只做兼容维护
include:component + spec:inputs 17.0 GA 组件化复用的基础
id_tokens(OIDC) 15.7+,16.x 完善 免密钥访问云资源
needs:parallel:matrix 15.9+ 并行矩阵的依赖
environment:action: prepare/verify 14.x+ 环境生命周期的前置/后置钩子
release-cli 18.0 弃用 改用 glab release create
Merge trains 15.x 完善 高频合并场景降低冲突
rules:changes:compare_to 15.3+ 和指定 ref 比较变更文件

不确定的地方要说清楚: 极狐 GitLab(GitLab 中国发行版)的版本号和上游不完全同步,2026 年 10 月的补丁版本是 19.5.x 系列。如果你的实例是极狐,include:component 的路径规则、$CI_SERVER_FQDN 的取值要以实际实例为准,不要直接抄上游文档的示例域名。

先确认自己的版本:

curl -s --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
  "https://gitlab.example.com/api/v4/version"
{"version": "19.4.2-ee", "revision": "a1b2c3d4"}

-ee 是企业版,-ce 是社区版。有些特性(比如 Merge trains 的部分能力、价值流分析)只有 EE 有,写 YAML 之前先确认。

三、核心机制:先搞懂这九件事

3.1 两种流水线结构:stage 串行 vs needs DAG

默认结构是按 stage 串行:同一 stage 的 job 并行,不同 stage 严格先后。

stages: [build, test, deploy]

build 全部成功,test 才开始;test 全部成功,deploy 才开始。这种结构简单,但有一个致命问题:最慢的 job 决定整个 stage 的时长。

needs 关键字把流水线从"阶段图"变成"依赖图"(DAG):

test:unit:
  stage: test
  needs: ["build:image"]     # 只等 build:image,不等 build 阶段的其他 job

一旦用了 needs,job 可以在依赖满足时立即开始,不必等整个 stage 结束。一个典型的三阶段流水线,DAG 化之后能省 30%~50% 的时间。

needs: [] 表示"不等任何东西,立刻开始",适合 lint 这种不依赖构建产物的检查。

3.2 rules 与 workflow:谁决定"跑不跑"

rules 决定单个 job 跑不跑,workflow:rules 决定整条流水线跑不跑。两者分工明确,混用会出问题。

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
      when: never
    - if: $CI_COMMIT_BRANCH
    - if: $CI_COMMIT_TAG

这三条规则的顺序不能乱,它解决的是重复流水线问题:当你在一个开了 MR 的分支上 push,GitLab 默认会同时创建"分支流水线"和"MR 流水线",两条都跑,浪费一半资源。第二条 when: never 就是掐掉分支流水线。

rules 的求值规则:从上到下,第一个匹配的生效,后面的不再看。 所以顺序即优先级,这一点和 if/else if 一样,但很多人写成"把所有条件列一遍",结果永远只命中第一条。

3.3 cache 与 artifacts:一个是共享,一个是传递

这两个概念最容易被混用,但它们的语义完全不同:

维度 cache artifacts
目的 加速(复用上次的依赖) 传递(把产物交给下游)
存储位置 Runner 本地或对象存储 GitLab 服务端(对象存储)
作用范围 同项目、同 key 的 job 之间共享 同一条流水线内,通过 needs 或 stage 顺序传递
是否保证存在 不保证,可能被清理 保证(在 expire_in 内)
失败时 不影响 失败 job 的 artifacts 默认仍上传(可配)
典型内容 node_modules、~/.m2、vendor 编译产物、测试报告、镜像 digest

判断标准: 这个文件是"下次能复用就快一点"还是"下游必须要它才能干活"?前者用 cache,后者用 artifacts。

把 jar 包放 cache 是错的——cache 可能被清掉,下游拿到空目录直接失败。把 node_modules 放 artifacts 也是错的——每个 job 都上传下载几百 MB,浪费带宽和存储。

3.4 variables 的层级与优先级

从低到高:

  1. .gitlab-ci.yml 的全局 variables
  2. job 级 variables
  3. 项目/组 CI/CD 变量(Settings → CI/CD → Variables)
  4. 流水线手动触发时传入的变量(Run pipeline)
  5. 触发 API 传入的变量

同名变量,高优先级覆盖低优先级。 但有一个例外:如果项目变量被标记为 "Protected",它只在受保护分支/tag 上注入,其他分支根本看不到这个变量(不是空值,是不存在)。

这个特性经常被用来做"生产凭证只在 main 上可用",但要注意:它不会阻止非保护分支的流水线运行,只会让变量缺失。 脚本里如果用 ${PROD_TOKEN} 而不做检查,会拿到空字符串,然后带着空凭证去调 API,报一个莫名其妙的 401。

正确写法:

: "${PROD_TOKEN:?PROD_TOKEN 未设置,可能不在受保护分支上}"

${VAR:?msg} 在变量为空时直接退出并打印消息,比后面报 401 好排查一百倍。

3.5 environment 与 deployment

environment 不只是给 UI 分组用的,它带来三个实际能力:

  1. 部署记录:谁在什么时候部署了哪个 commit 到哪个环境,可追溯。
  2. 一键回滚:GitLab 提供 "Re-deploy" 按钮,重新跑一次历史 job。
  3. 环境停止:on_stop 可以定义"销毁环境"的动作,适合 review app 这类临时环境。
deploy:staging:
  stage: deploy
  environment:
    name: staging
    url: https://staging.example.com
  script:
    - ./deploy.sh staging

environment:name 支持变量,可以做"每个 MR 一个临时环境":

review:
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    url: https://$CI_COMMIT_REF_SLUG.review.example.com
    on_stop: stop:review

3.6 resource_group:并发控制

同一个环境同时只允许一个部署在跑,这是刚需。否则两个人同时点"部署生产",后一个会把前一个覆盖掉,而且没人知道最终是哪个版本。

deploy:prod:
  resource_group: production

resource_group 的语义是互斥:同一 group 的 job 串行执行,后来的排队。配合 environment 使用,效果最好。

3.7 id_tokens:不要再往 CI 变量里塞长期密钥

传统做法是把云厂商的 AK/SK 存进 CI 变量,所有 job 都能读。一旦有恶意 MR 改了 .gitlab-ci.yml 把变量打印出来,密钥就泄露了。

OIDC 的做法是:GitLab 给每个 job 签发一个短期 JWT,云厂商验证这个 JWT 的签发方和 claims,然后换一个短期凭证。

deploy:prod:
  id_tokens:
    GITLAB_OIDC_TOKEN:
      aud: https://gitlab.example.com
  script:
    - ./assume-role.sh "${GITLAB_OIDC_TOKEN}"

AWS 侧配置 trust policy,信任 GitLab 的 OIDC provider,并限制 sub(项目路径 + 分支/tag)。这样即使 token 泄露,也只能在有效期内、对特定项目生效。

这是当前最推荐的免密钥方案,比把 AK/SK 存 CI 变量安全一个数量级。

3.8 components 与 include:复用的正确姿势

include 有三种来源:本地文件、远程 URL、另一个项目的文件。include:component 是 17.0 之后的推荐方式,它带版本号,可以像依赖包一样管理。

include:
  - component: $CI_SERVER_FQDN/platform/ci-templates/build@1.2.0
    inputs:
      image_name: "order-service"
      dockerfile: "docker/Dockerfile"

组件仓库里用 spec:inputs 声明参数:

# ci-templates/templates/build.yml
spec:
  inputs:
    image_name:
      description: "镜像名称"
    dockerfile:
      default: "Dockerfile"
---
build:image:
  stage: build
  script:
    - echo "building $[[ inputs.image_name ]] with $[[ inputs.dockerfile ]]"

关键点: 组件必须打 tag 才能被引用,@1.2.0 里的版本号对应仓库的 tag。用分支名引用(@main)在生产上是危险的——别人改一下 main,你的流水线行为就变了。

3.9 三道保护闸门

机制 作用 配置位置
Protected branch 限制谁能 push/merge 到 main Settings → Repository → Protected branches
Protected environment 限制谁能部署到 production Settings → CI/CD → Environments
Protected variable 变量只在受保护分支/tag 上注入 Settings → CI/CD → Variables

三者要配合使用。只设 Protected branch 不设 Protected environment,那么非保护分支的流水线里如果有一个 environment: production 的 job(比如通过 MR 修改了 YAML),照样能部署。

四、设计原则

在写 YAML 之前,先把原则定下来。后面所有具体写法都是这些原则的推导。

4.1 快速失败

最贵的成本不是机器,是开发者的等待。一个 50 分钟的流水线,开发者会去看别的,回来时上下文已经丢了。

做法:

  • lint、类型检查放在最前面,needs: [] 立即开始,通常 1 分钟内出结果。
  • 单测按模块拆分,用 parallel:matrix 并行。
  • 集成测试只在 MR 和 main 上跑,feature 分支只跑单测。
  • 镜像构建用层缓存。

目标:MR 流水线 10 分钟内出结论。超过 15 分钟,团队就会开始绕过它。

4.2 分层触发

不同事件跑不同的流水线,不要一条流水线打天下。

触发场景 跑什么 目标时长
push 到 feature 分支 lint + 单测 < 5 分钟
开/更新 MR lint + 单测 + 构建 + 扫描 < 15 分钟
merge 到 main 全量 + 构建 + 推镜像 + 部署 staging < 25 分钟
打 tag 全量 + 生产部署(人工) 按需

4.3 幂等

同一个 commit、同样的输入,跑两次应该得到同样的结果。做不到的地方要显式标注。

做不到的典型:docker build 每次生成的镜像 digest 不同(因为时间戳)。解决办法是固定 SOURCE_DATE_EPOCH,或者接受"内容相同但 digest 不同",用 tag 里的 commit sha 作为唯一标识。

4.4 制品不可变

镜像一旦推送,就不再覆盖同名 tag。latest 是唯一的例外,且只用于开发环境。

版本号用 commit sha(短 8 位)作为主键,语义化版本(v1.2.3)作为别名。回滚时用 sha,因为它是唯一确定的。

4.5 最小权限

  • CI_JOB_TOKEN 的权限默认很窄,够用就别加。
  • 部署凭证按环境隔离,staging 的凭证不能碰生产。
  • Runner 按环境打 tag,生产部署只在专用 Runner 上跑。

4.6 可回滚

每一版部署都要能回答三个问题:上一版是什么?怎么回去?回去需要多久?

做不到自动回滚,至少要有一条命令能回滚,并且这条命令被演练过。

4.7 可观测

流水线本身也要有指标:成功率、时长、失败原因分布。否则"流水线不稳定"永远是一句感觉,不是数据。

五、完整流水线:从提交到生产

5.1 仓库结构

order-service/
├── .gitlab-ci.yml
├── .gitlab/
│   └── ci/
│       ├── base.yml          # 全局默认、镜像、缓存
│       ├── test.yml          # 测试相关
│       ├── build.yml         # 构建与推送
│       ├── security.yml      # 扫描与签名
│       └── deploy.yml        # 部署与验证
├── docker/
│   └── Dockerfile
├── deploy/
│   ├── helm/
│   └── values/
├── src/
└── pom.xml

拆文件不是为了好看,是为了让不同的人负责不同的部分,减少冲突。

5.2 主干:.gitlab-ci.yml

# .gitlab-ci.yml
# 只做三件事:定义 workflow、引入模板、声明 stage 顺序

workflow:
  name: "$PIPELINE_NAME"
  rules:
    # MR 流水线
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      variables:
        PIPELINE_NAME: "MR !$CI_MERGE_REQUEST_IID"
    # 有开着的 MR 时,不再跑分支流水线(避免重复)
    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
      when: never
    # 主干分支
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      variables:
        PIPELINE_NAME: "main @$CI_COMMIT_SHORT_SHA"
    # 打 tag 触发发布
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
      variables:
        PIPELINE_NAME: "release $CI_COMMIT_TAG"
    # 其他分支:只跑 lint 和单测(由各 job 的 rules 控制)
    - if: $CI_COMMIT_BRANCH
      variables:
        PIPELINE_NAME: "branch $CI_COMMIT_REF_SLUG @$CI_COMMIT_SHORT_SHA"

stages:
  - validate
  - test
  - build
  - security
  - deploy
  - verify

include:
  - local: .gitlab/ci/base.yml
  - local: .gitlab/ci/test.yml
  - local: .gitlab/ci/build.yml
  - local: .gitlab/ci/security.yml
  - local: .gitlab/ci/deploy.yml

注意 workflow:name:默认的流水线名字是 #123456,一眼看不出是哪条。用 MR !123 或 main @abc1234 命名,在流水线列表里能直接认出来。

5.3 base.yml:全局默认

# .gitlab/ci/base.yml

# default 里只放"与镜像无关"的配置
default:
  # 所有 job 的超时,防止卡死占用 Runner
  timeout: 30m
  # 允许被新流水线打断(同一分支的新提交会取消旧流水线)
  interruptible: true
  retry:
    max: 1
    when:
      - runner_system_failure
      - stuck_or_timeout_failure
      - api_failure

variables:
  # 关闭 Maven 的交互式下载进度,日志干净且更快
  MAVEN_CLI_OPTS: "-s .m2/settings.xml --batch-mode --no-transfer-progress"
  # 镜像地址,统一从这里取,避免各处硬编码
  IMAGE_REPO: "${CI_REGISTRY_IMAGE}"
  IMAGE_TAG: "${CI_COMMIT_SHORT_SHA}"
  # 构建时间戳固定,提升可复现性
  SOURCE_DATE_EPOCH: "1700000000"
  # Git 浅克隆深度,需要 git describe 时改成 0
  GIT_DEPTH: "20"

# Java 相关 job 的公共模板:镜像 + 环境自检 + Maven 缓存
.java-base:
  image: registry.example.com/ci/maven-jdk21:3.9.9   # 自建基础镜像,固定版本
  before_script:
    - export MAVEN_OPTS="-Dmaven.repo.local=${CI_PROJECT_DIR}/.m2/repository"
    - java -version
    - mvn -v
  cache:
    key:
      files:
        - pom.xml
    paths:
      - .m2/repository/
    policy: pull-push

这里有一个必须避开的坑:不要把 image 和 before_script 写进 default。 default 会作用到所有 job,包括用 kaniko、trivy、curl 这些镜像的 job。如果 default.before_script 里写了 java -version,那么 scan:trivy 在 trivy 镜像里执行 java -version 会直接失败——java: command not found。正确做法是把"跟特定工具链绑定"的配置放进 .java-base 这样的模板,由需要的 job 显式 extends。

同理,default.image 设成 Maven 镜像后,build:image(kaniko)如果不显式指定 image,会跑在 Maven 镜像里,找不到 /kaniko/executor。这类错误在流水线第一次跑时就会暴露,但排查起来很费时间,因为报错信息是 command not found 而不是"镜像配错了"。

几个设计说明:

interruptible: true:同一个分支连续 push 两次,旧的流水线会被自动取消。这在开发阶段能省大量 Runner 时间。但部署 job 必须设 interruptible: false,否则部署到一半被取消,环境状态不确定。

retry:when:只重试基础设施故障(Runner 挂了、API 超时),不重试脚本失败。script_failure 重试是自欺欺人——代码有问题,重试一百次还是失败,只是浪费 30 分钟。

GIT_DEPTH: "20":默认是 20(浅克隆),git describe 这类需要历史的命令会失败。如果流水线里要算版本号,改成 0(完整克隆),代价是克隆变慢。

SOURCE_DATE_EPOCH:固定时间戳,让构建产物可复现。Java 的 maven-jar-plugin 支持这个变量。

5.4 test.yml:分层测试

# .gitlab/ci/test.yml

lint:java:
  extends: .java-base
  stage: validate
  needs: []
  script:
    - mvn ${MAVEN_CLI_OPTS} -q spotless:check
    - mvn ${MAVEN_CLI_OPTS} -q checkstyle:check
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_COMMIT_TAG

lint:yaml:
  stage: validate
  needs: []
  image: registry.example.com/ci/yamllint:1.35.1
  script:
    - yamllint -c .yamllint.yml .gitlab-ci.yml .gitlab/ci/
  rules:
    - changes:
        - "**/*.yml"
        - "**/*.yaml"
        - ".gitlab-ci.yml"

test:unit:
  extends: .java-base
  stage: test
  needs: ["lint:java"]
  parallel: 4
  script:
    - >
      mvn ${MAVEN_CLI_OPTS} test
      -DexcludedGroups=integration
      -Dsurefire.rerunFailingTestsCount=0
      -Dspring.profiles.active=test
  artifacts:
    when: always
    expire_in: 7 days
    reports:
      junit:
        - target/surefire-reports/TEST-*.xml
    paths:
      - target/surefire-reports/
  coverage: '/Total.*?([0-9]{1,3})%/'
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_COMMIT_TAG

test:integration:
  extends: .java-base
  stage: test
  needs: ["lint:java"]
  services:
    - name: mysql:8.4
      alias: mysql
      variables:
        MYSQL_ROOT_PASSWORD: testroot
        MYSQL_DATABASE: order_test
        MYSQL_USER: test
        MYSQL_PASSWORD: testpass
    - name: redis:7.4-alpine
      alias: redis

  variables:
    SPRING_DATASOURCE_URL: "jdbc:mysql://mysql:3306/order_test?useSSL=false&allowPublicKeyRetrieval=true"
    SPRING_DATASOURCE_USERNAME: test
    SPRING_DATASOURCE_PASSWORD: testpass
    SPRING_DATA_REDIS_HOST: redis

  script:
    # 等服务就绪,不要用固定 sleep
    - |
      for i in $(seq 1 60); do
        if mysqladmin ping -h mysql -u test -ptestpass --silent 2>/dev/null; then
          echo "MySQL 就绪,用时 ${i}s"
          break
        fi
        sleep 1
      done
    - mvn ${MAVEN_CLI_OPTS} verify -Dgroups=integration
  artifacts:
    when: on_failure
    expire_in: 3 days
    paths:
      - target/failsafe-reports/
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

关于 services 的几个坑:

  1. alias 决定主机名。 不写 alias,主机名是镜像名的变形(mysql__8.4),非常难记。一定要显式写。
  2. 服务启动不是瞬间的。 services 只保证容器启动,不保证内部进程就绪。MySQL 从容器启动到能接受连接要 10~30 秒。用 mysqladmin ping 轮询,不要 sleep 30(慢且不可靠)。
  3. mysql:8.4 的认证插件变化。 8.4 默认 caching_sha2_password,JDBC 连接串要加 allowPublicKeyRetrieval=true(非 SSL 时),否则报 Public Key Retrieval is not allowed。这个坑每年都有人踩。
  4. services 和 job 在同一网络。 服务名就是 hostname,不用 localhost。

关于 parallel: 4: 它会把 job 拆成 4 个实例,每个实例的 CI_NODE_INDEX 是 0~3、CI_NODE_TOTAL 是 4。但 GitLab 不会自动帮你分片——4 个实例默认跑完全相同的测试,等于白干四份。真正的分片要在脚本里按 CI_NODE_INDEX 挑测试类,方案见 11.4。

5.5 build.yml:镜像构建

# .gitlab/ci/build.yml

build:jar:
  extends: .java-base
  stage: build
  needs: ["test:unit"]
  script:
    - mvn ${MAVEN_CLI_OPTS} -DskipTests package
    - ls -lh target/*.jar
  artifacts:
    expire_in: 1 day
    paths:
      - target/*.jar

build:image:
  stage: build
  needs: ["build:jar"]
  image:
    name: gcr.io/kaniko-project/executor:v1.23.2-debug
    entrypoint: [""]
  script:
    - mkdir -p /kaniko/.docker
    # 用 CI_JOB_TOKEN 登录 GitLab Registry,不需要额外凭证
    - |
      cat > /kaniko/.docker/config.json <<EOF
      {
        "auths": {
          "${CI_REGISTRY}": {
            "auth": "$(printf '%s:%s' "${CI_REGISTRY_USER}" "${CI_REGISTRY_PASSWORD}" | base64 -w0)"
          }
        }
      }
      EOF
    - >
      /kaniko/executor
      --context "${CI_PROJECT_DIR}"
      --dockerfile "${CI_PROJECT_DIR}/docker/Dockerfile"
      --destination "${IMAGE_REPO}:${IMAGE_TAG}"
      --destination "${IMAGE_REPO}:${CI_COMMIT_REF_SLUG}"
      --cache=true
      --cache-repo="${IMAGE_REPO}/cache"
      --cache-ttl=168h
      --build-arg "JAR_FILE=target/order-service.jar"
      --build-arg "SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}"
      --label "org.opencontainers.image.revision=${CI_COMMIT_SHA}"
      --label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
      --label "org.opencontainers.image.source=${CI_PROJECT_URL}"
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_COMMIT_TAG

为什么用 kaniko 而不是 docker:dind:

方案 优点 缺点
docker:dind 兼容所有 Dockerfile,生态成熟 需要 privileged 模式,安全风险高;共享 Docker daemon 有并发问题
kaniko 不需要 daemon,不需要 privileged 不支持所有 Dockerfile 指令(如 RUN --mount 的某些用法);构建速度略慢
buildkit(rootless) 快,支持 RUN --mount=type=cache 配置复杂,需要单独的 daemon

生产环境优先 kaniko 或 rootless buildkit。dind + privileged: true 等于把 Runner 所在节点的 root 权限交出去,一旦有恶意 MR,节点就沦陷了。

--cache-repo 的作用: 把每一层的缓存推到镜像仓库。这样不同 Runner 之间也能共享缓存,而不是只在本机有效。

注意 --destination 推两个 tag: ${IMAGE_TAG} 是 commit sha(不可变),${CI_COMMIT_REF_SLUG} 是分支名(可变,方便开发调试)。生产部署只认 sha。

5.6 security.yml:扫描与签名

# .gitlab/ci/security.yml

scan:trivy:
  stage: security
  needs: ["build:image"]
  image:
    name: aquasec/trivy:0.57.1   # 固定版本,勿用 latest
    entrypoint: [""]
  variables:
    TRIVY_CACHE_DIR: "${CI_PROJECT_DIR}/.trivycache"
    TRIVY_USERNAME: "${CI_REGISTRY_USER}"
    TRIVY_PASSWORD: "${CI_REGISTRY_PASSWORD}"
  cache:
    key: trivy-db
    paths:
      - .trivycache/
  script:
    - >
      trivy image
      --severity HIGH,CRITICAL
      --ignore-unfixed
      --exit-code 1
      --format template
      --template "@/contrib/gitlab.tpl"
      --output gl-container-scanning-report.json
      "${IMAGE_REPO}:${IMAGE_TAG}"
  artifacts:
    when: always
    expire_in: 30 days
    reports:
      container_scanning: gl-container-scanning-report.json
  allow_failure: false
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_COMMIT_TAG

sign:image:
  stage: security
  needs: ["build:image"]
  image:
    name: gcr.io/projectsigstore/cosign:v2.4.1
    entrypoint: [""]
  id_tokens:
    SIGSTORE_ID_TOKEN:
      aud: sigstore
  script:
    # keyless 签名,依赖 OIDC,不需要长期私钥
    - cosign sign --yes
      --identity-token "${SIGSTORE_ID_TOKEN}"
      "${IMAGE_REPO}@$(cat image-digest.txt)"
  rules:
    - if: $CI_COMMIT_TAG

几个现实问题:

第一,--exit-code 1 会让流水线因为一个 CVE 就红。 这在刚上线时几乎必然发生——基础镜像里总有几个 HIGH。合理的做法是分两步走:先 allow_failure: true 跑两周,收集基线,把"当前已知的、暂时无法修的"写进 .trivyignore 并注明原因和到期时间,再改成硬失败。

.trivyignore 格式:

# CVE-2024-12345: 依赖的 netty 版本由上游 SDK 锁定,等待厂商升级
# 到期: 2026-12-31
CVE-2024-12345

# CVE-2025-67890: 仅影响未使用的 HTTP/2 功能,已通过 WAF 阻断
CVE-2025-67890

第二,image-digest.txt 从哪来。 kaniko 默认不输出 digest 到文件。两种办法:构建后用 skopeo inspect 或 registry API 查,或者在 kaniko 加 --digest-file=/workspace/image-digest.txt(kaniko 支持这个参数)。后者更简单:

- /kaniko/executor ... --digest-file /workspace/image-digest.txt

然后 sign:image 里 needs 带 artifacts 拿到这个文件。

第三,keyless 签名需要公网可达的 OIDC 和 Rekor。 内网环境要跑自建的 sigstore(fulcio + rekor),成本不低。如果做不到,退而求其次用 KMS 托管密钥做签名,至少保证密钥不在 CI 变量里。

5.7 deploy.yml:部署、验证、回滚

# .gitlab/ci/deploy.yml

.deploy-base:
  image:
    name: alpine/k8s:1.31.4
    entrypoint: [""]
  variables:
    KUBE_NAMESPACE: ""
    RELEASE_NAME: "order-service"
  before_script:
    # KUBE_CONFIG 从受保护变量注入,内容是 base64 后的 kubeconfig
    - mkdir -p "${HOME}/.kube"
    - echo "${KUBE_CONFIG}" | base64 -d > "${HOME}/.kube/config"
    - chmod 600 "${HOME}/.kube/config"
    - kubectl config use-context "${KUBE_CONTEXT}"
    - kubectl -n "${KUBE_NAMESPACE}" get deploy "${RELEASE_NAME}" -o wide || true

deploy:staging:
  extends: .deploy-base
  stage: deploy
  needs: ["build:image", "scan:trivy"]
  environment:
    name: staging
    url: https://staging.example.com
  resource_group: staging
  interruptible: false
  variables:
    KUBE_NAMESPACE: "order-staging"
  script:
    - |
      kubectl -n "${KUBE_NAMESPACE}" set image \
        deployment/${RELEASE_NAME} \
        app="${IMAGE_REPO}:${IMAGE_TAG}"
    - kubectl -n "${KUBE_NAMESPACE}" rollout status \
        deployment/${RELEASE_NAME} --timeout=300s
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

smoke:staging:
  stage: verify
  needs: ["deploy:staging"]
  image: curlimages/curl:8.10.1
  script:
    - |
      set -euo pipefail
      BASE="https://staging.example.com"
      for i in $(seq 1 30); do
        code=$(curl -s -o /dev/null -w '%{http_code}' "${BASE}/actuator/health/readiness" || true)
        if [ "${code}" = "200" ]; then
          echo "readiness OK,第 ${i} 次探测"
          break
        fi
        echo "第 ${i} 次探测返回 ${code},等待 5s"
        sleep 5
        if [ "${i}" = "30" ]; then
          echo "健康检查超时,判定部署失败" >&2
          exit 1
        fi
      done
      # 关键业务接口的冒烟
      curl -fsS "${BASE}/api/v1/orders/ping" -o /dev/null
      echo "冒烟通过"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

deploy:prod:
  extends: .deploy-base
  stage: deploy
  needs: ["deploy:staging", "smoke:staging"]
  environment:
    name: production
    url: https://api.example.com
  resource_group: production
  interruptible: false
  timeout: 20m
  variables:
    KUBE_NAMESPACE: "order-prod"
  script:
    # 记录上一版,供回滚使用
    - |
      PREV_IMAGE=$(kubectl -n "${KUBE_NAMESPACE}" get deploy "${RELEASE_NAME}" \
        -o jsonpath='{.spec.template.spec.containers[0].image}')
      echo "上一版镜像: ${PREV_IMAGE}" | tee rollback-info.txt
    - |
      kubectl -n "${KUBE_NAMESPACE}" set image \
        deployment/${RELEASE_NAME} app="${IMAGE_REPO}:${IMAGE_TAG}"
    - kubectl -n "${KUBE_NAMESPACE}" rollout status \
        deployment/${RELEASE_NAME} --timeout=600s
  artifacts:
    when: always
    expire_in: 30 days
    paths:
      - rollback-info.txt
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
      when: manual
      allow_failure: false

verify:prod:
  stage: verify
  needs: ["deploy:prod"]
  image: curlimages/curl:8.10.1
  script:
    - |
      set -euo pipefail
      for i in $(seq 1 20); do
        code=$(curl -s -o /dev/null -w '%{http_code}' https://api.example.com/actuator/health || true)
        [ "${code}" = "200" ] && break
        sleep 5
      done
      [ "${code}" = "200" ] || { echo "生产健康检查失败" >&2; exit 1; }
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/

rollback:prod:
  extends: .deploy-base
  stage: verify
  needs: ["deploy:prod"]
  environment:
    name: production
  variables:
    KUBE_NAMESPACE: "order-prod"
  script:
    - |
      set -euo pipefail
      PREV=""
      if [ -f rollback-info.txt ]; then
        # 格式为 "上一版镜像: <repo>:<tag>",取冒号后的部分
        PREV=$(awk -F'镜像: ' '/上一版镜像:/ {print $2}' rollback-info.txt | head -n1)
      fi
      if [ -n "${PREV}" ]; then
        echo "回滚到: ${PREV}"
        kubectl -n "${KUBE_NAMESPACE}" set image \
          deployment/${RELEASE_NAME} app="${PREV}"
      else
        echo "未找到上一版记录,使用 ReplicaSet 历史回滚"
        kubectl -n "${KUBE_NAMESPACE}" rollout undo deployment/${RELEASE_NAME}
      fi
      kubectl -n "${KUBE_NAMESPACE}" rollout status \
        deployment/${RELEASE_NAME} --timeout=300s
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
      when: on_failure

几个关键设计:

resource_group: production 保证同一时刻只有一个生产部署在跑。没有这个,两个人同时点按钮,先点的可能后完成,最终版本不可预测。

interruptible: false 对部署 job 是必须的。默认继承 default 的 true,如果新提交进来,正在跑的部署会被取消——K8s 那边可能已经改了一半。

先记录再变更。 PREV_IMAGE 在改之前取,写进 artifact。回滚时优先用它,而不是 rollout undo。原因:rollout undo 依赖 ReplicaSet 历史(revisionHistoryLimit 默认 10),如果连续部署超过 10 次,最早的版本已经被清理,undo 不一定能回到你想要的那一版。

when: on_failure 的回滚 job。 注意它的 rules 里也要写 when: on_failure,否则 rules 会覆盖 job 级的 when。这是一个非常常见的坑——写了 when: on_failure 但 rules 里没写,结果回滚 job 变成手动或者不触发。

allow_failure: false + when: manual 表示"必须有人点,且点了必须成功"。如果不写 allow_failure: false,手动 job 被跳过时流水线仍然算成功,生产可能压根没部署,但流水线是绿的。

六、关键机制详解与常见坑

6.1 rules 的五种写法

# 1. 按分支
rules:
  - if: $CI_COMMIT_BRANCH == "main"

# 2. 按 tag 格式
rules:
  - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/

# 3. 按变更文件(只在 MR 里有效)
rules:
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    changes:
      - "src/main/java/**/*.java"
      - "pom.xml"

# 4. 按文件是否存在
rules:
  - exists:
      - "Dockerfile"

# 5. 组合 + 变量
rules:
  - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $DEPLOY_ENABLED == "true"
    when: manual

坑一:changes 在非 MR 流水线里不可靠。 在 push 流水线里,changes 比较的是"本次 push 的 commit 范围",如果一次 push 了 20 个 commit,行为可能和预期不同;如果是新建分支,所有文件都算变更。需要精确控制时用 changes:compare_to:

rules:
  - changes:
      paths: ["src/**/*"]
      compare_to: "refs/heads/main"

坑二:rules 和 only/except 不能混用。 同一个 job 里同时写,GitLab 会报配置错误。迁移时要么全改,要么全不改。

坑三:rules 里没有 when 时默认是 on_success。 但如果在 workflow:rules 里写了 when: never,整条流水线不创建,job 级的 rules 根本不会执行。调试"为什么流水线没触发"时,先看 workflow。

6.2 needs 与 artifacts 传递

test:unit:
  needs:
    - job: build:jar
      artifacts: true   # 默认就是 true,显式写出来更清楚

needs 默认会下载上游 job 的 artifacts。如果不需要(比如只想要执行顺序),设 artifacts: false,能省不少下载时间。

坑:needs 指向的 job 被 rules 跳过了,下游会失败。

ERROR: Job 'test:unit' needs 'build:jar', but 'build:jar' was not run

解决办法是让上下游的 rules 保持一致,或者用 needs:optional: true:

needs:
  - job: build:jar
    optional: true

optional: true 表示"如果它没跑,我也不报错"。但要小心:这意味着下游可能在缺少产物的情况下运行,脚本里要做检查。

6.3 cache key 的设计

# 错误:所有分支共用一个 key,feature 分支的依赖会污染 main
cache:
  key: "maven"
  paths: [".m2/repository/"]

# 正确:按 lock 文件哈希,依赖变了自动失效
cache:
  key:
    files: ["pom.xml"]
  paths: [".m2/repository/"]

# 更好:加分支前缀,避免不同分支互相覆盖
cache:
  key:
    files: ["pom.xml"]
    prefix: "${CI_COMMIT_REF_SLUG}"
  paths: [".m2/repository/"]

policy 的用法:

  • pull-push(默认):下载 + 上传。适合构建 job。
  • pull:只下载不上传。适合测试 job——它们不该修改缓存,避免并发写冲突。
  • push:只上传。适合专门的"预热缓存" job。

并发写同一个 cache key 会导致缓存损坏(GitLab 用 zip 打包,并发写会产生不完整的包)。所以测试类 job 一律用 policy: pull。

6.4 Docker 构建的三个方案对比

方案一:kaniko(推荐)

优点:无 daemon、无需 privileged、支持层缓存推远端。缺点:不支持 RUN --mount=type=secret 的某些用法;多阶段构建的缓存命中率不如 buildkit。

方案二:buildkit rootless

build:
  image:
    name: moby/buildkit:v0.16.0-rootless
    entrypoint: [""]
  variables:
    BUILDKITD_FLAGS: "--oci-worker-no-process-sandbox"
  script:
    - |
      buildctl-daemonless.sh build \
        --frontend dockerfile.v0 \
        --local context=. \
        --local dockerfile=./docker \
        --opt filename=Dockerfile \
        --output type=image,name="${IMAGE_REPO}:${IMAGE_TAG}",push=true

优点:快,支持 RUN --mount=type=cache,缓存粒度细。缺点:配置复杂,rootless 模式对内核版本有要求。

方案三:docker:dind(不推荐用于生产)

build:
  image: docker:27.3
  services:
    - name: docker:27.3-dind
      alias: docker
  variables:
    DOCKER_HOST: "tcp://docker:2376"
    DOCKER_TLS_CERTDIR: "/certs"
    DOCKER_TLS_VERIFY: "1"
    DOCKER_CERT_PATH: "/certs/client"

必须有 privileged: true 才能跑。这意味着容器内的进程可以访问宿主机的所有设备,包括磁盘和内核模块。如果 Runner 是共享的,任何能提交 MR 的人都能拿到节点 root。 只有在自己完全控制的专用 Runner 上才考虑。

6.5 测试报告与覆盖率

test:unit:
  artifacts:
    when: always
    reports:
      junit: target/surefire-reports/TEST-*.xml
      coverage_report:
        coverage_format: cobertura
        path: target/site/jacoco/jacoco.xml
  coverage: '/Total.*?([0-9]{1,3})%/'

coverage 是正则,从 job 日志里抓覆盖率数字。不同工具的日志格式不同:

工具 正则
JaCoCo(Maven) /Total.*?([0-9]{1,3})%/
Jest (原文此处信息不完整,请以实际日志格式为准)
pytest-cov /TOTAL\s+\d+\s+\d+\s+(\d+%)/
go test /coverage: ([0-9.]+)% of statements/

正则写错了,覆盖率就显示不出来,但不报错——静默失败。配完之后一定要在 MR 页面确认覆盖率数字真的出现了。

6.6 手动 job 的四种形态

# 1. 手动触发,允许跳过
job1:
  when: manual

# 2. 手动触发,不允许跳过(跳过即流水线失败)
job2:
  when: manual
  allow_failure: false

# 3. 延迟执行(到时间自动跑,但可以提前手动点)
job3:
  when: delayed
  start_in: 30 minutes

# 4. 只在失败时执行
job4:
  when: on_failure

when: manual 的默认 allow_failure 是 true(除了在 rules 里显式写 when: manual 的情况,默认也是 true)。所以"部署生产"这种 job 一定要显式 allow_failure: false,否则跳过就等于成功。

七、制品与镜像管理

7.1 tag 策略

tag 用途 可变 示例
<commit-sha> 唯一标识,部署用 否 a1b2c3d4
v1.2.3 语义化版本,对外发布 否 v1.2.3
main 主干最新 是 main
latest 仅开发环境 是 latest

规则:生产部署只认 sha 或语义化版本,绝不认分支名。 分支名是移动的靶子,今天 main 指向 A,明天指向 B,回滚时你都不知道回到哪。

7.2 Registry 清理

镜像仓库会无限膨胀。GitLab 提供清理策略(Settings → Packages and registries → Clean up image tags),也可以自己写:

cleanup:registry:
  stage: deploy
  image: registry.example.com/ci/curl-jq:1.0
  script:
    - |
      set -euo pipefail
      # 保留最近 30 个 tag,其余删除(dry-run 先看)
      KEEP=30
      DRY_RUN="${DRY_RUN:-true}"
      TAGS=$(curl -s --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
        "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/registry/repository/${CI_PROJECT_PATH//\//%2F}/tags?per_page=100" \
        | jq -r '.[].name' | sort -V)
      TOTAL=$(echo "${TAGS}" | wc -l)
      DEL=$((TOTAL - KEEP))
      [ "${DEL}" -le 0 ] && { echo "无需清理"; exit 0; }
      echo "${TAGS}" | head -n "${DEL}" | while read -r tag; do
        [ -z "${tag}" ] && continue
        if [ "${DRY_RUN}" = "true" ]; then
          echo "[dry-run] 将删除 ${tag}"
        else
          echo "删除 ${tag}"
          curl -s -X DELETE --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
            "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/registry/repository/${CI_PROJECT_PATH//\//%2F}/tags/${tag}"
        fi
      done
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual

注意 DRY_RUN 默认 true。 清理是不可逆的,第一次必须 dry-run 看清单。确认无误后再手动改成 false 跑一次。

注意 %2F 转义。 项目路径里的 / 在 API URL 里要编码成 %2F,否则 404。这是 GitLab Registry API 的一个坑。

7.3 SBOM 与供应链

sbom:generate:
  stage: security
  needs: ["build:image"]
  image:
    name: aquasec/trivy:0.57.1
    entrypoint: [""]
  script:
    - trivy image --format cyclonedx --output sbom.cdx.json "${IMAGE_REPO}:${IMAGE_TAG}"
  artifacts:
    expire_in: 90 days
    paths:
      - sbom.cdx.json

SBOM(软件物料清单)的价值在出事的时候体现:某个基础库爆了 0day,你能在 10 分钟内查清"哪些镜像用了它",而不是全公司翻 Dockerfile。

八、部署策略

8.1 三种策略的取舍

策略 资源开销 回滚速度 适用场景
滚动更新 低(多 1 个 Pod) 中(要等新 Pod 起来) 无状态服务,默认选择
蓝绿 高(双倍资源) 快(切流量) 对回滚时间敏感的核心服务
金丝雀 中 快(缩容) 需要按真实流量验证

8.2 滚动更新的关键参数

spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
      minReadySeconds: 10
      progressDeadlineSeconds: 600

maxUnavailable: 0 + maxSurge: 1 表示"先起新的,再停旧的",全程容量不下降。代价是需要额外 1 个 Pod 的资源。

minReadySeconds: 10 很重要:Pod 变成 Ready 后还要稳定 10 秒才算成功。没有这个,一个"起来就崩"的版本可能在 readiness 探针的间隙里被判定为成功,然后进入下一个。

8.3 金丝雀的 CI 实现

GitLab 本身不做流量切分,需要配合 K8s 的 Service/Ingress 或者 Istio/Flagger。

最简版本:用两个 Deployment,靠 Service selector 的副本比例控制流量。

canary:10:
  stage: deploy
  environment:
    name: production/canary
  script:
    - kubectl -n order-prod set image deploy/order-canary app="${IMAGE_REPO}:${IMAGE_TAG}"
    - kubectl -n order-prod scale deploy/order-canary --replicas=1
    - kubectl -n order-prod scale deploy/order-stable --replicas=9
    - sleep 300   # 观察窗口
    - ./check-error-rate.sh production 0.01   # 错误率超过 1% 则失败
  when: manual

这个方案的缺点是流量比例靠副本数近似,不精确。真正的金丝雀要用 Istio 的 VirtualService 权重:

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: order-service
  namespace: order-prod
spec:
  hosts: ["order-service"]
  http:
    - route:
        - destination:
            host: order-service-stable
            weight: 90
        - destination:
            host: order-service-canary
            weight: 10

CI 里改权重 + 观察指标 + 自动推进或回退,这套逻辑通常用 Flagger 或 Argo Rollouts 实现,不要自己在 shell 里写。

8.4 数据库变更的顺序

这是发布里最容易出事的部分。原则:先兼容,再变更,后清理。

三步走:

  1. 扩展(expand):加新字段/新表,旧代码不受影响。这一步可以随应用一起发。
  2. 迁移(migrate):双写或者后台任务回填数据。
  3. 收缩(contract):删旧字段/旧表。这一步必须等所有旧版本实例都下线之后。

CI 里的体现:

migrate:prod:
  stage: deploy
  needs: ["deploy:prod"]
  script:
    - ./migrate.sh --env production --dry-run
    - ./migrate.sh --env production
  rules:
    - if: $CI_COMMIT_TAG
      when: manual
  environment:
    name: production
    action: prepare

风险: DDL 在大表上会锁表。MySQL 8.0 的 ALGORITHM=INSTANT 只支持加列(且列必须在最后),改索引、改类型仍然要 rebuild。生产上大表的 DDL 要用 gh-ost 或 pt-online-schema-change,且必须在低峰执行。

回滚: 数据库变更的回滚比应用回滚难得多。删掉的列加不回来(数据没了)。所以第 3 步"收缩"要单独排期,至少等一个完整的发布周期,确认没问题再做。

8.5 部署验证的四个层次

  1. Pod 就绪:rollout status 通过,readiness 探针 OK。
  2. 接口可用:冒烟测试,关键接口返回 200。
  3. 业务正确:跑一组核心用例(下单、支付回调),验证数据一致性。
  4. 指标正常:观察 5~15 分钟,错误率、P99、QPS 在基线范围内。

前两层可以自动做,第三层通常要半自动(跑固定用例),第四层靠监控 + 人工判断。

九、安全设计

9.1 变量的三种保护

Settings → CI/CD → Variables
  ├── Protected:只在受保护分支/tag 上注入
  ├── Masked:日志里显示为 [MASKED]
  └── Expanded:是否展开变量引用

Masked 的限制: 值必须满足特定格式(至少 8 个字符,只含 Base64 字符集,不能有空格和换行)。一个含换行的私钥没法 mask,只能靠 protected + 不在日志里打印。

Protected 的陷阱: 前面提过,非保护分支拿不到变量,但流水线照样跑。脚本里必须用 ${VAR:?} 检查。

9.2 防止恶意 MR 窃取凭证

威胁模型:一个外部贡献者提 MR,修改 .gitlab-ci.yml 加入 cat $PROD_TOKEN。

防护:

  1. Protected 变量:MR 流水线跑在源分支上,源分支不是受保护分支,拿不到变量。这是最有效的一层。
  2. 禁止 fork 流水线访问变量:Settings → CI/CD → Token Access,关闭 "Allow Git push requests to your repository"(针对 fork)。
  3. 合并前必须 review:.gitlab-ci.yml 加入 CODEOWNERS,改动必须由平台组审批。
# .gitlab/CODEOWNERS
/.gitlab-ci.yml          @platform-team
/.gitlab/ci/             @platform-team
/deploy/                 @platform-team
  1. 生产部署 job 只在 tag 上跑:tag 只有 maintainer 能打,MR 改不了 tag。

9.3 审批与冻结

部署审批(Premium 及以上):

Settings → CI/CD → Environments → production → Required approvals: 1

配置后,deploy:prod 在手动触发后还需要一个人审批,形成"双人复核"。

部署冻结窗口(Deploy freeze):

Settings → CI/CD → Deploy freezes

大促、月底结算这类时间窗,直接冻结生产部署,任何 job 都无法部署到该环境。这比"口头通知大家别发"可靠得多。

9.4 Runner 隔离

环境 Runner 类型 tag
开发/MR 共享 Runner,docker executor shared
staging 项目 Runner staging
production 专用 Runner,网络隔离 prod

生产 Runner 的关键配置:

# /etc/gitlab-runner/config.toml
[[runners]]
name = "prod-runner-01"
url = "https://gitlab.example.com"
token = "glrt-xxxx"
executor = "docker"
[runners.docker]
image = "registry.example.com/ci/base:1.0"
privileged = false
volumes = ["/cache"]
network_mode = "prod-net"
[runners.cache]
type = "s3"
shared = true
[runners.cache.s3]
server_address = "minio.example.com"
bucket_name = "gitlab-runner-cache"

privileged = false 是底线。生产 Runner 上跑 privileged 容器,等于把生产网络的管理权限开放给任何能提交代码的人。

十、Runner 运维

10.1 并发配置

concurrent = 20   # 全局最大并发 job 数
check_interval = 3

concurrent 是整个 Runner 进程的并发上限,不是每个 executor 的。设太大,机器会被压垮;设太小,job 排队。

经验值:concurrent = CPU 核数 × 1.5(docker executor,job 主要是 IO 等待)。

单个项目的并发限制在 [[runners]] 段:

[[runners]]
limit = 10   # 这个 runner 最多同时跑 10 个 job

10.2 磁盘清理

docker executor 最大的运维问题是磁盘。每个 job 都会拉镜像、建容器、写层,日积月累几十 GB。

# 定期清理(建议加进 cron,每天凌晨)
docker system prune -af --filter "until=72h"
docker volume prune -f

风险: docker system prune -af 会删掉所有未使用的镜像和停止的容器。如果 Runner 上有手工跑的其他容器,会被误删。生产上要么用专用 Runner(只有 CI 在用),要么用更精确的过滤:

# 只清理 72 小时前的构建缓存
docker builder prune -f --filter "until=72h"
# 只清理悬空镜像
docker image prune -f

注意 docker volume prune 会删数据。 如果有容器挂了 named volume,且当前没运行,volume 会被当成"未使用"删掉。执行前先 docker volume ls 看一眼。

10.3 缓存存储

本地缓存(/cache volume)只在一台 Runner 上有效。多 Runner 场景必须用分布式缓存(S3/MinIO/GCS)。

[runners.cache]
type = "s3"
shared = true
[runners.cache.s3]
server_address = "minio.example.com"
bucket_name = "gitlab-runner-cache"
bucket_location = "us-east-1"
insecure = false
authentication_type = "access-key"

凭证通过 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY 环境变量注入 Runner 进程,不要写在 config.toml 里。

shared = true 表示多个 Runner 共享同一个缓存桶。如果不同 Runner 服务于不同安全等级的项目,设成 false,避免缓存串项目。

十一、性能优化

11.1 先测量,再优化

GitLab 提供流水线时长分析(Analyze → CI/CD analytics),但更直接的办法是看 API:

curl -s --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
  "https://gitlab.example.com/api/v4/projects/${PROJECT_ID}/pipelines/${PIPELINE_ID}/jobs?per_page=100" \
  | jq -r '.[] | [.name, .duration, .queued_duration, .status] | @tsv' \
  | sort -k2 -rn

duration 是执行时间,queued_duration 是排队时间。如果 queued_duration 很大,问题在 Runner 容量,不在流水线设计。 这两个数要分开看,否则会优化错方向。

11.2 DAG 化

把串行改成 DAG,通常能省 30%~50%:

# 优化前:test 阶段要等 build 阶段全部完成
# 优化后:test:unit 只等 lint,不等 build:image

test:unit:
  stage: test
  needs: ["lint:java"]

build:image:
  stage: build
  needs: ["test:unit"]      # 单测过了就构建,不等集成测试

test:integration:
  stage: test
  needs: ["build:image"]    # 集成测试用镜像跑,和单测并行

关键洞察:集成测试可以用构建好的镜像跑,不必等 jar 包。这样单测和集成测试可以并行。

11.3 缓存与镜像预热

warm:cache:
  stage: validate
  needs: []
  cache:
    key:
      files: ["pom.xml"]
    paths: [".m2/repository/"]
    policy: push
  script:
    - mvn ${MAVEN_CLI_OPTS} dependency:go-offline
  rules:
    - changes:
        - "pom.xml"

只在 pom.xml 变化时跑,把依赖下载好推上去。后续 job 用 policy: pull 直接拿,省掉下载时间。

11.4 测试分片

parallel: 4 只是起了 4 个实例,要真正分片得让每个实例跑不同的测试。

Maven Surefire 支持按文件分片:

test:unit:
  parallel: 4
  script:
    - >
      mvn ${MAVEN_CLI_OPTS} test
      -Dsurefire.failIfNoSpecifiedTests=false
      -Dtest="$(./scripts/shard-tests.sh ${CI_NODE_INDEX} ${CI_NODE_TOTAL})"

shard-tests.sh 按文件名哈希取模:

#!/usr/bin/env bash
# shard-tests.sh <index> <total>
# 输出逗号分隔的测试类全限定名,供 mvn -Dtest= 使用
set -euo pipefail

INDEX="${1:?缺少 index}"
TOTAL="${2:?缺少 total}"

[[ "${INDEX}" =~ ^[0-9]+$ ]] || { echo "index 必须是整数" >&2; exit 2; }
[[ "${TOTAL}" =~ ^[1-9][0-9]*$ ]] || { echo "total 必须是正整数" >&2; exit 2; }
[[ "${INDEX}" -lt "${TOTAL}" ]] || { echo "index 必须小于 total" >&2; exit 2; }

OUT=""
while IFS= read -r f; do
  # cksum 给出稳定哈希,同一个文件在任何机器上都落到同一个分片
  H=$(cksum < "${f}" | awk '{print $1}')
  if [[ $((H % TOTAL)) -eq ${INDEX} ]]; then
    CLS=$(printf '%s' "${f}" | sed 's#^src/test/java/##; s#/#.#g; s#\.java$##')
    OUT="${OUT:+${OUT},}${CLS}"
  fi
done < <(find src/test/java -type f -name '*Test.java' | sort)

if [[ -z "${OUT}" ]]; then
  echo "NO_TESTS"
else
  printf '%s\n' "${OUT}"
fi

对应 job:

test:unit:
  parallel: 4
  script:
    - TESTS=$(./scripts/shard-tests.sh "${CI_NODE_INDEX}" "${CI_NODE_TOTAL}")
    - if [ "${TESTS}" = "NO_TESTS" ]; then echo "本分片无测试类,跳过"; exit 0; fi
    - mvn ${MAVEN_CLI_OPTS} test -Dtest="${TESTS}" -Dsurefire.failIfNoSpecifiedTests=false

注意: 分片是按类分,不是按方法分。如果某个类里有 500 个测试方法,它会全部落在一个分片上,分片不均。要更均匀,得用 JUnit 5 的 @Tag 手工分组,或者用 junit-platform 的动态分片。

十二、排障

12.1 job 一直 pending

This job is stuck because you don't have any runners online assigned to it.

三个原因:

  1. 没有匹配 tag 的 Runner。 job 写了 tags: [prod],但没有任何 Runner 注册了这个 tag。
  2. Runner 并发已满。 concurrent 或 limit 到了上限,其他 job 在排队。
  3. Runner 被暂停。 管理员在 UI 上 pause 了。

排查:

# 在 Runner 机器上
gitlab-runner list
gitlab-runner verify
# 通过 API 查项目可用的 Runner
curl -s --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
  "https://gitlab.example.com/api/v4/projects/${PROJECT_ID}/runners?per_page=100" \
  | jq -r '.[] | [.id, .description, .status, (.tag_list|join(","))] | @tsv'

12.2 缓存不命中

# 检查点 1:key 是否一致(files 的哈希变了 key 就变)
# 检查点 2:policy 是否允许下载(push-only 的缓存别人拿不到)
# 检查点 3:Runner 是否共享缓存(本地缓存换台机器就没有)

在 job 里打印调试:

script:
  - echo "cache key files hash: $(sha256sum pom.xml)"
  - ls -la .m2/repository/ | head

12.3 artifacts 下载失败

FATAL: invalid argument

常见原因:artifacts 超过大小限制(默认 100MB,可在 Admin 调整)、对象存储配置错误、expire_in 已过期。

# 通过 API 确认 artifacts 是否存在
curl -s --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
  "https://gitlab.example.com/api/v4/projects/${PROJECT_ID}/jobs/${JOB_ID}/artifacts" \
  -o artifacts.zip -w '%{http_code}\n'

12.4 Docker Hub 拉取限流

toomanyrequests: You have reached your pull rate limit

解决办法:

  1. 用私有镜像仓库做代理缓存(Harbor 的 proxy cache、GitLab Dependency Proxy)。
  2. 把常用镜像同步到内网仓库。
  3. 配置 Docker Hub 认证(docker login),提高限额。

GitLab Dependency Proxy 的用法:

variables:
  DOCKER_HUB_PROXY: "${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}"

然后把 image: node:20 改成 image: ${DOCKER_HUB_PROXY}/node:20。需要在 Group 设置里启用 Dependency Proxy。

12.5 权限类错误

remote: You are not allowed to download code from this project.

CI_JOB_TOKEN 的权限问题。GitLab 16.x 之后收紧了 CI_JOB_TOKEN 的默认权限,跨项目访问要在目标项目的 "CI/CD → Job token permissions" 里显式加白名单。

12.6 用 API 看完整日志

job 日志在 UI 上可能被截断(大文件),用 API 拿完整的:

curl -s --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
  "https://gitlab.example.com/api/v4/projects/${PROJECT_ID}/jobs/${JOB_ID}/trace" \
  -o job.log

grep -nE 'ERROR|FATAL|error:' job.log | head -50

十三、风险与回滚

13.1 流水线变更本身的风险

改 .gitlab-ci.yml 是有风险的变更,但它经常被当成"配置"随手提交。

变更类型 风险 防护
改 rules 某条流水线不再触发,或重复触发 在 MR 里对比"改动前后的流水线图"
改 needs 产物缺失导致下游失败 先在一个分支验证
改 cache key 缓存失效,流水线变慢 影响可控,注意观察时长
改部署脚本 可能部署错版本 必须走 staging 验证
改凭证引用 生产部署失败或权限过大 CODEOWNERS + 审批

回滚方式: git revert 对应 commit。但要注意,如果流水线已经跑了一半,revert 不会撤销已经部署的版本,需要单独回滚应用。

13.2 应用回滚的三种粒度

  1. K8s 层面:kubectl rollout undo。快(秒级),但依赖 ReplicaSet 历史。
  2. CI 层面:重跑上一次成功的 deploy:prod job(GitLab 的 Re-deploy 按钮)。可靠,但慢(要重新拉镜像、滚动)。
  3. 数据库层面:通常不可回滚,只能向前修复。

演练要求: 回滚流程必须演练过。很多团队的回滚脚本从来没跑过,真出事时发现 rollback-info.txt 格式不对、权限不够、镜像已经被清理。

13.3 生产变更的四个前置

  1. 权限控制:只有 maintainer 能打 tag,只有特定角色能审批生产部署。
  2. 执行窗口:避开业务高峰,大促期间冻结。
  3. 灰度:先 staging 全量验证,生产先 1 个副本。
  4. 验证方式:冒烟 + 指标观察 + 业务确认,三者缺一不可。

十四、度量:流水线好不好,用数据说话

DORA 的四个指标,都可以从 GitLab 数据算出来:

指标 定义 数据来源
部署频率 单位时间内部署到生产的次数 tag 流水线成功数
变更前置时间 从 commit 到部署生产的时长 commit 时间 → 部署 job 完成时间
变更失败率 部署后导致故障的比例 部署后 N 小时内是否有回滚/告警
恢复时长 从故障到恢复的时间 回滚 job 的时间戳

采集方式:

# 拉取最近 100 次生产部署
curl -s --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
  "https://gitlab.example.com/api/v4/projects/${PROJECT_ID}/pipelines?ref=main&status=success&per_page=100" \
  | jq -r '.[] | [.id, .created_at, .updated_at, .sha[:8]] | @tsv'

除了 DORA,还有两个内部指标值得盯:

  • 流水线成功率:低于 90% 说明流水线本身不稳定,团队会开始不信任它。
  • 平均排队时长:queued_duration 的均值。超过 2 分钟说明 Runner 容量不够。

十五、总结

回到开头那个"能跑但不敢用"的流水线,差距在哪?

第一,结构。 串行改 DAG,让每个 job 只等它真正依赖的东西。这一条通常能砍掉一半时长。

第二,契约。 每个阶段明确输入输出。构建产出镜像(不可变、带 sha),测试消费镜像,部署消费镜像。中间不传"某个目录下的某个文件",传有版本号的制品。

第三,闸门。 分支保护、环境保护、变量保护,三道闸门配合。没有闸门的自动化,只是把事故的发生速度提高了。

第四,可回滚。 部署前记录上一版,回滚有明确命令,命令被演练过。做不到这三点,"自动部署"就是"自动事故"。

第五,凭证。 长期密钥逐步换成 OIDC 短期令牌。这一条不是可选项,是必选项——CI 变量里的 AK/SK 是当前最常见的高危资产。

最后说一个反直觉的经验:流水线的价值不在于"自动化了多少步",而在于"让多少人敢提交"。 一条 8 分钟出结果、失败原因清晰、回滚只要一条命令的流水线,会让团队每天多发几次版;一条 50 分钟、失败要人肉排查、回滚靠运气的流水线,会让团队攒一周发一次,然后在那一次里塞进 200 个 commit。

后者的风险不是"发布频率低",而是"每次发布的变更量巨大,出问题时无法定位是哪个改动导致的"。这才是流水线设计真正要解决的问题。




上一篇:组织智能:AI胜任力与协同力如何改变企业规模与工资
下一篇:DeepSeek Super-agent架构图解:双层与事件驱动
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-11-2 01:44 , Processed in 0.183726 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表