情報畑でつかまえてロゴ
本サイトは NTTテクノクロスが旬の IT をキーワードに
IT 部門が今知っておきたい最新テクノロジーに関する情報をお届けするサイトです

GrailsでUnix/Linux的実行可能WARファイルをつくる

Spring Boot 1.3から対応した「Fully Executable JAR」という機能について、Grails目線で紹介します。Grailsでは、v3.1.0以降でこのFully Executable JAR/WARが利用できます。

はじめに

だいぶ間があきましたが、NTTソフトウェアの中野です。 ご無沙汰しております。 ちょっと油断していたらあっという間に師走になり、2016年も残り1ヶ月を切ってしまいました。 ちなみに、来年は2017年で「2017」は306番目の素数で、次の素数イヤーは10年後の2027年ですね。

さて、今回は、Spring Boot 1.3から対応した「Fully Executable JAR」という機能について、Grails目線で紹介します。 なお、先に書いてしまうと、Spring Boot 1.3は一年前の2015/12/1にリリースされていますし、Spring Boot 1.3(.1)を含む初のバージョンであるGrails 3.1.0は2016/1/28にリリースされているので、今さら一年前にリリースされた機能についての話かよという向きはありますが、そういうことはここでは気にしません。

とりあえず、 Grailsでは、v3.1.0以降でこのFully Executable JAR/WARが利用できる ということだけ強調しておきます。

従来の「Executable Fat JAR」と新しい「Fully Executable JAR」

Grails 3.xの下回りを担っているSpring Bootでは、1.3以前から「Fat JAR」(またはUber JAR)などと呼ばれる「依存関係にあるJARなどをすべて含んだ1つのJARファイル」が生成できます。元々は拡張子が .jar のJARファイルについての機能ですが、Webアプリケーションに必要なリソースも含めた形式であるWARファイルについても同じ手法でFat化できるので、便宜的にこれを「Fat WAR」(Uber WAR)と呼びます。

依存関係がすべて含まれているだけではなく、Fat JAR/WARでは多くの場合、以下のように java コマンドで簡単に起動できるようにマニフェストが設定されています。「Executable Fat JAR」の「Executable」のところが指す機能性ですね。

$ java -jar build/libs/my-app.war

Tomcatなどのサーブレットコンテナの事前インストールが不要で、Javaさえあればすぐにアプリケーションが起動できるので、これだけでも大変便利なのですが、Spring Boot 1.3では「Fully Executable JAR」としてさらに一段進化しました。

これは、言わば「Unix/Linux的実行可能JAR(WAR)ファイル」とでもいうべき機能で、 JAR(WAR)ファイル自体がUnix/Linux上で実行可能なスクリプトとなっている のです。

後で実例を紹介しますが、このスクリプトは以下の2種類の使い方が可能です。

  • (A) シェルコマンドとして実行する
  • (B) サービスとして実行する

設定してみる

Fully Executable JAR/WARは、デフォルトではOFFになっていますが、設定はとても簡単です。 Gradleの設定ファイルである build.gradle に以下のような設定を追加するだけです。

// in build.gradle
//...
springBoot {
// これによって、WARファイルが「Unix/Linux的な実行可能ファイル」になる。
executable = true
}

war { // 必須ではないが、デフォルトのままだとWARファイル名にバージョン番号なども含まれて // 設定ファイルと対応させづらいので、固定的なファイル名にするとよい。 archiveName = 'my-app.war' }

実行してみる

WARファイルを生成する

まずはWARファイルを生成します。以下のどちらでも構いません。

$ grails package
$ ./gradlew assemble

※実際のところ、サブコマンド(package, assemble等)については色々エイリアスがあるので grails war でも grails assemble でも gradlew bootRepackage でもOKなのですが、とりあえずGrailsコマンドを使う場合とGradleコマンドを使う場合の代表例としては上の2つになります。

(A) シェルコマンドとして実行する

java コマンドを使わずにアプリケーションをフォアグラウンドで起動します。

$ build/libs/my-app.war
Grails application running at http://localhost:8080 in environment: production

(B) サービスとして実行する

Unix/Linuxのサービスとしてアプリケーションを起動します。 /etc/init.d 配下にWARファイルのシンボリックリンクを作るだけで、それがそのままサービス起動用スクリプトとして機能します!

$ sudo ln - s build/libs/my-app.war /etc/init.d/my-app
$ sudo service my-app start

デフォルトでは、標準出力に出力されたログは /var/log/my-app.log に出力されます。

設定をカスタマイズする

詳しくはSpring Boot本家のドキュメントを参照してください。

root以外のユーザでサービスを起動したい

WARファイルの所有者を変更すればその所有者&グループで実行されます。

$ sudo useradd my-app
$ sudo chown my-app:my-app my-app.war
$ sudo service my-app start

JVM起動オプションを指定したい

WARファイルと同じディレクトリに、拡張子を .conf に変えたファイルを配置してその中に設定を記述することで、起動時に読み込まれます。 サービス起動の場合であっても /etc/init.d 配下ではなく、シンボリックリンクされているWARファイル本体と同じディレクトリ&同じベース名であるところがポイントです。

$ ls
my-app.conf my-app.war

$ cat my-app.conf LANG=ja_JP.utf8 JAVA_OPTS=-Xmx1024M LOG_FOLDER=/custom/log/folder

どのような設定が可能かは、Spring Boot本家のドキュメントを参照してください。

未サポートのプラットフォームでも使いたい

デフォルトのスクリプトはCentOSやUbuntuなどのLinuxディストリビューションで動作確認されています。 他のプラットフォームでサービスとして実行させたい場合は、springBoot.embeddedLaunchScript をカスタマイズする必要があるかもしれません。

詳しくはSpring Boot本家のドキュメントを参照してください。

ファイルの中身はどうなっているのか

springBoot.executable = true を設定しないで生成したWARファイルの内容をZIPファイルとして覗いてみると、

$ unzip -l build/libs/my-sample.war
Archive:  build/libs/my-sample.war
Length     Date   Time    Name
--------    ----   ----    ----
0  12-05-16 11:43   META-INF/
226  12-05-16 11:43   META-INF/MANIFEST.MF
0  12-05-16 11:43   WEB-INF/
0  12-05-16 11:43   WEB-INF/classes/
...(snipped)...

となっていますが、一方でspringBoot.executable = true を設定してから生成したWARファイルの場合は、

$ unzip -l build/libs/my-sample.war
Archive:  build/libs/my-sample.war
warning [build/libs/my-sample.war]:  6972 extra bytes at beginning or within zipfile
(attempting to process anyway)
Length     Date   Time    Name
--------    ----   ----    ----
0  12-05-16 11:51   META-INF/
226  12-05-16 11:51   META-INF/MANIFEST.MF
0  12-05-16 11:51   WEB-INF/
0  12-05-16 11:51   WEB-INF/classes/
...(snipped)...

のように、6972バイトの何かがWARファイルの先頭に追加されていることがわかります。

ちょっと長いですが、実際に head コマンドでのぞいてみると、次のようになっています。

$ head -c 6972 build/libs/my-sample.war
==> build/libs/my-sample.war <==
#!/bin/bash
#
#    .   ____          _            __ _ _
#   /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
#  ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
#   \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
#    '  |____| .__|_| |_|_| |_\__, | / / / /
#   =========|_|==============|___/=/_/_/_/
#   :: Spring Boot Startup Script ::
#

### BEGIN INIT INFO # Provides: spring-boot-application # Required-Start: $remote_fs $syslog $network # Required-Stop: $remote_fs $syslog $network # Default-Start: 2 3 4 5 # Default-Stop: 0 1 6 # Short-Description: Spring Boot Application # Description: Spring Boot Application # chkconfig: 2345 99 01 ### END INIT INFO
[[ -n "$DEBUG" ]] && set -x
# Initialize variables that cannot be provided by a .conf file WORKING_DIR="$(pwd)" # shellcheck disable=SC2153 [[ -n "$JARFILE" ]] && jarfile="$JARFILE" [[ -n "$APP_NAME" ]] && identity="$APP_NAME"
# Follow symlinks to find the real jar and detect init.d script cd "$(dirname "$0")" || exit 1 [[ -z "$jarfile" ]] && jarfile=$(pwd)/$(basename "$0") while [[ -L "$jarfile" ]]; do [[ "$jarfile" =~ init\.d ]] && init_script=$(basename "$jarfile") jarfile=$(readlink "$jarfile") cd "$(dirname "$jarfile")" || exit 1 jarfile=$(pwd)/$(basename "$jarfile") done jarfolder="$( (cd "$(dirname "$jarfile")" && pwd -P) )" cd "$WORKING_DIR" || exit 1
# Source any config file configfile="$(basename "${jarfile%.*}.conf")"
# Initialize CONF_FOLDER location defaulting to jarfolder [[ -z "$CONF_FOLDER" ]] && CONF_FOLDER="${jarfolder}"
# shellcheck source=/dev/null [[ -r "${CONF_FOLDER}/${configfile}" ]] && source "${CONF_FOLDER}/${configfile}"
# Initialize PID/LOG locations if they weren't provided by the config file [[ -z "$PID_FOLDER" ]] && PID_FOLDER="/var/run" [[ -z "$LOG_FOLDER" ]] && LOG_FOLDER="/var/log" ! [[ -x "$PID_FOLDER" ]] && PID_FOLDER="/tmp" ! [[ -x "$LOG_FOLDER" ]] && LOG_FOLDER="/tmp"
# Set up defaults [[ -z "$MODE" ]] && MODE="auto" # modes are "auto", "service" or "run" [[ -z "$USE_START_STOP_DAEMON" ]] && USE_START_STOP_DAEMON="true"
# Create an identity for log/pid files if [[ -z "$identity" ]]; then if [[ -n "$init_script" ]]; then identity="${init_script}" else identity=$(basename "${jarfile%.*}")_${jarfolder//\//} fi fi
# Initialize log file name if not provided by the config file [[ -z "$LOG_FILENAME" ]] && LOG_FILENAME="${identity}.log"
# ANSI Colors echoRed() { echo $'\e[0;31m'"$1"$'\e[0m'; } echoGreen() { echo $'\e[0;32m'"$1"$'\e[0m'; } echoYellow() { echo $'\e[0;33m'"$1"$'\e[0m'; }
# Utility functions checkPermissions() { touch "$pid_file" &> /dev/null || { echoRed "Operation not permitted (cannot access pid file)"; return 4; } touch "$log_file" &> /dev/null || { echoRed "Operation not permitted (cannot access log file)"; return 4; } }
isRunning() { ps -p "$1" &> /dev/null }
await_file() { end=$(date +%s) let "end+=10" while [[ ! -s "$1" ]] do now=$(date +%s) if [[ $now -ge $end ]]; then break fi sleep 1 done }
# Determine the script mode action="run" if [[ "$MODE" == "auto" && -n "$init_script" ]] || [[ "$MODE" == "service" ]]; then action="$1" shift fi
# Build the pid and log filenames if [[ "$identity" == "$init_script" ]] || [[ "$identity" == "$APP_NAME" ]]; then PID_FOLDER="$PID_FOLDER/${identity}" pid_subfolder=$PID_FOLDER fi pid_file="$PID_FOLDER/${identity}.pid" log_file="$LOG_FOLDER/$LOG_FILENAME"
# Determine the user to run as if we are root # shellcheck disable=SC2012 [[ $(id -u) == "0" ]] && run_user=$(ls -ld "$jarfile" | awk '{print $3}')
# Find Java if [[ -n "$JAVA_HOME" ]] && [[ -x "$JAVA_HOME/bin/java" ]]; then javaexe="$JAVA_HOME/bin/java" elif type -p java > /dev/null 2>&1; then javaexe=$(type -p java) elif [[ -x "/usr/bin/java" ]]; then javaexe="/usr/bin/java" else echo "Unable to find Java" exit 1 fi
arguments=(-Dsun.misc.URLClassPath.disableJarChecking=true $JAVA_OPTS -jar $jarfile $RUN_ARGS "$@")
# Action functions start() { if [[ -f "$pid_file" ]]; then pid=$(cat "$pid_file") isRunning "$pid" && { echoYellow "Already running [$pid]"; return 0; } fi do_start "$@" }
do_start() { working_dir=$(dirname "$jarfile") pushd "$working_dir" > /dev/null mkdir "$PID_FOLDER" &> /dev/null if [[ -n "$run_user" ]]; then checkPermissions || return $? if [[ -z "$pid_subfolder" ]]; then chown "$run_user" "$pid_subfolder" fi chown "$run_user" "$pid_file" chown "$run_user" "$log_file" if [ $USE_START_STOP_DAEMON = true ] && type start-stop-daemon > /dev/null 2>&1; then start-stop-daemon --start --quiet \ --chuid "$run_user" \ --name "$identity" \ --make-pidfile --pidfile "$pid_file" \ --background --no-close \ --startas "$javaexe" \ --chdir "$working_dir" \ -- "${arguments[@]}" \ >> "$log_file" 2>&1 await_file "$pid_file" else su -s /bin/sh -c "$javaexe $(printf "\"%s\" " "${arguments[@]}") >> \"$log_file\" 2>&1 & echo \$!" "$run_user" > "$pid_file" fi pid=$(cat "$pid_file") else checkPermissions || return $? "$javaexe" "${arguments[@]}" >> "$log_file" 2>&1 & pid=$! disown $pid echo "$pid" > "$pid_file" fi [[ -z $pid ]] && { echoRed "Failed to start"; return 1; } echoGreen "Started [$pid]" }
stop() { [[ -f $pid_file ]] || { echoYellow "Not running (pidfile not found)"; return 0; } pid=$(cat "$pid_file") isRunning "$pid" || { echoYellow "Not running (process ${pid}). Removing stale pid file."; rm -f "$pid_file"; return 0; } do_stop "$pid" "$pid_file" }
do_stop() { kill "$1" &> /dev/null || { echoRed "Unable to kill process $1"; return 1; } for i in $(seq 1 60); do isRunning "$1" || { echoGreen "Stopped [$1]"; rm -f "$2"; return 0; } [[ $i -eq 30 ]] && kill "$1" &> /dev/null sleep 1 done echoRed "Unable to kill process $1"; return 1; }
restart() { stop && start }
force_reload() { [[ -f $pid_file ]] || { echoRed "Not running (pidfile not found)"; return 7; } pid=$(cat "$pid_file") rm -f "$pid_file" isRunning "$pid" || { echoRed "Not running (process ${pid} not found)"; return 7; } do_stop "$pid" "$pid_file" do_start }
status() { [[ -f "$pid_file" ]] || { echoRed "Not running"; return 3; } pid=$(cat "$pid_file") isRunning "$pid" || { echoRed "Not running (process ${pid} not found)"; return 1; } echoGreen "Running [$pid]" return 0 }
run() { pushd "$(dirname "$jarfile")" > /dev/null "$javaexe" "${arguments[@]}" result=$? popd > /dev/null return "$result" }
# Call the appropriate action function case "$action" in start) start "$@"; exit $?;; stop) stop "$@"; exit $?;; restart) restart "$@"; exit $?;; force-reload) force_reload "$@"; exit $?;; status) status "$@"; exit $?;; run) run "$@"; exit $?;; *) echo "Usage: $0 {start|stop|restart|force-reload|status|run}"; exit 1; esac
exit 0

起動スクリプトがまるまる先頭に付与されていることがわかります。

なお、ZIPデータ部との境界をみてみると、

$ head -c 6978 build/libs/my-sample.war | od -ax
...(snipped)...
0015460    s   a   c  nl  nl   e   x   i   t  sp   0  nl   P   K etx eot
6173    0a63    650a    6978    2074    0a30    4b50    0403
0015500  dc4 nul
0014

と、 exit 0 の後にバイトコードで 0x504b0304 と並んでいることがわかります。 これはZIPファイルフォーマットの「Local File Header Signature」で、ここからが実際のZIPデータの始まりになります。 (参考: https://ja.wikipedia.org/wiki/ZIP_(%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%83%E3%83%88))

このようにZIPファイルの先頭には任意のデータを追加できるようになっていて、自己解凍形式のZIPファイルなどで利用されているようですが、こんな風にUnix/Linuxサービスとしてのシェルスクリプトを仕込むというのはいったい誰が考えたんでしょうかね。すごい発想です。

Grails 3.x時代のアプリケーションデプロイ考

これらの機能を踏まえた上で、Grails 3.xで実装したアプリケーションを実際にどのようにデプロイするか、ですがだいたい以下のパターンのいずれかを選ぶことになると思います。

  • (X) 別途インストールしたTomcatにデプロイする
    • すでに運用形態が固まっていてノウハウも十分あるのであれば、従来通りにデプロイしてもよいでしょう。
  • (Y) サービスとして起動する
    • Unix/Linuxサーバ上でサービス(デーモン)として起動する場合は、「Fully Executable JAR/WAR」機能を活用するとよいでしょう。
  • (Z) フォアグラウンドプロセスとして起動する
    • Dockerを使う場合など、フォアグラウンドでアプリケーションを起動して運用したければこれがよいでしょう。 その場合、以下の2択となりますが、特に大きな違いはないのでどちらでもよいかと思います。 つぶしがきく方向としては、サービスとしての起動する可能性も考えて「Fully Executable JAR/WAR」にしておいて、前者に倒しておくとよいかもしれません。もしくはシンプルさを優先して後者という判断もあります。
      • (a) シェルコマンドとして直接WARファイルを実行する
      • (b) 明示的にjavaコマンドで実行する

おわりに

なお、今回はG* Advent Calendar 2016の9日目の記事も兼ねています。 他の方の記事もぜひぜひご参照ください。

連載シリーズ
中野靖治のGroovy活用術
著者プロフィール
中野 靖治
中野 靖治
JVM上で動作する動的型付け言語であるGroovyと、Groovyで記述するWebアプリケーションフレームワークのGrailsを社内外へ推進するために日々奮闘している。 Groovyスクリプトの起動時間を短縮するGroovyServや、GroovyスクリプトでのExcel操作を劇的に楽にするGExcelAPIなどのOSSを業務/プライベートで開発、 一般に公開。Groovy/Grails/GradleなどのOSSへのバグフィックスや機能パッチの提供などにも積極的に貢献している。 また、国内外(JJUG CCC、Java Day Tokyo, Gr8conf EUなど)での講演や書籍執筆などでも活動中。 著書に『プログラミングGroovy』(技術評論社/共著)がある。自他共に認めるビール党。