はじめに
こんにちは、id:ahogappaです。 ここ最近ずっとRubyスクリプトのワンバイナリ化ついて模索しており、
先日、ついにRubyKaigi 2024でこれまでの成果を発表してきました。
https://rubykaigi.org/2024/presentations/ahogappa0613.html#day2
今回は、RubyKaigiで盛り込みきれず発表できなかった内容を、今後の備忘録としてもまとめてみようと思います。
RubyKaigiの発表について
簡単にRubyKaigiで発表内容について紹介しますと、
- Rubyにおいてワンバイナリ化する用途・モチベーション
- 作ったGem(Kompo)の紹介
- ワンバイナリ化する手法
- 今後やっていきたいこと
について発表してきました。
今回私はワンバイナリ化ツールとしてKompoというGemを作りました。1
まだまだ色々整っていないのですが、gem install kompo
からスタートしてワンバイナリ化するデモを発表できるくらいには実装できています。
Kompoのコンセプト
発表中、あまり積極的にアピールできなかったのですが、私の中でKompoのコンセプトとして以下のようなものがあります。
- 簡単にワンバイナリ化できること(Kompo導入からワンバイナリ化までが簡単)
- Ruby本体へパッチを入れないこと(=Ruby本体の変更に依存しないこと)
- 出来上がったバイナリのパフォーマンスを重視すること
- クロスコンパイルできること
これらの根底にはRubyで作ったプログラムを配布するのを簡単にしたい
という思いがあります。私は主にゲーム開発2のためにKompoを開発し始めましたが、開発者にはゲーム開発に集中してもらいため、簡単にワンバイナリ化でき、パフォーマンスもローカルと同じように、あるいはそれ以上になってもらいたいです。
さらに、ゲームはたくさんの人に遊んでもらいたいはず、と考えているので開発者の環境に依存しないように、クロスコンパイルが可能であることもコンセプトに入れています。
Kompo開発について
Kompoの開発について、最初にイメージしたのは(というより元ネタは)、deno compile
です。
Denoは、Node.jsに変わる新たなJS実行環境として作られました。後発ということもあり、Node.jsにはない3面白い機能が度々増えるのですが、JSをワンバイナリ化する機能としてdeno compile
があります。
この機能が内部的にやっていることを解説した記事を見て、「あれ、これRubyでも同じことやったらワンバイナリ化できるんじゃね?」という気持ちになりました。これが最初の動機です。
今でも実装に悩むと、Denoの実装を見に行ったりして参考にしています。
今後やっていきたいこと
スライドの今後やっていきたいこと
4としてあげた以下の三つについて盛りきれなかったことをまとめていきたいと思います。
- 内部ファイルへの自由なアクセス
- クロスコンパイル
- バイナリサイズの削減
内部ファイルへの自由なアクセス
Kompoではシングルバイナリ内にVFS(KompoFS)を保持しており、Kernel#require
やKernel#autoload
などのRubyファイルを読み込むメソッドが呼ばれた際に、ローカルファイルからではなくKompoFSにあるファイルを取得して、実行するという仕組みでした。
しかし、この挙動はrequire
やautoload
をモンキーパッチして実現しているものであり、
他のファイルを読み込むようなメソッド、File.open
やIO.open
などがKompoFSからファイルを読み出すことはできません。これを改善して、ユーザーが自由にアクセスできるようにしたい、というものです。
KompoFSについて
実はKompoFSはちゃんとしたVFSではありません。5 「ちゃんとした」というのは、KompoFSではファイルの絶対パスと中身だけ持っており、メタデータなどを持っていません。
// KompoFSの構造(のイメージ) // ファイルの中身がひたすら入っている配列 char* kompo_files[] = {"p 'hello world!'\0", ...}; // ファイルのパスがひたすら入っている配列 char* kompo_paths[] = {"/workspace/main.rb", "/workspace/lib/hoge.rb", ...};
こんな感じで、配列にファイルの中身とパスの情報が入っており、KompoFSの初期化時に絶対パスとファイルの中身を紐づけて管理することで、require
などで取得したいファイルを返していました。
この方法は実装がすごく簡単な分、メタデータを含んでいないためFileクラス
やIOクラス
にあるようなファイル参照系メソッド6の返却値を取得できません。ということで、KompoFSはちゃんとしたVFSの実装へと変更する必要があります。
ファイルアクセスのパッチ
ユーザーが自由にファイルにアクセスできるようにするためにまず思いつくのは、File.open
やIO.open
もrequire
などと同じようにモンキーパッチして挙動を変えることです。
しかし、Rubyにはたくさんのファイル操作するメソッドがあり、それら全てをモンキーパッチするのは骨が折れます。
また、拡張ライブラリのメソッドの中でファイル操作するようなものがあれば、それらもモンキーパッチする必要があり、これは現実的ではありません。
残された方法としてはopen
などの関数をパッチする方法です。
open
の場合、OSが提供しているシステムコールであり、ファイル操作をする際にはほぼ必ず呼ばれます。当然require
だけでなくFile.open
やIO.open
、拡張ライブラリであっても、最後にはopen
を呼ぶことになるので、ここをパッチすれば、openで読み込む時に対象をローカルにしたり、KompoFSに変更
ということが、どのパターンでも実現できます。
擬似的には以下のようなイメージです。
# 擬似コード(実際にはC言語などで記述する) def open(path) if kompo_fs? KompoFs.get_file(path) else LocalFs.get_file(path) end end
実際、Rubyのワンバイナリ化ツールの一つであるruby-packerではopen
をはじめとした関数をパッチすることで、ワンバイナリ化を実現しています。
ただし、ruby-packerは#define
を使いパッチを当てています。これはコンパイル時に、特定の関数を別の関数へのアクセスへと変更するポピュラーな方法ですが、Ruby本体の変更が必要なため、Kompoのコンセプトに相反します。
また、パッチする別の方法としてはLD_PRELOAD
を使う方法があります。これは実行時にLD_PRELOAD
という環境変数に共有ライブラリを指定すると、実行時にそれを優先して動的リンクしてくれるというものです。
しかし、こちらは共有ライブラリ限定な上、実行時に指定する必要があり、シングルバイナリを作りたいKompoには選択肢にあがりませんでした。
KompoにおいてパッチできるタイミングはRubyがコンパイルされ、出来上がったlibstatic-ruby.a
にあるopen
などを後からパッチできる必要があります。
結論から言えば-Wl,--wrap
オプションを使えば可能そう、というところまで掴んでいます。
このオプションはリンカーオプションであり、以下のようなコードを書くとパッチを当てられます(インターポジショニング)。
// wrap.c int __wrap_open() { // __wrap_から始まる関数は、元の関数呼び出しの代わりに呼び出される // 独自のopenの処理をここに書く printf("オープンしたぞ\n"); return __real_open(); // __real_から始まる関数は、元の関数を呼び出すように読み替えられる }
使う側としては普通にopen
を使うようにします。
// main.c int main(void) { int fd; fd = open("./file1.text", O_RDONLY); ... }
これらをgcc -Wl,--wrap=open main.c wrap.c
としてコンパイル&リンクさせることでopen
が呼ばれるたびにprintf("オープンしたぞ\n");
が実行されることになります。
しかもLD_PRELOAD
のように動的リンク限定ではなく、リンカーの名前解決時にパッチするのでまさにこの問題にうってつけと言えます。
つまり、ローカルの代わりにKompoFSに向けるようにした各_wrap関数とlibstatic-ruby.aを-Wl,--wrap
でコンパイルすることができればパッチできると考えました。
しかし、この方法でも問題があります。それは-Wl,--wrap
オプションがGNU ldの独自実装であること、です。
GNU ld以外のリンカーではオプションが存在しないので、リンカーの種類によらずに実現する必要があります。
これどうするかが、直近取り組んでいることになります。(Kompoリンカー作るしかないか...?となっている)
クロスコンパイル
RubyKaigiで発表した時点では、クロスコンパイルはできませんでした。そして今現在も実装が進んでいるわけではありません。 ただ、Ruby本体はクロスコンパイルが可能ですし、Kompoの周辺ツールやライブラリもRustで記述することで、クロスプラットフォーム対応になるべくできるようにしているので、割といけるんじゃないのかという漠然とした自信があります。
しかし、Windows対応だけはかなり難航するだろうと踏んでいます。この辺の知識はまだ仕入れていないので、一旦見なかったことにしています。
バイナリサイズの削減
最後にバイナリサイズの削減ですが、これはベストエフォートでやろうと思っています。 なので具体的な実装とかは何も進んでいないのですが、KompoFSに入れる際にファイルをgzipで圧縮するはすぐにできそうかなと思っています。
最後に
RubyKaigiの心残りをアウトプットできました。