kelvin@cluxnei:~/blog$ cat 2gb/05-manter-no-ar.md
O que só dói depois
Tudo está no ar e você consegue publicar. As quatro partes anteriores foram sobre colocar as coisas de pé. Esta é sobre elas continuarem de pé quando ninguém está olhando, e é a parte que separa um projeto que dura um ano de um que morre num domingo de madrugada por um motivo que parecia pequeno demais para merecer atenção.
Neste texto
- Backup que você já restaurou
- O disco cheio não avisa direito
- Rotação de log, e o journal que ninguém limitou
- A execução que começa antes da anterior terminar
- Publiquei e continua o antigo
- Monitoramento que cabe
- O que realmente bate na sua porta
- Quando 2 GB deixa de bastar
- O checklist da série inteira
Backup que você já restaurou
Começo pela única frase desta parte que vale mais que todas as outras juntas: backup não restaurado não é backup, é esperança.
Todo mundo configura um pg_dump num cron e marca a tarefa como
feita. O arquivo é gerado, cresce todo dia, e ninguém nunca tentou abrir. Os
jeitos de isso estar quebrado o tempo todo sem ninguém perceber são muitos e
todos silenciosos: o dump saiu vazio porque a senha mudou, saiu truncado porque o
disco encheu no meio, saiu de um banco errado, ou está lá perfeito e o comando de
restauração não funciona porque a versão do Postgres no servidor novo é outra.
O teste é ir até o fim, e ele leva quinze minutos:
# 1. restaure num banco descartável, na sua máquina createdb teste_restore pg_restore -d teste_restore backup-2026-09-19.dump # 2. confira que tem dados, não só schema psql teste_restore -c "select count(*) from usuarios;" # 3. aponte a aplicação para ele e abra uma tela DATABASE_URL=postgres://localhost/teste_restore npm start # 4. só agora o backup existe dropdb teste_restore
Três regras sobre onde ele mora:
- Fora da máquina. Um backup no mesmo disco protege contra
DROP TABLEe contra mais nada. O disco morrer, o provedor sumir e a conta ser suspensa levam os dois juntos. - Fora da sua conta do provedor, se der. A conta suspensa por engano é mais comum do que o disco falhar.
- Com data no nome e retenção decidida. Sete diários, quatro semanais. Sem retenção, o backup vira a causa do disco cheio da próxima seção.
São três coisas que precisam de backup, e a segunda e a terceira são as
esquecidas: o banco, o shared/ da parte 4 com os uploads e o
.env, e a configuração do sistema que você escreveu à mão, ou seja
os arquivos do nginx, as unidades do systemd e o crontab. Essa última parte cabe num
repositório privado e economiza um dia inteiro quando você precisar remontar a
máquina do zero.
O disco cheio não avisa direito
A parte 1 disse que RAM é a parede. Disco é a outra, e ele é pior de diagnosticar, porque não existe um disk full killer que mate um processo e deixe uma linha no log do kernel. O que existe é tudo falhando de um jeito diferente ao mesmo tempo.
Montei um filesystem de 56 MB e coloquei um processo gravando registros de tamanho fixo, como um serviço faria, até acabar o espaço:
o writer morreu no registro 1216599: [Errno 28] No space left on device # as duas últimas linhas do arquivo: 01216598 registro completo de log da aplicacao 0121659_ <- gravação parcial
Repare na última linha. Ela não está ausente, está pela metade. O processo pediu para escrever um registro inteiro, o kernel escreveu o que coube e devolveu erro. Qualquer coisa que leia esse arquivo depois vai encontrar um registro corrompido, e essa é exatamente a forma como um disco cheio contamina dados em vez de só parar o serviço.
E o comportamento seguinte é o que mais confunde. Com o filesystem marcando 100% e zero blocos livres, testei três escritas de tamanhos diferentes:
df: 55M usado, 0 livre (100%) inodes: 13 usados, 16.371 livres (1%) echo teste > novo.txt -> funcionou, 6 bytes gravados touch vazio.txt -> funcionou, 0 bytes dd de 1 MB -> falhou: No space left on device
Um disco cheio não falha de forma uniforme, ele falha por tamanho. A escrita pequena passa, a grande não. É por isso que os sintomas nunca fazem sentido juntos: o seu serviço continua gravando linhas de log, o arquivo de configuração que você editou salva normalmente, e o checkpoint do banco falha. Você conclui que o problema é no banco.
O ext4 reserva 5% dos blocos para o root. É por isso que,
com o df marcando 100%, você ainda consegue entrar por SSH e
consertar: a sua sessão de root tem uma reserva que os serviços não têm. Essa
reserva é a única razão de um disco cheio ser recuperável remotamente, e ela
existe por padrão sem ninguém pedir.
Rotação de log, e o journal que ninguém limitou
A causa número um de disco cheio numa máquina pequena é log. E o exemplo mais honesto que eu tinha estava na minha frente: fui medir o journald de um servidor Ubuntu em uso e encontrei isto:
journalctl --disk-usage 432,7M grep SystemMaxUse /etc/systemd/journald.conf (nada. O padrão é 10% do /var)
Quatrocentos e trinta e dois megabytes de log, porque ninguém definiu um teto. Numa máquina com disco grande isso passa despercebido para sempre. Numa VPS de 2 GB com 25 GB de disco, o padrão de 10% autoriza o journal a chegar a 2,5 GB antes de se preocupar.
Uma linha resolve, e é a mesma da parte 2:
# /etc/systemd/journald.conf SystemMaxUse=200M SystemMaxFileSize=20M systemctl restart systemd-journald journalctl --vacuum-size=200M # limpa o que já passou
Para o que não passa pelo journal, existe o logrotate. O
candidato clássico é o access_log do nginx, que numa VPS pequena
cresce mais rápido que tudo:
# /etc/logrotate.d/minhaapp /srv/minhaapp/shared/logs/*.log { daily rotate 7 compress delaycompress notifempty missingok copytruncate }
O copytruncate merece explicação, porque é a diferença entre
funcionar e não funcionar. O logrotate normalmente renomeia o
arquivo e espera o serviço reabrir o novo. Uma aplicação que não sabe fazer isso
continua escrevendo no arquivo renomeado, que segue ocupando disco e some do seu
tail. Com copytruncate ele copia e zera o original, o
descritor aberto continua válido, e o serviço nem percebe.
E o ajuste mais barato de todos, que a parte 3 já usou: access_log
off na location de arquivos estáticos. É a maioria das linhas de log de
um site, e nenhuma delas te diz nada.
A execução que começa antes da anterior terminar
Você agenda um sincronismo a cada cinco minutos. Um dia ele demora seis. A partir daí você tem duas cópias rodando juntas, depois três, e cada uma consumindo a memória da parte 1 numa máquina que não tem folga para duas.
Medi com uma tarefa que leva cinco segundos, disparada a cada dois:
| método | disparos | realmente executados | pico simultâneo |
|---|---|---|---|
| cron comum | 10 | 10 | 3 |
com flock -n | 10 | 4 | 1 |
O flock -n tenta pegar um arquivo de trava e, se não conseguir,
desiste em silêncio. Seis dos dez disparos simplesmente não rodaram, que é
exatamente o comportamento desejado para uma tarefa que não pode se
sobrepor:
*/5 * * * * /usr/bin/flock -n /tmp/sync.lock /srv/minhaapp/bin/sync.sh
Existe uma alternativa melhor, e ela já está instalada. Um timer do systemd não sobrepõe por construção, porque uma unidade não pode estar ativa duas vezes:
# minhaapp-sync.timer [Timer] OnCalendar=*:0/5 Persistent=true RandomizedDelaySec=30
Três vantagens sobre o cron, e todas importam numa caixa pequena. O
Persistent=true roda a execução perdida quando a máquina estava
desligada. O RandomizedDelaySec espalha o disparo, evitando que
cinco tarefas agendadas para 0 3 * * * acordem no mesmo segundo e
criem um pico de memória às três da manhã. E o serviço disparado pelo timer é uma
unidade normal, então ele herda o MemoryMax da parte 2 e o log no
journal de graça.
Publiquei e continua o antigo
Essa frase tem quatro suspeitos, e eles têm uma ordem. Do mais perto do usuário para o mais perto do seu código:
1. cache do navegador o que você mandou no Cache-Control 2. CDN ou proxy externo pode ignorar o que você mandou 3. cache do nginx proxy_cache, open_file_cache 4. cache da aplicação rota, view, consulta, opcode
O item 2 é o traiçoeiro, porque contraria a intuição de que a origem manda.
Montei a demonstração com um backend que responde a hora atual com
Cache-Control: no-store, no-cache, must-revalidate, atrás de dois
caches idênticos, mudando só se eles respeitam esse cabeçalho:
# cache respeitando a origem versao gerada em 21:16:45 X-Cache: MISS versao gerada em 21:16:46 X-Cache: MISS versao gerada em 21:16:47 X-Cache: MISS # mesmo backend, cache configurado para ignorar versao gerada em 21:16:48 X-Cache: HIT versao gerada em 21:16:48 X-Cache: HIT versao gerada em 21:16:48 X-Cache: HIT
O relógio congelou. O backend continuou gerando a hora certa e gritando
no-store em toda resposta, e o cache na frente decidiu que não era
com ele. Uma linha de configuração,
proxy_ignore_headers Cache-Control, e a origem perdeu a
autoridade sobre o próprio conteúdo.
É o mesmo mecanismo de um CDN configurado para sobrescrever o TTL da origem, e é por isso que publiquei e continua o antigo é tão difícil de depurar: você olha o seu código, olha os seus cabeçalhos, os dois estão certos, e a resposta errada vem de uma camada que você nem lembrava que existia.
A ordem para depurar, sempre de dentro para fora, porque conferir de fora te dá uma resposta que passou por todas as camadas:
curl -s localhost:3000/rota # 4. a aplicação está certa? curl -s -H 'Host: meusite.dev' localhost/rota # 3. e o nginx? curl -s https://meusite.dev/rota # 2. e a camada externa? # 1. o navegador: janela anônima, ou Ctrl+Shift+R
A primeira linha em que o conteúdo fica errado é a camada culpada. E a prevenção, que a parte 3 já mencionou, é ter hash no nome dos arquivos: um nome novo nunca está em cache em lugar nenhum, e o problema deixa de existir em vez de ser resolvido.
Monitoramento que cabe
A tentação aqui é montar a pilha completa. Medi o que ela custa ociosa, sem coletar quase nada:
| componente | memória, ocioso |
|---|---|
| node-exporter | 7,4 MB |
| Prometheus | 73,5 MB |
| Grafana | 371,5 MB |
| total | 454,8 MB |
São 22% de uma máquina de 2 GB para observar uma aplicação que, pelos números da parte 1, ocupa uns 100 MB. O Grafana sozinho custa mais que três processos Node. Numa caixa desse tamanho, essa pilha não observa o problema, ela é o problema.
O que realmente vale nessa escala são cinco coisas, e quatro delas são gratuitas:
- Um check externo de disponibilidade. O único que precisa estar fora da máquina, porque monitoramento que roda no servidor não te avisa quando o servidor cai. Serviço gratuito, um ping a cada minuto, alerta por e-mail.
- Disco. Um alerta em 80% te dá dias de antecedência. É o item de maior retorno da lista inteira.
- Memória disponível, a coluna
availableda parte 1, não afree. - Estado dos serviços.
systemctl is-failedem cada unidade que importa. - Validade do certificado. A renovação automática da parte 3 falha em silêncio, e você descobre noventa dias depois.
Isso cabe em um script e um cron, e ocupa zero byte de memória em repouso:
#!/usr/bin/env bash alerta(){ echo "$1" | mail -s "[servidor] $1" voce@exemplo.com; } uso=$(df --output=pcent / | tail -1 | tr -dc 0-9) [ "$uso" -ge 80 ] && alerta "disco em ${uso}%" livre=$(free -m | awk 'NR==2{print $7}') [ "$livre" -lt 200 ] && alerta "memória disponível: ${livre} MB" for u in minhaapp nginx postgresql; do systemctl is-failed --quiet "$u" && alerta "$u caiu" done dias=$(( ($(date -d "$(openssl s_client -connect meusite.dev:443 2>/dev/null \ | openssl x509 -noout -enddate | cut -d= -f2)" +%s) - $(date +%s)) / 86400 )) [ "$dias" -lt 15 ] && alerta "certificado vence em ${dias} dias"
Quando o projeto crescer a ponto de você precisar de histórico e gráficos, coloque a pilha de observação em outra máquina. Ela nunca deveria dividir memória com o que ela observa.
O que realmente bate na sua porta
Essa seção costuma ser abstrata, então fui buscar números reais. Este é o journal de SSH de um servidor Ubuntu comum, sem nada de especial, sem nome conhecido, exposto na internet:
janela: 2026-07-20 a 2026-09-20 (62 dias) linhas de falha de autenticação: 112.165 das quais "Invalid user": 53.813 IPs distintos: 2.834 média: ~1.810 tentativas por dia, ~75 por hora, sem parar
Setenta e cinco tentativas por hora, ininterruptas, numa máquina que ninguém conhece. Não existe "meu projeto é pequeno demais para ser alvo", porque não existe alvo: existe varredura da internet inteira, o tempo todo.
E os usuários que tentaram contam uma história:
8.012 admin 4.493 user 3.998 solana 2.701 solv 2.505 debian 2.063 sol 1.148 test 685 oracle 564 ftpuser 546 support
Os três em destaque são o mesmo alvo. São bots procurando nós de validação da Solana, que costumam ser máquinas com chave privada e saldo. Esses números dizem o que o ataque automatizado está caçando neste mês, e a lista muda com o que está valendo dinheiro.
O fail2ban baniu 209 IPs nessa janela. E vale ser honesto sobre o que isso significa: 209 de 2.834 é 7%. O fail2ban é higiene, reduz ruído no log e custa pouco. Ele não é a sua defesa. A sua defesa são as três linhas da parte 1: autenticação só por chave, root sem login, e um firewall que nega por padrão. Com elas, as 112 mil tentativas são todas irrelevantes, porque nenhuma senha existe para ser adivinhada.
O que se mantém ao longo do tempo é curto:
apt list --upgradable # o unattended-upgrades pega os de segurança ss -tulpn # o que está exposto? deveria? systemctl list-timers # certbot ainda vivo? journalctl -p err --since "7 days ago"
Aquele ss -tulpn é o de maior retorno. A pergunta é sempre a
mesma: todo processo escutando em 0.0.0.0 precisa mesmo estar
acessível do mundo? Quase sempre a resposta é não, e quase sempre é um banco de
dados ou um Redis que subiu com o padrão da distribuição.
Quando 2 GB deixa de bastar
A série inteira defendeu que cabe muito mais numa caixa pequena do que o catálogo de serviços deixa imaginar. Fechar sem dizer onde é o limite seria desonesto.
Os sinais, na ordem em que aparecem:
- O swap para de ficar parado. Pela parte 1, swap ocupado é saudável;
siesobatendo sem parar novmstaté falta de RAM de verdade. - O
availablenão volta. Depois do pico ele deveria voltar ao patamar. Se não volta, alguma coisa está crescendo. - O banco vira o gargalo. Conexões em fila, consultas esperando por
work_mem, cache do Postgres pequeno demais para o conjunto de dados ativo. - O deploy fica arriscado. Quando não sobra folga para o pico de 438 MB da parte 4 nem com teto, a máquina está cheia.
E a ordem certa dos próximos passos, que quase nunca é a que o hype sugere:
- Dobrar a máquina. 4 GB custa mais ou menos o dobro de 2 GB e resolve sem nenhuma mudança de arquitetura. É a opção mais barata quando você conta o seu tempo, e é a que as pessoas pulam por achar que não vale como solução.
- Tirar o banco para uma máquina própria. A primeira separação que vale, porque o banco é o que tem perfil de memória mais diferente de todo o resto.
- Tirar o estático e as imagens para armazenamento de objetos com CDN. Barato, e devolve disco e banda.
- Só então pensar em mais de um servidor de aplicação, balanceador, sessão compartilhada, e todo o custo operacional que vem junto.
Orquestração de contêineres não aparece nessa lista, e a razão é o orçamento da parte 1. Um plano de controle de Kubernetes consome, sozinho, mais memória do que a pilha inteira que a série montou. Ele resolve problemas reais de coordenação entre muitas máquinas. Nenhum desses problemas é o seu ainda.
O checklist da série inteira
As cinco partes em uma página. Se todas forem verdade, você tem um servidor que você entende:
| parte | o que precisa ser verdade |
|---|---|
| 1, a máquina | você lê available, tem swap, e sabe quem o OOM killer mataria |
| 2, o processo | sobe no boot, volta sozinho, não é root, tem MemoryMax |
| 3, a porta | prefixo com ^~, os quatro cabeçalhos, TLS renovando |
| 4, o deploy | troca com mv -T, dá para voltar, segredos em shared/ |
| 5, manter | backup já restaurado, teto no journal, flock no cron, alerta de disco |
Vinte linhas de comando, cinco arquivos de configuração e um script de deploy. Foi isso. Não teve painel, não teve YAML, não teve nenhuma peça que você não possa abrir e ler.
O que essas cinco partes realmente entregam não é uma VPS configurada. É a capacidade de responder o que está acontecendo nesta máquina agora, que é a pergunta que a plataforma gerenciada vinha respondendo por você, em troca de você nunca precisar aprender. Quando você volta para a plataforma depois disso, e vai voltar, porque para muita coisa ela é a escolha certa, você passa a escolher por motivo em vez de por falta de alternativa.
Dois gigabytes dão para muito mais do que te contaram.
kelvin@cluxnei:~/blog$