Files
Projeto-Portaria/docs/05-INFRA-DOCKER.md

305 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 # 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`:
```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) |
| 5000060000 | UDP | Mídia WebRTC |
| 3478 | UDP/TCP | STUN/TURN |
| 4916049200 | 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 4916049200 são 40 portas ≈ **20 chamadas simultâneas via TURN**. Suficiente para o piloto (TURN só entra quando o UDP direto falha, tipicamente 1020% 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 3060s. Um condomínio de 200 unidades gera ~50 visitas/dia com pico de 35 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=
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:**
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.