UnmazedBoot: Generic SpringBoot Docker Images to Build/Link/Run your application in seconds!
Do you write SpringBoot applications for the Cloud? How many times did you copy and paste the same Dockerfile from https://spring.io/guides/gs/spring-boot-docker/, over and over again? I have not once, not twice, more than a couple of times! Now, imagine updating settings on all of them! Although the tutorial is just an illustration on how you can dockerize a SpringBoot application, parameterized Multi-stage builds can be used to split the process of building the Jar and running it in an array of base Docker images.
As part of the Intuit Platform team, a group responsible to provide guidelines and best practices to Engineering teams, UnmazedBoot is an open-source solution for building and running SpringBoot apps in Containers, be it in Kubernetes or Docker Swarm clusters. All in all, users will have a well-defined and simplified process way to maintain Dockerfiles focused only on the application build and runtime requirements rather than in the SpringBoot boilerplate code. The code is Open-source is located at https://github.com/intuit/unmazedboot. Dockerize your SpringBoot application in just 3 steps!
Unmazed Basic Constructs
In order to understand the reasons why I wrote UnmazedBoot, it’s worth mentioning the basis of this work. It includes different advanced concepts used by the Docker community to build and run Docker containers in any infrastructure.
- Multi-stage Docker builds: how teams can split the process of building a Jar and running it.
- Dynamic Dockerfiles: how teams can reuse parameterized Dockerfiles.
Multi-stage Docker Builds
I learned about the Docker Multi-stage builds at DockerCon17, a concept later introduced on version 17.05, that helps to create Docker images for building and running an application. As described in the article Multi-stage Docker Images for Java Apps, the process is divided into two steps using two different images (Image credited to Moby Project presentation):
- buildbase: builds the application using a selected Build binary. This is usually a very large image that contains all the needed binary to build the application is it NOT used during runtime of the application.
- runbase: is the image that runs the application as a Docker Container, which is the smaller image containing only the required binary to run the application.
Both images are usually the ones blessed by your Engineering team. The advantages of this process is a smaller Runtime Docker image that is more secure and performant, as summarized by Google’s Kubernetes Best Practices. In other words, the resulting runtime image has a much smaller surface area for attacks because it only has the required binaries to run, with an added consequence of the improved performance during the build phase based on the Docker Cache capabilities and faster Docker Image downloads from the infrastructure that requires the Docker Image.
Dynamic Dockerfiles
Along with Multi-stage builds, we can also use build parameters and create the already-described Dynamic Dockerfiles. Those Dockerfiles goes through the process of parameterizing the Docker Builds along with Continuous Integration (CI) systems inputs. For example, when a build is executed by a DockerHub build, different build parameters such as Github Branch and Github SHA are automatically passed to the Docker Build Context.
One of the use cases is the injection of the build information into the Runtime image. There are even debates over the replacement of the regular semantic number to the use of the Git SHA as the artifact version (image credit). Those parameters can be easily injected to the Docker Runtime image as environment variables or files produced during the build process to assist Engineers to debug problems on any of the tools used at the Observability tools.
Unmazed Builder, Linker and Runner
After splitting the process, I realized that we had a small but complex step of generalizing the build processes. First, Engineering teams are autonomous and decide which build tools to use. Second, the Runtime image is usually one of the blessed ones by the Engineering organization that require Security approvals. Following this trail, I then decided to implement the first version of the buildbase and runbase image as follows:
- Builder images: All the different variations of build images used by different teams. Those are, in fact, the implementation of the buildbase step with full JDK images such as Gradle and Maven.
- Runner images: All the different variations of runtime images that support the runtime of the containers. That is, the current array of JREs blessed by security as the runbase step dependent on a given OS.
Overall, the process described at Multi-stage Docker Images for Java Apps describes the simplified deliverables from the builder to the runner:
- A selected Builder Image implements the steps to build an executable Jar.
- Any of the selected Runner images use the executable Jar to execute the container.
- An optional step is to create a custom JRE for your application if you are running JDK 9 or newer. As announced by Oracle, JDK 11 is the long-term supported version.
These two steps are described in the next sections.
Application Builder
Different teams use different build systems such as Gradle and Maven because of culture or performance (image credit). As a Platform team, we need to serve the overall Engineering population needs and provide the different versions of each of those. As a result, generalizing the build process required supporting more than one build system in a systematic way. That is, the set of buildbase images is a matrix of all the different versions of each build system across different versions of JDK required to build the application and the different Operating Systems required.
As SpringBoot supports both Gradle and Maven, the requirement to use one of the Builder images as follows:
- Select the builder image as the combination of Gradle/Maven and Operating System.
- Include the dependency for the creation of SpringBoot Executable Jar in the build script using the related method (Gradle or Maven).
Those steps are indeed generalized in our solution and users requiring either Gradle or Maven can use the same Builder Images.
Application Runner
The application runner is the Docker image that will be running the SpringBoot application. Depending on the requirements of your company, the runner image is based on a different OS distribution. We started with the following base Runner images:
- Alpine, the default and lightest of the images with the base image with the package “apk” to install binary dependencies.
- Debian, the popular Open-source version that is used by most of the OpenJDK, containing the “apt-get” tool to install binary dependencies.
- Centos, the Open-source version of the Enterprise RedHat Linux, which can be used to run OpenJDK and other binaries using the tool “yum”.
Each of the Runner is just the parent Java Runtime Environment (JRE) from one of the Open-source versions that maintain the distribution.
Application Linker
The application linker is the next generation of how all Java developers should be migrating to newer versions of production deployments running JRE. If you have any plans to go migrate from JRE 1.8 to JRE 11, which is the long-term supported version, this is the way to go! Technically speaking, it’s possible to compile your application in a JDK 1.8 and run it in any other newer version or any custom JVM.
Starting at JDK 9, we can create a Custom JVM that includes only the modules that include the packages used by your application. That is, by introspecting your application Jar with the JRE binary “jmods”, we can use the JRE binary “jlink” to create the smallest JVM needed by your application (DockerCon2018 Java in a Container World with slide credit). This includes all the binaries of a regular JRE based on the operating system in which the command was executed. For instance, Alpine linux will use “musl” while Debian and Centos will use “glibc”. In this way, you will have to select a compatible base Linux image when using this feature.
As a result, the final Runner image is the mix of the base image and the custom JVM, which will have better bootstrap performance and smaller footprint as shown below. In addition, enabling Application Class-Data Sharing, the bootstrap of application are even faster when enabled with “Jlink”. Take a look at the AppCDS intro to have a better understanding of how this feature can improve the bootstrap time of your application.
Enter and Leave the SpringBoot Maze
I’m a big fan of Westworld and I was fascinated with the first two seasons about the concept behind the Westworld Maze. In my humble opinion, Dockerizing and maintaining the build and runtime of SpringBoot applications is similar to entering a Maze of complex steps and then leaving it. The larger your Engineering group is, the larger the drift of how the application images will be. Each individual Developer and Operation Engineer will have a different input on the way the images are built and run and, as a consequence, we are left with a mix of different ways to build applications as well as how to maintain Operational aspects of the runtime settings in different deployment infrastructures.
UnmazedBoot is a set of Parameterized Docker images that takes advantages of Multi-stage builds to create a runtime image for SpringBoot applications. That is, a selected Builder image is responsible to build a SpringBoot application Jar and wrap it up on a Runner image during the steps of Continuous Integration (CI). This same exact image built is the one used by your Continuous Delivery (CD) service to bootstrap a container of the application built. Since we added a couple of features that can be toggled with Build-time and Runtime environment variables, we can say we have achieved a new level of Docker image reusability that is closer to a Dockerized Framework. That is, the entrypoint binary depends on both the Build arguments to build specific build and runtime images customized by the user through Environment variables.
In order to use the UnmazedBoot images, you just need to select one of each of the Builder and Runner images that your application use. In the case of migrating from JRE1.8 to JRE11, you can use a Linker image and the associated base Runner image that is described in the Github repo, as JRE11 is the long-term supported JVM. Another advanced feature UnmazedBoot supports is reusing intermediary images for running tests and any other custom execution based on the sources located at the Builder image. Those and other use cases are described in the Github Repo https://github.com/intuit/unmazedboot. The list of all Docker Images can be seen by executing the command “docker-compose config | grep image:” or going to dockerhub.
UnmazedBoot Dockerized Framework
Given the requirements and capabilities above, we have created something that’s more like a framework that is driven by environment variables in both Build and Runtime. Users will follow the steps as follows. For the examples below, the current values of the VERSION environment variables are all set to 0.5.0. As part of the explanation, I followed the initial steps of the tutorial https://spring.io/guides/tutorials/spring-boot-kotlin/ to generate a sample code to use UnmazedBoot from scratch.
Builder
- Users provide Build Parameters to create a new Build, including the commands used to generate the executable Jar, the location according to the build script, the extension, etc. Verify if there isn’t more properties in the Github repo README.
- Users select one of the available Builder images and creates a layer named “unmazedboot-builder-artifacts”.
Runner
- Users provide the port number of which the application is bind and select one of the available Runner images.
Linker
- If you decide to run the sample code without migrating the code to the JRE11, you can add the Linker image to build a custom JRE based on the app’s jar, as described above.
Sample Code
Following the documentation and existing samples, I decided to create a Gradle Kotlin application. The initial application just runs fine with Gradle, but let’s see how to create an UnmazedBoot Dockerfile. First, looking at the documentation, the parameters for the Builder image and Runner are quite clear. We need to select the build command-line, the directory location of the builds, the extension of the executable Jar, while choosing the Port number of the running application. Then, I selected the Builder Image and Runner image that are appropriate for my requirements. The result is a very clean Dockerfile, that can be extended even more. Since this is a child Image, all the ONBUILD instructions will be executed during the build of the final image.
The build is driven by a Docker-Compose file and can be used to build the sample code.
$ docker-compose -f docker-compose-samples.yml build samples-gradle-kotlin-jdk8-jre8
Building samples-gradle-kotlin-jdk8-jre8
Step 1/8 : ARG UNMAZEDBOOT_BUILDER_GRADLE_BUILD_CMD="gradle build -x test"
Step 2/8 : ARG UNMAZEDBOOT_BUILDER_DIR="build/libs"
Step 3/8 : ARG UNMAZEDBOOT_BUILDER_PACKAGE_EXTENSION="jar"
Step 4/8 : ARG UNMAZEDBOOT_BUILDER_GRADLE_VERSION=${UNMAZEDBOOT_BUILDER_GRADLE_VERSION:--latest}
Step 5/8 : ARG UNMAZEDBOOT_RUNNER_PORT="8080"
Step 6/8 : ARG UNMAZEDBOOT_RUNNER_VERSION=${UNMAZEDBOOT_RUNNER_VERSION:--latest}
Step 7/8 : FROM intuit/unmazedboot-builder-gradle:4.10.2-jdk8-alpine${UNMAZEDBOOT_BUILDER_GRADLE_VERSION} as unmazedboot-builder-artifacts
# Executing 18 build triggers
---> Using cache
---> Using cache
...
...
---> Using cache
---> Using cache
---> e57b60923ea1Step 8/8 : FROM intuit/unmazedboot-runner:openjdk-8u181-debian9-slim${UNMAZEDBOOT_RUNNER_VERSION}
# Executing 35 build triggers
---> Using cache
---> Using cache
---> Using cache
---> Using cache
...
...
---> Using cache
---> Using cache
---> Using cache
---> Using cache
---> Using cache
---> Running in 378ba1f263b5
Removing intermediate container 378ba1f263b5
---> Running in 109f52285830
Removing intermediate container 109f52285830
---> Running in 255dacfdb296
Removing intermediate container 255dacfdb296
---> Running in 8ee52f2a6dbf
Finished preparing image with env vars BUILD_COMMIT= from BUILD_BRANCH=
Removing intermediate container 8ee52f2a6dbf
---> e895b8aa10a6Successfully built e895b8aa10a6
Successfully tagged intuit/unmazedboot-sample-kotlin:jdk8-jre8-debian-0.5.0
Running the application then is simple and you can see that is super simple! With just a single Runner image. Note that the application is bootstrapped and a start script runs the application. This start script is part of a runtime framework that takes environment variables and files in any given volume mounted in the app as parameters. This is flexible, generic and can be further extended by users!
Runtime Framework
The runner image entrypoint script contains facilities to deal with the following features usually used in production deployments in Kubernetes and Docker Swarm:
- Init scripts: You can add init scripts to be executed before the application runs! Those scripts can be added during the image build time or injected. If you are required to run any executable, you can quickly inject them at build time to a specific init script directory. All the scripts will be executed before the Java command is, guaranteeing that your requirement is met!
- JVM hooks: a special way to set up the value of environment variables that the JVM expects. For instance, defining the JAVA_OPTS by using a single deployment value or by multiple values from files in a volume. This is useful when using Init containers as side-cars and injecting values.
- Pre-sources: source any extra script that you need to add into the Runner image. Again, some deployment scripts may require computed environment variables based on the current system such as the Container name to be passed to the JAVA_OPTS.
JVM Hooks
- It’s common to add the creation of JAVA_OPTS either in a shell script or a concatenation of all things needed. In addition, JAVA_OPTS may depend on agents available in given environments
- For a given environment, you can inject sources that will compute what’s needed
- In this case, something that is run before JAVA_OPTS gets created.
The support for hooks is added as a feature that uses an environment injector pattern. This is useful when dealing with containers in Kubernetes that are initialized via initContainer and requires sources.
Customizing for your Enterprise
You can just fork the project and customize the Dockerfiles to add extra functionality to those images. For instance, if you don’t want to use the base images provided, you can reuse the Dockerfile to use your Enterprise base images.
You can customize any of the builder, runner and linker images by forking the project and structuring them the way your team needs!