CICD 与部署
CI/CD 的本质是「将人工操作转化为自动化流水线」。从代码提交到生产发布,每步都涉及构建系统、测试、安全扫描和部署策略的精密配合。
1. CI/CD 流程
1.1 标准流水线
代码提交
↓
Lint(ESLint + Prettier + commitlint)
↓
Test(单元测试 + 集成测试 + E2E 测试)
↓
Build(构建 + 打包 + 资源优化)
↓
Deploy(发布到目标环境)
↓
Monitor(错误监控 + 性能监控 + 用户行为)
1.2 GitHub Actions
核心概念
name: CI/CD Pipeline
on:
push:
branches: [main, develop] # 触发分支
paths-ignore: ['docs/**'] # 排除文档变更
pull_request:
branches: [main]
workflow_dispatch: # 手动触发
env:
NODE_VERSION: '20'
REGISTRY: ghcr.io
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '${{ env.NODE_VERSION }}' }
- run: npm ci
- run: npm run lint
test:
needs: lint # 依赖 lint job
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22] # 多版本矩阵测试
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '${{ matrix.node-version }}' }
- run: npm ci
- run: npm test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results-${{ matrix.node-version }}
path: test-results/
deploy:
needs: [lint, test] # 等待 Lint + Test 通过
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- run: |
echo "${{ secrets.DEPLOY_KEY }}" > deploy_key
chmod 600 deploy_key
rsync -e "ssh -i deploy_key" -avz dist/ user@server:/var/www/
缓存策略
- name: Cache node_modules
uses: actions/cache@v4
with:
path: |
node_modules
~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
Matrix 矩阵策略
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20]
include: # 额外组合
- os: ubuntu-latest
node: 22
exclude: # 排除特定组合
- os: macos-latest
node: 18
1.3 pre-commit hooks(husky + lint-staged)
# 安装
npx husky init
npm install --save-dev lint-staged
// package.json
{
"lint-staged": {
"*.{js,ts,tsx,vue}": ["eslint --fix", "prettier --write"],
"*.{css,scss}": ["prettier --write"],
"*.md": ["prettier --write"]
}
}
# .husky/pre-commit
npx lint-staged
# .husky/commit-msg
npx --no -- commitlint --edit $1
工作流程:git commit → husky 触发 pre-commit hook → lint-staged 只对暂存区文件执行 lint/fix → 如果失败则阻止提交。
2. 代码检查
2.1 ESLint
Flat Config(ESLint 9+)
// eslint.config.js(Flat Config 格式,替代旧的 .eslintrc)
import js from '@eslint/js';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import vuePlugin from 'eslint-plugin-vue';
import globals from 'globals';
export default [
js.configs.recommended,
{
ignores: ['dist/**', 'node_modules/**', '*.min.js'],
},
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parser: tsParser,
parserOptions: { project: './tsconfig.json' },
globals: { ...globals.browser, ...globals.node },
},
plugins: { '@typescript-eslint': tsPlugin },
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
},
},
{
files: ['**/*.vue'],
plugins: { vue: vuePlugin },
rules: {
'vue/multi-word-component-names': 'error',
'vue/no-v-html': 'warn',
},
},
];
配置层级:
内联配置(/* eslint-disable */) → 文件级配置 → 项目级 eslint.config.js
优先级从高到低,上层覆盖下层
parser / plugin / extends 三者的关系:
| 概念 | 作用 | 示例 |
|---|---|---|
| parser | 解析源码为 AST(决定能理解什么语法) | @typescript-eslint/parser 解析 TS |
| plugin | 提供规则(rules)+ 处理器(processors) | eslint-plugin-vue 提供 vue 规则 |
| extends | 预设配置集(一组规则的集合) | js.configs.recommended |
2.2 Prettier
// .prettierrc
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always",
"endOfLine": "lf"
}
ESLint 与 Prettier 冲突处理
冲突根源:ESLint 既管代码质量(未使用变量)又管代码风格(缩进/引号),而 Prettier 管代码风格。两者在风格规则上重叠。
解决方案:
# ESLint 8 → 使用 eslint-config-prettier 关闭 ESLint 中与 Prettier 冲突的规则
npm install --save-dev eslint-config-prettier
// eslint.config.js(Flat Config)
import prettierConfig from 'eslint-config-prettier';
export default [
// ... 其他配置
prettierConfig, // 必须放在最后,覆盖所有风格规则
];
分工:
- ESLint 负责:未使用变量、空值检查、类型推断错误、代码坏味道
- Prettier 负责:缩进、分号、引号、换行、空行
2.3 commitlint
npm install --save-dev @commitlint/cli @commitlint/config-conventional
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always', [
'feat', 'fix', 'docs', 'style', 'refactor',
'perf', 'test', 'build', 'ci', 'chore', 'revert',
]],
'scope-case': [2, 'always', 'lower-case'],
'subject-case': [0], // subject 不强制大小写
},
};
Conventional Commits 格式:
<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>
feat(auth): add OAuth2 login
fix(payment): resolve decimal precision issue
docs(api): update API reference
refactor(utils): extract date formatting helpers
2.4 分支命名规范
main ── 生产分支(只合入,不直接开发)
develop ── 开发主分支
feature/* ── 功能分支: feature/user-profile
fix/* ── 修复分支: fix/login-error
hotfix/* ── 紧急修复: hotfix/security-vuln
release/* ── 发布分支: release/v2.1.0
3. 部署策略
3.1 蓝绿部署(Blue/Green)
┌────────────┐
│ Load │
│ Balancer │
└────┬──┬────┘
│ │
┌──────▼ ▼──────┐
│ Blue (v1.0) │ ← 当前接收全部流量
│ Green (v2.0) │ ← 新版本部署完毕但无流量
└────────────────┘
切换流程:
1. 部署 v2.0 到 Green 集群
2. 对 Green 进行冒烟测试
3. 切换 LB 指向 Green(全量切换)
4. Blue 保留为回滚环境
优点:
- 切换瞬时完成,无停机
- 回滚极快(LB 切回 Blue 即可)
- 测试环境与生产环境完全一致
缺点:
- 需要双倍资源(两台完全相同的生产集群)
- 数据库兼容性问题(Schema 变更需向前兼容)
3.2 滚动发布(Rolling Update)
更新过程(每次替换一个实例):
┌─────────────────────────────┐
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ ← 全部 v1
│ │v1 │ │v1 │ │v1 │ │v1 │ │
│ └───┘ └───┘ └───┘ └───┘ │
├─────────────────────────────┤
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ ← 逐步替换
│ │v2 │ │v1 │ │v1 │ │v1 │ │
│ └───┘ └───┘ └───┘ └───┘ │
├─────────────────────────────┤
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ ← 全部 v2
│ │v2 │ │v2 │ │v2 │ │v2 │ │
│ └───┘ └───┘ └───┘ └───┘ │
└─────────────────────────────┘
Kubernetes 滚动更新:
apiVersion: apps/v1
kind: Deployment
spec:
replicas: 4
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # 最多超过期望副本数 1 个
maxUnavailable: 0 # 更新期间不允许不可用
template:
spec:
containers:
- image: myapp:2.0
优缺点:
- 资源消耗低(不需要双倍集群)
- 更新过程慢(逐个替换)
- 回滚比蓝绿慢(需要逐个恢复)
- 更新期间新旧版本共存,需处理兼容性
3.3 金丝雀发布(Canary Release)
LB
├── 95% 流量 → v1(稳定版)
└── 5% 流量 → v2(金丝雀)
观察指标:
- 错误率 < 基线 + 1% → 逐步加大金丝雀比例
- 错误率 > 阈值 → 立即切回全部 v1
- P99 延迟无显著上升 → 继续
渐进过程:
5% → 20% → 50% → 100%
Flagger 自动化金丝雀:
apiVersion: flagger.app/v1beta1
kind: Canary
metadata:
name: myapp
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: myapp
service:
port: 80
analysis:
interval: 1m
threshold: 5 # 最大失败指标次数
maxWeight: 50 # 金丝雀最大流量比例
stepWeight: 10 # 每次增加的流量比例
metrics:
- name: error-rate
thresholdRange:
min: 0
max: 1 # 错误率 < 1%
- name: request-duration
thresholdRange:
max: 500 # P99 < 500ms
适用场景:需要灰度验证新版本稳定性的高风险发布。
3.4 特性开关(Feature Flag)
// LaunchDarkly / Unleash / 自研
const featureFlags = {
newCheckoutFlow: true, // 全量开启
darkMode: false, // 全量关闭
premiumSearch: { // 按用户百分比开启
type: 'percentage',
value: 10,
},
};
// 代码中使用
if (featureFlags.newCheckoutFlow) {
renderNewCheckout();
} else {
renderLegacyCheckout();
}
特性开关 vs 分支开发:
| 维度 | 特性开关 | 分支开发 |
|---|---|---|
| 集成频率 | 每日多次合并到主分支 | 功能完成后合入 |
| 未完成代码 | 开关关闭即可,代码已合入 | 在特性分支上 |
| 测试覆盖 | 需要测试开关打开/关闭两种状态 | 只测功能本身 |
| 技术债务 | 开关积累需要清理 | 无额外债务 |
| 回滚影响 | 关闭开关即可 | 需要 revert commit |
最佳实践:高频率部署 + 短时间特性开关(大部分特性开关应在 2 周内移除)。
4. 静态资源部署
4.1 CDN 原理
┌─────────┐
用户 ──→ DNS 解析 ─→ 全局 LB ──→ 最近边缘节点
└─────────┘
↓
┌──────────────┐
│ 边缘节点(POP) │
│ ├── 磁盘缓存 │
│ ├── 内存缓存 │
│ └── 回源配置 │
└──────┬───────┘
│ 缓存未命中
▼
┌──────────────┐
│ 源站(OSS) │
└──────────────┘
推模式 vs 拉模式:
| 模式 | 操作 | 时机 | 场景 |
|---|---|---|---|
| 推(Push) | 主动将资源推送到 CDN | 构建后立即 | 高频访问资源,预热缓存 |
| 拉(Pull) | 用户请求触发回源拉取 | 首次请求时 | 低频资源,动态内容 |
预热:在发布前主动将新版本资源推送至所有 CDN 节点,避免发布后用户首次请求经历回源耗时。
刷新:清除 CDN 节点上的缓存,使下次请求回源获取新内容。用于紧急更新或配置变更。
4.2 Hash 文件命名(长缓存策略)
// Webpack 输出配置
output: {
filename: 'js/[name].[contenthash:8].js',
chunkFilename: 'js/[name].[contenthash:8].chunk.js',
},
dist/
├── js/
│ ├── main.a1b2c3d4.js ← 入口文件,内容 hash
│ ├── vendor.e5f6g7h8.js ← 第三方依赖,hash 稳定
│ └── admin.8765f4e3.chunk.js ← 懒加载 chunk
├── css/
│ └── main.1a2b3c4d.css
└── assets/
└── logo.f00f00f0.svg
缓存策略:
文件名带 Hash(永久缓存):
main.a1b2c3d4.js → Cache-Control: public, max-age=31536000, immutable
文件名变化时视为新资源,旧版本自动过期
HTML(不缓存):
index.html → Cache-Control: no-cache
保证用户始终获取最新的资源引用列表
vite 的 hash 配置:
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
entryFileNames: 'js/[name]-[hash].js',
chunkFileNames: 'js/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
},
},
},
};
4.3 publicPath
// CDN 部署时
output: {
publicPath: 'https://cdn.example.com/my-app/', // 所有资源加上 CDN 前缀
}
// 产物:
// <script src="https://cdn.example.com/my-app/js/main.a1b2c3d4.js">
// background: url(https://cdn.example.com/my-app/assets/logo.f00f00f0.svg)
多环境动态 publicPath:
// 运行时 publicPath(__webpack_public_path__)
if (window.__CDN_DOMAIN__) {
__webpack_public_path__ = `https://${window.__CDN_DOMAIN__}/`;
}
5. 前端监控
5.1 错误监控
Source Map 还原:
// 生产环境收集到的错误堆栈:
// at u (https://cdn.com/js/main.a1b2c3d4.js:1:23456)
//
// 使用 source map 还原为:
// at handleClick (src/pages/Checkout.tsx:42:10)
// at submitOrder (src/services/order.ts:15:6)
// Sentry 集成
import * as Sentry from '@sentry/react';
Sentry.init({
dsn: 'https://xxx@sentry.io/xxx',
environment: process.env.NODE_ENV,
release: `myapp@${APP_VERSION}`,
integrations: [new Sentry.BrowserTracing()],
tracesSampleRate: 0.1, // 性能监控采样率
replaysSessionSampleRate: 0.1, // 会话回放采样
replaysOnErrorSampleRate: 1.0, // 出错时 100% 录制
});
错误分类:
| 类型 | 捕获方式 | 示例 |
|---|---|---|
| JS 运行时错误 | window.onerror |
TypeError: Cannot read property of null |
| Promise 未处理 | unhandledrejection |
异步请求失败 |
| 资源加载失败 | error 事件 |
图片/脚本加载 404 |
| 前端框架错误 | 框架 API | React Error Boundary / Vue errorHandler |
// 全局错误监控(核心)
window.onerror = (message, source, lineno, colno, error) => {
reportError({
type: 'js_error',
message,
stack: error?.stack,
source,
lineno,
colno,
timestamp: Date.now(),
url: location.href,
userAgent: navigator.userAgent,
});
return true; // 阻止默认错误处理
};
window.addEventListener('unhandledrejection', (event) => {
reportError({
type: 'promise_rejection',
reason: event.reason?.stack || event.reason,
});
});
5.2 性能监控
核心 Web 指标(Core Web Vitals)
| 指标 | 全称 | 测量内容 | 好坏阈值 |
|---|---|---|---|
| LCP | Largest Contentful Paint | 最大内容元素绘制时间 | 好 < 2.5s / 差 > 4.0s |
| FID | First Input Delay | 首次输入延迟(用户交互到响应) | 好 < 100ms / 差 > 300ms |
| CLS | Cumulative Layout Shift | 累积布局偏移总分 | 好 < 0.1 / 差 > 0.25 |
| INP | Interaction to Next Paint | 交互到下一次绘制的延迟(FID 替代) | 好 < 200ms / 差 > 500ms |
其他关键指标:
| 指标 | 说明 |
|---|---|
| FCP | First Contentful Paint,首次内容绘制 < 1.8s |
| TTFB | Time to First Byte,首字节时间 < 800ms |
| TTI | Time to Interactive,可交互时间 < 3.8s |
| TBT | Total Blocking Time,总阻塞时间 < 200ms |
| FMP | First Meaningful Paint,首次有意义绘制(已弃用但仍参考) |
// Web Vitals 采集
import { onLCP, onFID, onCLS, onINP, onTTFB } from 'web-vitals';
function sendToAnalytics(metric) {
const body = {
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
};
navigator.sendBeacon('/analytics', JSON.stringify(body));
}
onLCP(sendToAnalytics);
onFID(sendToAnalytics);
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onTTFB(sendToAnalytics);
Performance API
// Navigation Timing
const [entry] = performance.getEntriesByType('navigation');
console.log({
dnsLookup: entry.domainLookupEnd - entry.domainLookupStart,
tcpConnect: entry.connectEnd - entry.connectStart,
tlsHandshake: entry.secureConnectionStart ? entry.connectEnd - entry.secureConnectionStart : 0,
ttfb: entry.responseStart - entry.requestStart,
domContentLoaded: entry.domContentLoadedEventEnd - entry.domContentLoadedEventStart,
loadTime: entry.loadEventEnd - entry.loadEventStart,
});
// Resource Timing
performance.getEntriesByType('resource').forEach(res => {
if (res.initiatorType === 'script') {
console.log(`Script ${res.name}: ${res.duration}ms`);
}
});
// Performance Observer(更现代的 API)
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'largest-contentful-paint') {
console.log('LCP:', entry.renderTime || entry.loadTime);
}
if (entry.entryType === 'first-input') {
console.log('FID:', entry.processingStart - entry.startTime);
}
if (entry.entryType === 'layout-shift') {
if (!entry.hadRecentInput) {
console.log('CLS:', entry.value);
}
}
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
observer.observe({ type: 'first-input', buffered: true });
observer.observe({ type: 'layout-shift', buffered: true });
5.3 用户行为
| 类型 | 采集方式 | 用途 |
|---|---|---|
| PV | 页面加载时上报 | 页面访问量 |
| UV | cookies/localStorage 用户标识去重 | 独立访客数 |
| 热力图 | 记录鼠标位置和点击坐标 | 页面点击分布 |
| 会话回放 | 录制 DOM 变化和用户操作序列 | 问题复现、用户体验分析 |
| 埋点 | 自定义事件打点(点击/曝光/停留) | 产品转化漏斗分析 |
// PV/UV 采集
class Analytics {
constructor() { this.userId = this.getUserId(); }
getUserId() {
let id = localStorage.getItem('_uid');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('_uid', id);
}
return id;
}
pageView() {
navigator.sendBeacon('/analytics/pv', JSON.stringify({
uid: this.userId,
url: location.href,
referrer: document.referrer,
timestamp: Date.now(),
screen: `${screen.width}x${screen.height}`,
language: navigator.language,
}));
}
event(name, payload = {}) {
navigator.sendBeacon('/analytics/event', JSON.stringify({
uid: this.userId,
event: name,
payload,
url: location.href,
timestamp: Date.now(),
}));
}
}
5.4 Sentry 原理
错误发生
↓
客户端 SDK 捕获错误 → 关联当前 breadcrumbs(面包屑导航)
↓
合并上下文信息(用户、浏览器、URL、自定义 tags)
↓
压缩后通过 POST 上报到 sentry.io 或自建 sentry
↓
服务端接收后:
├── 存入 PostgreSQL / ClickHouse
├── 使用 Source Map 还原原始堆栈(通过 release + 文件匹配)
├── 分组聚合(Fingerprint + Stack Trace Hash)
└── 触发告警规则(Alert Rules)
Breadcrumbs:记录错误发生前的用户操作序列,帮助还原现场。
// Sentry Breadcrumb 示例
Sentry.addBreadcrumb({
category: 'ui.click',
message: 'User clicked "Checkout"',
level: 'info',
timestamp: Date.now(),
});
// 错误报告中可看到: error 之前 10 秒内用户做了哪些操作
6. Docker 化
6.1 Nginx 配置
# nginx.conf —— SPA 部署典型配置
server {
listen 80;
server_name example.com;
root /usr/share/nginx/html;
index index.html;
# Gzip 压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript
image/svg+xml;
gzip_min_length 1024;
gzip_vary on;
gzip_comp_level 6;
# 静态资源长期缓存(带 hash 的文件)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA 路由 fallback —— 所有路径指向 index.html
location / {
try_files $uri $uri/ /index.html;
# HTML 不缓存
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# 反向代理 API
location /api/ {
proxy_pass http://backend:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 超时配置
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
SPA 路由 fallback 原理:
用户访问 /user/profile
→ 浏览器请求 example.com/user/profile
→ Nginx 尝试匹配 /user/profile 文件,不存在
→ fallback 到 /index.html
→ 前端 Router 解析 URL path,渲染对应页面
6.2 Dockerfile 多阶段构建
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Stage 2: Production(仅包含产物)
FROM nginx:1.27-alpine AS production
# 从 builder 阶段复制产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制自定义 Nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:80/ || exit 1
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
多阶段构建的好处:
- 最终镜像仅包含 Nginx + 构建产物,体积通常 < 50MB
- 构建工具(Node/npm/TypeScript)不会进入生产镜像
- 减少攻击面(没有 shell、没有包管理器)
# 构建和运行
docker build -t myapp:latest .
docker run -d -p 8080:80 --name myapp myapp:latest
6.3 Docker Compose
# docker-compose.yml
version: '3.8'
services:
frontend:
build:
context: .
dockerfile: Dockerfile
ports:
- "80:80"
depends_on:
- backend
environment:
- API_BASE_URL=http://backend:3000
restart: unless-stopped
backend:
image: my-backend:latest
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/myapp
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: myapp
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
pgdata: