- EdgeDBがネットワークI/OをPythonからRustへ移行している最中、
reqwestベースの新しいHTTP fetchテストがARM64 CIでのみ停止したように見えたが、実際にはクラッシュしていた
- コアダンプを分析した結果、問題箇所は新しいHTTPコードではなく
libcの**getenv()**内部で、環境変数配列を走査中に不正なポインタ0x220を読もうとして失敗していた
- 別スレッドが
openssl-probe経路でSSL_CERT_FILEとSSL_CERT_DIRを設定し、setenv()がenvironを再配置した一方で、同時にPythonのエラー処理経路がgetenv()を呼び出し、競合状態が発生した
- Rustコードには明示的な
unsafeはなかったが、std::env::set_var()がグローバル環境を変更している間も、他のランタイムやlibcへの直接呼び出しまでRust内部のロックで同期されるわけではなかった
- 解決策は、Linuxで
reqwestのrust-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ホストへ渡され、
journalctlでpython3プロセスのコアダンプを確認した
- 最初に
gdbでコアを開いた際は、コンテナ内部の.soファイルがないためバックトレースは役に立たなかった
- コンテナから
/lib、/usrなどをコピーし、gdbのsolib-absolute-prefixを設定したうえで、クラッシュ地点がlibc.so.6の**getenv()**であることを確認した
getenv()が読んだ壊れた環境変数ポインタ
- 完全なバックトレースは
getenv() → __dcigettext() → strerror_r() → strerror() → PyErr_SetFromErrnoWithFilenameObjects()という流れだった
- 新しいHTTPコードが直接クラッシュしたのではなく、Pythonがerrnoベースの例外を生成する際にgettext関連の経路を経て
getenv()を呼び出していた
- GLIBC 2.17の
getenv()実装は、POSIXのenvironをchar **のリストとして走査し、最後のNULLポインタまで環境変数文字列ポインタを調べる
- ディスアセンブリとレジスタ状態から、
getenv()はx19 = 0x220のアドレスからバイトを読もうとしてクラッシュしていた
0x220は明らかに有効ではないメモリアドレスだった
environ自体を調べると、現在の環境変数リストは一見整合していた
原因: setenv()とgetenv()の競合状態
setenv()はマルチスレッド環境で安全に呼び出せる関数ではなく、libcのgetenv()で奇妙なクラッシュを引き起こす問題はこれまで何度も再発見されてきた
- ディスアセンブリとCコードを突き合わせた結果、
x20はenviron配列をたどるポインタepに対応していた
- クラッシュ時点で
x20は0x248b5000で、現在のenvironは0x28655750にあり、約60MB後方にあった
- 古い環境配列周辺のメモリと現在の
environを比較すると、最後の差分はSSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crtとSSL_CERT_DIR=/etc/ssl/certsの項目に現れた
- 別スレッドが
setenv()呼び出し中にenvironを移動させ、getenv()は古い環境配列を読み続けるuse-after-free状態に陥ったと考えられる
openssl-probeとTLSバックエンドのつながり
rust-native-tlsに関する古いIssueから、openssl-probeがシステム証明書を見つけるためにSSL_CERT_FILEとSSL_CERT_DIR環境変数を設定するという手がかりを得た
- Linuxで
rust-native-tlsのopensslバックエンドを使うと、この経路が呼び出される
- 問題となった
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では、
reqwestのrust-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件のコメント
Setenv はスレッドセーフではなく、C はこれを修正したくない
setenv関数がまた問題を起こしています。私はタイトルを「C stdlibのスレッド非安全性は、安全だとされるRustでさえ救えない」と書くつもりです。 :)
確かに理解しました。
Hacker News のコメント
ここでいちばん大きなポイントは、Rust の次のエディションで環境変数を設定する関数が unsafe になるということ
運がよければ、こうしたクラッシュを引き起こすクレートにまで影響が及びそうで、その間に upstream には https://github.com/alexcrichton/openssl-probe/issues/30 の issue が上がっている
getenvとsetenvまたはunsetenvを別々のスレッドから安全に呼べないという点は、実際には直っていない信頼できる解決策は、これらの関数が必ずミューテックスを取るように変えることだけのように見える
残念ながらエコシステムはそうなっていない: https://github.com/seanmonstar/reqwest/blob/master/Cargo.tom...
____UNSAFE_payattention__nevermindthatthisappears50timesinthisfile___のようなブロックや接頭辞を無視するよう訓練されているWeb フレームワークでも似たようなもので、Vue には
v-htmlディレクティブがあり、React にはdangerouslySetInnerHTMLがあるが、この点ではVue のほうが明らかに優れていると思うRust 標準ライブラリの
set_varとremove_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...アクティブな環境変数ごとに償却ベースで定数サイズのメモリリークが生じるが、変数そのものにもすでにそのようなリークがあり、しかも長さに依存し、もはや使われない値まで含まれるという
API 上は正しいプログラムでも、このためにメモリが際限なく増える病的なユースケースは明らかにありそうだ
API を複数スレッドで使って規則を破ったプログラムを直すために、API に従うプログラムへ無限のメモリ増加というバグを導入しているようで、興味深いがもやもやする。教条主義より実用主義という感じだ
unsafeを要求する必要があるのか疑問だC 標準ライブラリのメンテナーが
setenvをマルチスレッド安全にすることに反対するとしても、少なくとも POSIX の中で、あるいは事実上の標準を先に作って後から POSIX に受け入れさせる形で、新しいスレッドセーフ API は定義されるべきだ何もできない理由を説明するのに費やす時間でこの問題を直していれば、古い
setenvを置き換え、多くのソフトウェアプロジェクトで廃止・削除できていたはずだglibc がこの問題を事実上なくそうとする変更を行っていることを見ると、Musl の中では直せないという Musl メンテナーの言葉にも説得力が足りない
extern char **environ;が存在することだenvironが公開アクセス可能である限り、setenvとgetenvがそもそも使われる保証すらない。両者は必須ではないからだenvironをなくせるなら、setenvとgetenvをスレッドセーフにするのはかなり簡単だ。なくせないなら不可能だが、完全な解決ではないとしても、setenvとgetenvをスレッドセーフにするのは改善だという主張はできるPATHを検索するすべてのexec()関数にもロックが必要になりそうだLinuxで環境変数関連のバグに遭遇するのは一種の通過儀礼のようなもので、ほかのUnixでは不思議と問題になりにくい傾向がある
Linusとカーネルは、POSIXのバグが実際には発火しないように実用的に直しているのに、glibcは人々が問題を少しでも緩和しようとしてから何十年も経っているのに後れを取っている、という点が少しおかしい
TZのような厄介ごとが山ほどあるのは確かだが、getenv_r()を提供し、setenv()と同期させ、getenv()使用時にコンパイル・リンク段階で警告するだけでも、問題のかなりの部分は消えていたはずさらに進めて、環境ポインタを読み取り専用にしておく**コピーオンライト(COW)**方式にすることもできたはず
代わりに問題を個々のアプリケーションへ押し付けたが、アプリケーション作者は依存関係が何をしているかほとんど分からないので、大きな誤りである。昔、自分が置かれた状況もまさにそれで、当時のクローズドソース・ライブラリのベンダーは、そのおもちゃのUnixクローンであるLinuxを使うのをやめろと言っていた
問題は実装ではなく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 は環境変数よりはるかにましだと思う
NT では環境変数を型付けしたりテンプレート化したりでき、名前空間を持つ設定データベースであるレジストリもある。冗長で奇妙ではあるが
さらに MSVC は、ほぼすべての標準ライブラリ関数についてスレッドセーフ版を提供している
新しい C/C++ 開発者が MSVC の POSIX 互換性の不足を嘆くのをよく聞くが、それが実際に何を意味するのか深く考えてはいないようだ。単に1990年代に書かれた C プログラムと相互互換であってほしい、というのに近い
関数のシグネチャだけで挙動を推論する能力を妨げ、本来なら純粋関数であり得た多くの関数を不純にしてしまう
どのプロセスでも環境変数を使えず、1回だけ、変数ごとではなく一括で1回だけ読めるようにアプリをマークする言語機能があれば、どこでも使うと思う
getenv()自体はまったく問題なく、問題はsetenv()だ理論上は、その謎めいたアプリが起動する前に環境が設定されるので、これを使う必要はないはずだ
しかし、フラットな名前空間、文字列値、どんなライブラリやモジュールが入ってくるかも分からない状態で全員が共有する自由なグローバル空間である点は、
setenv()の安全性問題がなくても良い考えではないここで人々が話している問題の大半は、環境を変更可能なグローバル状態用のキー・バリュー・ストアのように乱用しようとするところから来ているようだ。なぜわざわざそうしたいのか分からない
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を簡単にはインストールできない」と言っているもう人々はクラウドとコンテナなしにローカルでビルドしてデバッグする能力を失ってしまったのか?
ごく些細なことをするのに、あらゆるクラウドの複雑さとデプロイ階層が必要になってしまった。PC 革命を100%巻き戻して、不格好で高価なメインフレーム・コンピューティングの時代に戻ったようなものだ
理由はクラウドに金があるからであり、クラウドは DRM だからだ。ソフトウェアをそこに置けばサブスクリプション料金を取れ、回避できず、完全な囲い込みを永遠に維持できる。ユーザーが自分のデータすら取り出せないことも多い
製品最適化のためのリアルタイム分析も楽にできる
コンピューティングアーキテクチャはビジネスモデルの下流にある。メインフレームが最初に死んだのは、インターネットがなく PC のほうが安かったからだが、ベンダーが囲い込みの力も大きく失ったからでもある
今では、はるかに収益性の高いモデルを復活させる方法ができた。ユーザーの面倒な自由は消えたし、正直なところ、ユーザーがそうした自由を得ると往々にして金を払わなくなるため、質の高いソフトウェア事業が成り立ちにくくなることもある
ローカルではクラッシュを再現できなかった可能性が高い。開発者マシンの多くは x86 で、そこではクラッシュしなかったはずだ
クラッシュ処理はもっと良くすべきだったが、彼らもその問題は認識しているようで、ここで扱っている核心ではない
変更可能なグローバル状態は悪だ。友人なら、友人に変更可能なグローバル状態を使わせたりしない
環境変数が嫌いだ。これが「Linux流」なのだが、疫病のように避けている。強くおすすめする
libcはひどいもので、世界はもう次へ進むべきだ
問題はLinuxでも、変更可能なグローバル状態やリソースでも、libcでもない
問題は、仕事で物事をきちんとやる時間を与えられていないことだ。たとえば問題が起きる前にGDBで捕まえるには、上司がコードと、そのコードが触るあらゆるものを粘り強くデバッグし、さかのぼって追跡する時間を与える必要がある
生煮えのコードに金が集まりすぎている。悲しいが事実だ
こういう問題が多すぎて、結局
LD_PRELOADでgetenv/setenv/putenvにパッチを当てた