全栈独立产品部署架构:从 Docker Compose 到 K8s 的演进路径

一、引言:部署架构跟随产品成长

独立产品的部署架构不是一成不变的,它需要随产品阶段持续演进。从 MVP 阶段的一台 VPS 手动部署,到成长期的多服务编排,再到规模化阶段的容器编排,每个阶段都有最佳的部署方案。

Docker Compose 是独立产品的起步之选:一个 YAML 文件定义所有服务,一条命令完成启动。但当产品用户增长、团队扩大、服务拆分后,Docker Compose 的单机部署模式就会成为瓶颈。Kubernetes(K8s)提供了服务发现、自动扩缩容、滚动更新、健康检查等生产级能力,但复杂度也呈指数级上升。

架构演进的智慧不在于一步到位,而在于在合适的阶段引入合适的复杂度。本文梳理从 Docker Compose 到 K8s 的完整演进路径,帮助独立产品在正确的时机做出正确的部署决策。

二、核心方案:三阶段的演进路径

2.1 演进路线图

graph LR
    A[阶段一:单机 Docker Compose] -->|用户量 > 1000,日活稳定增长| B[阶段二:多机 Swarm/Nomad]
    B -->|用户量 > 10000,需要弹性伸缩| C[阶段三:Kubernetes 集群]
    
    subgraph 阶段一
        A1[Nginx 反向代理]
        A2[Node.js 应用]
        A3[PostgreSQL]
        A4[Redis]
    end
    
    subgraph 阶段二
        B1[多节点编排]
        B2[服务发现]
        B3[滚动更新]
        B4[基础监控]
    end
    
    subgraph 阶段三
        C1[自动扩缩容]
        C2[服务网格]
        C3[GitOps]
        C4[全链路监控]
    end

2.2 阶段决策依据

维度 Docker Compose 容器编排 Kubernetes
适用用户量 < 1000 1000 - 10000 > 10000
服务数量 1-5 5-15 15+
团队规模 1-3人 3-10人 10+人
部署复杂度
运维成本
弹性伸缩 手动 半自动 自动

三、实战实现:逐阶段的部署方案

3.1 阶段一:Docker Compose 生产化

# docker-compose.yml - 生产就绪的单机部署
version: '3.8'

x-common-env: &common-env
  NODE_ENV: production
  TZ: Asia/Shanghai

services:
  # Nginx 反向代理 + 静态资源
  nginx:
    image: nginx:1.25-alpine
    container_name: app-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./certbot/conf:/etc/letsencrypt:ro
      - ./certbot/www:/var/www/certbot:ro
      - app-static:/app/public:ro
    networks:
      - app-network
    depends_on:
      - app
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

  # Node.js 应用
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: production
    container_name: app-server
    restart: unless-stopped
    <<: *common-env
    environment:
      <<: *common-env
      DATABASE_URL: postgresql://${DB_USER}:${DB_PASS}@postgres:5432/${DB_NAME}
      REDIS_URL: redis://redis:6379
      JWT_SECRET: ${JWT_SECRET}
    volumes:
      - app-static:/app/public
      - app-uploads:/app/uploads
    networks:
      - app-network
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"]
      interval: 30s
      timeout: 10s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: '1'
        reservations:
          memory: 256M

  # 数据库
  postgres:
    image: postgres:16-alpine
    container_name: app-postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASS}
      POSTGRES_DB: ${DB_NAME}
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./db/backups:/backups
    networks:
      - app-network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
      interval: 10s
      timeout: 5s
      retries: 5
    deploy:
      resources:
        limits:
          memory: 512M

  # 缓存
  redis:
    image: redis:7-alpine
    container_name: app-redis
    restart: unless-stopped
    command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis-data:/data
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    deploy:
      resources:
        limits:
          memory: 300M

  # 后台任务 Worker
  worker:
    build:
      context: .
      dockerfile: Dockerfile
      target: production
    container_name: app-worker
    restart: unless-stopped
    command: node dist/worker.js
    <<: *common-env
    environment:
      <<: *common-env
      DATABASE_URL: postgresql://${DB_USER}:${DB_PASS}@postgres:5432/${DB_NAME}
      REDIS_URL: redis://redis:6379
    networks:
      - app-network
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    deploy:
      resources:
        limits:
          memory: 256M

  # 自动化证书
  certbot:
    image: certbot/certbot
    container_name: app-certbot
    volumes:
      - ./certbot/conf:/etc/letsencrypt
      - ./certbot/www:/var/www/certbot
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"

volumes:
  postgres-data:
    driver: local
  redis-data:
    driver: local
  app-static:
    driver: local
  app-uploads:
    driver: local

networks:
  app-network:
    driver: bridge

部署脚本:

#!/bin/bash
# deploy.sh - Docker Compose 部署脚本

set -e

echo "=== 开始部署 ==="

# 拉取最新代码
git pull origin main

# 加载环境变量
set -a
source .env.production
set +a

# 构建镜像
echo "构建应用镜像..."
docker compose build app worker

# 备份数据库
echo "备份数据库..."
docker compose exec -T postgres pg_dump -U ${DB_USER} ${DB_NAME} > \
  ./db/backups/backup_$(date +%Y%m%d_%H%M%S).sql

# 零停机部署
echo "滚动更新..."
docker compose up -d --no-deps --build app worker

# 等待健康检查通过
echo "等待健康检查..."
sleep 10

# 检查服务状态
if docker compose ps | grep -q "unhealthy"; then
  echo "错误:服务不健康,执行回滚"
  docker compose up -d --no-deps app worker
  exit 1
fi

# 清理旧镜像
docker image prune -f

echo "=== 部署完成 ==="

3.2 阶段二:Docker Swarm 多节点编排

当单机资源不足时,Docker Swarm 是最平滑的过渡方案:

# docker-compose.swarm.yml
version: '3.8'

services:
  app:
    image: ${REGISTRY}/app:${TAG}
    deploy:
      replicas: 3
      update_config:
        parallelism: 1
        delay: 10s
        failure_action: rollback
      rollback_config:
        parallelism: 1
        delay: 5s
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
      placement:
        constraints:
          - node.role == worker
      resources:
        limits:
          cpus: '1'
          memory: 512M
    environment:
      NODE_ENV: production
      DATABASE_URL: postgresql://${DB_USER}:${DB_PASS}@postgres:5432/${DB_NAME}
    networks:
      - app-overlay
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 15s
      timeout: 5s
      retries: 3

  worker:
    image: ${REGISTRY}/worker:${TAG}
    deploy:
      replicas: 2
      placement:
        constraints:
          - node.role == worker

  # Traefik 替代 Nginx,支持动态服务发现
  traefik:
    image: traefik:v3.0
    command:
      - "--api.dashboard=true"
      - "--providers.docker.swarmMode=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.le.acme.tlschallenge=true"
      - "--certificatesresolvers.le.acme.email=${ADMIN_EMAIL}"
      - "--certificatesresolvers.le.acme.storage=/certificates/acme.json"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - traefik-certs:/certificates
    networks:
      - app-overlay
    deploy:
      placement:
        constraints:
          - node.role == manager

networks:
  app-overlay:
    driver: overlay
    attachable: true

volumes:
  traefik-certs:

Swarm 部署命令:

# 初始化 Swarm 集群
docker swarm init --advertise-addr <MANAGER_IP>

# 加入 Worker 节点
docker swarm join --token <TOKEN> <MANAGER_IP>:2377

# 部署服务栈
docker stack deploy -c docker-compose.swarm.yml app-prod

# 查看服务状态
docker service ls
docker service ps app-prod_app

# 滚动更新
docker service update --image ${REGISTRY}/app:${NEW_TAG} app-prod_app

# 扩缩容
docker service scale app-prod_app=5
docker service scale app-prod_worker=3

3.3 阶段三:Kubernetes 部署

当产品足够成熟时,迁移到 K8s:

# k8s/base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app-server
  labels:
    app: app-server
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: app-server
  template:
    metadata:
      labels:
        app: app-server
    spec:
      containers:
        - name: app
          image: ${REGISTRY}/app:${TAG}
          ports:
            - containerPort: 3000
          env:
            - name: NODE_ENV
              value: "production"
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: db-credentials
                  key: url
            - name: REDIS_URL
              value: "redis://redis-service:6379"
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "500m"
          livenessProbe:
            httpGet:
              path: /api/health
              port: 3000
            initialDelaySeconds: 30
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /api/health
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 5
          volumeMounts:
            - name: app-config
              mountPath: /app/config
              readOnly: true
      volumes:
        - name: app-config
          configMap:
            name: app-config
---
apiVersion: v1
kind: Service
metadata:
  name: app-service
spec:
  selector:
    app: app-server
  ports:
    - port: 80
      targetPort: 3000
  type: ClusterIP
---
# k8s/base/hpa.yaml - 水平自动扩缩容
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: app-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: app-server
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 80
---
# k8s/base/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-ingress
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
    nginx.ingress.kubernetes.io/proxy-body-size: "10m"
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - app.example.com
      secretName: app-tls
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app-service
                port:
                  number: 80

3.4 Kustomize 多环境管理

# k8s/overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - ../../base

namespace: production

replicas:
  - name: app-server
    count: 5
  - name: worker
    count: 3

images:
  - name: ${REGISTRY}/app
    newTag: "v2.5.0"

configMapGenerator:
  - name: app-config
    literals:
      - LOG_LEVEL=info
      - CORS_ORIGIN=https://app.example.com

secretGenerator:
  - name: db-credentials
    envs:
      - .env.secret

patches:
  - target:
      kind: Deployment
      name: app-server
    patch: |
      - op: replace
        path: /spec/template/spec/containers/0/resources/limits/memory
        value: "1Gi"

GitOps 部署流水线:

# .github/workflows/deploy-k8s.yml
name: Deploy to Kubernetes

on:
  push:
    tags:
      - 'v*'

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

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

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: |
            ${{ secrets.REGISTRY }}/app:latest
            ${{ secrets.REGISTRY }}/app:${{ github.ref_name }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Update K8s manifests
        run: |
          cd k8s/overlays/production
          kustomize edit set image \
            ${{ secrets.REGISTRY }}/app=${{ github.ref_name }}

      - name: Commit and push manifests
        run: |
          git config user.name "GitHub Actions"
          git config user.email "actions@github.com"
          git add k8s/
          git commit -m "chore: bump app to ${{ github.ref_name }}" || true
          git push

      - name: Deploy with ArgoCD
        run: |
          argocd app sync app-production --prune
          argocd app wait app-production --health

四、最佳实践与注意事项

4.1 选择合适的时间点迁移

可以问自己几个问题来判断是否该升级部署架构:

  • 单台服务器 CPU/内存使用率是否持续超过 70%?
  • 服务是否需要零停机部署?
  • 是否需要对不同服务进行独立的扩缩容?
  • 团队是否有 1-2 人可以投入 2-4 周学习容器编排?

如果前三个问题中两个回答"是",就应该开始考虑迁移。如果第四个问题回答"否",先用 Docker Swarm 或托管 K8s 服务(如 GKE、ACK)降低运维负担。

踩坑经验:一个团队在日活 300 时就迁移到了 K8s,结果运维成本从每周 2 小时飙升到每天 4 小时——调试 Pod 启动失败、排查 CNI 网络不通、处理 etcd 磁盘满。产品迭代反而被拖慢。另一个团队在日活 5000 时还在用单机 Docker Compose,一次流量高峰直接 OOM 导致服务不可用 2 小时。这两个极端说明一个道理:时机比技术更重要,基础设施的复杂度要和业务规模匹配。

4.2 监控先行

无论哪个阶段,监控都是必须的:

# docker-compose 监控套件
services:
  prometheus:
    image: prom/prometheus
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
    ports:
      - "9090:9090"

  grafana:
    image: grafana/grafana
    ports:
      - "3000:3000"
    environment:
      GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
    volumes:
      - grafana-data:/var/lib/grafana

  node-exporter:
    image: prom/node-exporter
    volumes:
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
      - /:/rootfs:ro

4.3 成本控制

K8s 集群的成本可能远超 Docker Compose 单机。控制成本的策略:

  • 使用 Spot 实例:对无状态服务使用更便宜的抢占式实例
  • 资源限制:为每个 Pod 设置合理的 requestslimits
  • 自动缩容:夜间自动缩容到最小副本数
  • 托管服务:用 RDS 替代自建 PostgreSQL,用 ElastiCache 替代自建 Redis,虽然单价更高但省去运维成本

五、总结与展望

从 Docker Compose 到 K8s 的演进,核心是在合适的阶段引入合适的复杂度

  1. Docker Compose:MVP 到早期成长阶段的最佳选择,简单可靠
  2. Docker Swarm:平滑过渡方案,与 Compose 语法兼容,学习成本低
  3. Kubernetes:规模化阶段的必然选择,生产级编排能力

关键原则:

  • 不要过早优化:在用户量、服务数没有达到阈值前,Docker Compose 足够
  • 保留回滚能力:每次架构升级都保留回退预案
  • 监控驱动决策:基于数据而非感觉来决定是否升级

未来方向:

  • Serverless 容器:Google Cloud Run、AWS Fargate 等方案提供 K8s 的能力,但零运维
  • 边缘部署:CDN 边缘节点运行容器,就近服务用户
  • AI 运维:ML 模型分析集群指标,自动调整配置和扩缩容策略

部署架构的演进没有标准答案。理解每个方案的适用边界,比盲目追求"最新最好"更重要。


部署架构是独立产品的底座。底座不稳,功能再多也是空中楼阁。选择最适合当前阶段的方案,用数据和节奏驱动演进。

更多推荐