7 ポイント 投稿者 GN⁺ 2025-05-10 | 3件のコメント | WhatsAppで共有
  • 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件のコメント

 
codemasterkimc 2025-05-11

Trivyで調べてみると、jsのNPMやJavaのMavenよりもhighやcriticalがはるかに少なくて安全なのに、Rustを使って何を主張しようとしている文章なのでしょうか?

 
GN⁺ 2025-05-10
Hacker Newsの意見
  • 私の考えでは、依存関係を「簡単に」追加できて、サイズやコストにペナルティがないシステムは、結局依存関係の問題に行き着く。過去40年間のソフトウェア配布の仕方を振り返ると、80年代にはライブラリはお金を払って買うもので、容量制約のある環境に合わせて必要な一部だけを選んで組み込んでいた。今ではライブラリの上にさらにライブラリを積み重ねていく。import foolib と1行書けば使えて、その中に何が入っているかは誰も気にしない。各段階で必要なのは機能の5%程度なのに、ツリーが深くなるほど無駄なコードが積み上がる。結果として、単純なバイナリ1つが500MiBになり、数値フォーマット1つのために依存関係を持ち込むようなことになる。GoやRustはすべてを1ファイルに押し込むことを促すので、一部だけ使いたくても困る。長期的な本当の解決策は、超高精度のシンボル/依存関係追跡で、すべての関数や型が必要な要素だけを明示し、本当に必要なコードだけを使って残りは捨てる方式だと思う。個人的にはこの発想はあまり好きではないが、依存関係ツリーから宇宙全部を引っ張ってくる今のシステムを解決する方法は、これしか思いつかない
    • 自分は大学生だから詳しくないのかもしれないけれど、Rustコンパイラはすでに未使用のコードや変数、関数などを検出してくれる。IDEも大半の言語でそれができる。なら、そういう部分を取り除けばいいのでは? 使われないコードはコンパイルされない
    • 実際、私がRustで比較的重い依存関係ツリーを持つライブラリ(Xilem)を触っていて、feature flagで削減を試してみたが、ほとんどは必要な機能に応じて維持せざるを得ない依存関係だった(vulkan、PNGデコード、unicode shaping など)。不要な依存関係は主にごく小さなもので、serde_json だけは小規模な修正で外せた。より大きな依存関係(winit/wgpu など)は構造変更が必要で、簡単には外せない
    • GoやC#(.NET)はよい反例だ。RustやJS(Node)に劣らないパッケージ管理とエコシステムを持ちながら、dependency hellは比較的少ない。標準ライブラリが非常に優れているからだ。標準ライブラリの充実は、大企業(Google、Microsoft)だけが投資できる部分だ
    • では、現在のコンパイラはなぜ使われていないコードを削除しないのか?
    • 昔は各関数ごとに .o ファイルを作って .a アーカイブにまとめ、リンカが必要な関数だけを取り出して使っていた。名前空間も foolib_do_thing() のようにしていた。今は god object パターンのようにトップレベルのオブジェクトが全関数を抱えているので、foolib を1つ import すると全部引っ張られる。こうなるとリンカがどの関数が本当に必要か判断するのは難しい。その代わりGoはデッドコード削除が優秀で、使わなければコンパイル結果から落とされる
    • 現代のコンパイラとリンカはすでにシンボル抽出とデッドコード削除を行っており、Rustも min-sized-rust のようなプロジェクトでこれを支援している
    • 昔はすべてのライブラリをプロジェクトに含めて、ビルドファイルに直接統合する形で管理していた。大変で面倒ではあるが、depsファイルに1行追加するよりもずっと深く中身と向き合うことになる
    • Goは実際には単一ファイルだけに固執しているわけではなく、論理的なファイル分割も簡単にサポートしている。この点は本当に気に入っている
    • Dotnetはすでに Trimming と Ahead Of Time Compilation によってこのアイデアを実現しつつある。他の言語もDotnetから学べる
    • LTO(Link Time Optimization)により、バイナリサイズの観点ではこの問題は完全に解決している。使われていない部分は最適化で削除される。ビルド時間は依然としてかかる
    • むしろ問題はライブラリそのものではなく、依存関係を追加した後にその内部のどこがどれだけ使われているのか見通しが悪いことだと思う。各パッケージごとの性能やビルド時の余剰コード比率などについて、簡単にフィードバックを得られる環境が必要だ
    • Unison という言語はこのアイデアに部分的に似た仕組みを導入している。各関数はAST構造に基づいて定義され、ハッシュベースのグローバルレジストリから読み込まれて再利用される
    • npmの isEvenisOddleftpad のように小さなライブラリ片を大量に使う分散保守より、連合チームが管理する大きな汎用ライブラリのほうが、将来性と継続性をはるかに担保できる
    • ultra-fine-grained なシンボル/依存関係を追求する代わりに、超細粒度のモジュール構成と既存の tree-shaking システムを活用するのも一案だ
    • Goの実際の依存関係管理方式は、元記事で説明された理想に近い。モジュールはパッケージの集合で、vendor化すると実際に使うパッケージとシンボルだけを含める形だ(正確にシンボル単位で動くのかは分からない)
    • JSモジュールシステムは、まさにそうした超高精度のシンボル管理と tree shaking をサポートしている
    • 元々提案されていた超高精度の依存関係アイデアは、すでに rust の --gc-sections のような section splitting で解決されている
    • Rustは crate feature によるAPI分割で、細かな import が非常にうまくできる言語だ。Goとは違う
    • アーキテクチャ次第では、たとえばローカル中心の thick client なら、初回インストールが800MBでも、実使用時にはネットワーク越しに非常に限定的な通信しかしないので問題ない。UIで協業のために繰り返し大きな依存関係を抱えることも許容できる
    • コード再利用のための最善の方法は、まさにこの依存関係の利用だ。本当に必要な部分でだけ最適化すればいい
    • 80年代にはすでに再利用可能なソフトウェアコンポーネントの概念が Objective-C のような言語を通じて実現していた。Rustの大きな成功の1つは、システムプログラミング言語でもこうしたソフトウェアコンポーネント化が広く採用されたことだ
    • tree shaking によってサイズやコード膨張の問題はある程度解決できる(サーバーではそもそも気にしない)。より深刻なのは依存関係のサプライチェーンリスクとセキュリティだ。大企業ほどオープンソース利用に承認プロセスがある。granularity だけを高めても、1000個の機能が1000人のNPM作者から来るなら、セキュリティ上の意味はない
    • パッケージ抽象化の各層で活用率が50%しかなくても、層ごとに全体サイズは実際に必要な量の2倍ずつ増える。3段階なら88%が無駄なコードだ。例として、Windows 11 の電卓に不要なライブラリ(アカウント復旧ツールまで)が付いてくる。機能追加のしやすさが複雑性の増大につながる例だ
    • 依存関係の累積が問題だという点には同意する。現時点で取れる最善の防御策は、システム依存関係を極端なまでに厳格に管理することだ。10行の関数1つのために外部ライブラリを持ち込む代わりに、コードを直接コピペすることもある。健全なライブラリエコシステムは例外的だ。Juniorエンジニアが依存関係を無分別に追加しようとしたら、すぐに止めることもよくある
    • Rustの基礎すら知らないのに断定的に語るケースは久しぶりに見た
    • デッドコード削除のおかげで、Rustのようなコンパイル言語では依存関係ツリーが大きくてもバイナリの肥大化にはつながらない
  • npmエコシステムで感じる問題は、多くの開発者が設計を十分考えずに依存関係を持ち込むことだ。たとえば glob ライブラリは単純な globbing 関数であるべきなのに、作者がコマンドラインツールまで同梱して巨大なパーサを依存関係に追加している。そのせいで「dependency out-of-date」の警告が頻繁に出る。また、glob ライブラリの責務範囲も議論の余地がある。単に文字列パターンマッチだけを行う方が柔軟な設計だ(テストやファイルシステム抽象化がしやすい)。何でもできる万能な “Do everything” ライブラリを望む利用者も多いが、その分副作用も大きい。Rustも大きくは変わらないと思う
    • 設計センスが重要で、良い言語はこうした開発者の嗜好を支えたり邪魔したりしない。Rust、Zig、C などがそうだ。問題が統計的に起こりにくい。開発者の「群衆」が集まると、「誰でも自由に」crateを積み上げる「バザール(bazaar)モデル」が生まれる。最終的にはRustも、公式標準ライブラリ(例: stdlib::data_structures::automata::weighted_finite_state_transducer など)と整理された名前空間を備えた「電池同梱」の構成になってほしい。言語自体がバージョン管理と後方互換性を内蔵しているのだから、今後の改善にも期待している
    • POSIX の glob 関数は実際にファイルシステムを探索する。文字列マッチング用には fnmatch がある。fnmatch は別モジュールにして glob の依存関係にするのが理想だ。glob を自前実装しようとするとかなり難しく、ディレクトリ構造、brace expansion など複雑な要件があるので、よく設計された関数の組み合わせが必要になる
    • Rustでは borrow checker が、設計センスの低い開発者に対する一種の防波堤として機能してきた。この影響がいつまで続くかは分からない
    • Rustの大きな長所の1つは、開発者全体のレベルが高く、crate の品質も高めであることだ
    • Bunにも glob 機能が含まれている
  • Rustに不必要に限定するまでもなく、依存関係の問題とサプライチェーン攻撃はすでに現実だ。新しい言語を設計するなら、ライブラリツリー全体を安全に隔離できるような capability system を組み込む必要がある。たとえば画像読み込みライブラリを設計するとき、ファイルではなくストリームだけを受け取って処理させたり、「ファイルを開く権限がない」と明示的に指定して危険な関数の利用をコンパイル時に防げるようにする。既存のエコシステムでは簡単ではないが、うまくできれば攻撃面を最小化できる。依存関係を最小化する文化だけでは根本解決は難しく、Goのような言語もサプライチェーン攻撃から自由ではない
    • Sans-IO(dependencyが直接IOを行わない設計)の文化を積極的に広める必要がある。新しいライブラリが発表されたとき、直接IOを実装していることを指摘する文化も必要だ。もちろん大衆による査読だけで十分ではないが、Sans-IO の原則が広がればよいと思う
    • 例として WUFFS という特定用途向け言語がある。実際には Hello world すら表示できず、文字列型もない。その代わり、信頼できないファイルフォーマットの解析だけに特化している。こういう特定用途言語がもっと増えるべきだ。高速で、危険性がなく、不要なチェックも減らせる
    • Java と .NET Framework には何十年も前に partial trust/capabilities の機能があったが、広く使われることはなく廃止された
    • Rustにも少し似た傾向はある。#![deny(unsafe_code)] によって unsafe コードの使用時にコンパイルエラーを出し、その事実を利用者に示せる。ただし完全な強制検査ではなく、特別に許可すれば unsafe コードは書ける。feature flag のように、標準ライブラリ機能を推移的に制御する capability system の導入を想像することはできる
    • こういうアイデアを自分で作ってみたいし、いつか実現してほしい。Rustでは linter ベースで capability 追跡を部分的に行える。コンパイラの unsoundness 問題は解決が必要だ
    • 既存の言語やエコシステムに完全な静的強制を導入するのは難しいが、ランタイム検証だけでも大半の効果は得られる。ライブラリコードをソースからコンパイルするなら、各システムコールに権限チェックのラッパーを置ける。違反時には panic を発生させ、各ライブラリごとの capability profile を作成・配布する取り組みが必要になる。これは TypeScript ですでに似たことが証明されている
    • Haskell は IO monad を通じて、この種のアプローチをある程度実現している。直接IOできない関数は型シグネチャで制約される
    • 私が思うに、こうした仕組みのためにはOSとのやり取りの方法そのものを全面的に変える必要がある。ストリームを読むだけでも、実際にはファイル読み取り用のシステムコールを使えてしまうのが落とし穴だ
    • Capslock というプロジェクトが Go でこの方式に近いことをしている
    • ライブラリがシステムAPIを import できないよう、エントリプログラム側から制限すれば、依存性注入だけで capability を渡せる。今ある言語でも設計は可能だが、既存ライブラリとの互換性が壊れるのが実運用上の問題だ
    • このアイデアに似たものが既に実装されたことがあるのか気になる。現在の言語では適用がかなり難しそうだ
    • 1つの言語だけでは無理で、マルチ言語のエコシステムが必要だ
    • TypeScript エコシステムでは、たとえばファイル操作クラスが存在しない環境ならコンパイルが失敗し、自然に制限がかかる
  • 現代のソフトウェア開発に共通する問題だ。参入障壁が低くなり、既存コードの活用も増えた。依存関係それ自体が、結局は信頼できないコードだ。技術的解決がなければ、誰かが継続的にコードレビューや保守、社会的・法的な信頼システムを維持し続ける必要がある。Rustの stdlib に取り込めば、中核チームがそのコード全体に責任を負うことになるので、管理負担が増す
    • 言語ごとに表面上の深刻さは違う。標準ライブラリが強力な言語は、最小限の外部依存で多くのことができて有利だ。JS/Node のように基本機能が少ない言語では、外部依存が前提になる。「軽さ」が常によいわけではない
    • Rustにはもっと多くの標準ライブラリ統合が必要だと思う。Goは標準ライブラリが素晴らしい一方、Rustは基本的な機能(web、tls、x509、base64 など)ですらライブラリ選定と管理が苦痛だ
    • Gilad Bracha はサードパーティライブラリのサンドボックス化について興味深いアプローチを提案していた。import をなくし、すべてを依存性注入にする。IO subsystem のようなものを注入しなければ、3rd party コードは絶対にそこへアクセスできない。読み取り機能だけを与えたいなら、読み取り機能だけラップして注入すればよい。ただしシステムプログラミング領域では限界がある(unsafe code などのため)
    • QubesOS のように、すべてのライブラリを隔離環境で動かし、自分のコードは dom0、各ライブラリは別々のテンプレートVM、通信にはネットワーク名前空間を使う構成も提案されている。機密性の高い業界では実用的だ
    • 私が見てきた限り、私たちはより複雑なことをしているのではなく、同じことをより複雑に処理しているだけだ。目標そのものが難しくなったわけではない
    • 実際、言語ごとに事情は違う。C/C++ は依存関係の追加が難しく、クロスプラットフォーム対応まで考えるとさらに面倒なので、同じような問題は起こりにくい
    • 問題は、不要なコード膨張こそが複雑性だということだ。ほぼすべてのプロジェクトが不要な複雑さと過剰設計に満ちている。これは業界全体の問題だ
  • blessed.rs は、標準ライブラリには入れにくい有用ライブラリの一覧を推薦してくれる。この仕組みのおかげで、たいていのパッケージが特定用途向けに限定され、管理しやすいのが気に入っている
    • cargo-vet も勧めたい。信頼できるパッケージの追跡と定義ができ、たとえば導入前に専門家の監査が必要なパッケージから、tokioメンテナが管理しているパッケージならそのまま信頼しよう、という semi-YOLO ポリシーまで可能だ。blessed.rs より少しフォーマルで、チーム内で公式の準標準リストを共有する手段としてよい
    • こういう仕組みがPythonにもあれば本当にいいのに
    • レビューしてみたが、本当に良い推薦プロジェクトだ
  • leftpad 事件以降、パッケージマネージャへの否定的な印象が残っている。tokio のようなものは事実上言語レベルの機能なので、もしOPがGo全体やNodeのV8まで自分で監査すべきだと考えているなら、それは現実的ではない
    • 実際、tokioも誰かが継続的に監査している。大勢ではないにせよ、とにかく誰かはやっている
    • cargo が、2つの依存関係が互いに異なるバージョンを使うと両方のバージョンを含める挙動は、cargo が独自にうまくサポートしている
  • cargo パッケージの feature flag は本当に良い点だ。不要な依存関係をこのフラグの裏に隠すPRをよく出している。cargo tree で依存関係ツリーを簡単に見られる。実際にバイナリへ入るコード行数ビューはあまり意味がない。関数がインライン化されると、たいてい main に吸収されるからだ
    • npm に feature flag がないのは残念だ。すでに対応しているパッケージマネージャがあるのか気になる。内部ライブラリで特定フレームワーク依存のコードを隔離して拡張したい
  • 私も同じように感じる。Cargo では依存関係の追加が簡単すぎて、自分1人が気をつけても、いくつか追加するだけで何十もの推移的依存関係が付いてくる。だからといって、それを使うなというのも現実的ではない。C++ではこういう現象は少ない。Rustは小さなパッケージ分割が多く、インターネットからランダムなコードを持ってくる感覚がある。Rust自体は好きだが、こういう構造は好きではない
    • Rustのサブレディットでリンクされていた記事では、C++で依存関係があまり見えないのは、たいてい動的ライブラリとして提供されているからだという。むしろOSパッケージマネージャの安定性・セキュリティ管理能力に依存できる点は利点でもある。Rustにも標準ライブラリ拡張版のような概念があるとよい
    • C++の依存関係は複雑でビルドシステムもひどいので、Rust式の多少不安定な依存関係のほうがまだ良いと思う。実際のC++の推移的依存関係はプリコンパイルされた形なので、さらに見えにくい
    • Rustで小さなパッケージ分割が多いのは「哲学」というよりビルド速度のためだ。規模が大きくなると、プロジェクトを分割してcrateに分けることになる。抽象化のためではなく、ビルド性能のためにそういう構造へ再編を強いられる
    • 「じゃあ自分で使わなければいい」という理屈に無条件で同意する必要はない。もう少し考えるべきだ
    • C++とCMakeは難しすぎて、実際には多くのソフトウェアが単に使われなくなることすらある
  • 中核ライブラリはオープンソースライブラリを使い、小規模な機能はオープンソースを参考にして直接コードへコピペする形で管理している。コードは多少不要に膨らむが、外部コードのレビュー負担とサプライチェーン露出を減らせる。大きなライブラリは依然として問題だが、何もかも自分で書くことはできない。これはRust固有の問題ではなく、全般的な問題だ
  • 私は以前(別の言語で)重要なシステムについて、モジュール/パッケージ最小化の方針を立て、使うパッケージはすべて社内リポジトリへ移して、ブランチや更新ごとに監査していた。フロントエンドなどの領域では、こうした厳格な管理は現実的に不可能だ。最近は派手なオープンソースAIツールやモデルでも、依存関係管理で同じような悩みがある。Rustで個人プロジェクトをするときでも、UI/async ライブラリの依存関係爆発がいちばん気持ち悪い。1つでも脆弱になれば突破されるので、これは時間の問題だ
    • CI/CD システムを公式の内部リポジトリだけにつなぐのが現実的だ。開発者はローカルで何でもインストールできても、無断コミットはビルドサーバーで弾かれる
    • セキュリティリスクを解決しようとする RFC もあるが、文化的な(たぶん)理由で急激な変化は起きていない
    • Rustのすごい点は、async も望むやり方で自分で実装できることだ。特定の実装にだけ縛られるわけではない
 
iolothebard 2025-05-11

Rustだけの問題ではありません。
共用パッケージリポジトリと推移的依存関係をサポートするパッケージマネージャーがある、あらゆる言語に共通する利点であり、潜在的な問題点でもあります。
結局のところ、持ってきて使う側がうまく使わなければならないのですが……
Node&npmのleft-pad騒動があったにもかかわらず、何も変わっていません。