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=1e aponte todos os stores para backends compartilhados (Postgres/S3). O servidor se recusa a bootar com qualquer store emmemoryou 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#
| Store | Env var | Backends suportados |
|---|---|---|
| Reports / CRUD | STORAGE_BACKEND | postgres |
| Rate limiter | RATE_LIMIT_STORE_BACKEND |
postgres (ou redis — opcional) |
| Job queue | JOB_QUEUE_BACKEND | postgres |
| OTP codes | OTP_STORE_BACKEND | postgres |
| Share links (metadata) | SHARE_LINK_STORE_BACKEND |
postgres |
| Share outputs (binários) | SHARE_OUTPUT_STORE_BACKEND |
s3 |
| Tokens (auth/edit) | TOKEN_STORE_BACKEND | postgres |
| Banned IPs | BANNED_IP_STORE_BACKEND | postgres |
| 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_SECRETeENCRYPTION_KEYprecisam 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 S3 —
https://s3.<region>.amazonaws.com -
Cloudflare R2 —
https://<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 embin/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
- 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#
- RFC-037 — motivação e design
- docs/server/config.md — env vars completas
- RENDER.md — deploy na Render