El día que una app sirvió la página de otra
Tras una actualización, la app de grupos empezó a mostrar la página de perfil. El código era correcto y la imagen se había reconstruido. La causa estaba en cómo Docker sincroniza los ficheros antes de construir.
Después de desplegar un rediseño de las ocho apps del hub, una de ellas falló de una forma rara. Al abrir la app de grupos aparecía la de perfil. No era un error visible ni una pantalla en blanco: era otra app, completa y funcionando, en la dirección equivocada. Este es el relato de cómo se encontró la causa, porque es un fallo que puede pasarle a cualquiera que construya varias imágenes parecidas en la misma máquina.
Lo que se veía
La app de grupos devolvía una página cuya ruta base era la de perfil. El navegador cargaba desde ahí los ficheros de perfil, y el resultado era la pantalla de perfil servida bajo la dirección de grupos.
Las primeras hipótesis fueron las habituales:
- El código está mal. No: en el repositorio, el fichero de grupos tenía su ruta correcta.
- El proxy enruta mal. No: las reglas apuntaban al servicio correcto.
- Es una caché del navegador. No: ocurría también desde una petición directa.
- Se ha desplegado la imagen equivocada. Tampoco: la etiqueta era la de grupos.
Quedaba mirar dentro de la propia imagen. Y ahí estaba: la imagen de grupos contenía la página principal de perfil. El fichero del disco era correcto; el de la imagen, no.
Ni reconstruir sin caché lo arreglaba
Lo normal ante una imagen sospechosa es reconstruirla ignorando la caché de capas. Se hizo, y la imagen nueva tenía exactamente el mismo fichero equivocado. Eso descartaba la caché de capas y apuntaba a un paso anterior: el momento en que Docker recibe los ficheros con los que va a construir.
La causa: la sincronización del contexto
Cuando se lanza una construcción, el cliente envía al motor de construcción el contenido de la carpeta, lo que se llama el contexto. Para no reenviar siempre todo, el motor moderno de Docker (BuildKit) sincroniza de forma incremental: compara los ficheros con los que ya recibió y solo transfiere los que han cambiado.
La cuestión es cómo decide que un fichero no ha cambiado. No compara el contenido. Mira los metadatos: el tamaño y la fecha de modificación. Y en este proyecto se dieron a la vez tres coincidencias:
- Todas las apps se construyen desde una carpeta que se llama igual. Cada app tiene su carpeta
build. Para el motor, construcciones sucesivas desde carpetas del mismo nombre se parecen mucho a la misma carpeta que ha cambiado un poco. - Los ficheros tenían el mismo tamaño. Las páginas principales de perfil y de grupos son casi idénticas: solo cambian la ruta base y el título, y daba la casualidad de que ocupaban exactamente los mismos bytes.
- Se habían modificado en el mismo segundo. El rediseño editó los ficheros de las ocho apps con un script, de golpe.
Misma ruta relativa, mismo tamaño, misma fecha. El motor concluyó que ese fichero ya lo tenía y no lo volvió a pedir. Construyó grupos con la página de perfil, que era la que había recibido antes. Como el fallo estaba en los ficheros de entrada y no en las capas, reconstruir sin caché no cambiaba nada.
La solución
La forma de eliminar la sincronización incremental es no darle al motor una carpeta que sincronizar. Docker admite recibir el contexto como un archivo empaquetado por la entrada estándar:
tar -C app/build --exclude=./node_modules -cf - . \
| docker buildx build -t mi-imagen -
Así el contexto es un flujo de datos completo en cada construcción. No hay estado anterior con el que compararlo ni nada que pueda quedarse a medias. Es un poco más lento, porque se envía todo cada vez, pero en un proyecto de este tamaño son segundos.
Verificar antes de publicar
La segunda medida fue dejar de asumir que una imagen contiene lo que debería. Ahora, antes de subir ninguna, un script la arranca y comprueba lo que lleva dentro:
- En las apps web: que la ruta base, el título y el color identificativo son los de esa app y no los de otra.
- En las APIs: que cada fichero de código de la imagen es idéntico, byte a byte, al del repositorio.
Tarda unos segundos y convierte un fallo que se descubre en producción en uno que se descubre antes de publicar.
Por qué los sistemas de integración continua no lo sufren
El fallo solo aparecía al construir a mano, varias apps seguidas, en la misma máquina. Un sistema de integración continua que construye cada imagen en un entorno recién creado no tiene estado anterior que mezclar. Es un buen argumento a favor de esos sistemas: no son solo automatización, también son aislamiento.
Qué nos llevamos
- «Sin caché» no significa «desde cero». Hay más de una caché en una construcción, y la opción habitual solo afecta a una.
- Comparar ficheros por tamaño y fecha es una optimización que falla justo cuando los ficheros se parecen mucho y se generan a la vez, que es lo que ocurre con las plantillas.
- Un mismo nombre para carpetas distintas es cómodo y tiene un coste oculto.
- Hay que verificar el resultado, no el proceso. Que la construcción termine sin errores no dice nada sobre lo que contiene la imagen.