Logosulfite.app
rafagazani/sulfite 999999

Horizontal Scaling — Multi-container deploy#

Guia para rodar sulfite_server com múltiplos containers/réplicas atrás de um load balancer (Render scaling, Kubernetes, ECS, Fly.io, etc). Implementa a arquitetura stateless descrita pela RFC-037.

TL;DR: ative SULFITE_CLUSTER_MODE=1 e aponte todos os stores para backends compartilhados (Postgres/S3). O servidor se recusa a bootar com qualquer store em memory ou arquivo local quando o cluster mode está ativo.

Arquitetura#

                  ┌─────────────────────┐
 requests ───▶    │   Load Balancer     │
                  └──────────┬──────────┘
                             │
          ┌──────────────────┼──────────────────┐
          ▼                  ▼                  ▼
    ┌──────────┐       ┌──────────┐       ┌──────────┐
    │ sulfite  │       │ sulfite  │       │ sulfite  │
    │ replica  │       │ replica  │       │ replica  │
    │    #1    │       │    #2    │       │    #3    │
    └────┬─────┘       └────┬─────┘       └────┬─────┘
         │                  │                  │
         └──────────────┬───┴──────────────────┘
                        │
          ┌─────────────┼───────────────────┐
          ▼             ▼                   ▼
    ┌──────────┐   ┌──────────┐    ┌───────────────┐
    │ Postgres │   │   S3 /   │    │  Redis (opt.) │
    │  (HA)    │   │   R2     │    │  rate limit   │
    └──────────┘   └──────────┘    └───────────────┘

Cada réplica é stateless: processa requisições e delega qualquer estado persistente para Postgres, S3 ou Redis. Qualquer réplica pode atender qualquer request — não há sticky sessions nem afinidade de processo.

O que precisa de backend compartilhado#

StoreEnv varBackends suportados
Reports / CRUDSTORAGE_BACKENDpostgres
Rate limiter RATE_LIMIT_STORE_BACKEND postgres (ou redis — opcional)
Job queueJOB_QUEUE_BACKENDpostgres
OTP codesOTP_STORE_BACKENDpostgres
Share links (metadata) SHARE_LINK_STORE_BACKEND postgres
Share outputs (binários) SHARE_OUTPUT_STORE_BACKEND s3
Tokens (auth/edit)TOKEN_STORE_BACKENDpostgres
Banned IPsBANNED_IP_STORE_BACKENDpostgres
Connection registry CONNECTION_REGISTRY_BACKEND postgres

Qualquer store em memory, file ou sqlite causa perda de dados ou comportamento inconsistente entre réplicas. O servidor detecta isso no boot e aborta com uma mensagem descritiva.

Env vars (cluster mode mínimo)#

# Ativa a asserção de boot — falha cedo se algo não estiver compartilhado
SULFITE_CLUSTER_MODE=1

# Storage principal
STORAGE_BACKEND=postgres

# Postgres compartilhado (usado por todos os stores abaixo)
DATABASE_URL=postgresql://sulfite_app:PASSWORD@db.your-project.supabase.co:5432/sulfite?sslmode=require

# Stores auxiliares — todos em Postgres
RATE_LIMIT_STORE_BACKEND=postgres
JOB_QUEUE_BACKEND=postgres
OTP_STORE_BACKEND=postgres
SHARE_LINK_STORE_BACKEND=postgres
TOKEN_STORE_BACKEND=postgres
BANNED_IP_STORE_BACKEND=postgres
CONNECTION_REGISTRY_BACKEND=postgres

# Binários de share em S3 (ou R2 / MinIO)
SHARE_OUTPUT_STORE_BACKEND=s3
S3_ENDPOINT=https://s3.us-east-1.amazonaws.com
S3_REGION=us-east-1
S3_BUCKET=sulfite-shares-prod
S3_ACCESS_KEY=AKIA...
S3_SECRET_KEY=...
S3_PREFIX=shares/               # opcional — default 'shares/'

# Secrets que DEVEM ser idênticos entre réplicas
AUTH_SECRET=...                 # JWT de usuários
EDIT_TOKEN_SECRET=...           # tokens de edição de report
ENCRYPTION_KEY=...              # reports criptografados

Importante: AUTH_SECRET, EDIT_TOKEN_SECRET e ENCRYPTION_KEY precisam ser idênticos em todas as réplicas. Se cada réplica gerar um valor próprio, um token emitido pela réplica A será rejeitado pela réplica B.

Endpoints S3 suportados#

O cliente S3 do Sulfite usa apenas o protocolo S3 + SigV4, então funciona com:

  • AWS S3https://s3.<region>.amazonaws.com
  • Cloudflare R2https://<account>.r2.cloudflarestorage.com, S3_REGION=auto
  • MinIO (self-hosted) — http://minio.local:9000
  • Backblaze B2 (S3 API) — https://s3.<region>.backblazeb2.com

Não há SDK pesado — é uma implementação direta de PUT/GET/DELETE/LIST com assinatura SigV4, validada contra Moto e MinIO em testes unitários.

Deploy em providers#

Render (Blueprint)#

Aumente numInstances no render.yaml:

services:
  - type: web
    name: sulfite-server
    numInstances: 3         # << scaling horizontal
    plan: standard
    envVars:
      - key: SULFITE_CLUSTER_MODE
        value: "1"
      - key: STORAGE_BACKEND
        value: postgres
      # ... demais env vars compartilhadas

⚠️ Não use disk: — discos persistentes na Render são por-instância e NÃO são compartilhados entre réplicas. Em cluster mode, o servidor nem tentará escrever em disco local.

Ver RENDER.md para o blueprint completo, incluindo a seção "HA / Multi-container".

Kubernetes#

apiVersion: apps/v1
kind: Deployment
metadata:
  name: sulfite-server
spec:
  replicas: 3
  template:
    spec:
      containers:
        - name: server
          image: ghcr.io/rafagazani/sulfite-server:latest
          envFrom:
            - secretRef:
                name: sulfite-secrets    # DATABASE_URL, S3 keys, AUTH_SECRET...
          env:
            - name: SULFITE_CLUSTER_MODE
              value: "1"
            - name: STORAGE_BACKEND
              value: postgres
            - name: RATE_LIMIT_STORE_BACKEND
              value: postgres
            - name: JOB_QUEUE_BACKEND
              value: postgres
            - name: OTP_STORE_BACKEND
              value: postgres
            - name: SHARE_LINK_STORE_BACKEND
              value: postgres
            - name: TOKEN_STORE_BACKEND
              value: postgres
            - name: BANNED_IP_STORE_BACKEND
              value: postgres
            - name: CONNECTION_REGISTRY_BACKEND
              value: postgres
            - name: SHARE_OUTPUT_STORE_BACKEND
              value: s3
          livenessProbe:
            httpGet: { path: /health, port: 8090 }
          readinessProbe:
            httpGet: { path: /health, port: 8090 }

Rolling updates funcionam sem drain especial: novas réplicas leem estado do Postgres/S3 direto; conexões antigas terminam e reconectam na próxima réplica.

Docker Compose (dev / staging)#

services:
  sulfite:
    image: sulfite-server
    deploy:
      replicas: 2
    environment:
      SULFITE_CLUSTER_MODE: "1"
      STORAGE_BACKEND: postgres
      DATABASE_URL: postgresql://postgres:dev@postgres:5432/sulfite?sslmode=disable
      RATE_LIMIT_STORE_BACKEND: postgres
      JOB_QUEUE_BACKEND: postgres
      OTP_STORE_BACKEND: postgres
      SHARE_LINK_STORE_BACKEND: postgres
      TOKEN_STORE_BACKEND: postgres
      BANNED_IP_STORE_BACKEND: postgres
      CONNECTION_REGISTRY_BACKEND: postgres
      SHARE_OUTPUT_STORE_BACKEND: s3
      S3_ENDPOINT: http://minio:9000
      S3_BUCKET: sulfite-shares
      S3_ACCESS_KEY: minioadmin
      S3_SECRET_KEY: minioadmin

  postgres:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: dev

  minio:
    image: minio/minio
    command: server /data
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin

Boot-time assertions#

Com SULFITE_CLUSTER_MODE=1, o servidor executa um conjunto de checks antes de abrir a porta. Exemplos de mensagens de erro:

StateError: Cluster mode safety check failed:
  - STORAGE_BACKEND=sqlite is not safe for multi-container: reports stored
    on one replica are invisible to others. Set STORAGE_BACKEND=postgres.
  - RATE_LIMIT_STORE_BACKEND=memory is not safe for multi-container:
    rate-limit counters are not shared between replicas. Set to postgres.
  - SHARE_OUTPUT_STORE_BACKEND=file is not safe for multi-container: ...

Consertar cada linha, reiniciar, e só então o servidor sobe. Essa checagem é propositalmente rude — é preferível falhar o boot no CI/CD do que descobrir em produção que tokens de edição não validam cross-replica.

Desativar o check (dev local)#

SULFITE_CLUSTER_MODE=0    # default — roda single-container com SQLite se quiser

Observabilidade multi-réplica#

Cada réplica expõe /health independentemente. Ao escalar, verifique:

  • Postgres pool saturation — cada réplica abre seu próprio pool (maxConnectionCount: 10, hardcoded em bin/sulfite_server.dart). Com N réplicas você consome N×10 conexões no banco; Postgres gerenciado (Supabase, RDS) geralmente tem limite de 100–500 conexões. Para ajustar, edite _buildPool() ou coloque um PgBouncer na frente.
  • Rate-limit hot keys — com store em Postgres, incrementos concorrentes de IPs agressivos viram contenção na linha. Em escala alta (> 50 req/s por IP sustentado), considere RATE_LIMIT_STORE_BACKEND=redis.
  • S3 eventual consistency — AWS S3 já é strong read-after-write desde
    1. R2 também. MinIO e B2 também. Não é um problema prático hoje, mas vale saber se você testar contra um backend antigo.

Ver também#