5 ポイント 投稿者 GN⁺ 2025-01-24 | 4件のコメント | WhatsAppで共有
  • EdgeDBがネットワークI/OをPythonからRustへ移行している最中、reqwestベースの新しいHTTP fetchテストがARM64 CIでのみ停止したように見えたが、実際にはクラッシュしていた
  • コアダンプを分析した結果、問題箇所は新しいHTTPコードではなくlibcの**getenv()**内部で、環境変数配列を走査中に不正なポインタ0x220を読もうとして失敗していた
  • 別スレッドがopenssl-probe経路でSSL_CERT_FILESSL_CERT_DIRを設定し、setenv()environを再配置した一方で、同時にPythonのエラー処理経路がgetenv()を呼び出し、競合状態が発生した
  • Rustコードには明示的なunsafeはなかったが、std::env::set_var()がグローバル環境を変更している間も、他のランタイムやlibcへの直接呼び出しまでRust内部のロックで同期されるわけではなかった
  • 解決策は、Linuxでreqwestrust-native-tls/opensslバックエンドの代わりに**rustls**へ移行することで、Rust 2024 editionとglibcもこの種の問題を減らす方向に変わりつつある

ARM64 CIでのみ表面化したクラッシュ

  • EdgeDBはネットワークI/Oコードのかなりの部分をPythonからRustへ移植しつつあり、新しいHTTP fetch機能を開発していた
  • HTTPクライアントライブラリとしてreqwestを使用しており、機能はローカル環境とx86_64 CIランナーでは通過したが、ARM64 CIランナーでは断続的に失敗した
  • 当初はテストランナーが無期限に停止したように見え、CIログにはエラーもなく1つのテストだけが実行中のまま残り、数時間後にタイムアウトしていた
  • 初期仮説は、IntelとARM64のメモリモデルの違いだった
    • Intelは比較的厳格なメモリモデルを持ち、メモリ書き込みについて全プロセッサが合意する全順序がある
    • ARMはより弱い順序のメモリモデルを持ち、書き込みが異なるスレッドに異なる順序で見えることがある

Docker CI環境でコアダンプを追跡

  • 夜間CIマシンはAmazon AWS上で動作していたため、コンテナ外の実際のrootユーザーとしてログインし、dmesgやシステムログを確認できた
  • コンテナの内外で停止したように見えたPIDを探したが、該当プロセスは存在せず、これによりデッドロックではなくクラッシュだったことが分かった
  • Dockerコンテナはプロセス名前空間で動くため、コアダンプはDockerホストへ渡され、journalctlpython3プロセスのコアダンプを確認した
  • 最初にgdbでコアを開いた際は、コンテナ内部の.soファイルがないためバックトレースは役に立たなかった
  • コンテナから/lib/usrなどをコピーし、gdbsolib-absolute-prefixを設定したうえで、クラッシュ地点がlibc.so.6の**getenv()**であることを確認した

getenv()が読んだ壊れた環境変数ポインタ

  • 完全なバックトレースはgetenv()__dcigettext()strerror_r()strerror()PyErr_SetFromErrnoWithFilenameObjects()という流れだった
  • 新しいHTTPコードが直接クラッシュしたのではなく、Pythonがerrnoベースの例外を生成する際にgettext関連の経路を経てgetenv()を呼び出していた
  • GLIBC 2.17のgetenv()実装は、POSIXのenvironchar **のリストとして走査し、最後のNULLポインタまで環境変数文字列ポインタを調べる
  • ディスアセンブリとレジスタ状態から、getenv()x19 = 0x220のアドレスからバイトを読もうとしてクラッシュしていた
    • 0x220は明らかに有効ではないメモリアドレスだった
    • environ自体を調べると、現在の環境変数リストは一見整合していた

原因: setenv()getenv()の競合状態

  • setenv()はマルチスレッド環境で安全に呼び出せる関数ではなく、libcgetenv()で奇妙なクラッシュを引き起こす問題はこれまで何度も再発見されてきた
  • ディスアセンブリとCコードを突き合わせた結果、x20environ配列をたどるポインタepに対応していた
  • クラッシュ時点でx200x248b5000で、現在のenviron0x28655750にあり、約60MB後方にあった
  • 古い環境配列周辺のメモリと現在のenvironを比較すると、最後の差分はSSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crtSSL_CERT_DIR=/etc/ssl/certsの項目に現れた
  • 別スレッドがsetenv()呼び出し中にenvironを移動させ、getenv()は古い環境配列を読み続けるuse-after-free状態に陥ったと考えられる

openssl-probeとTLSバックエンドのつながり

  • rust-native-tlsに関する古いIssueから、openssl-probeがシステム証明書を見つけるためにSSL_CERT_FILESSL_CERT_DIR環境変数を設定するという手がかりを得た
  • Linuxでrust-native-tlsopensslバックエンドを使うと、この経路が呼び出される
  • 問題となったopenssl-probeのコードは、明示的なunsafeなしにenv::set_var()で2つの環境変数を設定していた
    • SSL_CERT_FILE
    • SSL_CERT_DIR
  • この組み合わせにより、unsafeを使わないRustコードが同一プロセス内のlibc利用と悪い相互作用を起こし、クラッシュを生んだ

ARM64 Linuxで再現した理由

  • このクラッシュは、setenv()reallocで環境配列を移動する瞬間に、別スレッドがgetenv()を呼び出すと発生する
  • 再現には複数の条件が同時にそろう必要があった
    • 環境変数の個数がreallocを誘発する程度にちょうど合っていること
    • 無関係なI/O失敗がasyncioに捕捉されること
    • Pythonのエラー処理経路がLANGUAGE環境変数を取得するために、まさに同じタイミングでgetenv()を呼び出すこと
  • 不正な値0x220は64ビットワード単位で見て古い環境サイズに近かった
    • 0x220 / 8 = 68
    • 古い環境ブロックの終端NULL位置にこの値が上書きされており、これはシステムmallocがfree blockサイズを示すための値に見えた
  • これだけ多くの前提条件が必要だったため、単一プラットフォームでかなり再現しやすかったこと自体が幸運だった

ARM64ディスアセンブリで確認できた手がかり

  • getenv()のディスアセンブリではx20が変化する箇所が分かりづらく、混乱があった
  • 鍵となったのはAArch64のpre-indexアドレッシングモードだった
  • ldr x19, [x20, #8]!は次のように動作する
    • x19 = *(x20 + 8)
    • x20 = x20 + 8
  • このアドレッシングモードのため、x20は左辺に明示的に書かれていなくても、環境変数ポインタ配列を順にたどっていた

適用した修正と関連プロジェクトの変化

  • 最終的にLinuxでは、reqwestrust-native-tls/opensslバックエンドの代わりに**rustls**へ移行することに決めた
  • もともとnative TLSバックエンドを選んだ理由は、PythonコードをRustへ移している途中でTLSエンジンを2つ同時に搭載することを避けるためだった
  • 今回の問題を受け、短期的にはTLSエンジンを2つ搭載しても問題ないと判断した
  • 代替策としては、try_init_ssl_cert_env_vars()の最初の呼び出しをPythonのGILを保持した状態で実行する方法もあった
    • Rustには、Rustコード同士で環境の読み書き競合を防ぐための内部ロックがある
    • ただし、他言語のコードがlibcを直接使う場合まで防げるわけではない
    • GILを保持すれば、少なくともPythonスレッドとの競合は防げる
  • Rustプロジェクトはこの問題をすでに認識しており、2024 editionでは環境setter関数をunsafeにする計画がある
  • glibcプロジェクトも最近、reallocを避けて古い環境配列をリークさせる方式に変更し、getenv()スレッド安全性を高める修正を加えている

4件のコメント

 
carnoxen 2025-01-24

Setenv はスレッドセーフではなく、C はこれを修正したくない

setenv 関数がまた問題を起こしています。

 
y15un 2025-01-24

私はタイトルを「C stdlibのスレッド非安全性は、安全だとされるRustでさえ救えない」と書くつもりです。 :)

 
halfenif 2025-01-24

確かに理解しました。

 
GN⁺ 2025-01-24
Hacker News のコメント
  • ここでいちばん大きなポイントは、Rust の次のエディションで環境変数を設定する関数が unsafe になるということ
    運がよければ、こうしたクラッシュを引き起こすクレートにまで影響が及びそうで、その間に upstream には https://github.com/alexcrichton/openssl-probe/issues/30 の issue が上がっている

    • しかし根本問題、つまり getenvsetenv または unsetenv を別々のスレッドから安全に呼べないという点は、実際には直っていない
      信頼できる解決策は、これらの関数が必ずミューテックスを取るように変えることだけのように見える
    • 「今どきこの問題への最善の解決策は rustls のようなライブラリを使い、このクレートを使うのをやめること」というライブラリ作者の判断は妥当で、歓迎したい
      残念ながらエコシステムはそうなっていない: https://github.com/seanmonstar/reqwest/blob/master/Cargo.tom...
    • 人々は ____UNSAFE_payattention__nevermindthatthisappears50timesinthisfile___ のようなブロックや接頭辞を無視するよう訓練されている
      Web フレームワークでも似たようなもので、Vue には v-html ディレクティブがあり、React には dangerouslySetInnerHTML があるが、この点ではVue のほうが明らかに優れていると思う
  • Rust 標準ライブラリの set_varremove_var は、次のエディションである2024 エディションで、適切に unsafe {} ブロックを要求するようになる
    ドキュメントも今では安全性の問題に触れているが、そもそもこれらの関数を safe にしたのは誤りで、より高水準の言語も犯してきた誤りだ
    https://doc.rust-lang.org/stable/std/env/fn.set_var.html
    glibc には、環境が変更されるより多くのケースで getenv を安全にするパッチがあるが、C では依然として environ に直接アクセスできるため、変更が起きる状況では完全には安全になれない: https://github.com/bminor/glibc/commit/7a61e7f557a97ab597d6f...

    • glibc が今では古いバージョンを保持し、指数的なリサイズ方針を採用しているのは驚きだ
      アクティブな環境変数ごとに償却ベースで定数サイズのメモリリークが生じるが、変数そのものにもすでにそのようなリークがあり、しかも長さに依存し、もはや使われない値まで含まれるという
      API 上は正しいプログラムでも、このためにメモリが際限なく増える病的なユースケースは明らかにありそうだ
      API を複数スレッドで使って規則を破ったプログラムを直すために、API に従うプログラムへ無限のメモリ増加というバグを導入しているようで、興味深いがもやもやする。教条主義より実用主義という感じだ
    • 標準ライブラリ実装が同期を処理できるなら、なぜ unsafe を要求する必要があるのか疑問だ
  • C 標準ライブラリのメンテナーが setenv をマルチスレッド安全にすることに反対するとしても、少なくとも POSIX の中で、あるいは事実上の標準を先に作って後から POSIX に受け入れさせる形で、新しいスレッドセーフ API は定義されるべきだ
    何もできない理由を説明するのに費やす時間でこの問題を直していれば、古い setenv を置き換え、多くのソフトウェアプロジェクトで廃止・削除できていたはずだ
    glibc がこの問題を事実上なくそうとする変更を行っていることを見ると、Musl の中では直せないという Musl メンテナーの言葉にも説得力が足りない

    • 最大の問題はスレッドセーフ API がないことではなく、extern char **environ; が存在することだ
      environ が公開アクセス可能である限り、setenvgetenv がそもそも使われる保証すらない。両者は必須ではないからだ
      environ をなくせるなら、setenvgetenv をスレッドセーフにするのはかなり簡単だ。なくせないなら不可能だが、完全な解決ではないとしても、setenvgetenv をスレッドセーフにするのは改善だという主張はできる
    • 環境を引数に取らない、または実行ファイルを探すために PATH を検索するすべての exec() 関数にもロックが必要になりそうだ
    • 後方互換な形では直せないと結論づけた専門家たちより自分のほうがよく分かっている、という点には説得されない
  • Linuxで環境変数関連のバグに遭遇するのは一種の通過儀礼のようなもので、ほかのUnixでは不思議と問題になりにくい傾向がある
    Linusとカーネルは、POSIXのバグが実際には発火しないように実用的に直しているのに、glibcは人々が問題を少しでも緩和しようとしてから何十年も経っているのに後れを取っている、という点が少しおかしい
    TZ のような厄介ごとが山ほどあるのは確かだが、getenv_r()を提供し、setenv()と同期させ、getenv()使用時にコンパイル・リンク段階で警告するだけでも、問題のかなりの部分は消えていたはず
    さらに進めて、環境ポインタを読み取り専用にしておく**コピーオンライト(COW)**方式にすることもできたはず
    代わりに問題を個々のアプリケーションへ押し付けたが、アプリケーション作者は依存関係が何をしているかほとんど分からないので、大きな誤りである。昔、自分が置かれた状況もまさにそれで、当時のクローズドソース・ライブラリのベンダーは、そのおもちゃのUnixクローンであるLinuxを使うのをやめろと言っていた

    • 「Linuxで環境変数関連のバグがあり、ほかのUnixでは問題が少ない」という判断がどう出てきたのかは分からない
      問題は実装ではなくAPIそのものである。setenv()unsetenv()putenv()、特にenvironは、マルチスレッドプログラムでは本質的に安全ではない
      getenv_r()でも完全には救えない。あるスレッドが環境変数の古い値を与えられたバッファへコピーしている間に、別のスレッドがsetenv()を呼べるからだ
      もちろん、getenv()で受け取った後に別スレッドがsetenv()を呼び、そのメモリを無効化するケースはgetenv_r()で直せるが、APIを壊すほかの呼び出しを防ぐ方法はない
      libcがgetenv()/setenv()/putenv()/unsetenv()の内部でミューテックスを取る形で一部の問題を緩和することはできるが、getenv()が返した値が呼び出し側コードで使えるだけ十分長く有効だとlibcが保証する方法は依然としてない
      environへの直接アクセスを安全にする良い方法もない。environをスレッドローカルにすることはできるかもしれないが、そうするとスレッドごとの環境の見え方が恒久的にずれたり、getenv_r()呼び出しとenvironを直接確認した結果が異なったりする可能性がある
      ここで後方互換性を守るのは本当に難しく、関数群を保護するミューテックスを追加するだけでも既存プログラムの意味が変わって壊れる可能性がある
  • 以前、setenvがひどいという記事があった: https://www.evanjones.ca/setenv-is-not-thread-safe.html
    議論もあり、最初のコメントからRustで問題を引き起こすという内容だった: https://news.ycombinator.com/item?id=38342642

    • それはすでに知られている事実である
      ここで残りの問題の大半は開発環境に見える。AmazonのデータセンターにあるリモートマシンでDockerを使ってテストしており、そのマシンがプロセスのクラッシュを報告できていなかった
      さらにコンテナ内にはバックトレースを得るのに十分なデバッグシンボル情報もなかった。最初の失敗時にきれいなバックトレースを受け取れていれば、すぐに明確になっていたはず
      そもそもなぜsetenvを使っているのかも疑問である
  • この話を見て、昔の同僚の何人かが非常に信じていた 12-factor app ムーブメントを思い出した。その「factor」の1つが、アプリケーション設定は環境変数で行うべきだというものだった
    いつも少し愚かだと思っていた。設定方式が、フラットな名前空間に文字列型の値をかごのように入れる構造だからだ
    getenv()/setenv()/environ の危険性も、環境変数を設定に使うべきではない強い根拠だと思う
    もちろん、常に優れていて十分にサポートされた代替手段があるわけではない。私は設定ファイルを好むし、開発・ステージング・本番の値だけを埋め込むテンプレート設定も使える。普通は欠点や落とし穴があっても YAML を使うが、より良い設定ファイル形式はあり得るとしても、YAML は環境変数よりはるかにましだと思う

    • Windows と Microsoft に対する強い反感は多いが、時間が経つにつれて彼らの API 設計が正当化されることはかなりある
      NT では環境変数を型付けしたりテンプレート化したりでき、名前空間を持つ設定データベースであるレジストリもある。冗長で奇妙ではあるが
      さらに MSVC は、ほぼすべての標準ライブラリ関数についてスレッドセーフ版を提供している
      新しい C/C++ 開発者が MSVC の POSIX 互換性の不足を嘆くのをよく聞くが、それが実際に何を意味するのか深く考えてはいないようだ。単に1990年代に書かれた C プログラムと相互互換であってほしい、というのに近い
    • 環境変数については似た懸念がある。どこからでも読める点が嫌だ
      関数のシグネチャだけで挙動を推論する能力を妨げ、本来なら純粋関数であり得た多くの関数を不純にしてしまう
      どのプロセスでも環境変数を使えず、1回だけ、変数ごとではなく一括で1回だけ読めるようにアプリをマークする言語機能があれば、どこでも使うと思う
    • getenv() 自体はまったく問題なく、問題は setenv()
      理論上は、その謎めいたアプリが起動する前に環境が設定されるので、これを使う必要はないはずだ
      しかし、フラットな名前空間、文字列値、どんなライブラリやモジュールが入ってくるかも分からない状態で全員が共有する自由なグローバル空間である点は、setenv() の安全性問題がなくても良い考えではない
    • 「12-factor app」には、プロセスが生きている間は環境を読み取り専用として扱うべきだという付録を付けるべきだ
      ここで人々が話している問題の大半は、環境を変更可能なグローバル状態用のキー・バリュー・ストアのように乱用しようとするところから来ているようだ。なぜわざわざそうしたいのか分からない
      JVM は環境を事実上不変として扱っており、SoundCloud のような Scala・Java を使う会社が 12-factor app ムーブメントに影響を与えた可能性もある。私は環境が変わったり、スレッド絡みの問題を起こしたりするのを経験したことがない
      環境が変わっても、JVM 起動時に作られた不変コピーはそのままで、通常の Java API で環境とやり取りするコードはその変更を見ない
      設定ファイルの問題は、パースがプロセスごとだという点だ。だから Linux/Unix はあれほどごちゃごちゃしている。ツールごとに設定の慣習や仕組みが異なり、標準がない
      Docker エコシステムでは、コンテナ内で何をしようと、外部とのインターフェースはボリュームをマウントしてアプリごとの複雑な設定方式に従うか、単に環境変数を使うかだ
      最近 Docker で動かす現代的なソフトウェアの多くは、環境で挙動を完全に制御できるほど Docker フレンドリーで、多くの場合それで十分だ
      Docker Compose や Kubernetes を使えば、プロセスの起動方法を定義する環境変数リストを YAML ファイルとして持つことになるので、ある程度は望む構造にもなる。YAML が好きなわけではないが十分に機能するし、構文問題で1日が台無しになる可能性は高いものの、代替案にも問題がないわけではない
    • これは実のところ別問題だ。環境変数を設定として読み込んだ後で二度と触らなければ、完全に安全だ
      私も 12-factor app スタイルを使っているが、アプリに入った後で環境変数とデータを検証して保存する。その後は何の問題もない
  • 目立ちにくいバグを掘り下げる素晴らしい記事だ
    断続的なバグ、アーキテクチャ固有性、依存関係の中に隠れていること、Rust、Python GIL、gettext まで全部入っていた
    こうした詳細なトラブルシューティング報告は、自分で経験することに最も近い資料だ。依存関係がそれを使っていて、自分がどうやって知り得たのかという状況では、「X を使わなければいい」と簡単には言いにくい

  • 「夜間 CI マシンは Amazon AWS で動いていて、コンテナではない本物の root ユーザーを使える利点がある」としながら、同時に「コンテナの外には必要なファイルがなく、コンテナは非常に最小化されているため gdb を簡単にはインストールできない」と言っている
    もう人々はクラウドとコンテナなしにローカルでビルドしてデバッグする能力を失ってしまったのか?

    • その通り。クラウド SaaS が人々の理解をどれほど歪めたかには衝撃を受ける
      ごく些細なことをするのに、あらゆるクラウドの複雑さとデプロイ階層が必要になってしまった。PC 革命を100%巻き戻して、不格好で高価なメインフレーム・コンピューティングの時代に戻ったようなものだ
      理由はクラウドに金があるからであり、クラウドは DRM だからだ。ソフトウェアをそこに置けばサブスクリプション料金を取れ、回避できず、完全な囲い込みを永遠に維持できる。ユーザーが自分のデータすら取り出せないことも多い
      製品最適化のためのリアルタイム分析も楽にできる
      コンピューティングアーキテクチャはビジネスモデルの下流にある。メインフレームが最初に死んだのは、インターネットがなく PC のほうが安かったからだが、ベンダーが囲い込みの力も大きく失ったからでもある
      今では、はるかに収益性の高いモデルを復活させる方法ができた。ユーザーの面倒な自由は消えたし、正直なところ、ユーザーがそうした自由を得ると往々にして金を払わなくなるため、質の高いソフトウェア事業が成り立ちにくくなることもある
    • これは ARM でだけ発生するランダムなメモリ破壊のような問題だ
      ローカルではクラッシュを再現できなかった可能性が高い。開発者マシンの多くは x86 で、そこではクラッシュしなかったはずだ
      クラッシュ処理はもっと良くすべきだったが、彼らもその問題は認識しているようで、ここで扱っている核心ではない
    • もちろん能力を失ったわけではないが、私たちのマシンではクラッシュしなかった
    • 問題が起きるアーキテクチャを実行するデバイスがない可能性が高いのに、どうやってローカルでデバッグするのか? いずれにせよ、失敗が発生する実際の環境でデバッグするほうがはるかに速い
  • 変更可能なグローバル状態は悪だ。友人なら、友人に変更可能なグローバル状態を使わせたりしない
    環境変数が嫌いだ。これが「Linux流」なのだが、疫病のように避けている。強くおすすめする
    libcはひどいもので、世界はもう次へ進むべきだ

    • 環境変数は、プロセス内で読み取り専用として扱うなら問題ない
    • 「変更可能なグローバル状態が悪」なら、CPUとRAMも捨てるべきだ
    • 代案として何を提案しているのか気になる
      問題はLinuxでも、変更可能なグローバル状態やリソースでも、libcでもない
      問題は、仕事で物事をきちんとやる時間を与えられていないことだ。たとえば問題が起きる前にGDBで捕まえるには、上司がコードと、そのコードが触るあらゆるものを粘り強くデバッグし、さかのぼって追跡する時間を与える必要がある
      生煮えのコードに金が集まりすぎている。悲しいが事実だ
    • libcは世界を情報化時代へと押し込んだ
    • 好みの代替案が何なのか気になる
  • こういう問題が多すぎて、結局 LD_PRELOADgetenv / setenv / putenv にパッチを当てた

    • さっきglibcに入ったような、環境をリークさせる固定実装にしたということ?