技术博客

Docker CI/CD 集成实战:GitLab CI 与 Jenkins 流水线最佳实践

系统讲解 Docker 在 CI/CD 流水线中的最佳实践:GitLab CI 完整流水线(构建-扫描-推送-部署四阶段)、Jenkins Pipeline with Docker Agent、DinD(Docker-in-Docker)vs Socket 挂载的安全对比、镜像 tag 策略(commit SHA/语义版本/环境标签)、并行测试与缓存优化,以及 GitLab CI + Harbor + Swarm/K8s 的端到端 CD 实现。

DockerCI/CDGitLabJenkins流水线自动化DevOps企业级

Docker 与 CI/CD 的结合是企业容器化落地的核心环节。本文以 GitLab CI 为主、Jenkins 为辅,讲解从代码提交到生产部署的完整自动化链路,重点关注安全性、构建速度和镜像管理策略。

CI/CD 流水线总体设计

代码提交(git push)

CI 流水线:
  阶段1 lint & test  → 代码检查 + 单元测试
  阶段2 build        → docker buildx 构建镜像 + 缓存
  阶段3 scan         → Trivy 安全扫描(高危漏洞阻断)
  阶段4 push         → 推送到 Harbor 私有仓库
  ↓(仅 main 分支)
CD 流水线:
  阶段5 deploy-staging  → 部署到测试环境
  阶段6 integration-test → 集成测试
  阶段7 deploy-prod     → 手动审批后部署到生产

一、GitLab CI 完整流水线

基础配置

# .gitlab-ci.yml

variables:
  # 镜像仓库
  REGISTRY: harbor.company.com
  IMAGE_NAME: $REGISTRY/backend/$CI_PROJECT_NAME
  
  # 镜像 Tag 策略
  # feature 分支:用 commit SHA(可追溯,不会覆盖)
  # main 分支:用 SHA + latest
  # tag 推送:用 git tag + SHA
  IMAGE_TAG: $CI_COMMIT_SHORT_SHA
  
  # BuildKit 缓存
  CACHE_IMAGE: $REGISTRY/cache/$CI_PROJECT_NAME:buildcache
  
  # Docker 配置
  DOCKER_TLS_CERTDIR: ""            # DinD 配置(见下文)
  DOCKER_DRIVER: overlay2

stages:
  - test
  - build
  - scan
  - push
  - deploy

# 默认配置(所有 job 继承)
default:
  interruptible: true              # 新 pipeline 触发时中断旧的
  tags:
    - docker                       # 只在有 docker tag 的 runner 上运行

测试阶段

# 单元测试(并行)
unit-test:
  stage: test
  image: python:3.11-slim
  cache:
    key: pip-$CI_COMMIT_REF_SLUG
    paths:
      - .pip-cache/
  before_script:
    - pip install --cache-dir .pip-cache -r requirements-dev.txt
  script:
    - pytest tests/unit/ -v --tb=short --junitxml=junit.xml
  artifacts:
    reports:
      junit: junit.xml
    when: always
    expire_in: 1 week

# 代码质量检查
lint:
  stage: test
  image: python:3.11-slim
  script:
    - pip install flake8 mypy
    - flake8 src/
    - mypy src/
  allow_failure: false

构建阶段(BuildKit + 注册表缓存)

build:
  stage: build
  image: docker:27
  services:
    - name: docker:27-dind
      alias: docker
      variables:
        DOCKER_TLS_CERTDIR: ""
  
  before_script:
    - docker login -u "$HARBOR_USER" -p "$HARBOR_TOKEN" $REGISTRY
    - docker buildx create --use --name ci-builder --driver docker-container
  
  script:
    # 构建并利用注册表缓存
    - |
      docker buildx build \
        --platform linux/amd64 \
        --cache-from type=registry,ref=${CACHE_IMAGE} \
        --cache-to type=registry,ref=${CACHE_IMAGE},mode=max \
        --build-arg APP_VERSION=${CI_COMMIT_TAG:-$CI_COMMIT_SHORT_SHA} \
        --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
        --build-arg GIT_COMMIT=${CI_COMMIT_SHA} \
        -t ${IMAGE_NAME}:${IMAGE_TAG} \
        --output type=docker,dest=/tmp/image.tar \
        .
    
    # 保存镜像供后续 job 使用
    - mv /tmp/image.tar image.tar
  
  artifacts:
    paths:
      - image.tar
    expire_in: 1 day

安全扫描阶段

trivy-scan:
  stage: scan
  image: aquasec/trivy:latest
  
  before_script:
    - docker load < image.tar
  
  script:
    # 扫描镜像,高危/严重漏洞时 exit code 非0(自动失败)
    - |
      trivy image \
        --exit-code 1 \
        --severity HIGH,CRITICAL \
        --ignore-unfixed \
        --format sarif \
        --output trivy-results.sarif \
        ${IMAGE_NAME}:${IMAGE_TAG}
  
  artifacts:
    reports:
      sast: trivy-results.sarif    # GitLab Security Dashboard 展示
    when: always
    expire_in: 1 week
  
  allow_failure: false             # 高危漏洞必须修复才能继续
  
  needs:
    - job: build
      artifacts: true

推送阶段

push:
  stage: push
  image: docker:27
  services:
    - docker:27-dind
  
  before_script:
    - docker login -u "$HARBOR_USER" -p "$HARBOR_TOKEN" $REGISTRY
    - docker load < image.tar
  
  script:
    # feature 分支:只推 SHA tag
    - docker push ${IMAGE_NAME}:${IMAGE_TAG}
    
    # main 分支:额外推 latest
    - |
      if [ "$CI_COMMIT_BRANCH" = "main" ]; then
        docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${IMAGE_NAME}:latest
        docker push ${IMAGE_NAME}:latest
      fi
    
    # git tag:额外推语义版本 tag
    - |
      if [ -n "$CI_COMMIT_TAG" ]; then
        docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${IMAGE_NAME}:${CI_COMMIT_TAG}
        docker push ${IMAGE_NAME}:${CI_COMMIT_TAG}
      fi
  
  needs:
    - job: trivy-scan
    - job: build
      artifacts: true
  
  only:
    - main
    - tags
    - /^release\/.*$/

部署阶段

deploy-staging:
  stage: deploy
  image: alpine:3.19
  
  before_script:
    - apk add --no-cache openssh-client curl
    - eval $(ssh-agent -s)
    - echo "$DEPLOY_KEY" | ssh-add -
  
  script:
    # 通过 SSH 触发 Swarm 更新
    - |
      ssh -o StrictHostKeyChecking=no deploy@swarm-manager-01 \
        "docker service update \
          --image ${IMAGE_NAME}:${IMAGE_TAG} \
          --with-registry-auth \
          backend_api"
    
    # 等待更新完成
    - sleep 30
    
    # 验证部署
    - |
      HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
        https://api-staging.company.com/health)
      if [ "$HTTP_STATUS" != "200" ]; then
        echo "❌ 部署验证失败(HTTP $HTTP_STATUS),触发回滚"
        ssh deploy@swarm-manager-01 "docker service rollback backend_api"
        exit 1
      fi
      echo "✅ staging 部署成功"
  
  environment:
    name: staging
    url: https://api-staging.company.com
  
  only:
    - main
  needs:
    - push

deploy-production:
  stage: deploy
  image: alpine:3.19
  
  script:
    - |
      ssh deploy@swarm-manager-01 \
        "APP_VERSION=${IMAGE_TAG} docker stack deploy \
          -c /opt/stacks/myapp/stack.yml \
          --with-registry-auth \
          myapp"
  
  environment:
    name: production
    url: https://api.company.com
  
  when: manual                     # 手动触发(需要人工审批)
  only:
    - main
  needs:
    - deploy-staging

二、Jenkins Pipeline

// Jenkinsfile
pipeline {
    agent none  // 使用动态 agent

    environment {
        REGISTRY = 'harbor.company.com'
        IMAGE_NAME = "${REGISTRY}/backend/${env.JOB_NAME}"
        IMAGE_TAG = sh(returnStdout: true, script: 'git rev-parse --short HEAD').trim()
        CACHE_IMAGE = "${REGISTRY}/cache/${env.JOB_NAME}:buildcache"
    }

    stages {
        stage('Test') {
            agent {
                docker {
                    image 'python:3.11-slim'
                    args '-v /var/cache/pip:/root/.cache/pip'
                }
            }
            steps {
                sh 'pip install -r requirements-dev.txt'
                sh 'pytest tests/ --junitxml=test-results.xml'
            }
            post {
                always {
                    junit 'test-results.xml'
                }
            }
        }

        stage('Build & Scan') {
            agent {
                label 'docker-buildx'  // 有 buildx 的节点
            }
            steps {
                withCredentials([usernamePassword(
                    credentialsId: 'harbor-credentials',
                    usernameVariable: 'HARBOR_USER',
                    passwordVariable: 'HARBOR_TOKEN'
                )]) {
                    sh """
                        docker login -u \$HARBOR_USER -p \$HARBOR_TOKEN ${REGISTRY}
                        
                        docker buildx build \\
                            --cache-from type=registry,ref=${CACHE_IMAGE} \\
                            --cache-to type=registry,ref=${CACHE_IMAGE},mode=max \\
                            --build-arg APP_VERSION=${IMAGE_TAG} \\
                            -t ${IMAGE_NAME}:${IMAGE_TAG} \\
                            --load \\
                            .
                        
                        trivy image \\
                            --exit-code 1 \\
                            --severity HIGH,CRITICAL \\
                            --ignore-unfixed \\
                            ${IMAGE_NAME}:${IMAGE_TAG}
                    """
                }
            }
        }

        stage('Push') {
            agent { label 'docker-buildx' }
            when {
                branch 'main'
            }
            steps {
                withCredentials([usernamePassword(
                    credentialsId: 'harbor-credentials',
                    usernameVariable: 'HARBOR_USER',
                    passwordVariable: 'HARBOR_TOKEN'
                )]) {
                    sh """
                        docker login -u \$HARBOR_USER -p \$HARBOR_TOKEN ${REGISTRY}
                        docker push ${IMAGE_NAME}:${IMAGE_TAG}
                        docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${IMAGE_NAME}:latest
                        docker push ${IMAGE_NAME}:latest
                    """
                }
            }
        }

        stage('Deploy Staging') {
            agent { label 'deploy' }
            when { branch 'main' }
            steps {
                sshagent(['deploy-key']) {
                    sh """
                        ssh -o StrictHostKeyChecking=no deploy@swarm-manager-01 \\
                            docker service update --image ${IMAGE_NAME}:${IMAGE_TAG} \\
                            --with-registry-auth backend_api
                    """
                }
            }
        }

        stage('Deploy Production') {
            agent { label 'deploy' }
            when { branch 'main' }
            input {
                message "确认部署到生产环境?"
                ok "部署"
                submitter "ops-team"
            }
            steps {
                sshagent(['deploy-key']) {
                    sh """
                        ssh deploy@swarm-manager-01 \\
                            "APP_VERSION=${IMAGE_TAG} docker stack deploy \\
                            -c /opt/stacks/myapp/stack.yml \\
                            --with-registry-auth myapp"
                    """
                }
            }
        }
    }

    post {
        failure {
            slackSend(
                channel: '#ops-alerts',
                color: 'danger',
                message: "❌ 构建失败: ${env.JOB_NAME} #${env.BUILD_NUMBER} (<${env.BUILD_URL}|查看>)"
            )
        }
        success {
            slackSend(
                channel: '#ops-deploys',
                color: 'good',
                message: "✅ 部署成功: ${IMAGE_NAME}:${IMAGE_TAG}"
            )
        }
    }
}

三、DinD vs Socket 挂载

在 CI 中运行 Docker 命令有两种方式:

方式1:Docker-in-Docker(DinD)
  CI 容器内运行独立 Docker daemon
  优点:完全隔离,安全
  缺点:需要特权模式(--privileged),性能差(嵌套虚拟化)

方式2:挂载宿主机 Docker Socket(-v /var/run/docker.sock:/var/run/docker.sock)
  CI 容器直接使用宿主机的 Docker daemon
  优点:共享宿主机缓存,构建速度快
  缺点:安全风险(CI 容器拥有宿主机 Docker 的完整控制权)

方式3:Docker Socket Proxy(推荐折中方案)
  只暴露需要的 API(如只允许 build/push)
# GitLab CI — DinD 方式(安全但慢)
services:
  - name: docker:27-dind
    alias: docker
    variables:
      DOCKER_TLS_CERTDIR: "/certs"

variables:
  DOCKER_TLS_CERTDIR: "/certs"
  DOCKER_CERT_PATH: "/certs/client"
  DOCKER_HOST: tcp://docker:2376
  DOCKER_TLS_VERIFY: 1
# GitLab Runner 配置 — Socket 挂载方式(推荐自托管 runner)
# /etc/gitlab-runner/config.toml
[[runners]]
  name = "docker-runner"
  executor = "docker"
  [runners.docker]
    image = "docker:27"
    volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
    # 不需要 privileged = true

四、镜像 Tag 策略

推荐 Tag 规范:

开发分支(feature/*):
  image:abc1234              ← commit SHA,用于调试
  不推 latest(避免污染)

主分支(main):
  image:abc1234              ← commit SHA
  image:latest               ← 最新主分支
  image:main-20260515        ← 日期(便于回溯)

发布 Tag(v1.2.3):
  image:abc1234              ← commit SHA
  image:v1.2.3               ← 语义版本
  image:v1.2                 ← 次版本
  image:v1                   ← 主版本
  image:stable               ← 稳定版别名

环境 Tag(用于快速标识当前环境版本):
  image:staging              ← 当前 staging 环境的版本
  image:production           ← 当前 production 环境的版本
  (每次部署时更新这个 tag)

小结

Docker CI/CD 的三个关键实践:

构建缓存--cache-from/to type=registry 让每次 CI 构建复用上次的层,代码不变时 build job 从几分钟缩短到几十秒。

安全扫描前置:Trivy 扫描放在 push 之前,高危漏洞直接阻断流水线,确保进入仓库的镜像都通过了基本安全检查。

精准 Tag:用 commit SHA 作为主 Tag(可追溯),用语义版本和环境 Tag(staging/production)作为别名,任何时候都能精确定位线上运行的版本。