Files
api/docs/MICROSERVICES_ARCHITECTURE.md
2026-03-20 20:57:03 +01:00

32 KiB
Raw Blame History

Adiuva — Architettura Microservizi

Panoramica

Il monolite attuale viene suddiviso in 5 servizi + un API Gateway, orchestrati con Docker Compose e raggiungibili tramite dominio su Cloudflare.

                          ┌──────────────┐
                          │  Cloudflare  │
                          │  (DNS + CDN) │
                          └──────┬───────┘
                                 │ HTTPS / WSS
                          ┌──────▼───────┐
                          │   Traefik    │
                          │ API Gateway  │
                          │  (routing,   │
                          │   TLS term.) │
                          └──────┬───────┘
                                 │
          ┌──────────┬───────────┼───────────┬──────────┐
          │          │           │           │          │
    ┌─────▼────┐ ┌───▼───┐ ┌────▼────┐ ┌────▼───┐ ┌───▼─────┐
    │  Auth    │ │  Chat │ │ Storage │ │Billing │ │ Plugins │
    │ Service  │ │Service│ │ Service │ │Service │ │ Service │
    └─────┬────┘ └───┬───┘ └────┬────┘ └────┬───┘ └───┬─────┘
          │          │          │           │          │
    ┌─────▼──────────▼──────────▼───────────▼──────────▼─────┐
    │                   Infrastruttura                       │
    │  PostgreSQL │ Redis │ MinIO (S3) │ Qdrant │ (Pinecone) │
    └────────────────────────────────────────────────────────┘

1. Suddivisione dei Servizi

1.1 Auth Service (auth-service)

Responsabilità: Registrazione, login, refresh token, profilo utente, encryption key.

Endpoint originale Metodo
/api/v1/auth/register POST
/api/v1/auth/login POST
/api/v1/auth/refresh POST
/api/v1/auth/me GET / PUT

Database: Tabelle users, refresh_tokens (PostgreSQL condiviso, schema auth).

Modifica chiave — JWT con RS256: Il monolite usa un SECRET_KEY simmetrico (HS256). Con i microservizi, passare a RS256 (asimmetrico):

  • L'Auth Service firma i JWT con la chiave privata.
  • Tutti gli altri servizi verificano i JWT con la chiave pubblica senza mai contattare l'Auth Service.
  • La chiave pubblica viene esposta via GET /api/v1/auth/.well-known/jwks.json oppure montata come volume condiviso.
# auth-service/app/auth/jwt.py
from cryptography.hazmat.primitives.asymmetric import rsa
from jose import jwt

PRIVATE_KEY = ...  # Da env/secret
PUBLIC_KEY = ...   # Derivata o da env

def create_access_token(user_id: str, tier: str) -> str:
    return jwt.encode(
        {"sub": user_id, "tier": tier, "exp": ...},
        PRIVATE_KEY,
        algorithm="RS256",
    )
# shared/auth.py  (usato da tutti gli altri servizi)
from jose import jwt

PUBLIC_KEY = ...  # Volume montato o fetched da JWKS endpoint

def verify_token(token: str) -> dict:
    return jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])

Scaling: 2 repliche sufficienti, stateless. Rate-limit dedicato su /login e /register.


1.2 Chat Service (chat-service) Core

Responsabilità: WebSocket device, home chat, floating chat, agent runner, memory middleware, agent setup journeys.

Endpoint originale Tipo
/api/v1/ws/device WebSocket
/api/v1/chat POST (REST fallback)
/api/v1/agents/catalog GET
/api/v1/agents/can-create POST
/api/v1/agents/trigger POST

Moduli inclusi: deep_agent, agent_runner, agent_registry, memory_middleware, ws_context, device_manager, tutti gli agent tools (task_agent, project_agent, note_agent, timeline_agent, filesystem_agent).

Questa è la bestia che deve scalare orizzontalmente — è il servizio più CPU/memory intensive (LLM calls, tool loops, WebSocket persistenti).


1.3 Storage Service (storage-service)

Responsabilità: CRUD record crittografati su S3, vector operations, backup.

Endpoint originale Metodo
/api/v1/storage/records POST / GET
/api/v1/storage/records/{id} GET / PUT / DELETE
/api/v1/vectors/upsert POST
/api/v1/vectors/search POST
/api/v1/vectors/embed POST
/api/v1/vectors DELETE
/api/v1/backup PUT / GET / DELETE
/api/v1/backup/history GET

Scaling: 23 repliche. I/O bound (S3, Qdrant). Stateless.


1.4 Billing Service (billing-service)

Responsabilità: Stripe checkout, webhook, subscription management, tier enforcement.

Endpoint originale Metodo
/api/v1/billing/checkout POST
/api/v1/billing/webhook POST
/api/v1/billing/subscription GET / DELETE

Database: Tabelle subscriptions (schema billing).

Comunicazione inter-servizio: Quando Stripe invia un webhook e il tier cambia, il Billing Service pubblica un evento su Redis pub/sub channel tier_changed:{user_id}. L'Auth Service aggiorna il campo tier nella tabella users (oppure i servizi leggono il tier direttamente dal JWT, aggiornato al prossimo refresh).

Scaling: 1 replica sufficiente. Basso traffico.


1.5 Plugin Service (plugin-service)

Responsabilità: Marketplace, installazione plugin, revenue split.

Endpoint originale Metodo
/api/v1/plugins GET
/api/v1/plugins/{id} GET
/api/v1/plugins/{id}/install POST / DELETE

Database: Tabelle plugins, plugin_installations, revenue_events.

Scaling: 1 replica. Basso traffico.


2. WebSocket con Scaling Orizzontale — Il Problema Chiave

Il problema attuale

DeviceConnectionManager è un singleton in-memory:

class DeviceConnectionManager:
    def __init__(self):
        self._connections: dict[str, DeviceConnection] = {}  # ← In-memory!

Con N istanze del Chat Service, il device si connette a una sola istanza. Quando un'altra istanza deve inviare un tool_call a quel device (es. un agent trigger da un'API call), non trova la connessione.

La soluzione: Redis Pub/Sub + Registry

┌──────────────────────────────────────────────────────────────┐
│                     Redis                                    │
│                                                              │
│  Hash: ws:connections                                        │
│    user_123 → instance_A                                     │
│    user_456 → instance_B                                     │
│                                                              │
│  Pub/Sub channels:                                           │
│    tool_call:{user_id}  → tool call payloads                 │
│    tool_result:{call_id} → tool result payloads              │
│    stream:{user_id}     → text_chunk streaming               │
└──────────────────────────────────────────────────────────────┘

 Instance A (ha WS di user_123)     Instance B (deve chiamare tool su user_123)
 ┌───────────────────────┐          ┌───────────────────────┐
 │  1. Sottoscrive a     │          │  1. Lookup Redis Hash │
 │     tool_call:user_123│          │     → user_123 è su A │
 │                       │          │                       │
 │  2. Riceve tool_call  │◄─────────│  2. PUBLISH           │
 │     da Redis channel  │          │    tool_call:user_123 │
 │                       │          │    {id, action, ...}  │
 │  3. Invia al device   │          │                       │
 │     via WS            │          │  4. SUBSCRIBE         │
 │                       │          │    tool_result:{id}   │
 │  4. Device risponde   │          │                       │
 │     tool_result       │──────────│► 5. Riceve risultato  │
 │                       │          │                       │
 │  5. PUBLISH           │          │                       │
 │    tool_result:{id}   │          │                       │
 └───────────────────────┘          └───────────────────────┘

Implementazione: RedisDeviceManager

# chat-service/app/core/device_manager.py

import asyncio
import json
import os
import redis.asyncio as aioredis
from dataclasses import dataclass, field
from fastapi import WebSocket

INSTANCE_ID = os.environ.get("INSTANCE_ID", os.urandom(8).hex())

@dataclass
class LocalConnection:
    ws: WebSocket
    device_id: str
    pending_calls: dict[str, asyncio.Future[dict]] = field(default_factory=dict)


class RedisDeviceManager:
    """Device manager backed by Redis for cross-instance communication."""

    def __init__(self, redis_url: str = "redis://redis:6379"):
        self._redis = aioredis.from_url(redis_url)
        self._pubsub = self._redis.pubsub()
        self._local: dict[str, LocalConnection] = {}  # Solo connessioni locali
        self._remote_futures: dict[str, asyncio.Future[dict]] = {}

    async def start(self):
        """Avvia il listener Redis per tool_call in arrivo."""
        asyncio.create_task(self._listen_tool_calls())

    # ── Registrazione ──

    async def register(self, user_id: str, device_id: str, ws: WebSocket):
        # Registra localmente
        self._local[user_id] = LocalConnection(ws=ws, device_id=device_id)
        # Registra in Redis quale istanza ha la connessione
        await self._redis.hset("ws:connections", user_id, INSTANCE_ID)
        # Sottoscrivi ai tool_call per questo utente
        await self._pubsub.subscribe(f"tool_call:{user_id}")

    async def unregister(self, user_id: str):
        conn = self._local.pop(user_id, None)
        if conn:
            for fut in conn.pending_calls.values():
                if not fut.done():
                    fut.cancel()
        await self._redis.hdel("ws:connections", user_id)
        await self._pubsub.unsubscribe(f"tool_call:{user_id}")

    # ── Presenza ──

    async def is_online(self, user_id: str) -> bool:
        return await self._redis.hexists("ws:connections", user_id)

    # ── Tool-call round-trip (cross-instance) ──

    async def execute_tool_call(self, user_id: str, payload: dict) -> dict:
        """
        Invia un tool_call al device dell'utente.
        Funziona sia che la WS sia locale che su un'altra istanza.
        """
        call_id = payload["id"]

        # Caso 1: connessione locale → invio diretto
        if user_id in self._local:
            conn = self._local[user_id]
            loop = asyncio.get_event_loop()
            fut: asyncio.Future[dict] = loop.create_future()
            conn.pending_calls[call_id] = fut
            await conn.ws.send_text(json.dumps({"type": "tool_call", **payload}))
            return await asyncio.wait_for(fut, timeout=30.0)

        # Caso 2: connessione remota → Redis pub/sub
        loop = asyncio.get_event_loop()
        fut = loop.create_future()
        self._remote_futures[call_id] = fut

        # Sottoscrivi al canale di risposta
        result_channel = f"tool_result:{call_id}"
        await self._pubsub.subscribe(result_channel)

        # Pubblica il tool_call
        await self._redis.publish(
            f"tool_call:{user_id}",
            json.dumps(payload),
        )

        try:
            return await asyncio.wait_for(fut, timeout=30.0)
        finally:
            self._remote_futures.pop(call_id, None)
            await self._pubsub.unsubscribe(result_channel)

    # ── Risoluzione tool_result (da WS locale) ──

    def resolve_local(self, user_id: str, call_id: str, result: dict):
        conn = self._local.get(user_id)
        if conn:
            fut = conn.pending_calls.pop(call_id, None)
            if fut and not fut.done():
                fut.set_result(result)

    async def resolve_and_publish(self, user_id: str, call_id: str, result: dict):
        """Chiamato quando il device locale invia un tool_result."""
        self.resolve_local(user_id, call_id, result)
        # Pubblica anche su Redis per l'istanza remota che aspetta
        await self._redis.publish(
            f"tool_result:{call_id}",
            json.dumps(result),
        )

    # ── Listener Redis ──

    async def _listen_tool_calls(self):
        """Loop che ascolta i tool_call in arrivo da altre istanze."""
        async for message in self._pubsub.listen():
            if message["type"] != "message":
                continue
            channel = message["channel"]
            if isinstance(channel, bytes):
                channel = channel.decode()

            data = json.loads(message["data"])

            if channel.startswith("tool_call:"):
                # Un'altra istanza vuole che inviamo un tool_call al nostro device
                user_id = channel.split(":", 1)[1]
                conn = self._local.get(user_id)
                if conn:
                    await conn.ws.send_text(json.dumps({"type": "tool_call", **data}))

            elif channel.startswith("tool_result:"):
                # Risposta a un tool_call che abbiamo inviato tramite Redis
                call_id = channel.split(":", 1)[1]
                fut = self._remote_futures.pop(call_id, None)
                if fut and not fut.done():
                    fut.set_result(data)

    # ── Stream cross-instance ──

    async def publish_stream_chunk(self, user_id: str, chunk: dict):
        """Pubblica un chunk di streaming su Redis (per REST→WS relay)."""
        await self._redis.publish(f"stream:{user_id}", json.dumps(chunk))

3. Struttura Directory Proposta

adiuva-api/
├── docker-compose.yml          # Orchestrazione completa
├── docker-compose.dev.yml      # Override per sviluppo locale
├── shared/                     # Codice condiviso (montato come volume)
│   ├── auth.py                 # JWT verification (chiave pubblica)
│   ├── schemas.py              # Pydantic schemas condivisi
│   ├── middleware/
│   │   ├── rate_limit.py
│   │   └── sanitizer.py
│   └── models/
│       └── base.py             # SQLAlchemy base condivisa
│
├── auth-service/
│   ├── Dockerfile
│   ├── requirements.txt
│   └── app/
│       ├── main.py
│       ├── config.py
│       ├── db.py
│       ├── models.py           # users, refresh_tokens
│       ├── routes/
│       │   └── auth.py
│       └── services/
│           ├── jwt_service.py  # RS256 signing
│           └── user_service.py
│
├── chat-service/
│   ├── Dockerfile
│   ├── requirements.txt
│   └── app/
│       ├── main.py
│       ├── config.py
│       ├── db.py
│       ├── models.py           # agent_run_logs, memory_*
│       ├── routes/
│       │   ├── device_ws.py
│       │   ├── chat.py
│       │   └── agents.py
│       ├── core/
│       │   ├── device_manager.py   # RedisDeviceManager
│       │   ├── deep_agent.py
│       │   ├── agent_runner.py
│       │   ├── agent_registry.py
│       │   ├── memory_middleware.py
│       │   ├── ws_context.py
│       │   ├── output_formatter.py
│       │   └── llm.py
│       └── agents/
│           ├── task_agent.py
│           ├── project_agent.py
│           ├── note_agent.py
│           ├── timeline_agent.py
│           └── filesystem_agent.py
│
├── storage-service/
│   ├── Dockerfile
│   ├── requirements.txt
│   └── app/
│       ├── main.py
│       ├── config.py
│       ├── db.py
│       ├── models.py           # storage_records, backup_metadata
│       ├── routes/
│       │   ├── storage.py
│       │   ├── vectors.py
│       │   └── backup.py
│       └── services/
│           ├── blob_store.py
│           └── vector_store.py
│
├── billing-service/
│   ├── Dockerfile
│   ├── requirements.txt
│   └── app/
│       ├── main.py
│       ├── config.py
│       ├── db.py
│       ├── models.py           # subscriptions
│       ├── routes/
│       │   └── billing.py
│       └── services/
│           ├── stripe_service.py
│           └── tier_manager.py
│
├── plugin-service/
│   ├── Dockerfile
│   ├── requirements.txt
│   └── app/
│       ├── main.py
│       ├── config.py
│       ├── db.py
│       ├── models.py           # plugins, installations, revenue
│       └── routes/
│           └── plugins.py
│
└── infra/
    ├── traefik/
    │   └── traefik.yml
    └── alembic/                # Migrazioni condivise o per-servizio

4. Docker Compose — Configurazione Completa

# docker-compose.yml

services:

  # ══════════════════════════════════════════════════════════
  # API Gateway
  # ══════════════════════════════════════════════════════════
  traefik:
    image: traefik:v3.2
    command:
      - "--api.insecure=true"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      # Cloudflare gestisce TLS, Traefik riceve HTTP dal proxy
      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
    ports:
      - "80:80"
      - "443:443"
      - "8080:8080"   # Dashboard Traefik
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    restart: unless-stopped

  # ══════════════════════════════════════════════════════════
  # Auth Service (2 repliche)
  # ══════════════════════════════════════════════════════════
  auth-service:
    build: ./auth-service
    deploy:
      replicas: 2
    env_file: .env
    environment:
      DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/adiuva
      JWT_PRIVATE_KEY_FILE: /run/secrets/jwt_private_key
      SERVICE_NAME: auth
    secrets:
      - jwt_private_key
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.auth.rule=PathPrefix(`/api/v1/auth`)"
      - "traefik.http.services.auth.loadbalancer.server.port=8000"
    depends_on:
      db:
        condition: service_healthy

  # ══════════════════════════════════════════════════════════
  # Chat Service (scalabile, N repliche)
  # ══════════════════════════════════════════════════════════
  chat-service:
    build: ./chat-service
    deploy:
      replicas: 3
    env_file: .env
    environment:
      DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/adiuva
      REDIS_URL: redis://redis:6379
      JWT_PUBLIC_KEY_FILE: /run/secrets/jwt_public_key
      SERVICE_NAME: chat
    secrets:
      - jwt_public_key
    labels:
      - "traefik.enable=true"
      # REST routes
      - "traefik.http.routers.chat.rule=PathPrefix(`/api/v1/chat`) || PathPrefix(`/api/v1/agents`)"
      - "traefik.http.services.chat.loadbalancer.server.port=8000"
      # WebSocket route con sticky session
      - "traefik.http.routers.ws.rule=PathPrefix(`/api/v1/ws`)"
      - "traefik.http.routers.ws.service=chat-ws"
      - "traefik.http.services.chat-ws.loadbalancer.server.port=8000"
      - "traefik.http.services.chat-ws.loadbalancer.sticky.cookie.name=ws_affinity"
      - "traefik.http.services.chat-ws.loadbalancer.sticky.cookie.httpOnly=true"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  # ══════════════════════════════════════════════════════════
  # Storage Service (2 repliche)
  # ══════════════════════════════════════════════════════════
  storage-service:
    build: ./storage-service
    deploy:
      replicas: 2
    env_file: .env
    environment:
      DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/adiuva
      JWT_PUBLIC_KEY_FILE: /run/secrets/jwt_public_key
      SERVICE_NAME: storage
    secrets:
      - jwt_public_key
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.storage.rule=PathPrefix(`/api/v1/storage`) || PathPrefix(`/api/v1/vectors`) || PathPrefix(`/api/v1/backup`)"
      - "traefik.http.services.storage.loadbalancer.server.port=8000"
    depends_on:
      db:
        condition: service_healthy

  # ══════════════════════════════════════════════════════════
  # Billing Service (1 replica)
  # ══════════════════════════════════════════════════════════
  billing-service:
    build: ./billing-service
    deploy:
      replicas: 1
    env_file: .env
    environment:
      DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/adiuva
      REDIS_URL: redis://redis:6379
      JWT_PUBLIC_KEY_FILE: /run/secrets/jwt_public_key
      SERVICE_NAME: billing
    secrets:
      - jwt_public_key
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.billing.rule=PathPrefix(`/api/v1/billing`)"
      - "traefik.http.services.billing.loadbalancer.server.port=8000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  # ══════════════════════════════════════════════════════════
  # Plugin Service (1 replica)
  # ══════════════════════════════════════════════════════════
  plugin-service:
    build: ./plugin-service
    deploy:
      replicas: 1
    env_file: .env
    environment:
      DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/adiuva
      JWT_PUBLIC_KEY_FILE: /run/secrets/jwt_public_key
      SERVICE_NAME: plugins
    secrets:
      - jwt_public_key
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.plugins.rule=PathPrefix(`/api/v1/plugins`)"
      - "traefik.http.services.plugins.loadbalancer.server.port=8000"
    depends_on:
      db:
        condition: service_healthy

  # ══════════════════════════════════════════════════════════
  # Infrastruttura
  # ══════════════════════════════════════════════════════════
  db:
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: adiuva
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped

  minio:
    image: minio/minio:latest
    command: server /data --console-address ":9001"
    ports:
      - "9000:9000"
      - "9001:9001"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    volumes:
      - minio_data:/data
    restart: unless-stopped

  qdrant:
    image: qdrant/qdrant:latest
    volumes:
      - qdrant_data:/qdrant/storage
    restart: unless-stopped

secrets:
  jwt_private_key:
    file: ./infra/keys/jwt_private.pem
  jwt_public_key:
    file: ./infra/keys/jwt_public.pem

volumes:
  postgres_data:
  redis_data:
  minio_data:
  qdrant_data:

5. Configurazione Cloudflare + VPS

5.1 DNS

api.tuodominio.com  →  A record  →  IP del VPS
                    →  Proxy: ON (orange cloud)

5.2 Cloudflare Settings

Setting Valore Motivo
SSL/TLS mode Full (Strict) Cloudflare ↔ VPS con certificato valido
WebSocket ON Necessario per /api/v1/ws/device
Proxy timeout 100s (Enterprise) o default Le LLM calls possono durare 30s+
Under Attack Mode Off (attivare se necessario)

5.3 TLS sul VPS

Due opzioni:

  • Opzione A (consigliata): Cloudflare Origin Certificate → montato in Traefik
  • Opzione B: Let's Encrypt via Traefik (con DNS challenge Cloudflare)
# traefik.yml — con Cloudflare Origin Certificate
entryPoints:
  websecure:
    address: ":443"

tls:
  certificates:
    - certFile: /certs/origin.pem
      keyFile: /certs/origin-key.pem

5.4 Rete VPS

# UFW firewall — solo Cloudflare può raggiungere le porte 80/443
# https://www.cloudflare.com/ips/
ufw default deny incoming
ufw allow from 173.245.48.0/20 to any port 443
ufw allow from 103.21.244.0/22 to any port 443
# ... (tutti gli IP range di Cloudflare)
ufw allow ssh
ufw enable

6. Comunicazione Inter-Servizio

6.1 Pattern: Event Bus via Redis Pub/Sub

┌──────────┐  tier_changed:user_123   ┌──────────┐
│ Billing  │ ────────────────────────► │   Auth   │
│ Service  │                           │ Service  │
└──────────┘                           └──────────┘

┌──────────┐  agent_triggered:user_123 ┌──────────┐
│  Chat    │ ◄──────────────────────── │  Any     │
│ Service  │                           │ Service  │
└──────────┘                           └──────────┘

6.2 Pattern: HTTP Sincrono (per query semplici)

Il Chat Service può avere bisogno del tier dell'utente per il rate-limiting degli agent. Due strategie:

  • Strategia A (preferita): Il tier è nel JWT. All'aggiornamento, il Billing Service forza token refresh invalidando i vecchi token su Redis.
  • Strategia B: Il Chat Service chiama http://auth-service:8000/internal/user/{id}/tier (rete Docker interna, non esposta).

6.3 Health Checks e Service Discovery

Traefik gestisce automaticamente il service discovery via Docker labels. I servizi non devono conoscersi tra loro — comunicano solo via:

  • Redis pub/sub (eventi asincroni)
  • Redis hash (stato condiviso, es. ws:connections)
  • PostgreSQL (dati persistenti condivisi)

7. Piano di Migrazione Incrementale

Fase 1 — Preparazione (senza rompere nulla)

  1. Aggiungere Redis al docker-compose.yml attuale
  2. Migrare JWT da HS256 → RS256 (backward-compatible: accetta entrambi)
  3. Implementare RedisDeviceManager come drop-in replacement
  4. Estrarre shared/ con auth verification, schemas, middleware

Fase 2 — Primo split: Auth Service

  1. Estrarre auth.py routes + models in auth-service/
  2. Verificare che i JWT firmati da auth-service vengano validati dal monolite
  3. Aggiornare Traefik per routare /api/v1/auth/* al nuovo servizio
  4. Il monolite continua a servire tutto il resto

Fase 3 — Storage + Billing + Plugins

  1. Servizi stateless e senza WebSocket → facili da estrarre
  2. Estrarre uno alla volta, testare, routare via Traefik
  3. Il monolite diventa sempre più magro

Fase 4 — Chat Service (il più delicato)

  1. Il monolite residuo diventa il Chat Service
  2. Rimuovere i route migrati, tenere solo WS + chat + agents
  3. Testare lo scaling a 2+ istanze con RedisDeviceManager
  4. Verificare tool-call cross-instance

Fase 5 — Cleanup

  1. Rimuovere il monolite originale
  2. CI/CD pipeline per build/push separati
  3. Monitoring (Prometheus + Grafana) per ogni servizio

8. Rate Limiting Distribuito

Il middleware attuale usa un contatore in-memory per il rate-limiting. Con i microservizi:

# shared/middleware/rate_limit.py
import redis.asyncio as aioredis

class DistributedRateLimiter:
    def __init__(self, redis: aioredis.Redis):
        self._redis = redis

    async def check(self, user_id: str, tier: str) -> bool:
        limits = {"free": 20, "pro": 60, "power": 120, "team": 200}
        max_req = limits.get(tier, 20)
        key = f"rate:{user_id}"

        pipe = self._redis.pipeline()
        pipe.incr(key)
        pipe.expire(key, 60)  # Finestra di 60 secondi
        count, _ = await pipe.execute()

        return count <= max_req

9. Monitoraggio e Logging

# Aggiungere al docker-compose.yml

  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
    restart: unless-stopped

  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    volumes:
      - grafana_data:/var/lib/grafana
    restart: unless-stopped

  loki:
    image: grafana/loki:latest
    restart: unless-stopped

Ogni servizio espone /metrics (Prometheus) e scrive log strutturati (JSON) raccolti da Loki.


10. Sizing VPS Minimo Consigliato

Componente CPU RAM Note
Traefik 0.25 128MB
Auth Service ×2 0.25 ×2 128MB ×2
Chat Service ×2 1.0 ×2 1GB ×2 Il più pesante (LLM calls)
Storage Service ×2 0.5 ×2 256MB ×2 I/O bound
Billing Service 0.25 128MB
Plugin Service 0.25 128MB
PostgreSQL 1.0 1GB
Redis 0.25 256MB
Qdrant 0.5 512MB
MinIO 0.25 256MB
Totale ~6 vCPU ~5.5 GB

Raccomandazione: VPS con 8 vCPU / 16 GB RAM per avere margine. Hetzner CPX41 (~€30/mese) o equivalente.


Riepilogo Decisioni Architetturali

Decisione Scelta Motivazione
API Gateway Traefik Nativo Docker, WebSocket support, service discovery automatico
JWT RS256 (asimmetrico) Verifica distribuita senza contattare Auth Service
WebSocket scaling Redis pub/sub + registry Cross-instance tool-call routing
Rate limiting Redis contatori Distribuito, sliding window
Service communication Redis pub/sub + HTTP interno Asincrono per eventi, sincrono per query
Database PostgreSQL condiviso (un DB, schema separation opzionale) Semplicità; split DB futuro facile
TLS Cloudflare Origin Certificate Zero maintenance, trust Cloudflare
Orchestrazione Docker Compose Sufficiente per un singolo VPS