Creating dependency images

Gunnar Morling edited this page Jan 8, 2018 · 11 revisions

As pointed out in multiple blog posts, Java 9 modular runtime images created via jlink are a great way for distributing Java apps in Docker containers. Instead of shipping a full JRE, such modular runtime image will just contain the JVM modules your app actually needs, resulting in smaller images.

But in a way, modular runtime images are like a fat JARs, they can become rather big themselves, resulting in longer times to build and distribute them. Therefore dependency images take the idea one step further and split things up into two separate Docker images:

  • A Docker base image which contains a modular runtime image only comprising your application's dependencies (JVM modules and 3rd party)
  • A Docker image with the actual application, which is derived from the base image and only contains your application module(s)

As the dependencies of your application are going to change much less frequently than your application code itself, the base image needs to be rebuilt only rarely. Whereas the application image will be rebuilt whenever you apply changes to your app. But as this image is rather small (all the dependencies are in the base image), it is very quick to built and distribute e.g. in a Kubernetes cluster.

Example: A dependency image for Vert.x

As an example let's walk through the creation of a dependency image for Vert.x and see how the ModiTect Maven plug-in can help you with that. The resulting image will contain Vert.x, its dependencies (Netty, Jackson) and the needed JVM modules and is used to run a simple Hello World HTTP service. You can find the complete example in the integration tests.

Let's begin by defining a Maven profile for creating the base image:

<profile>
    <id>docker-base</id>
    <build>
        <plugins>
            <plugin>
                <groupId>org.moditect</groupId>
                <artifactId>moditect-maven-plugin</artifactId>
                <executions>
                    <execution>
                        <id>create-dependency-image</id>
                        <phase>package</phase>
                        <goals>
                            <goal>create-runtime-image</goal>
                        </goals>
                        <configuration>
                            <baseJdk>jdk-9_linux-x64</baseJdk>
                            <modulePath>
                                <path>${project.build.directory}/modules</path>
                            </modulePath>
                            <modules>
                                <module>com.example</module>
                            </modules>
                            <compression>2</compression>
                            <stripDebug>true</stripDebug>
                            <excludedResources>
                                <pattern>glob:/com.example/**</pattern>
                            </excludedResources>
                            <outputDirectory>${project.build.directory}/jlink-image</outputDirectory>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</profile>

This creates a modular runtime image (via jlink),

  • taking the modular JARs under target/modules as input; if needed, ModiTect can be used to modularize existing non-modular JARs (such as the Vert.x JARs at this point in time)
  • using com.example as the root module for the analyzis of the modules to be included in the image
  • compressing the contents and removing debug symbols and
  • using target/jlink-image as the output folder.

Two configuration parameters are of specific interest, baseJdk and excludedResources.

baseJdk is used to identify a JDK from which the jmod files (JVM modules) should be taken. This is required if the modular runtime image should target another platform than the one the build is running on. E.g. we may run this build on OS X, but the resulting Docker image might be Linux-based (we'll use CentOS 7 in the following). The given identifier is used to select exactly one entry from Maven's toolchains.xml file which must look like so:

...
<toolchain>
   <type>jdk</type>
   <provides>
       <version>9</version>
       <vendor>oracle</vendor>
       <id>jdk-9_linux-x64</id>
   </provides>
   <configuration>
       <jdkHome>path/to/jdk-9.0.1_linux-x64</jdkHome>
   </configuration>
</toolchain>
...

This requires the JDK for the intended target platform to be downloaded and unpacked to the given jdkHome.

excludedResources is used to enable jlink's ExcludePlugin which let's you skip specific contents from the resulting modular runtime image. As we'd like to add the actual application in a separate Docker image we can omit it from the jlink image using the glob:/com.example/** pattern, i.e. all files from the com.example module should be skipped.

The next step is to create the (base) Docker image containing this jlink image, e.g. using the fabric8 Docker Maven plug-in:

<plugin>
    <groupId>io.fabric8</groupId>
    <artifactId>docker-maven-plugin</artifactId>
    <executions>
        <execution>
            <id>build-dependency-image</id>
            <phase>package</phase>
            <goals>
                <goal>build</goal>
            </goals>
            <configuration>
                <images>
                    <image>
                        <alias>vertx-helloworld-base</alias>
                        <name>moditect/vertx-helloworld-base</name>
                        <build>
                            <dockerFileDir>${project.basedir}/src/main/docker-base</dockerFileDir>
                            <assembly>
                                <descriptor>assembly-base.xml</descriptor>
                            </assembly>
                        </build>
                    </image>
                </images>
            </configuration>
        </execution>
    </executions>
</plugin>

The assembly descriptor used by the plug-in describes the contents of the image:

<?xml version="1.0" encoding="UTF-8"?>
<assembly xmlns="..." xmlns:xsi="..." xsi:schemaLocation="...">
    <id>vertx-helloworld-base</id>
    <fileSets>
        <fileSet><directory>target/jlink-image</directory></fileSet>
    </fileSets>
</assembly>

Also the Docker file is straight forward:

FROM centos:7

COPY maven/target/jlink-image /opt/vertx-helloworld/
ADD entrypoint.sh /entrypoint.sh

EXPOSE 8080

ENTRYPOINT ["/entrypoint.sh"]

We copy the modular runtime image, expose the port 8080 and add a script entrypoint.sh.

Building the application image

With everything in place for creating the dependency image, it's time to define another Maven profile which builds the Docker image with the actual application:

<profile>
    <id>docker</id>
    <build>
        <plugins>
            <plugin>
                <groupId>io.fabric8</groupId>
                <artifactId>docker-maven-plugin</artifactId>
                <executions>
                    <execution>
                        <id>build-application-image</id>
                        <phase>package</phase>
                        <goals>
                            <goal>build</goal>
                        </goals>
                        <configuration>
                            <images>
                                <image>
                                    <alias>vertx-helloworld</alias>
                                    <name>moditect/vertx-helloworld</name>
                                    <build>
                                        <dockerFileDir>${project.basedir}/src/main/docker</dockerFileDir>
                                        <assembly>
                                            <descriptor>assembly.xml</descriptor>
                                        </assembly>
                                    </build>
                                </image>
                            </images>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</profile>

Here we only need the Docker Maven plug-in for creating a Docker image which is derived from the base one. The assembly descriptor defines our application module and a simple launcher script (which is referenced from the container's entry point) as the contents of that image:

<?xml version="1.0" encoding="UTF-8"?>
<assembly xmlns="..." xmlns:xsi="..." xsi:schemaLocation="...">
    <id>vertx-helloworld</id>
    <files>
        <file>
            <source>target/modules/moditect-integrationtest-vertx-1.0.0-SNAPSHOT.jar</source>
        </file>
        <file>
            <source>src/main/docker/helloWorld.sh</source>
        </file>
    </files>
</assembly>

The Docker file declares the dependency image as its base and adds the application module and the launcher script:

FROM moditect/vertx-helloworld-base

COPY maven/moditect-integrationtest-vertx-1.0.0-SNAPSHOT.jar /opt/modules/
COPY maven/helloWorld.sh /opt/vertx-helloworld/bin

Finally, let's take a look at this launcher script:

#!/bin/sh
DIR=`dirname $0`
$DIR/java -XX:+UnlockExperimentalVMOptions \
    -XX:+UseCGroupMemoryLimitForHeap \
    --module-path=/opt/modules \
    -m com.example $@

This runs the java binary from the modular runtime image, specifies the module path so that the application module (which isn't part of the modular runtime image) can be found and defines the module to run (com.example). The UseCGroupMemoryLimitForHeap is provided to make sure any memory limit given for the Docker container (and not the entire memory of the Docker host) is used to determine the maximum heap size.

Building and running the application

Eventually it's time to create the Docker images and execute the example application. To (re)-build the base image, just run

mvn clean package -Pdocker-base

This is only needed when the dependencies of the application change (or when additional JVM modules are used). To build the application image, run

mvn clean package -Pdocker

Of course you also can build both images at once, if needed:

mvn clean package -Pdocker-base,docker

To run the application, simply create a container from the application image:

docker run -p 8080:8080 -it moditect/vertx-helloworld

You can access the Hello World service via curl then:

> curl localhost:8080?name=Bob

Hello, Bob!

Finally, let's take a look at the sizes of the Docker images to see the benefit of splitting up our application into a bigger, rather stable, dependency image and much smaller, frequently built, application image:

> docker history moditect/vertx-helloworld

IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
626a242cdb78        About an hour ago   /bin/sh -c #(nop) COPY file:905dc10b9a6a8d...   174B
618eab3ad001        About an hour ago   /bin/sh -c #(nop) COPY file:01c997460d07f3...   6.65kB
8d3281258724        About an hour ago   /bin/sh -c #(nop)  ENTRYPOINT ["/entrypoin...   0B
8c5966f2864e        About an hour ago   /bin/sh -c #(nop)  EXPOSE 8080/tcp              0B
93a7f6caeaa5        About an hour ago   /bin/sh -c #(nop) ADD file:466570d016add52...   664B
d6d2af40839c        About an hour ago   /bin/sh -c #(nop) COPY dir:b1231d9ef74dab0...   57.1MB
3fa822599e10        5 weeks ago         /bin/sh -c #(nop)  CMD ["/bin/bash"]            0B
<missing>           5 weeks ago         /bin/sh -c #(nop)  LABEL name=CentOS Base ...   0B
<missing>           5 weeks ago         /bin/sh -c #(nop) ADD file:7441d818786942a...   204MB

The application image are just the two small top-most layers in the image hierarchy, and only these two layers need to be re-built and distributed most of the times, resulting in a very quick and efficient development lifecycle. Of course a real application module will be bigger than our Hello World example, but still it will typically be much smaller than the sum of its JVM and 3rd party dependencies. So splitting up these things into two images definitely makes sense. This gets possible by leveraging Java 9 modules, modular runtime images and the ability to add further modules on top of such runtime image.

Note that the approach also could be slightly modified by letting the runtime image actually contain the application modules and then rather update instead of add them in a separate Docker image. To do so, you'd remove the exclude-resources configuration from the base image and then provide the modules in the application image using the --upgrade-module-path parameter instead of --module-path. That way the base image will be self-contained itself, whereas the modules provided by the application image will take precedence over the one baked into the base image. This may be beneficial in some cases, but it also increases the resulting overall image size, as the application module(s) would be contained in both the base image and the application image.

The complete example including the code of the Vert.x Hello World service can be found here.

You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.
Press h to open a hovercard with more details.