- Rustの依存関係管理システムは開発を便利にしてくれる一方で、依存関係の数や品質の問題が悩みの種になっている
- うまく使えているCrateでさえ最新の状態とは限らず、自分で実装したほうがよい場合もある
- AxumやTokioなど有名なCrateを追加したところ、依存関係を含む全体のコード行数は360万行にもなり、把握しきれない
- 実際に自分が書いたコードは1,000行ほどにすぎないが、周辺コードはレビューや監査が事実上できない
- Rustの標準ライブラリを拡張するかどうかや、中核インフラの実装方法について明確な解決策はなく、コミュニティ全体で性能・安全性・保守性のバランスをどう取るか考えなければならない状況
Rustの依存関係問題の概要
- Rustは私が最も好きな言語であり、コミュニティと言語の使いやすさは非常に優れている
- 開発生産性は高いが、最近は依存関係管理の面で不安を感じている
RustのCrateとCargoの利点
- Cargoによってパッケージ管理とビルド作業の自動化が可能になり、生産性が大きく向上する
- 複数のOSやアーキテクチャ間の移行が容易で、ファイルを自分で管理したりビルドツールの設定を気にしたりする必要がない
- 別途パッケージ管理に悩まず、すぐにコードを書くことができる
RustのCrate管理の欠点
- パッケージ管理を意識しなくて済むぶん、安定性への注意が薄れがちになる
- たとえばdotenvクレートを使っていたが、メンテナンス停止をSecurity Advisoryで知った
- 代替クレートであるdotenvyを検討したものの、実際に必要な部分だけを約35行で自前実装した
- 多くの言語でパッケージのメンテナンス停止問題は頻繁に起きており、依存関係が避けられない状況こそが本質的な問題だ
依存関係がもたらすコード量の急増
- TokioやAxumなど、Rustエコシステムの重要で高品質なパッケージを利用している
- 依存関係としてAxum、Reqwest、ripunzip、serde、serde_json、tokio、tower-http、tracing、tracing-subscriberを追加した
- Webサーバーやファイル展開、ログ機能が主目的なので、プロジェクト自体は単純である
- Cargo vendor機能を使って、すべての依存クレートをローカルにダウンロードした
- tokeiでコード行数を分析すると、依存関係を含めて約360万行に達した(vendor化したクレートを除くと約11,136行)
- 参考までにLinuxカーネル全体は約2,780万行とされており、私の小さなプロジェクトでもその7分の1に相当する量になる
- 自分が実際に書いたコードは約1,000行にすぎない
- これほど多くの依存コードを監視し監査するのは事実上不可能だ
解決策についての悩み
- 現時点でははっきりした解決策はない
- 一部ではGoのように標準ライブラリを拡張すべきだという主張もあるが、これも保守負担など新たな問題を生む
- Rustは高性能、安全性、モジュール性を追求し、組み込みやC++と競合することを目指しているため、標準ライブラリの拡大には慎重であるべきだ
- たとえばTokioのような高度なランタイムもGithubやDiscordで非常に活発にメンテナンスされている
- 現実的には、非同期ランタイムやWebサーバーのような中核インフラを自分で実装するのは個人開発者には無理がある
- 大規模サービスのCloudflareもtokioやcrates.ioへの依存をそのまま利用しており、どれほど頻繁に監査しているかは不明だ
- Clickhouseもバイナリサイズやクレート数に関する問題に言及している
- Cargoでは最終バイナリに含まれるコード行数を正確に特定しにくく、プラットフォームごとの不要なコードも含まれるという限界がある
- 結局のところ、コミュニティ全体に答えを求めるしかないのが現実だ
3件のコメント
Trivyで調べてみると、jsのNPMやJavaのMavenよりもhighやcriticalがはるかに少なくて安全なのに、Rustを使って何を主張しようとしている文章なのでしょうか?
Hacker Newsの意見
import foolibと1行書けば使えて、その中に何が入っているかは誰も気にしない。各段階で必要なのは機能の5%程度なのに、ツリーが深くなるほど無駄なコードが積み上がる。結果として、単純なバイナリ1つが500MiBになり、数値フォーマット1つのために依存関係を持ち込むようなことになる。GoやRustはすべてを1ファイルに押し込むことを促すので、一部だけ使いたくても困る。長期的な本当の解決策は、超高精度のシンボル/依存関係追跡で、すべての関数や型が必要な要素だけを明示し、本当に必要なコードだけを使って残りは捨てる方式だと思う。個人的にはこの発想はあまり好きではないが、依存関係ツリーから宇宙全部を引っ張ってくる今のシステムを解決する方法は、これしか思いつかないserde_jsonだけは小規模な修正で外せた。より大きな依存関係(winit/wgpu など)は構造変更が必要で、簡単には外せないfoolib_do_thing()のようにしていた。今は god object パターンのようにトップレベルのオブジェクトが全関数を抱えているので、foolibを1つ import すると全部引っ張られる。こうなるとリンカがどの関数が本当に必要か判断するのは難しい。その代わりGoはデッドコード削除が優秀で、使わなければコンパイル結果から落とされるisEven、isOdd、leftpadのように小さなライブラリ片を大量に使う分散保守より、連合チームが管理する大きな汎用ライブラリのほうが、将来性と継続性をはるかに担保できる--gc-sectionsのような section splitting で解決されているstdlib::data_structures::automata::weighted_finite_state_transducerなど)と整理された名前空間を備えた「電池同梱」の構成になってほしい。言語自体がバージョン管理と後方互換性を内蔵しているのだから、今後の改善にも期待しているglob関数は実際にファイルシステムを探索する。文字列マッチング用にはfnmatchがある。fnmatchは別モジュールにしてglobの依存関係にするのが理想だ。globを自前実装しようとするとかなり難しく、ディレクトリ構造、brace expansion など複雑な要件があるので、よく設計された関数の組み合わせが必要になるcapability systemを組み込む必要がある。たとえば画像読み込みライブラリを設計するとき、ファイルではなくストリームだけを受け取って処理させたり、「ファイルを開く権限がない」と明示的に指定して危険な関数の利用をコンパイル時に防げるようにする。既存のエコシステムでは簡単ではないが、うまくできれば攻撃面を最小化できる。依存関係を最小化する文化だけでは根本解決は難しく、Goのような言語もサプライチェーン攻撃から自由ではない#![deny(unsafe_code)]によって unsafe コードの使用時にコンパイルエラーを出し、その事実を利用者に示せる。ただし完全な強制検査ではなく、特別に許可すれば unsafe コードは書ける。featureflag のように、標準ライブラリ機能を推移的に制御する capability system の導入を想像することはできるcargo treeで依存関係ツリーを簡単に見られる。実際にバイナリへ入るコード行数ビューはあまり意味がない。関数がインライン化されると、たいていmainに吸収されるからだRustだけの問題ではありません。
共用パッケージリポジトリと推移的依存関係をサポートするパッケージマネージャーがある、あらゆる言語に共通する利点であり、潜在的な問題点でもあります。
結局のところ、持ってきて使う側がうまく使わなければならないのですが……
Node&npmのleft-pad騒動があったにもかかわらず、何も変わっていません。