こんにちは、エムスリーエンジニアリンググループの福林 (@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-deps
jlink
の--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!
冒頭で紹介したように、現在新サービス立ち上げの真っ最中です。 一緒に開発に参加してくれる仲間を募集中です。 お気軽にお問い合わせください。