Pular para o conteúdo

App pronto para a Nebular e para escalar

Para a versão curta e normativa, que serve de briefing a um agent, veja o Contrato de App Nebular.

Esta página é para quem escreve o app (pessoa ou agent). Ela junta o que a plataforma faz hoje com um conjunto de hábitos que evitam reescrever o app quando houver mais de uma cópia dele rodando.

Tema Hoje Futuro
Cópias do serviço Uma por serviço. Não há campo de réplicas na API. Réplicas não existem e não há data prometida. Esta página só prepara o app para o dia em que existirem.
Deploy Blue/green: o container novo passa no health check antes de receber tráfego; o antigo some após uma drenagem. Se o novo falha, o anterior continua no ar. Sem mudança no app.
Recursos resources.memory_mb e resources.cpus por serviço (Escala e recursos). Serviços novos nascem com 512 MB e 0.5 CPU; quando o serviço não define nada, o runtime usa 1 GiB e 1 vCPU. Declare resources explícito e não dependa do padrão. —
Escalar Só vertical (mais memória/CPU, até o máximo do plano). Horizontal, se e quando existir.
Disco Raiz somente leitura. Graváveis: /tmp (tmpfs, 64 MB, sem exec) e o volume do serviço (1 por serviço, 1 a 50 GB). Volume continua pertencendo a um serviço.

Escreva hoje como se houvesse N cópias: custa pouco agora. Cron e worker gerenciados também não existem.

Cada container é efêmero: roda sob gVisor (runsc), é recriado a cada deploy e pode ser reiniciado (limite de memória estourado, queda do app). Nada que esteja só na memória ou no disco do container sobrevive a isso.

Não guarde em variável global, arquivo local ou memória do processo:

  • sessão de login (use token assinado, ou tabela de sessões no banco);
  • cache que precisa ser coerente entre requisições (um cache só de otimização, que pode estar vazio a qualquer momento, é aceitável);
  • uploads (veja a seção de réplicas);
  • contadores, filas e locks (use o banco).

Teste simples: reinicie o app no meio de um fluxo. Se o usuário perde algo, havia estado local.

Toda configuração vem do ambiente: URL do banco, chaves, URLs de outros serviços. Nada de arquivo de config gravado no build com valores de produção, e nada de segredo no repositório.

  • Variáveis do serviço e do projeto: Variáveis e referências. Para apontar para outro serviço use ${{api.PRIVATE_URL}}; para um valor comum a vários serviços, ${{shared.NOME}}.
  • Banco gerenciado: o app lê DATABASE_URL (via referência ${{db.…}}, veja Conexão e referências). Não monte a URL na mão nem use outro banco.
  • Mudar variável só vale no próximo deploy.
  • Segredo nunca em NEXT_PUBLIC_* ou equivalente de bundle de browser.

O app lê a variável PORT e escuta em 0.0.0.0, não em localhost/127.0.0.1. A porta declarada no serviço (port) sempre vence e é a que o container recebe em PORT. Portas abaixo de 1024 não estão disponíveis (Go: "0.0.0.0:" + os.Getenv("PORT"); Node: server.listen(PORT, "0.0.0.0")).

Sem healthcheck_path, qualquer resposta HTTP em / basta. Com ele, o container novo só recebe tráfego quando GET <healthcheck_path> responde 2xx ou 3xx (redirecionamento não é seguido). O tempo total de espera é de cerca de 60 s, seguido de uma janela de estabilização (cerca de 30 s) em que o container não pode cair.

Faça o endpoint responder rápido e sem efeito colateral: 200 OK sem consultar nada, ou no máximo um SELECT 1. Não rode migration, aquecimento pesado nem chamada a terceiros dentro dele. Um app que só abre a porta depois de terminar um trabalho longo falha em activate_no_http.

Ao trocar de versão, a plataforma envia SIGTERM ao container antigo (e SIGKILL depois de um prazo curto, na ordem de segundos). O app deve parar de aceitar conexões novas, terminar as requisições em andamento e sair com código 0.

Em Go, signal.NotifyContext(ctx, syscall.SIGTERM) + srv.Shutdown(ctx); em Node, process.on("SIGTERM", () => server.close(() => process.exit(0))). Evite npm start encadeado em shell: o sinal pode não chegar ao app.

A plataforma lê stdout/stderr (Logs), mascarando valores sensíveis. Não grave arquivo de log. Uma linha JSON por evento, com request_id. Em falha de boot, logue a causa antes de sair: o erro do deploy usa as últimas linhas do container.

7. Assets e dados gerados entram no BUILD, nunca no boot

Seção intitulada “7. Assets e dados gerados entram no BUILD, nunca no boot”

O diretório do app (/app) é somente leitura em runtime. Tudo que o app precisa ter em disco tem de existir quando o container sobe.

Caso real: uma aplicação de música tentava baixar o SoundFont (scripts/get-soundfont.sh) ao iniciar e gravar em data/soundfonts/FluidR3_GM.sf2. O boot falhava com open data/soundfonts/...: no such file or directory e o deploy terminava em activate_app_crashed. Caminhos corretos:

  1. Baixar no build (build_command ou etapa do Dockerfile), fixando versão e hash, e ler de dentro do artefato.
  2. Commitar o arquivo, se o tamanho permitir.
  3. Se é grande e não muda por deploy, baixar uma vez para um volume e reaproveitar.

Nunca: baixar ou gerar em /app/... no boot.

  • /tmp: 64 MB, na memória, sem exec, apagado a cada reinício e deploy.
  • Volume de serviço: sobrevive a deploys, mas é de um serviço. Serve para dados que não precisam ser vistos por outra cópia (ex.: arquivo SQLite, cache refazível).

Se o app grava em /app/data, crie o volume nessa pasta ou leia o caminho de uma variável (DATA_DIR), nunca fixo no código.

Requisições chegam duas vezes (retry, deploy no meio da chamada). Aceite Idempotency-Key com UNIQUE no banco, use INSERT ... ON CONFLICT DO NOTHING e registre efeitos externos (e-mail, cobrança) como “já enviado”.

Não há agendador nem worker gerenciado, e setInterval/cron dentro do app reinicia a cada deploy e rodaria N vezes com réplicas. Alternativas: um agendador externo (cron na internet ou GitHub Actions schedule) chamando um endpoint idempotente (POST /internal/jobs/limpeza com segredo no cabeçalho e lock no banco); trabalho sob demanda disparado pela ação do usuário; ou fila no banco (veja abaixo). O serviço de worker também precisa responder HTTP hoje.

No blue/green a versão antiga e a nova convivem por segundos no mesmo banco. Faça migrations idempotentes e protegidas por advisory lock (duas cópias subindo juntas não migram duas vezes), em dois tempos (adicione a coluna antes de usá-la; remova a antiga só no deploy seguinte) e sem migração pesada no boot, que atrasa o health check (Migrações).

Réplicas não existem hoje. Quando existirem, o tráfego será dividido entre cópias idênticas, sem afinidade: duas requisições do mesmo usuário podem cair em cópias diferentes. Cada linha abaixo é um defeito latente desse dia.

Se o app faz isto Com N cópias vira Faça isto
Sessão em memória ou em arquivo usuário “desloga” ao cair em outra cópia token assinado (JWT/cookie assinado) ou tabela de sessões no banco gerenciado
Upload gravado em /tmp, no disco ou no volume do serviço (que é de uma cópia só) arquivo existe só em uma cópia e some no reinício ou não é visto pelas outras envie direto para a CDN/galeria ou outro armazenamento de objetos por API, e guarde só a referência no banco
Fila ou lock em memória dois consumidores pegam o mesmo item fila no banco (FOR UPDATE SKIP LOCKED) e lock pelo banco (pg_advisory_lock)
Job periódico dentro do app roda N vezes agendador externo chamando endpoint idempotente, com lock no banco
UPDATE jobs SET status = 'running', started_at = now()
WHERE id = (
SELECT id FROM jobs WHERE status = 'pending'
ORDER BY created_at LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING *;

Use pool de conexões pequeno por cópia (5 a 10): com N cópias o total é N vezes o pool e o banco tem limite de conexões.

Serviços do projeto se falam pelo nome (Rede privada). Saída para a internet é permitida; bloqueados: SMTP direto (use e-mail por API HTTPS), rede interna, host e metadados da nuvem (Isolamento).

  • Escuta em 0.0.0.0 na porta de PORT e a port do serviço bate com ela.
  • healthcheck_path responde 2xx/3xx rápido, sem trabalho pesado.
  • Nada é gravado em /app; escritas vão só para /tmp ou para o volume, e o caminho vem de variável de ambiente.
  • Assets, modelos e arquivos gerados são baixados ou criados no build (ou commitados).
  • Toda config e todo segredo vêm de variável; DATABASE_URL vem da referência do banco.
  • O boot não depende de outro serviço estar de pé (tenta de novo e degrada em vez de abortar).
  • SIGTERM encerra o app limpo em poucos segundos.
  • resources.memory_mb/cpus declarados com folga para o pico (o estouro de memória reinicia o container).
  • Migrations idempotentes, com lock e compatíveis com a versão anterior.
  • Sem cron, fila ou lock em memória; sem sessão ou upload em disco local.

Um deploy que falha termina em failed com um error.code estável (Deploys e status); leia também o log de build e do container (Logs). A versão anterior continua no ar.

error.code Causa mais comum Correção
activate_readonly_write o app tentou gravar em /app/... (EROFS, mkdir, open ...: read-only file system) gravar em /tmp; ou criar um volume na pasta indicada pela mensagem; ou gerar o arquivo no build
activate_app_crashed o processo caiu logo ao iniciar: variável ausente, arquivo ausente (asset não gerado no build), exceção no boot, banco inalcançável ler a “primeira linha de erro” na mensagem e o log do container; definir a variável; incluir o arquivo no build; tratar a falha de conexão sem abortar
activate_no_http o app roda mas não respondeu na porta: escuta em 127.0.0.1/outra porta, ou demora a abrir a porta, ou healthcheck_path devolve 4xx/5xx escutar em 0.0.0.0:$PORT; declarar port; abrir a porta primeiro e fazer o trabalho pesado depois; corrigir o caminho do health check
activate_oom o app usou mais memória que o limite do serviço subir resources.memory_mb, reduzir caches e concorrência; novo deploy
image_requires_root a imagem exige root (container encerrou nos primeiros 30 s) declarar build.user (uid[:gid], nunca root) ou usar uma variante sem privilégio
build_failed e família (build_install_failed, build_script_failed, build_detect_failed, build_clone_failed, build_copy_failed) instalação de dependência, script de build ou detecção do builder falhou ler o log de build; fixar versões; declarar build.builder/build_command; root_dir correto em monorepo
build_oom, build_timeout, build_disk_full o build estourou memória, tempo ou disco reduzir paralelismo e o que entra no artefato; tirar etapas pesadas do build
unresolved_reference (422 ao disparar o deploy) ${{svc.VAR}} aponta para variável ou serviço que não existe criar a variável/serviço ou corrigir o nome (Tabela de erros)

Confirme PORT, escrita só em /tmp/volume e config por variável antes de codar; gere assets no build; não crie cron nem fila em memória (proponha agendador externo); não diga que o app “tem réplicas” (hoje há uma cópia por serviço); em falha, leia error.code antes de mexer no código.