GraalVMでRubyはシングルバイナリの夢を見るのか？

はじめに
--------

Rubyはとっても好きなので簡単なサーバ上の管理ツールとしてもよく使うのですが、そんな時の問題点は

- Rubyがどこにでも入ってるとは限らない
- 起動時のオーバーヘッドがある

と言うものがあります。
そのためもあってかシングルバイナリにする方法は結構昔から色々試されてるけど、決定版はまだない印象。mruby-cliとかがここ最近だと一番有力？
個人的にはJRubyでシングルJARにしてしまうのが取り回しは一番楽なのですが、オーバーヘッドが大きくてコマンドとかだとちょっと微妙と言うのはあります。

そこで、いつぞやのRuby会議で爆速のベンチマークを叩き出してみんなを驚愕させたTruffleRuby、正確にはその大元であるGrralVMのエコシステムのnative-imageを使ってLinux向けのシングルバイナリを作ってみる事にしました。

GraalVMって？ native-imageって？
-----------------

Ruby界隈では多分あまり有名ではないので、色々端折って簡単に説明しておくと次世代JVMの名前がGraalVMです。
単にJavaだけではなく、GraalVMの上に乗せたTruffleと言うレイヤーを使ってRubyを含むスクリプト言語をJava並みに最適化効かせた状態で実行する基盤としても作られています。
https://www.graalvm.org/docs/

色々特徴はあるのですが、今回関連するポイントとしてはGraalVMにはJavaをAOTコンパイルに加えてネイティブイメージとして実行するためのnative-imageと言う仕組みがあります。
こいつを使えばfootprintの悪い事で有名なJavaもgoとかC並みの起動速度になるって寸法です。これを今回はRubyでも適用してみます。

JRubyによるAOTを試してみる
----------------

先に書きますがこの方法では現在は動きません。なので読み飛ばしても問題ないです。

と言うわけでまずはシンプルにJRubyのAOT機能を使ってクラスファイルを作り、それをnative-imageで変換しようとしてみます。

```ruby
puts "HellWorld"
```

こちらの`HelloWorld.rb`を変換してみます。

```bash
$ jrubyc HelloWorld.rb
$ native-image -cp jruby.jar:. HelloWorld
Build on Server(pid: 12, port: 26681)
   classlist:   6,942.97 ms
error: Main entry point class 'HelloWorld' not found.
```

どうやらクラスパスの通し方がダメっぽいです。と言う訳で別の方法を試します。

jar-bootstrap.rbを試してみる
--------------------

またまた最初に書きますがこの方法では現在は動きません。なので読み飛ばしても問題ないです。

native-imageは一般的にはfat-jarと呼ばれる単体で起動するJARファイルに変換して実施するのが普通です。
なので、今回もその方法を試してみます。JRubyでの実行可能JARの作り方で一般的な`jar-bootstrap.rb`を使います。

```bash
$ cp jruby.jar ./myapp.jar               
$ cp HelloWorld.rb jar-bootstrap.rb
$ jar ufe myapp.jar org.jruby.JarBootstrapMain jar-bootstrap.rb 
$ java -jar myapp.jar 
```

これで実行可能JARができました。これを`native-image`で変換してみます。

```bash
root@45888fcfb357:/tmp# native-image -jar myapp.jar 
Build on Server(pid: 12, port: 26681)
   classlist:   7,985.42 ms
       (cap):   2,400.36 ms
       setup:   3,070.57 ms
RecomputeFieldValue.FieldOffset automatic substitution failed. The automatic substitution registration was attempted because a call to sun.misc.Unsafe.objectFieldOffset(Field) was detected in the static initializer of org.jruby.RubyBasicObject. Add a RecomputeFieldValue.FieldOffset manual substitution for org.jruby.RubyBasicObject. 
RecomputeFieldValue.FieldOffset automatic substitution failed. The automatic substitution registration was attempted because a call to sun.misc.Unsafe.objectFieldOffset(Field) was detected in the static initializer of org.jruby.RubyBasicObject. Add a RecomputeFieldValue.FieldOffset manual substitution for org.jruby.RubyBasicObject. 
RecomputeFieldValue.ArrayBaseOffset automatic substitution failed. The automatic substitution registration was attempted because a call to sun.misc.Unsafe.arrayBaseOffset(Class) was detected in the static initializer of org.jruby.util.StringSupport. Add a RecomputeFieldValue.ArrayBaseOffset manual substitution for org.jruby.util.StringSupport. 
RecomputeFieldValue.ArrayBaseOffset automatic substitution failed. The automatic substitution registration was attempted because a call to sun.misc.Unsafe.arrayBaseOffset(Class) was detected in the static initializer of org.jruby.util.SipHashInline$LongReader. Add a RecomputeFieldValue.ArrayBaseOffset manual substitution for org.jruby.util.SipHashInline$LongReader. 
    analysis:  16,356.29 ms
fatal error: java.lang.NullPointerException
	at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
	at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
	at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
	at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
	at java.util.concurrent.ForkJoinTask.getThrowableException(ForkJoinTask.java:598)
	at java.util.concurrent.ForkJoinTask.get(ForkJoinTask.java:1005)
	at com.oracle.svm.hosted.NativeImageGenerator.run(NativeImageGenerator.java:398)
	at com.oracle.svm.hosted.NativeImageGeneratorRunner.buildImage(NativeImageGeneratorRunner.java:240)
	at com.oracle.svm.hosted.NativeImageGeneratorRunner.build(NativeImageGeneratorRunner.java:337)
	at com.oracle.svm.hosted.server.NativeImageBuildServer.executeCompilation(NativeImageBuildServer.java:378)
	at com.oracle.svm.hosted.server.NativeImageBuildServer.lambda$processCommand$8(NativeImageBuildServer.java:315)
	at com.oracle.svm.hosted.server.NativeImageBuildServer.withJVMContext(NativeImageBuildServer.java:396)
	at com.oracle.svm.hosted.server.NativeImageBuildServer.processCommand(NativeImageBuildServer.java:312)
	at com.oracle.svm.hosted.server.NativeImageBuildServer.processRequest(NativeImageBuildServer.java:256)
	at com.oracle.svm.hosted.server.NativeImageBuildServer.lambda$serve$7(NativeImageBuildServer.java:216)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)
Caused by: java.lang.NullPointerException
	at com.oracle.svm.hosted.ameta.AnalysisConstantReflectionProvider.readFieldValue(AnalysisConstantReflectionProvider.java:70)
	at com.oracle.graal.pointsto.ObjectScanner.scanField(ObjectScanner.java:111)
	at com.oracle.graal.pointsto.ObjectScanner.doScan(ObjectScanner.java:263)
	at com.oracle.graal.pointsto.ObjectScanner.finish(ObjectScanner.java:307)
	at com.oracle.graal.pointsto.ObjectScanner.scanBootImageHeapRoots(ObjectScanner.java:78)
	at com.oracle.graal.pointsto.ObjectScanner.scanBootImageHeapRoots(ObjectScanner.java:60)
	at com.oracle.graal.pointsto.BigBang.checkObjectGraph(BigBang.java:581)
	at com.oracle.graal.pointsto.BigBang.finish(BigBang.java:552)
	at com.oracle.svm.hosted.NativeImageGenerator.doRun(NativeImageGenerator.java:653)
	at com.oracle.svm.hosted.NativeImageGenerator.lambda$run$0(NativeImageGenerator.java:381)
	at java.util.concurrent.ForkJoinTask$AdaptedRunnableAction.exec(ForkJoinTask.java:1386)
	at java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:289)
	at java.util.concurrent.ForkJoinPool$WorkQueue.runTask(ForkJoinPool.java:1056)
	at java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1692)
	at java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:157)
Error: Processing image build request failed
```

失敗しました！ 現状のnative-imageではリフレクション周りはそのままでは動かないとか制約があるので、その辺に引っかかったのでしょう。
どうやらこの方法も一筋縄では行かないようです。

Polyglotを試してみる
-------------

最終手段としてPolyglotを試してみます。これはJava側でRubyをロードして実行するやり方です。

```java
import org.graalvm.polyglot.*;

public class RubyNative {
    public static void main(String[] args) throws PolyglotException {
        try (Context context = Context.newBuilder().allowAllAccess(true).build()) {
            context.eval("ruby", "puts 'Hello, Native!'");
        }
    }
}
```

これを一旦javac(私はmvn経由)でクラスファイルに変換して下記のコマンドを使います。

```bash
$ native-image --verbose --language:ruby --tool:truffle --no-server RubyNative
```

お、ついに正常終了です！ 実行してみます。

```bash
$ time ./rubynative
[ruby] WARNING could not determine TruffleRuby's home - the standard library will not be available
[ruby] WARNING could not determine TruffleRuby's home - the standard library will not be available
Hello, Native!

real	0m1.008s
user	0m0.150s
sys	0m0.170s
```

おー、無事に終了しましたね。
ただ、ちゃんと計測はしてないのですがRubyコードはビルドされてないので原理的にさほどフットプリントは軽くないかと思います。TruffleRubyで動くので実行速度は十分速いと思いますけど。

加えて実はこのビルドはCPUを死ぬほど使いましてDocker on Macと言う性能問題のある環境とはいえ、たったこれだけのコードで20分くらいかかります。。。
そしてモジュールサイズも152MBあるし。。。

これも悪くないのですが最適とはいえないですね。

まとめ
---------

Graalのnative-imageを使ってRubyをgolangのようなシングルバイナリにできるか？　と言う試みでした。
単純にはできなかったのですがPolyglot経由ならなんとか動くことがわかりました。ちょっとどころじゃなくビルド時間が長いのが問題ですが、Graal自体も進化中なのでこの辺は今後の改善に期待ですかねぇ。

それでは来年もHappy Hacking!

参考
-------

- [Getting Started GraalVM / GraalVM超入門](https://www.slideshare.net/tamrin69/getting-started-graalvm)
- [Embed Languages with the Graal Polyglot API](http://www.graalvm.org/docs/graalvm-as-a-platform/embed/)
- [Great Ideas in GraalVM](https://www.slideshare.net/jyukutyo/great-idea-in-graalvm)