Um Health Check Que Passa Não Significa Que É o Seu Build
Tenho um pequeno servidor em Go que lê estado local e renderiza no navegador. Nada exótico: go build, rodar com nohup, bater em /healthz para confirmar que subiu.
Mudei um pouco de CSS, refiz o build, reiniciei, sondei o endpoint de health, recebi claude-dashboard de volta, e disse à pessoa com quem estava trabalhando que a mudança estava no ar.
Não estava. O binário antigo continuava servindo, e havia continuado todo o tempo.
O que realmente aconteceu
Meu restart era assim:
OLD=$(lsof -ti :4747)
[ -n "$OLD" ] && kill $OLD
nohup ./dashboard -port 4747 >> dashboard.log 2>&1 &
O kill falhou. O novo processo falhou ao fazer bind. O antigo continuou rodando. E /healthz respondia claude-dashboard todo o tempo, porque algo estava escutando — só não a coisa que eu havia construído.
Cada passo individual reportou de forma plausível. go build teve sucesso. A linha do nohup retornou. O health check passou. O único sinal estava num arquivo de log que eu não havia acompanhado:
listen tcp 127.0.0.1:4747: bind: address already in use
A pista
A evidência mais clara, quando pensei em olhar:
$ ls -l /proc/3813939/exe
/home/vicente/.claude/dashboard/dashboard (deleted)
(deleted) significa que o executável do processo em execução não existe mais naquele caminho — eu o havia substituído com go build. No Linux esse é o sinal definitivo de que um processo sobreviveu ao seu binário. Se você está atrás de "minha mudança não aparece", verifique isso antes de qualquer outra coisa.
Por que o kill falhou
Essa é a parte que vale o post. Meu lsof -ti :4747 retornou três PIDs, e o kill rejeitou a string emendada com illegal pid. O comando está errado de duas formas independentes.
Ele casa com clientes, não só com o servidor. lsof -i :4747 seleciona qualquer processo com um socket envolvendo aquela porta — incluindo a aba do navegador que eu tinha aberta no dashboard. Minha própria conexão de cliente estava na lista de kill.
O lsof faz OR entre seus seletores. Essa é a que poderia ter causado dano real. Eu "corrigi" o primeiro problema adicionando um filtro de estado:
lsof -ti -sTCP:LISTEN -iTCP:4747
e recebi dois PIDs de volta, um dos quais era um processo renderizador do Discord. O lsof combina múltiplos critérios de seleção com OR a menos que você passe -a. Então aquele comando significa qualquer coisa em estado LISTEN ou qualquer coisa na porta 4747 — uma consulta que casa com boa parte do seu desktop.
Se eu não tivesse olhado o que aqueles PIDs eram, kill $(lsof -ti -sTCP:LISTEN -iTCP:$PORT) teria matado o Discord. E exatamente essa forma — kill $(lsof -ti :$PORT) — estava no meu próprio runbook como a maneira documentada de parar o servidor.
A forma correta usa AND entre os seletores:
lsof -ti -a -sTCP:LISTEN -iTCP:4747
Agora -sTCP:LISTEN descarta as conexões de cliente do navegador, e -a significa que as duas condições precisam valer. Um PID, o certo.
Verifique que o processo que você iniciou é o que está servindo
A correção estrutural não é um kill melhor. É não confiar no health check para responder a pergunta que você de fato tem.
/healthz responde tem algo escutando nesta porta e saudável? A pergunta que eu tinha era é o meu build que está servindo esta porta? São diferentes, e a lacuna entre elas é exatamente onde um processo velho se esconde. Então o restart afirma a identidade de quem escuta:
nohup ./dashboard -port "$PORT" >> dashboard.log 2>&1 &
NEW=$!
for _ in 1 2 3 4 5 6 7 8; do
sleep 0.3
if healthy && [ "$(listener)" = "$NEW" ]; then
echo "http://localhost:$PORT"; exit 0
fi
done
echo "não subiu — últimas linhas do log:" >&2
tail -5 dashboard.log >&2
exit 1
Duas coisas importam aqui além da comparação de PID. O caminho de falha imprime o fim do log, porque o motivo real estava num arquivo todo o tempo e eu não havia olhado. E ele sai diferente de zero, para que quem chamou possa reagir em vez de presumir sucesso.
A armadilha vizinha na detecção de rebuild
O mesmo servidor embute seu HTML com go:embed. Isso significa que uma edição só de CSS ainda exige um rebuild — o arquivo em disco é irrelevante para o processo em execução.
Meu script de start até verificava isso:
if [ ! -x dashboard ] || [ main.go -nt dashboard ] || [ index.html -nt dashboard ]; then
go build -o dashboard .
fi
O que ele não fazia era conectar o rebuild ao restart. A lógica dele era: sondar health, e se algo estiver servindo, imprimir a URL e parar. Então a sequência "fontes mudaram → rebuild → algo já está servindo → reportar sucesso" deixava o novo binário parado em disco, sem uso. O problema da interface velha não era um rebuild ausente; era um rebuild sem consequência.
O script agora registra se de fato refez o build, e trata um build atual servindo como a condição de sucesso, em vez de qualquer coisa servindo:
- saudável e nada foi refeito → pronto, imprime a URL
- saudável mas refizemos o build → para quem escuta, sobe o novo, afirma o PID
- nada escutando → sobe, afirma o PID
De frio até servindo leva cerca de 400ms, então não há razão para não rodar isso em todo ponto de entrada que precise do servidor.
O que eu generalizaria
Uma verificação de liveness responde uma pergunta mais estreita do que você quer. Ela te diz que algo responde. Versão, identidade do build, e "este é o artefato que eu acabei de produzir" são perguntas separadas, e se importam, pergunte-as separadamente — um endpoint que reporta um selo de build é uma mudança de cinco minutos que teria me poupado disso inteiramente.
Uma ferramenta que seleciona coisas precisa ter sua semântica de combinação verificada. Usei lsof -ti :PORT por anos sem ler como múltiplos seletores se combinam. A resposta era OR, o que é um padrão razoável para uma ferramenta de diagnóstico e perigoso para a entrada de um kill.
Quando vários passos reportam plausivelmente e o resultado está errado, suspeite da costura. Build, start e health check eram todos individualmente honestos. O que ninguém verificou foi se a coisa que subiu era a coisa que estava sendo verificada.