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

11 KiB
Raw Blame History

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)
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):

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.