Dockerで動かす軽量なJava環境の作成


11/13のOracle Groundbreakers APAC Tour in Tokyoで日本語講演されたJava in a World of Containersをもとに書いています。

はじめに

以下の図はjava9以降のモジュールグラフです。

Javaでアプリケーションを作成するとき、このモジュールグラフに出ているものを全て使うことは稀でしょう。たとえば、java.xml,corba, javawsとかは必要無いときが多いです。

Dockerで何も考えずにLinuxとJDKの乗っているDockerイメージを作成すると結構な容量になります。そのため、コンテナとjavaは相性が悪いと言われることが多いようです。容量が多い、重い、起動が遅いなどなど。それでも、Javaが持つ特徴はコンテナに理想的でもあります。ランタイム、ハードやOSに依存しない、JVMによって保証されるセキュリティ互換性、環境が変化したときに安定した実行の保証、エコシステム、コンテナの最も適切な選択肢を取れるなどなどメリットとなる部分も数多くあります。デフォルトでは重すぎるJDKをJigsawでJDK自体をモジュール化して、依存関係を整理すれば改善できる。

Dockerfile

OSは、ubuntu:latestでイメージを作成します。最初は、デフォルトのフルサイズで、環境を作ってHelloWorldをやってみます。

Dockerfile
FROM ubuntu:latest
ADD openjdk-11.0.1_linux-x64_bin.tar.gz /opt/jdk/
ADD HelloWorld.class HelloWorld.class
ENV PATH /opt/jdk/jdk-11.0.1/bin:$PATH
CMD [ "java", "-showversion", "HelloWorld" ]

イメージをビルドして実行します。

$ docker build -t helloworld-java .
$ docker run --rm helloworld-java
openjdk version "11.0.1" 2018-10-16
OpenJDK Runtime Environment 18.9 (build 11.0.1+13)
OpenJDK 64-Bit Server VM 18.9 (build 11.0.1+13, mixed mode)
Hello World!

作成したイメージを見てみます。

$ docker images helloworld-java
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
helloworld-java     latest              682ae9922b5b        2 minutes ago       396MB

おおよそ、400MBあります。

カスタムJRE

jdepsとjlinkを使って、カスタムJREを作成します。
jdepsを使うことで、モジュールの依存関係を見ることができます。HelloWorldのモジュールを見てみます。

$ jdeps --list-deps HelloWorld.class
   java.base

内容がHelloWorldなので、依存が少ないです。HelloWorldレベルでは、java.baseモジュールだけを使用しているようです。

jlinkでカスタムしたJREを作成してみます。

 jlink --compress=2 --module-path $JAVA_HOME/jmods --add-modules java.base --output jre-min

利用しているのが、java.baseだけなので、add-modulesこれだけを指定します。実行後、jre-minが作成されています。この環境で、HelloWorldを実行できる必要最低限になっています。容量も27MBでした。

.
├── Dockerfile
├── HelloWorld.class
├── HelloWorld.java
├── hello.jar
├── jre-min
│   ├── bin
│   │   ├── java
│   │   └── keytool
│   ├── conf
│   │   ├── net.properties
│   │   └── security
│   │       ├── java.policy
│   │       ├── java.security
│   │       └── policy
│   │           ├── README.txt
│   │           ├── limited
│   │           │   ├── default_US_export.policy
│   │           │   ├── default_local.policy
│   │           │   └── exempt_local.policy
│   │           └── unlimited
│   │               ├── default_US_export.policy
│   │               └── default_local.policy
│   ├── include
│   │   ├── classfile_constants.h
│   │   ├── darwin
│   │   │   └── jni_md.h
│   │   ├── jni.h
│   │   ├── jvmti.h
│   │   └── jvmticmlr.h
│   ├── legal
│   │   └── java.base
│   │       ├── COPYRIGHT
│   │       ├── LICENSE
│   │       ├── aes.md
│   │       ├── asm.md
│   │       ├── c-libutl.md
│   │       ├── cldr.md
│   │       ├── icu.md
│   │       ├── public_suffix.md
│   │       └── unicode.md
│   ├── lib
│   │   ├── classlist
│   │   ├── jli
│   │   │   └── libjli.dylib
│   │   ├── jrt-fs.jar
│   │   ├── jspawnhelper
│   │   ├── jvm.cfg
│   │   ├── libjava.dylib
│   │   ├── libjimage.dylib
│   │   ├── libjsig.dylib
│   │   ├── libnet.dylib
│   │   ├── libnio.dylib
│   │   ├── libosxsecurity.dylib
│   │   ├── libverify.dylib
│   │   ├── libzip.dylib
│   │   ├── modules
│   │   ├── security
│   │   │   ├── blacklisted.certs
│   │   │   ├── cacerts
│   │   │   ├── default.policy
│   │   │   └── public_suffix_list.dat
│   │   ├── server
│   │   │   ├── Xusage.txt
│   │   │   ├── libjsig.dylib
│   │   │   └── libjvm.dylib
│   │   └── tzdb.dat
│   └── release
└── openjdk-11.0.1_linux-x64_bin.tar.gz

この最小環境でHelloWorldを実行してみます。

$ jre-min/bin/java -showversion HelloWorld
java version "11.0.1" 2018-10-16 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.1+13-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.1+13-LTS, mixed mode)
Hello World!

最小構成のjavaでDockerビルド

jlinkでカスタムJREを作成してビルドしてみます。

Dockerfile
FROM ubuntu:latest AS build
ADD openjdk-11.0.1_linux-x64_bin.tar.gz /opt/jdk/
ENV PATH /opt/jdk/jdk-11.0.1/bin:$PATH
RUN ["jlink", "--compress=2", "--module-path", "/opt/jdk/jdk-11/jmods", "--add-modules", "java.base", "--output", "/linked"]

FROM ubuntu:latest
COPY --from=build /linked /opt/jdk/
ENV PATH=$PATH:/opt/jdk/bin
ADD HelloWorld.class /

CMD [ "java", "-showversion", "HelloWorld" ]
$ docker images
REPOSITORY        TAG        IMAGE ID            CREATED             SIZE
helloworld-java   latest     e71329542c40        2 minutes ago       123MB
$ docker run -it helloworld-java
openjdk version "11.0.1" 2018-10-16
OpenJDK Runtime Environment 18.9 (build 11.0.1+13)
OpenJDK 64-Bit Server VM 18.9 (build 11.0.1+13, mixed mode)
Hello World!

サイズが、先程の約4分の1弱になりました。

更に軽量な環境構築Dockerビルド

必要最低限のJavaは用意できましたが、それでもまだ123MBもあります。先程やり方だけだと、Javaしか軽量化できていないので、OSにも手を入れていきます。avaを動かすだけであれば、ubuntuは贅沢すぎます。そこで、alpine linux を使って必要最低限のlinux環境を作成します。alpineは、musl libcとBusyBoxをベースに構成されている軽量なLinuxです(ほんとに何も入っていない・・・)。最小構成だと5MBを下回る軽さのようです。AdoptOpenJDKで公開されているalpineベースのJava環境Dockerfileを参考に最小構成でイメージを作成します。

Dockerfile
FROM adoptopenjdk/openjdk11:alpine-slim AS jlink
RUN ["jlink", "--compress=2", \
     "--module-path", "/opt/java/openjdk/jmods", \
     "--add-modules", "java.base", \
     "--output", "/jlinked"]


FROM alpine

RUN apk --update add --no-cache ca-certificates curl openssl binutils xz \
    && GLIBC_VER="2.28-r0" \
    && ALPINE_GLIBC_REPO="https://github.com/sgerrand/alpine-pkg-glibc/releases/download" \
    && GCC_LIBS_URL="https://archive.archlinux.org/packages/g/gcc-libs/gcc-libs-8.2.1%2B20180831-1-x86_64.pkg.tar.xz" \
    && GCC_LIBS_SHA256=e4b39fb1f5957c5aab5c2ce0c46e03d30426f3b94b9992b009d417ff2d56af4d \
    && ZLIB_URL="https://archive.archlinux.org/packages/z/zlib/zlib-1%3A1.2.9-1-x86_64.pkg.tar.xz" \
    && ZLIB_SHA256=bb0959c08c1735de27abf01440a6f8a17c5c51e61c3b4c707e988c906d3b7f67 \
    && curl -Ls https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub -o /etc/apk/keys/sgerrand.rsa.pub \
    && curl -Ls ${ALPINE_GLIBC_REPO}/${GLIBC_VER}/glibc-${GLIBC_VER}.apk > /tmp/${GLIBC_VER}.apk \
    && apk add /tmp/${GLIBC_VER}.apk \
    && curl -Ls ${GCC_LIBS_URL} -o /tmp/gcc-libs.tar.xz \
    && echo "${GCC_LIBS_SHA256}  /tmp/gcc-libs.tar.xz" | sha256sum -c - \
    && mkdir /tmp/gcc \
    && tar -xf /tmp/gcc-libs.tar.xz -C /tmp/gcc \
    && mv /tmp/gcc/usr/lib/libgcc* /tmp/gcc/usr/lib/libstdc++* /usr/glibc-compat/lib \
    && strip /usr/glibc-compat/lib/libgcc_s.so.* /usr/glibc-compat/lib/libstdc++.so* \
    && curl -Ls ${ZLIB_URL} -o /tmp/libz.tar.xz \
    && echo "${ZLIB_SHA256}  /tmp/libz.tar.xz" | sha256sum -c - \
    && mkdir /tmp/libz \
    && tar -xf /tmp/libz.tar.xz -C /tmp/libz \
    && mv /tmp/libz/usr/lib/libz.so* /usr/glibc-compat/lib \
    && apk del binutils \
    && rm -rf /tmp/${GLIBC_VER}.apk /tmp/gcc /tmp/gcc-libs.tar.xz /tmp/libz /tmp/libz.tar.xz /var/cache/apk/*

COPY --from=jlink /jlinked /opt/jdk/

ADD HelloWorld.class /
CMD ["/opt/jdk/bin/java", "-showversion", "HelloWorld"]
$ docker build -t helloworld-java .
$ docker images
REPOSITORY                TAG                 IMAGE ID            CREATED              SIZE
helloworld-java           latest              407614883e2b        38 seconds ago       52.1MB

先程の半分以下になりました。もとのものと比べると8分の1ほどになりました。

もちろん実行も可能

$ docker run -it helloworld-java
openjdk version "11.0.1" 2018-10-16
OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.1+13)
OpenJDK 64-Bit Server VM AdoptOpenJDK (build 11.0.1+13, mixed mode)
Hello World!

まとめ

容量が多い、重い、起動が遅いなどなどコンテナとjavaは相性が悪いと言われることが多いようです。JDK9以降はjdepsとjlinkで専用の環境を作成することができます。必要最低限の環境を作ることができるので、軽いコンテナを作ることが可能です。OSにも手を入れていくことで更に軽量で自分好み環境も作成可能です。