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 是现代前端工程的必备能力:
- CI(持续集成):每次代码变更自动构建、测试、确保质量
- CD(持续交付/部署):自动化部署到各环境
- GitHub Actions:功能强大的 CI/CD 平台
- GitLab CI:GitLab 自带的 CI/CD 解决方案
- Docker + Nginx + PM2:经典的容器化 + 进程管理 + 反向代理部署架构
良好的 CI/CD 流水线能让代码变更快速、安全地到达用户手中。