STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

RubyKaigi 2024で発表してきました & 発表できなかったこと

はじめに

こんにちは、id:ahogappaです。 ここ最近ずっとRubyスクリプトのワンバイナリ化ついて模索しており、

zenn.dev

zenn.dev

先日、ついにRubyKaigi 2024でこれまでの成果を発表してきました。

https://rubykaigi.org/2024/presentations/ahogappa0613.html#day2

speakerdeck.com

今回は、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#requireKernel#autoloadなどのRubyファイルを読み込むメソッドが呼ばれた際に、ローカルファイルからではなくKompoFSにあるファイルを取得して、実行するという仕組みでした。

しかし、この挙動はrequireautoloadをモンキーパッチして実現しているものであり、 他のファイルを読み込むようなメソッド、File.openIO.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.openIO.openrequireなどと同じようにモンキーパッチして挙動を変えることです。 しかし、Rubyにはたくさんのファイル操作するメソッドがあり、それら全てをモンキーパッチするのは骨が折れます。 また、拡張ライブラリのメソッドの中でファイル操作するようなものがあれば、それらもモンキーパッチする必要があり、これは現実的ではありません。


残された方法としてはopenなどの関数をパッチする方法です。 openの場合、OSが提供しているシステムコールであり、ファイル操作をする際にはほぼ必ず呼ばれます。当然requireだけでなくFile.openIO.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をはじめとした関数をパッチすることで、ワンバイナリ化を実現しています。

github.com

ただし、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の心残りをアウトプットできました。


  1. 読み方は「こんぽう」です。
  2. 私が趣味で開発しているRubyで開発できるゲームエンジン。
  3. Node.jsでもv20からできるようになっているようです。
  4. P44参照。
  5. 発表中は便宜上VFSと呼んでいました。
  6. 例えばFile.statなど。