上线部署与运维保障

SaaS 产品从开发到上线有一道"生死线"——生产环境的稳定性。功能做完只是 50%,剩下 50% 是安全加固、性能优化、监控告警、灰度发布。本章是 SaaS 上线前的终极 Checklist。

1. 生产环境 Checklist

1.1 安全清单

检查项状态说明
HTTPS 强制所有流量走 HTTPS
环境变量分离生产密钥不在代码仓库
CSRF 保护Server Actions 自动保护
CSP 头防 XSS 注入
Rate LimitingAPI + 登录限流
SQL 注入防护Drizzle ORM 参数化查询
Cookie 安全HttpOnly + Secure + SameSite
敏感日志脱敏不打印密码、Token

1.2 性能清单

检查项状态目标
Lighthouse Performance≥ 90
LCP< 2.5s
FID/INP< 200ms
CLS< 0.1
Bundle Size首页 JS < 100KB
图片优化next/image + WebP
字体优化next/font 预加载

1.3 基础设施清单

检查项状态说明
数据库备份自动每日备份
数据库连接池Serverless 用连接池
CDN 配置静态资源走 CDN
DNS 配置域名 + SSL 证书
错误监控Sentry 集成
日志收集结构化日志
Stripe Webhook生产环境 Webhook URL

1.4 业务流程清单

检查项状态说明
注册流程邮箱验证 + 欢迎邮件
登录流程OAuth + 密码 + MFA
支付流程Stripe 测试卡验证通过
WebhookStripe Webhook 生产 URL 配置
邮件发送生产域名 SPF/DKIM 配置
错误页面自定义 404/500 页面
法律页面Terms of Service + Privacy Policy
SEOsitemap.xml + robots.txt + OG 标签

1.5 Next.js 安全头配置

// next.config.ts
const securityHeaders = [
  { key: 'X-DNS-Prefetch-Control', value: 'on' },
  { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
  { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'Referrer-Policy', value: 'origin-when-cross-origin' },
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
]
 
const nextConfig = {
  async headers() {
    return [{ source: '/(.*)', headers: securityHeaders }]
  },
}

1.6 CSP(Content Security Policy)配置

// middleware.ts
export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
  const csp = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'unsafe-inline'`,
    `img-src 'self' blob: data: https://*.r2.cloudflarestorage.com`,
    `font-src 'self'`,
    `connect-src 'self' https://*.posthog.com https://*.sentry.io https://*.stripe.com`,
    `frame-src https://js.stripe.com`,
    `object-src 'none'`,
    `base-uri 'self'`,
    `form-action 'self'`,
  ].join('; ')
 
  const headers = new Headers(request.headers)
  headers.set('x-nonce', nonce)
 
  const response = NextResponse.next({ request: { headers } })
  response.headers.set('Content-Security-Policy', csp)
 
  return response
}

1.7 环境变量管理

// lib/env.ts
import { z } from 'zod'
 
const envSchema = z.object({
  // 数据库
  DATABASE_URL: z.string().url(),
 
  // 认证
  NEXTAUTH_SECRET: z.string().min(32),
  NEXTAUTH_URL: z.string().url(),
 
  // Stripe
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
 
  // 邮件
  RESEND_API_KEY: z.string().startsWith('re_'),
 
  // 存储
  S3_ENDPOINT: z.string().url(),
  S3_ACCESS_KEY: z.string(),
  S3_SECRET_KEY: z.string(),
  S3_BUCKET: z.string(),
 
  // 监控
  SENTRY_DSN: z.string().url().optional(),
  NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
 
  // Admin
  SUPER_ADMIN_EMAILS: z.string(),
})
 
// 启动时验证——缺少环境变量立即报错
export const env = envSchema.parse(process.env)

2. 灰度发布

2.1 发布策略

策略说明风险
全量发布一次性全部更新
灰度发布按比例/条件逐步放量
蓝绿部署两套环境切换低(成本高)
金丝雀发布先给少量用户最低

2.2 Vercel 灰度方案

// middleware.ts
export function middleware(request: NextRequest) {
  // 基于 Cookie 的灰度分流
  const bucket = request.cookies.get('ab-bucket')?.value
 
  if (!bucket) {
    // 首次访问,随机分配
    const newBucket = Math.random() < 0.1 ? 'canary' : 'stable'
    const response = NextResponse.next()
    response.cookies.set('ab-bucket', newBucket, { maxAge: 86400 * 30 })
 
    if (newBucket === 'canary') {
      // 重写到金丝雀部署
      return NextResponse.rewrite(new URL(request.url, process.env.CANARY_URL))
    }
    return response
  }
 
  if (bucket === 'canary') {
    return NextResponse.rewrite(new URL(request.url, process.env.CANARY_URL))
  }
 
  return NextResponse.next()
}

2.3 Feature Flag 灰度

// 基于 PostHog Feature Flag(见第 69 章)
const showNewDashboard = await isFeatureEnabled('new-dashboard', tenantId)
 
if (showNewDashboard) {
  return <NewDashboard />
}
return <OldDashboard />

3. 监控告警

3.1 监控层级

层级工具指标
应用错误Sentry错误率、未捕获异常
性能Vercel AnalyticsWeb Vitals、路由耗时
业务PostHog注册转化、付费转化
基础设施Vercel / ProviderCPU、内存、数据库连接数
可用性BetterUptime站点可达性

3.2 Sentry 生产配置

// sentry.server.config.ts
import * as Sentry from '@sentry/nextjs'
 
Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: 0.1,       // 生产 10% 采样
  profilesSampleRate: 0.1,
  beforeSend(event) {
    // 过滤已知的非关键错误
    if (event.exception?.values?.[0]?.type === 'AbortError') return null
    return event
  },
})

3.3 告警规则

指标阈值通知方式
错误率 > 1%5 分钟窗口Slack + 邮件
P95 延迟 > 3s10 分钟窗口Slack
支付失败任何一次邮件(立即)
站点不可达连续 2 次检查电话 + Slack
数据库连接数 > 80%实时Slack

3.4 健康检查端点

// app/api/health/route.ts
export async function GET() {
  try {
    await db.execute(sql`SELECT 1`)
 
    return Response.json({
      status: 'healthy',
      timestamp: new Date().toISOString(),
      version: process.env.NEXT_PUBLIC_APP_VERSION || 'unknown',
    })
  } catch (error) {
    return Response.json({ status: 'unhealthy', error: String(error) }, { status: 503 })
  }
}

3.5 结构化日志

// lib/logger.ts
type LogLevel = 'debug' | 'info' | 'warn' | 'error'
 
interface LogContext {
  userId?: string
  tenantId?: string
  requestId?: string
  [key: string]: any
}
 
function log(level: LogLevel, message: string, context?: LogContext) {
  const entry = {
    level,
    message,
    timestamp: new Date().toISOString(),
    ...context,
  }
 
  // 生产环境输出 JSON(便于日志聚合工具解析)
  if (process.env.NODE_ENV === 'production') {
    console[level === 'error' ? 'error' : 'log'](JSON.stringify(entry))
  } else {
    console[level === 'error' ? 'error' : 'log'](`[${level.toUpperCase()}] ${message}`, context)
  }
}
 
export const logger = {
  debug: (msg: string, ctx?: LogContext) => log('debug', msg, ctx),
  info: (msg: string, ctx?: LogContext) => log('info', msg, ctx),
  warn: (msg: string, ctx?: LogContext) => log('warn', msg, ctx),
  error: (msg: string, ctx?: LogContext) => log('error', msg, ctx),
}

3.6 Sentry 错误边界

// app/global-error.tsx
'use client'
 
import * as Sentry from '@sentry/nextjs'
import { useEffect } from 'react'
 
export default function GlobalError({
  error, reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    Sentry.captureException(error)
  }, [error])
 
  return (
    <html>
      <body>
        <div className="flex min-h-screen items-center justify-center">
          <div className="text-center">
            <h2 className="text-2xl font-bold">Something went wrong</h2>
            <p className="mt-2 text-muted-foreground">
              We've been notified and are working on a fix.
            </p>
            <button
              onClick={reset}
              className="mt-4 rounded bg-primary px-4 py-2 text-primary-foreground"
            >
              Try Again
            </button>
          </div>
        </div>
      </body>
    </html>
  )
}

3.7 自定义 404 页面

// app/not-found.tsx
import Link from 'next/link'
 
export default function NotFound() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="text-center">
        <h1 className="text-6xl font-bold">404</h1>
        <p className="mt-4 text-lg text-muted-foreground">Page not found</p>
        <Link href="/" className="mt-6 inline-block rounded bg-primary px-6 py-2 text-primary-foreground">
          Go Home
        </Link>
      </div>
    </div>
  )
}

4. 数据库生产配置

4.1 连接池

// lib/db/index.ts
import { drizzle } from 'drizzle-orm/neon-http'
import { neon } from '@neondatabase/serverless'
 
// Serverless 环境用 HTTP 连接(无连接池开销)
const sql = neon(process.env.DATABASE_URL!)
export const db = drizzle(sql)

4.2 迁移策略

阶段操作
开发drizzle-kit push 快速同步
预发布drizzle-kit generate 生成迁移文件
生产drizzle-kit migrate 执行迁移(CI/CD 中)

4.3 备份

策略频率保留期
自动快照每日7 天
WAL 归档持续30 天
手动快照大变更前永久

5. 上线流程

5.1 标准流程

1. 功能冻结(Code Freeze)
2. 跑完整测试套件
3. 生产环境迁移(数据库)
4. 灰度发布(10% 流量)
5. 监控 30 分钟
6. 全量发布
7. 验证核心流程(注册/登录/支付)
8. 更新 Status Page

5.2 CI/CD Pipeline

# .github/workflows/deploy.yml
name: Deploy to Production
on:
  push:
    branches: [main]
 
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npm run lint
      - run: npm run type-check
      - run: npm run test
 
  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Database Migrations
        run: npx drizzle-kit migrate
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
      - name: Deploy to Vercel
        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 }}
          vercel-args: '--prod'

5.3 数据库迁移安全

// 生产迁移原则
// 1. 只做加法(加列/加表),不做减法
// 2. 新列先 nullable,后续再改为 NOT NULL
// 3. 列重命名分两步:加新列 → 迁移数据 → 删旧列
// 4. 大表加索引用 CONCURRENTLY
 
// drizzle.config.ts
export default {
  schema: './lib/db/schema.ts',
  out: './drizzle',
  driver: 'pg',
  dbCredentials: {
    connectionString: process.env.DATABASE_URL!,
  },
}

5.4 回滚策略

情况操作时效
UI 问题Vercel 即时回滚到上一个部署< 1 分钟
数据库迁移问题执行回滚迁移脚本5-30 分钟
第三方服务故障Feature Flag 关闭相关功能< 1 分钟
严重安全漏洞立即回滚 + 通知用户< 5 分钟
数据损坏从备份恢复30-60 分钟

5.5 上线验证脚本

// scripts/verify-production.ts
async function verifyProduction() {
  const baseUrl = process.env.PRODUCTION_URL!
  const checks = [
    { name: 'Health Check', url: `${baseUrl}/api/health` },
    { name: 'Homepage', url: baseUrl },
    { name: 'Login Page', url: `${baseUrl}/login` },
    { name: 'Stripe Webhook', url: `${baseUrl}/api/webhooks/stripe` },
  ]
 
  for (const check of checks) {
    try {
      const res = await fetch(check.url, { method: check.name.includes('Webhook') ? 'POST' : 'GET' })
      const status = res.status
      const ok = check.name.includes('Webhook') ? status === 400 : status === 200
      console.log(`${ok ? '✅' : '❌'} ${check.name}: ${status}`)
    } catch (err) {
      console.log(`❌ ${check.name}: ${err}`)
    }
  }
}
 
verifyProduction()

6. SEO 与 Metadata

6.1 全局 Metadata

// app/layout.tsx
import type { Metadata } from 'next'
 
export const metadata: Metadata = {
  title: {
    default: 'YourSaaS - Project Management for Teams',
    template: '%s | YourSaaS',
  },
  description: 'The modern project management platform for growing teams.',
  metadataBase: new URL('https://yoursaas.com'),
  openGraph: {
    type: 'website',
    locale: 'en_US',
    siteName: 'YourSaaS',
    images: [{ url: '/og-image.png', width: 1200, height: 630 }],
  },
  twitter: {
    card: 'summary_large_image',
    creator: '@yoursaas',
  },
  robots: {
    index: true,
    follow: true,
  },
}

6.2 Sitemap 生成

// app/sitemap.ts
import type { MetadataRoute } from 'next'
 
export default function sitemap(): MetadataRoute.Sitemap {
  const staticPages = [
    '', '/pricing', '/features', '/about', '/blog',
    '/terms', '/privacy',
  ].map((path) => ({
    url: `https://yoursaas.com${path}`,
    lastModified: new Date(),
    changeFrequency: 'weekly' as const,
    priority: path === '' ? 1 : 0.8,
  }))
 
  return staticPages
}

6.3 Robots.txt

// app/robots.ts
import type { MetadataRoute } from 'next'
 
export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        disallow: ['/admin', '/api/', '/dashboard'],
      },
    ],
    sitemap: 'https://yoursaas.com/sitemap.xml',
  }
}

7. 性能优化

7.1 Bundle 分析

// package.json
{
  "scripts": {
    "analyze": "ANALYZE=true next build"
  }
}
// next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer'
 
const nextConfig = withBundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
})({
  // ...其他配置
})

7.2 关键性能优化

优化项实现方式效果
动态导入next/dynamic 懒加载重组件首屏 JS 减少
字体优化next/font 预加载消除 FOIT
图片优化next/image 自动 WebP + 懒加载LCP 改善
Server Components默认 RSC,减少客户端 JSBundle 减小
路由预取&lt;Link&gt; 自动预取导航秒开
数据库查询Promise.all 并行查询TTFB 减少

7.3 缓存策略

// 静态页面缓存(ISR)
export const revalidate = 3600  // 1 小时
 
// 动态数据缓存
import { unstable_cache } from 'next/cache'
 
export const getCachedPricing = unstable_cache(
  async () => {
    return db.select().from(plans).where(eq(plans.active, true))
  },
  ['pricing-plans'],
  { revalidate: 3600, tags: ['pricing'] }
)
 
// 手动失效
import { revalidateTag } from 'next/cache'
revalidateTag('pricing')

本章小结

  • 安全清单:HTTPS、CSP(nonce)、Rate Limiting、Cookie 安全、日志脱敏
  • 环境变量:Zod schema 启动时验证,缺少变量立即报错
  • 性能清单:Lighthouse ≥ 90、LCP < 2.5s、首页 JS < 100KB
  • 灰度发布:Middleware Cookie 分流 / Feature Flag 按租户发布
  • 监控告警:Sentry 错误 + Vercel 性能 + BetterUptime 可用性 + 结构化日志
  • 错误处理:全局 error.tsx 自动上报 Sentry,自定义 404 页面
  • CI/CD:GitHub Actions → lint + type-check + test → 迁移 → Vercel 部署
  • 迁移安全:只做加法、先 nullable 再 NOT NULL、大表 CONCURRENTLY
  • 回滚:Vercel 秒级回滚 + 数据库回滚脚本 + Feature Flag Kill Switch
  • SEO:Metadata API + sitemap.ts + robots.ts + Open Graph
  • 性能优化:Bundle Analyzer + 动态导入 + ISR 缓存 + unstable_cache