部署方案:Vercel、Docker 与自托管
第25章:部署方案:Vercel、Docker 与自托管
Vercel 是最零摩擦的方案但成本随规模上升;Docker 有完全控制权但运维负担在你这里。理解各方案的内部机制是做出正确选择的基础。
本章核心问题:Vercel 的 Edge vs Serverless 函数有何区别?Docker 多阶段构建如何优化镜像?Nginx 反向代理需要注意什么?
读完本章你将理解:
- output: 'standalone' 将镜像从 800MB+ 缩小到 100-200MB 的原理
- 多阶段 Dockerfile 的安全最佳实践(非 root 用户、构建时/运行时变量分离)
- Nginx SSE 路由的 proxy_buffering off 配置
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 ''——否则事件流会被缓冲,无法实时送达。