02Operação5 min

Por que "funciona na minha máquina" já não me diz nada

As diferenças entre a sua máquina e produção não são azar: são quatro categorias concretas que falham sempre e que o build verde não consegue enxergar.

Por Salva Castells

Imagem de capa: Edna Winti, CC BY 2.0.
Neste post

Um dos commits de que menos me orgulho diz literalmente "fixear la mierda de dependencias, que no dan error en build pero sí al desplegar". Escrevi cansado, mas a frase descreve bem o problema: o build passou, os testes passaram, e o deploy caiu do mesmo jeito.

Não foi um caso raro. Num serviço em Python para processar documentos tenho seis commits seguidos no mesmo dia, todos com o título "try to deploy on railway", numerados 1 e 2 quando acabaram os nomes. Cada um era uma tentativa às cegas contra um ambiente que eu não conseguia reproduzir. Isso não é azar: é uma categoria de falha com forma própria.

As dependências que resolvem diferente conforme quem as instala

O caso dos seis commits era o PyTorch. Na minha máquina eu já tinha ele em cache e subia. No contêiner, o uv resolvia o torch>=2.0.0 do PyPI, que arrasta a stack inteira do CUDA: nvidia-cublas-cu12 são 594 MB num único wheel, e atrás vêm cuda-cupti, cuda-nvrtc e companhia. O build nem falhava com um erro meu, morria por espaço e tempo. Numa máquina que nunca vai ver uma GPU.

O que consertou foi parar de pedir o pacote e começar a pedir o índice:

dependencies = ["torch==2.9.1+cpu", "torchvision==0.24.1+cpu"]

[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true

[tool.uv.sources]
torch = { index = "pytorch-cpu" }

O detalhe que me custou duas tentativas: com torch>=2.0.0 apontando para o índice CPU, o lockfile termina com duas entradas, 2.9.1 para sys_platform == 'darwin' e 2.9.1+cpu para o resto, porque no macOS esse índice não publica sufixo +cpu. Funciona, mas significa que o que você instala no seu Mac e o que instala no contêiner não são o mesmo artefato. Fixar a versão exata com sufixo fecha essa porta.

A outra variante, mais boba e mais frequente: uma dependência que você usa no código e que não está no package.json. Lá está o puppeteer-core entrando num commit chamado "adds missing dependency", e outro chamado "add missing dependencies" três semanas antes. Localmente funcionava porque o pacote estava em node_modules por ser dependência transitiva de outra coisa. Num contêiner com instalação limpa, não.

O ambiente que você não replica: bibliotecas do sistema

A falha mais cara que já tive dessa família estava num app de desktop de transcrição. O microfone funcionava perfeitamente com uv run, e não funcionava no .deb instalado. O erro era Invalid sample rate [PaErrorCode -9997].

A causa: o PyInstaller empacotava o PortAudio que vem no wheel do sounddevice, compilado sem backend de PulseAudio, e um libasound.so.2 sem seus módulos de alsa-lib. Dentro do bundle sumiam os PCM pulse, pipewire e default, e só sobravam os hw: em acesso exclusivo. O PipeWire já tinha pegado o microfone, então gravar falhava. Com uv run isso não aparecia porque ali o link é contra o PortAudio do sistema, que tem PulseAudio.

A solução foi excluir libportaudio, libasound e libjack do bundle. E aí chegou a segunda parte, que é a que de fato ensina algo: a versão seguinte não subia em nenhuma distro moderna.

OSError: cannot load library 'libportaudio.so.2': .../_internal/libstdc++.so.6:
version `GLIBCXX_3.4.32' not found (required by /usr/lib/x86_64-linux-gnu/libjack.so.0)

Ao carregar o PortAudio do sistema entra em jogo o libjack do sistema, que numa distro nova precisa de GLIBCXX_3.4.32. Mas o PyInstaller continuava empacotando libstdc++.so.6, e o bundle vem primeiro no caminho de busca do linker, então ganhava a cópia do runner de build, teto GLIBCXX_3.4.30. A regra que tirei dali: excluir uma biblioteca corta nas duas direções. Se uma dependência resolve contra o sistema, tudo o que ela arrasta tem que resolver contra o sistema também.

Por que o build verde quase não diz nada

Um build verifica que o código compila com as dependências que o resolvedor escolheu naquela máquina, naquele momento. Não verifica que ele sobe, que encontra suas bibliotecas nativas, nem que o processo saiba escutar onde o runtime vai mandar tráfego.

Isso último também já paguei. Numa ferramenta interna passei uma tarde com o Procfile e a string de conexão: next start sem -H 0.0.0.0 não aceita tráfego de fora do contêiner, o web: pnpm next start -p $PORT virou web: pnpm start -- -p $PORT para que a flag chegasse, e de quebra foi preciso tirar channel_binding da DATABASE_URL porque nem todos os runtimes suportam. Três consertos, no mesmo dia, nenhum visível localmente: localmente o servidor escuta em localhost e você também está em localhost.

O teste que teria pegado o bug

O importante no caso do libstdc++ não é o conserto, é por que o smoke test não viu: ele rodava no mesmo runner onde construíamos. Ali o libjack do sistema é tão velho quanto o libstdc++ empacotado, então eles batem e o app sobe. A falha só aparecia em distros mais novas que a máquina de build. Ou seja, nas dos usuários.

O que coloco hoje no CI, em ordem do que mais me salvou:

  • Subir o artefato, não só construí-lo. Um docker run do contêiner real que faça uma requisição a /health. O build verde e o processo vivo são duas coisas diferentes.
  • Subi-lo num ambiente diferente do de build. Neste caso, um contêiner ubuntu:24.04 instalando só as Depends declaradas, o que de quebra verifica que essa lista esteja completa.
  • Uma verificação estática de ABI. Um script com objdump que garante que o runtime de C++ não viaja no bundle e compara o que as libs do sistema pedem contra o que o bundle oferece. Não pode ser intermitente, e falha também no runner velho onde o bug não se reproduz.
  • Instalação limpa, sem cache. É a única forma de fazer aparecer a dependência que você tem por acidente.

"Funciona na minha máquina" significa que funciona com as suas bibliotecas de sistema, o seu cache de pacotes, a sua versão de distro e a sua interface de rede. Quatro coisas que produção não compartilha com você.