Microservices Trên 1 VPS Với Docker Compose: 10 Service Không Cần Kubernetes

📅 07/2026 · ⏱ 18 phút đọc · 🏷 DevOps

Năm 2023 tôi nhận maintain một SaaS nho nhỏ từ anh bạn. 3 service (auth, API, worker), chạy bằng PM2 trên 1 VPS. "Có gì khó đâu," tôi nghĩ. 3 tháng sau, user tăng gấp 5, tôi tự tách API thành 6 microservices. Lúc đó PM2 bắt đầu lòi ra vấn đề: port conflict, env variable rối nùi, dependency giữa các service không kiểm soát được, một service leak RAM kéo theo OOM kill giết luôn service bên cạnh.

Giải pháp: Docker Compose. KHÔNG phải Kubernetes. Bài này là toàn bộ hành trình tôi chuyển 1 project từ PM2 monolithic lên Docker Compose 10 microservices — những gì hoạt động, những gì suýt giết production, và tại sao bạn có thể chưa cần Kubernetes.

🎯 Đọc xong bạn sẽ: (1) Tự tin dùng Docker Compose cho production, không chỉ dev, (2) Biết cách chạy 10 service + database + cache + message queue trên 1 VPS 2-4GB RAM, (3) Tránh 5 gotcha production tôi đã dính, (4) Hiểu khi nào THỰC SỰ cần Kubernetes (và khi nào không).

Tại Sao Docker Compose, Không Phải Kubernetes?

Đây là câu hỏi tôi được hỏi nhiều nhất. Câu trả lời ngắn: Kubernetes cho 1 VPS giống như dùng xe tải chở 1 thùng mì.

Tiêu chíDocker ComposeKubernetes (k3s/microk8s)
RAM tối thiểu512MB (chỉ Docker) + app2GB chỉ riêng k8s control plane
Số file config1 file docker-compose.ymlDeployment + Service + Ingress + ConfigMap + Secret + PVC = ít nhất 6 file/service
Learning curve2-3 ngày2-3 tháng để production-ready
Auto-scalingKhông (scale thủ công hoặc dùng Watchtower)Có HPA (Horizontal Pod Autoscaler)
Rolling updateKhông built-in (dùng docker stack với Swarm)Có, zero-downtime
Self-healingrestart: unless-stopped đơn giảnTự restart pod chết, reschedule sang node khác
Phù hợp1-3 server, <20 services, team <5 người3+ server, 20+ services, team 5+ người

Nếu bạn có 1 VPS và dưới 20 service: Docker Compose. Nếu bạn có 3 VPS trở lên và cần auto-scale, zero-downtime deploy: Kubernetes. Đơn giản vậy thôi.

Kiến Trúc Thực Tế: 10 Service Trên 1 VPS 4GB RAM

Đây là stack tôi đang chạy cho một web app bán hàng nho nhỏ:

Docker Network: app_network (bridge, internal DNS)

┌─────────────────────────────────────────────────┐
│                  NGINX (port 80/443)             │
│            Reverse Proxy + SSL Terminate         │
└────┬────┬────┬────┬────┬────┬────┬────┬────┬────┘
     │    │    │    │    │    │    │    │    │
     ▼    ▼    ▼    ▼    ▼    ▼    ▼    ▼    ▼    ▼
  ┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐
  │Auth││API ││Wrk││Cron││WS ││Adm││PG ││Rds││Rab││Mel│
  │Svc ││Svc ││Svc││Svc ││Svc││Svc││DB ││Cch││MQ ││Svc│
  └────┘└────┘└────┘└────┘└────┘└────┘└────┘└────┘└────┘└────┘
  Node  Node  Node  Node   Go   Remix  Pstg  Rds  Rbbt Minio
  3001  3002  3003  3004   3005  3006   5432  6379 5672  9000

10 service, chạy trên VPS 4GB RAM, CPU load trung bình 25%. Tổng chi phí: 160K VPS + 0K orchestration = 160K/tháng. Nếu dùng managed Kubernetes (GKE/EKS) thì riêng control plane đã $70/tháng (~1.8 triệu), chưa tính node.

docker-compose.yml Production: Từng Dòng Một

Đây không phải file mẫu "Hello World". Đây là config tôi đang chạy thật. Tôi giải thích từng phần.

version: "3.8"

# ===== NETWORK =====
networks:
  app_network:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16
  # Database network riêng — không expose ra app_network
  db_network:
    driver: bridge
    internal: true  # Không có internet access

# ===== VOLUMES =====
volumes:
  postgres_data:
    driver: local
  redis_data:
    driver: local
  rabbitmq_data:
    driver: local
  minio_data:
    driver: local
  nginx_logs:
    driver: local

# ===== SERVICES =====
services:
  # 1. NGINX Reverse Proxy
  nginx:
    image: nginx:1.25-alpine
    container_name: prod_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
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - nginx_logs:/var/log/nginx
    networks:
      - app_network
    depends_on:
      - auth_service
      - api_service
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 256M
        reservations:
          cpus: "0.25"
          memory: 128M

  # 2. Auth Service (Node.js + JWT)
  auth_service:
    build:
      context: ./services/auth
      dockerfile: Dockerfile
    container_name: prod_auth
    restart: unless-stopped
    expose:
      - "3001"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@postgres:5432/${DB_NAME}
      - REDIS_URL=redis://redis:6379
      - JWT_SECRET=${JWT_SECRET}
    networks:
      - app_network
      - db_network
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 15s
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 256M

  # 3. API Service (Node.js + Express)
  api_service:
    build:
      context: ./services/api
      dockerfile: Dockerfile
    container_name: prod_api
    restart: unless-stopped
    expose:
      - "3002"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@postgres:5432/${DB_NAME}
      - REDIS_URL=redis://redis:6379
      - RABBITMQ_URL=amqp://${RABBIT_USER}:${RABBIT_PASS}@rabbitmq:5672
      - MINIO_ENDPOINT=minio
      - MINIO_PORT=9000
    networks:
      - app_network
      - db_network
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
      rabbitmq:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3002/health"]
      interval: 30s
      timeout: 5s
      retries: 3
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 1G
        reservations:
          cpus: "0.5"
          memory: 512M

  # 4. PostgreSQL 15
  postgres:
    image: postgres:15-alpine
    container_name: prod_postgres
    restart: unless-stopped
    environment:
      - POSTGRES_USER=${DB_USER}
      - POSTGRES_PASSWORD=${DB_PASS}
      - POSTGRES_DB=${DB_NAME}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - db_network  # Chỉ nối vào db_network, không expose ra app_network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
      interval: 10s
      timeout: 5s
      retries: 5
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.5"
          memory: 256M
    command: 
      - "postgres"
      - "-c"
      - "shared_buffers=128MB"
      - "-c"
      - "effective_cache_size=384MB"

  # 5. Redis 7
  redis:
    image: redis:7-alpine
    container_name: prod_redis
    restart: unless-stopped
    command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    networks:
      - db_network
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 384M
        reservations:
          cpus: "0.25"
          memory: 128M

  # 6. RabbitMQ
  rabbitmq:
    image: rabbitmq:3.12-management-alpine
    container_name: prod_rabbitmq
    restart: unless-stopped
    environment:
      - RABBITMQ_DEFAULT_USER=${RABBIT_USER}
      - RABBITMQ_DEFAULT_PASS=${RABBIT_PASS}
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq
    networks:
      - db_network
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
      interval: 30s
      timeout: 10s
      retries: 5
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 256M

  # 7. MinIO (S3-compatible storage)
  minio:
    image: minio/minio:latest
    container_name: prod_minio
    restart: unless-stopped
    command: server /data --console-address ":9001"
    environment:
      - MINIO_ROOT_USER=${MINIO_USER}
      - MINIO_ROOT_PASSWORD=${MINIO_PASS}
    volumes:
      - minio_data:/data
    networks:
      - db_network
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 5s
      retries: 3
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 512M

  # 8. Worker Service (xử lý job queue)
  worker_service:
    build:
      context: ./services/worker
      dockerfile: Dockerfile
    container_name: prod_worker
    restart: unless-stopped
    environment:
      - NODE_ENV=production
      - RABBITMQ_URL=amqp://${RABBIT_USER}:${RABBIT_PASS}@rabbitmq:5672
      - MINIO_ENDPOINT=minio
    networks:
      - db_network
    depends_on:
      rabbitmq:
        condition: service_healthy
      minio:
        condition: service_healthy
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 512M

  # 9. Cron Service (scheduled tasks)
  cron_service:
    build:
      context: ./services/cron
      dockerfile: Dockerfile
    container_name: prod_cron
    restart: unless-stopped
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@postgres:5432/${DB_NAME}
    networks:
      - db_network
    depends_on:
      postgres:
        condition: service_healthy
    deploy:
      resources:
        limits:
          cpus: "0.25"
          memory: 256M

  # 10. Melisearch (full-text search)
  meilisearch:
    image: getmeili/meilisearch:v1.5
    container_name: prod_meilisearch
    restart: unless-stopped
    environment:
      - MEILI_MASTER_KEY=${MEILI_KEY}
      - MEILI_ENV=production
      - MEILI_NO_ANALYTICS=true
    volumes:
      - meili_data:/meili_data
    networks:
      - db_network
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 512M
⚙️ Tổng resource usage: 10 service dùng ~2.5GB RAM (limit), CPU tổng 6.5 core limit. Trên thực tế RAM usage ~1.8GB vì reservation thấp hơn limit. Các service dùng Alpine-based image để giảm size (Node Alpine: ~180MB vs Node full: ~1GB).

5 Gotcha Production Khi Dùng Docker Compose

⚠️ Bẫy #1: depends_on chỉ đợi container start, không đợi service ready. depends_on đợi PostgreSQL container STARTED, nhưng PostgreSQL có thể mất 5-10 giây để accept connection. Kết quả: auth_service start trước khi DB sẵn sàng → crash loop. Fix: dùng condition: service_healthy (Compose v3+) hoặc script wait-for-it.sh trong entrypoint.
⚠️ Bẫy #2: Network bridge mặc định resolve DNS bằng container name, nhưng không tự động reconnect. Nếu Redis container restart, IP đổi, nhưng connection từ auth_service vẫn giữ IP cũ → lỗi connection refused. Fix: ứng dụng phải có retry logic + connection pooling có reconnect. Hoặc dùng network_mode: host (chỉ Linux, mất isolation).
⚠️ Bẫy #3: Không giới hạn resource → 1 service ăn hết RAM. Không set deploy.resources.limits, worker bị memory leak → dùng hết 4GB RAM → OOM killer của kernel giết ... postgres (process dùng nhiều RAM nhất). Luôn set memory limit cho mọi service. Dùng docker stats để theo dõi.
⚠️ Bẫy #4: volume mount path sai → mất dữ liệu khi container restart. Mount ./data:/app/data — path tương đối tính từ thư mục chạy docker compose up. Nếu chạy từ cron job trong thư mục khác, volume mount sai chỗ. Dùng absolute path: /opt/app/data:/app/data.
⚠️ Bẫy #5: Không rotate log → ổ cứng đầy. Mặc định Docker ghi log JSON không giới hạn. Sau 3 tháng tôi phát hiện 1 file log 47GB từ api_service (log mọi request). Fix: set logging.options.max-sizemax-file cho TỪNG service.

So Sánh Các Phương Án Orchestration Trên VPS

Phương ánRAM cầnĐộ phức tạpAuto-restartNetwork isolationPhù hợp
PM2 thuần128MBThấp nhấtKhông1-3 service Node.js
systemd + service file0MB extraThấpCó (Restart=always)KhôngĐa ngôn ngữ, cần native performance
Docker Compose200-400MB (Docker daemon)Trung bìnhCó (restart: unless-stopped)Có (bridge network)5-20 service, multi-language
Docker Swarm300-500MBTrung bình-caoCó + rolling updateCó (overlay network)2-5 node, cần HA đơn giản
k3s (Kubernetes nhẹ)2GB+CaoCó + self-healingCó (CNI plugin)3+ node, team DevOps
Nomad200-400MBTrung bìnhCó + rescheduleMulti-datacenter, non-container

Deploy Flow: Từ Git Push Đến Production

Đây là flow deploy tôi dùng — đơn giản, không cần CI/CD phức tạp:

# Trên máy dev: push code lên Git
git push origin main

# SSH vào VPS, pull + deploy
ssh user@vps-ip
cd /opt/app

# Pull code mới
git pull origin main

# Build image mới (chỉ build service thay đổi)
docker compose build api_service worker_service

# Zero-downtime restart: scale lên 2 instance, đợi healthy, scale cũ xuống
docker compose up -d --scale api_service=2 --no-recreate nginx postgres redis
# Đợi instance mới healthy (10s)
sleep 10
docker compose up -d --scale api_service=1 --no-recreate

# Cleanup image cũ (giữ 2 version gần nhất)
docker image prune -a --filter "until=48h" -f
⚙️ Tại sao scale trick hoạt động: --scale api_service=2 tạo container mới (với image mới) trong khi container cũ vẫn chạy. NGINX upstream với 2 server tự động load balance. Sau khi container mới healthy, scale về 1 — container cũ bị xóa. Downtime: 0 giây nếu NGINX config đúng.

Monitoring + Logging Cho Docker Compose Stack

Không có Kubernetes dashboard, bạn cần tool riêng. Đây là stack monitoring tôi dùng trên cùng VPS:

# docker-compose.monitoring.yml
services:
  prometheus:
    image: prom/prometheus
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    ports:
      - "127.0.0.1:9090:9090"
  
  grafana:
    image: grafana/grafana
    ports:
      - "127.0.0.1:3030:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASS}
  
  # Loki + Promtail cho log aggregation
  loki:
    image: grafana/loki:2.9
  
  promtail:
    image: grafana/promtail:2.9
    volumes:
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - ./promtail-config.yml:/etc/promtail/config.yml

Đọc thêm: Hướng dẫn Monitoring VPS với Prometheus + GrafanaQuản lý Log Docker với Loki + Grafana.

Khi Nào Nên Lên Kubernetes?

Tôi không anti-Kubernetes. Tôi anti-Kubernetes-cho-mọi-thứ. Đây là checklist để bạn biết đã đến lúc migrate chưa:

Nếu chưa đạt ít nhất 3/6 tiêu chí trên: Docker Compose vẫn là bạn thân.

Chi Phí Thực Tế: Docker Compose vs Kubernetes

Hạng mụcDocker Compose (1 VPS)k3s (3 VPS)Managed K8s (GKE)
VPS/Node1x 4GB = 160K3x 4GB = 480K3x e2-medium + control plane
Control plane0K0K (k3s embedded)$73/tháng (~1.85M)
Load balancerNGINX trong VPSMetalLB/Traefik$18/tháng (~450K)
Storage (PVC)Local volumeLonghorn/local-path$0.1/GB/tháng
MonitoringPrometheus + Grafana (free)kube-prometheus-stackGoogle Cloud Monitoring (free tier)
Tổng/tháng~160K VND~480K VND~$100 (~2.5M VND)

Khác biệt 15x giữa Docker Compose và managed Kubernetes. Với 160K/tháng, bạn có thể chạy 10 services + database + cache + queue + storage + monitoring. Cùng stack đó trên GKE: 2.5 triệu/tháng. Với startup nhỏ, đó là cả một khoản tiết kiệm khổng lồ.

🚀 Bắt đầu với Docker Compose ngay hôm nay

VPS 4GB RAM chỉ 160K/tháng tại TrumVPS — đủ chạy 10+ microservices

Thuê VPS Ngay

Xem thêm: Cài Docker + Docker Compose trên VPS hoặc Netihot Hosting giá rẻ