02Operación6 min

Mi smoke test pasaba porque corría en la máquina equivocada

Arreglé un micrófono muerto en un .deb y la siguiente versión no arrancaba en ninguna distro moderna. El CI no lo vio porque probaba el paquete en el mismo sitio donde lo construía.

Por Salva Castells

Imagen de portada: Edna Winti, CC BY 2.0.
En este post

Tengo un commit que dice, tal cual, "fixear la mierda de dependencias, que no dan error en build pero sí al desplegar". No es mi mejor mensaje de commit. Pero cada vez que lo releo pienso que resume mejor que cualquier post el problema de "en local funciona": el build pasa, los tests pasan, y el despliegue se cae igual.

Lo que quiero contar aquí es un caso concreto en el que yo ya había hecho los deberes, o eso creía. Tenía un smoke test que arrancaba el artefacto. Y aun así se me escapó un fallo que impedía arrancar la app en cualquier distro reciente.

El arreglo que rompió otra cosa

La app es de escritorio, de transcripción, empaquetada con PyInstaller en un .deb. Venía de arreglar un bug feo: el micrófono no grababa en el paquete instalado (Invalid sample rate [PaErrorCode -9997]) porque PyInstaller metía en el bundle el PortAudio del wheel de sounddevice, compilado sin backend de PulseAudio, y un libasound.so.2 sin los módulos de alsa-lib. Con uv run todo iba bien, porque ahí se enlaza contra el PortAudio del sistema. La solución fue excluir libportaudio, libasound y libjack del bundle y dejar que vinieran del sistema.

Contento, saqué la versión. Y la siguiente no arrancaba en ninguna 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)

Tardé un rato en leer bien ese mensaje, porque a primera vista parece que falta PortAudio. Lo que pasa es otra cosa. Al cargar el PortAudio del sistema, entra detrás el libjack del sistema, y en una distro reciente ese libjack necesita GLIBCXX_3.4.32. Pero PyInstaller seguía empaquetando su propio libstdc++.so.6, y el directorio del bundle va primero en la ruta de búsqueda del linker. Así que ganaba la copia que venía de la runner de build, que llegaba como mucho a GLIBCXX_3.4.30.

Había sacado tres librerías del bundle para que resolvieran contra el sistema, y me había dejado dentro una de la que esas tres dependían. Lo que saqué de ahí lo aplico ahora cada vez que toco un empaquetado: si excluyes una librería, tienes que seguir el hilo hacia abajo. Todo lo que ella arrastre tiene que venir del mismo sitio que ella. Medio bundle y medio sistema es la peor combinación posible, porque funciona justo en la máquina donde lo pruebas.

Por qué el smoke test no lo vio

Esto es lo que más me escoció. El CI arrancaba el binario, tal y como yo predico que hay que hacer. Pero lo arrancaba en la misma runner donde se había construido. Y en esa runner, el libjack del sistema es tan viejo como el libstdc++ empaquetado. Coinciden, carga todo, la app arranca, verde.

El fallo solo existía en distros más nuevas que la máquina de build. O sea, en las de los usuarios, que es donde menos me conviene descubrir las cosas.

Seguro que alguien está pensando "eso te pasa por usar PyInstaller, usa Flatpak / AppImage / lo que sea". Puede ser. Flatpak te da un runtime fijo y te quita esta clase de problema a cambio de otras, y no descarto moverme. Pero la lección no cambia de herramienta: si pruebas en el mismo entorno donde construyes, estás probando que el entorno de build es coherente consigo mismo, cosa que ya sabías.

Lo que tengo ahora en el pipeline, dos piezas:

  1. Un contenedor ubuntu:24.04 limpio donde se instala el .deb con solo las Depends declaradas y se arranca la app. De paso comprueba que la lista de Depends está completa, que es otra cosa que yo no habría verificado nunca a mano.
  2. Una comprobación estática con objdump: asegura que el runtime de C++ no viaja dentro del bundle y compara las versiones de símbolos que piden las librerías del sistema con lo que ofrece el bundle. Me gusta mucho esta porque no depende de qué distro tenga la runner: falla también en la runner vieja, donde el bug ni siquiera se reproduce. Y no es intermitente.

La primera te dice que hoy funciona en una distro concreta. La segunda te dice que la estructura del paquete es correcta, y esa es la que me deja dormir.

Otras formas en que "en local" me ha mentido

El caso del libstdc++ es el más retorcido, pero no el único. Repasando el historial veo el mismo patrón en sitios muy distintos.

La primera, PyTorch trayéndose CUDA entero a un servidor sin GPU. En un servicio de Python para procesar documentos tengo seis commits seguidos el mismo día titulados "try to deploy on railway" (cuando se me acabaron las ideas, los numeré: 1, 2). En local tenía torch en caché y arrancaba. En el contenedor, uv resolvía el torch>=2.0.0 de PyPI, que arrastra todo el stack de CUDA: solo nvidia-cublas-cu12 son 594 MB en un wheel, y detrás vienen cuda-cupti, cuda-nvrtc y compañía. El build no fallaba con un error mío; se moría por espacio y tiempo. En una máquina que no va a ver una GPU en su vida.

Lo que funcionó fue pedir el índice además del paquete:

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" }

Y aquí hay un detalle que me costó dos intentos. Si dejas torch>=2.0.0 apuntando al índice CPU, el lockfile acaba con dos entradas: 2.9.1 para sys_platform == 'darwin' y 2.9.1+cpu para el resto, porque ese índice no publica sufijo +cpu para macOS. Funciona, pero lo que instalas en tu Mac y lo que instalas en el contenedor ya no son el mismo artefacto, y eso es exactamente la clase de diferencia que luego no sabes explicar. Fijar la versión exacta con el sufijo cierra esa puerta.

Luego está la dependencia que tienes por accidente, que es la más tonta y la que más veces me ha pasado: puppeteer-core entrando en un commit llamado "adds missing dependency", y tres semanas antes otro llamado "add missing dependencies". En local funcionaba porque el paquete estaba en node_modules como dependencia transitiva de otra cosa. En un contenedor con instalación limpia, no existía. Los gestores estrictos (pnpm por defecto no te deja importar lo que no declaras) te salvan de parte de esto, pero no de todo.

Y el servidor que solo escuchaba para mí. En una herramienta interna me pasé una tarde entera con el Procfile y la cadena de conexión. El servidor no aceptaba tráfico de fuera del contenedor hasta que le pasé -H 0.0.0.0. El web: pnpm next start -p $PORT acabó como web: pnpm start -- -p $PORT para que el flag llegara de verdad al script. Y hubo que quitar channel_binding de la DATABASE_URL porque ese runtime no lo soportaba. Tres arreglos el mismo día, ninguno visible en local, porque en local el servidor escucha en localhost y tú también estás en localhost.

Lo que hago ahora, sin mucha ceremonia

No tengo una receta universal, y cada stack tiene su propio infierno. Pero cuando monto un pipeline nuevo me hago estas preguntas, por orden de cuántas veces me han salvado:

  • ¿Se arranca el artefacto real, o solo se construye? Un docker run que pida /health ya vale.
  • ¿Se arranca en un sitio distinto de donde se construyó?
  • ¿Hay alguna comprobación estática que falle aunque el bug no se reproduzca en la runner?
  • ¿Alguna instalación en el pipeline se hace sin caché?

Cuando alguien me dice "en local funciona", ahora lo entiendo como que funciona con sus librerías del sistema, su caché, su distro y su interfaz de red. Que es mucho decir y a la vez no dice casi nada.