11 KiB
05 — Infraestrutura e Docker
Piloto: um VPS com Compose. Produção: mesmo código, só configuração diferente. O backend é stateless desde o dia 1, então migrar é config, não reescrita.
1. Topologia
PILOTO (1 VPS — 8 vCPU, 16GB, link ≥ 1Gbps)
traefik · backend · postgres · livekit · coturn · minio · observabilidade
+ backup automático do Postgres com restore testado
PRODUÇÃO (mesmo compose, override de config)
├─ Postgres gerenciado (Neon / RDS) com réplica e PITR
├─ 2+ réplicas do backend atrás do Traefik
├─ LiveKit + coturn em máquina de banda dedicada
└─ MinIO → S3 (mesma API)
Por que o piloto num VPS só é aceitável: o volume real é de ~0,3 req/s, e a falha é mitigada por backup testado e pela contingência física obrigatória (07-REQUISITOS-DE-CAMPO.md). O que não é aceitável é o código depender de rodar em instância única — daí a regra de ser stateless.
2. infra/docker-compose.yml
services:
traefik:
image: traefik:v3.3
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --certificatesresolvers.le.acme.tlschallenge=true
- --certificatesresolvers.le.acme.email=${ACME_EMAIL}
- --certificatesresolvers.le.acme.storage=/acme/acme.json
ports: ["80:80", "443:443"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik_acme:/acme
restart: unless-stopped
postgres:
image: postgis/postgis:17-3.5
environment:
POSTGRES_DB: portaria
POSTGRES_USER: portaria # dono do schema — usado só pelo Flyway
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD} # lido por init/02-roles.sh → cria portaria_app
volumes:
- pgdata:/var/lib/postgresql/data
- ./postgres/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U portaria"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
backend:
image: ghcr.io/${GH_OWNER}/portaria-backend:${TAG:-latest}
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/portaria
# aplicação NUNCA conecta como dona do schema — senão REVOKE e RLS são inócuos (02 §8)
SPRING_DATASOURCE_USERNAME: portaria_app
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_APP_PASSWORD}
SPRING_FLYWAY_USER: portaria
SPRING_FLYWAY_PASSWORD: ${POSTGRES_PASSWORD}
# livekit está em network_mode:host — o nome de serviço não resolve; alcançado via gateway do host
LIVEKIT_URL: ws://host.docker.internal:7880
LIVEKIT_API_KEY: ${LIVEKIT_API_KEY}
LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET}
STORAGE_ENDPOINT: http://minio:9000
STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER}
STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
FCM_CREDENTIALS_PATH: /secrets/fcm.json
APNS_KEY_PATH: /secrets/apns.p8
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
volumes:
- ./secrets:/secrets:ro
extra_hosts:
- host.docker.internal:host-gateway
depends_on:
postgres: { condition: service_healthy }
minio: { condition: service_started }
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/actuator/health/readiness"]
interval: 15s
timeout: 5s
retries: 5
start_period: 60s
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`api.${DOMAIN}`)
- traefik.http.routers.api.tls.certresolver=le
- traefik.http.services.api.loadbalancer.server.port=8080
restart: unless-stopped
# SFU — host network é necessário: o LiveKit precisa das faixas UDP reais
livekit:
image: livekit/livekit-server:latest
command: --config /etc/livekit.yaml
network_mode: host
volumes:
- ./livekit/livekit.yaml:/etc/livekit.yaml:ro
restart: unless-stopped
coturn:
image: coturn/coturn:latest
network_mode: host
command: >
-n --log-file=stdout --min-port=49160 --max-port=49200
--realm=${DOMAIN} --use-auth-secret --static-auth-secret=${TURN_SECRET}
--external-ip=${PUBLIC_IP} --no-cli --no-tlsv1 --no-tlsv1_1
restart: unless-stopped
redis: # exclusivo do LiveKit e do rate limit
image: redis:7-alpine # NUNCA para dados que não podem se perder
command: redis-server --save "" --appendonly no
ports:
- 127.0.0.1:6379:6379 # livekit está em host network e alcança via localhost; nunca exponha fora do host
restart: unless-stopped
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- miniodata:/data
restart: unless-stopped
web-visitor:
image: ghcr.io/${GH_OWNER}/portaria-web-visitor:${TAG:-latest}
labels:
- traefik.enable=true
- traefik.http.routers.visitor.rule=Host(`v.${DOMAIN}`)
- traefik.http.routers.visitor.tls.certresolver=le
restart: unless-stopped
web-admin:
image: ghcr.io/${GH_OWNER}/portaria-web-admin:${TAG:-latest}
labels:
- traefik.enable=true
- traefik.http.routers.admin.rule=Host(`admin.${DOMAIN}`)
- traefik.http.routers.admin.tls.certresolver=le
restart: unless-stopped
volumes:
pgdata: {}
miniodata: {}
traefik_acme: {}
Observabilidade (otel-collector, prometheus, grafana, loki) fica em docker-compose.observability.yml, ativada por perfil.
Domínios: v. (visitante) curto de propósito — ele aparece impresso no QR e às vezes é digitado à mão.
3. LiveKit e coturn
infra/livekit/livekit.yaml:
port: 7880
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 60000
use_external_ip: true
keys:
${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}
redis:
address: localhost:6379 # obrigatório com mais de uma instância
turn:
enabled: false # coturn cuida do TURN
room:
empty_timeout: 120
max_participants: 6
Portas — abrir no firewall
| Porta | Protocolo | Serviço |
|---|---|---|
| 80, 443 | TCP | Traefik |
| 7880 | TCP | LiveKit signaling (atrás do Traefik) |
| 7881 | TCP | LiveKit ICE/TCP (fallback) |
| 50000–60000 | UDP | Mídia WebRTC |
| 3478 | UDP/TCP | STUN/TURN |
| 49160–49200 | UDP | Relay do coturn |
Erro clássico: esquecer a faixa UDP. A chamada conecta, a sinalização funciona, e o vídeo nunca aparece — sintoma que parece bug de aplicação e é firewall.
Redis não é opcional com múltiplas instâncias do LiveKit: sem ele, o estado das salas se divide silenciosamente entre nós e dois participantes da mesma sala não se enxergam.
Capacidade de relay do coturn: a faixa 49160–49200 são 40 portas ≈ 20 chamadas simultâneas via TURN. Suficiente para o piloto (TURN só entra quando o UDP direto falha, tipicamente 10–20% das chamadas), mas amplie a faixa em produção junto com o firewall.
4. Dimensionamento de banda
Uma chamada de portaria tem 2 participantes, vídeo em 480p a ~600kbps mais áudio. O SFU recebe e reenvia:
1 chamada ≈ 1,2 Mbps entrada + 1,2 Mbps saída ≈ 2,4 Mbps
| Chamadas simultâneas | Banda no SFU |
|---|---|
| 10 | ~24 Mbps |
| 50 | ~120 Mbps |
| 100 | ~240 Mbps |
| 200 | ~480 Mbps |
Chamadas de portaria duram 30–60s. Um condomínio de 200 unidades gera ~50 visitas/dia com pico de 3–5 simultâneas. Um VPS de 1Gbps atende com folga dezenas de condomínios.
O que estoura banda é gravação (módulo video_recording): o LiveKit Egress soma outro fluxo por chamada e escreve no MinIO. Ative com quota por plano.
Limite de resolução no servidor, não no cliente: 480p é suficiente para reconhecer alguém na portaria, e 1080p multiplicaria o custo por quatro sem ganho de decisão.
5. Variáveis de ambiente
infra/.env.example (nunca commitar .env):
DOMAIN=portaria.exemplo.com.br
PUBLIC_IP=203.0.113.10
ACME_EMAIL=ops@exemplo.com.br
GH_OWNER=sua-org
TAG=latest
POSTGRES_PASSWORD=
POSTGRES_APP_PASSWORD=
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
TURN_SECRET=
MINIO_ROOT_USER=
MINIO_ROOT_PASSWORD=
JWT_SIGNING_KEY=
Credenciais de FCM e APNs são arquivos em ./secrets/, montados read-only. Nada de secret em imagem.
6. Observabilidade
Actuator expõe /actuator/health (liveness e readiness), /actuator/prometheus e /actuator/info. OpenTelemetry exporta traces do backend, do LiveKit e do Postgres.
Dashboards obrigatórios no Grafana:
- SLOs — latência QR→toque (p50/p95/p99), taxa de atendimento, latência de entrega, abandono
- Fila e outbox — profundidade de
scheduled_jobs, idade do evento mais antigo não publicado, taxa de retry - Notificações — push enviados, entregues e falhados por plataforma; tokens inválidos (indicador precoce de morador que desinstalou)
- Mídia — chamadas ativas, banda, falhas de conexão, uso do TURN
Alertas:
| Alerta | Condição | Severidade |
|---|---|---|
| Outbox atrasado | evento não publicado > 60s | crítico |
| Fila travada | job vencido > 30s | crítico |
| Push falhando | taxa de falha > 20% em 5min | crítico |
| LiveKit indisponível | circuit breaker aberto | crítico |
| Taxa de atendimento baixa | < 50% num condomínio por 7 dias | negócio |
| Disco do MinIO | > 80% | aviso |
Os dois últimos são de negócio, não de infraestrutura — viram tarefa de customer success, não plantão.
7. Backup e restore
Postgres pg_dump diário + WAL archiving retenção 30 dias
MinIO replicação de bucket ou snapshot diário retenção 30 dias
Segredos fora do servidor, em cofre
Restore é testado mensalmente em ambiente separado, com o tempo medido. Backup não verificado não é backup — e aqui perder o banco significa não só perder dados, mas deixar prédios inacessíveis.
8. CI/CD
push → GitHub Actions
├─ backend: ./gradlew build (unit + Testcontainers)
├─ shared: ./gradlew allTests (JVM + iOS)
├─ web: pnpm lint && pnpm test && pnpm build
│ └─ contraste + orçamento de bundle (falha se visitor > 200KB gzip)
├─ docker compose config (valida o compose)
└─ tag na main → build/push das imagens → deploy por SSH
O orçamento de bundle do visitante é gate de CI, não recomendação. A escolha de React em vez de Wasm foi motivada por TTI; sem gate automático, essa vantagem evapora em três sprints.
9. Modo degradado
Quando o circuit breaker do LiveKit abre, o backend rebaixa automaticamente e emite DEGRADED_MODE no WebSocket:
LiveKit fora ─► modo AUDIO
└─► modo FOTO_TEXTO (visitante manda foto, morador aprova)
└─► FILA_OPERADOR (resolução por telefone)
Backend fora ─► contingência física (botão + telefone do responsável)
documentada em 07-REQUISITOS-DE-CAMPO.md
O nível vigente aparece em /actuator/health e no painel admin. Degradar em silêncio é pior que degradar — o síndico precisa saber que o sistema está em modo reduzido antes de receber a reclamação.