Spring Boot では Fully Executable Jar と呼ばれるファイルを作ることができる。例えばこの jar ファイルをそのまま /etc/init.d の下に置いておけば、そのまま service の起動スクリプトとして使えるような代物である。これがどう実現されているか、ふと不思議に思ったので調べてみた。

調査

Fully Executable Jar の中身は、bash スクリプトのあとに続けて jar ファイルの中身がぶち込んである不思議なファイルである。イメージ的にはこんな感じ:

$ cat script.sh app.jar >executable.jar

Fully Executable Jar は java コマンドに渡すのではなく実行可能ファイルとしてそのまま実行することを想定している。つまりこのように使う:

$ ./executable.jar

ファイル内のスクリプトでは、自身を java -jar の引数に渡すようなことを行っている。つまり、こうなる:

$ java -jar executable.jar

ここで不思議なのが、zip ファイルとしてはゴミが入ったファイルであるところの executable.jar を、なぜ java コマンドがエラーも吐かずに受け入れてくれるのかという点である。これを調べるために少し実験をしてみた。

まずは実験用のかんたんな Java プログラムを用意する。

$ cat <<EOF >Test.java
heredoc> public class Test {
heredoc>   public static void main(String[] args) {
heredoc>     System.out.println("hello, world!");
heredoc>   }
heredoc> }
heredoc> EOF

これをコンパイルして jar に固める。

$ javac Test.java
$ jar cfe test.jar Test Test.class
$ java -jar test.jar
hello, world!

次に、test.jar を壊れた zip ファイルにしてやる。イメージ的には Spring Boot の Fully Executable Jar よろしく jar ファイルの前にテキストがくっついたものにしてみる。

$ cat Test.java test.jar >broken1.jar
$ java -jar broken1.jar
hello, world!

なんと問題なく動いた。こういうもんなんだろうか。

逆に連結してみるとどうか。

$ cat test.jar Test.java >broken2.jar
$ java -jar broken2.jar
Error: Invalid or corrupt jarfile broken2.jar

これは動かなかった。

ちなみに先頭にゴミがある broken1.jar は、unzip コマンドでも受け入れてくる。

$ unzip -l broken1.jar
Archive:  broken1.jar
warning [broken1.jar]:  110 extra bytes at beginning or within zipfile
  (attempting to process anyway)
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  2017-04-13 23:39   META-INF/
       87  2017-04-13 23:39   META-INF/MANIFEST.MF
      415  2017-04-13 23:39   Test.class
---------                     -------
      502                     3 files

unzip コマンドの実装がどうなっているのか、少し調べてみた。今回は Ubuntu 14.04 のリポジトリで配布されているものを調べた。

出力されているメッセージ

warning [broken1.jar]:  110 extra bytes at beginning or within zipfile
  (attempting to process anyway)

から当たりをつけて探してみる。

process.c:159

   static ZCONST char Far ExtraBytesAtStart[] =
     "warning [%s]:  %s extra byte%s at beginning or within zipfile\n\
  (attempting to process anyway)\n";

この ExtraBytesAtStart を使っている箇所を調べる。

process.c:863

        if ((G.extra_bytes = G.real_ecrec_offset-G.expect_ecrec_offset) <
            (zoff_t)0)
        {
            Info(slide, 0x401, ((char *)slide, LoadFarString(MissingBytes),
              G.zipfn, FmZofft((-G.extra_bytes), NULL, NULL)));
            error_in_archive = PK_ERR;
        } else if (G.extra_bytes > 0) {
            if ((G.ecrec.offset_start_central_directory == 0) &&
                (G.ecrec.size_central_directory != 0))   /* zip 1.5 -go bug */
            {
                Info(slide, 0x401, ((char *)slide,
                  LoadFarString(NullCentDirOffset), G.zipfn));
                G.ecrec.offset_start_central_directory = G.extra_bytes;
                G.extra_bytes = 0;
                error_in_archive = PK_ERR;
            }
#ifndef SFX
            else {
                Info(slide, 0x401, ((char *)slide,
                  LoadFarString(ExtraBytesAtStart), G.zipfn,
                  FmZofft(G.extra_bytes, NULL, NULL),
                  (G.extra_bytes == 1)? "":"s"));
                error_in_archive = PK_WARN;
            }
#endif /* !SFX */
        }

最後のブロックで使っている。 G.extra_bytes > 0 のときに出るようだ。 G.extra_bytes の元ネタを探っていくと、マジックナンバーを検索する関数 rec_find に引っかかった。

process.c:1133

    if ((tail_len = G.ziplen % INBUFSIZ) > rec_size) {
#ifdef USE_STRM_INPUT
        zfseeko(G.zipfd, G.ziplen-tail_len, SEEK_SET);
        G.cur_zipfile_bufstart = zftello(G.zipfd);
#else /* !USE_STRM_INPUT */
        G.cur_zipfile_bufstart = zlseek(G.zipfd, G.ziplen-tail_len, SEEK_SET);
#endif /* ?USE_STRM_INPUT */
        if ((G.incnt = read(G.zipfd, (char *)G.inbuf,
            (unsigned int)tail_len)) != (int)tail_len)
            return 2;      /* it's expedient... */

        /* 'P' must be at least (rec_size+4) bytes from end of zipfile */
        for (G.inptr = G.inbuf+(int)tail_len-(rec_size+4);
             G.inptr >= G.inbuf;
             --G.inptr) {
            if ( (*G.inptr == (uch)0x50) &&         /* ASCII 'P' */
                 !memcmp((char *)G.inptr, signature, 4) ) {
                G.incnt -= (int)(G.inptr - G.inbuf);
                found = TRUE;
                break;
            }
        }

要はファイルの末尾から順繰りマジックナンバーを探している。なのでマジックナンバーより前にあるゴミは無視され、警告を出すだけで済んでいる。java コマンドは調べていないが、動きをみるに、おそらく似たような制御になっているのだろう。

調査結果

Spring Boot では Fully Executable Jar と呼ばれるファイルを作ることができる。(snip) これがどう実現されているか、ふと不思議に思った

bash スクリプトと jar ファイルを連結したファイルが Fully Executable Jar の正体だった。bash スクリプトの部分で java -jar 自分自身 としてプログラムを起動していた。

なぜ java コマンドがエラーも吐かずに受け入れてくれるのか

java コマンドのソースまでは確認していないが、類似の unzip コマンドの実装を調べたところ、マジックナンバーの探し方によっては、マジックナンバーより前のゴミを無視することができる。java コマンドでもおそらくこのような制御があり、マジックナンバーより前のゴミを無視できるので、ゴミ付きの jar を受け入れてくれる。