Skip to content

前端部署

"怎么把代码传到服务器上"——这个问题十年前就有答案了,FTP、rsync、scp 都能干。真正麻烦的事在后面:在持续迭代的生产环境里,怎么保证每次发布都是可控、可验证、可回滚的。

部署不是一个动作,是一个系统——构建、分发、缓存、验证、监控、回滚。六个环节,一个环节出问题就是线上故障。


前端部署的底层模型

所有前端部署问题都可以抽象为同一个模型:

text
构建产物(静态文件)→ 分发管道 → 边缘节点 → 用户浏览器
     ↑                                    ↑
  构建阶段                             缓存策略
  - 编译/打包                           - CDN 缓存
  - hash 命名                           - 浏览器缓存
  - 代码分割                           - Service Worker

这个模型的三个核心变量:

  1. 产物的唯一性:每次构建的产物是否能精确定位到一次提交?(hash 命名)
  2. 分发的原子性:新版本上线时,用户是否会加载到新旧混合的产物?(缓存失效窗口)
  3. 回滚的速度:出了问题后,从发现问题到用户看到修复,路径多长?(CDN 刷新 vs 版本回退)

这三个变量决定了你的部署系统是否"生产可用"。


构建产物:部署的起点

Hash 命名的工程意义

静态资源的命名策略是前端部署里最基础但最容易被忽视的问题。

js
// 三种常见命名方式
bundle.js              // 无版本 —— 永远不知道线上跑的是哪个版本
bundle.1.2.3.js        // 语义版本 —— 手动维护,不可靠
bundle.a3f8b2c1.js    // 内容 hash —— 内容变则文件名变,自动去重

content hash 是对的,但实现细节上踩过坑的人不少:

  • Webpack 的三种 hashhash(整个项目共用一个 hash,改一个文件所有 hash 都变)、chunkhash(每个 chunk 独立 hash)、contenthash(基于文件内容)。对于需要长期缓存的资源,contenthash 是唯一正确的选择。
  • Vite/Rollup 默认使用 content hash,不需要额外配置。这是 Vite 在生产构建上比 Webpack 少坑的一个点。
  • CSS 文件的 hash:如果 CSS 中引用了其他资源(字体、图片),这些资源的 hash 变化会导致 CSS 的 hash 变化。在 Webpack 中,MiniCssExtractPlugin + contenthash 处理这个问题。

hash 命名的工程目标是:文件内容不变,文件名不变;文件内容变化,文件名必然变化。 这个目标达成的标志是:你可以对任意资源设置 Cache-Control: max-age=31536000, immutable,浏览器永久缓存,永远不会有缓存不一致的问题。

构建产物的缓存策略矩阵

资源类型缓存策略Header 配置原因
*.a3f8b2c1.js永久缓存max-age=31536000, immutablehash 保证了唯一性
*.a3f8b2c1.css永久缓存max-age=31536000, immutable同上
index.html不缓存 / 协商缓存Cache-Control: no-cachemax-age=0HTML 是入口文件,必须每次验证
图片/字体(带 hash)永久缓存max-age=31536000, immutablehash 保证唯一性
图片/字体(无 hash)短期缓存max-age=86400内容可能变化

关键点:HTML 文件不应该被缓存。 因为 HTML 中引用的 JS/CSS 文件名(带 hash)会在每次构建时变化。如果 HTML 被缓存了,用户拿到的旧 HTML 会引用旧版本的 JS/CSS,而这些旧资源可能已经被新的部署覆盖了——结果就是 404 或白屏。

一个真实的故障案例:某次发布后,运维反馈"部分用户页面白屏"。排查发现 CDN 上的 HTML 被设置了 max-age=600(10 分钟缓存),而静态资源已经上传覆盖了旧版本(没用 hash 命名)。结果是:HTML 被缓存,引用的是旧文件名 → 浏览器请求旧文件 → 旧文件已被覆盖 → 404。修了线上故障后,第一件事是把构建配置改成 content hash,第二件事是把 HTML 的缓存策略改成 no-cache。这两件事做好,"新旧版本混加载"这类问题就不会再出现。

一个常见的架构选择:HTML 走源站(动态服务器),静态资源走 CDN。HTML 文件由应用服务器(Node.js / Nginx)直接返回,不经过 CDN 缓存;JS/CSS/图片走 CDN,享受边缘加速。

构建产物的结构设计

一个合理的构建产物目录结构:

text
dist/
├── index.html              # 入口 HTML,走源站
├── assets/
│   ├── js/
│   │   ├── main.a3f8b2.js  # 主入口(带 hash)
│   │   ├── vendor.b7c3d1.js # 第三方库(独立 chunk,hash)
│   │   └── runtime.e9f4a5.js # webpack runtime
│   ├── css/
│   │   └── main.c2d8e1.css # 样式(带 hash)
│   └── images/
│       └── logo.f1a2b3.svg # 图片(带 hash)
└── robots.txt

这个结构的设计原则:

  1. 入口文件(HTML)在根目录,方便源站直接服务。
  2. 静态资源在 assets/,可以整个目录推到 CDN(OSS/S3)。
  3. 所有资源带 content hash,允许永久缓存。
  4. vendor 独立分包,利用浏览器缓存——vendor 的变更频率远低于业务代码,独立分包后用户更新业务代码时不需要重新下载 vendor。

CDN:静态资源分发的核心

CDN 的工作原理(从架构角度)

CDN 不是"快一点的服务器",而是一个分层缓存网络

text
用户请求


L1: 浏览器缓存(Cache-Control, Service Worker)
   │ (miss)

L2: CDN 边缘节点(离用户最近的 CDN 服务器)
   │ (miss)

L3: CDN 区域节点(同区域的多个边缘节点共享)
   │ (miss)

L4: 源站(你的服务器/OSS)

大部分 CDN 供应商(阿里云 CDN、Cloudflare、AWS CloudFront)都遵循这个分层架构。关键参数是缓存命中率——CDN 边缘节点的命中率通常 > 95%。这意味着源站只需要处理不到 5% 的请求。

CDN 的缓存策略设计

CDN 缓存需要同时考虑两个目标:加速(缓存尽量久)和可控(更新能生效)。

对于带 content hash 的资源:永久缓存。CDN 上的缓存时间可以设置到最大(通常 365 天)。因为文件名变了就是新文件,不存在"更新"的问题。

对于 HTML 文件:看架构选择。如果 HTML 走 CDN,需要做缓存键(cache key)配置——至少把 HostURL Path 作为缓存键。回源策略设置短 TTL(如 60s)或 no-cache

CDN 缓存刷新的工程实践

CDN 缓存刷新(purge / invalidate)是部署流程的核心环节。基本流程:

text
构建完成 → 上传静态资源到 CDN 源站(OSS/S3)
         → 发布 HTML(新版本的 HTML 引用新 hash 的静态资源)
         → 刷新 CDN 上 HTML 文件的缓存(如果需要)

注意:不需要刷新静态资源的 CDN 缓存——因为新版本的资源有新的 hash,在 CDN 上是全新的文件。只需要刷新 HTML 入口的缓存,或者如果 HTML 走源站则不需要刷新。

这就是 hash 命名的架构价值:它把"部署"变成"新增文件"而不是"覆盖文件"。新版本上线 = 上传新 hash 文件 + 修改 HTML 引用。旧版本的文件不需要删除——它们在 CDN 上自然过期。

多级 CDN 架构:自有 CDN + 第三方 CDN

大型应用常见的架构:

text
用户 → 第三方 CDN(Cloudflare / Akamai,全球加速)


      自有 CDN(阿里云 CDN / 腾讯云 CDN,国内加速)


      源站 OSS(存储构建产物)

这个双层架构的成本比单 CDN 高 30-50%,但对于百万级 UV 的 ToC 业务,首屏加载时间的每 100ms 下降都能转化为可衡量的业务指标提升。

CDN 常见踩坑

  • 回源超时:CDN 回源时源站响应慢,CDN 边缘节点超时返回 504。解决:CDN 超时时间 ≥ 源站正常响应的 P99 + 2 倍 buffer。
  • 缓存雪崩:同一时间大量缓存同时过期,请求全部穿透到源站。解决:给不同资源的缓存 TTL 加随机偏移量(max-age=31536000 + random(0, 86400))。
  • CDN 缓存了错误页面:源站短暂故障返回 5xx,CDN 把 5xx 页面缓存了,源站恢复后用户看到的还是错误页面。这种情况在生产中非常阴险——你看着源站监控一切正常,但用户那边全是 500。解决:配置 CDN 的"状态码缓存规则"——只缓存 2xx/3xx,不缓存 4xx/5xx。另外,CDN 上配置一个"缓存锁定"告警——当回源成功率突然下降时,可能是错误页面正在被缓存。
  • 跨域问题:CDN 域名和主站域名不同导致 CORS 错误。解决:CDN 源站(OSS)配置 CORS 头 + CDN 转发 CORS 头。

Nginx:反向代理与静态资源服务

Nginx 在前端部署中的角色

Nginx 在前端部署中通常承担以下角色之一(或同时承担多个):

  1. 静态资源服务器:直接服务构建产物。
  2. 反向代理:将请求转发到 Node.js 应用(SSR)。
  3. 网关:路由分发、限流、认证、日志。
  4. 负载均衡:将流量分配到多个应用实例。

典型的 SPA 部署 Nginx 配置

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

    # SSL 证书
    ssl_certificate     /etc/nginx/ssl/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/privkey.pem;

    root /var/www/app/dist;

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

    # 静态资源:永久缓存(因为文件名有 content hash)
    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        # 可选:如果某些资源没有 hash,加短缓存
        # 这里的逻辑按实际构建配置调整
    }

    # HTML 文件:不缓存
    location / {
        try_files $uri $uri/ /index.html;
        add_header Cache-Control "no-cache, no-store, must-revalidate";
        add_header Pragma "no-cache";
        add_header Expires "0";
    }

    # 安全头
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
}

几个配置要点:

  • try_files $uri $uri/ /index.html:SPA 的核心路由配置。先尝试匹配静态文件,匹配不到就返回 index.html,让前端路由接管。
  • expires 1y + immutable:告诉浏览器和中间代理,这个资源在一年内不会变化,可以直接使用缓存,不需要重新验证。这对于带 content hash 的资源是正确的。
  • 安全头X-Frame-Options 防止 clickjacking,X-Content-Type-Options 防止 MIME 类型嗅探攻击。

Nginx 作为 API 网关

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

    # 前端静态资源
    location / {
        root /var/www/app/dist;
        try_files $uri $uri/ /index.html;
    }

    # API 代理到后端服务
    location /api/ {
        proxy_pass http://backend-service: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;

        # 缓冲配置
        proxy_buffering on;
        proxy_buffer_size 4k;
        proxy_buffers 8 4k;
    }
}

前端静态资源和后端 API 在同一个域名下,避免了跨域问题,同时 Nginx 可以根据路径做路由分发。


Docker 容器化部署

为什么前端会引入 Docker

“前端只是静态文件,放在 Nginx 后面即可”只适用于简单场景。当系统具备以下特征时,Docker 带来的环境一致性和交付稳定性会更明显:

  • 多环境一致性:开发/测试/预发布/生产环境需要完全相同的运行时。
  • SSR 应用:Node.js 服务端渲染,需要进程管理、资源限制、健康检查。
  • 微前端 / BFF 层:前端有自己的 Node.js BFF(Backend For Frontend),需要独立的服务治理。
  • 水平扩展:需要快速扩缩容。

前端 Dockerfile 常见做法

纯静态 SPA 的多阶段构建:

dockerfile
# 阶段 1:构建
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

# 阶段 2:运行
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

多阶段构建的核心价值:运行时镜像不包含构建工具链。最终镜像大小 = nginx:alpine(约 10MB)+ 构建产物(通常 < 5MB)。没有 node_modules,没有源代码,没有构建缓存。

SSR 应用:

dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/.output /app/.output
# 只复制生产依赖(Nuxt 的 .output 已包含运行时依赖)
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", ".output/server/index.mjs"]

几个工程要点:

  • --frozen-lockfile:禁止 pnpm install 时修改 lockfile。CI 环境中必须使用,否则 lockfile 和实际安装的版本不一致。
  • .dockerignore:排除 node_modules.gitdist、构建缓存。减少构建上下文大小,加快 COPY . . 的速度。
  • 非 root 用户:Nginx 官方镜像默认用 nginx 用户运行。如果用 Node.js 镜像,加上 USER node

Docker Compose 编排

yaml
version: '3.8'
services:
  frontend:
    build: .
    ports:
      - "80:80"
    restart: unless-stopped
    environment:
      - NODE_ENV=production
    networks:
      - app-network

  backend:
    image: my-backend:latest
    ports:
      - "3000:3000"
    restart: unless-stopped
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

restart: unless-stopped 保证容器在宿主重启后自动启动。这个配置在生产中容易被忘记。


CI/CD 流水线设计

流水线的架构模型

text
Git Push → CI Pipeline
             ├── 1. Lint & Type Check(2 min)
             ├── 2. Unit Test(3 min)
             ├── 3. Build(5 min)
             ├── 4. E2E Test(8 min)         ← 并行运行
             ├── 5. Bundle Analysis(1 min)   ← 并行运行
             └── 6. Deploy
                   ├── 上传静态资源到 OSS
                   ├── 刷新 CDN(仅 HTML)
                   └── 发送部署通知

流水线的设计原则:

  • 失败快速终止:Lint 失败就不要再跑 Build。把最可能失败的步骤放在最前面。
  • 独立步骤并行:E2E 测试和 Bundle 分析互不依赖,应该并行。
  • 部署是最后一步:只有前面所有步骤通过才触发部署。

GitHub Actions 实现

yaml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm typecheck
      - run: pnpm test

  build-and-deploy:
    needs: quality
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm build

      # 上传静态资源到 OSS
      - name: Upload to OSS
        uses: manyuanrong/setup-ossutil@v3
        with:
          endpoint: oss-cn-shenzhen.aliyuncs.com
          access-key-id: ${{ secrets.OSS_ACCESS_KEY_ID }}
          access-key-secret: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
      - run: |
          ossutil cp -r dist/assets/ oss://my-bucket/assets/ --update

      # 部署 HTML 到服务器
      - name: Deploy HTML
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          source: dist/index.html
          target: /var/www/app/

      # 刷新 CDN
      - name: Purge CDN
        run: |
          curl -X POST "https://cdn.aliyuncs.com/..." \
            -d "ObjectPath=https://example.com/index.html"

      # 通知
      - name: Notify
        run: |
          curl -X POST "${{ secrets.WEBHOOK_URL }}" \
            -H "Content-Type: application/json" \
            -d '{"text":"✅ 部署成功: '${GITHUB_SHA:0:7}'"}'

CI/CD 的关键配置

  • Secrets 管理:永远不要在 workflow 文件中硬编码密钥。使用 GitHub Secrets / GitLab CI Variables / Vault。即使仓库是私有的,也不要把密钥写在代码里——一旦代码被分享或仓库权限变更,密钥就泄露了。
  • 构建缓存actions/setup-node@v4cache: 'pnpm' 会自动缓存 ~/.pnpm-store。对于大型项目,这能节省 60-80% 的 install 时间。
  • 环境隔离:至少区分 staging 和 production 两条部署流水线。staging 部署在 feature branch push 时触发,production 部署在 main branch push 时触发。

部署策略:从全量到灰度

部署策略对比

策略原理回滚速度风险暴露面适用场景
全量部署所有用户同时看到新版本1-5 min(CDN 刷新)100%非关键修复、低风险变更
蓝绿部署两套环境切换< 1 min(DNS/负载均衡切换)50%(切换瞬间 100%)重大版本升级
金丝雀部署逐步增加新版本流量比例< 1 min1% → 5% → 25% → 100%需要验证的变更
灰度发布按用户属性分流(UID/地域/设备)< 1 min按灰度比例需要定向验证的变更

前端金丝雀部署的实现

前端的金丝雀部署和后端不同——前端是静态文件,没有"实例"的概念。纯静态 SPA 实现金丝雀部署的关键在 Nginx 层的分流:

nginx
# 通过 cookie 分流
map $cookie_canary $backend {
    "true"  "new-version-server";    # 金丝雀用户 → 新版本
    default "stable-server";         # 普通用户 → 稳定版本
}

server {
    location / {
        proxy_pass http://$backend;
    }
}

更细粒度的实现:

nginx
# 按用户 ID hash 分流(10% 流量到新版本)
split_clients "${remote_addr}${http_user_agent}" $variant {
    10%  "new-version-server";
    *    "stable-server";
}

split_clients 是 Nginx 原生的流量分割指令,基于请求属性的 hash 值分配流量。对于同一个用户,hash 结果一致,保证用户不会在刷新页面时在新旧版本间跳转。

蓝绿部署的完整流程

nginx
蓝环境(当前版本)              绿环境(新版本)
  /var/www/blue/                  /var/www/green/
  ├── index.html                  ├── index.html (new)
  ├── assets/...                  ├── assets/... (new)
  Nginx → proxy_pass blue         Nginx → proxy_pass green (切换后)

部署流程:

bash
#!/bin/bash
# deploy.sh

# 1. 确定目标环境(当前蓝色 -> 部署到绿色)
CURRENT=$(readlink /var/www/current)
if [ "$CURRENT" = "/var/www/blue" ]; then
    TARGET="green"
else
    TARGET="blue"
fi

# 2. 部署到目标环境
rsync -avz --delete dist/ /var/www/$TARGET/

# 3. 健康检查
curl -f http://localhost:8000/health || exit 1

# 4. 切换流量
ln -sfn /var/www/$TARGET /var/www/current
nginx -s reload

# 5. 验证
sleep 5
curl -f https://example.com/ || {
    echo "Deploy failed, rolling back"
    ln -sfn /var/www/$CURRENT /var/www/current
    nginx -s reload
    exit 1
}

echo "Deploy success: $CURRENT$TARGET"

核心思想:永远保留上一个可用版本,切换成本是一次软链接更新或一次 Nginx reload。


环境管理

多环境配置策略

前端应用的环境配置有三种常见模式:

模式 1:构建时注入

js
// vite.config.js
export default defineConfig(({ mode }) => ({
  define: {
    __API_BASE_URL__: JSON.stringify(
      mode === 'production' ? 'https://api.example.com' : 'http://localhost:3000'
    ),
  },
}))

优点:简单。缺点:每个环境需要一次单独的构建,产物不通用。一个构建产物不能在 staging 验证后直接推到 production。

模式 2:运行时注入

html
<!-- index.html -->
<script>
  window.__APP_CONFIG__ = {
    API_BASE_URL: '__API_BASE_URL__',
    ENV: '__ENV__',
  };
</script>

在 Docker 启动时或 Nginx 启动时替换占位符:

bash
# docker-entrypoint.sh
sed -i "s|__API_BASE_URL__|${API_BASE_URL}|g" /usr/share/nginx/html/index.html
sed -i "s|__ENV__|${ENV}|g" /usr/share/nginx/html/index.html

优点:同一份构建产物可以部署到任意环境。staging 验证通过后,同一个 Docker 镜像推到 production,只需调整环境变量,这样也避免了 staging 和 production 构建结果不一致的问题。

模式 3:配置服务

从独立的配置服务(Consul / Nacos / 自建配置 API)拉取配置。适合大型微前端或多项目共享配置的场景。引入这一层的前提是:你的配置变更频率和复杂度已经超出了环境变量能处理的范畴。

环境变量的安全管理

bash
# .env.production(不要提交到 Git)
API_BASE_URL=https://api.example.com
SENTRY_DSN=https://xxx@sentry.io/123

# 在 CI 中注入
# GitHub Actions Secrets → 环境变量 → 构建/部署脚本

敏感信息(API Key、Sentry DSN、分析 ID)的管理规则:

  • 永远不在代码中硬编码
  • 永远不提交到 Git(即使仓库是私有的)
  • 在 CI/CD 系统中通过 Secrets 机制注入
  • 定期轮换(尤其是有人员离职时)
  • 生产环境的密钥和 staging 环境的密钥严格隔离

监控、告警与回滚

部署不是终点——监控是

部署成功的标准不是"构建通过 + 文件上传完成",而是线上用户的实际体验没有劣化。

部署后必须验证的指标:

指标正常阈值告警阈值数据来源
JS 错误率< 0.5%> 2%Sentry / 自建埋点
首屏加载时间 (P95)< 3s比基线增加 > 30%RUM(Real User Monitoring)
页面可用率> 99.9%< 99%拨测(Uptime 监控)
API 成功率> 99.5%< 98%网关日志
静态资源加载成功率> 99.9%< 99%CDN 日志

部署后的健康检查流程

text
部署完成

  ├── 30s: 检查 CDN 缓存是否已刷新(curl 验证最新 HTML 的 ETag)

  ├── 1min: 检查核心页面是否 200(拨测 /首页 /关键功能页)

  ├── 3min: 检查 Sentry 错误率是否上升
  │         (对比部署前后 5 分钟窗口的错误率)

  ├── 5min: 检查 RUM 数据(首屏时间、页面加载成功率)

  └── 15min: 检查用户反馈渠道是否有异常报告

回滚策略

回滚不是"还原代码再部署一次"。 一次完整的部署流程需要 5-10 分钟。如果线上出了 P0 故障,等 10 分钟是不可接受的。

快速回滚方案:

text
方案 A: Nginx 快速回滚(< 30 秒)
  前提:保留上一个版本的构建产物
  操作:ln -sfn /var/www/last-stable /var/www/current && nginx -s reload

方案 B: CDN 层面回滚(< 5 分钟)
  操作:重新发布上一个版本的 HTML 到源站 + 刷新 CDN 上 HTML 的缓存

方案 C: Docker 回滚(< 1 分钟)
  操作:docker service rollback(K8s: kubectl rollout undo)

方案 A 的价值在于可快速恢复。 它的核心要求是:每次部署时都保留旧版本文件。保留最近 N 个版本(N ≥ 3),用磁盘空间换取回滚稳定性。即使后续部署步骤失败,仍然保留可回滚版本。

bash
# 保留最近 5 个版本的部署脚本逻辑
ls -t /var/www/releases/ | tail -n +6 | xargs rm -rf  # 删除 5 个版本之前的

部署通知

bash
# 部署成功/失败的 Webhook 通知
# 必须包含:环境、版本号(git SHA)、变更内容、部署耗时
curl -X POST "$WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -d '{
    "env": "production",
    "version": "'${GIT_SHA:0:7}'",
    "changelog": "修复首页加载慢的问题(#234)",
    "duration": "8min 32s",
    "status": "success",
    "rollback_command": "/deploy-rollback production"
  }'

通知里附带回滚命令是一条很实用的实践。故障发生时,值班同事需要在最短时间内得到可执行的回滚入口,而不是再去检索文档。

另外,部署通知不要只发"成功"的。失败的通知比成功的通知重要 10 倍——失败意味着有人需要立刻行动。如果部署失败但没人收到告警,这个部署流水线等于没有监控。部署失败的通知里至少包含:失败的步骤、错误日志的前 50 行、git diff 的链接(方便快速判断是不是代码变更导致的)。


生产部署检查清单

在每次部署到生产环境之前,确认以下事项:

text
□ 构建产物的 JS/CSS 文件名包含 content hash
□ HTML 文件没有被 CDN 长时间缓存
□ Gzip/Brotli 压缩已启用
□ SSL 证书在 30 天内不会过期
□ .env 文件中的密钥没有提交到 Git
□ Docker 镜像大小 < 100MB(纯静态)或 < 300MB(SSR)
□ Dockerfile 使用多阶段构建
□ CI 流水线中使用了 --frozen-lockfile
□ 部署后有自动健康检查(curl 验证关键页面 200)
□ 保留上一个版本的构建产物,可以 < 30 秒回滚
□ Sentry 或其他错误监控已配置
□ 部署通知中附带了回滚命令
□ .dockerignore 排除了 node_modules 和 .git
□ CDN 配置了正确的 CORS 头
□ CDN 不缓存 4xx/5xx 响应

几个 Principal 级别的判断

1. 前端部署的核心问题不是“怎么传文件”,而是“怎么保证用户拿到的是一致版本”。

文件上传是手段,缓存一致性是目的。所有部署策略——hash 命名、CDN 刷新、蓝绿部署——最终都在回答一个问题:当用户访问你的网站时,浏览器渲染的 HTML + JS + CSS 是否构成一个"一致的版本"?如果 HTML 引用了新版本的 JS 但 CDN 还没刷新,用户看到的是旧 HTML + 新 JS(或反之)——这就是不一致,会导致白屏或功能异常。

2. 部署速度和部署安全本身存在张力,部署系统需要同时兼顾两者。

部署速度的瓶颈通常不在"上传文件"(静态资源上传到 OSS 通常 < 30s),在"验证"。自动化测试、健康检查、指标对比——这些安全措施增加了部署时间,但也降低了故障风险。这个矛盾的解决方案不是跳过验证,而是让验证更快、更自动化。

3. 回滚能力通常比部署速度更关键。

一个能在 30 秒内回滚的系统,比一个能在 2 分钟内完成的部署系统有价值得多。因为部署总是会出问题的——无论你多谨慎——问题不在于"会不会出",在于"出了以后多久能恢复"。

4. 部署流程应建立在可观测性的基础上。

没有监控的部署 = 盲飞。你不知道部署后错误率是上升了 0.1% 还是 10%。部署流程的最后一步不应该是"构建完成",而应该是"确认线上指标正常(至少观察 15 分钟)"。把部署流程设计为一个闭环:部署 → 验证 → 正常(结束)/ 异常(回滚)。

5. 环境变量是前端部署里容易被忽视的安全边界。

前端代码运行在浏览器里,不是服务器。但构建时的环境变量、CI 的 Secrets、Docker 的 env——这些都可能把敏感信息泄露到前端 bundle 中。一个常见错误:在构建时将包含密钥的环境变量注入到前端代码中(例如 define: { API_KEY: 'sk-xxxx' }),然后这个密钥出现在了浏览器的 JS 文件中。前端环境变量中任何会被打入客户端 bundle 的值都不应该是秘密

6. Docker 不是唯一方案,但“不可变基础设施”的思路值得保留。

你可以在物理机 / 虚拟机上用 Nginx 部署,不需要 Docker。但 Docker 带来的"构建一次,到处运行"的不可变基础设施思想,应该贯彻在任何部署方案中。具体来说:部署到服务器的是一份"完整的环境快照"(包含运行时、依赖、配置),而不是"JS 文件 + 手动改 Nginx 配置"。


延伸阅读