// produto próprio

Hipotle

Um jogo diário de diagnóstico com casos brasileiros, e como eu redesenhei a infraestrutura depois que ele superou a primeira configuração.

Papel
Fundador e engenheiro
Status
Em produção

Next.js · TypeScript · Azure Container Apps · Azure Front Door · Bicep · Postgres (Neon) · Drizzle · Key Vault · Application Insights

acessos no total
200.000+acessos no total
acessos nas últimas 24h
1.318acessos nas últimas 24h
especialidades médicas
35especialidades médicas

Visão geral

O Hipotle é um jogo diário de diagnóstico com casos brasileiros, para médicos e estudantes de medicina. Começou como um projeto feito com vibe coding e cresceu além do que aquela primeira configuração aguentava, então redesenhei a infraestrutura do jeito que faria no trabalho. Esta página mostra o que deu errado na Vercel, como ele roda hoje e as decisões por trás.

O que aconteceu na Vercel

A Vercel foi uma boa escolha no começo. O tráfego era baixo, e o modelo serverless tornava o deploy e a escala sem esforço.

Quando o Hipotle passou de 200.000 acessos, o desempenho começou a piorar, principalmente quando muita gente jogava ao mesmo tempo. O app não estava falhando em atender as requisições. A infraestrutura estava adicionando overhead a cada uma delas.

Cada requisição de API podia iniciar uma nova invocação de function serverless, e cada invocação tinha que montar a sua própria comunicação com o banco. O driver HTTP do Neon adicionava mais idas e vindas de rede a cada query. Com pouca concorrência, esse custo era invisível. Conforme a concorrência crescia, virava latência, e os endpoints que precisavam de várias leituras em sequência sofriam mais. O /api/me é o exemplo mais claro, com seis leituras uma depois da outra:

// /api/me, simplificado: cada await é uma ida e volta ao banco
const settings = await getSettings(userId);
const claims = await getClaims(userId);
const universidade = await getUniversidade(userId);
const stats = await getStats(userId);
const badges = await getBadges(userId);
const pontos = await getPontosTotais(userId);

Cada invocação pagava essas idas e voltas de novo.

Mudando para o Container Apps

Movi o backend para o Azure Container Apps, que dá à aplicação um runtime persistente em vez de execuções de vida curta. Isso me permitiu:

  • manter instâncias quentes, em vez de inicializar um novo ambiente de execução toda hora
  • reutilizar conexões com o banco por meio de connection pooling
  • definir um número mínimo de réplicas, sem atrasos de cold start
  • controlar concorrência, escala e alocação de recursos

O pooling é a parte que mais importa. O pool é criado uma vez por processo de container e compartilhado por todas as requisições, em vez de conectar de novo a cada invocação:

import { Pool } from "pg";
import { drizzle } from "drizzle-orm/node-postgres";

// Esboço simplificado: um pool por processo de container, compartilhado por todas as requisições
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: Number(process.env.DB_POOL_MAX),
});

export const db = drizzle(pool);

O resultado foi tempo de resposta mais consistente sob tráfego concorrente, e um backend que eu posso continuar ajustando conforme o app cresce. A diferença se resume a isto:

Vercel serverlessAzure Container Apps
Execuções de function de vida curtaProcessos de aplicação de longa duração
Ciclo de vida da conexão preso às invocaçõesConexões com o banco reutilizáveis
Escala serverlessEscala de réplicas configurável
Menos controle sobre o runtimeControle de CPU, memória e concorrência

Arquitetura

Azure (Bicep)
AI content pipeline

SERVICE

Azure Front Door

Explicit cache rules

SERVICEREPLICAS

Next.js app

Azure Container Apps: VNet, autoscaling

  • Next.js
  • TypeScript
  • SSR + SEO
  • PWA

SERVICE

Admin platform

RBAC and audit log

EXTERNAL

Key Vault

Managed Identity access

EXTERNAL

Application Insights

SLO alerting

SERVICE

Generate

35 specialties

SERVICE

Validate

Semantic duplicate detection

SERVICE

Import

Into the case library

PEOPLE

Players

PEOPLE

Case reviewers

Medical students

DATASTORE

Neon Postgres

Drizzle ORM

Hipotle architectureArraste os nós para explorar
Dependências (texto)
  • players -> frontdoor [HTTPS]
  • frontdoor -> app [routes and caches]
  • app -> db [reads cases]
  • app -> vault [secrets]
  • app -> insights [telemetry]
  • reviewers -> admin [review and manage]
  • admin -> db [writes, audit log]
  • generate -> validate [candidate cases]
  • validate -> import [accepted cases]
  • import -> db [imports]

Compute e borda

O app é Next.js e TypeScript, empacotado como container e executado no Azure Container Apps. Ele fica dentro de uma rede virtual, mantém um número mínimo de réplicas quentes e escala conforme a carga, e por isso o diagrama mostra uma pilha de réplicas em vez de uma caixa só.

O Azure Front Door fica na frente, no tier Standard, com regras de cache explícitas. Eu decido o que é cacheado na borda em vez de deixar nos padrões.

Dados e secrets

Os casos ficam no Postgres do Neon, acessado pelo Drizzle ORM. O banco foi a única peça que não mudou quando o resto mudou. O modelo de dados é pequeno: partidas, usuários e suas configurações, badges e pontos, e um ranking calculado com uma janela ROW_NUMBER sobre pontos agrupados.

Os secrets ficam no Key Vault, e o app os lê por uma Managed Identity. Isso significa que ele não guarda uma credencial própria para buscá-los.

Observabilidade

O Application Insights coleta a telemetria do app e alerta sobre os SLOs que eu defini. Eu queria que o monitoramento fosse meu, e não algo que vinha embutido na hospedagem.

Infraestrutura como código

Todo o ambiente é Bicep modular. Staging e produção são construídos a partir dos mesmos módulos, então são idênticos, e cada imagem de container é versionada por commit. Qualquer commit corresponde exatamente ao que está rodando. Eu registro as decisões de arquitetura como ADRs.

Este é um esboço simplificado de como o app, a identidade dele e os secrets se encaixam no Bicep. Todo valor vem de um parâmetro, o que mantém staging e produção idênticos:

resource app 'Microsoft.App/containerApps@2024-03-01' = {
  name: appName
  location: location
  identity: { type: 'SystemAssigned' }
  properties: {
    managedEnvironmentId: environmentId // o ambiente integrado à VNet
    configuration: {
      ingress: { external: true, targetPort: targetPort }
      secrets: [
        { name: 'database-url', keyVaultUrl: databaseUrlSecretUri, identity: 'system' }
      ]
    }
    template: {
      containers: [
        {
          name: 'web'
          image: image // versionada por commit
          env: [{ name: 'DATABASE_URL', secretRef: 'database-url' }]
        }
      ]
      scale: {
        minReplicas: minReplicas // instâncias quentes, sem cold start
        maxReplicas: maxReplicas
        rules: [
          { name: 'http', http: { metadata: { concurrentRequests: string(concurrentRequests) } } }
        ]
      }
    }
  }
}

O pipeline de conteúdo e o lado admin

Os casos vêm de um pipeline de IA com três etapas: gerar, validar e importar. Ele cobre 35 especialidades médicas, e a validação checa duplicatas tanto por similaridade quanto por significado, o que mantém a biblioteca de casos limpa. Quatro estudantes de medicina revisam os casos e cuidam da comunidade.

Existe uma plataforma de admin com acesso por papéis e log de auditoria. O jogo em si é um PWA instalável com uma camada renderizada no servidor, para os buscadores conseguirem lê-lo.

Decisões

A maior decisão foi mover compute e borda da Vercel para o Azure, com o banco de dados ficando no Neon. O motivo principal foi o runtime: processos persistentes em vez de functions de vida curta. Eu também queria containers padrão em vez de um runtime proprietário, controle explícito de cache e CDN, um ambiente que eu consiga reconstruir a partir de código e observabilidade própria.

Mantive do tamanho certo de propósito. Escolhi Container Apps em vez de AKS, porque o AKS é mais cluster do que essa escala precisa, e Front Door Standard em vez de Premium, o que significa sem WAF pago.

Resultado

O tempo de resposta agora é consistente sob tráfego concorrente, inclusive nos dias de pico. O Hipotle passou de 200.000 acessos, com cerca de 1.000 por dia e 500 jogos. Continuo sendo o único engenheiro, e o Claude ainda escreve boa parte do código.

← Projetos selecionados