こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。
先日の中村の記事で宣言してしまったので、 今回は「医師版Stack Overflow」(仮名) のSpringBootのdockerイメージを 必要最小限にまで小さくする際に試したことをまとめました。
なお、ちょっと検索すると先人の記事が色々出てきますが、 当時はまだなかったdockerイメージや、JDKの機能の違いにより、今ではちょっと古い部分もあります。 今回の記事も、半年もしないうちに古くなると思うので、2019年9月時点での方法だと思って読んでいただけると幸いです。

小さいdockerイメージのメリット
イメージのサイズを小さくしたいと書きましたが、 そもそも、そのメリットをネットで調べてみてもあまり明確な答えは見つかりません。
- 実行環境のディスクを節約できる?
- Fargateはイメージのサイズの大小で料金が変わらないので小さくても安くはならない。EC2タイプならある程度は節約になるかも?
- ECRの転送料金が抑えられる?
- と言っても1GBあたり0.1USD程度なので気にしたところで効果は薄そう。
- 起動が速くなる?
- ホント? 転送にかかる時間は短くなりそうだけど、起動は速くなるのか?
実はディスクスペースや転送(アップロード、ダウンロード)速度くらいしかメリットがなさそうで、 最近のクラウドサービス上で運用するならあまり考えなくてもいい問題かもしれません。
ただ、もう作ってしまったので以下で説明していきます。興味のある方はどうぞ。
マルチステージビルド
マルチステージビルドはDocker17.05から追加された機能で、前のステージのビルドでの成果物を次のステージのビルドにコピーできます。
よく例に上がるのはGoを使ったイメージです。
Goはコンパイルしたバイナリがあれば、実行環境にGoのコンパイラやソースコードなどは不要です。
なので、golang イメージ上でビルドして、
生成されたバイナリだけ取り出して小さいLinuxイメージ(alpine などは5MB程度)にコピーすれば、
アプリ用イメージは小さくシンプルになります。
SpringBootでは?
SpringBootもfat jarと呼ばれる単一jarに全ての機能を含まれているという意味では、 Goのシングルバイナリと似たような環境です。
ただし、Goとは違い、jar単体だけあってもアプリケーションは起動できず、jarの起動のためにはJDKが必要です。
単一fat jar + できるだけ小さいJDKが入ったイメージにすれば必要最小限のイメージができるのですが、
JDK入りのイメージはそれなりの大きさがあって、openjdk:12-alpine でも adoptopenjdk/openjdk12:alpine でも350MB程度あります。
REPOSITORY TAG IMAGE ID CREATED SIZE openjdk 12-alpine 0c68e7c5b7a0 7 months ago 339MB adoptopenjdk/openjdk12 alpine 4e357e347607 4 days ago 365MB
そのほとんどがJDKで占められています。
# openjdk:12-alpine % du -sh $JAVA_HOME 318.7M /opt/openjdk-12 # adoptopenjdk/openjdk12:alpine % du -sh $JAVA_HOME 335.3M /opt/java/openjdk
jlinkで必要最小限JREを作る
Java9以降では、Javaにモジュールの概念が追加されたことで、 必要なモジュールだけで構成されるJREを自分で作ることができます*1。
jlink コマンドは必要なモジュールを選んでJREを生成するコマンドです。
以下は、 java.base のみを含むJREの生成例です。335.3MBから50.2MBまで減りました。
# adoptopenjdk/openjdk12:alpine
% jlink \
--module-path $JAVA_HOME/jmods \
--add-modules java.base \
--output /tmp/jre-java-base
% du -sh /tmp/jre-java-base
50.2M /tmp/jre-java-base
また、いくつかサイズを削減に効きそうなオプションを足すともう少し小さくなります。
# adoptopenjdk/openjdk12:alpine
% jlink \
--module-path $JAVA_HOME/jmods \
--add-modules java.base \
--compress=2 \ # ZIPで圧縮
--strip-debug \ # デバッグ時に必要な情報を外す
--no-header-files \ # headerファイルを外す
--no-man-pages \ # man pageを外す
--output /tmp/jre-java-base-min
% du -sh /tmp/jre-java-base-min
34.4M /tmp/jre-java-base-min
jdepsで必要なモジュールを選ぶ
jdeps コマンドはjarやclassファイル内で使われるモジュールの依存関係を解析します。
SpringBootとして生成したfat jarに対して実行してみると java.base, java.logging だけが出力されました。
# adoptopenjdk/openjdk12:alpine % jdeps \ --list-deps \ --ignore-missing-deps \ # 見つからなかった依存関係は無視(コンパイルはできているので) app.jar java.base java.logging
しかし、この2つだけを残したJREでは起動しません。
# adoptopenjdk/openjdk12:alpine
# JREの生成
% jlink \
--module-path ${JAVA_HOME}/jmod \
--add-modules java.base,java.logging \
--output /tmp/jre-simple
# 生成したJREで起動
% /tmp/jre-simple/bin/java -jar app.jar
...
Caused by: java.lang.ClassNotFoundException: java.sql.SQLException
at java.base/java.net.URLClassLoader.findClass(URLClassLoader.java:436)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:588)
at org.springframework.boot.loader.LaunchedURLClassLoader.loadClass(LaunchedURLClassLoader.java:92)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:521)
... 17 more
残念ながら jdeps は jar に含まれる jar を再帰的には解析してくれないので、
それらのjarが使うモジュールが足りないのです。
この部分は先人も共通して困るポイントになっていて、色々ググってみたりもしましたが、 理由なく固定でモジュールが列挙されているか、それをそのまま持ってきているだけの方法しか見つかりませんでした。
そこで、fat jarに含まれるjarも含めて依存関係を解析するスクリプトを用意しました。 jarを展開して、アプリケーション本体とSpringBootの起動部分とjarの依存関係を調べます。
#!/bin/sh
# jdeps-spring-boot
set -eu
readonly TARGET_JAR=$1
readonly TARGET_VER=$2
# jarを展開するディレクトリ
readonly TMP_DIR="/tmp/app-jar"
mkdir -p ${TMP_DIR}
trap 'rm -rf ${TMP_DIR}' EXIT
# jarを展開
unzip -q "${TARGET_JAR}" -d "${TMP_DIR}"
# 出力
jdeps \
-classpath \'${TMP_DIR}/BOOT-INF/lib/*:${TMP_DIR}/BOOT-INF/classes:${TMP_DIR}\' \
--print-module-deps \
--ignore-missing-deps \
--module-path ${TMP_DIR}/BOOT-INF/lib/javax.activation-api-1.2.0.jar \
--recursive \
--multi-release ${TARGET_VER} \
-quiet \
${TMP_DIR}/org ${TMP_DIR}/BOOT-INF/classes ${TMP_DIR}/BOOT-INF/lib/*.jar
オプションについていくつか補足します。
-classpath- 解析に必要なclasspathを指定しています
--print-module-depsjlinkの--add-modulesにそのまま渡せる形式で出力します。
--module-path- Java11で廃止されたモジュール
java.activationの場所を指定しています。このモジュールを使っているとこのオプションがないと通りません。
- Java11で廃止されたモジュール
--multi-relase- マルチリリースJAR*2に対してどのバージョンとして解析するかを指定します
これで実行すると必要なモジュールが出力されます。
# adoptopenjdk/openjdk12:alpine % ./jdeps-spring-boot app.jar 12 java.base,java.compiler,java.desktop,java.instrument,java.management.rmi,java.prefs,java.scripting,java.security.jgss,java.security.sasl,java.sql.rowset,jdk.attach,jdk.httpserver,jdk.jdi,jdk.unsupported
Dockerfile
以上をまとめてDockerfileにします。
元の app.jar が50MBくらいあるので、単純に作ると400MB程度になるはずです。
OpenJDK12
最初のステージでは openjdk:12-alpine 上で app.jar からモジュールの依存関係を抽出、JREの生成をして、
次のステージで素の alpine に app.jar とJREをコピーします。
# 依存モジュールの解析とJREの生成
FROM openjdk:12-alpine as builder
COPY "app.jar" "app.jar"
COPY "jdeps-spring-boot" "jdeps-spring-boot"
RUN jlink \
--module-path /opt/java/jmods \
--strip-debug \
--compress=2 \
--add-modules $(./jdeps-spring-boot app.jar 12) \
--no-header-files \
--no-man-pages \
--vm server \
--output /opt/jre
# 本体イメージの生成
FROM alpine:3.10.2 as app
# set timezone
ENV TZ='Asia/Tokyo'
# create directory for application
RUN mkdir -p /app
WORKDIR /app
# recommended by spring boot
# cf. https://spring.io/guides/gs/spring-boot-docker/#_containerize_it
VOLUME /tmp
ENV JAVA_HOME=/opt/jre
ENV PATH="$PATH:$JAVA_HOME/bin"
COPY --from=builder /opt/jre /opt/jre
ADD "app.jar" "/app/app.jar"
# set entrypoint to execute spring boot application
CMD java \
-D -Djava.security.egd=file:///dev/urandom ${JAVA_OPTS} \
-jar /app/app.jar
ビルド結果。110MB。
% docker build -t myapp-openjdk12 --target app . ... % docker images myapp-openjdk12 REPOSITORY TAG IMAGE ID CREATED SIZE myapp-openjdk12 latest b51297c41a1c 14 seconds ago 110MB
AdoptOpenJDK12
OpenJDK12 とほぼ同じですが、
AdoptOpenJDK12のalpineのイメージは、GLIBCがないと動かないので、
ビルド用のイメージ adoptopenjdk/openjdk12:alpine からコピーします。
# 依存モジュールの解析とJREの生成
FROM adoptopenjdk/openjdk12:alpine as builder
COPY "app.jar" "app.jar"
COPY "jdeps-spring-boot" "jdeps-spring-boot"
RUN jlink \
--module-path /opt/java/jmods \
--strip-debug \
--compress=2 \
--add-modules $(./jdeps-spring-boot app.jar 12) \
--no-header-files \
--no-man-pages \
--vm server \
--output /opt/jre
# 本体イメージの生成
FROM alpine:3.10.2 as app
# set timezone
ENV TZ='Asia/Tokyo'
# create directory for application
RUN mkdir -p /app
WORKDIR /app
# recommended by spring boot
# cf. https://spring.io/guides/gs/spring-boot-docker/#_containerize_it
VOLUME /tmp
ENV JAVA_HOME=/opt/jre
ENV PATH="$PATH:$JAVA_HOME/bin"
COPY --from=builder /opt/jre /opt/jre
ADD "app.jar" "/app/app.jar"
# GLIBC
COPY --from=builder /lib64 /lib64
COPY --from=builder /usr/glibc-compat/lib /usr/glibc-compat/lib
# set entrypoint to execute spring boot application
CMD java \
-D -Djava.security.egd=file:///dev/urandom ${JAVA_OPTS} \
-jar /app/app.jar
ビルド。122MB。
% docker build -t myapp-adoptopenjdk12 --target app . ... % docker images myapp-adoptopenjdk12 REPOSITORY TAG IMAGE ID CREATED SIZE myapp-adoptopenjdk12 latest 8b5811b520aa 6 seconds ago 122MB
AdoptOpenJDK11
現在開発中の「医師版Stack Overflow」(仮名)では LTSであるAdoptOpenJDK11を採用するつもりなので、dockerイメージもAdoptOpenJDK11で作ります。
一方で、ここまですべてJava12で説明してきました。これには訳があります。
--print-module-deps や --ignore-missing-deps などの一部のオプションが
Java11の jdeps には存在せず、上記で紹介したスクリプトがJava11では動かないからです。
したがって、AdoptOpenJDK11用のイメージ生成では少し工夫が必要です。
3ステージに分けて生成します。
まず adoptopenjdk/openjdk12:alpine の jdeps で依存関係だけ抽出します。
その後 adoptopenjdk/openjdk11:alpine の jlink でJREを生成します。
最後に素の alpine にGLIBCと app.jar と生成したJREをコピーします。
# 依存モジュールの解析
FROM adoptopenjdk/openjdk12:alpine as appdeps
COPY "app.jar" "app.jar"
COPY "jdeps-spring-boot" "jdeps-spring-boot"
RUN ./jdeps-spring-boot app.jar 11 > /tmp/app-jdeps
# JREの生成
FROM adoptopenjdk/openjdk11:alpine as builder
COPY --from=appdeps /tmp/app-jdeps /tmp/app-jdeps
RUN jlink \
--module-path /opt/java/jmods \
--strip-debug \
--compress=2 \
--add-modules $(cat /tmp/app-jdeps) \
--no-header-files \
--no-man-pages \
--vm server \
--output /opt/jre
# 本体イメージの生成
FROM alpine:3.10.2 as app
# set timezone
ENV TZ='Asia/Tokyo'
# create directory for application
RUN mkdir -p /app
WORKDIR /app
# recommended by spring boot
# cf. https://spring.io/guides/gs/spring-boot-docker/#_containerize_it
VOLUME /tmp
ENV JAVA_HOME=/opt/jre
ENV PATH="$PATH:$JAVA_HOME/bin"
COPY --from=builder /opt/jre /opt/jre
ADD "app.jar" "/app/app.jar"
# GLIBC
COPY --from=builder /lib64 /lib64
COPY --from=builder /usr/glibc-compat/lib /usr/glibc-compat/lib
# set entrypoint to execute spring boot application
CMD java \
-D -Djava.security.egd=file:///dev/urandom ${JAVA_OPTS} \
-jar /app/app.jar
ビルド。119MB。
% docker build -t myapp-adoptopenjdk11 --target app . ... % docker images myapp-adoptopenjdk11 REPOSITORY TAG IMAGE ID CREATED SIZE myapp-adoptopenjdk11 latest d3d856f7d9c5 34 minutes ago 119MB
まとめ
どのJDKでも100MB程度のイメージに抑えることができました。
REPOSITORY TAG IMAGE ID CREATED SIZE myapp-adoptopenjdk12 latest 8b5811b520aa 3 hours ago 122MB myapp-openjdk12 latest b51297c41a1c 3 hours ago 110MB myapp-adoptopenjdk11 latest d3d856f7d9c5 4 hours ago 119MB
We are hiring!
冒頭で紹介したように、現在新サービス立ち上げの真っ最中です。 一緒に開発に参加してくれる仲間を募集中です。 お気軽にお問い合わせください。