第 21 章:Docker 部署与 CI/CD 流水线

构建 Next.js 的完整部署流水线——从 Docker 多阶段构建优化、GitHub Actions 自动化、Vercel 一键部署,到 Kubernetes 编排、Helm Chart 与蓝绿部署。

本章目标:掌握 Next.js 生产部署的完整技术栈——理解 Standalone Output 模式、构建优化后的 Docker 镜像、配置 GitHub Actions 自动化流水线,并了解 Vercel / Docker / Kubernetes 多种部署方案的取舍。


21.1 部署方案对比

主流部署平台

平台类型适用场景复杂度成本
VercelServerless快速上线、Next.js 官方推荐按用量付费
NetlifyServerless静态站点为主免费层 + 付费
Docker 自托管容器私有部署、合规要求服务器成本
Kubernetes容器编排大规模、高可用集群成本
Cloudflare PagesEdge全球加速、低延迟免费层优厚
RenderPaaS全栈应用月付固定价
RailwayPaaS快速部署 + 数据库按用量
AWS ECS容器服务AWS 生态按用量

选型决策树

你的需求...
├── 快速上线 + Next.js 最佳体验 → ✅ Vercel
├── 数据合规 / 私有部署 → ✅ Docker 自托管
├── 全球边缘加速 → ✅ Cloudflare Pages / Vercel Edge
├── 已有 K8s 集群 → ✅ Kubernetes + Helm
├── 预算有限 + 全栈应用 → ✅ Render / Railway
└── 已有 AWS / GCP / Azure → 对应云服务的容器方案

21.2 Next.js Standalone Output

启用 Standalone 模式

// next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',  // 启用独立部署包

  // 可选:自定义 server 配置
  // serverExternalPackages: ['sharp', 'bcrypt'],
};

module.exports = nextConfig;

Standalone 输出结构

npm run build
.next/
├── standalone/           # 独立部署包(仅包含必要依赖)
│   ├── node_modules/     # 精简的 node_modules
│   ├── public/           # 静态资源
│   ├── .next/            # 构建产物
│   └── server.js         # 启动脚本
├── static/               # 静态文件(需要复制到 standalone)
└── cache/                # 构建缓存

为什么使用 Standalone?

传统部署:
  - 需要完整 node_modules(通常 200-500MB)
  - 启动慢、镜像大
  - 依赖 npm install

Standalone 部署:
  - 仅包含运行必需的依赖(通常 30-80MB)
  - 启动快、镜像小
  - 不需要 npm install
  - 适合 Docker、Serverless

21.3 Docker 多阶段构建

完整 Dockerfile

# Dockerfile

# ============ 阶段 1:依赖安装 ============
FROM node:20-alpine AS base
RUN apk add --no-cache libc6-compat
WORKDIR /app

# ============ 阶段 2:安装依赖 ============
FROM base AS deps
COPY package.json package-lock.json* ./
COPY prisma ./prisma/

# 安装生产依赖(用于生成 Prisma Client)
RUN npm ci --omit=dev

# 生成 Prisma Client
RUN npx prisma generate

# 重新安装所有依赖(包括 dev,用于构建)
RUN npm ci

# ============ 阶段 3:构建 ============
FROM base AS builder

# 复制依赖和 Prisma Client
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/.prisma ./.prisma

COPY . .

# 设置构建时环境变量
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production

# 构建 Next.js 应用
RUN npm run build

# ============ 阶段 4:生产运行 ============
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

# 复制 Prisma 相关文件(用于运行时迁移)
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma

# 切换到非 root 用户
USER nextjs

# 暴露端口
EXPOSE 3000

# 配置环境变量
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"

# 启动命令(包含数据库迁移)
CMD ["sh", "-c", "npx prisma migrate deploy && node server.js"]

.dockerignore

# .dockerignore

Dockerfile
.dockerignore
node_modules
npm-debug.log
.next
.git
.gitignore
README.md
.env
.env.local
.env.*.local
.vscode
.idea
coverage
playwright-report
test-results
*.md
!README.md

构建与运行

# 构建镜像
docker build -t my-blog:latest .

# 查看镜像大小
docker images my-blog
# REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
# my-blog      latest    abc123         2 minutes ago    180MB

# 运行容器
docker run -d \
  -p 3000:3000 \
  --env-file .env.production \
  --name my-blog \
  my-blog:latest

# 查看日志
docker logs -f my-blog

# 停止容器
docker stop my-blog
docker rm my-blog

多阶段构建优化

优化技巧:

1. 使用 Alpine 基础镜像(5MB vs 900MB)
2. 多阶段构建(构建阶段 + 运行阶段分离)
3. 复制 standalone 而非完整 node_modules
4. 使用 npm ci 而非 npm install
5. 缓存 Prisma Client 生成
6. 非 root 用户运行(安全)
7. 健康检查端点(监控)

镜像大小对比:
- 传统方式:~800MB
- Standalone + Alpine:~150-200MB
- 进一步压缩(distroless):~120MB

21.4 Docker Compose 本地开发

完整开发环境

# docker-compose.yml

version: '3.9'

services:
  # Next.js 应用
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - '3000:3000'
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://postgres:postgres@db:5432/myblog
      - REDIS_URL=redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    restart: unless-stopped
    networks:
      - app-network

  # PostgreSQL 数据库
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: myblog
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - app-network

  # Redis 缓存
  redis:
    image: redis:7-alpine
    ports:
      - '6379:6379'
    volumes:
      - redis_data:/data
    networks:
      - app-network

  # Nginx 反向代理(可选)
  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
    networks:
      - app-network

volumes:
  postgres_data:
  redis_data:

networks:
  app-network:
    driver: bridge

开发环境 Docker Compose

# docker-compose.dev.yml

version: '3.9'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - '3000:3000'
    environment:
      - NODE_ENV=development
    volumes:
      - .:/app
      - /app/node_modules
      - /app/.next
    command: npm run dev
    depends_on:
      - db
      - redis

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: myblog_dev
    ports:
      - '5432:5432'
    volumes:
      - postgres_dev_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    ports:
      - '6379:6379'

volumes:
  postgres_dev_data:

使用方式

# 生产环境
docker-compose up -d
docker-compose down
docker-compose logs -f app

# 开发环境
docker-compose -f docker-compose.dev.yml up
docker-compose -f docker-compose.dev.yml down

21.5 GitHub Actions CI/CD

完整 CI/CD 流水线

# .github/workflows/ci.yml

name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  # ============ 代码质量检查 ============
  lint:
    name: Lint & Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci

      - name: ESLint
        run: npm run lint

      - name: TypeScript
        run: npm run type-check

      - name: Prettier
        run: npm run format:check

  # ============ 单元测试 ============
  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npm run test:run

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage/lcov.info
          token: ${{ secrets.CODECOV_TOKEN }}

  # ============ E2E 测试 ============
  e2e:
    name: E2E Tests
    runs-on: ubuntu-latest
    needs: [lint, test]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx playwright install --with-deps chromium

      - name: Start PostgreSQL
        run: |
          docker run -d \
            --name postgres \
            -e POSTGRES_USER=postgres \
            -e POSTGRES_PASSWORD=postgres \
            -e POSTGRES_DB=test_db \
            -p 5432:5432 \
            postgres:16-alpine

      - name: Wait for PostgreSQL
        run: |
          until pg_isready -h localhost -p 5432 -U postgres; do
            sleep 2
          done

      - name: Run migrations
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
        run: npx prisma migrate deploy

      - name: Seed test data
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
        run: npx prisma db seed

      - name: Run E2E tests
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
          NEXTAUTH_SECRET: test-secret
          NEXTAUTH_URL: http://localhost:3000
        run: npm run test:e2e

      - name: Upload test artifacts
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

  # ============ 构建检查 ============
  build:
    name: Build
    runs-on: ubuntu-latest
    needs: [lint, test]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci

      - name: Generate Prisma Client
        run: npx prisma generate

      - name: Build Next.js
        env:
          NEXT_TELEMETRY_DISABLED: 1
        run: npm run build

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: next-build
          path: |
            .next/standalone
            .next/static
            public
          retention-days: 1

CD 流水线(部署到 Docker Registry)

# .github/workflows/deploy.yml

name: Deploy

on:
  push:
    branches: [main]
    tags: ['v*']

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  # ============ 构建并推送 Docker 镜像 ============
  build-and-push:
    name: Build & Push Docker Image
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha,prefix=

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          platforms: linux/amd64,linux/arm64

  # ============ 部署到生产环境 ============
  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: build-and-push
    if: startsWith(github.ref, 'refs/tags/v')
    environment:
      name: production
      url: https://example.com

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ secrets.PRODUCTION_HOST }}
          username: ${{ secrets.PRODUCTION_USER }}
          key: ${{ secrets.PRODUCTION_SSH_KEY }}
          script: |
            cd /opt/my-blog
            docker-compose pull
            docker-compose up -d --remove-orphans
            docker image prune -f

      - name: Verify deployment
        run: |
          sleep 10
          curl -f https://example.com/api/health || exit 1

      - name: Notify Slack
        if: always()
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          text: 'Production deployment ${{ job.status }}'
          webhook_url: ${{ secrets.SLACK_WEBHOOK }}

部署到 Vercel

# .github/workflows/deploy-vercel.yml

name: Deploy to Vercel

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install Vercel CLI
        run: npm install -g vercel

      - name: Pull Vercel Environment
        run: vercel pull --yes --environment=${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} --token=${{ secrets.VERCEL_TOKEN }}

      - name: Build
        run: vercel build ${{ github.ref == 'refs/heads/main' && '--prod' || '' }} --token=${{ secrets.VERCEL_TOKEN }}

      - name: Deploy
        id: deploy
        run: |
          url=$(vercel deploy --prebuilt ${{ github.ref == 'refs/heads/main' && '--prod' || '' }} --token=${{ secrets.VERCEL_TOKEN }})
          echo "url=$url" >> $GITHUB_OUTPUT

      - name: Comment PR
        if: github.event_name == 'pull_request'
        uses: thollander/actions-comment-pull-request@v2
        with:
          message: |
            🚀 Preview deployed to ${{ steps.deploy.outputs.url }}

21.6 Nginx 反向代理配置

基础配置

# nginx.conf

upstream nextjs_app {
    server app:3000;
    keepalive 64;
}

# HTTP 重定向到 HTTPS
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

# HTTPS 主配置
server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    # SSL 证书
    ssl_certificate /etc/nginx/ssl/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
    ssl_prefer_server_ciphers off;

    # SSL 会话缓存
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    # HSTS
    add_header Strict-Transport-Security "max-age=63072000" always;

    # Gzip 压缩
    gzip on;
    gzip_vary on;
    gzip_comp_level 6;
    gzip_types
        text/plain
        text/css
        text/xml
        text/javascript
        application/json
        application/javascript
        application/xml
        application/rss+xml
        image/svg+xml;

    # 静态资源(Next.js 构建产物,永久缓存)
    location /_next/static/ {
        proxy_pass http://nextjs_app;
        proxy_cache_valid 200 365d;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # 公共资源(手动管理的静态文件)
    location /public/ {
        proxy_pass http://nextjs_app;
        proxy_cache_valid 200 1d;
        add_header Cache-Control "public, max-age=86400";
    }

    # API 路由(不缓存)
    location /api/ {
        proxy_pass http://nextjs_app;
        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;
        add_header Cache-Control "no-store";
    }

    # 默认路由(Next.js 处理)
    location / {
        proxy_pass http://nextjs_app;
        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 /api/health {
        proxy_pass http://nextjs_app;
        access_log off;
    }

    # 阻止访问敏感文件
    location ~ /\. {
        deny all;
    }

    location ~* \.(env|git|gitignore|dockerignore)$ {
        deny all;
    }
}

Let’s Encrypt SSL

# 安装 Certbot
apt-get install certbot python3-certbot-nginx

# 申请证书
certbot --nginx -d example.com -d www.example.com

# 自动续期
certbot renew --dry-run

21.7 Kubernetes 部署

Deployment 配置

# k8s/deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-blog
  labels:
    app: my-blog
spec:
  replicas: 3
  selector:
    matchLabels:
      app: my-blog
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: my-blog
    spec:
      containers:
        - name: my-blog
          image: ghcr.io/username/my-blog:latest
          imagePullPolicy: Always
          ports:
            - containerPort: 3000
              name: http
          env:
            - name: NODE_ENV
              value: production
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: my-blog-secrets
                  key: database-url
            - name: NEXTAUTH_SECRET
              valueFrom:
                secretKeyRef:
                  name: my-blog-secrets
                  key: nextauth-secret
          resources:
            requests:
              memory: '256Mi'
              cpu: '250m'
            limits:
              memory: '512Mi'
              cpu: '500m'
          livenessProbe:
            httpGet:
              path: /api/health
              port: 3000
            initialDelaySeconds: 30
            periodSeconds: 30
            timeoutSeconds: 10
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /api/health
              port: 3000
            initialDelaySeconds: 10
            periodSeconds: 10
            timeoutSeconds: 5
            failureThreshold: 3
      imagePullSecrets:
        - name: ghcr-secret

Service 配置

# k8s/service.yaml

apiVersion: v1
kind: Service
metadata:
  name: my-blog
  labels:
    app: my-blog
spec:
  type: ClusterIP
  ports:
    - port: 80
      targetPort: 3000
      protocol: TCP
      name: http
  selector:
    app: my-blog

Ingress 配置

# k8s/ingress.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-blog
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/proxy-body-size: '50m'
    nginx.ingress.kubernetes.io/ssl-redirect: 'true'
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - example.com
        - www.example.com
      secretName: my-blog-tls
  rules:
    - host: example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: my-blog
                port:
                  number: 80
    - host: www.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: my-blog
                port:
                  number: 80

Helm Chart(可选)

# helm/my-blog/values.yaml

replicaCount: 3

image:
  repository: ghcr.io/username/my-blog
  tag: latest
  pullPolicy: Always

service:
  type: ClusterIP
  port: 80

ingress:
  enabled: true
  className: nginx
  hosts:
    - host: example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: my-blog-tls
      hosts:
        - example.com

resources:
  limits:
    cpu: 500m
    memory: 512Mi
  requests:
    cpu: 250m
    memory: 256Mi

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

env:
  NODE_ENV: production

secrets:
  database-url: ""
  nextauth-secret: ""

部署命令

# 创建命名空间
kubectl create namespace my-blog

# 创建 Secret
kubectl create secret generic my-blog-secrets \
  --from-literal=database-url="postgresql://..." \
  --from-literal=nextauth-secret="your-secret" \
  -n my-blog

# 应用配置
kubectl apply -f k8s/ -n my-blog

# 或使用 Helm
helm install my-blog ./helm/my-blog -n my-blog

# 查看状态
kubectl get pods -n my-blog
kubectl get svc -n my-blog
kubectl get ingress -n my-blog

# 查看日志
kubectl logs -f deployment/my-blog -n my-blog

# 滚动更新
kubectl set image deployment/my-blog my-blog=ghcr.io/username/my-blog:v2 -n my-blog

21.8 蓝绿部署与金丝雀发布

蓝绿部署脚本

#!/bin/bash
# scripts/blue-green-deploy.sh

set -e

BLUE_DEPLOYMENT="my-blog-blue"
GREEN_DEPLOYMENT="my-blog-green"
SERVICE="my-blog"
NEW_IMAGE=$1

# 检查当前活跃的颜色
ACTIVE_COLOR=$(kubectl get svc $SERVICE -o jsonpath='{.spec.selector.color}')

if [ "$ACTIVE_COLOR" == "blue" ]; then
  NEW_COLOR="green"
  NEW_DEPLOYMENT=$GREEN_DEPLOYMENT
else
  NEW_COLOR="blue"
  NEW_DEPLOYMENT=$BLUE_DEPLOYMENT
fi

echo "Active color: $ACTIVE_COLOR"
echo "Deploying to: $NEW_COLOR"

# 更新非活跃 deployment 的镜像
kubectl set image deployment/$NEW_DEPLOYMENT my-blog=$NEW_IMAGE

# 等待新 deployment 就绪
kubectl rollout status deployment/$NEW_DEPLOYMENT

# 健康检查
echo "Running health check on new deployment..."
kubectl exec deployment/$NEW_DEPLOYMENT -- curl -f http://localhost:3000/api/health

# 切换流量
echo "Switching traffic to $NEW_COLOR..."
kubectl patch svc $SERVICE -p "{\"spec\":{\"selector\":{\"color\":\"$NEW_COLOR\"}}}"

echo "Deployment complete! $NEW_COLOR is now active."
echo "To rollback, run: kubectl patch svc $SERVICE -p '{\"spec\":{\"selector\":{\"color\":\"$ACTIVE_COLOR\"}}}'"

金丝雀发布(Argo Rollouts)

# k8s/rollout.yaml

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: my-blog
spec:
  replicas: 10
  strategy:
    canary:
      steps:
        - setWeight: 10       # 10% 流量到新镜像
        - pause: { duration: 5m }  # 观察 5 分钟
        - setWeight: 30       # 30% 流量
        - pause: { duration: 5m }
        - setWeight: 60       # 60% 流量
        - pause: { duration: 5m }
        - setWeight: 100      # 100% 流量
      analysis:
        templates:
          - templateName: success-rate
        startingStep: 1
  selector:
    matchLabels:
      app: my-blog
  template:
    metadata:
      labels:
        app: my-blog
    spec:
      containers:
        - name: my-blog
          image: ghcr.io/username/my-blog:latest
          ports:
            - containerPort: 3000

21.9 环境变量管理

环境分类

# .env.example(示例,提交到 git)
DATABASE_URL="postgresql://user:pass@localhost:5432/myblog"
NEXTAUTH_SECRET="your-secret-here"
NEXTAUTH_URL="http://localhost:3000"

# .env.local(本地开发,不提交)
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myblog_dev"
NEXTAUTH_SECRET="dev-secret"

# .env.production(生产环境,不提交)
DATABASE_URL="postgresql://prod_user:prod_pass@db.example.com:5432/myblog"
NEXTAUTH_SECRET="prod-secret-very-long-and-random"
NEXTAUTH_URL="https://example.com"

使用 GitHub Secrets

# .github/workflows/deploy.yml

env:
  DATABASE_URL: ${{ secrets.DATABASE_URL }}
  NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
  SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

使用 Vault(生产环境)

# 安装 Vault CLI
brew install vault

# 登录
vault login

# 读取 secrets
export DATABASE_URL=$(vault kv get -field=url secret/my-blog/database)
export NEXTAUTH_SECRET=$(vault kv get -field=secret secret/my-blog/auth)

# Kubernetes 集成
kubectl create secret generic my-blog-secrets \
  --from-literal=database-url="$DATABASE_URL" \
  --from-literal=nextauth-secret="$NEXTAUTH_SECRET"

21.10 部署检查清单

## 生产部署检查清单

### Docker 镜像
- [ ] 使用 multi-stage build 优化镜像大小
- [ ] 镜像大小 < 300MB
- [ ] 使用 Alpine 或 Distroless 基础镜像
- [ ] 非 root 用户运行
- [ ] 配置了 HEALTHCHECK

### 安全
- [ ] 敏感信息使用 Secrets(不写入代码)
- [ ] 环境变量未泄露到 git
- [ ] HTTPS 已启用
- [ ] Nginx 隐藏了 server 头
- [ ] 依赖已扫描(npm audit、Trivy)

### 性能
- [ ] Standalone output 已启用
- [ ] Gzip / Brotli 压缩已开启
- [ ] 静态资源配置了长期缓存
- [ ] 数据库连接池已配置
- [ ] CDN 已配置(Cloudflare / CloudFront)

### 监控
- [ ] /api/health 端点可访问
- [ ] 日志已收集(Loki / CloudWatch)
- [ ] 错误监控已集成(Sentry)
- [ ] Uptime 监控已配置
- [ ] 告警已设置

### 备份
- [ ] 数据库自动备份(每日)
- [ ] 备份已测试恢复
- [ ] 上传文件有备份(S3 / R2)

### 扩展性
- [ ] 支持水平扩展(无状态)
- [ ] Session 存储在 Redis(而非内存)
- [ ] 文件上传使用外部存储
- [ ] 配置了 auto-scaling(如适用)

### 文档
- [ ] README 包含部署说明
- [ ] 环境变量已文档化
- [ ] Runbook(故障处理手册)已准备
- [ ] 团队成员知晓部署流程

本章小结

Key Takeaways

  1. Standalone Output 是 Docker 部署的前提:镜像更小、启动更快
  2. 多阶段构建是 Docker 优化的核心:构建阶段 + 运行阶段分离
  3. GitHub Actions 是 CI/CD 的标准方案:自动化测试、构建、部署
  4. 部署方案选择取决于业务需求:Vercel 适合快速、Docker 适合合规、K8s 适合大规模
  5. 蓝绿部署和金丝雀发布降低发布风险:渐进式发布、快速回滚
  6. 环境变量管理是安全的底线:使用 Secrets、Vault,永不写入代码

下一步

下一章(也是本系列最后一章)我们将深入 多租户 SaaS 架构实战——构建一个完整的多租户博客系统,涵盖租户隔离、计费、权限管理与自定义域名。


参考资料

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页