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
优势:
- 缓存持久化(本地磁盘)
- 预装依赖(node / pnpm / docker)
- 自定义硬件(更大内存,更快 CPU)
节省时间:依赖安装 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:
- 32 核 CPU
- 64GB 内存
- NVMe SSD
- 预装所有工具
总耗时:2.5 分钟(4 个 job 真正并行)。
八、不同 CI 平台的取舍
8.1 GitHub Actions
优势:
- 与 GitHub 深度集成
- 远程缓存方便(Turbo + Vercel)
- 巨大的 actions 生态
劣势:
- 公开仓库免费,私有仓库按分钟计费
- runner 性能有限(2 核 7GB)
- self-hosted runner 配置复杂
8.2 GitLab CI
优势:
- 内置 container registry
- 完整 DevOps 平台
- 私有仓库免费
劣势:
- 性能优化需要调 runner
- 远程缓存需要自建
8.3 Jenkins
优势:
- 完全可控
- 自定义 runner
- 插件生态丰富
劣势:
- 运维成本高
- 需要自己实现缓存机制
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 关键指标
- PR 构建 P50/P95 时间
- 缓存命中率
- 失败率
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 篇全部完成。
系列回顾
- ✅ 构建工具三十年演进史
- ✅ webpack 的辉煌与瓶颈
- ✅ esbuild 原理与速度革命
- ✅ Vite 双引擎与 ESM 魔法
- ✅ Rspack / Rsbuild Rust 系的崛起
- ✅ 从 webpack 迁移到 Rspack 实战
- ✅ Turbopack / Bun / Deno 等新势力
- ✅ Monorepo 构建工具链
- ✅ CI 环境下的构建优化
下一个系列是 TypeScript 进阶——从类型基础到类型体操。