kelvin@cluxnei.dev:~/blog

kelvin@cluxnei:~/blog$ cat 2gb/02-o-processo.md

2026-09-20 PT ~18 min de leitura read in English

Fechei o terminal e a app morreu

Todo mundo passa por isso uma vez. Você faz ssh, roda npm start, abre o IP no navegador, funciona, fecha o terminal, e o site cai. A primeira reação é procurar um jeito de manter o terminal aberto. Esse é o caminho errado, e entender por que é errado é a virada de chave que falta pra quem sempre trabalhou com plataforma gerenciada.

Neste texto

  1. O que matou a sua aplicação
  2. A unidade mínima, linha por linha
  3. Voltar sozinho, e o limite que impede o loop
  4. Log sem arquivo de log
  5. O teto de memória, medido
  6. pm2, supervisor e companhia
  7. Caixa lateral: PHP-FPM é outro modelo
  8. Onde ele escuta
  9. O checklist

O que matou a sua aplicação

Quando você abre uma sessão SSH, o kernel cria uma sessão com um terminal de controle. Tudo que você roda ali nasce dentro dela. Ao fechar a conexão, o terminal some, e o kernel manda SIGHUP para o grupo de processos em primeiro plano. O padrão desse sinal é terminar. Seu npm start não decidiu nada, ele recebeu um sinal de que o terminal que o gerou deixou de existir.

O primeiro instinto é escapar do sinal. nohup ignora o SIGHUP, & joga pro segundo plano, tmux e screen mantêm a sessão viva do outro lado. Os três funcionam, e nenhum dos três é a resposta, porque nenhum resolve as perguntas que vêm logo em seguida:

Essas quatro perguntas têm uma resposta só, e ela já está instalada na sua máquina. O systemd é o PID 1: ele subiu antes de tudo, vai continuar rodando de qualquer jeito, e supervisionar processos é literalmente a função dele. Na parte 1 eu medi: 6,4 MB de PSS. Você já está pagando por esse supervisor, use ou não use.

A unidade mínima, linha por linha

Um arquivo em /etc/systemd/system/minhaapp.service. Isto aqui é tudo que uma aplicação Node precisa para virar um serviço de verdade:

[Unit] Description=minha api After=network-online.target Wants=network-online.target [Service] Type=simple User=deploy Group=deploy WorkingDirectory=/srv/minhaapp/current EnvironmentFile=/srv/minhaapp/shared/.env ExecStart=/usr/bin/node /srv/minhaapp/current/server.js Restart=on-failure RestartSec=2 [Install] WantedBy=multi-user.target

systemctl daemon-reload systemctl enable --now minhaapp systemctl status minhaapp

O enable é o que faz subir no boot. O --now sobe agora também. São duas coisas diferentes e vale saber qual é qual, porque esquecer o enable é a forma clássica de descobrir três meses depois que a máquina não volta sozinha.

As quatro armadilhas

ExecStart não é um shell. Não existe pipe, não existe glob, não existe &&, e $VAR não expande como você espera. É um caminho absoluto e uma lista de argumentos. Se você precisa mesmo de shell, é ExecStart=/bin/bash -lc '...', e aí você assumiu um shell inteiro de overhead por um motivo que quase sempre é preguiça.

O PATH é mínimo. Nada de ~/.nvm, nada do que o seu .bashrc monta. Use caminho absoluto do binário. Com nvm, o caminho real é algo como /home/deploy/.nvm/versions/node/v22.22.0/bin/node, e descobrir isso com which node na sua sessão interativa não ajuda, porque o serviço não tem a sua sessão.

User= não é opcional. Sem essa linha o serviço roda como root. Uma aplicação web que roda como root transforma qualquer execução remota de código em controle total da máquina, e não existe ganho nenhum em troca.

O .env não é um script. EnvironmentFile lê CHAVE=valor por linha, sem export, sem aspas mágicas, sem interpolar outra variável. E ele merece chmod 600 com dono certo, porque é onde as senhas moram.

Voltar sozinho, e o limite que impede o loop

Restart=on-failure sobe de novo quando o processo sai com código diferente de zero ou morre por sinal. Existe também Restart=always, que sobe de novo até quando a saída foi limpa, e é o que você quer para um servidor que nunca deveria terminar sozinho.

Testei com um serviço que imprime o PID, espera dois segundos e sai com erro, com RestartSec=1. Em onze segundos:

subi, pid 2651864 subi, pid 2651915 subi, pid 2651938 NRestarts=3

PID novo a cada volta, e o contador exposto em systemctl show -p NRestarts, que é um bom sinal pra monitorar depois.

Agora a parte que pega todo mundo na primeira vez. Se a aplicação está quebrada de verdade, ela morre, sobe, morre, sobe. O systemd tem um freio pra isso: por padrão, cinco partidas em dez segundos e ele desiste, deixando a unidade em estado failed sem tentar mais. O sintoma é a aplicação parar de voltar e o status dizer start request repeated too quickly.

Isso é proteção, e não bug. Ajuste com StartLimitIntervalSec e StartLimitBurst, e limpe o estado com systemctl reset-failed minhaapp. Um RestartSec de alguns segundos costuma ser mais útil do que afrouxar o limite, porque dá tempo do banco voltar antes da próxima tentativa.

Log sem arquivo de log

Tudo que o processo escreve em stdout e stderr vai para o journald. Você não cria arquivo, não configura rotação, não corre o risco de encher o disco com um app.log de 40 GB, que é uma das formas mais comuns de matar uma máquina pequena.

journalctl -u minhaapp -f # acompanhando journalctl -u minhaapp --since "1 hour ago" journalctl -u minhaapp -p err # só erro pra cima journalctl -u minhaapp -n 200 --no-pager journalctl --disk-usage

O journal tem teto próprio, em /etc/systemd/journald.conf. Numa VPS pequena, SystemMaxUse=200M é um número sensato, e é uma linha que evita um problema que só aparece meses depois.

Uma escolha de aplicação que combina com isso: escreva log em uma linha por evento, sem arte ASCII e sem cores. Cor no terminal vira sequência de escape no journal, e aí o grep de madrugada não acha o que você procura.

O teto de memória, medido

Esta é a seção que justifica a parte 2 inteira, e é o que torna uma VPS de 2 GB previsível em vez de uma roleta.

Na parte 1 vimos o OOM killer global: quando a RAM acaba, o kernel escolhe o processo maior e mata, o que quase sempre significa matar o banco de dados por causa de um vazamento em outro lugar. A defesa é dar a cada serviço um limite próprio, e o cgroup v2 faz isso com duas chaves que são bem diferentes uma da outra.

Montei o experimento com um programa que aloca 200 MB e toca todas as páginas, para que a memória seja real e não promessa. Rodei três vezes, com o próprio systemd medindo:

configuraçãotempopico de RAMfoi pro swapfim
sem teto252 ms203,9 MB0terminou
MemoryHigh=64M502 ms64,2 MB143,6 MBterminou
MemoryMax=64Mmorreu64 MBproibidooom-kill

Leia a linha do meio com atenção, porque ela é a menos conhecida das duas. Com MemoryHigh, o programa terminou corretamente. O kernel segurou a memória residente em 64,2 MB e empurrou 143,6 MB para o swap, cobrando por isso o dobro do tempo. MemoryHigh é um freio: passou da linha, o processo sofre pressão de recuperação e fica lento, mas continua vivo.

MemoryMax é uma parede. Ao encostar nela, o kernel mata dentro do cgroup, e o log diz exatamente isso:

minhaapp.service: The kernel OOM killer killed some processes in this unit. minhaapp.service: Main process exited, code=killed, status=9/KILL minhaapp.service: Failed with result 'oom-kill'.

E no log do kernel, a palavra que muda tudo:

oom-kill:constraint=CONSTRAINT_MEMCG,...,oom_memcg=/system.slice/minhaapp.service Memory cgroup out of memory: Killed process 2650976 (python3) anon-rss:65296kB

CONSTRAINT_MEMCG quer dizer que a falta de memória foi local ao cgroup, não da máquina. Compare com a mensagem global da parte 1, que é só Out of memory: Killed process. A máquina durante o experimento inteiro: 4269 MB disponíveis antes, 4257 MB depois. Ela não ficou sabendo.

Essa é a diferença entre um vazamento que derruba um serviço e um vazamento que derruba o site inteiro.

Que números usar

A receita que funciona é usar as duas chaves juntas, com papéis diferentes:

[Service] MemoryHigh=180M # freio: acima disso, começa a doer MemoryMax=256M # parede: acima disso, morre sozinho MemorySwapMax=256M

Ponha o MemoryHigh um pouco acima do consumo normal medido, e o MemoryMax com folga sobre ele. Assim um pico legítimo de tráfego fica lento e sobrevive, e só um vazamento de verdade chega na parede. Medir o consumo normal é systemctl show -p MemoryCurrent minhaapp ou systemd-cgtop, lembrando da parte 1 que esse número inclui page cache.

Isso não é configuração exótica. O polkit.service, que vem instalado por padrão no Ubuntu, já sobe com MemoryMax=32M. A distribuição usa o mecanismo nos próprios serviços dela, e a maioria das aplicações que as pessoas escrevem sobe sem teto nenhum.

pm2, supervisor e companhia

Quem vem de Node quase sempre chega no pm2 antes de chegar no systemd. Ele funciona, a interface é agradável, e tem uma conta que vale fazer com número.

Medi um pm2 real em produção, olhando só os processos de supervisão, sem contar nenhuma aplicação:

processoPSS
PM2 v7.0.3: God Daemon36,7 MB
módulo pm2-logrotate40,5 MB
total antes da sua app subir77,2 MB
systemd, PID 16,4 MB, e já estava rodando

Numa máquina de 2 GB, 77 MB é quase 4% de tudo que você tem, gasto em supervisão. O detalhe mais afiado da tabela é o segundo item: um processo Node de 40 MB cuja única função é rotacionar arquivos de log que o journald nunca teria criado.

Sendo justo com a ferramenta, o pm2 entrega coisas que o systemd não entrega de graça: modo cluster com recarga sem downtime, um arquivo de ecossistema que descreve várias apps juntas, e o pm2 monit. Se você usa isso, pague os 77 MB sabendo o que comprou. Se você usa o pm2 só para o processo não morrer quando o terminal fecha, você está pagando 4% da máquina por algo que o PID 1 já faz.

O mesmo raciocínio vale para supervisor, forever e companhia. A pergunta não é qual é melhor, é o que você está comprando e quanto custa em RAM numa máquina onde RAM é a parede.

Caixa lateral: PHP-FPM é outro modelo

Se a sua aplicação é PHP, quase nada acima se aplica do mesmo jeito, porque o supervisor já vem junto. O PHP-FPM tem um mestre que faz fork de filhos, e cada filho atende uma requisição por vez. O systemd supervisiona o mestre, e o mestre supervisiona o pool.

A consequência é que o seu limite de concorrência é um número em arquivo de configuração, e esse número multiplica memória. Com os 25 a 40 MB por filho medidos na parte 1:

pm = dynamic pm.max_children = 8 # 8 x ~35 MB = ~280 MB no pico pm.start_servers = 2 pm.min_spare_servers = 1 pm.max_spare_servers = 3 pm.max_requests = 500 # recicla o filho, corta vazamento lento

O padrão de fábrica costuma vir com pm.max_children alto demais para uma VPS pequena. A conta é direta: multiplique pelo consumo medido de um filho e veja se cabe. Se não couber, o pico de tráfego não vai deixar o site lento, vai acionar o OOM killer.

pm.max_requests merece uma linha própria. Ele mata e recria o filho depois de N requisições, o que transforma vazamento lento em problema que nunca aparece. É uma gambiarra, e é uma gambiarra que funciona há vinte anos.

Onde ele escuta

Sua aplicação precisa aceitar conexão em algum lugar, e há duas opções.

Porta TCP. O jeito comum. A regra que importa: escute em 127.0.0.1:3000, nunca em 0.0.0.0:3000. Com 0.0.0.0, a aplicação fica acessível direto pelo IP público na porta 3000, contornando o proxy reverso, o TLS e qualquer regra que você tenha posto na frente. O firewall da parte 1 ajuda, e não é motivo pra deixar aberto.

Socket unix. Um arquivo em vez de uma porta, com permissão de filesystem em vez de regra de firewall. Não ocupa porta, não tem como ser alcançado de fora por acidente, e evita a pilha TCP inteira para tráfego que nunca sai da máquina. É o que o PHP-FPM usa por padrão no Ubuntu, em /run/php/php8.5-fpm.sock.

Para uma aplicação atrás de proxy na mesma máquina, socket unix é a escolha mais segura por construção. Como o nginx conversa com cada um dos dois é assunto da parte 3.

O checklist

Sua aplicação está em ordem quando todas estas forem verdade:

itemcomo conferir
sobe no bootsystemctl is-enabled minhaapp
volta depois de morrerkill -9 e veja o PID mudar
não roda como rootps -o user= -p $(pgrep -f minhaapp)
tem teto de memóriasystemctl show -p MemoryMax minhaapp
loga no journaljournalctl -u minhaapp -n 20
escuta só no localhostss -tulpn | grep 3000
segredos fora do gitls -l no .env, esperado 600

Sete linhas de comando. Se alguma falhar, o problema já está escrito no servidor esperando o dia ruim.

O que vem na parte 3

A aplicação roda, volta sozinha, tem teto e escreve log. E continua inalcançável de fora, porque ela escuta em 127.0.0.1:3000 e ninguém digita porta 3000 no navegador.

A parte 3 é a porta de entrada: um IP, uma porta 443, e várias aplicações atrás dela. Proxy reverso, a ordem de match do nginx que não é a que você imagina, os cabeçalhos que se perdem no caminho e fazem sua aplicação gerar link errado, e onde exatamente o TLS termina.

kelvin@cluxnei:~/blog$