一、问题背景:当git push origin master变成一场赌博

2023年初,我们团队从5人扩展到20人,后端仓库日均提交从12次涨到87次。当时的工作流极其原始:

  • 所有人直接在master上开发,本地跑通就push
  • 没有Code Review,没有CI,测试靠人肉
  • 发布时拉一个release-20230301分支,手动cherry-pick

结果就是:每周至少3次冲突回滚,发布窗口从30分钟膨胀到4小时,生产事故月均2.3次。最离谱的一次,两个同事同时改了OrderService.java,一个把另一个的幂等逻辑覆盖了,上线后重复下单投诉47笔。

我们决定彻底改造Git工作流。目标很明确:分支策略清晰、Review强制、CI/CD自动化、可回滚

二、环境与版本

  • GitLab CE 15.11(自托管)
  • Git 2.39.2
  • Jenkins 2.401.1 LTS + GitLab Plugin 1.7.0
  • GitLab Runner 15.11.1,Docker Executor
  • Java 17 + Maven 3.9.3
  • 生产环境K8s 1.26,ArgoCD 2.7

三、方案设计:Trunk-Based + Release分支 + 短生命周期Feature分支

我们对比了三种主流策略:

策略 合并频率 冲突概率 适合场景
Git Flow 版本制软件
GitHub Flow Web服务
Trunk-Based 极高 持续交付

最终选择改良版Trunk-Based

  • main:永远可部署,受保护,禁止直接push
  • feature/*:生命周期≤2天,从main切出,MR合并后删除
  • release/*:发布冻结分支,仅接受cherry-pick
  • hotfix/*:从release切出,修复后同时合回main和release

关键规则:
1. Feature分支超过2天必须rebase main
2. MR必须至少1个Approve + 流水线全绿
3. main分支保护:禁止force push,禁止删除

四、核心实现:分支保护 + Code Review + CI/CD

4.1 GitLab分支保护配置

通过API设置main保护规则(project_id=123):

curl --request POST \
  --header "PRIVATE-TOKEN: " \
  --header "Content-Type: application/json" \
  --data '{
    "name": "main",
    "push_access_level": 0,
    "merge_access_level": 30,
    "allow_force_push": false,
    "code_owner_approval_required": true
  }' \
  "https://gitlab.example.com/api/v4/projects/123/protected_branches"

参数说明:
- push_access_level=0:没人能直接push
- merge_access_level=30:Developer及以上可合并
- code_owner_approval_required=true:CODEOWNERS必须批准

CODEOWNERS文件放在仓库根目录:

# 核心订单模块必须由架构组Review
/src/main/java/com/example/order/ @arch-team
# CI配置变更需DevOps批准
/.gitlab-ci.yml @devops-team

4.2 GitLab CI配置(.gitlab-ci.yml)

stages:
  - build
  - test
  - sonar
  - deploy-staging

variables:
  MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository"
  DOCKER_DRIVER: overlay2

cache:
  key: "$CI_COMMIT_REF_SLUG"
  paths:
    - .m2/repository/

build:
  stage: build
  image: maven:3.9.3-eclipse-temurin-17
  script:
    - mvn clean compile -DskipTests
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'

test:
  stage: test
  image: maven:3.9.3-eclipse-temurin-17
  script:
    - mvn test -Dtest.coverage=true
  coverage: '/Total.*?([0-9]{1,3})%/'
  artifacts:
    when: always
    reports:
      junit: target/surefire-reports/TEST-*.xml
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

sonar:
  stage: sonar
  image: sonarsource/sonar-scanner-cli:5.0
  script:
    - sonar-scanner -Dsonar.projectKey=order-service
      -Dsonar.qualitygate.wait=true
  allow_failure: false
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

deploy-staging:
  stage: deploy-staging
  image: bitnami/kubectl:1.26
  script:
    - kubectl set image deployment/order-service order-service=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -n staging
  environment:
    name: staging
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

关键点:
- sonar.qualitygate.wait=true:质量门禁不通过直接失败,MR无法合并
- coverage正则提取覆盖率,低于80%会触发GitLab的覆盖率检查
- MR事件和main分支走不同流水线,节省Runner资源

4.3 Jenkins多分支流水线(Jenkinsfile)

生产发布走Jenkins,因为需要审批和回滚:

pipeline {
    agent { label 'k8s-agent' }

    options {
        timeout(time: 30, unit: 'MINUTES')
        buildDiscarder(logRotator(numToKeepStr: '20'))
    }

    parameters {
        choice(name: 'ENV', choices: ['staging', 'prod'], description: '部署环境')
        string(name: 'RELEASE_TAG', defaultValue: '', description: 'release分支tag')
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
                sh 'git describe --tags --always > version.txt'
            }
        }

        stage('Build Image') {
            steps {
                script {
                    def image = "registry.example.com/order-service:${env.BUILD_NUMBER}"
                    sh "docker build -t ${image} ."
                    sh "docker push ${image}"
                    env.IMAGE = image
                }
            }
        }

        stage('Deploy Prod') {
            when { expression { params.ENV == 'prod' } }
            steps {
                input message: "确认发布到生产?", ok: "发布"
                sh """
                    kubectl set image deployment/order-service \
                      order-service=${env.IMAGE} -n prod
                    kubectl rollout status deployment/order-service -n prod --timeout=180s
                """
            }
        }

        stage('Rollback') {
            when { expression { currentBuild.result == 'FAILURE' } }
            steps {
                sh 'kubectl rollout undo deployment/order-service -n prod'
                sh 'curl -X POST $DINGTALK_WEBHOOK -d "{\\"msgtype\\":\\"text\\",\\"text\\":{\\"content\\":\\"生产回滚已触发\\"}}"'
            }
        }
    }

    post {
        success {
            sh 'curl -X POST $DINGTALK_WEBHOOK -d "{\\"msgtype\\":\\"text\\",\\"text\\":{\\"content\\":\\"发布成功: ${env.IMAGE}\\"}}"'
        }
    }
}

input步骤实现了人工审批,rollout status超时180秒自动失败并触发回滚。

五、踩坑与优化

坑1:rebase导致MR流水线重复触发。 GitLab在rebase后会新建pipeline,浪费Runner。解决:在.gitlab-ci.ymlworkflow: rules限制,只对merge_request_eventmain跑。

坑2:Sonar质量门禁误报。 新代码覆盖率要求80%,但历史代码只有45%,导致老模块改动也失败。解决:配置sonar.newCode.referenceBranch=main,只检查增量代码。

坑3:Jenkins并发发布冲突。 两个release同时发布,kubectl互相覆盖。解决:加disableConcurrentBuilds(),并在K8s用RollingUpdate策略,maxSurge=1, maxUnavailable=0

坑4:hotfix合回main时冲突。 因为release分支落后main太多。解决:规定hotfix修复后必须由发起人当天完成双向合并,否则CI告警。

六、效果数据

改造后运行6个月的数据对比:

指标 改造前 改造后
平均MR合并时间 4.2小时 18分钟
发布耗时 4小时 12分钟
生产事故/月 2.3次 0.7次
回滚耗时 35分钟 90秒
代码覆盖率 45% 78%
冲突回滚/周 3次 0.2次

最直观的感受:以前发布要拉群、@所有人、手动测试,现在点一下Jenkins的"发布"按钮,12分钟后钉钉收到成功通知。

七、总结

Git工作流不是越复杂越好,关键是规则可执行、门禁自动化、回滚可预期。我们的核心经验就三条:

  1. 分支生命周期要短,超过2天的feature分支就是技术债
  2. Review和CI必须强制,靠自觉的团队最后都会退化成直接推master
  3. 回滚比发布更重要kubectl rollout undo + 自动告警是最后的安全网

如果你团队还在用git push origin master,建议先从分支保护+MR流水线开始,成本最低,收益最明显。