DevOps

Docker for Java Developers: Build on Docker

This article is part of our Academy Course titled Docker Tutorial for Java Developers.

In this course, we provide a series of tutorials so that you can develop your own Docker based applications. We cover a wide range of topics, from Docker over command line, to development, testing, deployment and continuous integration. With our straightforward tutorials, you will be able to get your own projects up and running in minimum time. Check it out here!

1. Introduction

Over the first few parts of the tutorial we went through basics of the Docker and the multitude of the ways to interface with it. It is time to apply the knowledge we have acquired to real-world Java projects, starting the discussion from the topic of how Docker affects the well-established build processes and practices.

Fairly speaking, the goal of this section is two-fold. First, we will take a look at how the existing build tools, namely the Apache Maven and Gradle, are helping to package Java applications as Docker containers. Secondly, we will push this idea even further and learn how we could use Docker to entirely encapsulate the build pipeline of our Java applications and produce the final Docker images at the end.

2. Under the Magnifying Glass

To experiment with, we are going to design two simple (but nonetheless meaningful) Java web applications which would implement and expose the REST(ful) APIs for tasks management.

The first application is going to be developed on top of Spring Boot and Spring Webflux, using Gradle as build and dependency management tool. In terms of versions, we will be using Spring Boot latest milestone 2.0.0.M6, Spring Webflux latest release 5.0.1 and Gradle latest release 4.3.

The second application, while functionally equivalent to the first one, will be developed on top of another popular Java framework, Dropwizard, this time using Apache Maven for build and dependency management. In terms of versions, we are going to bring Dropwizard latest release 1.2.0 and Apache Maven latest release 3.5.2.

As we mentioned, both applications would implement and expose the REST(ful) APIs for tasks management, essentially wrapping the CRUD (create, read, update and delete) operations.

    GET     /tasks
    POST    /tasks
    DELETE  /tasks/{id}
    GET     /tasks/{id}

The task itself is modeled as a persistent entity which is going to be managed by Hibernate ORM and stored in the MySQL relation database.

@Entity 
@Table(name = "tasks")
public class Task {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Integer id;
    
    @Column(name = "title", nullable = false, length = 255)
    private String title;
    
    @Column(name = "description", nullable = true, columnDefinition = "TEXT")
    private String description;
    
    // Getters and setters are omitted
    ...
}

At this point, the similarities between both applications end and each of them is going to follow its own idiomatic way of development.

3. Gradle and Docker

So the ground is set, let us begin the journey by exploring what it takes to integrate Docker into typical Gradle build. For this subsection you would need to have Gradle 4.3 installed on your development machine. If you don’t have it yet, please follow the installation instructions by choosing any suggested method you prefer.

In order to package a typical Spring Boot application as a Docker image using Gradle we just need to include two additional plugins in build.gradle file:

The build pipeline would basically rely on Spring Boot Gradle plugin to produce an uber-jar (the term often used to describe the technique of generating a single runnable application JAR archive) which will be later used by Palantir Docker Gradle to assemble the Docker image. Here is how the build definition, build.gradle file, looks like.

buildscript {
    repositories {
        maven { url 'https://repo.spring.io/libs-milestone' }
    }
  
    dependencies {
        classpath "org.springframework.boot:spring-boot-gradle-plugin:2.0.0.M6"
    }
}

plugins {
    id 'com.palantir.docker' version '0.13.0'
}

apply plugin: "org.springframework.boot"
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'application'

sourceCompatibility = 1.8
targetCompatibility = 1.8

dependencies {
    compile("org.flywaydb:flyway-core:4.2.0")
    compile("org.springframework.boot:spring-boot-starter-webflux:2.0.0.M6")
    compile("org.springframework.boot:spring-boot-starter-data-jpa:2.0.0.M6")
    compile("org.springframework.boot:spring-boot-starter-actuator:2.0.0.M6")
    compile("mysql:mysql-connector-java:8.0.7-dmr")
}

repositories {
    maven {
        mavenCentral()
        url 'https://repo.spring.io/libs-milestone'
    }
}

springBoot {
    mainClassName = "com.javacodegeeks.spring.AppStarter"
}

jar {
    mainClassName = "com.javacodegeeks.spring.AppStarter"
    baseName = 'spring-boot-webapp '
    version = project.version
}

bootJar {
    baseName = 'spring-boot-webapp '
    version = project.version
}

docker {
    name "jcg/spring-boot-webapp:$project.version"
    tags 'latest'
    dependsOn build
    files bootJar
    dockerfile file('src/main/docker/Dockerfile')
    buildArgs([BUILD_VERSION: project.version])
}

That is actually pretty straightforward, all the meat is essentially inside the docker section of the build.gradle file. You may also notice that we are using our own Dockerfile, src/main/docker/Dockerfile, to supply the instructions to Docker on how to build the image.

FROM openjdk:8-jdk-alpine
ARG BUILD_VERSION

ENV DB_HOST localhost
ENV DB_PORT 3306

ADD spring-boot-webapp-${BUILD_VERSION}.jar spring-boot-webapp.jar

EXPOSE 19900

ENTRYPOINT exec java $JAVA_OPTS -Ddb.host=$DB_HOST -Ddb.port=$DB_PORT -jar /spring-boot-webapp.jar

Indeed, as simple as it could get. Please notice how we use ARG instruction (and buildArgs setting in build.gradle file) to pass the arguments to the image. In this case, we are passing the version of the project in order to locate the final build artifacts. Another interesting detail to look at is the usage of ENV instructions to wire the MySQL instance host and port to connect to. And, as you may guess already, the EXPOSE instruction informs Docker that the container listens on the port 19900 at runtime.

Awesome, so what is next? Well, we just need to trigger our Gradle build, like that:

> gradle clean docker dockerTag
...
BUILD SUCCESSFUL in 12s
15 actionable tasks: 14 executed, 1 up-to-date

The dockerTag task is not really necessary but due to this issue reported against Palantir Docker Gradle plugin we should explicitly invoke it in order to have our image tagged properly. Let us check if we have our image available locally.

> docker image ls
REPOSITORY               TAG            IMAGE ID      CREATED             SIZE
jcg/spring-boot-webapp   0.0.1-SNAPSHOT 65057c7ae9ba  21 seconds ago      133MB
jcg/spring-boot-webapp   latest         65057c7ae9ba  21 seconds ago      133MB
...

Nice, the new image is there, right from the oven. We could run it immediately, using docker command line tool, but first we need to have the MySQL container available somewhere. Luckily, we did it so many times already that it will not puzzle us.

docker run --rm -d \
  --name mysql \
  -e MYSQL_ROOT_PASSWORD='p$ssw0rd' \
  -e MYSQL_DATABASE=my_app_db \
  -e MYSQL_ROOT_HOST=% \
  mysql:8.0.2

Now we are ready to run our application as a Docker container. There are multiple ways we could use to reference the MySQL container, with user-defined networking being the preferred option. For the simple cases like ours we may just refer to it by assigning to DB_HOST environment variable the IP address of the running MySQL container, for example:

docker run -d --rm \
  --name spring-boot-webapp \
  -p 19900:19900 \
  -e DB_HOST=`docker inspect --format '{{ .NetworkSettings.IPAddress }}' mysql` \
  jcg/spring-boot-webapp:0.0.1-SNAPSHOT

By mapping the port 19900 from the container to the host, we could talk to our application by accessing its REST(ful) APIs from curl using the localhost as the host name. Let us do that right away.

$ curl -X POST http://localhost:19900/tasks \
   -d '[{"title": "Task #1", "description": "Sample Task"}]' \
   -H "Content-Type: application/json"

[
  {
    "id":1,
    "title":"Task #1",
    "description":"Sample Task"
  }
]
$ curl http://localhost:19900/tasks

[
  {
    "id":1,
    "title":"Task #1",
    "description":"Sample Task"
  }
]
$ curl http://localhost:19900/tasks/1

{
  "id":1,
  "title":"Task #1",
  "description":"Sample Task"
}

There are many moving parts under the hood like, for example, automatic database migrations using Flyway and out of the box health checks support using Spring Boot Actuator. Some of them are going to pop up in the upcoming sections of the tutorial but look how simple and natural it is to build and package your Spring Boot applications as Docker images using Gradle.

4. Gradle on Docker

It turns out that building Docker images with Gradle is not painful at all. But still, the prerequisite to have Gradle installed on the target system, along with JDK/JRE, requires some preliminary work to be done. It may not be an issue let say for development, as it is very likely you would have all of that (and many more) installed anyway.

In the case of cloud deployments or CI/CD pipelines, this could be an issue though, incurring additional costs in terms of work or/and maintenance. Could we somehow find a way to get rid of such overhead and rely on Docker entirely? Yes, in fact we can, by adopting multi-stage builds, one of the recent additions to the Docker feature set.

If you are wondering how it may help us, here is the idea. Essentially, we are going to follow the regular procedure to build the image from the Dockerfile. But the Dockerfile would actually contain two image definitions. The first one (based on the one of the official Gradle images) instructs the Docker to run the Gradle build of our Spring Boot application. The second one would pick the binaries produced by the first image and create the final Docker  image with our Spring Boot application sealed inside (much like we have done before).

It might be better to see it once than trying to explain it. The Dockerfile.build file below illustrates this idea in action, using multi-stage build instructions.

FROM gradle:4.3.0-jdk8-alpine

ADD src src
ADD build.gradle .
ADD gradle.properties .

RUN gradle build

FROM openjdk:8-jdk-alpine
ARG BUILD_VERSION

ENV DB_HOST localhost
ENV DB_PORT 3306

COPY --from=0 /home/gradle/build/libs/spring-boot-webapp-${BUILD_VERSION}.jar spring-boot-webapp.jar

EXPOSE 19900

ENTRYPOINT exec java $JAVA_OPTS -Ddb.host=$DB_HOST -Ddb.port=$DB_PORT -jar /spring-boot-webapp.jar

The first part of the Dockerfile definition describes the image based on  gradle:4.3.0-jdk8-alpine. Because our project is quite small, we just copy all the sources inside the image and run gradle build command (this command will be executed by Docker while the image is being built). The result of the build would be the uber-jar which we feed into another image definition, this time based on openjdk:8-jdk-alpine. This would constitute our final image, which we could produce using docker command line tool.

docker image build \
  --build-arg BUILD_VERSION=0.0.1-SNAPSHOT \
  -f Dockerfile.build \
  -t jcg/spring-boot-webapp:latest \
  -t jcg/spring-boot-webapp:0.0.1-SNAPSHOT .

Upon command competition, we should see our newly baked image in the list of the available Docker images.

$ docker image ls
REPOSITORY               TAG            IMAGE ID       CREATED           SIZE
jcg/spring-boot-webapp   0.0.1-SNAPSHOT  02abf724da64  10 seconds ago    133MB
jcg/spring-boot-webapp   latest          02abf724da64  10 seconds ago    133MB
...

There is lot of potential behind multi-stage builds, but even for such a simple application as ours they have proven to be worth the attention.


 

5. Maven and Docker

Let us switch gears a bit and look at how Apache Maven rides the build management for Dropwizard applications. For this subsection you would need to have Apache Maven 3.2.5 installed on your development machine (however if you already have Apache Maven 3.2.1 or higher, you may just stick to it).

The steps we have to follow are mostly identical to what we have discussed for Gradle builds, the changes are essentially only in the plugins to use:

The Maven Shade plugin produces an uber-jar which will be later used by Spotify Docker Maven plugin to build Docker image. Without any fuss, let us take a look at the pom.xml file.

     
<project 
  xmlns=http://maven.apache.org/POM/4.0.0
  xmlns:xsi=http://www.w3.org/2001/XMLSchema-instance
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                      http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.javacodegeeks</groupId>
  <artifactId>dropwizard-webapp</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <packaging>jar</packaging>

  <properties>
      <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>io.dropwizard</groupId>
        <artifactId>dropwizard-bom</artifactId>
        <version>1.2.0</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <dependencies>
    <dependency>
      <groupId>io.dropwizard</groupId>
      <artifactId>dropwizard-core</artifactId>
    </dependency>

    <dependency>
      <groupId>io.dropwizard</groupId>
      <artifactId>dropwizard-hibernate</artifactId>
    </dependency>
        
    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <version>8.0.7-dmr</version>
    </dependency>

    <dependency>
      <groupId>io.dropwizard.modules</groupId>
      <artifactId>dropwizard-flyway</artifactId>
      <version>1.2.0-1</version>
    </dependency>
        
    <dependency>
      <groupId>com.google.guava</groupId>
      <artifactId>guava</artifactId>
    </dependency>
    
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.1</version>
        <configuration>
          <source>1.8</source>
          <target>1.8</target>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.0.2</version>
        <configuration>
          <archive>
            <manifest>
              <mainClass>com.javacodegeeks.docker.AllApiApp</mainClass>
            </manifest>
          </archive>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-shade-plugin</artifactId>
        <version>3.1.0</version>
        <configuration>
          <filters>
            <filter>
              <artifact>*:*</artifact>
              <excludes>
                <exclude>META-INF/*.SF</exclude>
                <exclude>META-INF/*.DSA</exclude>
                <exclude>META-INF/*.RSA</exclude>
              </excludes>
            </filter>
          </filters>
        </configuration>
        <executions>
          <execution>
            <phase>package</phase>
            <goals>
              <goal>shade</goal>
            </goals>
            <configuration>
              <transformers>
                <transformer 
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
                <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">                                  
                  <mainClass>com.javacodegeeks.dw.AppStarter</mainClass>
                </transformer>
              </transformers>
            </configuration>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <groupId>com.spotify</groupId>
        <artifactId>docker-maven-plugin</artifactId>
        <version>1.0.0</version>
        <configuration>
          <imageName>jcg/dropwizard-webapp:${project.version}</imageName>
          <dockerDirectory>src/main/docker</dockerDirectory>
          <resources>
            <resource>
              <targetPath>/</targetPath>
              <directory>${project.build.directory}</directory>
              <include>${project.build.finalName}.jar</include>
            </resource>
            <resource>
              <targetPath>/</targetPath>
              <directory>${project.basedir}</directory>
              <include>application.yml</include>
            </resource>
          </resources>
          <buildArgs>
            <BUILD_VERSION>${project.version}</BUILD_VERSION>
          </buildArgs>
          <imageTags>
            <tag>latest</tag>
          </imageTags>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

To be fair, it looks considerably more verbose than Gradle build, but if we imagine for a second that all XML tags are gone, we would end up with mostly identical definition, at least in case of Docker plugins. The Dockerfile is a bit different though:

FROM openjdk:8-jdk-alpine
ARG BUILD_VERSION

ENV DB_HOST localhost
ENV DB_PORT 3306

ADD dropwizard-webapp-${BUILD_VERSION}.jar dropwizard-webapp.jar
ADD application.yml application.yml 
ADD docker-entrypoint.sh docker-entrypoint.sh
RUN chmod a+x /docker-entrypoint.sh

EXPOSE 19900 19901

ENTRYPOINT ["/docker-entrypoint.sh"]

Due to the specifics of Dropwizard application, we have to bundle the configuration file, in our case application.yml, along with application. Instead of exposing just one port 19900, we have to expose another one, 19901, for administrative tasks. Last but not least, we provide the script to the ENTRYPOINT  instruction, the docker-entrypoint.sh.

#!/bin/sh

set -e
java $JAVA_OPTS -DDB_HOST=$DB_HOST -DDB_PORT=$DB_PORT -jar /dropwizard-webapp.jar db migrate application.yml

if [ ! $? -ne 0 ]; then
  exec java $JAVA_OPTS -DDB_HOST=$DB_HOST -DDB_PORT=$DB_PORT -jar /dropwizard-webapp.jar server application.yml	
fi

exec "$@"

The reason for adding a bit of complexity here is because by default the Dropwizard Flyway addon bundle does not perform automatic database schema migrations. We could workaround that but the cleanest way is to run db migrate command before starting the Dropwizard application. This is exactly what we do inside the shell script above. Now, it is time to trigger the build!

>  mvn clean package docker:build

...

Successfully tagged jcg/dropwizard-webapp:0.0.1-SNAPSHOT
[INFO] Built jcg/dropwizard-webapp:0.0.1-SNAPSHOT
[INFO] Tagging jcg/dropwizard-webapp:0.0.1-SNAPSHOT with latest
[INFO] ---------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ---------------------------------------------------------

...

Let us see if we have our image available locally this time.

> docker image ls
REPOSITORY               TAG            IMAGE ID      CREATED             SIZE
jcg/dropwizard-webapp    0.0.1-SNAPSHOT fa9c310683b1  20 seconds ago      128MB
jcg/dropwizard-webapp    latest         fa9c310683b1  20 seconds ago      128MB
...

Excellent, assuming the MySQL container is up and running (this part does not change at all, we could use the same command from the previous section), we could just run our Dropwizard application container.

docker run -d --rm \
  --name dropwizard-webapp \
  -p 19900:19900 \
  -p 19901:19901 \
  -e DB_HOST=`docker inspect --format '{{ .NetworkSettings.IPAddress }}' mysql` \
  jcg/dropwizard-webapp:0.0.1-SNAPSHOT

We also map the ports 19900 and 19901 from the container to the host so we could use localhost as the host name in curl.

$ curl -X POST http://localhost:19900/tasks \
   -d '[{"title": "Task #1", "description": "Sample Task"}]' \
   -H "Content-Type: application/json"

[
  {
    "id":1,
    "title":"Task #1",
    "description":"Sample Task"
  }
]
$ curl http://localhost:19900/tasks

[
  {
    "id":1,
    "title":"Task #1",
    "description":"Sample Task"
  }
]
$ curl http://localhost:19900/tasks/1

{
  "id":1,
  "title":"Task #1",
  "description":"Sample Task"
}

Please take a note that with host port mappings we could run either jcg/dropwizard-webapp:0.0.1-SNAPSHOT container or jcg/spring-boot-webapp:0.0.1-SNAPSHOT container, but not both at the same time, due to the inevitable port conflicts. We just use the same port for convenience but it in most cases you will be using dynamic port bindings and will not see this issue happening.

6. Maven on Docker

The same technique of using multi-stage builds is equally applicable for the projects which use Apache Maven for build and dependency management (to our luck, there are official Apache Maven images published on Docker Hub).

FROM maven:3.5.2-jdk-8-alpine

ADD src src
ADD pom.xml .

RUN mvn package

FROM openjdk:8-jdk-alpine
ARG BUILD_VERSION

ENV DB_HOST localhost
ENV DB_PORT 3306

COPY --from=0 /target/dropwizard-webapp-${BUILD_VERSION}.jar dropwizard-webapp.jar
ADD application.yml application.yml 
ADD src/main/docker/docker-entrypoint.sh docker-entrypoint.sh
RUN chmod a+x /docker-entrypoint.sh

EXPOSE 19900 19901

ENTRYPOINT ["/docker-entrypoint.sh"]

Not much to add here once we cracked how the multi-stage builds work, so let us build the final image using docker command line tool.

docker image build \
  --build-arg BUILD_VERSION=0.0.1-SNAPSHOT \
  -f Dockerfile.build \
  -t jcg/dropwizard-webapp:latest \
  -t jcg/dropwizard-webapp:0.0.1-SNAPSHOT .

And make sure the image appears in the list of the available Docker images.

> docker image ls
REPOSITORY             TAG             IMAGE ID       CREATED          SIZE
jcg/dropwizard-webapp  0.0.1-SNAPSHOT  5b006fcc9a1d   26 seconds ago   128MB
jcg/dropwizard-webapp  latest          5b006fcc9a1d   26 seconds ago   128MB
...

It is pretty awesome, to be honest. Before finishing up the discussion about multi-stage builds, let us touch upon the use case you may certainly run into: checking out the project from the source control system. The examples we have seen so far assume the project is available locally, but we could clone it from the remote repository as part of the multi-stage builds definition as well.

7. Conclusions

In this section of the tutorial we have seen a couple of examples on how the popular build and dependency management tools, namely Apache Maven and Gradle, support the packaging of the Java applications as Docker images. We have also spent some time discussing the multi-stage builds and the opportunities they open for implementing portable, zero-dependency (literally!) build pipelines.

8. What’s next

In the next section of the tutorial we are going to look at how Docker could simplify the development processes and practices, particularly around dealing with data stores and external (or even internal) services.

The complete project sources are available for download.

Andrey Redko

Andriy is a well-grounded software developer with more then 12 years of practical experience using Java/EE, C#/.NET, C++, Groovy, Ruby, functional programming (Scala), databases (MySQL, PostgreSQL, Oracle) and NoSQL solutions (MongoDB, Redis).
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

2 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Nick Christopher
Nick Christopher
7 years ago

Thanks for the info on the palantir.docker plugin. I’ve been working with the gradle-docker-plugin (https://nwillc.wordpress.com/2017/11/11/travis-ci-to-docker-hub/) and this is an interesting contrast.

Andriy Redko
7 years ago

Thank you! I found Palantir’s Docker plugin a bit more straightforward and easier to use. Glad it turned out to be useful for you! Thanks!

Back to top button