← Blog
·10 blog.minutes

构建现代 CI/CD 管道:从提交到生产

一份设计快速、可靠、安全的 CI/CD 管道的实用指南——涵盖 GitHub Actions、GitLab CI、缓存、部署策略和生产监控。

每个软件团队都运行 CI/CD。很少有团队能把它做好。加速交付的管道和拖慢所有人的管道之间的区别在于设计:你如何组织阶段、如何处理失败、如何管理环境以及如何部署。设计良好的管道将每次提交变成一条安全、可重复的生产路径。设计不良的管道将每次部署变成一场消防演习。

本文介绍了现代 CI/CD 管道在实践中是什么样的。我们将讨论提供商选择、阶段设计、缓存、密钥管理、部署策略以及将所有这一切联系起来的监控。每个部分都包含真实的工作流示例,你可以根据自己的技术栈进行调整。

为什么现代 CI/CD 管道很重要

CI/CD 的目的不是为了自动化而自动化。而是为了缩短从编写代码到知道该代码在生产中是否正常工作之间的反馈循环。在该循环中节省的每一分钟,都是开发者可以花在下一个问题上的一分钟,而不是切换回一个二十分钟前因他们已经不记得的原因而失败的构建。

现代 CI/CD 在几个重要方面不同于经典的 Jenkins 加 Travis 时代。首先,管道现在定义为代码。一个 .github/workflows/deploy.yml 文件存在于它部署的应用所在的同一个仓库中,与其一起进行版本控制,一起审阅。管道配置不再是一个由独立团队维护的独立构件。它是代码库的一部分,与任何其他变更一样经过相同的审阅流程。

其次,现代管道是为速度而设计的。它们使用缓存、并行作业执行、矩阵构建和条件阶段跳过,在几分钟而不是几十分钟内完成。一个管道如果花费的时间超过开发者的上下文保持窗口——大约十到十五分钟——就已经失败了它的主要目的。

第三,现代管道天生具有安全意识。密钥在运行时从专用密钥存储注入,而不是嵌入在配置文件中。供应链攻击通过依赖锁定、锁定文件和签名验证来缓解。部署凭证按环境分配范围并自动轮换。

这三个属性——管道即代码、速度优先设计和安全优先的默认设置——定义了现代 CI/CD 的面貌。其他一切都是实现细节。

选择你的 CI/CD 提供商并设置质量门

提供商格局已整合为三个主要选项,各有不同的权衡。GitHub Actions 是已在使用 GitHub 的团队中最受欢迎的选择。紧密的 GitHub 集成意味着拉取请求检查、合并队列和部署环境都开箱即用。Action 市场为生态系统中的几乎每个工具提供了预构建步骤,托管运行器包括 macOS、Windows、ARM 和 GPU 实例用于特殊构建。

GitLab CI 是最强的替代品,特别是对于想要一个集源码控制、CI/CD、容器注册表和构件存储于一身的单一平台的团队。GitLab 的管道配置在多个方面比 GitHub Actions 更具表现力——原生支持有向无环图(DAG)管道、子管道和父管道,以及通过 GitLab Kubernetes 代理内置的金丝雀部署。GitLab 还在主要提供商中提供最强的免费层 CI 分钟数。

其他选项服务于特定的细分市场。Jenkins 对于有大量插件投资和本地需求的组织仍然相关,尽管其配置即代码的故事较弱。CircleCI 通过智能缓存和并行性提供快速构建时间,其配置格式清晰可读。Buildkite 占据了一个有趣的混合位置:你提供自己的基础设施,Buildkite 提供编排层,让团队完全控制执行环境而无需管理 CI 服务器。

  • GitHub Actions:最适合 GitHub 原生团队,最大的预构建 actions 生态系统,公共仓库免费。
  • GitLab CI:最适合端到端 DevOps 平台,最强的 Kubernetes 集成,慷慨的免费层。
  • CircleCI:最适合优先考虑原始速度的团队,优秀的缓存和并行性。
  • Buildkite:最适合需要自定义基础设施和托管编排的团队。
  • Jenkins:最适合有现有插件投资的传统企业环境。

对于本文,示例使用 GitHub Actions,因为它是最广泛采用的。这些模式可以直接翻译到其他提供商,只需最少的语法更改。

选择了提供商后,要设计的第一个管道阶段是质量门。代码检查(Linting)和测试是每个变更在向部署迈进之前必须清除的最低门槛。关键的设计决策是这些门是在合并前运行(拉取请求上的必要检查)还是合并后运行(合并后验证)。对于任何向生产交付的团队,正确的答案是两者都做——但合并前门是保护主分支的那个。

代码检查应该快。TypeScript 类型检查、ESLint、Prettier——这些应该在几秒而不是几分钟内完成。如果你的代码检查阶段花费超过六十秒,你要么在检查生成的文件,要么在运行应该被自动修复而不是被检查的规则。保持代码检查专注于正确性和无法自动修复的风格强制,并将格式化器作为预提交钩子运行。

测试需要更精心的阶段设计。单元测试套件在每次推送时运行——它必须快。集成测试套件在单元测试通过时运行——它必须彻底。端到端测试套件在集成测试通过时运行——它必须可靠。将测试组织到这些层中并条件性地运行它们,可以保持总管道时间可预测,同时保持覆盖率深度。

name: CI — Lint and Test

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

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    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 typecheck
      - run: npm run lint

  unit:
    needs: [lint]
    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 test:unit -- --coverage

  integration:
    needs: [unit]
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: app_test
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"
      - run: npm ci
      - run: npm run test:integration
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/app_test

在这个工作流中值得指出三个模式。首先,带有 cancel-in-progress 的并发控制:如果开发者推送了第二次提交而第一次运行仍在进行中,第一次运行会自动取消。这防止了计算浪费并保持反馈快速。其次,使用 needs 的依赖链:集成测试只在单元测试通过时运行。第三,用于集成测试依赖的服务容器,它启动干净的数据库而无需外部基础设施。

阶段 1:构建和缓存——以制品为部署单元

测试通过后,下一个阶段生成可部署的制品。制品应该只构建一次并在各个环境中提升。在每个环境中重新构建是一个常见的反模式,引入了不必要的风险:通过测试的代码可能与到达生产的代码不同,因为构建过程中的环境特定差异。

构建阶段是缓存影响最大的地方。依赖——npm 包、Docker 层、Python wheels——可以在运行之间缓存,将构建时间减少一个数量级。缓存键的策略很重要:包含锁定文件哈希,这样包更新会使缓存失效,但避免包含提交哈希,因为那会使每个构建都成为缓存未命中。

基于 Docker 的部署增加了一个额外的缓存层。Docker 层缓存(DLC)缓存中间镜像层,这样更改应用文件不需要重新安装系统包。GitHub Actions 通过 docker/build-push-action 支持 DLC,使用指向注册表或 GitHub Actions 缓存的 cache-from 和 cache-to 参数。

name: Build and Push

on:
  push:
    branches: [main]

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

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

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

      - name: Extract short SHA for tagging
        id: vars
        run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/myorg/myapp:latest
            ghcr.io/myorg/myapp:${{ steps.vars.outputs.sha_short }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

最终的制品——一个同时标记了 latest 和提交 SHA 的 Docker 镜像——是单一的部署单元。SHA 标签提供了精确的可追溯性:给定一个运行中的容器,你可以查看其镜像标签并确切知道哪个提交产生了它。latest 标签为开发环境提供了方便的引用。生产环境始终按显式 SHA 部署,从不使用 latest。

阶段 2:环境管理和密钥——在不妥协的情况下进行配置

环境管理是大多数管道设计错误发生的地方。天真的方法——维护一个开发分支、一个预发布分支和一个生产分支,每个都有自己的管道配置——会创建级联问题。当环境与分支绑定时,合并冲突、配置漂移和跳过环境的修补程序是不可避免的。

现代方法是按部署设置环境,而不是按分支设置环境。一个单一主分支产生一个单一制品。该制品部署到开发环境进行验证,提升到预发布环境进行上线前验证,并在准备好时提升到生产环境。如果制品在开发环境中通过但在预发布环境中失败,修复会进入下一个提交——而不是进入绕过质量门的修补程序。环境是同一个制品的不同部署,而不是不同的代码库。

密钥处理遵循同样的原则:注入,不要烘焙。密钥绝不应出现在 Docker 镜像、仓库中的环境文件或管道日志中。GitHub Actions 在仓库和环境级别提供了加密密钥,GITHUB_TOKEN 提供了 GitHub API 访问的短期凭证,无需存储个人访问令牌。

对于超出内置密钥管理的组织,外部密钥存储如 HashiCorp Vault、AWS Secrets Manager 或 Doppler 提供了额外功能:自动轮换、审计日志和细粒度访问策略。管道通过认证请求在运行时检索密钥,而不是完全存储在管道配置中。

name: Deploy to Environment

on:
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        type: choice
        options:
          - staging
          - production
      tag:
        description: "Docker image tag (commit SHA)"
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    concurrency:
      group: deploy-${{ inputs.environment }}
      cancel-in-progress: false
    steps:
      - name: Deploy to Kubernetes
        uses: actions-hub/kubectl@master
        env:
          KUBE_CONFIG: ${{ secrets[format('KUBE_CONFIG_{0}', inputs.environment)] }}
        with:
          args: |-
            set image deployment/myapp \
              myapp=ghcr.io/myorg/myapp:${{ inputs.tag }} \
              -n ${{ inputs.environment }}

      - name: Verify deployment
        run: |
          kubectl rollout status deployment/myapp \
            -n ${{ inputs.environment }} \
            --timeout=5m

      - name: Notify on failure
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "Deploy to ${{ inputs.environment }} failed for tag ${{ inputs.tag }}"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

这个工作流使用了环境范围的密钥:KUBE_CONFIG_STAGING 和 KUBE_CONFIG_PRODUCTION 分开存储,管道根据目标环境选择正确的那个。环境级别的并发组确保每个环境一次只运行一个部署,防止 rollout 期间的竞态条件。rollout 状态命令会阻塞直到 Kubernetes 报告部署健康,失败通知仅在此检查失败时触发。

阶段 3:部署策略——蓝绿部署、金丝雀部署和回滚

如何部署与部署什么同样重要。最简单的策略——关闭旧版本,启动新版本——适用于启动时间快且无状态的低风险服务。对于其他任何情况,部署策略决定了糟糕的发布是影响所有用户还是少数用户,回滚需要几秒还是几分钟,以及团队是在周五下午部署还是只在周二早上部署。

蓝绿部署维护两个相同的环境。在任何时候,一个环境(蓝色)服务生产流量,而另一个(绿色)运行新版本。当绿色环境通过健康检查时,负载均衡器将流量从蓝色切换到绿色。蓝色环境保持就绪以供即时回滚:如果新版本失败,切换回流量只需一次 DNS 或负载均衡器更改。权衡是成本——两个完整环境意味着在切换窗口期间双倍的基础设施支出。

金丝雀部署将一小部分流量路由到新版本,而旧版本服务于大部分流量。金丝雀百分比随着可观测性确认新版本健康而逐步增加:百分之一,然后百分之五,然后百分之二十五,然后百分之一百。这种策略在全面上线之前用真实流量发现问题,并在问题发生时限制影响范围。权衡是复杂性——金丝雀部署需要复杂的流量路由、可观测性集成以及基于指标的自动提升或回滚。

回滚自动化是每个管道都需要但大多数管道缺乏的安全网。回滚应该是一个命令或按钮点击,而不是重新部署之前制品的手动过程。关键的设计要求是之前的制品是可用的:如果你的镜像用提交 SHA 标记并存储在不对未引用镜像进行垃圾回收的注册表中,回滚意味着重新部署最后已知良好的 SHA。如果你的镜像是临时标记的——例如 latest——回滚需要从更早的提交重建,这更慢并引入了重建可能产生不同输出的风险。

name: Rollback

on:
  workflow_dispatch:
    inputs:
      environment:
        description: "Environment to roll back"
        required: true
        type: choice
        options:
          - staging
          - production
      target-tag:
        description: "Tag to roll back to (leave empty for previous deploy)"
        required: false

jobs:
  rollback:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - name: Get previous deployment tag
        id: previous
        if: inputs.target-tag == ''
        run: |
          PREVIOUS=$(kubectl rollout history deployment/myapp \
            -n ${{ inputs.environment }} \
            --revision=1 2>/dev/null | grep -oP 'ghcr\.io/myorg/myapp:\K[a-f0-9]+')
          echo "tag=$PREVIOUS" >> $GITHUB_OUTPUT

      - name: Roll back to target
        run: |
          TAG="${{ inputs.target-tag || steps.previous.outputs.tag }}"
          kubectl set image deployment/myapp \
            myapp=ghcr.io/myorg/myapp:$TAG \
            -n ${{ inputs.environment }}
          kubectl rollout status deployment/myapp \
            -n ${{ inputs.environment }} \
            --timeout=3m

上面的回滚工作流在未提供特定目标时查询 Kubernetes rollout 历史以找到之前的修订版本,并重新部署那个镜像。在实践中,大多数团队还会在部署跟踪系统中维护最近成功部署的列表,以便回滚永远不依赖 Kubernetes 内部历史,后者在可配置数量的修订版本后可以被垃圾回收。

阶段 4:监控和持续改进

部署不是在管道变绿时就完成了。它在团队知道已部署版本在生产中健康时才完成。这需要在每个管道阶段集成监控:部署前检查在部署前验证环境健康,部署后检查在 rollout 后几分钟内验证新版本,以及为金丝雀提升和回滚决策提供数据的持续可观测性。

管道有效性最重要的监控指标是部署频率。如果你每月部署一次,你的管道并没有提高你的交付能力——它只是自动化了不频繁的发布。CI/CD 管道设计的目标不是自动化部署,而是持续交付:在任何时候安全且自信地发布任何提交的能力。部署频率是管道是否实现这一目标的最佳代理指标。

第二个最重要的指标是平均恢复时间(MTTR)。从检测到生产问题到修复部署需要多长时间?最快的恢复路径是回滚——几秒或几分钟。次快的是绕过完整管道的修补程序——几分钟或几小时。最慢的是从提交到部署的完整管道——仍应控制在三十分钟以内。如果你的 MTTR 超过你的部署频率,你有一个管道设计问题。

部署频率告诉你你的管道是否足够快。平均恢复时间告诉你你的管道是否足够安全。如果任何一个数字在恶化,你的管道设计就朝着错误的方向发展。
  • 将部署频率和 MTTR 作为管道健康的前置指标,而不是虚荣指标。
  • 设置部署后监控仪表盘,按版本而不是仅按环境显示错误率、延迟和流量。
  • 基于错误预算自动化金丝雀提升:当错误率在 N 分钟内保持在阈值以下时提升,超过时自动回滚。
  • 对每次失败的部署进行无指责的事后分析,并更新管道以防止再次发生。
  • 每周检查管道持续时间。如果任何阶段花费的时间超过必要,在向管道添加更多功能之前投资于缓存、并行性或阶段重新设计。

管道本身应该经受与任何其他产品相同的改进周期。安排定期的管道评审,让团队检查管道持续时间、失败率和摩擦点。将慢速阶段视为与应用程序 bug 同等优先级的 bug。当开发者说管道太慢时,相信他们——管道存在是为了服务开发者,而不是相反。

设计良好的 CI/CD 管道是不可见的。提交流经它,开发者不需要思考。部署每天发生多次而没有事故。回滚在需要时快速且平静。管道淡入开发工作的背景中,因为它可靠且可预测地工作。这种不可见性是良好设计的标志——不是因为管道简单,而是因为复杂性被管理得很好,以至于开发者不需要去思考它。

构建那个管道需要精心的设计、迭代和投入。但回报——更快的反馈、更安全的部署、更高的开发者信心——随着每次提交而复利。