CI 环境下的构建优化

CI 环境下的构建优化

写在前面

前端构建系列的最后一篇。前面所有的优化都在本地,但生产环境的瓶颈在 CI——pull request 每次跑构建测试,慢一次就拖慢整个团队的迭代速度。

本篇会讲清楚 CI 构建的关键优化:缓存策略、Docker 镜像、远程缓存、CI runner 调优。实战把公司的 CI 构建从 15 分钟压到 4 分钟。

一、CI 构建为什么慢

1.1 慢在哪里

CI 一次完整构建的耗时分布:

Git Checkout           5s
依赖安装(npm/pnpm)     90s
Lint                   30s
Type Check             60s
Unit Test              180s
Build                  120s
E2E Test               240s
Deploy (staging)       60s
─────────────────────
Total                  785s ≈ 13min

13 分钟每次 PR——这个数字对团队效率影响巨大。

1.2 优化方向

优化点 节省时间
依赖缓存 90s → 5s
并行任务 总时间 / 2
远程缓存(构建产物) 120s → 5s
增量 lint / type check 30s → 5s
智能测试(只跑受影响的) 180s → 30s

理论上可以从 13 分钟压到 4 分钟。

二、依赖缓存

2.1 GitHub Actions 缓存

# .github/workflows/ci.yml
- name: Setup Node
  uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'pnpm'  # 自动缓存 pnpm store

- name: Install
  run: pnpm install --frozen-lockfile

pnpm/yarn/npm 都支持自动缓存,节省 80s+。

2.2 更激进的缓存:Turbo + 远程缓存

- name: Setup
  uses: actions/setup-node@v4
  with:
    node-version: 20

- name: Install Turbo
  run: npm install -g turbo

- name: Build
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: my-team
  run: turbo run build

第一次跑全量构建,后续所有 PR 都享受远程缓存。

2.3 self-hosted runner

公司自建 runner:

# .github/workflows/ci.yml
jobs:
  build:
    runs-on: [self-hosted, linux, large]  # 标签匹配自建 runner

优势:

节省时间:依赖安装 90s → 5s。

三、Docker 缓存

3.1 镜像分层缓存

CI 经常需要在 Docker 里构建:

FROM node:20-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --no-audit --no-fund

COPY . .
RUN npm run build

3.2 GitHub Actions Docker 缓存

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

- name: Cache Docker layers
  uses: actions/cache@v3
  with:
    path: /tmp/.buildx-cache
    key: ${{ runner.os }}-buildx-${{ github.sha }}
    restore-keys: |
      ${{ runner.os }}-buildx-      

- 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

3.3 Registry 缓存(推荐)

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    cache-from: type=registry,ref=ghcr.io/myorg/myapp:buildcache
    cache-to: type=registry,ref=ghcr.io/myorg/myapp:buildcache,mode=max

缓存层推到 Registry,跨 CI job 共享。

四、并行执行

4.1 GitHub Actions 并行 job

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm run lint

  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm run type-check

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm run test

  build:
    runs-on: ubuntu-latest
    needs: [lint, typecheck, test]  # 等前面都过
    steps:
      - uses: actions/checkout@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm run build

lint + typecheck + test 并行,build 等前面——总时间 = max + build。

4.2 Matrix 矩阵

test:
  runs-on: ubuntu-latest
  strategy:
    matrix:
      node-version: [18, 20]
      package: [web-app, admin-app]
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
    - run: pnpm install --frozen-lockfile
    - run: pnpm --filter ${{ matrix.package }} run test

4 个 job 并行(2 个 Node 版本 × 2 个包)。

五、智能测试(只跑受影响的)

5.1 Turborepo affected

- name: Test (affected only)
  run: |
    turbo run test --filter=...[origin/main]    

只跑受当前 PR 影响的包和它们的依赖。

5.2 Nx affected

- name: Test (affected only)
  run: npx nx affected --target=test --base=origin/main

更智能——Nx 会分析代码改动,只跑真正受影响的包。

5.3 改动 vs 依赖

# 改了 packages/ui-library,所有依赖它的应用都要测
git diff --name-only origin/main | grep "packages/ui-library"

简单的脚本也能做到。

六、E2E 测试优化

6.1 E2E 是 CI 最大瓶颈

E2E 测试需要启动完整环境(数据库、API、浏览器),每次 PR 都跑全套 240 秒太慢。

6.2 分层跑 E2E

时机 跑哪些
每次 PR 关键路径(登录、下单)
合并到 main 全量 E2E
部署前 全量 E2E
# .github/workflows/ci.yml
e2e-smoke:
  if: github.event_name == 'pull_request'
  steps:
    - run: pnpm exec playwright test --grep "@smoke"

e2e-full:
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  steps:
    - run: pnpm exec playwright test

6.3 Playwright 分片

e2e-full:
  strategy:
    matrix:
      shard: [1, 2, 3, 4]
  steps:
    - run: pnpm exec playwright test --shard=${{ matrix.shard }}/4

4 个分片并行跑,总时间 / 4。

6.4 复用浏览器

// playwright.config.ts
export default {
    workers: process.env.CI ? 4 : undefined,
    reuseExistingServer: true,
};

七、实战:CI 从 15 分钟压到 4 分钟

7.1 优化前

name: CI
on: pull_request

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm install
      - run: npm run lint
      - run: npm run type-check
      - run: npm test
      - run: npm run build

总耗时:15 分钟。

7.2 第一步:加缓存

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

- uses: actions/cache@v3
  with:
    path: |
      node_modules
      .next/cache
      .turbo      
    key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

总耗时:10 分钟(省 5 分钟)。

7.3 第二步:并行 job

jobs:
  lint:
    runs-on: ubuntu-latest
    steps: [...]

  typecheck:
    runs-on: ubuntu-latest
    steps: [...]

  test:
    runs-on: ubuntu-latest
    steps: [...]

  build:
    needs: [lint, typecheck, test]
    runs-on: ubuntu-latest
    steps: [...]

总耗时:7 分钟(lint/typecheck/test 并行)。

7.4 第三步:Turbo 远程缓存

- run: npx turbo run build
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}

总耗时:5 分钟(build 命中远程缓存)。

7.5 第四步:E2E 拆分层

e2e-smoke:
  if: github.event_name == 'pull_request'
  steps: [...]

e2e-full:
  if: github.event_name == 'push'
  steps: [...]

PR 只跑 smoke 测试(2 分钟),main 跑全量(10 分钟)。

PR 总耗时:4 分钟。

7.6 第五步:自建 runner

公司自建 runner:

总耗时:2.5 分钟(4 个 job 真正并行)。

八、不同 CI 平台的取舍

8.1 GitHub Actions

优势:

劣势:

8.2 GitLab CI

优势:

劣势:

8.3 Jenkins

优势:

劣势:

8.4 选型

个人/小团队   → GitHub Actions
中大企业      → GitLab CI / Jenkins
特殊需求      → 自建(CircleCI, Buildkite)

九、CI 缓存的几个坑

9.1 缓存 key 必须精确

# NG - 缓存太宽
key: ${{ runner.os }}-cache

# OK - 精确到 lock 文件
key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

缓存太宽会拿到过期依赖,出问题难查。

9.2 缓存不能跨架构

# NG - linux-amd64 缓存用在 macOS runner
runs-on: macos-latest
key: ${{ runner.os }}-${{ hashFiles(...) }}  # macOS

要在 key 里加 arch:

key: ${{ runner.os }}-${{ runner.arch }}-${{ hashFiles(...) }}

9.3 自建 runner 的缓存清理

self-hosted runner 的缓存会一直累积。需要定期清理:

# 清理 GitHub Actions runner 缓存
sudo rm -rf /home/runner/_work/_temp/*
sudo rm -rf /home/runner/_work/_tool/*

十、监控 CI

10.1 关键指标

10.2 Datadog / Grafana 集成

- name: Report CI metrics
  if: always()
  run: |
    curl -X POST https://api.datadoghq.com/api/v1/series \
      -H "DD-API-KEY: ${{ secrets.DD_API_KEY }}" \
      -d '{
        "series": [{
          "metric": "ci.build.duration",
          "points": [[$(date +%s), ${{ env.BUILD_DURATION }}]],
          "tags": ["repo:${{ github.repository }}"]
        }]
      }'    

十一、本篇小结

优化点 节省时间
依赖缓存 90s → 5s
远程构建缓存 120s → 5s
并行 job 总时间 / 2~4
Affected 测试 测试时间 / 3
E2E 分层 PR 时间 / 5
决策 推荐
GitHub 平台 GitHub Actions
自定义硬件 Self-hosted runner
Monorepo 远程缓存 Turborepo + Vercel
E2E 框架 Playwright(分片)

到这里前端构建系列 9 篇全部完成。

系列回顾

  1. ✅ 构建工具三十年演进史
  2. ✅ webpack 的辉煌与瓶颈
  3. ✅ esbuild 原理与速度革命
  4. ✅ Vite 双引擎与 ESM 魔法
  5. ✅ Rspack / Rsbuild Rust 系的崛起
  6. ✅ 从 webpack 迁移到 Rspack 实战
  7. ✅ Turbopack / Bun / Deno 等新势力
  8. ✅ Monorepo 构建工具链
  9. ✅ CI 环境下的构建优化

下一个系列是 TypeScript 进阶——从类型基础到类型体操。

参考资料