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
linoxide.com, installing OpenJDK on Ubuntu 20.04
linuxcapable.com, OpenJDK-17 on Ubuntu 20.04
springdoc, OpenAPI available properties for configuration
github fabianlee, project code for this article
docker hub, public location of image created from this project