kelvin@cluxnei.dev:~/blog

kelvin@cluxnei:~/blog$ cat 2gb/05-manter-no-ar.md

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

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

  1. Backup que você já restaurou
  2. O disco cheio não avisa direito
  3. Rotação de log, e o journal que ninguém limitou
  4. A execução que começa antes da anterior terminar
  5. Publiquei e continua o antigo
  6. Monitoramento que cabe
  7. O que realmente bate na sua porta
  8. Quando 2 GB deixa de bastar
  9. 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:

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étododisparosrealmente executadospico simultâneo
cron comum10103
com flock -n1041

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:

componentememória, ocioso
node-exporter7,4 MB
Prometheus73,5 MB
Grafana371,5 MB
total454,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:

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:

  1. O swap para de ficar parado. Pela parte 1, swap ocupado é saudável; si e so batendo sem parar no vmstat é falta de RAM de verdade.
  2. O available não volta. Depois do pico ele deveria voltar ao patamar. Se não volta, alguma coisa está crescendo.
  3. 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.
  4. 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:

  1. 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.
  2. 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.
  3. Tirar o estático e as imagens para armazenamento de objetos com CDN. Barato, e devolve disco e banda.
  4. 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:

parteo que precisa ser verdade
1, a máquinavocê lê available, tem swap, e sabe quem o OOM killer mataria
2, o processosobe no boot, volta sozinho, não é root, tem MemoryMax
3, a portaprefixo com ^~, os quatro cabeçalhos, TLS renovando
4, o deploytroca com mv -T, dá para voltar, segredos em shared/
5, manterbackup 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$