CI/CD 与自动化部署

深入理解持续集成/持续部署(CI/CD)流水线、GitHub Actions、GitLab CI 配置、以及自动化测试与部署的最佳实践。


CI/CD 概念

什么是 CI/CD

┌──────────────────────────────────────────────────────────────┐
│                        CI/CD 全貌                             │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│   CI (持续集成)              CD (持续交付/部署)               │
│   ┌─────────────────┐       ┌─────────────────────────┐     │
│   │                 │       │                         │     │
│   │  Code → Build  │       │  Build → Test → Deploy  │     │
│   │      ↓         │       │        ↓                │     │
│   │  Test          │       │  Staging → Production   │     │
│   │      ↓         │       │                         │     │
│   │  Merge         │       │  Automated (CD)         │     │
│   │                 │       │  Manual (Delivery)      │     │
│   └─────────────────┘       └─────────────────────────┘     │
│                                                              │
│   目标:快速发现错误          目标:随时可部署                 │
│   每次代码变更都自动构建测试    自动部署到各环境                 │
│                                                              │
└──────────────────────────────────────────────────────────────┘

CI/CD 流程图

┌─────────────────────────────────────────────────────────────────────┐
│                        完整 CI/CD 流水线                            │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│   Commit → Lint → Test → Build → Security Scan → Deploy            │
│      │       │      │      │         │               │              │
│      ▼       ▼      ▼      ▼         ▼               ▼              │
│   ┌─────┐ ┌────┐ ┌────┐ ┌────┐ ┌─────────┐    ┌─────────┐          │
│   │Git  │ │ESLint│ │Jest│ │Vite │ │Snyk    │    │Staging │          │
│   │Hook │ │     │ │    │ │     │ │NPM audit│    │Server  │          │
│   └─────┘ └────┘ └────┘ └────┘ └─────────┘    └────┬────┘          │
│                                                      │              │
│                                                      ▼              │
│                                              ┌─────────────┐        │
│                                              │  E2E Tests  │        │
│                                              └──────┬──────┘        │
│                                                     │               │
│                                                     ▼               │
│                                              ┌─────────────┐        │
│                                              │ Production  │        │
│                                              │  Deploy     │        │
│                                              └─────────────┘        │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

GitHub Actions

基本概念

┌──────────────────────────────────────────────────────────────┐
│                   GitHub Actions 核心概念                       │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  Workflow (工作流)                                            │
│  ┌────────────────────────────────────────────────────────┐ │
│  │  .github/workflows/ci.yml                               │ │
│  │  定义在仓库中执行自动化工作的 YAML 文件                    │ │
│  └────────────────────────────────────────────────────────┘ │
│                           │                                  │
│         ┌─────────────────┼─────────────────┐                │
│         ▼                 ▼                 ▼                │
│   ┌──────────┐      ┌──────────┐      ┌──────────┐            │
│   │   Job   │ ───→ │   Job   │ ───→ │   Job   │            │
│   │ (任务 1) │      │ (任务 2) │      │ (任务 3) │            │
│   └────┬─────┘      └────┬─────┘      └────┬─────┘            │
│        │                 │                 │                  │
│        └─────────────────┼─────────────────┘                  │
│                          ▼                                    │
│                  ┌──────────────┐                             │
│                  │   Step       │                             │
│                  │  (步骤 1, 2…) │                             │
│                  └──────────────┘                             │
│                                                              │
│  Runner: 执行 Workflow 的服务器(GitHub 托管或自托管)           │
│  Action: 可重用的 Workflow 步骤(市场上有大量现成 Action)        │
│                                                              │
└──────────────────────────────────────────────────────────────┘

完整 CI 配置文件

# .github/workflows/ci.yml
name: CI

on:
  # 触发条件
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

  # 手动触发
  workflow_dispatch:
    inputs:
      environment:
        description: 'Deploy environment'
        required: true
        default: 'staging'

# 环境变量
env:
  NODE_VERSION: '20'
  NPM_CONFIG_CACHE: ${{ runner.os }}/.npm

jobs:
  # Job 1: 代码检查
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run ESLint
        run: npm run lint

      - name: Check types
        run: npm run type-check

  # Job 2: 单元测试
  test:
    name: Test
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run unit tests
        run: npm run test:coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: ./coverage/lcov.info
          fail_ci_if_error: true

  # Job 3: 构建
  build:
    name: Build
    runs-on: ubuntu-latest
    needs: [lint, test]
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build
        env:
          VITE_API_URL: ${{ secrets.VITE_API_URL }}

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
          retention-days: 30

  # Job 4: 安全扫描
  security:
    name: Security Scan
    runs-on: ubuntu-latest
    needs: [lint, test]
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Run Snyk security scan
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

  # Job 5: 部署预览
  preview:
    name: Deploy Preview
    runs-on: ubuntu-latest
    needs: build
    if: github.event_name == 'pull_request'
    steps:
      - name: Download build
        uses: actions/download-artifact@v4
        with:
          name: dist

      - name: Deploy to Vercel Preview
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          github-comment: true

部署配置文件

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:
    inputs:
      environment:
        description: 'Environment to deploy'
        required: true
        type: choice
        options:
          - staging
          - production

jobs:
  deploy:
    name: Deploy to ${{ inputs.environment || 'production' }}
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment || 'production' }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build
        env:
          NODE_ENV: production

      - name: Deploy to Server
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            cd /var/www/app
            npm ci --production
            cp -r dist/* .
            pm2 restart app

      - name: Notify Slack
        if: always()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "Deployment ${{ job.status }} for ${{ github.ref }}",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*Deployment ${{ job.status }}*\n*Branch:* `${{ github.ref }}`\n*Commit:* ${{ github.sha }}"
                  }
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

GitLab CI

GitLab CI 配置

# .gitlab-ci.yml
stages:
  - lint
  - test
  - build
  - security
  - deploy

variables:
  NODE_VERSION: '20'
  NPM_CONFIG_CACHE: ${CI_PROJECT_DIR}/.npm

# 缓存配置
cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - .npm/
    - node_modules/

# 代码检查
eslint:
  stage: lint
  image: node:${NODE_VERSION}
  script:
    - npm ci
    - npm run lint
  allow_failure: false

# 类型检查
typecheck:
  stage: lint
  image: node:${NODE_VERSION}
  script:
    - npm ci
    - npm run type-check
  allow_failure: false

# 单元测试
test:
  stage: test
  image: node:${NODE_VERSION}
  coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
  script:
    - npm ci
    - npm run test:coverage
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
    expire_in: 1 week
  allow_failure: false

# E2E 测试
e2e:
  stage: test
  image: cypress/base:18
  services:
    - docker:dind
  script:
    - npm ci
    - npx cypress run --record --key $CYPRESS_RECORD_KEY
  only:
    - main
    - develop
  allow_failure: true

# 构建
build:
  stage: build
  image: node:${NODE_VERSION}
  script:
    - npm ci
    - npm run build
  artifacts:
    name: dist
    paths:
      - dist/
    expire_in: 1 week
  only:
    - main
    - develop

# 安全扫描
security:
  stage: security
  image: node:${NODE_VERSION}
  script:
    - npm ci
    - npx snyk test
  allow_failure: true
  only:
    - main

# 部署到 Staging
deploy_staging:
  stage: deploy
  image: alpine:latest
  script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - ssh-keyscan $STAGING_HOST >> ~/.ssh/known_hosts
    - scp -r dist/* $STAGING_USER@$STAGING_HOST:/var/www/staging
    - ssh $STAGING_USER@$STAGING_HOST "cd /var/www/staging && npm ci --production && pm2 restart app"
  environment:
    name: staging
    url: https://staging.example.com
  only:
    - develop
  when: manual

# 部署到 Production
deploy_production:
  stage: deploy
  image: alpine:latest
  script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - ssh-keyscan $PRODUCTION_HOST >> ~/.ssh/known_hosts
    - scp -r dist/* $PRODUCTION_USER@$PRODUCTION_HOST:/var/www/production
    - ssh $PRODUCTION_USER@$PRODUCTION_HOST "cd /var/www/production && npm ci --production && pm2 restart app"
  environment:
    name: production
    url: https://example.com
  only:
    - main
  when: manual

Docker 容器化

Dockerfile

# 构建阶段
FROM node:20-alpine AS builder

WORKDIR /app

# 安装依赖(利用 Docker 缓存)
COPY package*.json ./
RUN npm ci

# 复制源码并构建
COPY . .
RUN npm run build

# 生产阶段
FROM node:20-alpine AS production

WORKDIR /app

# 创建非 root 用户
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nextjs -u 1001

# 只复制必要的文件
COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./

# 切换到非 root 用户
USER nextjs

# 暴露端口
EXPOSE 3000

# 健康检查
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

# 启动命令
CMD ["node", "dist/server.js"]

docker-compose.yml

# docker-compose.yml
version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - '3000:3000'
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgres://user:pass@db:5432/app
      - REDIS_URL=redis://cache:6379
    depends_on:
      - db
      - cache
    restart: unless-stopped
    healthcheck:
      test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:3000/health']
      interval: 30s
      timeout: 3s
      retries: 3

  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=app
    volumes:
      - postgres_data:/var/lib/postgresql/data
    restart: unless-stopped

  cache:
    image: redis:7-alpine
    volumes:
      - redis_data:/data
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    ports:
      - '80:80'
      - '443:443'
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./ssl:/etc/nginx/ssl:ro
    depends_on:
      - app
    restart: unless-stopped

volumes:
  postgres_data:
  redis_data:

Nginx 配置

# nginx.conf
events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    # 日志格式
    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';

    access_log /var/log/nginx/access.log main;
    error_log /var/log/nginx/error.log warn;

    # Gzip 压缩
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml;

    # 上游服务器
    upstream app_servers {
        least_conn;  # 最少连接优先
        server app:3000 max_fails=3 fail_timeout=30s;
        keepalive 32;
    }

    server {
        listen 80;
        server_name example.com;

        # 重定向到 HTTPS
        return 301 https://$server_name$request_uri;
    }

    server {
        listen 443 ssl http2;
        server_name example.com;

        # SSL 配置
        ssl_certificate /etc/nginx/ssl/cert.pem;
        ssl_certificate_key /etc/nginx/ssl/key.pem;
        ssl_session_timeout 1d;
        ssl_session_cache shared:SSL:50m;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
        ssl_prefer_server_ciphers off;

        # 安全头
        add_header X-Frame-Options "SAMEORIGIN" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header X-XSS-Protection "1; mode=block" always;
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

        # 静态资源
        location /assets/ {
            expires 1y;
            add_header Cache-Control "public, immutable";
        }

        # API 代理
        location /api/ {
            proxy_pass http://app_servers;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            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_cache_bypass $http_upgrade;
        }

        # 应用代理
        location / {
            proxy_pass http://app_servers;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
            proxy_cache_bypass $http_upgrade;
        }

        # 健康检查
        location /health {
            proxy_pass http://app_servers;
            access_log off;
        }
    }
}

PM2 进程管理

PM2 配置文件

// ecosystem.config.js
module.exports = {
  apps: [
    {
      name: 'app',
      script: './dist/server.js',
      instances: 'max',  // 最多实例数
      exec_mode: 'cluster',  // 集群模式
      env: {
        NODE_ENV: 'development',
        PORT: 3000
      },
      env_production: {
        NODE_ENV: 'production',
        PORT: 3000
      },
      // 日志
      log_file: './logs/combined.log',
      out_file: './logs/out.log',
      error_file: './logs/error.log',
      log_date_format: 'YYYY-MM-DD HH:mm:ss',
      // 资源限制
      max_memory_restart: '1G',
      // 自动重启
      autorestart: true,
      watch: false,
      max_restarts: 10,
      min_uptime: '10s',
      // 安全
      instance_var: 'INSTANCE_ID'
    }
  ]
};

PM2 命令

# 启动
pm2 start ecosystem.config.js --env production

# 查看状态
pm2 status
pm2 list

# 查看日志
pm2 logs app
pm2 logs app --lines 100

# 重启
pm2 restart app
pm2 restart all

# 重新加载(零 downtime)
pm2 reload app

# 停止
pm2 stop app

# 删除
pm2 delete app

# 监控
pm2 monit

# 保存进程列表
pm2 save

# 开机自启
pm2 startup
pm2 resurrect  # 恢复已保存的进程列表

自动化部署最佳实践

Git Flow 与 CI/CD

┌──────────────────────────────────────────────────────────────┐
│                 Git Flow + CI/CD 集成                         │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│   feature/xxx ──→ develop ──→ main ──→ Production            │
│                        │                │                    │
│                        ▼                ▼                    │
│                    Staging         Production                │
│                   (auto-deploy)    (manual approval)         │
│                                                              │
│   main:        自动部署到 Staging + 触发 E2E 测试              │
│   release/*:   手动部署到 Production                          │
│   hotfix/*:    紧急修复,直接合并到 main 触发部署               │
│                                                              │
└──────────────────────────────────────────────────────────────┘

蓝绿部署

# 基础设施脚本 - 蓝绿部署
#!/bin/bash

APP_NAME="myapp"
CURRENT_VERSION=$(kubectl get deployment $APP_NAME -o jsonpath='{.spec.selector.matchLabels.version}')
NEW_VERSION=$1

if [ "$CURRENT_VERSION" == "blue" ]; then
  NEW_COLOR="green"
else
  NEW_COLOR="blue"
fi

# 部署新版本
kubectl set image deployment/$APP_NAME app=$APP_NAME:$NEW_VERSION --namespace=production

# 更新标签
kubectl label deployment/$APP_NAME version=$NEW_COLOR --namespace=production

# 等待新版本就绪
kubectl rollout status deployment/$APP_NAME --namespace=production

# 验证
curl -f https://api.example.com/health || exit 1

# 切换流量(更新 service selector)
kubectl patch service $APP_NAME -p "{\"spec\":{\"selector\":{\"version\":\"$NEW_COLOR\"}}}" --namespace=production

echo "Deployed $APP_NAME:$NEW_VERSION successfully"

回滚策略

# GitLab CI 回滚任务
rollback:
  stage: deploy
  script:
    - kubectl rollout undo deployment/$APP_NAME -n production
    - kubectl rollout status deployment/$APP_NAME -n production
  environment:
    name: production
    action: rollback
  when: manual
  only:
    - main

这一章想说的

CI/CD 是现代前端工程的必备能力:

  1. CI(持续集成):每次代码变更自动构建、测试、确保质量
  2. CD(持续交付/部署):自动化部署到各环境
  3. GitHub Actions:功能强大的 CI/CD 平台
  4. GitLab CI:GitLab 自带的 CI/CD 解决方案
  5. Docker + Nginx + PM2:经典的容器化 + 进程管理 + 反向代理部署架构

良好的 CI/CD 流水线能让代码变更快速、安全地到达用户手中。


延展阅读