GraalVMを使ってJavaのシングルバイナリを作ってみた話

8 min read

先日お上からとあるJavaソースを渡されこういわれました。

「このソースはAをいれたらBを出力するんだけどBを出力する前に内部的にaというフォーマットに変換してるんだ今度そのaのフォーマットで出力するPGが必要になったから改造して欲しい。」

実際は何が言いたいかわからなかったから事情聴取にかなり時間が掛かってますが要約するとこういう内容でした。

そこでやった事を身バレしないように備忘録として書いておこうと思います。

調査開始

そのソースは20に満たないjavaファイルで構成されていました。
恐らくJDK1.4時代。。。
長大なループ文、まさかのラベル付きbrakeやcontinueに苦しめられながら全貌をなんとなく理解した時にわかったのは

これbashで3コマンドで行けるな

なんならワンライナーで書いたほうがスッキリするレベルのコードでした。

ですがそれはしませんというか出来ません。
Javaソースを改造するんだからJavaじゃないといけないのです。
もしココで

「bashで1行っすよ」

とか言おうものならそれをしたことで発生した問題は全て私の責任にされてしまうのです。
ところが元のソースを流用することによって「それは既存です」という今は居なくなった人の責任に出来るという最強の防護服を身に纏えるのです。

本人からすれば防護服ですが日本のIT社会という観点からみると癌の一部ですね。

なのでここでやっていいのは元のソースのロジックを変えずにaからBを出力するロジックを全て消すということなのです。

幸い全貌を理解したのでうまく消せました
なので次はコレを実行可能形式にするというところです。

リリース手段を考える

元々どうやって起動していたのかをコレが実行されているサーバに入ってfindコマンドやgrepコマンドやhistoryコマンドを駆使して探します。
すると。。。

  • javacコマンドでclassファイルを生成するバッチ
  • zipコマンドで圧縮して拡張子をjarにするバッチ
  • javaコマンドでjarを実行するバッチ

の3つのバッチで構成されていることがわかりました。

xxx.sh.oldとか.orgとか.bakとか大量に合って切れそうになりながらdiffしたりしてました

色々突っ込みたい所はありましたが突っ込む相手も居ないのでイライラだけを募らせつつ考えました

今までの経験上これをこのまま流用してしまうと、このやってることは単純なのになぜこんなに大作になっているのかわからないレベルで巨大な糞バッチ群を私が作った物とされてしまう

そこで単独実行出来るようにパッケージングしてしまおうという結論に行き着きました。

幸いあてはあります

こいつです

https://www.graalvm.org/docs/getting-started/container-images/

昔GraalVMとかいう奴つかったらJarで実行したりしてたやつを単独で実行できるバイナリ化出来るって記事を読んだ記憶があります。

直接インストールするのは嫌なのでこのDockerイメージにこの糞ソースが入ったディレクトリをマウントさせてバイナリ化を実行しよう、そしてバイナリファイルの作成方法を手順書にしてリリースするのはバイナリファイルだけにすれば私の責任範囲は最小化されるはずだ。

という事でやっていきます。

実際のソース使うとどっかの提督みたいに炎上しちゃうので適当にググったハローワールドのソースを拾ってきました。

実践

まずはソースの準備と確認

$ ll
合計 39
drwxrwxr-x  2 dev  dev    3  322 17:19 ./
drwxrwxrwt 22 root root  29  322 17:17 ../
-rw-rw-r--  1 dev  dev  117  322 16:52 HelloWorld.java
$ cat HelloWorld.java 
public class HelloWorld{
   public static void main(String[] args){
     System.out.println("Hello World!!");
   }
}

そしてコンテナにマウント

dev@dev-virtual-machine:/tmp/src$ docker run --volume $(pwd):/tmp/src -it --rm ghcr.io/graalvm/graalvm-ce:ol8-java8-21.0.0.2 bash

java8を使っているのはあまりにも最新のJDK使い過ぎたら今度はそこで問題起きそうと思って日和ったせいです。
特に理由がないなら最新版使っていいと思います。

まずはコンテナ内でコンパイル。

bash-4.4# cd /tmp/src
bash-4.4# javac HelloWorld.java
bash-4.4# ls -al
total 12
drwxrwxr-x 2 1000 1000   4 Mar 22 08:19 .
drwxrwxrwt 4 root root   4 Mar 22 08:19 ..
-rw-r--r-- 1 root root 427 Mar 22 08:19 HelloWorld.class
-rw-rw-r-- 1 1000 1000 117 Mar 22 07:52 HelloWorld.java

そしてネイティブ化するためのnative-imageコマンドをインストール

bash-4.4# gu install native-image
Downloading: Component catalog from www.graalvm.org
Processing Component: Native Image
Downloading: Component native-image: Native Image  from github.com
Installing new component: Native Image (org.graalvm.native-image, version 21.0.0.2)
Refreshed alternative links in /usr/bin/

そしてネイティブ化その①

bash-4.4# native-image HelloWorld --no-server -H:Name=app-name
[app-name:129]    classlist:   1,362.06 ms,  1.08 GB
[app-name:129]        (cap):     595.08 ms,  1.08 GB
[app-name:129]        setup:   2,144.71 ms,  1.53 GB
[app-name:129]     (clinit):     154.70 ms,  1.59 GB
[app-name:129]   (typeflow):   3,631.16 ms,  1.59 GB
[app-name:129]    (objects):   2,911.04 ms,  1.59 GB
[app-name:129]   (features):     146.99 ms,  1.59 GB
[app-name:129]     analysis:   6,970.41 ms,  1.59 GB
[app-name:129]     universe:     328.15 ms,  1.59 GB
[app-name:129]      (parse):     729.03 ms,  1.59 GB
[app-name:129]     (inline):     908.34 ms,  1.59 GB
[app-name:129]    (compile):   4,452.39 ms,  1.71 GB
[app-name:129]      compile:   6,415.17 ms,  1.82 GB
[app-name:129]        image:     493.91 ms,  1.82 GB
[app-name:129]        write:     141.88 ms,  1.82 GB
[app-name:129]      [total]:  18,019.78 ms,  1.82 GB

その②

bash-4.4# native-image HelloWorld --no-server --static -H:Name=app-name-static
[app-name-static:243]    classlist:   1,394.16 ms,  1.12 GB
[app-name-static:243]        (cap):     544.53 ms,  1.12 GB
[app-name-static:243]        setup:   2,046.63 ms,  1.52 GB
[app-name-static:243]     (clinit):     133.12 ms,  1.52 GB
[app-name-static:243]   (typeflow):   3,764.40 ms,  1.52 GB
[app-name-static:243]    (objects):   2,882.82 ms,  1.52 GB
[app-name-static:243]   (features):     133.08 ms,  1.52 GB
[app-name-static:243]     analysis:   7,030.02 ms,  1.52 GB
[app-name-static:243]     universe:     365.38 ms,  1.57 GB
[app-name-static:243]      (parse):     727.85 ms,  1.57 GB
[app-name-static:243]     (inline):     842.86 ms,  1.58 GB
[app-name-static:243]    (compile):   4,479.29 ms,  1.76 GB
[app-name-static:243]      compile:   6,368.34 ms,  1.76 GB
[app-name-static:243]        image:     505.39 ms,  1.76 GB
[app-name-static:243]        write:     287.21 ms,  1.76 GB
[app-name-static:243]      [total]:  18,152.77 ms,  1.76 GB

実際には②だけでいいです。
なんで①と②わけたかって説明するの面倒だったので実際に出来上がったバイナリ見比べてもらったほうが早いからです。

で出来上がったバイナリがどう違うかがこちらになります。

bash-4.4# ./app-name
Hello World!!
bash-4.4# ./app-name-static 
Hello World!!

動きは同じですねではこちら

bash-4.4# ldd app-name*
app-name:
        linux-vdso.so.1 (0x00007ffd0399e000)
        libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f97f5ff3000)
        libdl.so.2 => /lib64/libdl.so.2 (0x00007f97f5def000)
        libz.so.1 => /lib64/libz.so.1 (0x00007f97f5bd8000)
        librt.so.1 => /lib64/librt.so.1 (0x00007f97f59d0000)
        libc.so.6 => /lib64/libc.so.6 (0x00007f97f560d000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f97f6213000)
app-name-static:
        not a dynamic executable

そう--static付けると共有ライブラリの依存が無くなります。
その分

bash-4.4# ls -alh app-name*
-rwxr-xr-x 1 root root 4.6M  322 17:20 app-name
-rwxr-xr-x 1 root root 5.9M  322 17:21 app-name-static

容量が増えます。

人によってどっちがいいかはそれぞれなんですが今までの経験上リリース先のOSのアップデートやそもそもディストリ変えられたりとかで共有ライブラリ周りで動かなくなる経験が何度かありましてですね、しかもそのケースが発生するとすんなりライブラリ追加してくれるならまだ助かるんですが影響範囲がわからないとかよくわからない難癖を付けられてアプリ側で直せと言われたりで毎回インフラと揉めるのですよ。
なので私はもう全部入れちゃいます。

この程度のライブラリで何いってんだこいつみたいに思う人もいるかもしれませんので動かない例も紹介しときますね。

今作ったバイナリを一時期もてはやされてたのに今となってはこんなもん使うなみたいな可愛そうな扱いをされてるalpineのコンテナ環境で実行してみましょう。

$ docker run --volume $(pwd):/tmp/src -it --rm alpine ash
/ # ls -al /tmp/src
total 4022
drwxrwxr-x    2 1000     1000             6 Mar 22 08:21 .
drwxrwxrwt    3 root     root             3 Mar 22 08:54 ..
-rw-r--r--    1 root     root           427 Mar 22 08:19 HelloWorld.class
-rw-rw-r--    1 1000     1000           117 Mar 22 07:52 HelloWorld.java
-rwxr-xr-x    1 root     root       4727736 Mar 22 08:20 app-name
-rwxr-xr-x    1 root     root       6121072 Mar 22 08:21 app-name-static
/ # /tmp/src/app-name
ash: /tmp/src/app-name: not found
/ # /tmp/src/app-name-static 
Hello World!!

ほらね?ライブラリがないとかのエラーならまだわかるけど
ash: /tmp/src/app-name: not found
ってなんだよ意味不明だよ。
しかもこの場合もっと意味わからないのが

/tmp/src # ldd ./app-name
        /lib64/ld-linux-x86-64.so.2 (0x7f405615f000)
        libpthread.so.0 => /lib64/ld-linux-x86-64.so.2 (0x7f405615f000)
        libdl.so.2 => /lib64/ld-linux-x86-64.so.2 (0x7f405615f000)
        libz.so.1 => /lib/libz.so.1 (0x7f4056145000)
        librt.so.1 => /lib64/ld-linux-x86-64.so.2 (0x7f405615f000)
        libc.so.6 => /lib64/ld-linux-x86-64.so.2 (0x7f405615f000)

ライブラリはあるんだよね🤣
ハローワールドごときでこの有様だよ。
でもライブラリ内包しといた方はちゃんと動いたでしょ?
自分のなら1byteも無駄にしたくないけど他人のディスク容量なんてどんだけ犠牲にしてでもコンパイル時のライブラリそのまま内包できたほうが絶対いいと思うので私はこれからもstaticを使い続けたいと思います。

という訳で自分への備忘録を兼ねたGraalVMでシングルバイナリを作った時のお話でしたとさ。

おしまい。

ちなみに手順書とソースをまとめたものを納品して解放されましたが後日何度か問い合わせがありました。
もちろん「既存です」防護服を彼らが破れるはずもなく無事に帰還できましたとさ。
めでたしめでたし
とっぴんぱらりのぷう