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 rundo 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.04instalando só asDependsdeclaradas, o que de quebra verifica que essa lista esteja completa. - Uma verificação estática de ABI. Um script com
objdumpque 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ê.
