CI/CD 集成实战

CI/CD 集成实战

写在前面

前面的文章把镜像怎么写、怎么编排、怎么存都讲完了,但生产环境真正的工作流是:代码 push → 自动测试 → 自动构建镜像 → 推送到 Harbor → 自动部署到测试/生产环境。这就是 CI/CD。

本篇会用三个主流 CI 平台(GitHub Actions / GitLab CI / Jenkins)各跑一个完整流水线,讲清楚关键节点和性能优化。

一、CI/CD 的关键节点

任何 Docker 化的 CI/CD 都包含这几个核心步骤:

[Push Code]
     │
     ▼
[Checkout] → 拉代码
     │
     ▼
[Test] → 跑单测、lint、类型检查
     │
     ▼
[Build Image] → 构建 Docker 镜像
     │
     ▼
[Push Image] → 推送到 Harbor
     │
     ▼
[Deploy] → 触发部署
     │
     ▼
[Notify] → 通知开发者

每一步都要:

二、镜像标签策略

在开始写 CI 之前,先确定怎么给镜像打 tag。

Tag 来源 适用
Git 分支名 开发环境,如 develop feature/foo
Git tag 生产版本,如 v1.2.3
PR/MR 编号 预览环境,如 pr-123
Commit SHA(短) 精确追溯,如 a1b2c3d
时间戳 备份/归档

推荐策略:多 tag 同时打:

# 比如 commit a1b2c3d 在 main 分支,PR #123
docker tag myapp:a1b2c3d myapp:main
docker tag myapp:a1b2c3d myapp:pr-123
docker tag myapp:a1b2c3d myapp:a1b2c3d  # SHA 永远保留

三、GitHub Actions 完整流水线

3.1 项目结构

.
├── .github/
│   └── workflows/
│       ├── ci.yml           # 主流程
│       └── release.yml      # 发布流程
├── Dockerfile
├── docker-compose.yml
└── src/

3.2 基础流水线

.github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  REGISTRY: harbor.company.com
  IMAGE_NAME: myproject/myapp

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

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm run build

  build:
    name: Build & Push
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write  # OIDC
    steps:
      - uses: actions/checkout@v4

      # Docker Buildx
      - uses: docker/setup-buildx-action@v3

      # 登录 Harbor (用用户名密码)
      - name: Login to Harbor
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.HARBOR_USER }}
          password: ${{ secrets.HARBOR_PASSWORD }}

      # 镜像元数据(自动生成 tag)
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=sha,prefix=git-
            type=raw,value=latest,enable={{is_default_branch}}            

      # 构建并推送(支持缓存)
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

3.3 缓存优化:从 8 分钟到 90 秒

第一次跑:8 分钟(因为没有缓存,完全 cold start)。

开启 cache-from 和 cache-to 后:

关键是:缓存层单独打 tag(buildcache),跟应用镜像分开放。

3.4 多架构镜像(ARM + AMD64)

公司有 ARM 服务器(Mac mini M2 集群)+ AMD 服务器,需要多架构镜像:

- name: Set up QEMU
  uses: docker/setup-qemu-action@v3

- name: Build multi-arch
  uses: docker/build-push-action@v5
  with:
    context: .
    platforms: linux/amd64,linux/arm64
    push: true
    tags: ${{ steps.meta.outputs.tags }}
    cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
    cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

setup-qemu 会启动一个 QEMU 模拟器,慢(ARM64 镜像在 AMD runner 上跑要 2-3 倍时间)。生产中我们后来换了 ARM runner(GitHub 还没出,自建或者用 Cirrus),速度大幅提升。

3.5 触发部署

测试 + 构建完成后,自动部署到测试环境:

  deploy:
    name: Deploy to Staging
    needs: build
    if: github.ref == 'refs/heads/develop'
    runs-on: ubuntu-latest
    steps:
      - name: Trigger Argo CD sync
        run: |
          curl -X POST "https://argocd.company.com/api/v1/applications/myapp-staging/sync" \
            -H "Authorization: Bearer ${{ secrets.ARGOCD_TOKEN }}" \
            -H "Content-Type: application/json" \
            -d '{"revision":"HEAD"}'          

四、GitLab CI 完整流水线

4.1 .gitlab-ci.yml

stages:
  - test
  - build
  - deploy

variables:
  REGISTRY: harbor.company.com
  IMAGE_NAME: myproject/myapp
  DOCKER_TLS_CERTDIR: ""  # 关闭 TLS,内网用

# 缓存
.cache_template: &cache_template
  key:
    files:
      - package-lock.json
  paths:
    - node_modules/

# ===== 测试 =====
test:
  stage: test
  image: node:20-alpine
  cache:
    - <<: *cache_template
  script:
    - npm ci --no-audit --no-fund
    - npm run lint
    - npm test
  coverage: '/Statements\s*:\s*(\d+\.\d+)%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

# ===== 构建 =====
build:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  needs: [test]
  before_script:
    - echo "$HARBOR_PASSWORD" | docker login $REGISTRY -u "$HARBOR_USER" --password-stdin
  script:
    - |
      TAGS=""
      TAGS="$TAGS --tag $REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHORT_SHA"
      TAGS="$TAGS --tag $REGISTRY/$IMAGE_NAME:$CI_COMMIT_REF_NAME"
      if [ "$CI_COMMIT_BRANCH" = "main" ]; then
        TAGS="$TAGS --tag $REGISTRY/$IMAGE_NAME:latest"
      fi
      if [ -n "$CI_COMMIT_TAG" ]; then
        TAGS="$TAGS --tag $REGISTRY/$IMAGE_NAME:$CI_COMMIT_TAG"
      fi

      docker buildx create --use
      docker buildx build \
        --platform linux/amd64,linux/arm64 \
        --cache-from type=registry,ref=$REGISTRY/$IMAGE_NAME:buildcache \
        --cache-to type=registry,ref=$REGISTRY/$IMAGE_NAME:buildcache,mode=max \
        $TAGS \
        --push \
        .      
  only:
    - branches
    - tags
    - merge_requests

# ===== 部署测试 =====
deploy:staging:
  stage: deploy
  image: alpine:3.19
  needs: [build]
  before_script:
    - apk add --no-cache curl
  script:
    - |
      curl -X POST "https://argocd.company.com/api/v1/applications/myapp-staging/sync" \
        -H "Authorization: Bearer $ARGOCD_TOKEN" \
        -H "Content-Type: application/json" \
        -d '{"revision":"HEAD"}'      
  environment:
    name: staging
    url: https://staging.myapp.com
  only:
    - develop

# ===== 部署生产 =====
deploy:production:
  stage: deploy
  image: alpine:3.19
  needs: [build]
  before_script:
    - apk add --no-cache curl
  script:
    - |
      curl -X POST "https://argocd.company.com/api/v1/applications/myapp-production/sync" \
        -H "Authorization: Bearer $ARGOCD_TOKEN" \
        -H "Content-Type: application/json" \
        -d "{\"revision\":\"$CI_COMMIT_TAG\"}"      
  environment:
    name: production
    url: https://myapp.com
  when: manual
  only:
    - tags

4.2 GitLab CI 的几个特殊点

缓存配置比 GitHub 灵活:

cache:
  key:  # 不同分支不共享缓存
    files:
      - package-lock.json
  paths:
    - node_modules/
  policy: pull-push  # 默认,pull 旧缓存,push 新缓存

services 启动 DinD:

image: docker:24
services:
  - docker:24-dind  # 启动一个 docker-in-docker 容器

GitLab Runner 默认开启了 DinD 支持,直接用即可。

when: manual 控制生产部署——必须人工点确认才能部署生产。

五、Jenkins 完整流水线

5.1 Jenkinsfile(Declarative Pipeline)

pipeline {
  agent any

  environment {
    REGISTRY = 'harbor.company.com'
    IMAGE_NAME = 'myproject/myapp'
  }

  stages {
    stage('Checkout') {
      steps {
        checkout scm
      }
    }

    stage('Test') {
      agent {
        docker {
          image 'node:20-alpine'
          reuseNode true
        }
      }
      steps {
        sh 'npm ci --no-audit --no-fund'
        sh 'npm run lint'
        sh 'npm test'
      }
      post {
        always {
          junit '**/junit-*.xml'
        }
      }
    }

    stage('Build') {
      steps {
        script {
          // 计算 tag
          def gitCommit = sh(returnStdout: true, script: 'git rev-parse --short HEAD').trim()
          def gitBranch = sh(returnStdout: true, script: 'git rev-parse --abbrev-ref HEAD').trim()
          def tags = [
            "${env.REGISTRY}/${env.IMAGE_NAME}:${gitCommit}",
            "${env.REGISTRY}/${env.IMAGE_NAME}:${gitBranch}"
          ]
          if (gitBranch == 'main') {
            tags.add("${env.REGISTRY}/${env.IMAGE_NAME}:latest")
          }

          // 登录
          sh "echo \${HARBOR_PASSWORD} | docker login ${env.REGISTRY} -u \${HARBOR_USER} --password-stdin"

          // 构建并推送
          sh """
            docker buildx create --use --name mybuilder || docker buildx use mybuilder
            docker buildx build \
              --platform linux/amd64,linux/arm64 \
              --cache-from type=registry,ref=${env.REGISTRY}/${env.IMAGE_NAME}:buildcache \
              --cache-to type=registry,ref=${env.REGISTRY}/${env.IMAGE_NAME}:buildcache,mode=max \
              ${tags.collect { "--tag " + it }.join(' ')} \
              --push \
              .
          """

          env.GIT_COMMIT_SHORT = gitCommit
        }
      }
    }

    stage('Deploy to Staging') {
      when {
        branch 'develop'
      }
      steps {
        sh """
          curl -X POST "https://argocd.company.com/api/v1/applications/myapp-staging/sync" \
            -H "Authorization: Bearer ${env.ARGOCD_TOKEN}" \
            -H "Content-Type: application/json" \
            -d '{"revision":"HEAD"}'
        """
      }
    }

    stage('Deploy to Production') {
      when {
        buildingTag()
      }
      steps {
        input 'Deploy to production?'
        sh """
          curl -X POST "https://argocd.company.com/api/v1/applications/myapp-production/sync" \
            -H "Authorization: Bearer ${env.ARGOCD_TOKEN}" \
            -H "Content-Type: application/json" \
            -d '{"revision":"${env.GIT_COMMIT_SHORT}"}'
        """
      }
    }
  }

  post {
    success {
      // 通知 Slack
      slackSend(
        channel: '#deployments',
        color: 'good',
        message: "✅ ${env.JOB_NAME} #${env.BUILD_NUMBER} succeeded"
      )
    }
    failure {
      slackSend(
        channel: '#deployments',
        color: 'danger',
        message: "❌ ${env.JOB_NAME} #${env.BUILD_NUMBER} failed"
      )
    }
  }
}

5.2 Jenkins 的几个特殊点

agent { docker { ... } } 跑测试——每个 stage 用不同容器,隔离性强。

input 'Deploy to production?'——Jenkins 原生的"人工确认"步骤,生产部署前暂停等点确认。

buildingTag()——只在 git tag 触发时才跑生产部署。

六、缓存优化的几个进阶技巧

6.1 本地缓存

GitHub Actions 的 runner 每次都是新的 VM,没有本地磁盘持久化。但 BuildKit 自带的本地缓存能跨 step 共享(同一 job 内):

- name: Build
  uses: docker/build-push-action@v5
  with:
    cache-from: type=local,src=/tmp/.buildx-cache
    cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max

- name: Move cache
  run: |
    rm -rf /tmp/.buildx-cache
    mv /tmp/.buildx-cache-new /tmp/.buildx-cache    

6.2 远程缓存(Registry)

已经在前面用过,生产强烈推荐——多个 CI job 之间共享缓存。

6.3 依赖预构建

问题:每次 CI 都 npm ci,即使 package.json 没变也要下载。

解决:预构建一个 base 镜像,把所有 dependencies 装好:

# Dockerfile.base
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
# 每周跑一次,更新 base 镜像
- name: Build base
  run: |
    docker buildx build \
      -f Dockerfile.base \
      -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/base:latest \
      --push .    

应用 Dockerfile:

FROM harbor.company.com/myproject/myapp/base:latest AS deps

WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

我们用这个方法,首次构建时间从 8 分钟降到 3 分钟(因为 deps 已经预装好)。

6.4 跳过某些文件的缓存影响

Dockerfile 改了不该影响缓存的层?用 --cache-from + BuildKit 的 inline cache:

- uses: docker/build-push-action@v5
  with:
    cache-from: |
      type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
      type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main      

main 是上一次构建的完整镜像,作为 fallback cache。

七、安全:CI 镜像安全扫描

7.1 在 CI 里集成 Trivy

- name: Scan image
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
    format: 'sarif'
    output: 'trivy-results.sarif'
    severity: 'CRITICAL,HIGH'
    exit-code: '1'  # 有漏洞就失败
    ignore-unfixed: true

- name: Upload Trivy results
  uses: github/codeql-action/upload-sarif@v2
  if: always()
  with:
    sarif_file: 'trivy-results.sarif'

把扫描结果上传到 GitHub Security tab,可以在 PR 里直接看到漏洞。

7.2 SBOM(软件物料清单)

- name: Generate SBOM
  uses: anchore/sbom-action@v0
  with:
    image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
    format: spdx-json
    artifact-name: myapp.spdx.json

SBOM 是合规要求——很多行业(金融、医疗)必须提供。

八、我们最终的完整 DevOps 闭环

Developer Push Code
       │
       ▼
GitHub Actions (CI)
  ├─ Test (lint + unit + integration)
  ├─ Build & Push to Harbor
  └─ Trivy Scan
       │
       ▼
Harbor (镜像仓库)
       │
       ▼
Argo CD (CD)
  ├─ 监听 Harbor 新镜像
  ├─ 自动同步到 K8s
  └─ 失败自动回滚
       │
       ▼
Production

这个闭环的好处:

下一篇讲 容器监控与日志,用 Prometheus + Grafana + Loki 搭一套完整的可观测性体系。

参考资料