第 25 章

部署方案:Vercel、Docker 与自托管

第25章:部署方案:Vercel、Docker 与自托管

Vercel 是最零摩擦的方案但成本随规模上升;Docker 有完全控制权但运维负担在你这里。理解各方案的内部机制是做出正确选择的基础。

本章核心问题:Vercel 的 Edge vs Serverless 函数有何区别?Docker 多阶段构建如何优化镜像?Nginx 反向代理需要注意什么?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

部署的本质问题

把一个 Next.js 应用部署到生产,核心问题是:你的代码需要一个运行环境,这个环境要能持续服务请求、处理动静态资源、管理环境变量、支持扩容。不同的部署方案在这些维度上有不同的权衡:Vercel 是最零摩擦的,但成本随规模上升显著;Docker 自托管有完全控制权,但运维负担在你这里;中间方案如 Railway、Fly.io 提供了折中。

理解各方案的内部机制是做出正确选择的基础。

Vercel 部署

零配置部署

Vercel 是 Next.js 的创建者设计的托管平台,对 Next.js 的支持是原生级别的。连接 GitHub 仓库、点击部署,Vercel 自动检测 Next.js 项目、运行 next build、部署产物。

npm install -g vercel
vercel deploy

每次 push 到非主分支都会创建预览部署(Preview Deployment),有独立 URL,团队可以在合并前审查功能。主分支 push 触发生产部署。

函数类型:Edge vs Serverless

Vercel 上的 Next.js 函数默认以 Serverless Functions 运行(Lambda 模型),可以按路由指定运行时:

// 在 Route Handler 或 Page 顶部指定
export const runtime = 'edge' // 或 'nodejs'(默认)
Edge Functions Serverless Functions
冷启动 ~0ms(V8 isolate) 100-500ms
运行时 Edge Runtime(有限 API) 完整 Node.js
执行限制 25ms(CPU time) 最长 60s
全球分布 否(单区域)

Edge Functions 适合:认证中间件、简单的路由重写、地理定向。Serverless Functions 适合:数据库查询、文件处理、任何需要 Node.js native 模块的操作。

环境变量管理

Vercel 的环境变量通过 Dashboard 管理,分三个环境:Production、Preview、Development。

# .env.local(本地开发,不提交到 Git)
DATABASE_URL=postgresql://localhost:5432/myapp
NEXTAUTH_SECRET=dev-secret-change-me

# Vercel Dashboard 中设置
DATABASE_URL=postgresql://prod-host:5432/myapp
NEXTAUTH_SECRET=production-secret-32-chars-min

NEXT_PUBLIC_ 前缀的变量会被打包进客户端代码,其他变量只在服务端可用。永远不要把密钥放进 NEXT_PUBLIC_ 变量。

健康检查端点

生产服务应该有健康检查端点,供负载均衡器和监控系统验证服务存活:

// app/api/health/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'

export async function GET() {
  try {
    // 检查数据库连接
    await prisma.$queryRaw`SELECT 1`

    return NextResponse.json({
      status: 'ok',
      timestamp: new Date().toISOString(),
      version: process.env.npm_package_version,
    })
  } catch (error) {
    return NextResponse.json(
      { status: 'error', error: String(error) },
      { status: 503 }
    )
  }
}

Level 2 · 它是怎么运行的(3-5年经验)

Docker 部署

Standalone 输出模式

Next.js 提供了专门为容器化设计的 standalone 输出模式。开启后,next build 会生成一个 .next/standalone 目录,其中包含应用所需的最小 Node.js 代码(通过静态分析 node_modules 依赖树,只复制实际用到的模块),可以直接用 node server.js 启动,无需安装依赖。

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  output: 'standalone',
}

export default nextConfig

这会将镜像大小从通常的 800MB+ 减小到 100-200MB(取决于依赖数量)。

多阶段 Dockerfile

多阶段构建是 Docker 镜像优化的标准做法:构建阶段使用完整工具链,最终镜像只包含运行所需的文件:

# Dockerfile
FROM node:22-alpine AS base

# 1. 安装依赖
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

# 优先复制包管理文件,利用 Docker 层缓存
COPY package.json package-lock.json* ./
COPY prisma ./prisma/

# 安装所有依赖(包括 devDependencies,构建需要)
RUN npm ci

# 2. 构建应用
FROM base AS builder
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

# 生成 Prisma Client
RUN npx prisma generate

# 构建时可能需要 NEXT_PUBLIC_ 变量(它们会被编译进客户端代码)
ARG NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL

ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

# 3. 最终运行镜像
FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

# 创建非 root 用户(安全最佳实践)
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# 复制静态文件
COPY --from=builder /app/public ./public

# 复制 standalone 产物
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]

几个关键点:

FROM node:22-alpine:Alpine 基础镜像比 Debian 小 5 倍以上。libc6-compat 是某些 npm 包(如 sharp)的 glibc 兼容层。

多阶段分离deps 阶段安装依赖,builder 阶段构建,runner 阶段只包含运行产物。最终镜像里没有 node_modules(standalone 已内联了需要的依赖)、没有源码、没有构建工具。

非 root 用户:容器以 nextjs 用户运行,不是 root。这是容器安全的基本原则——如果应用被攻破,攻击者拿到的权限有限。

--chown=nextjs:nodejs:standalone 目录归 nextjs 用户所有,否则 USER nextjs 切换后读不到文件。

构建与运行

# 构建镜像(传入构建时变量)
docker build \
  --build-arg NEXT_PUBLIC_APP_URL=https://example.com \
  -t myapp:latest .

# 运行容器(传入运行时环境变量)
docker run -d \
  -p 3000:3000 \
  --env-file .env.production \
  --name myapp \
  myapp:latest

# 查看日志
docker logs -f myapp

# 健康检查
curl http://localhost:3000/api/health

环境变量区分构建时(ARG)和运行时(ENV/--env-file):NEXT_PUBLIC_ 变量必须在构建时传入(它们被编译进 JavaScript bundle),其他服务端变量在运行时传入,不会暴露在镜像里。

Docker Compose

本地开发和单机部署可以用 Docker Compose 编排:

# docker-compose.yml
version: '3.8'

services:
  app:
    build:
      context: .
      args:
        NEXT_PUBLIC_APP_URL: http://localhost:3000
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://postgres:password@db:5432/myapp
      - NEXTAUTH_URL=http://localhost:3000
      - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      - app

volumes:
  postgres_data:

Level 3 · 规范怎么定义的(资深)

Nginx 反向代理配置

在自托管场景,Nginx 通常作为反向代理坐在 Next.js 前面,处理 TLS 终止、压缩、静态文件缓存:

# nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;

events {
  worker_connections 1024;
}

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

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

  upstream nextjs {
    server app:3000;
    keepalive 32;
  }

  server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
  }

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

    ssl_certificate /etc/nginx/certs/cert.pem;
    ssl_certificate_key /etc/nginx/certs/key.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    # 安全响应头
    add_header X-Frame-Options DENY;
    add_header X-Content-Type-Options nosniff;
    add_header X-XSS-Protection "1; mode=block";
    add_header Referrer-Policy strict-origin-when-cross-origin;

    # Next.js 静态文件:长期缓存(内容哈希保证更新后自动失效)
    location /_next/static/ {
      proxy_pass http://nextjs;
      proxy_cache_valid 200 365d;
      add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # Next.js 图片优化端点
    location /_next/image {
      proxy_pass http://nextjs;
      proxy_cache_valid 200 1d;
    }

    # SSE 路由:禁用缓冲(否则事件无法实时推送到客户端)
    location /api/events {
      proxy_pass http://nextjs;
      proxy_http_version 1.1;
      proxy_set_header Connection '';
      proxy_buffering off;
      proxy_cache off;
      chunked_transfer_encoding on;
    }

    # 其他所有请求
    location / {
      proxy_pass http://nextjs;
      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;
    }
  }
}

SSE 路由需要特殊处理:proxy_buffering off 确保 Nginx 不缓冲来自 Next.js 的响应,Connection '' 配合 proxy_http_version 1.1 启用 keepalive,这样事件流才能实时送达客户端。

PM2 vs Docker:进程管理选择

如果不用 Docker,直接在 VPS 上部署,PM2 是管理 Node.js 进程的工具:

npm install -g pm2

# ecosystem.config.js
module.exports = {
  apps: [{
    name: 'nextjs',
    script: '.next/standalone/server.js',
    instances: 'max',        // 利用所有 CPU 核心
    exec_mode: 'cluster',    // 集群模式,自动负载均衡
    env_production: {
      NODE_ENV: 'production',
      PORT: 3000,
    },
    error_file: 'logs/err.log',
    out_file: 'logs/out.log',
    max_memory_restart: '1G', // 内存超限自动重启
  }],
}

pm2 start ecosystem.config.js --env production
pm2 save            # 保存进程列表
pm2 startup         # 配置系统服务,开机自启

Docker 的优势是环境完全隔离(不依赖服务器上的 Node.js 版本)、更易于横向扩展(Kubernetes/Docker Swarm)、回滚更可靠(拉老镜像重启)。PM2 的优势是更轻量(不需要容器运行时)、调试更直接、适合单机小规模部署。

数据库迁移策略

生产部署时,数据库迁移需要在应用启动前运行:

# Dockerfile 中,或部署脚本中
# 运行迁移,然后启动应用
CMD ["sh", "-c", "npx prisma migrate deploy && node server.js"]

或者通过 Docker Compose 的 command 覆盖:

app:
  command: sh -c "npx prisma migrate deploy && node server.js"

prisma migrate deploy 只执行待处理的迁移,不会重新执行已完成的。在 CI/CD 流水线里,也可以把迁移作为独立步骤运行,早于应用部署,这样迁移失败时应用不会上线。

选择部署方案没有唯一正确答案。Vercel 在早期和中期规模下是最优选择,省去了大量运维工作;Docker 在需要完全控制或有特定合规要求(数据不能出境、特定云商)时不可替代。理解每种方案的机制,是在需要时能快速切换的保障。

Level 4 · 边界与陷阱(所有人)

陷阱1:NEXT_PUBLIC_ 前缀的变量会被打包进客户端代码——永远不要把密钥放进 NEXT_PUBLIC_ 变量。

陷阱2:standalone 输出的 .next/static 目录需要单独复制——它不包含在 standalone 目录中。

陷阱3:SSE 路由需要 Nginx 特殊配置:proxy_buffering off + Connection ''——否则事件流会被缓冲,无法实时送达。

本章评分
4.7  / 5  (4 评分)

💬 留言讨论