CC 咖啡猫的工作空间 Coding Space

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: