Por qué Railway está cambiando de Nix a Railpack
(blog.railway.com)- Railway rediseñó el builder que crea imágenes de contenedor a partir del código del usuario, trasladando la experiencia de Nixpacks, con la que construyó más de 14 millones de apps, a Railpack
- Nixpacks era suficiente para el 80% de los usuarios, pero los 200 mil usuarios restantes de Railway podían chocar con límites en control de versiones, tamaño de imagen y caché
- Railpack mejora la reproducibilidad de builds al usar versiones
major.minor.patch, bloqueo de dependencias y un flujo de instalación basado en Mise, en lugar del versionado basado en commits de Nix - Al generar directamente BuildKit LLB y Frontend, la imagen base de Node se redujo 38% y la imagen base de Python 77%, además de habilitar caché compartible entre entornos
- Railpack está disponible actualmente en Beta y puede activarse desde la configuración del servicio; Railway está priorizando primero la calidad en los lenguajes más usados antes que un soporte amplio
El contexto detrás del nuevo builder de Railway
- Railway presentó Railpack como el siguiente paso de su builder de Railway
- Railpack fue desarrollado desde cero a partir de la experiencia obtenida al construir más de 14 millones de apps con Nixpacks
- Nixpacks se lanzó hace unos 3 años y se convirtió en la forma predeterminada en Railway para construir imágenes a partir del código del usuario
- Funcionaba bien para el 80% de la base de usuarios, pero los 200 mil usuarios restantes de Railway podían experimentar limitaciones
- Railway concluyó que, para escalar su base de usuarios de 1 millón a 100 millones, necesitaba una gran mejora en el builder
Los límites con los que Nixpacks se topó en Nix
- El mayor problema era el versionado de paquetes basado en commits de Nix
- Cada paquete solo ofrecía la versión major más reciente
- Las versiones quedaban atadas a un commit específico del repo nixpkgs
- El intento de soportar todas las versiones patch terminó en una estructura que mapeaba manualmente cadenas de versión con SHA de commit, algo que no era claro ni fácil de mantener para colaboradores no familiarizados con el versionado de Nix
- Lenguajes como Node y Python terminaron soportando solo la versión major más reciente
- Al actualizar el SHA del commit para soportar versiones de paquete más nuevas, otras versiones de paquetes también podían cambiar al mismo tiempo
- Si cambiaba la versión predeterminada, aumentaba la probabilidad de que builds de usuarios que antes funcionaban empezaran a fallar con errores inesperados
- Railway considera peor que una build que antes tenía éxito se rompa de repente, que el hecho de que el usuario no pueda acceder al paquete más reciente
Problemas de tamaño de imagen y caché
- La forma en que Nixpacks obtenía dependencias con Nix solía generar imágenes grandes
- Nix y los paquetes y librerías relacionados, necesarios tanto para build como para runtime, quedaban dentro de una sola capa
/nix/store - Como no había forma de separar las dependencias de Nix en capas distintas, había límites para reducir el tamaño final de la imagen
- Railway considera que esto no es un problema de Nix en sí, sino de cómo Nixpacks usaba Nix
- También era difícil controlar cuándo se invalidaba la caché de capas
- Railway inyecta una variable de entorno con el ID de despliegue en todos los builds
- En un Dockerfile, cualquier capa ejecutada después de agregar esa variable siempre quedaba invalidada y no podía cachearse
- El enfoque de intentar ocultar al usuario los elementos centrales de Nix tampoco encajaba bien
- Querían evitar que el usuario tuviera que entender qué es una derivation o por qué Node 22.14.0 está en cierta versión de archive del canal unstable
Cambios estructurales en Railpack
- Railway creó Railpack para resolver los problemas que encontró con Nixpacks
- Al alejarse de Nix, el nombre también cambió de Nixpacks a Railpack
- El codebase cambió de Rust a Go por las librerías de BuildKit
- Railpack controla de forma más directa cómo se genera la imagen final
- Genera directamente BuildKit LLB y Frontend
- Frente a Nixpacks, la imagen base de Node es 38% más pequeña y la imagen base de Python es 77% más pequeña
- Usa Mise para la resolución de versiones y la mayor parte de la instalación de paquetes
- También deja abierta la posibilidad de soportar otras fuentes de ejecutables en el futuro
- Es posible bloquear las dependencias usadas en un build exitoso
- Así, aunque la versión predeterminada de Node cambie de 22 a 24, la build puede seguir funcionando sin romperse
- Usa BuildKit secrets para evitar que variables de entorno secretas aparezcan en logs de build o en la imagen final
Cómo funciona un build de Railpack
- El proceso de Railpack se divide en tres etapas
- Analyze: inspecciona el código y decide qué paquetes instalar, qué comandos ejecutar y cuál será el comando de arranque
- Plan: crea un plan de build serializable en JSON compuesto por varias etapas, donde cada una toma entradas de otras etapas o de la imagen completa
- Generate: construye el grafo de build de BuildKit según las entradas y salidas del plan
- Mientras que un Dockerfile es lineal, un grafo de BuildKit se organiza de forma mucho más paralela
- Cada comando se ejecuta en su propia etapa dentro de un build multistage, lo que permite controlar con precisión las capas de entrada y cómo se ensambla el sistema de archivos final
- Railpack genera un plan de build con todos los pasos necesarios
- Cada etapa define específicamente de qué etapas o imágenes previas depende
- Este formato es de nivel más bajo que el que usaba Nixpacks
- El plan se interpreta tras convertirse en un grafo en formato LLB
- BuildKit trabaja desde el final hacia atrás, tomando de la caché cuando es posible y ejecutando comandos solo cuando hace falta para resolver las capas solicitadas
- Para invalidar capas cuando cambian variables de entorno específicas, Railpack hace hash de los valores usados y monta en el sistema de archivos de entrada un archivo con ese hash
- Si ni el código ni las variables usadas cambian, la caché de capas se aprovecha
- Railpack puede definir por completo la forma en que se construye una imagen
Lo que Railpack hace posible
- Puede construir y desplegar sitios estáticos de Vite, Astro, CRA y Angular sin configuración
- La integración entre el build y la UI de Railway es más estrecha
- Puede soportar versiones más recientes de lenguajes sin necesidad de una nueva release de Railpack
- Puede usar caché de capas optimizada entre varios entornos de un proyecto
Cómo usarlo hoy y alcance de soporte
- Railpack se ofrece actualmente en Beta y puede activarse desde la configuración del servicio
- Ya se usa para builds de railway.com y central station
- Actualmente soporta lo siguiente
- Node
- Python
- Go
- PHP
- Despliegue de HTML estático
- Soporte base para sitios estáticos de Vite, Astro, CRA y Angular
- Railway apunta a ser un entorno donde tanto frontend como backend puedan desplegarse fácilmente
- El soporte para frameworks y lenguajes sigue ampliándose
- Las solicitudes pueden enviarse en Help Station
- Hasta que se definan por completo la API central y las abstracciones, priorizan la profundidad en los lenguajes más usados por encima de un soporte amplio
- Railpack es open source y la documentación está disponible en railpack.com
1 comentarios
Opiniones de Hacker News
Soy entusiasta de Nix, pero no pretendo criticar a Railway por alejarse de Nix. Solo que algunas quejas parecen necesitar más explicación.
Nixpkgs es excelente, pero no es lo mismo que Nix, y si lo que se quiere es traer una versión arbitraria de un toolchain, Nixpkgs simplemente no es ideal. Las herramientas de Nix para traer versiones arbitrarias de Rust ya son muy buenas, y otras herramientas de desarrollo basadas en Nix también han mostrado formas de manejar bien esto.
Tampoco entiendo eso de que “no hay forma de separar las dependencias de Nix en una capa aparte”. Se puede separar como uno quiera, y las herramientas Docker integradas en Nixpkgs incluso tienen algo de soporte para eso.
El paso de Rust a Go no está directamente relacionado con Nix, pero es interesante, y también suena como si Railpacks y Nixpacks hubieran sido creados por personas distintas. He visto que salen cosas bastante malas cuando gente que no está familiarizada con Nix se hace cargo de una solución Nix incompleta dentro de una organización, y por eso en el trabajo normalmente no uso Nix para evitar crear este tipo de situación.
Con “separa las capas como quieras” también importa si eso es claro, simple y el comportamiento por defecto.
La razón por la que la gente se queja de Nix no es que no sea Turing-completo, sino que no ofrece una API de primera clase simple que encaje directamente con los proyectos idiomáticos de ese ecosistema, por lo que termina creando más problemas de los que resuelve.
Si cada proyecto que intenta usar Nix termina escribiendo sus propios módulos para arreglar problemas de Nix, hay pocas razones para usar Nix en vez de herramientas mainstream con buena documentación. Este caso parece exactamente eso, y creo que la mayoría simplemente elegiría Docker.
Es frustrante que un producto para desarrolladores se aferre a flakes ideológicamente puros en vez de resolver problemas prácticos de experiencia de desarrollo a una velocidad que no sea de escala geológica. Entiendo que se trata de contribuciones voluntarias, pero da mucha pena que tanto esfuerzo técnico termine en algo que, por una mala experiencia de usuario, se vuelve difícil de usar en la práctica.
En la forma en que Nix funciona junto con la estructura de Nixpkgs, fijar una versión de un paquete significa fijar el commit de todo el árbol de nixpkgs. Las compilaciones de paquetes de node/python/ruby también dependen del estado del árbol fuera del directorio del paquete, así que hace falta un mapeo entre versiones y commits.
Esta abstracción tiene fugas, por lo que Railway tendría que exponerla a sus usuarios, y el usuario, que solo quería hacer
yarn add new-fancy-nodejs-package-with-linked–native-deps, puede terminar teniendo que cuadrar varios estados del repositorio nixpkgs.Para usos de alcance reducido quizá esté bien usar Nix sin Nixpkgs, pero en una plataforma como Railway parece difícil de justificar.
pkgen general funcionaba bien, pero un día intenté compilar vim desde ports con flags USE personalizados, se bajaron más de 20 dependencias, en cadamake menuconfigme preguntaba por opciones, y luego el paquete 16 de 23 falló con algo como “esto necesita aquello, y aquello necesita Fubar3.32.1, pero Fubar3 fue deprecated en favor de Fubar4”, así que me rendí.Entiendo que los desarrolladores del Core OS no pueden dar soporte a más de 10 mil paquetes, pero también debería quedar claro que, si uno intenta usarlo activando funciones personalizadas, la probabilidad de falla es alta. O sería mejor exigir que una compilación estándar generada de forma independiente tenga éxito antes de aparecer en ports, y sacar de la lista de ports lo que no compile.
Podría criticar el lenguaje Nix durante horas, pero es antiguo, hizo lo mejor que pudo en su momento y quizá ya no valga mucho la pena cambiarlo. El sistema de build de Nix se siente bastante primitivo, y a menudo recompila cosas que no parecen necesitar recompilarse. Por ejemplo, una parte considerable del build de la ISO de instalación de NixOS depende de la línea de comandos que se le pasa al kernel,
console=ttyS2,1500000n8, así que con solo cambiar la velocidad del puerto serial hace falta un build de unos 3 minutos. Es gracioso, pero no voy a dejar Nix por eso; simplemente es algo que no permitiría en mis propios builds.Personalmente, creo que Nix para imágenes Docker es donde Nix peor se desempeña. Hace tiempo, al crear software en Go, tuve que agregar el binario
pg_dumpde Postgres a una imagen de contenedor. Siguiendo la sugerencia del equipo de infraestructura, usé Nix, y una imagen con un binario Go comprimido de 50 MB se convirtió en algo misterioso de 1.5 GB.pg_dumppesa 464 KB. Al final instalé el paquete apt con Bazel yrules_debian, y sobre distroless quedó mucho más limpio y pequeño. Según mi experiencia real con Nix, los sistemas Nix siempre parecen terminar pesando 1.4 GB. La ISO de instalación pesa 1.4 GB, y una máquina recién instalada también pesa 1.4 GB.La situación de querer compilar un proyecto grande en C++ ya es un camino bien transitado, y cambiar C++ por Rust no altera lo esencial. Hay sistemas de build que hacen menos dolorosa la situación de las bibliotecas, y aunque son tan complejos como Nix, algunos encajan mejor con este uso. Como Nix intenta ser un sistema de build para software ajeno y para nixpkgs, termina ubicándose en un terreno muy general. Un sistema de build diseñado para compilar tu propio software normalmente hace mejor ese trabajo. En lo personal estoy satisfecho con Bazel y, fuera de
go buildpara proyectos solo de Go, creo que casi no usaría otra cosa, pero hay muchas opciones. En el 99% de los casos usaría eso en vez de Nix, y escribiría un flake para que la gente pueda instalar la última versión con home-manager.La parte de selección de versiones suena rara. Las versiones de nixpkgs tienen sentido cuando ejecutas o compilas un sistema, pero si ofreces runtimes o compiladores como plataforma, necesitas una forma de proporcionar versiones directamente, como hace devenv.
Si terminas compilando un sistema viejo para ofrecer un nodejs viejo, te pierdes los parches de seguridad de las dependencias. Devenv lo maneja, por ejemplo, con https://github.com/cachix/nixpkgs-python, “manteniendo todas las versiones de Python actualizadas cada hora en Nix”.
Que Railway inyecte una variable de entorno con el ID de despliegue en todos los builds podría haberse hecho en una capa posterior a la instalación. También puedes dividir los paquetes en varias capas, y hay automatización por lotes para reducir la cantidad de capas.
“No había un problema con Nix en sí, sino con la forma en que lo usábamos” es un buen ejemplo de usar la herramienta adecuada para el trabajo adecuado. Nix es excelente para algunos usos y terrible para otros.
El problema es que la curva de aprendizaje de Nix es tan alta que, para cuando entiendes lo suficiente como para juzgarlo, ya invertiste tanto tiempo que da pena volver atrás, y terminas forzándolo para encajar con lo que originalmente necesitabas resolver.
Por este paradigma, para la IA es muy fácil crear un
shell.nixo unconfiguration.nixque cumpla una especificación. Por ejemplo, puede incluir paquetes de Python, paquetes de Linux, variables de entorno, entradas de rutas, etc.Uso esto a menudo para incluir en el repositorio un entorno que soporte completamente ese paquete. Con flakes sería más reproducible, pero entiendo
flake.nixcomo algo parecido a unshell.nixcon versiones fijadas, y todavía lo estoy aprendiendo.Parece que intentan meter versiones a la fuerza donde no las hay. Es como intentar meter un cubo cuadrado en un agujero redondo.
Que una “versión predeterminada” rompa lo que depende de ella... no entiendo qué significa eso. Es como usar la etiqueta
:latestde Docker y sorprenderse de que, cada vez que levantas un servidor nuevo, se rompa porque es una versión distinta de la imagen “predeterminada” anterior.No entiendo nada de la explicación de esta entrada de blog. Parecen personas que no tienen ni idea de qué es una “versión” de software.
Tampoco entiendo por qué dicen que “no hay forma de separar las dependencias de Nix en capas aparte”. Por supuesto que puedes dividir
/nix/storeen tantas capas como necesites. Incluso me pregunto si saben cómo se usan contenedores y Nix en primer lugar.Con esta inmadurez tan evidente, no sorprende que la solución propuesta huela a pescado podrido. Es el típico síndrome NIH, y parece muy probable que los mismos problemas que no pudieron resolver con Nix se propaguen a su nueva “solución”.
Como dijeron otros, nix2container y flakes parecen resolver todos los problemas que tenían.
En cuanto al versionado, flakes que escribí hace 3 años todavía se compilan hoy con exactamente las mismas versiones y la misma salida que cuando los escribí por primera vez.
Aunque sí suena a que quieren salir al mercado como plataforma y levantar inversión.
Edición: acabo de revisar el GitHub de nixpacks y de inmediato saltó a la vista que usan
rustPlatformde nixpkgs en vez de rust-overlay[0] de oxalica, que habría aparecido con una búsqueda rápida del problema de Rust.rust-overlayes uno de los overlays más útiles y potentes que he usado.[0] https://github.com/oxalica/rust-overlay
nix2container[1] de hecho puede separar dependencias en capas aparte. Puedes crear explícitamente capas que contengan solo algunas de las dependencias necesarias para la imagen, y hay ejemplos en esta sección: https://github.com/nlewo/nix2container?tab=readme-ov-file#is...Por ejemplo, si tus imágenes usan bash, puedes crear explícitamente una capa que contenga el cierre de bash. Esa capa se reutiliza en todas las imágenes, y solo se recompila y se vuelve a subir cuando cambia ese cierre de bash.
Que las imágenes se vuelvan grandes por una única capa de
/nix/storeaplica a la función predeterminadanixpkgs.dockerTools.buildImage, pero no a nix2container ni anixpkgs.dockerTools.streamLayeredImage. Estas herramientas, en lugar de escribir las capas en el Nix store, crean un script que realmente sube la imagen usando rutas existentes del store. La implementación de nix2container crea un archivo JSON que describe las rutas del Nix store de todas las capas, y Skopeo consume ese JSON para subir la imagen al daemon de Docker, a un registro, a podman, etc.Como referencia, soy el autor de nix2container.
[1] https://github.com/nlewo/nix2container
El problema central aquí es aferrarse a la actitud de sopa de versiones a medida que fomentan los gestores de paquetes de lenguajes. Este enfoque es completamente insostenible.
La alternativa, Mise, no parece tener capacidad para entender restricciones de versiones entre paquetes, ni parece ejecutar pruebas para verificar que cada paquete instalado funcione bien con las versiones de su entorno. Entonces no obtienes en absoluto lo mismo.
La sopa de versiones personalizadas no es sostenible, pero una de las razones por las que la gente la sigue usando es que, en general, funciona bien. Una de las razones por las que funciona bien es que las bibliotecas a nivel de sistema operativo vienen de otro mundo mucho más conservador, y tratan de evitar al máximo romper la compatibilidad hacia atrás
Así que se puede tomar como base un sistema operativo estable y bien mantenido, montar encima una sopa de versiones personalizadas de herramientas y runtimes de lenguajes con herramientas como mise o asdf, y luego ejecutar la app. Casi nunca se rompe. Cuando se rompe, uno trastea con versiones y pequeños arreglos hasta que vuelve a funcionar, y sigue adelante. Que se haya roto es molesto, pero no importante. Aumentar la fricción, exigir aprendizaje o pedir más trabajo es una pérdida de tiempo
En cambio, también hay personas que buscan una solución para que no vuelva a romperse nunca. Para ellas el problema es importante, así que está bien que la solución exija fricción, aprendizaje y trabajo adicional. Esas personas quieren Nix
La mayoría pertenece al primer grupo, así que una empresa como Railway, que quiere crecer, al final termina eligiendo una solución adecuada para ese grupo
Cargo.lockes trivial. nixpkgs va en dirección opuesta a la sopa de versiones personalizadas, pero Nix en sí también puede manejar ese enfoque bastante bienPor mi experiencia trabajando como DevOps/SRE, cuando alguien intenta crear un sistema para gestionar dependencias y cosas similares, por lo general termina tomando uno de dos caminos. Python puede servir como ejemplo
Opción 1: “Usemos un gran repositorio único compartido.” La ventaja es que todo está en un solo lugar, incluye lo que se necesita, y todos usan lo mismo, así que es más fácil corregir problemas como vulnerabilidades. La desventaja es que alguien siempre quiere una versión especial, los despliegues graduales son difíciles y los cambios tienden a volverse un big bang, además de que aparece la pregunta: “¿Cómo hacemos una versión pequeña de Docker?”
Opción 2: “Que todos tengan su propio conda/venv.” La ventaja es que cada quien obtiene exactamente lo que quiere, no usa paquetes innecesarios y las actualizaciones por etapas son fáciles. La desventaja es que terminas con “¿cuántos entornos conda hay, exactamente?”, las bibliotecas de distintos grupos quizá no se prueben con la misma combinación de bibliotecas de Python, y la gestión de vulnerabilidades se vuelve una pesadilla porque ni siquiera sabes dónde están los distintos entornos conda
Por eso siempre soy escéptico cuando alguien dice “este nuevo enfoque lo resuelve todo”. Mientras más avanza mi carrera, más cierta me parece la frase “no hay soluciones, solo trade-offs”
Incluso con la poca experiencia que tengo con Nix, los argumentos aquí no me parecen muy acertados
Dicen que “no es claro ni mantenible para colaboradores que no están familiarizados con la gestión de versiones de Nix” y que “Node y Python pasaron a soportar solo las versiones mayores más recientes”, pero no entiendo por qué eso sería inmantenible. Si el problema es que hay que crear una lista de versiones disponibles, me pregunto si no se puede automatizar
Más aún, no entiendo por qué Railway define cómo deben usar Nix sus usuarios. ¿No es uno de los puntos centrales de Nix que puedes configurar una máquina vacía con la versión exacta de los paquetes que quieres? No entiendo por qué Railway tiene que interponerse entre el usuario y esa versión para limitarla
Si acaso la arquitectura impide que los usuarios vean Nix directamente, la pregunta original sigue en pie: ¿no se puede automatizar la lista de versiones de paquetes?
Sinceramente, las razones presentadas no parecen muy sólidas. Tal vez la persona que introdujo Nix se fue y a quienes quedaron no les gustaba mucho. El lenguaje en sí no es muy bueno, y la documentación antigua tampoco era excelente
Aun así, aunque no conozco lo suficiente el stack que eligieron, me pregunto si ofrece un nivel de determinismo cercano al de Nix. Si no, podría terminar jugándoles en contra más adelante o hacer más difícil la operación
/nix/store, donde están todos los paquetes y bibliotecas relacionados con Nix necesarios para la compilación y el runtime”Eso se parece a decir: “abandonamos los autos porque no pudimos hacer que avanzaran”. Esta es una de las cosas que Nix puede hacer con mayor confiabilidad. Detecta automáticamente las dependencias de runtime realmente referenciadas por el binario resultante mediante matching de cadenas hash de
/nix/storeSi no lograron hacer eso, lo estaban usando de una forma bastante extraña o hicieron algo gravemente mal. Incluso cuesta imaginar cómo impedir que Nix resuelva esto automáticamente
Así que no tomaría demasiado en serio su experiencia con Nix. Lo relativo a la gestión de versiones es un problema muy general que todo el mundo enfrenta, y habría sido más interesante si hubieran intentado resolverlo
Nix no ofrece garantías de versiones arbitrarias, sino garantías de commits. Cuando aparezcan casos límite, van a sufrir por cambios en glibc o por bibliotecas compartidas en conflicto
Quizá sea un poco tarde, pero con gusto puedo ofrecer consultoría para hacer que funcione al estilo idiomático de Nix. El producto se ve genial
Y no solo eso: también las dependencias de esas dependencias, y las dependencias de esas dependencias, y así sucesivamente, por lo que ocurren grandes recompilaciones con frecuencia
Evita los conflictos de bibliotecas compartidas, sí, pero esta solución es extremadamente derrochadora y también puede hacer que el desarrollo sea doloroso. Basta con mirar el proceso de staging de nixpkgs
Aun así, probablemente seguirá estando “empaquetado con alta probabilidad de funcionar correctamente” más que el 95% del software del mundo
No entiendo por qué no pudieron crear una derivation propia en lugar de depender del hash de nixpkgs