はじめに
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で変換しようとしてみます。
puts "HellWorld"
こちらのHelloWorld.rb
を変換してみます。
$ 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
を使います。
$ 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
で変換してみます。
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をロードして実行するやり方です。
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経由)でクラスファイルに変換して下記のコマンドを使います。
$ native-image --verbose --language:ruby --tool:truffle --no-server RubyNative
お、ついに正常終了です! 実行してみます。
$ 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!