Spring BootアプリケーションのDockerイメージを作成する際、Dockerfileに次のような処理を記述することがあります。
RUN ./gradlew clean bootJar --no-daemon \
&& JAR_FILE=$(find build/libs -maxdepth 1 -type f \
-name "*.jar" ! -name "*-plain.jar" | head -n 1) \
&& cp "$JAR_FILE" app.jar
一見すると複雑ですが、この処理が行っていることは大きく分けて次の3つです。
- Spring BootアプリケーションをJARファイルとしてビルドする
- 生成された実行可能JARを探す
- 見つけたJARを
app.jarという固定名でコピーする
この記事では、コマンドとオプションを一つずつ分解して解説します。
厳密には、この
RUN命令だけでアプリケーションをDockerへ「デプロイ」しているわけではありません。Dockerイメージのビルド中に、Spring Bootアプリケーションを実行可能JARとして生成・配置する処理です。
コマンド全体の流れ
まず、改行を取り除いて1行にすると次のようになります。
RUN ./gradlew clean bootJar --no-daemon && JAR_FILE=$(find build/libs -maxdepth 1 -type f -name "*.jar" ! -name "*-plain.jar" | head -n 1) && cp "$JAR_FILE" app.jar
処理の流れを日本語にすると、次のようになります。
Gradleで実行可能JARを作成する
↓ 成功した場合
生成されたJARファイルを検索してJAR_FILE変数に設定する
↓ 成功した場合
見つけたJARファイルをapp.jarとしてコピーする
RUN
RUN ...
RUNは、Dockerイメージのビルド中にコマンドを実行するためのDockerfile命令です。
この例はシェル形式のRUNであり、Linux系のイメージでは通常、シェルを通してコマンドが実行されます。そのため、次のようなシェルの機能を利用できます。
-
&&によるコマンドの連結 -
$(...)によるコマンド置換 -
$JAR_FILEによる変数展開 -
|によるパイプ処理
RUNで実行された結果は、Dockerイメージの新しいレイヤーとして保存されます。
行末の\
RUN ./gradlew clean bootJar --no-daemon \
&& JAR_FILE=$(...) \
&& cp "$JAR_FILE" app.jar
行末の\は、命令が次の行へ続いていることを表します。
実際には1つのRUN命令ですが、長いコマンドを複数行に分けることで読みやすくしています。
次の2つは、基本的に同じ処理です。
RUN command1 && command2 && command3
RUN command1 \
&& command2 \
&& command3
\の後ろに空白を入れると、正しく行継続されないことがあります。そのため、\は行末の最後の文字として記述します。
./gradlew
./gradlew
gradlewは、Gradle Wrapperを実行するためのスクリプトです。
システムにインストールされているgradleコマンドを直接使用するのではなく、プロジェクトで指定されたバージョンのGradleを使用します。
Gradle公式ドキュメントでも、Gradle Wrapperを使用することが推奨されています。
先頭の./は、現在のディレクトリにあるgradlewを実行するという意味です。
Dockerfileで実行するためには、事前にgradlewなどをイメージ内へコピーしておく必要があります。
COPY gradlew .
COPY gradle gradle
COPY build.gradle .
COPY settings.gradle .
COPY src src
また、Linux上で実行権限が必要な場合は、次の処理も必要です。
RUN chmod +x gradlew
clean
./gradlew clean
cleanは、以前のビルドで生成されたファイルを削除するGradleタスクです。
通常は、プロジェクトのbuildディレクトリが削除されます。
例えば、以前のビルドで次のファイルが残っていたとします。
build/libs/sample-0.0.1-SNAPSHOT.jar
build/libs/sample-0.0.2-SNAPSHOT.jar
この状態でJARファイルを検索すると、古いJARを誤って取得する可能性があります。
最初にcleanを実行することで、古いビルド結果を削除してから新しいJARを作成できます。
ただし、cleanを実行するとGradleの差分ビルドによる恩恵を受けにくくなります。Dockerビルドでは毎回確実に成果物を作り直したい場合に使われますが、常に必須というわけではありません。
bootJar
./gradlew bootJar
bootJarは、Spring Bootアプリケーションを実行可能JARとしてパッケージングするタスクです。
Spring BootのGradle Pluginによって追加されます。
通常のJARとは異なり、bootJarで生成されるJARには、アプリケーションのクラスだけでなく実行に必要な依存ライブラリなども含まれます。
そのため、生成されたJARは次のように実行できます。
java -jar app.jar
Spring Boot公式ドキュメントでは、bootJarは実行可能JARを作成するタスクとして説明されています。アプリケーションのクラスはBOOT-INF/classes、依存ライブラリはBOOT-INF/libへ格納されます。
JARファイルは、通常、次のディレクトリに生成されます。
build/libs/
なお、bootJarタスクはJARの作成を目的としたタスクです。一般的なbuildタスクとは異なり、通常はテストの実行までは行いません。
テストも含めてビルドしたい場合は、プロジェクトの設定に応じて、事前に次のような処理を実行します。
./gradlew clean test bootJar
または、通常のビルド処理として次を使用します。
./gradlew clean build
--no-daemon
./gradlew clean bootJar --no-daemon
--no-daemonは、Gradle Daemonを継続利用せずにビルドするためのオプションです。
Gradle Daemonは、Gradleの実行に必要なJVMプロセスをバックグラウンドで動作させ、次回以降のビルドを高速化する仕組みです。現在のGradleではDaemonの使用がデフォルトになっています。
ローカル開発ではビルド時間を短縮できるため便利ですが、Dockerイメージのビルドではビルド処理が終わると実行環境も破棄されます。
そのため、バックグラウンドプロセスを継続させる利点が小さく、次のように--no-daemonを付けることがあります。
./gradlew bootJar --no-daemon
ただし、--no-daemonを付けた場合でも、Gradleの実行条件によってはビルド専用の一時的なプロセスが使用される場合があります。
&&
command1 && command2
&&は、左側のコマンドが成功した場合だけ、右側のコマンドを実行する演算子です。
今回のコマンドでは次のように連結されています。
./gradlew clean bootJar --no-daemon \
&& JAR_FILE=$(...) \
&& cp "$JAR_FILE" app.jar
例えば、Gradleのビルドが失敗した場合は、JARファイルの検索やコピーは実行されません。
Gradleビルド成功
→ JAR検索を実行
Gradleビルド失敗
→ 以降の処理を実行しない
Dockerイメージのビルドも、その時点で失敗します。
複数のRUN命令に分けず、&&で連結することで、関連する処理をひとまとまりとして扱えます。
JAR_FILE=$(...)
JAR_FILE=$(find ...)
これは、コマンドの実行結果をJAR_FILEというシェル変数へ代入しています。
$(...)はコマンド置換と呼ばれる構文です。
括弧内のコマンドを実行し、その標準出力を文字列として取得します。
簡単な例を挙げると、次のコマンドではpwdの実行結果がCURRENT_DIRへ代入されます。
CURRENT_DIR=$(pwd)
今回の場合は、findコマンドで検索したJARファイルのパスが代入されます。
JAR_FILE=$(find build/libs ...)
例えば検索結果が次のパスだった場合、
build/libs/sample-0.0.1-SNAPSHOT.jar
変数は次のような状態になります。
JAR_FILE="build/libs/sample-0.0.1-SNAPSHOT.jar"
find build/libs
find build/libs
findは、指定したディレクトリ以下から条件に一致するファイルやディレクトリを検索するコマンドです。
ここでは、build/libsを検索開始地点にしています。
build/libs
├── sample-0.0.1-SNAPSHOT.jar
└── sample-0.0.1-SNAPSHOT-plain.jar
この中から、Dockerコンテナで実行するJARを探します。
-maxdepth 1
find build/libs -maxdepth 1
-maxdepthは、検索するディレクトリの深さを制限するオプションです。
-maxdepth 1
とした場合、検索対象はbuild/libs自身と、その直下までになります。
例えば次の構成の場合、
build/libs/
├── sample.jar
└── backup/
└── old-sample.jar
-maxdepth 1を指定すると、backupの中にあるold-sample.jarまでは検索しません。
GNU Findutilsのドキュメントでは、-maxdepthは検索開始地点から降りる階層数の上限を指定するものと説明されています。
今回生成されるJARは通常build/libsの直下にあるため、それより深いディレクトリを検索する必要はありません。
なお、-maxdepthはGNU系のfindなどで利用されるオプションです。使用するベースイメージやOSによって、搭載されているfindの実装が異なる可能性には注意が必要です。
-type f
-type f
-typeは、検索対象の種類を指定する条件です。
fは通常のファイルを表します。
-type f
とすることで、ディレクトリではなくファイルだけを検索対象にします。
似た指定として、ディレクトリを検索する場合はdを使います。
-type d
-name "*.jar"
-name "*.jar"
-nameは、ファイル名のパターンを指定する条件です。
-name "*.jar"
は、名前が.jarで終わるファイルを検索します。
例えば次のファイルが対象になります。
sample.jar
sample-0.0.1.jar
sample-0.0.1-SNAPSHOT.jar
*.jarをダブルクォートで囲んでいる点も重要です。
-name "*.jar"
クォートしなかった場合、findが実行される前に、シェルが*を展開してしまう可能性があります。
パターンをそのままfindへ渡すため、クォートで囲んでいます。
! -name "*-plain.jar"
! -name "*-plain.jar"
!は、直後の検索条件を否定します。
-name "*-plain.jar"
が「名前が-plain.jarで終わるファイル」を表すため、
! -name "*-plain.jar"
は「名前が-plain.jarで終わらないファイル」という意味になります。
Spring BootのGradle Pluginでは、実行可能JARと通常のJARを区別するため、通常のjarタスクによる成果物にplainというclassifierが設定されます。
例えば、次の2つのJARが生成される場合があります。
sample-0.0.1-SNAPSHOT.jar
sample-0.0.1-SNAPSHOT-plain.jar
それぞれの役割は、おおむね次のようになります。
sample-0.0.1-SNAPSHOT.jar
Spring Bootの実行可能JAR
sample-0.0.1-SNAPSHOT-plain.jar
通常のJAR
Dockerコンテナでは一般的に、java -jarで起動できるSpring Bootの実行可能JARを使用します。
そのため、次の条件によって-plain.jarを除外しています。
! -name "*-plain.jar"
なお、今回のようにclean bootJarだけを実行する一般的な構成では、実行可能JARだけが生成される場合もあります。この除外条件は、複数のJARが存在する場合に備えた防御的な指定と考えられます。
|
find ... | head -n 1
|はパイプと呼ばれる機能です。
左側のコマンドの標準出力を、右側のコマンドの標準入力へ渡します。
今回の場合、findが出力した検索結果をheadへ渡しています。
findによる検索結果
↓
headコマンド
例えばfindが次のような結果を出力したとします。
build/libs/sample.jar
build/libs/another.jar
この2行がheadコマンドへ渡されます。
head -n 1
head -n 1
headは、入力されたデータの先頭部分を取得するコマンドです。
-nは、取得する行数を指定するオプションです。
head -n 1
は、先頭の1行だけを取得します。
GNU Coreutilsのheadは、ファイルが指定されていない場合、標準入力からデータを読み取ります。
したがって、次の処理では、
find build/libs ... | head -n 1
findで見つかったJARファイルのうち、先頭の1件だけを取得します。
その結果がJAR_FILEへ代入されます。
JAR_FILE=$(find build/libs ... | head -n 1)
cp "$JAR_FILE" app.jar
cp "$JAR_FILE" app.jar
cpは、ファイルをコピーするコマンドです。
この処理では、JAR_FILE変数に格納されたJARファイルを、app.jarという名前でコピーしています。
例えば変数の内容が次の場合、
JAR_FILE="build/libs/sample-0.0.1-SNAPSHOT.jar"
実際には次のような処理になります。
cp build/libs/sample-0.0.1-SNAPSHOT.jar app.jar
コピー後は、次のようになります。
app.jar
build/
└── libs/
└── sample-0.0.1-SNAPSHOT.jar
なぜapp.jarに名前を統一するのか
Gradleで生成されるJARファイル名には、プロジェクト名やバージョンが含まれることがあります。
sample-0.0.1-SNAPSHOT.jar
sample-0.0.2-SNAPSHOT.jar
sample-1.0.0.jar
そのままDockerfileに記述すると、バージョンを変更するたびに起動コマンドも修正しなければなりません。
ENTRYPOINT ["java", "-jar", "sample-0.0.1-SNAPSHOT.jar"]
JARファイルをapp.jarという固定名にしておけば、元のファイル名やバージョンが変わっても、起動コマンドを変更する必要がありません。
ENTRYPOINT ["java", "-jar", "app.jar"]
ダブルクォートを付ける理由
cp "$JAR_FILE" app.jar
変数をダブルクォートで囲んでいるのは、ファイルパスに空白などが含まれていても、1つの引数として扱えるようにするためです。
例えば、変数に次の値が入っていたとします。
build/libs/sample application.jar
クォートしない場合、
cp $JAR_FILE app.jar
シェルからは次のように複数の引数として解釈される可能性があります。
build/libs/sample
application.jar
app.jar
クォートしておけば、変数全体が1つのファイルパスとして扱われます。
cp "$JAR_FILE" app.jar
シェル変数をファイルパスとして使用する場合は、基本的にダブルクォートで囲むのが安全です。
コマンド全体を改めて確認する
RUN ./gradlew clean bootJar --no-daemon \
&& JAR_FILE=$(find build/libs -maxdepth 1 -type f \
-name "*.jar" ! -name "*-plain.jar" | head -n 1) \
&& cp "$JAR_FILE" app.jar
それぞれの処理を日本語に置き換えると、次のようになります。
./gradlew
プロジェクト付属のGradle Wrapperを実行する
clean
以前のビルド結果を削除する
bootJar
Spring Bootの実行可能JARを生成する
--no-daemon
Gradle Daemonを継続利用しない
&&
直前のコマンドが成功した場合だけ次へ進む
find build/libs
build/libs以下を検索する
-maxdepth 1
build/libsの直下までを検索する
-type f
通常のファイルだけを対象にする
-name "*.jar"
拡張子が.jarのファイルを対象にする
! -name "*-plain.jar"
-plain.jarで終わるファイルを除外する
head -n 1
検索結果の先頭1件を取得する
JAR_FILE=$(...)
検索結果をJAR_FILE変数へ代入する
cp "$JAR_FILE" app.jar
対象のJARをapp.jarという名前でコピーする
この書き方の注意点
JARが複数見つかった場合
head -n 1
によって先頭の1件だけを取得していますが、findの出力順は、常に期待した順序になるとは限りません。
実行可能なJARが複数存在する場合、意図しないJARを選ぶ可能性があります。
今回の処理では最初にcleanを実行しているため、古いJARが残る可能性は低くなっています。それでも、複数のモジュールや複数の成果物を生成するプロジェクトでは注意が必要です。
JARが見つからなかった場合
条件に一致するJARが存在しなければ、JAR_FILEは空文字になります。
その後、次のようなコピーが実行されます。
cp "" app.jar
この処理は失敗するため、Dockerイメージのビルドも失敗します。
ただし、エラーメッセージだけでは「JARが見つからなかった」ことが分かりにくい場合があります。
明示的にチェックするなら、次のように記述できます。
RUN ./gradlew clean bootJar --no-daemon \
&& JAR_FILE=$(find build/libs -maxdepth 1 -type f \
-name "*.jar" ! -name "*-plain.jar" | head -n 1) \
&& test -n "$JAR_FILE" \
&& cp "$JAR_FILE" app.jar
test -n "$JAR_FILE"は、変数が空でないことを確認しています。
よりシンプルにする方法
JARファイルの出力名をGradle側で最初からapp.jarに設定すれば、findやcpを使用しなくても済みます。
Groovy DSLのbuild.gradleでは、例えば次のように設定できます。
tasks.named('bootJar') {
archiveFileName = 'app.jar'
}
Kotlin DSLのbuild.gradle.ktsでは次のようになります。
tasks.named<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJar") {
archiveFileName.set("app.jar")
}
この場合、Dockerfileの処理は次のように簡略化できます。
RUN ./gradlew clean bootJar --no-daemon
生成先は次のようになります。
build/libs/app.jar
マルチステージビルドで実行用イメージへコピーする場合は、次のように記述できます。
COPY --from=builder /app/build/libs/app.jar app.jar
ファイル名をGradle側で固定できるのであれば、こちらの方が対象ファイルが明確であり、複数のJARから誤ったファイルを選ぶ心配も少なくなります。
まとめ
今回のDockerfileの処理は、Spring Bootの実行可能JARを生成し、そのJARをapp.jarという固定名に統一するためのものです。
RUN ./gradlew clean bootJar --no-daemon \
&& JAR_FILE=$(find build/libs -maxdepth 1 -type f \
-name "*.jar" ! -name "*-plain.jar" | head -n 1) \
&& cp "$JAR_FILE" app.jar
長いコマンドでも、次の3段階に分けると理解しやすくなります。
1. clean bootJarで実行可能JARを生成する
2. findで実行可能JARを検索する
3. cpでapp.jarという固定名にする
Dockerfileでは、Gradleだけでなくシェルコマンドも組み合わせて使用します。
それぞれのコマンドを個別に理解することで、Dockerイメージのビルドに失敗した際にも、どの処理で問題が発生しているのかを切り分けやすくなります。