この記事は、Techouse Advent Calendar 2024 2日目です。
昨日は nissiy による 「エンジニア100人に聞きました」〜Techouse紹介編〜 でした。
二日目は、2024年に新卒で入社し、ジョブハウスでバックエンドエンジニアをしている aki が担当します。
先日、自身の参画しているプロジェクトの Ruby のバージョンが 3.3.5 に更新されました。これに伴い、手元の開発環境にも Ruby 3.3.5 を install しようと思いました。 開発環境では Ruby のバージョン管理マネージャとして rbenv を利用しております。徐ろに rbenv install 3.3.5
を実行すると、
BUILD FAILED (macOS 15.0 on arm64 using ruby-build YYYYMMDD)
... build に失敗してしまいました。同期の環境では再現していないようで、私の開発環境でのみ起きている事象のようです。他の男子には和やかに Installed ruby-3.3.5
のログを返しているというのに... 何故私にだけ。本記事では、その原因究明から解決に至るまでの過程を詳述します。
Build FAILED !
実際に build に失敗した際のログを確認してみます。
$ rbenv install 3.3.5 ruby-build: using openssl@3 from homebrew ==> Downloading ruby-3.3.5.tar.gz... -> curl -q -fL -o ruby-3.3.5.tar.gz https://cache.ruby-lang.org/pub/ruby/3.3/ruby-3.3.5.tar.gz % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 21.1M 100 21.1M 0 0 5454k 0 0:00:03 0:00:03 --:--:-- 5454k ==> Installing ruby-3.3.5... ruby-build: using libyaml from homebrew ruby-build: using gmp from homebrew -> ./configure "--prefix=$HOME/.rbenv/versions/3.3.5" --with-openssl-dir=/opt/homebrew/opt/openssl@3 --enable-shared --with-libyam l-dir=/opt/homebrew/opt/libyaml --with-gmp-dir=/opt/homebrew/opt/gmp --with-ext=openssl,psych,+ -> make -j 8 BUILD FAILED (macOS 15.0 on arm64 using ruby-build YYYYMMDD)
... 標準出力だけでは何が起きているか分かりませんね。ruby-build は ビルドプロセスの詳細なログをファイル出力してくれるので、そちらを確認してみましょう。
... ld: load command #12 string extends beyond end of load command in '/private/var/folders/yd/cqxks2px3fsf5mkh3r2lqnk00000gn/T/ruby-build.20241116210102.37429.3U8rNw/ruby-3.3.5/libruby.3.3.dylib' ... external command failed with status 2
といった具合で、ログを見たところでまあなんだかよくわからないのですが、どうやら ld コマンドがエラーを吐いている事がわかります。また .dylib は macOS における動的ライブラリ(Linux の .so に相当)であり、libruby.3.3.dylib のリンク時にエラーが起きているっぽいですね。
load command #12 string extends beyond end of load command
とありますが、ここで言う「load command」とは、Mach-O (後述します)の実行ファイルやライブラリのヘッダ情報に含まれる構造体のことです。シェルのコマンドのことではありません。
「load command」には以下のような定義がされております。
struct load_command { uint32_t cmd; /* type of load command */ uint32_t cmdsize; /* total size of command in bytes */ };
リンカはこの情報を参照して、実行ファイルやライブラリを読み込んでいる訳ですね。今回のエラーメッセージとファイル名から、リンカ周りでエラーが発生しているか?と当たりをつけ、調査を進めました。
調査開始!
とりあえずエラーメッセージで Google 検索してみると、以下のソースコードが見つかりました。
diag.warning("load command #%d string extends beyond end of load command", index);
まさしくそのままエラーメッセージと一致していますね。dyld
と書いているので、このコードはやはり Mach-O の動的リンクの処理の一部であるようです。
先述しましたが、Mach-O とは Mach マイクロカーネル由来の実行ファイルフォーマットです。 NeXTSTEP という OS の実行ファイルフォーマットとして利用され、その系譜は macOS (Mac OS X)・iOS・iPadOS の標準の実行ファイルフォーマットとして引き継がれています。 Linux 等で使われている ELF と異なり、Universal Binary に対応しており、異なるアーキテクチャのバイナリを 1 つのファイルに含めることができるという魅力があります。
更に、該当箇所の処理を読み込んでみます。
forEachLoadCommand(diag, ^(const load_command* cmd, bool& stop) { ... if ( !foundEnd ) { diag.warning("load command #%d string extends beyond end of load command", index); stop = true; allGood = false; } ... }
どうやら該当箇所は、Mach-O の load command を順に処理しているコードの一部のようです。さらに、
forEachLoadCommand(diag, ^(const load_command* cmd, bool& stop) { ... bool foundEnd = false; const char* start = (char*)dylibCmd + dylibCmd->dylib.name.offset; const char* end = (char*)dylibCmd + cmd->cmdsize; for (const char* s=start; s < end; ++s) { if ( *s == '\0' ) { foundEnd = true; break; } } if ( !foundEnd ) { diag.warning("load command #%d string extends beyond end of load command", index); stop = true; allGood = false; } ... }
と後ろに処理が続きます。offset の位置から cmdsize のバイト数分バイナリを読み込み、その中に終端文字(\0)があるかどうかを確認しているようです。終端文字がない場合に、該当のエラーメッセージを出力している事がわかります。
このことと index の値から、libruby.3.3.dylib の 12 番目の load command になにか問題があるのだろう、と推測出来ます。
libruby.3.3.dylib の解析
さて、最近 Binary Hacks Rebooted を読んでいる私。
バイナリの実行に何か問題があると分かれば、低レイヤミーハーとして覚えたての知識やコマンドをつい使いたくなってしまいます。この後、意気揚々と libruby.3.3.dylib の解析に進んでいきました。
Mach-O バイナリは以下の 3 つの領域に分かれています。
- ヘッダ領域
- load command 領域
- データ領域
まず、ヘッダ領域を確認しました。
$ otool -h libruby.3.3.dylib libruby.3.3.dylib: Mach header magic cputype cpusubtype caps filetype ncmds sizeofcmds flags 0xfeedface 16777228 0 0x00 6 12 1996 0x00900085
ヘッダ情報から、load command の数が 12 であることがわかります。次に、load command 領域を確認しました。
$ otool -l libruby.3.3.dylib libruby.3.3.dylib: Load command 0 cmd LC_SEGMENT cmdsize 56 segname __PAGEZERO vmaddr 0x00000000 vmsize 0x00003000 fileoff 0 filesize 0 maxprot 0x00000000 initprot 0x00000000 nsects 0 flags 0x0 ... Load command 11 cmd LC_LOAD_DYLIB cmdsize 48 name /usr/lib/libobjc.A.dylib (offset 24) time stamp 2 Thu Jan 1 09:00:02 1970 current version 228.0.0 compatibility version 1.0.0
load command の情報が 11 個まで表示されましたが、12 番目の load command の情報が欠落しています。このことから、 ビルドプロセス中に何らかの要因で load command の領域が破損してしまった可能性が高いと考えました。
問題解決…?
しかし、バイナリの破損原因は何なのでしょうか?所詮拝読した付け焼き刃の知識、ミーハーの私にはこれ以上のバイナリ解析は困難でした。己の無知を知り狼狽しながら調査を継続するうちに、以下の Issue にたどり着きました。
この Issue は全く別のプロジェクトのものですが、どうやら同様のリンカのエラーが報告されています。更に、「その原因が GNU binutils の strip コマンドにあり、GNU binutils を uninstall することで問題が解決した(抄訳)」との報告がありました。
GNU binutils とは、GNU プロジェクトが開発しているバイナリを扱うためのツールであり、アセンブラ、リンカ、オブジェクトファイルの操作ツールなどを含みます。Linux では、これらのツールが標準的に利用されています。しかし、macOS のデフォルトではインストールされていません。Homebrew 等で入れる必要があります。
そんなもの自分の開発環境に入っていたかな?と思い確認すると、
$ brew list | grep binutils
binutils
... どうやらいつの間にか GNU binutils が install されていたようです。試しに、GNU binutils を uninstall し、再度 build を試みました。
$ rbenv install 3.3.5 ruby-build: using openssl@3 from homebrew ==> Downloading ruby-3.3.5.tar.gz... -> curl -q -fL -o ruby-3.3.5.tar.gz https://cache.ruby-lang.org/pub/ruby/3.3/ruby-3.3.5.tar.gz % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 21.1M 100 21.1M 0 0 7198k 0 0:00:03 0:00:03 --:--:-- 7201k ==> Installing ruby-3.3.5... ruby-build: using libyaml from homebrew ruby-build: using gmp from homebrew -> ./configure "--prefix=$HOME/.rbenv/versions/3.3.5" --with-openssl-dir=/opt/homebrew/opt/openssl@3 --enable-shared --with-libyam l-dir=/opt/homebrew/opt/libyaml --with-gmp-dir=/opt/homebrew/opt/gmp --with-ext=openssl,psych,+ -> make -j 8 -> make install ==> Installed ruby-3.3.5 to $HOME/.rbenv/versions/3.3.5
あら、build が成功してしまいました!これにて一件落着です。めでたしめでたし。
事の真相
いやいや、何もめでたくありません。なぜ GNU binutils を uninstall するだけで問題が解決したのでしょうか?
strip コマンドの役割
そもそも strip コマンドとは何なのでしょう?何もわからなかったのでとりあえず GPT に聞いてみました。
strip コマンドは、UNIX 系システムで提供される開発者向けツールで、オブジェクトファイルや実行ファイルからシンボル情報を削除するためのコマンドです。この処理により、ファイルサイズを削減し、セキュリティや配布効率を向上させる目的があります。
なるほど、バイナリのシンボル情報を削除して、ファイルサイズを削減するためのコマンドのようです。
シンボルとは、バイナリに含まれる関数名や変数名などの識別子情報です。シンボルは他のオブジェクトファイルやライブラリと結合する際に、関数や変数を参照するために使用されたり、デバッガがソースコードとプログラムを関連付けるために利用されたりします。これらの情報は実行時には必須でないため、バイナリを配布する際には、strip コマンドを使用してシンボル情報を削除し、ファイルサイズを削減することがあります。
推測するに、Ruby のビルドプロセスで strip コマンドが利用され、libruby.3.3.dylib からシンボル情報が削除された結果、load command の領域が破損してしまったのではないかと考えられます。
ビルドログの比較
ということは、build が成功した場合と失敗した場合で、 strip コマンドの挙動に何かしらの差分がある、ということになるでしょう。そこで、ビルド成功時と失敗時のログを比較してみました。
... --- Configuration summary for ruby version 3.3.5 ... + * strip command: strip -A -n - * strip command: strip -S -x ...
上記のように、 strip コマンドのオプションが異なっていることがわかりました。ビルド成功時には -A -n オプションが使用されているのに対し、ビルド失敗時には -S -x オプションが使用されていました。この違いが、libruby.3.3.dylib の load command 領域が破損してしまった原因であると考えられます。
Ruby の configure スクリプト
となると、Ruby のビルドプロセスにおいて、 strip コマンドのオプションをどのように選択しているのかが気になります。ソースコードを調査してみると、configure.ac の以下の箇所で strip コマンドのオプションを検出していることがわかりました。
: "strip" && { AC_MSG_CHECKING([for $STRIP flags]) AC_LINK_IFELSE([AC_LANG_PROGRAM], [AS_IF( ["${STRIP}" -A -n conftest$ac_exeext 2>/dev/null], [ AC_MSG_RESULT([-A -n]) STRIP="${STRIP} -A -n" ], ["${STRIP}" -S -x conftest$ac_exeext 2>/dev/null], [ AC_MSG_RESULT([-S -x]) STRIP="${STRIP} -S -x" ], [ AC_MSG_RESULT([none needed]) ]) ]) }
configure.ac は Autoconf という configure スクリプト自動生成ツールのマクロを駆使して書かれており、この処理は、以下の順序で strip コマンドのオプションを検出します。
- -A -n オプションが使用可能か確認
- 使用不可の場合、-S -x オプションを検出
- それも不可の場合、デフォルトのオプションを使用
なーるほど。となるとなんとなく察しがついてくるわけですな。試しに GNU binutils の strip コマンドを確認してみると、
$ strip --help Usage: gstrip <option(s)> in-file(s) Removes symbols and sections from files The options are: -I --input-target=<bfdname> Assume input file is in format <bfdname> -O --output-target=<bfdname> Create an output file in format <bfdname> -F --target=<bfdname> Set both input and output format to <bfdname> -p --preserve-dates Copy modified/access timestamps to the output -D --enable-deterministic-archives Produce deterministic output when stripping archives (default) -U --disable-deterministic-archives Disable -D behavior -R --remove-section=<name> Also remove section <name> from the output --remove-relocations <name> Remove relocations from section <name> --strip-section-headers Strip section headers from the output -s --strip-all Remove all symbol and relocation information -g -S -d --strip-debug Remove all debugging symbols & sections --strip-dwo Remove all DWO sections --strip-unneeded Remove all symbols not needed by relocations --only-keep-debug Strip everything but the debug information -M --merge-notes Remove redundant entries in note sections (default) --no-merge-notes Do not attempt to remove redundant notes -N --strip-symbol=<name> Do not copy symbol <name> --keep-section=<name> Do not strip section <name> -K --keep-symbol=<name> Do not strip symbol <name> --keep-section-symbols Do not strip section symbols --keep-file-symbols Do not strip file symbol(s) -w --wildcard Permit wildcard in symbol comparison -x --discard-all Remove all non-global symbols -X --discard-locals Remove any compiler-generated symbols -v --verbose List all object files modified -V --version Display this program's version number -h --help Display this output --info List object formats & architectures supported -o <file> Place stripped output into <file> ...
案の定 -A オプションをサポートしていないことがわかります。このことから、Ruby のビルドプロセス上で GNU binutils の strip コマンドが利用されているのだろうと推測できます。
これを確認するにはどうすればよいでしょうか。そうですね、ビルドプロセスで利用されるパスを確認してみましょう。単に echo $PATH
するのも味気ないですから、 build 時のログから確認してみましょう。実は先述した ruby-build のログより詳細なログが、手元で Ruby を build することで取得できます。公式ドキュメントを参照しながら build を行い、生成された config.log ファイルを確認してみます。
... PATH: /opt/homebrew/opt/binutils/bin/ ... PATH: /usr/bin/ ...
やはり /opt/homebrew/opt/binutils/bin/
が /usr/bin/
よりも優先されているため、macOS 標準の strip コマンドではなく GNU binutils の strip コマンドを利用している事がわかりました。
では、何故こんなことになっているかというと、、、
# .zshrc ... # バイナリ解析用に binutils を追加 export PATH="/opt/homebrew/opt/binutils/bin:$PATH"
... はい、完全に私のせいでした。可愛らしいコメントも添えられていますが、単に .zshrc
で GNU binutils のパスを先頭に追加してしまっていたのです。これが原因で GNU binutils の strip コマンドが優先されていたのですね。なんという初歩的な…。
macOS 標準の strip コマンドは -A オプションをサポートしています。具体的にオプションの違いでどのようにバイナリの操作が変容するのかまではデバッグ出来ていないのですが、想定していないオプションで strip が実行された結果、libruby.3.3.dylib の load command 領域が破損してしまったのだと考えられます。
結論
以上の調査から、GNU binutils の strip コマンドが原因で Ruby 3.3.5 のビルドに失敗していたことが判明しました。GNU binutils を uninstall し、システム標準の strip コマンドを使用することで、問題は解決しました。
ビルドプロセスにおいて、システム標準のツールと外部から導入したツールが競合する場合には、細心の注意が必要であることを痛感しました。RubyのビルドプロセスやMach-Oバイナリの仕様についても理解を深めることができ、非常に楽しく調査出来ました。
ソフトウェアをインストールする際、誰しもエラーに遭遇することがあると思いますが、皆さんもぜひ詳細な調査を行ってみてください。思わぬ発見や学びがあるかもしれません!
明日のTechouse Advent Calendar 2024は satoh さんによる Sidekiq Middleware でコンテナイメージの整合性を確認する についてです。
Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。