持续集成与持续部署

代码推送到 main,自动测试、构建、上线

🎯 学习目标

学完本章,你将能够:

  • 理解 CI/CD 的核心概念和完整工作流程
  • 配置 GitHub Actions 工作流实现自动化测试和构建
  • 使用 Matrix 构建和缓存优化 CI 性能
  • 实现自动部署和预览部署
  • 配置分支保护、环境保护和 Secrets 管理

你有没有想过

为什么大公司的代码从不出现"忘记跑测试就上线"的事故?为什么他们的部署只需要点一个按钮?

因为他们有 CI/CD。每次代码推送,机器自动执行:检查代码 → 跑测试 → 构建 → 部署。

这个比喻只是为了帮你建立直观感受:CI/CD 像一条自动质检流水线,代码像产品一样经过每道工序。不合格的产品(代码)会被自动拦截,不会到达客户手中。

CI/CD 流程

从 git push 到自动部署的完整链路

图:CI/CD 完整流程——从 git push 到自动部署

核心概念

GitHub Actions 的六个关键词

概念说明类比
Workflow自动化流程定义一条流水线
Event触发条件启动按钮
Job一组步骤一个工位
Step单个操作工位上的动作
Action可复用操作单元可复用的工具
Runner执行任务的虚拟机执行环境

Part 1: 基础 CI

配置一个完整的持续集成流水线。

CI 工作流配置

推代码时自动跑 Lint + 测试 + 构建

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

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: npx eslint src/ --max-warnings=0
      - run: npx tsc --noEmit

  test:
    runs-on: ubuntu-latest
    needs: lint
    services:
      postgres:
        image: postgres:16-alpine
        env: { POSTGRES_DB: test_db, POSTGRES_USER: test_user, POSTGRES_PASSWORD: test_password }
        ports: ['5432:5432']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npx prisma migrate deploy
      - run: npx vitest run --coverage

触发事件

什么时候跑流水线?你来定

事件触发条件示例
push代码推送到分支branches: [main]
pull_request创建/更新 PRtypes: [opened]
release发布 Releasetypes: [published]
schedule定时执行cron: '0 2 * * *'
workflow_dispatch手动触发UI 点击运行

使用 npm ci 而不是 npm install。它严格按照 lock 文件安装,速度更快,结果更可预测。

缓存依赖

让 CI 从 3 分钟缩短到 30 秒

每次 CI 运行默认在全新的虚拟机里执行,不缓存任何文件。依赖安装(npm ci)通常占 CI 总时间的 60% 以上。actions/cache 可以把 node_modules 保存到 GitHub 的存储服务,下次直接复用。

actions/setup-node 内置的 cache: 'npm' 就是对 actions/cache 的一行封装。原理完全相同:根据 lock 文件的 hash 生成缓存 key,如果 key 命中则跳过安装。

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-

path 是要缓存的目录,key 是缓存的唯一标识,restore-keys 是回退匹配(前缀匹配)。hashFiles 基于文件内容生成 hash——lock 文件变了,缓存就失效。

缓存效果对比

一次配置,每次受益

场景无缓存有缓存节省
首次 push(冷启动)~90s~90s---
第二次 push(lock 文件未变)~90s~15s83%
node 小版本升级~90s~20s78%
新增一个依赖~90s~85s---

最佳实践:永远缓存 lock 文件对应的依赖。不要缓存 node_modules 本身(体积大、跨平台不稳定),而是缓存 npm 的下载缓存目录 ~/.npm。

# 完整示例:带缓存的 setup-node
- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'npm'    # 等价于上面的 actions/cache 配置
注意:不要用 node_modules 作为 cache path。不同操作系统的编译产物不同,且目录体积可能超过 GitHub 的 10GB 缓存限制。始终缓存 npm/yarn 的下载缓存目录。

分支保护与环境保护

给 main 分支加一把锁

CI 跑完了,但谁能直接把代码推到 main?如果有人直接 git push origin main 跳过了 PR 审查,CI 就没有意义了。分支保护规则强制所有代码必须经过 PR + CI 才能合并。

规则作用配置位置
Require a pull request禁止直接 push,必须走 PRBranches → Protection Rules
Require status checksCI 必须通过才能合并Branch protection → Status checks
Require approvals至少 N 人批准Branch protection → Review
Environments部署到 production 需要审批Environments → Protection rules

配置保护规则

Settings → Branches → Add rule

  1. 进入仓库 Settings → Branches
  2. 点击 Add branch protection rule
  3. Branch name pattern 填 main
  4. 勾选 Require a pull request before merging
  5. 勾选 Require status checks to pass,搜索并添加 CI job 名称(如 lint、test)
  6. 点击 Save changes
# 此时 GitHub 会显示:
# "✓ lint — Job succeeded"
# "✓ test — Job succeeded"
# 只有这两个检查都通过,PR 的 Merge 按钮才会变绿

Environments 审批:在 Settings → Environments → production 中添加 Required reviewers,部署到生产环境前必须有人手动批准。这样即便 CI 通过,部署也不会自动发生——除非有人点击 Approve。

Matrix 构建

一次配置,多版本测试

你的代码需要在 Node 18、20、22 上都能跑。手动写三个 job 太蠢——用 strategy.matrix 自动生成多个组合。

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        node-version: [18, 20, 22]
        os: [ubuntu-latest, windows-latest]
        exclude:
          - os: windows-latest
            node-version: 18
    steps:
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npx vitest run

上面的配置会生成 3 x 2 = 6 个并行任务(减去 exclude 的 1 个 = 5 个)。fail-fast: false 确保一个失败不影响其他任务继续运行。

注意:Matrix 任务数量 = 各维度长度的乘积。4 个 Node 版本 x 3 个 OS = 12 个任务。GitHub Free 计划每月只有 2000 分钟额度,注意控制矩阵大小。

环境变量与上下文

GitHub Actions 的内置变量

GitHub Actions 提供了丰富的上下文变量,可以在 workflow 中直接引用,无需手动配置。

变量说明示例值
${{ github.event_name }}触发事件类型push
${{ github.ref_name }}分支或 tag 名main
${{ github.sha }}完整的 commit SHAabc123def456...
github.actor触发者用户名xiangzz
github.repository仓库名user/repo
github.run_number当前运行编号42
# 实用技巧:只在 main 分支推送时部署
deploy:
  needs: test
  if: github.ref_name == 'main'
  runs-on: ubuntu-latest
  steps:
    - run: echo "Deploying commit ${{ github.sha }}"

# 自定义环境变量
env:
  NODE_ENV: production
  API_URL: https://api.example.com

env 可以定义在 workflow、job、step 三个级别。step 级别的变量会覆盖 job 级别,job 级别会覆盖 workflow 级别。

Secrets 最佳实践

安全地管理敏感信息

数据库密码、API 密钥、SSH 私钥——这些都不能写在代码里。GitHub Secrets 提供了加密存储和安全的引用方式。

层级作用域设置位置
Repository当前仓库Settings → Secrets → Actions
Organization组织内所有仓库Organization Settings → Secrets
Environment特定环境Environment → Secrets
# 引用 Secret(Secret 值会自动在日志中脱敏)
steps:
  - name: Deploy
    env:
      DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
      API_KEY: ${{ secrets.API_KEY }}
    run: |
      echo "Connecting to database..."
      psql -h localhost -U admin -p $DB_PASSWORD
安全警告:Secret 会自动在日志中脱敏(显示为 ***),但不要 echo $SECRET。虽然 GitHub 会脱敏,但你自己的脚本可能会意外泄露。永远通过 env 传递 Secret,不要直接嵌入命令字符串。

可复用 Workflow

避免重复配置

多个项目需要相同的 CI 配置?用 workflow_call 创建可复用的 workflow,像函数一样调用。

# .github/workflows/reusable-test.yml
name: Reusable Test
on:
  workflow_call:
    inputs:
      node-version:
        required: true
        type: string
    secrets:
      api-key:
        required: true

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/setup-node@v4
        with: { node-version: '${{ inputs.node-version }}' }
      - run: npm ci
      - run: API_KEY=${{ secrets.api-key }} npx vitest run
# 在另一个 workflow 中调用
jobs:
  call-test:
    uses: ./.github/workflows/reusable-test.yml
    with:
      node-version: '20'
    secrets:
      api-key: ${{ secrets.MY_API_KEY }}

可复用 workflow 类似于编程中的"函数"——定义一次,多处调用。当需要更新 CI 逻辑时,只改一处即可。还支持从其他仓库调用:uses: org/repo/.github/workflows/test.yml@main。

手动触发 Workflow

workflow_dispatch——按需运行

有时候你想手动触发一个 workflow(比如手动部署某个 tag,或者清理缓存)。workflow_dispatch 让你可以在 GitHub UI 上点击按钮运行 workflow。

on:
  workflow_dispatch:
    inputs:
      environment:
        description: '部署环境'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production
      tag:
        description: '镜像标签 (留空使用 latest)'
        required: false
        type: string

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: |
          echo "Deploying to ${{ github.event.inputs.environment }}"
          echo "Tag: ${{ github.event.inputs.tag }}"

配置后,在 GitHub 的 Actions 页面会出现一个 "Run workflow" 按钮。点击后可以选择输入参数。这对于生产环境的手动部署非常有用。

Part 2: 自动部署

测试通过后,自动构建镜像并部署到服务器。

Part 3: 部署策略

不止是自动部署——还要会回滚、会预览、会安全上线。

预览部署

每个 PR 自动获得独立 URL

每次提交 PR,CI 系统自动为这个分支构建一个临时环境,生成一个独立 URL(如 https://pr-42.example.app)。产品经理、设计师可以直接打开链接查看效果,无需拉代码到本地运行。

平台原生支持?URL 格式
Vercel是project-abc123-pr42.vercel.app
Railway是pr-42.up.railway.app
Netlify是pr-42--project.netlify.app
自托管 Docker需手动实现Nginx 动态路由
# 自托管方案:为每个 PR 启动独立容器
# .github/workflows/preview.yml
on:
  pull_request:
    types: [opened, synchronize]

jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build & Deploy Preview
        run: |
          docker build -t preview-pr${{ github.event.number }} .
          docker stop preview-pr${{ github.event.number }} 2>/dev/null || true
          docker run -d --name preview-pr${{ github.event.number }} \
            -p 8000:3000 preview-pr${{ github.event.number }}

自托管方案的核心思路:用 Nginx 做反向代理,根据子域名路由到不同端口的容器。pr-42.example.app 映射到 localhost:8042。PR 合并后自动清理容器。

回滚策略

出事了怎么办——三种经典方案

部署失败不可避免,关键是多快能恢复。不同的回滚策略在速度、资源成本、风险之间取舍。

策略原理回滚速度资源开销
蓝绿部署两套环境并行,切换流量最快(秒级)2x
滚动更新逐个替换实例中(分钟级)1x + 少量
金丝雀发布先让 5% 流量走新版本快(分钟级)1x + 少量
# Docker 回滚:切回上一个镜像标签
# 查看历史镜像标签
$ docker images myapp
REPOSITORY   TAG        IMAGE ID       CREATED
myapp        v1.2.3     abc123         2 hours ago
myapp        v1.2.2     def456         1 day ago

# 回滚到 v1.2.2
$ docker stop myapp-latest
$ docker run -d --name myapp-latest \
    -p 3000:3000 myapp:v1.2.2

# 或者用 docker-compose
$ docker compose down
$ IMAGE_TAG=v1.2.2 docker compose up -d
关键原则:永远给每个构建打版本号(Git commit SHA 或 semver),不要用 latest 标签部署生产。latest 是不可变的——你永远不知道它指向哪里。用 myapp:abc1234 才能精确回滚。

部署通知

让团队知道部署结果

部署成功或失败,团队应该第一时间知道。GitHub Actions 可以发送 Slack、Discord、邮件通知。

- name: Notify Slack on failure
  if: failure()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {{
        "text": "Deploy FAILED for ${{ github.ref_name }}",
        "blocks": [{{
          "type": "section",
          "text": {{
            "type": "mrkdwn",
            "text": "Commit ${{ github.sha }} failed to deploy. Check the Actions log."
          }}
        }}]
      }}
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

- name: Notify Slack on success
  if: success()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {{
        "text": "Deployed ${{ github.ref_name }} (${{ github.sha }})"
      }}
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

最佳实践:只在失败时通知(成功时保持安静)。频繁的成功通知会让人忽略真正重要的失败告警。使用 if: failure() 条件控制通知时机。

健康检查

部署后自动验证服务状态

部署完成不等于服务正常。健康检查在部署后自动请求服务的 /health 端点,确认服务真正启动成功。

- name: Health check
  run: |
    for i in $(seq 1 10); do
      if curl -sf ${{ secrets.SERVER_URL }}/health; then
        echo "Health check passed"
        exit 0
      fi
      echo "Attempt $i failed, retrying..."
      sleep 5
    done
    echo "Health check failed after 10 attempts"
    exit 1

- name: Rollback on failure
  if: failure()
  run: |
    echo "Rolling back to previous version..."
    docker compose down
    docker compose up -d

上面的脚本会尝试 10 次,每次间隔 5 秒。如果全部失败,则触发回滚。

你的应用应该暴露一个 /health 端点,返回 200 状态码表示健康。可以检查数据库连接、Redis 连接等关键依赖。

完整 CD 工作流

从 push 到部署的完整流水线

把前面学的所有概念组合起来,就是一个生产级的 CD 工作流。

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '${{ matrix.node-version }}', cache: 'npm' }
      - run: npm ci
      - run: npx vitest run

  deploy:
    needs: test
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Build & Push Docker image
        run: |
          docker build -t myapp:${{ github.sha }} .
          docker push myapp:${{ github.sha }}
      - name: Deploy to server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            docker pull myapp:${{ github.sha }}
            docker stop myapp || true
            docker run -d --name myapp -p 3000:3000 myapp:${{ github.sha }}
      - name: Health check
        run: |
          sleep 10
          curl -sf ${{ secrets.SERVER_URL }}/health || exit 1

多环境部署

staging → production 流水线

生产环境通常有多个部署阶段:开发环境 → 预发布 → 生产。每个环境独立配置,逐步验证。

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - run: |
          echo "Deploying to staging..."
          # 部署到 staging 服务器

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - run: |
          echo "Deploying to production..."
          # 部署到生产服务器

只有 staging 部署成功后,才会触发 production 部署。production 环境还需要人工审批。

环境隔离的关键:每个环境使用独立的数据库、独立的配置。staging 环境的代码和生产环境完全一致,只是数据不同。

排错:CI 性能优化

让 CI 跑得更快

CI 慢是开发者最大的痛点之一。以下是常见的优化手段:

问题解决方案预期效果
依赖安装慢使用 cache: 'npm'-75s
测试串行拆分为并行 job-50% 总时间
镜像构建慢使用 docker/build-push-action 的 cache-60s
全量测试只跑变更相关的测试(paths-filter)-30% 时间
# 只在前端代码变更时跑前端测试
frontend-test:
  needs: changes
  if: ${{ needs.changes.outputs.frontend == 'true' }}
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - run: npm ci
    - run: npx vitest run --project=frontend

# 检测哪些目录有变更
changes:
  runs-on: ubuntu-latest
  outputs:
    frontend: ${{ steps.filter.outputs.frontend }}
  steps:
    - uses: dorny/paths-filter@v3
      id: filter
      with:
        filters: |
          frontend:
            - 'src/frontend/**'
          backend:
            - 'src/backend/**'

排错:CI 超时与失败

常见问题的诊断方法

症状诊断解决方案
Job 卡住不动是否有 pause 或等待输入?添加 timeout-minutes: 10
偶发性失败网络问题或竞态条件?添加重试逻辑或 continue-on-error
Secret 为 emptySecret 名称拼写错误?区分大小写,检查拼写
Permission deniedToken 权限不足?检查 permissions 配置
# 使用 act 在本地调试 GitHub Actions
$ npm install -g act
$ act push                    # 模拟 push 事件
$ act pull_request           # 模拟 PR 事件
$ act -j test               # 只运行 test job
$ act --secret-file .env    # 使用本地 secrets

act 工具用 Docker 在本地运行 GitHub Actions workflow,可以快速调试而不需要反复 push。

部署到 VPS

通过 SSH + Docker 自动部署

# .github/workflows/deploy-vps.yml
name: Deploy to VPS
on:
  push:
    branches: [main]

jobs:
  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: npx vitest run

  deploy:
    runs-on: ubuntu-latest
    needs: test
    environment: production
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /opt/myapp
            git pull origin main
            docker compose up -d --build
            docker compose exec -T app npx prisma migrate deploy

Docker 镜像发布

构建并推送到 GitHub Container Registry

# .github/workflows/docker-publish.yml
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions: { 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 }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

自动 Release

打 Tag 自动生成发布说明

# .github/workflows/release.yml
name: Create Release
on:
  push:
    tags: ['v*']

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - name: Generate changelog
        run: |
          PREV_TAG=$(git describe --tags --abbrev=0 HEAD^)
          git log --pretty=format:"- %s (%h)" $PREV_TAG..HEAD > changelog.txt
      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        env: { GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" }
        with:
          body_path: changelog.txt
          generate_release_notes: true

排错:CI 失败

症状:PR 提交后 CI 一直红灯,但本地测试全部通过。
原因:CI 环境和本地不同——Node 版本不一致、数据库没启动、环境变量缺失。
修复:检查 actions/setup-node 的版本号;确认 services 中的数据库健康检查配置;检查 env 环境变量。
# 本地复现 CI 环境
$ act pull_request    # 用 act 工具在本地跑 GitHub Actions

最佳实践

让 CI/CD 更快、更安全

实践说明收益
缓存依赖cache: 'npm'加速 CI
最小权限只授予必要的 permissions安全
并行任务lint 和 test 并行执行减少总时间
环境保护GitHub Environments 配审批防误部署
Secrets 管理敏感信息存 GitHub Secrets安全
分支保护PR 必须通过 CI 才能合并最后防线

试一试

练习一

任务:为你的项目创建 .github/workflows/ci.yml,在 push 到 main 时自动运行 ESLint 和测试。

参考答案

name: CI
on:
  push:
    branches: [main]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npx eslint src/
      - run: npx vitest run

练习二

任务:添加 npm audit --audit-level=high 到 CI 流水线,实现自动安全审计。

参考答案

  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm audit --audit-level=high

练习三

任务:修改本章的 ci.yml,添加基于 package-lock.json hash 的缓存步骤,让第二次 CI 运行跳过依赖下载。

参考答案

# 在 steps 中添加 cache 步骤(位于 setup-node 之前)
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Cache npm dependencies
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-20-npm-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-20-npm-
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npx vitest run

练习四

任务:为你的仓库配置分支保护规则,要求 PR 必须通过 CI 的 lint 和 test 检查才能合并到 main。

配置步骤

  1. 进入 GitHub 仓库 → Settings → Branches
  2. 点击 Add branch protection rule,pattern 填 main
  3. 勾选 Require a pull request before merging
  4. 勾选 Require status checks to pass before merging
  5. 搜索 lint 和 test,选中作为 required checks
  6. 点击 Create 保存
# 验证:直接 push 到 main 会被拒绝
$ git push origin main
remote: error: GH006: Protected branch update failed for refs/heads/main.
remote: error: 2 of 2 required status checks have not succeeded.

📝 要点总结

本章核心知识点

CI 工作流

push 触发 → 安装依赖 → 运行测试 → 构建产物,自动化质量门禁

缓存优化

缓存 node_modules 和构建产物,CI 时间从分钟级降到秒级

自动部署

测试通过后自动部署到预览/生产环境,减少人为失误

安全实践

Secrets 管理敏感信息,环境保护规则控制部署权限

下一章

CI/CD 让部署自动化。下一章我们学习 部署与运维——从 PaaS 到传统 VPS,从 PM2 到 Nginx。

1 / X