GrailsでUnix/Linux的実行可能WARファイルをつくる
Spring Boot 1.3から対応した「Fully Executable JAR」という機能について、Grails目線で紹介します。Grailsでは、v3.1.0以降でこのFully Executable JAR/WARが利用できます。
中野靖治のGroovy活用術 第10回
- 2016年12月09日公開
はじめに
だいぶ間があきましたが、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コマンドで実行する
- Dockerを使う場合など、フォアグラウンドでアプリケーションを起動して運用したければこれがよいでしょう。 その場合、以下の2択となりますが、特に大きな違いはないのでどちらでもよいかと思います。 つぶしがきく方向としては、サービスとしての起動する可能性も考えて「Fully Executable JAR/WAR」にしておいて、前者に倒しておくとよいかもしれません。もしくはシンプルさを優先して後者という判断もあります。
おわりに
なお、今回はG* Advent Calendar 2016の9日目の記事も兼ねています。 他の方の記事もぜひぜひご参照ください。

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