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 serverless | Azure Container Apps |
|---|---|
| Execuções de function de vida curta | Processos de aplicação de longa duração |
| Ciclo de vida da conexão preso às invocações | Conexões com o banco reutilizáveis |
| Escala serverless | Escala de réplicas configurável |
| Menos controle sobre o runtime | Controle de CPU, memória e concorrência |
Arquitetura
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.