RailwayがNixからRailpackへ移行する理由
(blog.railway.com)- Railwayは、ユーザーコードからコンテナイメージを作成するビルダーを再設計し、1,400万件以上のアプリをビルドしたNixpacksの経験をRailpackへ移した
- Nixpacksはユーザーの80%には十分だったが、残る20万人のRailwayユーザーはバージョン管理・イメージサイズ・キャッシュの限界に直面する可能性があった
- Railpackは、Nixのコミットベースのバージョン管理ではなく
major.minor.patchバージョン、依存関係のロック、Miseベースのインストールフローにより、ビルド再現性を高める - BuildKit LLBとFrontendを直接生成することで、デフォルトのNodeイメージは38%、デフォルトのPythonイメージは**77%**小さくなり、環境間で共有可能なキャッシュも利用できるようになった
- Railpackは現在Betaとしてサービス設定から有効化でき、Railwayは幅広い言語対応よりも、よく使われる言語の完成度向上を優先している
Railwayが新しいビルダーを作った背景
- Railwayは、RailpackをRailwayビルダーの次の段階として公開した
- Railpackは、Nixpacksで1,400万件以上のアプリをビルドして得た経験を基に、ゼロから新しく開発された
- Nixpacksは約3年前に公開されて以来、Railwayでユーザーコードからイメージをビルドする基本方式となっていた
- 全ユーザーのうち80%にはうまく機能していたが、残る20万人のRailwayユーザーは制約を受けることがあった
- Railwayは、ユーザーベースを100万人から1億人へ拡大するには、ビルダーを大幅にアップグレードする必要があると判断した
NixpacksがNixで直面した限界
- 最大の問題は、Nixのコミットベースのパッケージバージョン管理だった
- 各パッケージでは最新メジャーバージョンのみが提供される
- バージョンはnixpkgs repoの特定コミットに紐づく
- すべてのパッチバージョンをサポートしようとすると、バージョン文字列とコミットSHAを直接対応付ける構造になり、Nixのバージョン管理に慣れていないコントリビューターには明確でも保守しやすくもなかった
- NodeやPythonのような言語は、最終的に最新メジャーバージョンしかサポートできなくなった
- 最新のパッケージバージョンをサポートするためにコミットSHAを更新すると、他のパッケージバージョンも一緒に変わる可能性があった
- デフォルトバージョンが変わると、これまで動いていたユーザービルドが予期しないエラーで失敗する可能性が高まる
- Railwayは、ユーザーが最新パッケージにアクセスできないことよりも、成功していたビルドが突然壊れる状況のほうを悪いと見ていた
イメージサイズとキャッシュの問題
- NixpacksがNixで依存関係を取得する方式は、イメージサイズが大きくなりがちだった
- ビルドとランタイムに必要なNixおよび関連パッケージ・ライブラリが、単一の
/nix/storeレイヤーに入っていた - Nix依存関係を別レイヤーに分割する方法がなく、最終イメージサイズを減らすうえで限界があった
- RailwayはこれをNix自体の問題ではなく、NixpacksにおけるNixの使い方の問題だと見ている
- キャッシュについても、レイヤーキャッシュがいつ無効化されるかを制御しにくかった
- RailwayはすべてのビルドにデプロイID環境変数を注入している
- Dockerfileでは、この変数が追加されたあとに実行されるレイヤーは常に無効化され、キャッシュできなかった
- Nixの中核要素をユーザーから隠そうとするアプローチも、あまりうまく噛み合わなかった
- ユーザーにderivationとは何かを理解させたり、Node 22.14.0がなぜunstableチャネルの特定archiveバージョンにあるのかを知ってもらったりする必要はないと考えていた
Railpackの構造的変化
- Railwayは、Nixpacksで経験した問題を解決するためにRailpackを作った
- Nixから離れることで、名前もNixpacksからRailpackへ変わった
- コードベースはBuildKitライブラリのためにRustからGoへ変更された
- Railpackは最終イメージ生成方式をより直接的に制御する
- BuildKit LLBとFrontendを直接生成する
- Nixpacksと比べて、デフォルトのNodeイメージは38%、デフォルトのPythonイメージは**77%**小さくなった
- バージョン解決とほとんどのパッケージインストールにはMiseを使用する
- 将来的に別の実行ファイルソースをサポートする余地も残している
- 成功したビルドで使われた依存関係をロックできる
- デフォルトのNodeバージョンが22から24へ変わっても、ビルドが壊れないようにできる
- BuildKit secretsを使い、秘密の環境変数がビルドログや最終イメージに現れないようにする
Railpackビルドの動作方式
- Railpackのプロセスは3段階に分かれる
- Analyze: コードを見て、インストールするパッケージ、実行するコマンド、起動コマンドを決定する
- Plan: 複数ステージから成るJSONシリアライズ可能なビルド計画を作り、各ステージは他のステージまたはイメージ全体から入力を受け取る
- Generate: 計画の入力と出力に応じてBuildKitビルドグラフを構成する
- Dockerfileは線形だが、BuildKitグラフははるかに並列的に構成される
- 各コマンドはマルチステージビルド内の独自ステージで実行され、入力レイヤーと最終ファイルシステムの組み立て方を細かく制御できる
- Railpackは必要なすべてのビルドステップを含むビルド計画を生成する
- 各ステップは、必要な前段ステップまたはイメージを具体的に定義する
- この形式は、Nixpacksで使っていた方式よりも低レベルである
- 計画はLLB形式のグラフに変換されて解釈される
- BuildKitは末尾から逆向きに作業を始め、可能な場合はキャッシュから取得し、必要なときだけコマンドを実行して要求されたレイヤーを解決する
- 特定の環境変数の変更時にレイヤーを無効化するため、Railpackは使用された変数値をハッシュ化し、そのハッシュを含むファイルを入力ファイルシステムにマウントする
- コードと使用された変数が変わらなければ、レイヤーキャッシュがヒットする
- Railpackは、イメージが作られる方式を完全に定義できる
Railpackによって可能になったこと
- Vite、Astro、CRA、Angularの静的サイトを設定不要でビルド・デプロイできる
- ビルドとRailway UIの統合がより密接になった
- Railpackのリリースなしでも、言語の最新バージョンをサポートできる
- プロジェクトの複数環境で最適化されたレイヤーキャッシュを利用できる
現在の利用方法と対応範囲
- Railpackは現在Betaとして提供されており、サービス設定で有効化できる
- すでにrailway.comおよびcentral stationのビルドに使われている
- 現在の対応対象は次のとおり
- Node
- Python
- Go
- PHP
- Static HTMLデプロイ
- Vite、Astro、CRA、Angular静的サイトの基本対応
- Railwayは、フロントエンドとバックエンドの両方を簡単にデプロイできる環境を目指している
- フレームワークと言語サポートは継続的に追加中である
- 要望はHelp Stationで受け付けている
- 中核APIと抽象化が確定するまでは、幅広い対応よりも、よく使われる言語の深さを優先する
- Railpackはオープンソースであり、ドキュメントはrailpack.comで提供されている
1件のコメント
Hacker News のコメント
Nix 愛好家ではあるが、Railway が Nix から離れることを批判したいわけではない。ただ、いくつかの不満点はもう少し説明が必要に見える
Nixpkgs は素晴らしいが、Nix と同じものではなく、任意のバージョンのツールチェーンを持ってきたい場合には Nixpkgs が理想的ではないというだけのこと。Rust の任意のバージョンを取得する Nix ツールはすでにかなり優れており、ほかの Nix ベースの開発ツールも、それをうまく扱う方法を示している
「Nix の依存関係を別レイヤーに分ける方法がない」という話も理解できない。望む形でそのまま分けられるし、Nixpkgs 組み込みの Docker ツールにも一部サポートがある
Rust から Go に移ったことは Nix と直接関係はないが興味深く、Railpacks と Nixpacks が別々の人たちによって作られたかのようにも聞こえる。Nix に慣れていない人たちが、組織内の未完成な Nix ソリューションを背負わされるとかなりまずいことが起きるのを見てきたので、職場ではたいてい、こうした状況を作りかねないため Nix は使わない
「好きなようにレイヤーを分ければいい」というのも、それが明確で、簡単で、デフォルトの挙動なのかが重要になる
人々が Nix に不満を持つ理由は、チューリング完全ではないからではなく、そのエコシステムの慣用的なプロジェクトとすぐ噛み合う単純な一級 APIを提供せず、解決する問題より多くの問題を生み出しているからである
Nix を使おうとするプロジェクトごとに、結局 Nix の問題を直すために自前でモジュールを書く方向へ流れていくなら、ドキュメントが充実した主流ツールの代わりに Nix を使う理由は弱い。今回のケースもまさにそう見えるし、大半は単に Docker を選ぶと思う
開発者向けプロダクトが、実用的な開発者体験の問題を地質学的な時間スケールではない速さで解決するよりも、思想的に純粋な flakes にしがみついている点がもどかしい。自発的な貢献だということは分かっているが、悪いユーザー体験のせいで実質的に使いにくくなっているものに、これほど多くの技術的努力が注がれているのは本当に残念だ
Nix が Nixpkgs の構造とともに動作する方式では、あるパッケージのバージョンを固定することは、すなわち nixpkgs ツリー全体のコミットを固定することを意味する。node/python/ruby パッケージのビルドはパッケージディレクトリ外のツリーの状態にも依存するため、バージョンとコミットの間のマッピングが必要になる
この抽象化は漏れるので、Railway はそれをユーザーに見せざるを得ず、ユーザーはただ
yarn add new-fancy-nodejs-package-with-linked–native-depsしたかっただけなのに、nixpkgs リポジトリの複数の状態を合わせなければならない状況に直面し得る範囲の狭い用途なら Nix を Nixpkgs なしで使うのもよいだろうが、Railway のようなプラットフォームでは正当化しにくそうだ
pkgは概ねよく動いたが、ある日 ports で vim をカスタム USE フラグ付きでコンパイルしようとしたら、20 個以上の依存関係を取得し、make menuconfigごとにオプションを聞かれた挙げ句、23 個中 16 個目のパッケージが「これはあれを必要とし、あれは Fubar3.32.1 を必要とするが、Fubar3 は Fubar4 で deprecated になっている」という具合に失敗して、諦めたCore OS の開発者たちが 1 万個を超えるパッケージをすべてサポートできないことは理解しているが、実際にカスタム機能を有効にして使おうとすると失敗する確率が高いことも明確にすべきだ。あるいは ports に載る前に独立して生成された標準ビルドが成功していることを基準にし、コンパイルできないものは ports の一覧から外すほうがよい
Nix 言語については何時間でも批判できるが、古いものであり、当時は最善を尽くしたものでもあり、今となっては変える価値が大きくないのかもしれない。Nix ビルドシステムはかなり原始的に感じられ、再ビルドの必要がなさそうなものまで頻繁に再ビルドする。たとえば NixOS インストール ISO のビルドの相当部分が、カーネルに渡すコマンドライン
console=ttyS2,1500000n8に依存していて、シリアルポートの速度を変えるだけでも約 3 分のビルドが必要になる。おかしくはあるが、これが理由で Nix をやめるつもりはない。ただ、自分のビルドでは許容しないことだDocker イメージ用の Nix は、個人的には Nix が最も苦手とする領域だと思う。昔 Go ソフトウェアを作っていて、コンテナイメージに Postgres の
pg_dumpバイナリを追加する必要があった。インフラチームの提案どおり Nix を使ったところ、圧縮済みの Go バイナリ 50MB イメージが、正体不明の 1.5GB に膨れ上がった。pg_dumpは 464KB だ。結局 Bazel とrules_debianで apt パッケージをインストールし、distroless 上ではるかにすっきり小さくなった。実際の Nix 体験に基づくと、Nix システムは常に 1.4GB になる感覚がある。インストール ISO も 1.4GB、インストール直後のマシンも 1.4GB だ大きな C++ プロジェクトをビルドしたいという状況は、すでに整備された道であり、C++ を Rust に置き換えても本質は変わらない。ライブラリ周りをあまり苦痛にしないビルドシステムはいくつもあり、それらも Nix と同じくらい複雑だが、この用途にはより適しているものもある。Nix は他人のソフトウェアと nixpkgs をビルドするビルドシステムになろうとしているため、非常に汎用的な側に位置している。自分のソフトウェアをビルドするよう設計されたビルドシステムのほうが、たいていその仕事はうまい。個人的には Bazel に満足しており、Go 専用プロジェクトの
go build以外ではほかのものをほとんど使わないと思うが、選択肢は多い。99% の場合は Nix の代わりにそうしたものを使い、人々が home-manager で最新バージョンをインストールできるように flake を書けばよいバージョン選択の部分は奇妙に聞こえる。nixpkgs のバージョンは、システムを実行したりビルドしたりするときには筋が通るが、プラットフォームとしてランタイムやコンパイラを提供するなら、devenv のようにバージョンを直接提供する方式が必要になる。
古い nodejs を提供しようとして古いシステムをビルドしてしまうと、依存関係のセキュリティパッチを取りこぼすことになる。Devenv は例えば https://github.com/cachix/nixpkgs-python で「すべての Python バージョンを Nix で毎時最新状態に保つ」といった形で処理している。
Railway がすべてのビルドにデプロイ ID の環境変数を注入するのは、インストールの次のレイヤーでやってもよかったはずだ。パッケージを複数のレイヤーに分けることもできるし、レイヤー数を減らすためにバンドル単位で自動化する仕組みもある。
「Nix 自体に問題があるのではなく、自分たちの使い方に問題があった」というのは、適切な道具を適切な用途に使えという良い例だ。Nix は用途によっては素晴らしいが、別の用途ではひどいものになる。
問題は Nix の学習曲線があまりに高く、判断できるほど理解したころにはすでに多くの時間を投資していて引き返すのが惜しくなり、本来のニーズを解決するために無理やり当てはめるようになってしまう点だ。
このパラダイムのおかげで、AI が仕様に合った
shell.nixやconfiguration.nixを作るのはとても簡単だ。例えば Python パッケージ、Linux パッケージ、環境変数、パス項目などを含められる。リポジトリに、そのパッケージを完全にサポートする環境を含めるために、こういうものをよく使っている。flakes を使えばさらに再現性が高くなるはずだが、
flake.nixはバージョン固定付きのshell.nixのようなものだと理解していて、まだ学習中だ。バージョンがないところに無理やりバージョンを押し込もうとしているように見える。四角いキューブを丸い穴に入れようとしているようなものだ。
「デフォルトバージョン」がそれに依存するものを壊すというが、それがどういう意味なのか分からない。Docker の
:latestタグを使って、新しいサーバーが立ち上がるたびに以前の「デフォルト」イメージとは違うバージョンになって壊れることに驚いているようなものだ。このブログ記事の説明は一つも理解できない。ソフトウェアの「バージョン」が何なのかまったく分かっていない人たちのように見える。
「Nix の依存関係を別レイヤーに分ける方法がない」というのも、なぜそうなのか分からない。もちろん
/nix/storeは必要なだけ多くのレイヤーに分けられる。そもそもコンテナと Nix をどう使うのか分かっているのかも疑問だ。こうした明らかな未熟さを見ると、提案された解決策から腐った魚のような臭いがするのも驚きではない。典型的な NIH 症候群で、Nix で解決できなかった同じ問題が新しい「解決策」にもそのまま広がる可能性が高そうだ。
他の人たちが言っているように、nix2container と flakes が彼らの抱えるすべての問題を解決してくれそうだ。
バージョン管理については、3年前に書いた flakes が今でも、最初に書いたときとまったく同じバージョン、同じ出力でビルドされる。
プラットフォームとして市場に出て資金調達しようとしているようにも聞こえる。
編集: たった今 nixpacks の GitHub を確認したところ、Rust の問題をざっと検索するだけで見つかったはずの oxalica の rust-overlay[0] ではなく、nixpkgs の
rustPlatformを使っているのがすぐ目に付いた。rust-overlayは、私が使った overlay の中でも最も有用で強力なものの一つだ。[0] https://github.com/oxalica/rust-overlay
nix2container[1] は実際に依存関係を別レイヤーに分けられる。イメージに必要な依存関係の一部だけを含むレイヤーを明示的に作ることができ、例もこのセクションにある: https://github.com/nlewo/nix2container?tab=readme-ov-file#is...例えば、複数のイメージが bash を使うなら、bash のクロージャを含むレイヤーを明示的に作れる。このレイヤーはすべてのイメージで再利用され、その bash クロージャが変わったときだけ再ビルドされ、再プッシュされる。
単一の
/nix/storeレイヤーのせいでイメージが大きくなるというのは、標準のnixpkgs.dockerTools.buildImage関数には当てはまるが、nix2container やnixpkgs.dockerTools.streamLayeredImageには当てはまらない。これらのツールは、レイヤーを Nix store に書く代わりに、既存の store パスを使って実際にイメージをプッシュするスクリプトを作る。nix2container の実装は、すべてのレイヤーの Nix store パスを記述した JSON ファイルを作り、Skopeo がこの JSON を読み取って Docker デーモン、レジストリ、podman などにイメージをプッシュする。ちなみに、私は nix2container の作者だ。
[1] https://github.com/nlewo/nix2container
ここでの核心的な問題は、言語パッケージマネージャが助長するカスタム版バージョンスープ的な態度にしがみついていることだ。この方式は完全に持続不可能だ。
代替案である Mise は、パッケージ間のバージョン制約を理解する能力がないように見えるし、インストールされた各パッケージが周辺のバージョンとうまく動作するかテストを回しているわけでもなさそうだ。だとすれば、まったく同じものを得ているわけではない。
カスタム版バージョンの寄せ集めは持続可能ではないが、それでも人々が使い続ける理由の一つは、たいていうまく動くからだ。うまく動く理由の一つは、OS レベルのライブラリがずっと保守的な別世界から来ていて、後方互換性を壊すことをできるだけ避けようとするからだ
だから、安定してよく管理された OS を土台にし、その上に mise や asdf のようなツールでツールや言語ランタイムのカスタム版バージョンの寄せ集めを積み上げて、アプリを実行できる。ほとんど壊れない。壊れたときは、バージョンや小さな修正をいじって、また動くようにして先へ進む。壊れたという事実は腹立たしいが重要ではない。摩擦を増やしたり、学習を求めたり、作業を増やしたりするのは時間の無駄だ
逆に、二度と壊れないようにする解決策を探す人たちもいる。この人たちにとっては問題が重要なので、その解決策が摩擦や学習、追加作業を要求しても構わない。こういう人たちが Nix を求める
大多数は最初のグループに属するので、成長を目指す Railway のような企業は、結局そのグループに合った解決策を選ぶことになる
Cargo.lockファイルから Nix で Rust パッケージをビルドするのは些細なことだ。nixpkgs はカスタム版バージョンの寄せ集めとは逆方向だが、Nix 自体はその方式も十分扱えるDevOps/SRE として働いた経験からすると、誰かが依存関係などを管理するシステムを作ろうとすると、たいてい二つの道のどちらかに進む。Python を例にできる
選択肢 1: 「大きな共有の単一リポジトリを一つ使おう」。利点は、すべてが一か所にあり、必要なものが含まれていて、全員が同じものを使うので脆弱性のような問題を直しやすいこと。欠点は、誰かがいつも特別なバージョンを欲しがり、段階的な展開が難しく変更がビッグバンになりやすく、「小さい Docker 版はどう作るのか?」という話になること
選択肢 2: 「全員が自分の conda/venv を持とう」。利点は、それぞれが欲しいものを正確に得られ、不要なパッケージを使わず、段階的なアップグレードがしやすいこと。欠点は「conda 環境はいったいいくつあるんだ?」となり、異なるグループのライブラリが同じ Python ライブラリの組み合わせでテストされていない可能性があり、異なる conda 環境がどこにあるのかも分からず脆弱性管理が悪夢になること
だから「この新しい方式がすべて解決する」という言葉にはいつも懐疑的だ。キャリアが長くなるほど、「解決策はなく、あるのはトレードオフだけ」という言葉がますます真実に思えてくる
Nix の経験が少ししかない立場から見ても、ここでの論点はあまり正しく見えない
「Nix のバージョン管理に慣れていないコントリビューターにとって明確でも保守しやすくもない」、「Node と Python は最新のメジャーバージョンだけをサポートするようになった」とあるが、これがなぜ保守不能なのか分からない。利用可能なバージョン一覧を作らなければならないからだというなら、自動化できないのか疑問だ
さらに、Railway がなぜユーザーの Nix の使い方を定義するのかも分からない。Nix の中核の一つは、空のマシンを望むパッケージの正確なバージョンで構成できることではないのか。なぜ Railway がユーザーとそのバージョンの間に入り込んで制限しなければならないのか分からない
もしユーザーに Nix を直接見せない構造なら、元の疑問はやはり残る。パッケージバージョン一覧を自動化できないのか?
正直、提示された理由はあまり堅固には見えない。おそらく Nix を導入した人が去り、残った人たちがあまり気に入っていなかったのかもしれない。言語自体もそれほど良くなく、昔のドキュメントも素晴らしくはなかった
それでも、彼らが選んだスタックを十分に知っているわけではないが、Nix に近いレベルの決定性を提供するのか気になる。そうでないなら、後で足かせになったり運用をより難しくしたりする可能性がある
/nix/store階層を持つ巨大なイメージにつながり、ビルドとランタイムに必要なすべての Nix 関連パッケージとライブラリがそこにある」と書かれているこれは「車を前に進ませられないので車を諦める」と言っているのに似ている。これは Nix が最も安定してできることの一つだ。結果のバイナリから実際に参照されるランタイム依存関係を、
/nix/storeのハッシュ文字列マッチングで自動検出するそれができなくなったのなら、かなり変な使い方をしているか、深刻に間違っているということだ。Nix がこれを自動で解決できないようにする方法を思いつくのすら難しいほどだ
だから彼らの Nix 体験をあまり深刻に受け止めるつもりはない。バージョン管理に関する内容は誰もが経験する非常に一般的な問題なので、解決を試みていたならもっと興味深かっただろう
Nix は任意のバージョン保証ではなく、コミット保証を提供する。境界ケースが出てくると、glibc の変更や競合する共有ライブラリのせいで苦労することになるだろう
少し遅いかもしれないが、Nix の慣用的なやり方で動くようにするコンサルティングなら喜んで提供できる。プロダクトは良さそうだ
それだけでなく、依存項目の依存項目、そのさらに依存項目へと続き、大規模な再ビルドが頻繁に発生する
共有ライブラリの競合は避けられるだろうが、この解決策は極度に無駄が多く、開発も苦痛にしうる。nixpkgs の staging プロセスを見れば分かる
それでも、世の中のソフトウェアの 95% よりは「正常に動作する可能性が高い形でパッケージされている」状態だと思う
なぜ nixpkgs のハッシュに依存せず、独自の derivation を作れなかったのか分からない