エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

SpringBootのdockerイメージを必要最小限に絞りたい(2019年9月版)

こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。

先日の中村の記事で宣言してしまったので、 今回は「医師版Stack Overflow」(仮名) のSpringBootのdockerイメージを 必要最小限にまで小さくする際に試したことをまとめました。

なお、ちょっと検索すると先人の記事が色々出てきますが、 当時はまだなかったdockerイメージや、JDKの機能の違いにより、今ではちょっと古い部分もあります。 今回の記事も、半年もしないうちに古くなると思うので、2019年9月時点での方法だと思って読んでいただけると幸いです。

f:id:fukubaya:20190912162145j:plain
メットライフドームは埼玉県所沢市にあるドーム球場。本文には特に関係ありません。

小さいdockerイメージのメリット

イメージのサイズを小さくしたいと書きましたが、 そもそも、そのメリットをネットで調べてみてもあまり明確な答えは見つかりません。

  • 実行環境のディスクを節約できる?
    • Fargateはイメージのサイズの大小で料金が変わらないので小さくても安くはならない。EC2タイプならある程度は節約になるかも?
  • ECRの転送料金が抑えられる?
    • と言っても1GBあたり0.1USD程度なので気にしたところで効果は薄そう。
  • 起動が速くなる?
    • ホント? 転送にかかる時間は短くなりそうだけど、起動は速くなるのか?

実はディスクスペースや転送(アップロード、ダウンロード)速度くらいしかメリットがなさそうで、 最近のクラウドサービス上で運用するならあまり考えなくてもいい問題かもしれません。

ただ、もう作ってしまったので以下で説明していきます。興味のある方はどうぞ。

マルチステージビルド

マルチステージビルドはDocker17.05から追加された機能で、前のステージのビルドでの成果物を次のステージのビルドにコピーできます。

docs.docker.com

よく例に上がるのは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 の場所を指定しています。このモジュールを使っているとこのオプションがないと通りません。
  • --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の生成をして、 次のステージで素の alpineapp.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:alpinejdeps で依存関係だけ抽出します。 その後 adoptopenjdk/openjdk11:alpinejlink で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!

冒頭で紹介したように、現在新サービス立ち上げの真っ最中です。 一緒に開発に参加してくれる仲間を募集中です。 お気軽にお問い合わせください。

open.talentio.com

jobs.m3.com

*1:そのためか、Java11からPublic JREはなくなったようです。

*2:単一のJARファイルで複数のバージョンに対応したJAR