Java: Creating Docker image for Spring Boot web app using gradle

While working on your Spring Boot web application locally, gradle provides the ‘bootRun’ for a quick development lifecycle and ‘bootJar’ for packaging all the dependencies as a single jar deliverable.

But for most applications these days, you will need this packaged into an OCI compatible (i.e. Docker) image for its ultimate deployment to an orchestrator such as Kubernetes.

Gradle is able to generate OCI compatible images by utilizing the Palantir plugins and the Docker engine installed on your development host. Once configured, a ‘docker’ and ‘dockerPush’ task are available for use.

Overview

We will follow these steps to create a REST service instrumented with Actuator.

  • Install prerequisite binaries for Ubuntu development server
  • Create basic Spring Boot project using start.spring.io
  • Modify build.gradle to support additional Docker plugins/tasks, and dependencies
  • Create template Dockerfile that will be used when building image
  • Configure application.properties to support Actuator, OpenAPI/Swagger page
  • Add logback xml configuration for console logging
  • Create first test web Controller at ‘:8080/info’
  • Create simple REST Controller at ‘:8080/api/user’, custom metrics at ‘:8081/actuator/prometheus’
  • Add custom health check to ‘:8081/actuator/health’ that shows down if user count is 0

We will then validate this application’s endpoints and logic when run as a local development server.

  • Run jar on local embedded Tomcat server
  • Validate /info endpoint
  • Validate /api/user endpoint
  • Validate console log levels
  • Validate custom health check at /actuator/health
  • Validate prometheus metrics at /actuator/prometheus
  • Validate timed metrics at /actuator/prometheus
  • Validate deletion of users
  • Validate failed health when user count = 0 at /actuator/metrics
  • Validate custom user count metric =0 at /actuator/prometheus
  • Validate OpenAPI/Swagger UI page at /swagger-ui/index.html

Once we are satisfied with the validation, we will move on to the Docker specific tasks.

  • Building a local docker image
  • Running the application in our local Docker daemon
  • Pushing the docker image to hub.docker.com for public consumption

Prerequisites

This article assumes you are on an Ubuntu development server. You will need the following packages installed.

OpenJDK 11+

# refresh package repos
sudo apt update

# show available openjdk versions
sudo apt search openjdk-* | grep -P '^openjdk-1\d-jdk/'

# pick latest (which is 17 as of this writing)
sudo apt install openjdk-17-jdk curl git -y

# validate version reported is the one just installed
java --version

Docker CE

Here is my article on installing Docker CE on Ubuntu focal.

Create Spring Boot starter project

Use start.spring.io to generate a Spring Boot project that supports a REST API that is instrumented with Actuator health/metrics and uses OpenAPI (Swagger) for service documentation.

# variables to be used in project creation
id=spring-boot-with-docker-push
artifact_id="${id//-}"
SpringAppClassName=SpringMain
version="0.0.2-SNAPSHOT"
groupId="org.fabianlee"

# send parameters that create zip containing SpringBoot project
curl https://start.spring.io/starter.zip \
    -d type=gradle-project \
    -d dependencies=web,prometheus,devtools,actuator \
    -d javaVersion=11 \
    -d bootVersion=2.7.0 \
    -d groupId=$groupId \
    -d artifactId=$artifact_id \
    -d name=$SpringAppClassName \
    -d baseDir=$id \
    -d version=$version \
    -o $id.zip

# unzip archive to directory
unzip $id.zip
cd $id
chmod +x ./gradlew

# validate jar build is successful, with no errors
./gradlew bootJar

Configure build.gradle

Add the Docker Palantir plugins that enable the gradle docker tasks.

plugins {
...
  id 'com.palantir.docker' version '0.33.0'
  id "com.palantir.docker-run" version "0.33.0"
}

Add project variables for minimum Java levels and docker hub owner account.

...
sourceCompatibility = '11'
// add the following two variables
targetCompatibility = '11'
def dockerOwner = 'fabianlee'

Add dependencies for Micrometer Prometheus instrumentation and OpenAPI (Swagger) documentation of REST services.  This will automatically expose Prometheus formatted metrics at ‘/actuator/prometheus’ and Swagger documentation and API tester page at ‘/swagger-ui/index.html’

dependencies {
...
  runtimeOnly 'io.micrometer:micrometer-registry-prometheus:1.9.0'
  implementation 'org.springdoc:springdoc-openapi-ui:1.6.9'
}

Append a springBoot section that enables BuildProperties from build.gradle to be pulled from the Spring context.

// makes BuildProperties available from Spring context
springBoot {
  buildInfo()
}

Append customized Docker tasks for ‘docker’ and ‘dockerRun’.

// takes templatized Dockerfile, places into buildDir
task prepareDockerfileTemplate(type: Copy) {
  from "src/main/resources/docker"
  include "Dockerfile"
  filter { it.replaceAll('<%=name%>', project.name) }
  filter { it.replaceAll('<%=version%>', project.version) }
  into "$buildDir"
}
// add explicit dependency, otherwise we get warning at console
dockerPrepare.dependsOn bootJar
bootJar.dependsOn prepareDockerfileTemplate
bootJarMainClassName.dependsOn prepareDockerfileTemplate

// https://plugins.gradle.org/plugin/com.palantir.docker
docker {
  name "${dockerOwner}/${project.name}:${project.version}"
  files "$buildDir/libs/${project.name}-${project.version}.jar"
  dockerfile file("$buildDir/Dockerfile")
}

// https://plugins.gradle.org/plugin/com.palantir.docker-run
dockerRun {
  name "${project.name}"
  image "${dockerOwner}/${project.name}:${project.version}"
  ports '8080:8080','8081:8081'
  clean true
  daemonize false
}

Here is the source for my full build.gradle.

With the Palantir docker plugins now added, you should be able to see new tasks available to gradle.

$ ./gradlew tasks | grep ^docker

docker - Builds Docker image.
dockerClean - Cleans Docker build directory.
dockerfileZip - Bundles the configured Dockerfile in a zip file
dockerPrepare - Prepares Docker build directory.
dockerPush - Pushes named Docker image to configured Docker Hub.
dockerPush0.0.2-SNAPSHOT - Pushes the Docker image with tag '0.0.2-SNAPSHOT' to configured Docker Hub
dockerTag - Applies all tags to the Docker image.
dockerTag0.0.2-SNAPSHOT - Tags Docker image with tag '0.0.2-SNAPSHOT'
dockerTagsPush - Pushes all tagged Docker images to configured Docker Hub.
dockerNetworkModeStatus - Checks the network configuration of the container
dockerRemoveContainer - Removes the persistent container associated with the Docker Run tasks
dockerRun - Runs the specified container with port mappings
dockerRunStatus - Checks the run status of the container
dockerStop - Stops the named container if it is running

Create template Dockerfile

Create a template Dockerfile in the resources directory.  Here is the full source for my Dockerfile.

mkdir -p src/main/resources/docker

# use content below for file
vi src/main/resources/docker/Dockerfile

FROM openjdk:19-slim-buster

# create non-root user and group
# -l and static IDs assigned to avoid delay in lookups and system logging
ARG THE_USER_ID=1001
ARG THE_GROUP_ID=1001
RUN DEBIAN_FRONTEND=noninteractive && \
/usr/sbin/groupadd -g $THE_GROUP_ID spring && \
/usr/sbin/useradd -l -u $THE_USER_ID -G spring -g $THE_GROUP_ID spring && \
mkdir logs && chgrp spring logs && chmod ug+rwx logs

# run as non-root
USER spring:spring

# main REST service and OpenAPI /swagger-ui/index.html
EXPOSE 8080
# actuator metrics /actuator/health, /actuator/prometheus
EXPOSE 8081

COPY <%=name%>-<%=version%>.jar app.jar
CMD ["java","-jar","app.jar"]

The ‘src/main/resources/docker/Dockerfile’ is a template so that we do not have to hardcode the name and version of the final jar. It contains the line:

COPY <%=name%>-<%=version%>.jar app.jar

Which is filtered by build.gradle’s prepareDockerfileTemplate task to produce the final Dockerfile which is placed in the build directory.

Configure application.properties

We provide a minimal set of application configuration values.  Here is my full application.properties.

# use content below for file
vi src/main/resources/application.properties

# default profile made explicit
spring.profiles.active=dev

# avoid bug where PetStore config used by Swagger
springdoc.swagger-ui.disable-swagger-default-url=true
# do not add test Controller at /info to docs
springdoc.pathsToExclude=/info

# Actuator endpoints enabled, uses independent port
management.endpoints.enabled-by-default=false
management.endpoints.web.exposure.include=health,info,prometheus
management.endpoint.health.show-details: always
management.server.port=8081

# allow restarts on changes that need recompile
spring.devtools.restart.enabled=true

Configure logging

Since this service is meant to be deployed as a container, we could get by with the most basic logging configuration where we set the levels in application.properties

# logging levels to console, which is typical for containers
logging.level=WARN
logging.level.org.fabianlee.springbootwithdockerpush=INFO

But, if we wanted the more logging complexity in the future, it is best to instead create a simple logback configuration at ‘src/main/resources/logback-spring.xml’.  Here is my full logback-spring.xml.

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

  <appender name="stdout" class="ch.qos.logback.core.ConsoleAppender">
    <layout class="ch.qos.logback.classic.PatternLayout">
    <Pattern>%black(%d{ISO8601}) %highlight(%-5level) [%blue(%t)] %yellow(%C{1.}): %msg%n%throwable</Pattern>
    </layout>
  </appender>

  <root level="warn">
    <appender-ref ref="stdout"/>
  </root>

  <logger name="org.fabianlee.springbootwithdockerpush" level="info" additivity="false">
    <appender-ref ref="stdout" />
  </logger>

</configuration>

This uses the same logic where everything is logged to the console at the WARN level, expect for our packages which are set to INFO.  If you ever needed more advanced capabilities such as sending the logs to a syslog server or in json format, this file could be customized.

Create first test endpoint

As a basic test, the Controller below exposes build.gradle and application.properties at the ‘/info’ web context.  Here is the full source for Info.java

mkdir -p src/main/java/org/fabianlee/springbootwithdockerpush/user

# use content below for Info.java
vi src/main/java/org/fabianlee/springbootwithdockerpush/Info.java

package org.fabianlee.springbootwithdockerpush;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.Iterator;
import java.util.Map;
import java.util.TreeMap;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.info.BuildProperties;
import org.springframework.http.MediaType;

@Controller
public class Info {

    // pull from build.gradle via 'springBoot' directive
    @Autowired
    private BuildProperties buildProperties;

    // pull from application.properties
    @Value("${management.server.port}")
    protected String mgmtPort;

    @RequestMapping(value="/info",method=RequestMethod.GET,produces = MediaType.TEXT_PLAIN_VALUE)
    public @ResponseBody String metrics() {

        // create map of all keys we want reported back
        Map<String,String> kv = new TreeMap<String,String>();
        kv.put("key1","0.0");
        kv.put("management_server_port",mgmtPort);
        kv.put(String.format("springbootwithdockerpush{version=\"%s\"}",buildProperties.getVersion().toString()),"0.0");
        kv.put(String.format("springbootwithdockerpush{name=\"%s\"}",buildProperties.getName().toString()),"0.0");
        kv.put(String.format("springbootwithdockerpush{group=\"%s\"}",buildProperties.getGroup().toString()),"0.0");

        // build sorted, line-by-line for each key
        StringBuffer sbuf = new StringBuffer();
        for (Iterator<String> it=kv.keySet().iterator(); it.hasNext();) {
            String key = it.next();
            sbuf.append(key + " " + kv.get(key) + "\n");
        }

        return sbuf.toString();
    }
   
}

Create simple REST endpoint

We are going to create a very small REST service, one that exposes a list of User object and allows you to either list all users or delete a user.

Here is the full source for User.java

# use content below for file contents
vi src/main/java/org/fabianlee/springbootwithdockerpush/user/User.java

package org.fabianlee.springbootwithdockerpush.user;

public class User {
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public User(String name) {
        this.name = name;
    }
}

And then create the UserController REST controller.  Here is the full source for UserController.java

# use content below for file contents
vi src/main/java/org/fabianlee/springbootwithdockerpush/user/UserController.java

package org.fabianlee.springbootwithdockerpush.user;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Supplier;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import io.micrometer.core.annotation.Timed;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;

@RestController
@CrossOrigin(origins = "http://localhost:8080")
@RequestMapping("/api/user")
public class UserController {

    public static final Logger logger = LoggerFactory.getLogger(UserController.class);

    private List userListV1 = new ArrayList(
        Arrays.asList(new User("moe"),new User("larry"),new User("curly"))
    );
    public Supplier fetchUserCount() {
        return ()->userListV1.size();
    }

    // constructor injector for exposing metrics at Actuator /prometheus
    public UserController(MeterRegistry registry) {

        Gauge.builder("usercontroller.usercount",fetchUserCount()).
            tag("version","v1").
            description("usercontroller descrip").
            register(registry);

    }

    @Timed(value="user.get.time",description="time to retrieve users",percentiles={0.5,0.9})
    @Operation(summary = "get list of users")
    @ApiResponse(responseCode = "200")
    @GetMapping
    public Iterable findAllUsers() {
        logger.debug("doing findAllUsers");
        logger.info("doing findAllUsers");
        logger.warn("doing findAllUsers"); 
        logger.error("doing findAllUsers");
        return userListV1;
    }

    @Operation(summary = "delete last user")
    @ApiResponse(responseCode = "200")
    @DeleteMapping
    public void deleteUser() {
        logger.debug("called deleteUser");
        if(userListV1.size()>0) {
            userListV1.remove(userListV1.size()-1);
        }
    }

}

This REST service will be available at ‘http://localhost:8080/api/user’ and has a GET method for listing all users and DELETE method for deleting the last user.

The GET user listing logs messages at all log levels to prove out console logging levels.

The constructor with MeterRegistry injection is what enables our custom Gauge count of users to be included at ‘http://localhost:8081/actuator/prometheus’.

Enable custom Actuator health check

The Actuator dependency has surfaced a standard health check at ‘http://localhost:8081/actuator/health’, but to add our own custom health check to contribute to the status we need to create a custom class that implements HealthIndicator.

Here is the full source for UserHealthIndicator.java

# use content below for file contents 
vi src/main/java/org/fabianlee/springbootwithdockerpush/user/UserHealthIndicator.java

package org.fabianlee.springbootwithdockerpush.user;

import org.fabianlee.springbootwithdockerpush.user.UserController;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;

@Component
public class UserHealthIndicator implements HealthIndicator {

  @Autowired
  protected UserController userController;

  @Override
  public Health health() {
    int userCount = ((Number)userController.fetchUserCount().get()).intValue();
    if (userCount>0)
      return new Health.Builder().up().withDetail("usercount", userCount).build();
    else
      return new Health.Builder().down().withDetail("usercount", userCount).build();
  }

}

Validate web app locally

Before we move on to packaging this application into a Docker image, we will first validate the application endpoints and behavior so we understand what to expect from the deployed container.

Run Jar on local development server

Run the web application using the built-in internal Tomcat server.

./gradlew bootJar bootRun

Given our settings in application.properties, this will expose the main service at port 8080 and Actuator metrics at port 8081.

Validate Simple ‘:8080/info’ endpoint

The Info.java Controller exposes itself at ‘/info’ and pulls from application.properties to provide the Actuator management port value of 8081, and the build.gradle properties for the group, name, and version in a text format that is similar to Prometheus metrics.

$ curl http://localhost:8080/info
key1 0.0
management_server_port 8081
spring_micro_with_actuator 0.0
springbootwithdockerpush{group="org.fabianlee"} 0.0
springbootwithdockerpush{name="springbootwithdockerpush"} 0.0
springbootwithdockerpush{version="0.0.2-SNAPSHOT"} 0.0

Validate User Controller endpoint ‘:8080/api/user’ lists all users

The UserController.java exposes a REST compliant GET method at ‘/api/user’ that returns the list of currently known User objects in json format.

$ curl -s http://localhost:8080/api/user | jq
[
  {
    "name": "moe"
  },
  {
    "name": "larry"
  },
  {
    "name": "curly"
  }
]

Validate console logs shown when GET /api/user was invoked

The application.properties is set to log to the console at INFO level for our custom ‘org.fabianlee.springbootwithdockerpush.user’ package.  Verify that you see only INFO, WARN, and ERROR lines (and not DEBUG) at the console where ‘bootRun’ is active.

2022-06-29 19:31:44.545 INFO 3292052 --- [nio-8080-exec-2] o.f.s.user.UserController : doing findAllUsers
2022-06-29 19:31:44.545 WARN 3292052 --- [nio-8080-exec-2] o.f.s.user.UserController : doing findAllUsers
2022-06-29 19:31:44.545 ERROR 3292052 --- [nio-8080-exec-2] o.f.s.user.UserController : doing findAllUsers

Validate custom health check at ‘:8081/actuator/health’

The health check will return status=UP as long as the user count is greater than 0.

$ curl -s http://localhost:8081/actuator/health | jq
{
  "status": "UP",
  "components": {
    "diskSpace": {
      "status": "UP",
      "details": {
        "total": 502467059712,
        "free": 385481076736,
        "threshold": 10485760,
        "exists": true
      }
    },
    "ping": {
      "status": "UP"
    },
    "user": {
      "status": "UP",
      "details": {
        "usercount": 3
      }
    }
  }
}

Validate custom Prometheus metrics at ‘/actuator/prometheus’ matches user count

The UserController.java constructor with MeterRegistry injection creates a Gauge that exposes the user count.

$ curl -s http://localhost:8081/actuator/prometheus | grep ^usercontroller_usercount
usercontroller_usercount{version="v1",} 3.0

Validate Timed method metrics at ‘actuator/prometheus’

The UserController.java finalAllUsers() method is annotated with the @Timed annotation, which exposes requests counts and quantile response times.

$ curl -s http://localhost:8081/actuator/prometheus | grep ^user_get_time_seconds
user_get_time_seconds_max{exception="None",method="GET",outcome="SUCCESS",status="200",uri="/api/user",} 0.0
user_get_time_seconds{exception="None",method="GET",outcome="SUCCESS",status="200",uri="/api/user",quantile="0.5",} 0.0
user_get_time_seconds{exception="None",method="GET",outcome="SUCCESS",status="200",uri="/api/user",quantile="0.9",} 0.0
user_get_time_seconds_count{exception="None",method="GET",outcome="SUCCESS",status="200",uri="/api/user",} 1.0
user_get_time_seconds_sum{exception="None",method="GET",outcome="SUCCESS",status="200",uri="/api/user",} 0.095670809

Validate deletion of users

There are 3 users when the application is started.  But you can remove them one at a time by calling the API endpoint with the DELETE method.

$ curl -s -X DELETE http://localhost:8080/api/user | jq

# now only 2 users
$ curl -s http://localhost:8080/api/user | jq
[
{
"name": "moe"
},
{
"name": "larry"
}
]

# empty users list by calling twice more
$ curl -s -X DELETE http://localhost:8080/api/user | jq
$ curl -s -X DELETE http://localhost:8080/api/user | jq

# verified that users list is now empty
$ curl -s http://localhost:8080/api/user | jq
[]

Validate failed health when User count is 0

With the user list now emptied, the status will now be reported as DOWN as controlled by the logic in UserHealthIndicator.java.

$ curl -s http://localhost:8081/actuator/health | jq
{
  "status": "DOWN",
  "components": {
    "diskSpace": {
      "status": "UP",
      "details": {
        "total": 502467059712,
        "free": 385480744960,
        "threshold": 10485760,
        "exists": true
      }
    },
    "ping": {
      "status": "UP"
    },
    "user": {
      "status": "DOWN",
      "details": {
        "usercount": 0
      }
    }
  }
}

Validate custom prometheus metric at ‘/actuator/prometheus’

The custom Gauge created in the UserController constructor with MeterRegistry injection will also report that the user count is 0.  This count could trigger an alert in a Prometheus solution that had a custom rule to monitor this expression.

$ curl -s http://localhost:8081/actuator/prometheus | grep ^usercontroller_usercount
usercontroller_usercount{version="v1",} 0.0

Validate OpenAPI/Swagger UI

Because of the ‘org.springdoc:springdoc-openapi-ui’ dependency added to build.gradle, the OpenAPI/Swagger UI page is also available as documentation and a test harness for the UserController.java REST API.

Going to http://localhost:8080/swagger-ui/index.html delivers the page below to your web browser.

 

Test local Docker build

Use the ‘docker’ task to build the Docker image locally.

# builds image
$ ./gradlew bootJar docker

BUILD SUCCESSFUL in 2s
9 actionable tasks: 5 executed, 4 up-to-date

We should be able to see this image in the local Docker images.

$ sudo docker images | grep springbootwithdockerpush
fabianlee/springbootwithdockerpush 0.0.2-SNAPSHOT 51d6d5ddc0fe About a minute ago 436MB

Test local Docker run

We can run this Docker image locally using ‘dockerRun’.

# runs image in foreground
./gradlew dockerRun

Feel free to go through all the validations again, the same endpoints at port 8080 and 8081 are available when run as a local Docker image.  Logging continues to go to the stdout console.

Test push to Docker Hub

If you have an account on Docker Hub, then you should be able to push the image for public consumption but first you must login via the CLI.

# establish login to hub.docker.com
sudo docker login -u <userid> -p <password>

# use gradle to push image to hub.docker.com for public consumption
./gradlew dockerPush

You can validate this public image at https://hub.docker.com/repository/docker/fabianlee/springbootwithdockerpush

Full github project for article

If instead of copying all the files described above, you simply want to download the project from github, then:

git clone https://github.com/fabianlee/spring-boot-with-docker-push.git
cd spring-boot-with-docker-push

# run app locally with embedded Tomcat server
./gradlew bootJar bootRun

# create local Docker image, run container locally
./gradlew docker dockerRun

# push Docker image to hub.docker.com
sudo docker login -u <userid> -p <password>
./gradlew dockerPush

 

REFERENCES

Baeldung, Spring Boot Tomcat configuration

Tom Gregory, automating docker builds with gradle and Palantir plugin

Gradle, Palantir plugins

linoxide.com, installing OpenJDK on Ubuntu 20.04

linuxcapable.com, OpenJDK-17 on Ubuntu 20.04

github, Palantir source

springdoc, OpenAPI available properties for configuration

github fabianlee, project code for this article

docker hub, public location of image created from this project