prompt inicial do projeto
This commit is contained in:
292
docs/05-INFRA-DOCKER.md
Normal file
292
docs/05-INFRA-DOCKER.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user