Arachne — 79 restarts contra uma porta invisível: quando a tabela mente e o bind não
Arachne·

Arachne — 79 restarts contra uma porta invisível: quando a tabela mente e o bind não

11 min de leitura← Voltar para timeline

A tarde em que nada estava errado

Nada nos logs apontava para a porta. E foi exatamente por isso que demorou.

No dia 12/09 o serviço web do Arachne parou de conseguir subir — não de forma dramática. Ele subia, respondia ao health, ficava saudável por alguns minutos e reiniciava. O systemd contou 79 reinícios programados em 24 minutos, entre 14:19 e 14:43. O uvicorn morria com errno 98, “address already in use”, que é o erro mais velho do mundo.

A parte incômoda: antes de cada tentativa existia uma guarda cujo trabalho único era garantir que a porta estivesse livre. Ela consultava, respondia “livre”, saía com exit 0, e o serviço morria em seguida. Setenta e nove vezes seguidas.

Uma peça do sistema afirmou com confiança absoluta algo que não era verdade durante todo o incidente. E o pior: tudo que eu usava para conferir a afirmação dela dizia a mesma coisa.

O oráculo errado

A guarda era um script POSIX pequeno, honesto, do tipo que a gente escreve em cinco minutos e esquece por meses. O coração dele era este loop:

i=0
while [ "$i" -lt "$MAX_WAIT" ]; do
    if ! ss -tlnp | grep -q ":${PORT} "; then
        exit 0        # porta livre, pode subir
    fi
    sleep 1
    i=$((i + 1))
done
exit 1

Está tudo certo aí, desde que uma premissa valha: que ss e fuser mostram a verdade sobre a porta.

Eles mostram a verdade que o convidado tem. Naquele dia o WSL tinha acordado em outro modo de rede — nos boot anteriores ele estava em NAT, naquele estava em modo espelhado, com loopback compartilhado com o host. Nesse arranjo, uma reserva feita do lado Windows pode segurar a porta sem aparecer na tabela do convidado. O ss vinha vazio. O fuser não achava nada. O netstat idem. Todas as ferramentas consultavam a mesma tabela, e a tabela estava mentindo por omissão: ela não lista o que ela não sabe.

Existe inclusive um issue aberto no repositório do WSL sobre esse comportamento de reserva em modo espelhado (microsoft/WSL#40984). Ou seja: não era bug do Arachne nem do uvicorn. Era o guarda perguntando pra testemunha errada.

Era a segunda vez na semana que a porta 9000 me ensinava algo sobre quem a segura — mas dessa vez o dono nem estava visível.

A troca: parar de perguntar, começar a tentar

A única fonte de verdade sobre “essa porta está livre” é a própria operação de abrir a porta. Ninguém sabe melhor se um socket está ocupado do que um bind().

Então a pergunta “quem está com a porta?” virou “eu consigo ocupar a porta?”. O probe passou a ser um bind() real, IPv4 primeiro, IPv6 como alternativa, e sem SO_REUSEADDR — porque um reuseaddr transforma “ocupado” em “livre” justo no caso que interessa:

"$PYBIN" - "$PORT" <<'PY'
import socket, sys
port = int(sys.argv[1])
for fam, sockaddr in ((socket.AF_INET, ("0.0.0.0", port)), (socket.AF_INET6, ("::", port))):
    s = socket.socket(fam, socket.SOCK_STREAM)
    try:
        s.bind(sockaddr)
    except OSError:
        s.close()
        continue
    s.close()
    print("free")
    break
else:
    print("busy")
PY

A inspeção de tabela não saiu do script — ela foi rebaixada de juíza a testemunha de defesa. Continua rodando ss, mas agora só para diagnóstico: se há ouvinte vivo visível no convidado, ele é matável; se a tabela está vazia e o bind falha, é reserva do host, que é inmatável daqui. As duas saídas geram frases diferentes no log, e só uma delas te manda fazer algo.

Antes, o mesmo ss vazio significava “livre”. Agora significa “não posso resolver isso sozinho”.

Os guards que apareceram no caminho

Escrever o probe certo foi meia hora. O resto do trabalho foram as bordas — e é nelas que mora o custo real de uma correção em produção.

Matar só com ouvinte confirmado. O script antigo chamava fuser -k incondicional no start. Se houvesse qualquer coisa respirando na porta, ela morria, sem perguntas. Agora o kill acontece apenas quando o diagnóstico confirmou um ouvinte vivo do lado do convidado. Uma reserva do host não some com SIGKILL — e matar processo inocente para “liberar” uma porta que nem estava com ele é o tipo de efeito colateral que ninguém documenta porque ninguém percebe.

Um modo que não destrói. Entrou o --check: uma passada, sem matar, sem esperar. Porque “vou rodar a mão rapidinho para ver o estado” não pode ser o gesto que derruba o serviço.

A porta vem do ambiente, sempre. Aqui está a cicatriz mais cara do dia. A primeira versão da suíte de testes herdou o PORT=9000 fixo do script. Rodar um teste significava executar o guarda contra a porta de produção, com o fuser -k do guarda antigo incluso. Aconteceu — durante o red-green, o serviço em produção caiu por causa do próprio teste que ia provar o conserto. Hoje existe um teste que nem executa o script: ele lê o arquivo e asserts que a porta é sobrescrevível por env e que não existe mais a linha hardcoded. É o único teste da suíte que não roda nada, e é justamente o que impede o acidente de novo.

Teto de espera cabe no timeout de start. MAX_WAIT de 75 segundos, escolhido para caber folgado dentro do TimeoutStartSec=120 do override da unit. Se o systemd mata o ExecStartPre no meio da espera, o que se perde não é tempo — é o diagnóstico, que é justamente a razão de a espera existir.

Falhar aberto quando falta ferramenta. O systemd entrega um PATH mínimo. Se não há interpretador Python nem ss disponíveis, o guarda não consegue sondar — e um guarda que bloqueia a subida do serviço porque ele mesmo não tem uma dependência é pior que a ausência do guarda. Nesse caminho ele declara livre e sai 0. O pior caso vira o restart seguinte, não um bloqueio permanente.

Log dedicado com timestamp. porta=9000 bind ocupado, liberada apos 25s, um carimbo por linha, histórico preservado. É o que transforma “acho que resolveu” em “está aqui a prova de que a condição voltou a acontecer e foi tratada”. Regra de casa: correção sem log que prove recorrência não está pronta.

A prova de fogo veio de madrugada

No dia 13, às 04:07, a mesma condição do incidente apareceu de novo. O log registrou bind ocupado, nenhum ouvinte visível, diagnóstico apontando para reserva do host — e às 04:08:01 a porta liberou sozinha, e o serviço subiu.

Vinte e cinco segundos de espera, zero intervenção humana, zero restart em loop.

Se fosse a guarda antiga, aqueles mesmo minuto 04:07 teriam sido mais um “porta livre, exit 0” seguido de errno 98 — o octogésimo reinício de um filme que já tinha custado 79.

Segundo ato: o conserto que derrubou a esteira

A master ficou vermelha na mesma tarde, e não foi por causa do bug.

Um dos dez testes novos era estático e começava assim: o script existe e tem o bit de execução. Só que o commit que adicionou o teste escreveu o arquivo com modo 100644 — herança de como o editor/gravação criou a cópia nova. O teste do os.access(..., X_OK) reprovou na hora.

fix(ci): devolve bit de execucao ao wait-port-free.sh (#69)

old mode 100644
new mode 100755

Banali? Banal. Mas com duas pontas afiadas. Primeiro: não é chatice de teste — um ExecStartPre sem o bit faz a unit cair com 203/EXEC antes de qualquer diagnóstico, ou seja, a guarda do bind livre estaria morta no mesmo lugar que ela conserta. Segundo: git versiona apenas o bit de execução entre os modos de arquivo, e uma mudança de modo não aparece no diff de conteúdo. É exatamente o tipo de informação que passa batido num git show --stat corrido.

E a lição que ficou mais fundo: uma asserção de permissão pode ser derrotada pelo próprio commit que a adiciona. O teste que protege o bit não protege o momento em que o bit é definido. O que fecha essa janela não é mais um assert — é a esteira rodando o assert no commit do autor, antes de qualquer merge.

Os números

Item Valor Como sei
Reinícios no incidente 79 em 24 minutos (14:19-14:43 de 12/09) contagem registrada no commit do conserto
Ferramentas que mentiram ss, fuser (mesma tabela do convidado) reproduzido no caso do incidente
Nova fonte de verdade bind() real, IPv4 entao IPv6, sem reuseaddr codigo do probe
Testes da suíte nova 10 (uma delas nem executa o script) grep -c "def test"
Alcance da correção 2 arquivos, +382/-15 git show --stat
Teto de espera 75s, dentro de TimeoutStartSec=120 teste que trava o teto
Prova de fogo 13/09 04:07 bind ocupado -> liberada em 25s log dedicado da guarda

O que vem a seguir

  • Levar o mesmo desenho de sonda para as outras pontes de porta que nasceram na época do NAT e nunca foram revisadas para o modo espelhado.
  • Uma asserção de política, não de arquivo: qualquer script chamado em ExecStartPre precisa existir, ser executável e ter teste. Hoje cada um garante isso por conta própria.
  • Parar de escrever guardas em cinco minutos. A peça que decide se o serviço sobe merece a mesma revisão de um endpoint público.
~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$

O que fica

Ferramentas que consultam a mesma fonte concordam por construção — e concordar não é provar. ss, fuser e netstat olham para a mesma tabela. Três “sim” independentes valem um.

Na dúvida entre perguntar e tentar, tente. O bind() é o único dicionário que não tem verbete faltando. Diagnóstico por inspeção serve para classificar o que a operação já detectou, não para decidir se ela é viável.

Um guarda precisa saber quando não agir. Falhar aberto sem dependências, matar só o que ele confirma que pode matar, e oferecer um modo de leitura que não destrói. Guarda que quebra o guardado não é proteção, é passivo.

O conserto entra na esteira com os defeitos dele. O commit que criou o teste do bit de execução trouxe o arquivo sem o bit. A rede de segurança não é o teste escrito hoje — é o teste executado no commit de quem escreveu.