CI/CD 流水线设计:从提交到上线的完整链路

一条好流水线的四个标准:快、可信、可回、可追溯。完整 GitLab CI 与 GitHub Actions 配置,以及六个最常见的反模式。

「我们上了 CI/CD」这句话经常名不副实。实际的流水线是:手动点一下构建 → 等 8 分钟 → 手动下载产物 → 手动 scp 到服务器 → 手动重启。这叫「自动构建」,不叫 CI/CD。

一条真正可用的流水线要解决四个问题:多快、多可信、出问题能不能回、谁在什么时候改了什么。

这篇文章按这四个维度组织,给出一个从提交到上线的完整设计,以及六个常见的反模式。

一、先定义清楚每个阶段

commit → ①lint/编译 → ②单测 → ③构建镜像 → ④推送 → ⑤部署测试环境
       → ⑥集成测试 → ⑦人工审批 → ⑧部署生产 → ⑨健康检查 → ⑩完成

每个阶段的「失败该不该阻断」是不一样的:

阶段 目标耗时 失败是否阻断 说明
① lint / 编译 < 60s 是 最快发现问题,必须前置
② 单测 < 3min 是 覆盖率不达标也阻断
③ 构建镜像 < 2min 是 分层缓存能压到 20s
④ 推送 < 1min 是 分层推送
⑤ 部署测试 < 1min 是 —
⑥ 集成测试 < 5min 是 核心链路 smoke
⑦ 人工审批 — — 生产必过,除非是 hotfix 通道
⑧ 部署生产 < 2min 是 滚动 + 健康检查
⑨ 健康检查 < 2min 是(自动回滚) 关键

总时长目标:从 push 到测试环境就绪 < 10 分钟。 超过这个时间,开发者就会开始「等 CI 的时候去干别的」,反馈环断裂。

二、一份完整的 GitLab CI 配置

# .gitlab-ci.yml
stages:
  - validate
  - test
  - build
  - deploy-test
  - integration
  - deploy-prod

variables:
  IMAGE: $CI_REGISTRY_IMAGE
  TAG: $CI_COMMIT_SHORT_SHA
  DOCKER_DRIVER: overlay2

# ---------- ① 静态检查 ----------
lint:
  stage: validate
  image: node:20-alpine
  cache:
    key: ${CI_COMMIT_REF_SLUG}-npm
    paths: [node_modules/]
  script:
    - npm ci --prefer-offline
    - npm run lint
    - npx vue-tsc --noEmit
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

# ---------- ② 单测 ----------
unit:
  stage: test
  image: maven:3.9-eclipse-temurin-17
  cache:
    key: ${CI_COMMIT_REF_SLUG}-m2
    paths: [.m2/repository/]
  variables:
    MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
  script:
    - mvn -B test
    - mvn -B jacoco:report
  coverage: '/Total.*?([0-9]{1,3})%/'
  artifacts:
    reports:
      junit: target/surefire-reports/TEST-*.xml
      coverage_report:
        coverage_format: cobertura
        path: target/site/jacoco/jacoco.xml
    expire_in: 1 week

# ---------- ③ 构建镜像 ----------
build:
  stage: build
  image: docker:24
  services: [docker:24-dind]
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - |
      docker build \
        --cache-from $IMAGE:latest \
        --build-arg BUILDKIT_INLINE_CACHE=1 \
        -t $IMAGE:$TAG \
        -t $IMAGE:latest \
        .
    - docker push $IMAGE:$TAG
    - docker push $IMAGE:latest
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

# ---------- ⑤ 部署测试环境 ----------
deploy-test:
  stage: deploy-test
  image: bitnami/kubectl:1.29
  environment:
    name: test
    url: https://test.example.com
  script:
    - kubectl set image deployment/api api=$IMAGE:$TAG -n test
    - kubectl rollout status deployment/api -n test --timeout=180s
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

# ---------- ⑥ 集成测试 ----------
integration:
  stage: integration
  image: curlimages/curl:8.4.0
  script:
    - ./scripts/smoke-test.sh https://test.example.com
  artifacts:
    when: on_failure
    paths: [smoke-logs/]

# ---------- ⑦⑧ 生产部署 ----------
deploy-prod:
  stage: deploy-prod
  image: bitnami/kubectl:1.29
  environment:
    name: production
    url: https://example.com
  when: manual              # 人工点击
  allow_failure: false
  script:
    - kubectl set image deployment/api api=$IMAGE:$TAG -n prod
    - kubectl rollout status deployment/api -n prod --timeout=300s
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

几个关键设计:

--cache-from + BUILDKIT_INLINE_CACHE:让 CI 复用上次构建的层。这能把镜像构建从 3 分钟压到 30 秒。

rollout status:不是发完命令就认为成功,而是等 k8s 确认所有 Pod 就绪。失败会返回非 0,流水线自动标红。

expire_in:测试报告一周就过期,避免占满存储。

coverage 正则:从输出里提取覆盖率,GitLab 会画成趋势图。

三、GitHub Actions 版本

name: CI/CD

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: 'maven'          # 自动缓存 ~/.m2

      - name: Test
        run: mvn -B verify

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-reports
          path: target/surefire-reports/

  build:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha          # GitHub Actions 缓存
          cache-to: type=gha,mode=max

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        run: |
          echo "${{ secrets.KUBECONFIG }}" > /tmp/kubeconfig
          kubectl set image deployment/api api=ghcr.io/${{ github.repository }}:${{ github.sha }}
          kubectl rollout status deployment/api --timeout=300s

cache-from: type=gha 是 GitHub Actions 的原生缓存,比手动维护 registry 缓存省事得多。

environment 配合仓库设置里的「Required reviewers」,就实现了人工审批。

四、测试环境与生产环境的差异

最常见的反模式:测试环境跑得很好,生产一上线就出问题。原因是两个环境不一致。

差异来源

项 测试环境 生产环境 风险
数据量 1 万行 500 万行 慢查询只在生产暴露
副本数 1 6 并发 bug 只在生产出现
配置 手改 手改 两边不一致
中间件 单机 集群 Cluster 的跨槽限制只在生产暴露
流量 无 真实 缓存穿透只在生产出现

缓解措施

1. 配置用同一份模板 + 环境变量注入

# k8s 里用 ConfigMap + Secret,不用手改文件
envFrom:
  - configMapRef: { name: app-config }
  - secretRef: { name: app-secret }

2. 测试环境也跑多副本

replicas: 2      # 至少 2 个,能暴露会话共享、缓存不一致问题

3. 用生产数据脱敏后回灌

mysqldump -h prod --single-transaction --no-create-info \
  --where="id % 100 = 0" production orders | \
  mysql -h test testdb

按 1% 采样,既能暴露数据量相关问题,又不至于太大。

4. 定期在生产做只读的「彩排」

部署到生产但只接 1% 流量(灰度),观察 30 分钟。这比测试环境充分得多。

五、六个反模式

反模式 1:流水线跑 40 分钟

问题:开发者 push 后去干别的,2 小时后再看结果
后果:反馈环断裂,"CI 挂了先不管"成为常态

修法:拆分快慢两条流水线。

# 快流水线:每次 push 跑,5 分钟内
quick-check:
  script:
    - mvn -B compile          # 只编译,不测试
    - ./scripts/lint.sh

# 慢流水线:合并到主干才跑,可以慢
full-test:
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

反模式 2:只跑单测,不跑集成测试

单测全绿但接口 500 的事每周都发生。原因是单测 mock 了所有外部依赖,无法发现「数据库迁移没执行」「环境变量配错了」这类问题。

修法:加 smoke test。

#!/bin/bash
# scripts/smoke-test.sh
set -e
BASE=$1

echo "1. 健康检查"
curl -sf "$BASE/actuator/health" | grep -q '"status":"UP"'

echo "2. 登录"
TOKEN=$(curl -sf -X POST "$BASE/api/login" \
  -H 'Content-Type: application/json' \
  -d '{"username":"smoke","password":"smoke123"}' | jq -r .token)
[ -n "$TOKEN" ] && [ "$TOKEN" != "null" ]

echo "3. 核心接口"
curl -sf -H "Authorization: Bearer $TOKEN" "$BASE/api/orders?page=1" | jq -e '.items' > /dev/null

echo "4. 数据库连通"
curl -sf -H "Authorization: Bearer $TOKEN" "$BASE/api/orders/1" | jq -e '.id'

echo "全部通过"

四条命令,覆盖「服务活着、能登录、核心接口可用、数据库通」这四个必须项。执行 10 秒,拦住一半的部署事故。

反模式 3:镜像 tag 用 latest

image: registry.example.com/api:latest    # ✗

问题:

  • 回滚时不知道回退到哪个版本
  • 两个环境可能拉到不同的 latest
  • 无法追溯「线上跑的是哪个 commit」

修法:用 git sha。

image: registry.example.com/api:${{ github.sha }}

同时打上语义版本 tag 便于人读:

docker tag $IMAGE:$SHA $IMAGE:v1.4.2
docker tag $IMAGE:$SHA $IMAGE:latest

回滚时用 sha,不用 latest。

反模式 4:部署不等待就绪

kubectl set image deployment/api api=$IMAGE       # 命令返回就认为成功

set image 只是更新了 deployment spec,Pod 可能还在拉镜像、还在启动、还在崩溃重启。

修法:

kubectl set image deployment/api api=$IMAGE -n prod
kubectl rollout status deployment/api -n prod --timeout=300s || {
    echo "部署失败,自动回滚"
    kubectl rollout undo deployment/api -n prod
    exit 1
}

反模式 5:数据库迁移和代码部署绑在一起

script:
  - ./migrate.sh            # 先迁移
  - kubectl set image ...   # 再部署

问题:迁移成功但部署失败 → 数据库结构变了,旧代码跑不起来。而且迁移脚本通常不可回滚。

修法:迁移和代码保持兼容,分两步走(Expand-Contract 模式):

第 1 次发布:加新字段(可空),代码双写,读旧字段
第 2 次发布:代码读写新字段
第 3 次发布:删旧字段

每一步都是向后兼容的,任何一步失败都能回滚。

反模式 6:密钥写进仓库

# ✗ 明文
env:
  DB_PASSWORD: "Prod@123456"

哪怕是私有仓库,密钥也会出现在:构建日志、镜像层、PR diff、分支历史里。

修法:

env:
  DB_PASSWORD: $DB_PASSWORD      # 从 CI 变量注入

配合密钥管理服务(Vault / AWS Secrets Manager / k8s Secret + 外部 secrets operator)。

额外一条:密钥一旦提交过,就视为泄露。 改掉密钥 + 清理历史(git filter-repo),而不是只删文件。

六、加速 CI 的五个手段

手段 收益 前提
依赖缓存(~/.m2、node_modules) 40~60% 依赖不常变
镜像层缓存(--cache-from) 60~80% Dockerfile 分层合理
并行执行 job 30~50% job 之间无依赖
只跑受影响的测试 50~70% 有模块化测试结构
自建 runner(就近网络) 20~40% 有机器资源

「只跑受影响的测试」收益最大也最容易做:

# 找出改动的模块
CHANGED=$(git diff --name-only origin/main...HEAD | cut -d/ -f1-2 | sort -u)
# 只跑这些模块的测试
mvn -pl $CHANGED -am test

七、监控流水线本身

流水线也是生产系统,需要监控四个指标:

指标 目标 说明
构建成功率 > 95% 低于 90% 说明测试不稳定(flaky)
构建时长(P90) < 10min 变长说明缓存失效或有新阻塞
部署频率 越高越好 DORA 指标之一
变更失败率 < 15% 部署后导致故障的比例
平均恢复时间(MTTR) < 1h 从故障到恢复

「构建成功率」尤其重要。 如果经常有 flaky test,团队就会养成「重跑一次」的习惯——这时候 CI 的信任就崩了,红灯不再被重视。

flaky test 的处理:标记、隔离、限期修复。不要长期保留。

@Tag("flaky")
@Disabled("https://jira.example.com/ISSUE-123 修复中")

小结

一条好的流水线满足四个标准:

  1. 快:push 到测试环境就绪 < 10 分钟
  2. 可信:红灯一定是真问题,绿灯一定可上线
  3. 可回:任何一次部署都能一条命令回滚
  4. 可追溯:从线上镜像能查到 commit、从 commit 能查到谁改了什么

六个反模式里,最该先修的是「部署不等待就绪」和「镜像用 latest」——这两个改起来只要几行,但能消掉大部分「上线后才发现问题」的事故。

查看「工程效能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

CI/CD 流水线设计:从提交到上线的完整链路 - 起航社区