Por que a Railway está migrando do Nix para o Railpack
(blog.railway.com)- A Railway redesenhou seu builder, que cria imagens de contêiner a partir do código dos usuários, levando para o Railpack a experiência do Nixpacks, que já construiu mais de 14 milhões de apps
- O Nixpacks era suficiente para 80% dos usuários, mas os outros 200 mil usuários da Railway podiam esbarrar em limitações de gerenciamento de versões, tamanho de imagem e cache
- O Railpack aumenta a reprodutibilidade dos builds com versões
major.minor.patch, travamento de dependências e um fluxo de instalação baseado no Mise, em vez do gerenciamento de versões do Nix baseado em commits - Ao gerar diretamente BuildKit LLB e Frontend, a imagem Node padrão ficou 38% menor e a imagem Python padrão ficou 77% menor, além de permitir cache compartilhável entre ambientes
- O Railpack está atualmente em Beta e pode ser ativado nas configurações do serviço; a Railway está priorizando elevar a maturidade das linguagens mais usadas antes de ampliar o suporte a muitas linguagens
Contexto da criação do novo builder da Railway
- A Railway apresentou o Railpack como a próxima etapa do builder da Railway
- O Railpack foi desenvolvido do zero com base na experiência adquirida ao construir mais de 14 milhões de apps com o Nixpacks
- O Nixpacks foi lançado há cerca de 3 anos e se tornou o método padrão na Railway para criar imagens a partir do código dos usuários
- Ele funcionava bem para 80% dos usuários, mas os outros 200 mil usuários da Railway podiam enfrentar limitações
- A Railway concluiu que precisaria fazer um grande upgrade no builder para aumentar sua base de usuários de 1 milhão para 100 milhões
Limitações que o Nixpacks encontrou com o Nix
- O maior problema era o gerenciamento de versões de pacotes baseado em commits do Nix
- Cada pacote oferece apenas a versão major mais recente
- A versão fica vinculada a um commit específico do repositório nixpkgs
- A tentativa de oferecer suporte a todas as versões de patch acabou virando uma estrutura que mapeava manualmente strings de versão para SHAs de commit, algo que não era claro nem fácil de manter para contribuidores não familiarizados com o gerenciamento de versões do Nix
- Linguagens como Node e Python acabaram ficando com suporte apenas à versão major mais recente
- Ao atualizar um SHA de commit para oferecer suporte a uma versão de pacote mais recente, outras versões de pacotes também podiam mudar junto
- Quando a versão padrão mudava, aumentava a chance de builds de usuários que antes funcionavam falharem com erros inesperados
- Para a Railway, um build que antes passava quebrar de repente era pior do que usuários não terem acesso aos pacotes mais recentes
Problemas de tamanho de imagem e cache
- A forma como o Nixpacks trazia dependências via Nix muitas vezes gerava imagens grandes
- O Nix e os pacotes e bibliotecas relacionados necessários em build e runtime ficavam em uma única camada
/nix/store - Como não havia como dividir as dependências do Nix em camadas separadas, havia um limite para reduzir o tamanho da imagem final
- A Railway vê isso não como um problema do Nix em si, mas como um problema da forma como o Nixpacks usa o Nix
- No cache, também era difícil controlar quando o cache de camadas seria invalidado
- A Railway injeta uma variável de ambiente com o ID de deploy em todos os builds
- Em um Dockerfile, as camadas executadas depois que essa variável é adicionada sempre eram invalidadas e não podiam ser armazenadas em cache
- A abordagem de esconder dos usuários elementos centrais do Nix também não se encaixava bem
- A intenção era que os usuários não precisassem entender o que é uma derivation nem por que o Node 22.14.0 está em uma versão específica de archive do canal unstable
Mudanças estruturais do Railpack
- A Railway criou o Railpack para resolver os problemas encontrados no Nixpacks
- Ao se afastar do Nix, o nome também mudou de Nixpacks para Railpack
- A base de código mudou de Rust para Go por causa das bibliotecas do BuildKit
- O Railpack controla de forma mais direta como a imagem final é criada
- Ele gera diretamente BuildKit LLB e Frontend
- Em comparação com o Nixpacks, a imagem Node padrão ficou 38% menor, e a imagem Python padrão ficou 77% menor
- Ele usa o Mise para resolução de versões e a maior parte das instalações de pacotes
- Também deixa espaço para oferecer suporte a outras fontes de executáveis no futuro
- É possível travar as dependências usadas em um build bem-sucedido
- Isso pode impedir que o build quebre mesmo se a versão padrão do Node mudar de 22 para 24
- Ele usa BuildKit secrets para impedir que variáveis de ambiente secretas apareçam nos logs de build ou na imagem final
Como um build do Railpack funciona
- O processo do Railpack é dividido em três etapas
- Analyze: examina o código e decide quais pacotes instalar, quais comandos executar e qual comando de inicialização usar
- Plan: cria um plano de build serializável em JSON, composto por várias etapas; cada etapa obtém entradas de outras etapas ou da imagem inteira
- Generate: monta o grafo de build do BuildKit de acordo com as entradas e saídas do plano
- Um Dockerfile é linear, mas o grafo do BuildKit é muito mais paralelo
- Cada comando é executado em sua própria etapa dentro de um build multiestágio, permitindo controle fino sobre as camadas de entrada e sobre como o sistema de arquivos final é montado
- O Railpack gera um plano de build contendo todas as etapas de build necessárias
- Cada etapa define explicitamente as etapas anteriores ou imagens de que precisa
- Esse formato é de nível mais baixo do que o usado no Nixpacks
- O plano é convertido em um grafo no formato LLB e interpretado
- O BuildKit começa pelo fim e trabalha de trás para frente, buscando no cache quando possível e executando comandos apenas quando necessário para resolver a camada solicitada
- Para invalidar camadas quando variáveis de ambiente específicas mudam, o Railpack gera um hash dos valores das variáveis usadas e monta no sistema de arquivos de entrada um arquivo que contém esse hash
- Se o código e as variáveis usadas não mudarem, há acerto no cache da camada
- O Railpack consegue definir completamente como a imagem é criada
O que se tornou possível com o Railpack
- É possível construir e publicar sites estáticos em Vite, Astro, CRA e Angular sem configuração
- A integração entre o build e a UI da Railway fica mais estreita
- É possível oferecer suporte às versões mais recentes das linguagens sem um release do Railpack
- É possível usar cache de camadas otimizado entre vários ambientes de um projeto
Como usar atualmente e escopo de suporte
- O Railpack está atualmente disponível em Beta e pode ser ativado nas configurações do serviço
- Ele já está sendo usado nos builds de railway.com e da central station
- O suporte atual inclui:
- Node
- Python
- Go
- PHP
- Deploy de HTML estático
- Suporte básico a sites estáticos em Vite, Astro, CRA e Angular
- O objetivo da Railway é oferecer um ambiente em que seja fácil publicar tanto frontend quanto backend
- O suporte a frameworks e linguagens continua sendo adicionado
- Pedidos podem ser enviados pela Help Station
- Até que as APIs centrais e abstrações estejam definidas, a prioridade é a profundidade nas linguagens mais usadas, e não a amplitude de suporte
- O Railpack é open source, e a documentação está disponível em railpack.com
1 comentários
Opiniões do Hacker News
Sou entusiasta do Nix, mas não pretendo criticar a Railway por se afastar dele. Ainda assim, algumas reclamações parecem precisar de mais explicação.
Nixpkgs é excelente, mas não é a mesma coisa que Nix; e, se a ideia é obter versões arbitrárias de toolchains, o Nixpkgs simplesmente não é o ideal. As ferramentas Nix para trazer versões arbitrárias de Rust já são muito boas, e outras ferramentas de desenvolvimento baseadas em Nix também mostraram formas de lidar bem com isso.
Também não entendo a afirmação de que “não há como separar dependências Nix em uma camada à parte”. Dá para separar simplesmente da forma que você quiser, e as ferramentas Docker embutidas do Nixpkgs também têm algum suporte a isso.
A mudança de Rust para Go não tem relação direta com Nix, mas é interessante, e também soa como se Railpacks e Nixpacks tivessem sido feitos por pessoas diferentes. Já vi coisas bem ruins acontecerem quando pessoas não familiarizadas com Nix acabam tendo de assumir uma solução Nix inacabada dentro de uma organização; por isso, no trabalho, normalmente evito criar esse tipo de situação.
A frase “basta dividir as camadas do jeito que quiser” também depende de isso ser claro, simples e o comportamento padrão.
As pessoas se frustram com Nix não porque ele não seja Turing-completo, mas porque ele não oferece uma API de primeira classe simples que se encaixe diretamente nos projetos idiomáticos daquele ecossistema; com isso, acaba criando mais problemas do que resolve.
Se todo projeto que tenta usar Nix acaba caminhando para escrever seus próprios módulos para corrigir problemas do Nix, há pouco motivo para escolher Nix em vez de ferramentas mainstream com boa documentação. Este caso parece exatamente isso, e acho que a maioria simplesmente escolheria Docker.
É frustrante ver um produto voltado a desenvolvedores se prender a flakes ideologicamente puros, em vez de resolver problemas práticos de experiência de desenvolvimento em uma escala de tempo que não seja geológica. Sei que se trata de contribuição voluntária, mas é muito triste ver tanto esforço técnico indo para algo que, por causa da má experiência de usuário, acaba sendo praticamente difícil de usar.
Na forma como o Nix funciona junto com a estrutura do Nixpkgs, fixar uma versão de pacote significa fixar o commit de toda a árvore do nixpkgs. Builds de pacotes node/python/ruby também dependem do estado da árvore fora do diretório do pacote, então passa a ser necessário mapear versões para commits.
Como essa abstração vaza, a Railway precisa expô-la aos usuários; e o usuário, que só queria fazer
yarn add new-fancy-nodejs-package-with-linked–native-deps, pode acabar tendo de conciliar vários estados do repositório nixpkgs.Para usos de escopo estreito, talvez seja aceitável usar Nix sem Nixpkgs, mas em uma plataforma como a Railway isso parece difícil de justificar.
pkgfuncionava em geral bem, mas um dia tentei compilar o vim a partir dos ports com uma flag USE personalizada; ele puxou mais de 20 dependências, perguntou opções em cadamake menuconfige, então, o 16º pacote de 23 falhou com algo do tipo “isto precisa daquilo, aquilo precisa de Fubar3.32.1, mas Fubar3 foi deprecated em favor de Fubar4”, e eu desisti.Entendo que os desenvolvedores do Core OS não conseguem dar suporte a mais de 10 mil pacotes, mas também é preciso deixar claro que, na prática, quando você tenta ativar recursos personalizados, a chance de falha é alta. Ou então seria melhor estabelecer que uma build padrão gerada independentemente precisa funcionar antes de aparecer nos ports, e remover da lista de ports aquilo que não compila.
Dá para criticar a linguagem Nix por horas, mas ela é antiga, foi o melhor que se pôde fazer na época e talvez hoje não valha muito a pena mudá-la. O sistema de build do Nix parece bastante primitivo e frequentemente recompila até coisas que não parecem precisar ser recompiladas. Por exemplo, uma parte considerável da build da ISO de instalação do NixOS depende da linha de comando passada ao kernel,
console=ttyS2,1500000n8, então só mudar a velocidade da porta serial exige cerca de 3 minutos de build. É engraçado, mas não vou deixar de usar Nix por causa disso; apenas não aceitaria isso nas minhas próprias builds.Para imagens Docker, pessoalmente acho que é onde o Nix se sai pior. Há muito tempo, ao fazer software em Go, precisei adicionar o binário
pg_dumpdo Postgres a uma imagem de contêiner. Usei Nix como o time de infraestrutura sugeriu, e a imagem com um binário Go comprimido de 50 MB virou 1,5 GB de origem misteriosa. Opg_dumptem 464 KB. No fim, usei Bazel comrules_debianpara instalar pacotes apt, e em cima de distroless ficou muito mais limpo e menor. Pela minha experiência real com Nix, os sistemas Nix parecem sempre acabar com 1,4 GB. A ISO de instalação também tem 1,4 GB; uma máquina recém-instalada também tem 1,4 GB.A situação de querer compilar um grande projeto C++ já é um caminho bem pavimentado, e trocar C++ por Rust não muda a essência. Existem sistemas de build que tornam a situação das bibliotecas menos dolorosa; eles também são tão complexos quanto Nix, mas alguns se encaixam melhor nesse uso. Como o Nix tenta ser um sistema de build para software dos outros e para o nixpkgs, ele acaba ficando em um ponto muito genérico. Sistemas de build projetados para compilar o seu próprio software normalmente fazem melhor esse trabalho. Pessoalmente, estou satisfeito com Bazel e, fora
go buildpara projetos só em Go, acho que quase não usaria outra coisa, mas há muitas opções. Em 99% dos casos, use algo assim em vez de Nix, e escreva um flake para que as pessoas possam instalar a versão mais recente com home-manager.A parte sobre escolha de versões soa estranha. A versão do nixpkgs faz sentido quando se executa ou compila um sistema, mas, se a plataforma oferece um runtime ou compilador, é preciso um modelo como o do devenv, em que você fornece as versões diretamente
Se você acaba compilando um sistema antigo só para oferecer um nodejs antigo, deixa de receber patches de segurança das dependências. O Devenv lida com isso, por exemplo, via https://github.com/cachix/nixpkgs-python, no estilo “manter todas as versões do Python atualizadas de hora em hora com Nix”
O fato de a Railway injetar uma variável de ambiente com o ID de deploy em todos os builds poderia ter sido feito na camada seguinte à instalação. Também dá para dividir pacotes em várias camadas, e há automação por agrupamentos para reduzir o número de camadas
“Não havia problema com o Nix em si; havia problema com a forma como o usávamos” é um bom exemplo de usar a ferramenta certa para o trabalho certo. O Nix é excelente para alguns usos e terrível para outros
O problema é que a curva de aprendizado do Nix é tão alta que, quando você entende o suficiente para avaliar, já investiu tempo demais para sentir que vale a pena voltar atrás, e acaba forçando a ferramenta para tentar resolver a necessidade original
Por causa desse paradigma, é muito fácil para uma IA criar um
shell.nixouconfiguration.nixque atenda a uma especificação. Por exemplo, pode conter pacotes Python, pacotes Linux, variáveis de ambiente, entradas de caminho etc.Uso isso com frequência para incluir no repositório um ambiente que ofereça suporte completo ao pacote em questão. Com flakes seria mais reprodutível, mas entendo
flake.nixcomo algo parecido com umshell.nixcom versões fixadas, e ainda estou aprendendoParece uma tentativa de enfiar versões onde não há versões. É como tentar colocar um cubo quadrado em um buraco redondo
Dizem que a “versão padrão” quebra coisas que dependem dela; não entendo o que isso quer dizer. É como usar a tag
:latestdo Docker e ficar surpreso quando um novo servidor sobe com uma versão diferente da imagem “padrão” anterior e tudo quebraNão entendo nenhuma das explicações desse post. Parecem pessoas que não têm ideia do que é uma “versão” de software
Também não entendo por que dizem que “não há como separar dependências Nix em uma camada separada”. É claro que dá para dividir
/nix/storeem quantas camadas forem necessárias. Fico até em dúvida se eles sabem como usar contêineres e Nix em primeiro lugarDada essa imaturidade evidente, não surpreende que a solução proposta cheire a peixe podre. É a clássica síndrome do NIH, e me parece bem provável que os mesmos problemas que eles não conseguiram resolver com Nix se espalhem para a nova “solução”
Como outros disseram, nix2container e flakes parecem resolver todos os problemas que eles tinham
Quanto a versionamento, flakes que escrevi há 3 anos ainda compilam hoje exatamente com as mesmas versões e a mesma saída de quando foram escritos
Embora isso soe como uma tentativa de ir ao mercado como plataforma e levantar investimento
Edição: acabei de verificar o GitHub do nixpacks e algo saltou aos olhos: eles usam
rustPlatformdo nixpkgs, e não o rust-overlay[0] da oxalica, que teria aparecido com uma busca superficial por problemas de Rust.rust-overlayé um dos overlays mais úteis e poderosos que já usei[0] https://github.com/oxalica/rust-overlay
nix2container[1] de fato permite separar dependências em camadas próprias. Você pode criar explicitamente uma camada com apenas parte das dependências necessárias para a imagem, e há exemplos nesta seção: https://github.com/nlewo/nix2container?tab=readme-ov-file#is...Por exemplo, se as imagens usam bash, você pode criar explicitamente uma camada contendo o closure do bash. Essa camada é reutilizada por todas as imagens e só é recompilada e reenviada quando aquele closure do bash muda
A ideia de que a imagem fica grande por causa de uma única camada
/nix/storese aplica à função padrãonixpkgs.dockerTools.buildImage, mas não ao nix2container nem aonixpkgs.dockerTools.streamLayeredImage. Essas ferramentas, em vez de escrever as camadas na Nix store, criam scripts que usam caminhos existentes da store para realmente enviar a imagem. A implementação do nix2container cria um arquivo JSON que descreve os caminhos da Nix store de todas as camadas, e o Skopeo consome esse JSON para enviar a imagem ao daemon do Docker, a um registry, ao podman etc.Para referência, eu sou o autor do nix2container
[1] https://github.com/nlewo/nix2container
O problema central aqui é se apegar à atitude de sopa de versões personalizada incentivada por gerenciadores de pacotes de linguagens. Esse modelo é totalmente insustentável
A alternativa, Mise, não parece ter capacidade de entender restrições de versão entre pacotes, nem parece executar testes para verificar se cada pacote instalado funciona bem com as versões ao redor. Então você não está obtendo a mesma coisa de forma alguma
Uma “sopa” de versões personalizadas é insustentável, mas um dos motivos pelos quais as pessoas continuam usando isso é que, em geral, funciona bem. Um dos motivos de funcionar bem é que as bibliotecas no nível do sistema operacional vêm de um outro mundo, muito mais conservador, e tentam evitar ao máximo quebrar compatibilidade retroativa
Por isso dá para usar como base um sistema operacional estável e bem mantido, empilhar por cima uma sopa de versões personalizadas de ferramentas e runtimes de linguagens com ferramentas como mise ou asdf, e então executar o app. Quase nunca quebra. Quando quebra, você mexe em versões e pequenos ajustes até voltar a funcionar e segue em frente. O fato de ter quebrado é irritante, mas não é importante. Aumentar o atrito, exigir aprendizado ou demandar mais trabalho é perda de tempo
Por outro lado, também há pessoas que procuram uma solução para impedir que isso volte a quebrar. Para elas, o problema é importante, então não há problema se a solução exigir atrito, aprendizado e trabalho extra. São essas pessoas que querem Nix
A maioria pertence ao primeiro grupo, então uma empresa como a Railway, que quer crescer, acaba escolhendo uma solução adequada a esse grupo
Cargo.lock. O nixpkgs vai na direção oposta da sopa de versões personalizadas, mas o próprio Nix consegue lidar bem com esse modelo tambémPela minha experiência trabalhando com DevOps/SRE, quando alguém tenta criar um sistema para gerenciar dependências etc., em geral acaba seguindo um de dois caminhos. Dá para usar Python como exemplo
Opção 1: “vamos usar um grande repositório único e compartilhado.” A vantagem é que tudo fica em um só lugar, o que você precisa está incluído, e todo mundo usa a mesma coisa, então é mais fácil corrigir problemas como vulnerabilidades. A desvantagem é que sempre alguém quer uma versão especial, deploys graduais são difíceis e mudanças tendem a virar big bang, além de surgir a pergunta “como fazemos uma versão Docker pequena?”
Opção 2: “cada um terá seu próprio conda/venv.” A vantagem é que cada pessoa obtém exatamente o que quer, não usa pacotes desnecessários, e upgrades graduais são fáceis. A desvantagem é que vira “quantos ambientes conda existem, afinal?”, bibliotecas de grupos diferentes podem não ser testadas com a mesma combinação de bibliotecas Python, e gerenciar vulnerabilidades vira um pesadelo porque nem se sabe onde estão os diferentes ambientes conda
Por isso sempre sou cético quando alguém diz “essa nova abordagem resolve tudo”. Quanto mais tempo de carreira passa, mais verdadeira fica a frase “não existem soluções, só trade-offs”
Mesmo tendo pouca experiência com Nix, os pontos aqui não me parecem muito corretos
Há afirmações como “não é claro nem sustentável para contribuidores que não estão familiarizados com o versionamento do Nix” e “Node e Python passaram a dar suporte apenas às versões major mais recentes”, mas não entendo por que isso seria impossível de manter. Se o problema é ter que montar uma lista de versões disponíveis, fico me perguntando se isso não poderia ser automatizado
Além disso, não entendo por que a Railway define a forma como os usuários usam Nix. Um dos pontos centrais do Nix não é justamente poder configurar uma máquina vazia com as versões exatas dos pacotes que você quer? Não entendo por que a Railway precisa se colocar entre o usuário e essas versões para impor restrições
Se a arquitetura for tal que o usuário não veja Nix diretamente, a pergunta original ainda permanece. Não dá para automatizar a lista de versões de pacotes?
Sinceramente, os motivos apresentados não parecem muito sólidos. Talvez a pessoa que introduziu Nix tenha saído, e quem ficou não gostasse muito dele. A linguagem em si não é lá muito boa, e a documentação antiga também não era excelente
Ainda assim, embora eu não conheça bem o stack que eles escolheram, fico curioso se ele oferece um nível de determinismo próximo ao do Nix. Caso contrário, isso pode atrapalhá-los no futuro ou tornar a operação mais difícil
/nix/store, onde ficam todos os pacotes e bibliotecas relacionados ao Nix necessários para build e runtime”Isso é parecido com dizer “desistimos de carros porque não conseguimos fazer um carro andar para a frente”. Essa é uma das coisas que o Nix faz de forma mais confiável. Ele detecta automaticamente as dependências de runtime que são de fato referenciadas pelo binário resultante por correspondência de strings de hash em
/nix/storeSe eles não conseguiram fazer isso, então estavam usando de um jeito bem estranho ou fizeram algo gravemente errado. É até difícil pensar em uma forma de impedir que o Nix resolva isso automaticamente
Por isso eu não levaria tão a fundo a experiência deles com Nix. O conteúdo sobre versionamento é um problema muito comum que todo mundo enfrenta, então teria sido mais interessante se eles tivessem tentado resolvê-lo
Nix não oferece garantia de versão arbitrária, e sim garantia de commit. Quando surgirem casos de borda, eles vão sofrer com mudanças na glibc ou bibliotecas compartilhadas conflitantes
Parece um pouco tarde, mas eu ficaria feliz em oferecer consultoria para fazer isso funcionar do jeito idiomático do Nix. O produto parece legal
E não só isso: também as dependências das dependências, e as dependências dessas dependências, continuando assim, o que frequentemente causa rebuilds em grande escala
Ele evita conflitos de bibliotecas compartilhadas, mas essa solução é extremamente desperdiçadora e também pode tornar o desenvolvimento doloroso. Dá para ver isso no processo de staging do nixpkgs
Ainda assim, provavelmente ficará em um estado “empacotado com alta chance de funcionar corretamente” em comparação com 95% do software do mundo
Não entendo por que não foi possível criar derivations próprias em vez de depender do hash do nixpkgs