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.
O que existe hoje e o que não existe
Seção intitulada “O que existe hoje e o que não existe”| 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.
Princípios (12-factor aplicados à Nebular)
Seção intitulada “Princípios (12-factor aplicados à Nebular)”1. Processo sem estado
Seção intitulada “1. Processo sem estado”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.
2. Config por variável de ambiente
Seção intitulada “2. Config por variável de ambiente”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.
3. Escute em 0.0.0.0:$PORT
Seção intitulada “3. Escute em 0.0.0.0:$PORT”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")).
4. Health check barato
Seção intitulada “4. Health check barato”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.
5. Shutdown gracioso no SIGTERM
Seção intitulada “5. Shutdown gracioso no SIGTERM”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.
6. Logs no stdout, em JSON
Seção intitulada “6. Logs no stdout, em JSON”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:
- Baixar no build (
build_commandou etapa doDockerfile), fixando versão e hash, e ler de dentro do artefato. - Commitar o arquivo, se o tamanho permitir.
- Se é grande e não muda por deploy, baixar uma vez para um volume e reaproveitar.
Nunca: baixar ou gerar em /app/... no boot.
8. Escreva só em /tmp ou no volume
Seção intitulada “8. Escreva só em /tmp ou no volume”/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.
9. Idempotência
Seção intitulada “9. Idempotência”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”.
10. Sem cron interno
Seção intitulada “10. Sem cron interno”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.
11. Migrations idempotentes e compatíveis
Seção intitulada “11. Migrations idempotentes e compatíveis”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).
Pronto para réplicas (quando existirem)
Seção intitulada “Pronto para réplicas (quando existirem)”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 |
Fila e lock no banco (Postgres)
Seção intitulada “Fila e lock no banco (Postgres)”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.
Rede e egress
Seção intitulada “Rede e egress”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).
Checklist: meu app está pronto para a Nebular?
Seção intitulada “Checklist: meu app está pronto para a Nebular?”- Escuta em
0.0.0.0na porta dePORTe aportdo serviço bate com ela. -
healthcheck_pathresponde 2xx/3xx rápido, sem trabalho pesado. - Nada é gravado em
/app; escritas vão só para/tmpou 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_URLvem 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).
-
SIGTERMencerra o app limpo em poucos segundos. -
resources.memory_mb/cpusdeclarados 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.
Erros comuns
Seção intitulada “Erros comuns”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) |
Para agents
Seção intitulada “Para agents”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.