# 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` ```yaml 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 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 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 SPRING_DATASOURCE_USERNAME: portaria SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} LIVEKIT_URL: ws://livekit: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 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 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`: ```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. ## 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`): ```bash 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= 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:** 1. **SLOs** — latência QR→toque (p50/p95/p99), taxa de atendimento, latência de entrega, abandono 2. **Fila e outbox** — profundidade de `scheduled_jobs`, idade do evento mais antigo não publicado, taxa de retry 3. **Notificações** — push enviados, entregues e falhados por plataforma; **tokens inválidos** (indicador precoce de morador que desinstalou) 4. **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.