kelvin@cluxnei:~/blog$ cat 2gb/02-o-processo.md
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
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:
- A máquina reiniciou de madrugada. Quem sobe a aplicação de volta?
- O processo morreu com uma exceção não tratada às quatro da manhã. Quem percebe?
- Ele vazou memória e levou o banco junto. Quem estava segurando?
- Você precisa ver o log de ontem às 14h. Onde ele está?
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ção | tempo | pico de RAM | foi pro swap | fim |
|---|---|---|---|---|
| sem teto | 252 ms | 203,9 MB | 0 | terminou |
MemoryHigh=64M | 502 ms | 64,2 MB | 143,6 MB | terminou |
MemoryMax=64M | morreu | 64 MB | proibido | oom-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:
| processo | PSS |
|---|---|
| PM2 v7.0.3: God Daemon | 36,7 MB |
| módulo pm2-logrotate | 40,5 MB |
| total antes da sua app subir | 77,2 MB |
| systemd, PID 1 | 6,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:
| item | como conferir |
|---|---|
| sobe no boot | systemctl is-enabled minhaapp |
| volta depois de morrer | kill -9 e veja o PID mudar |
| não roda como root | ps -o user= -p $(pgrep -f minhaapp) |
| tem teto de memória | systemctl show -p MemoryMax minhaapp |
| loga no journal | journalctl -u minhaapp -n 20 |
| escuta só no localhost | ss -tulpn | grep 3000 |
| segredos fora do git | ls -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$