Quarkus 2 + Kubernetes Maven Plugin + GraalVM integration
Introduction
In this tutorial, we'll see how to develop and integrate a very simple Quarkus 2 application with Kubernetes Maven Plugin (Eclipse JKube) to publish a native GraalVM image into Docker Hub and deploy it on Kubernetes.
This is a remake of my original article Quarkus + Fabric8 Maven Plugin + GraalVM integration, since the Fabric8 Maven Plugin is now deprecated.
In the first part, I describe how to build a very simple Quarkus application. Next, I describe how to build a Quarkus native executable with GraalVM. Finally, I show how to integrate the project with Kubernetes Maven Plugin and how to publish the application container images into Docker Hub and deploy them to Kubernetes.
Quarkus 2 example application
In this section, I'll describe how to build a simple application that will return a random quote each time you perform a request to the /quotes/random
endpoint.
Project bootstrapping
Since you probably already have Maven installed in your system, the easiest way to bootstrap the project is by running the following command:
mvn io.quarkus:quarkus-maven-plugin:2.1.1.Final:create \
-DprojectGroupId=com.marcnuri.demo \
-DprojectArtifactId=kubernetes-maven-plugin-quarkus \
-DclassName="com.marcnuri.demo.kmp.quote.QuoteResource" \
-Dextensions='quarkus-resteasy-jackson'
If the command completes successfully, you will be able to see a new directory kubernetes-maven-plugin-quarkus
with an initial maven project with maven wrapper support.
However, if you don't have Maven installed, or if on the other hand, you prefer an interactive graphical user interface, you can navigate to code.quarkus.io to customize and download a bootstrapped project with your specific requirements.
Project resources
As I already explained, the application will serve a random quote each time a user performs a request to an endpoint. The application will load these quotes from a JSON file located in the project resources folder. For this purpose, you'll add the file quotes.json to the src/main/resources/quotes/
directory.
Random quote endpoint
Once we've got the resources set up, we can start with the code implementation. The first step is to create a Quote POJO that will be used to map the quotes defined in the JSON file when it's deserialized.
1public class Quote implements Serializable {
2 /** ... **/
3 private String content;
4 private String author;
5 /** ... **/
6}
Next, we'll create a QuoteService class to provide the service that reads the quotes from the resources directory and selects a random quote.
1@Singleton
2public class QuoteService {
3
4 private static final Logger log = LoggerFactory.getLogger(QuoteService.class);
5
6 private static final String QUOTES_RESOURCE= "/quotes/quotes.json";
7
8 private final List<Quote> quotes;
9
10 public QuoteService() {
11 quotes = new ArrayList<>();
12 }
13
14 @PostConstruct
15 protected final void initialize() {
16 final var objectMapper = new ObjectMapper();
17 try (final InputStream quotesStream = QuoteService.class.getResourceAsStream(QUOTES_RESOURCE)) {
18 quotes.addAll(objectMapper.readValue(quotesStream,
19 objectMapper.getTypeFactory().constructCollectionType(List.class, Quote.class)));
20 } catch (IOException e) {
21 log.error("Error loading quotes", e);
22 }
23 }
24
25
26 Quote getRandomQuote() {
27 return quotes.get(ThreadLocalRandom.current().nextInt(quotes.size()));
28 }
29
30}
The initialize
method uses Jackson to read and deserialize the quotes.json file into a member ArrayList
variable that will be used later on to fetch a random quote.
The getRandomQuote
method returns a random Quote
entry from the ArrayList
for each invocation.
To complete the application, we need to modify the bootstrapped REST endpoint to use the service we implemented. For this purpose, we'll modify the QuoteResource class.
1@Path("/quotes")
2public class QuoteResource {
3
4 private static final String HEADER_QUOTE_AUTHOR = "Quote-Author";
5
6 private QuoteService quoteService;
7
8 @GET
9 @Path("/random")
10 @Produces(MediaType.TEXT_PLAIN)
11 public Response getRandomQuote() {
12 final var randomQuote = quoteService.getRandomQuote();
13 return Response
14 .ok(randomQuote.getContent(), MediaType.TEXT_PLAIN_TYPE)
15 .header(HEADER_QUOTE_AUTHOR, randomQuote.getAuthor())
16 .build();
17 }
18
19 @Inject
20 public void setQuoteService(QuoteService quoteService) {
21 this.quoteService = quoteService;
22 }
23}
The method getRandomQuote
uses an instance of the previously described QuoteService
class to get a random quote and return its content in the HTTP response body. In addition, the author of the quote is also added as a Response header.
Once we complete all the steps, we can start the application in development mode using the following command:
./mvnw clean compile quarkus:dev
[INFO] --- quarkus-maven-plugin:2.1.1.Final:dev (default-cli) @ kubernetes-maven-plugin-quarkus ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] skip non existing resourceDirectory D:\00-MN\projects\marcnuri-demo\kubernetes-maven-plugin-quarkus\src\test\resources
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 2 source files to D:\00-MN\projects\marcnuri-demo\kubernetes-maven-plugin-quarkus\target\test-classes
Listening for transport dt_socket at address: 5005
__ ____ __ _____ ___ __ ____ ______
--/ __ \/ / / / _ | / _ \/ //_/ / / / __/
-/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
2021-07-04 07:49:33,690 INFO [io.quarkus] (Quarkus Main Thread) kubernetes-maven-plugin-quarkus 1.0.0-SNAPSHOT on JVM (powered by Quarkus 2.1.1.Final) started in 3.774s. Listening on: http://localhost:8080
2021-07-04 07:49:33,693 INFO [io.quarkus] (Quarkus Main Thread) Profile dev activated. Live Coding activated.
2021-07-04 07:49:33,693 INFO [io.quarkus] (Quarkus Main Thread) Installed features: [cdi, resteasy, resteasy-jackson, smallrye-context-propagation]
--
Tests paused, press [r] to resume, [h] for more options>
The endpoint will now be accessible at http://localhost:8080/quotes/random.
Building a native executable with GraalVM
Now is the time to make our application supersonic, for this purpose we are going to use GraalVM to create a native binary of the application.
We are now going to adapt the application to make it fully compatible with GraalVM.
Include resources
By default, GraalVM won’t include any of the resources available on the classpath during image creation using native-image. Resources that must be available at runtime must be specifically included during image creation.
To configure GraalVM to account for our quotes.json
resource file, we need to modify the project's application.properties file and include the following line:
1quarkus.native.additional-build-args=-H:IncludeResources=.*\.json$
With this line, we indicate Quarkus to add the -H:IncludeResources
command-line flag to the native-image
command. In this specific case, we want to add any file that ends with the .json
extension.
Native image reflection
Jackson JSON deserialization uses reflection to create instances of the target classes when performing reads. Graal native image build requires to know ahead of time which kind of elements are reflectiveley accessed by the program.
Quarkus eases this task by providing a @RegisterForReflection
annotation that automates this task. For our example application, we’ll need to annotate the Quote class.
Building the native application
Now that we've completed adapting the application to be fully GraalVM compatible, we can perform the build in native mode. If Graal VM with native-image support is available in our system, we can simply run the following command:
./mvnw clean package -Pnative
[INFO] [io.quarkus.deployment.pkg.steps.NativeImageBuildRunner] D:\00-MN\bin\graalvm-ce-java11-21.1.0\bin\native-image.cmd
-J-Djava.util.logging.manager=org.jboss.logmanager.LogManager -J-Dsun.nio.ch.maxUpdateArraySize=100
-J-Dvertx.logger-delegate-factory-class-name=io.quarkus.vertx.core.runtime.VertxLogDelegateFactory
-J-Dvertx.disableDnsResolver=true -J-Dio.netty.leakDetection.level=DISABLED -J-Dio.netty.allocator.maxOrder=3
-J-Duser.language=en -J-Duser.country=US -J-Dfile.encoding=UTF-8 -H:IncludeResources=.*.json\$
--initialize-at-build-time=
-H:InitialCollectionPolicy=com.oracle.svm.core.genscavenge.CollectionPolicy\$BySpaceAndTime -H:+JNI
-H:+AllowFoldMethods -jar kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner.jar -H:FallbackThreshold=0
-H:+ReportExceptionStackTraces -H:-AddAllCharsets -H:EnableURLProtocols=http -H:-UseServiceLoaderFeature
-H:+StackTrace kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner
[kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner:2772] classlist: 2,473.21 ms, 0.96 GB
[kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner:2772] (cap): 3,921.54 ms, 0.96 GB
[kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner:2772] setup: 6,147.99 ms, 0.96 GB
08:52:27,268 INFO [org.jbo.threads] JBoss Threads version 3.4.0.Final
[kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner:2772] (clinit): 664.62 ms, 4.67 GB
[kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner:2772] (typeflow): 16,865.10 ms, 4.67 GB
[kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner:2772] (objects): 20,902.79 ms, 4.67 GB
[kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner:2772] (features): 945.86 ms, 4.67 GB
[kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner:2772] analysis: 40,710.23 ms, 4.67 GB
[kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner:2772] universe: 1,702.71 ms, 4.67 GB
[kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner:2772] (parse): 4,527.99 ms, 4.67 GB
[kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner:2772] (inline): 8,267.27 ms, 5.83 GB
[kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner:2772] (compile): 24,709.00 ms, 5.69 GB
[kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner:2772] compile: 39,765.47 ms, 5.69 GB
[kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner:2772] image: 4,563.42 ms, 5.69 GB
[kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner:2772] write: 2,281.61 ms, 5.69 GB
# Printing build artifacts to: kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner.build_artifacts.txt
[kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner:2772] [total]: 98,272.86 ms, 5.69 GB
[WARNING] [io.quarkus.deployment.pkg.steps.NativeImageBuildRunner] objcopy executable not found in PATH. Debug symbols will not be separated from executable.
[WARNING] [io.quarkus.deployment.pkg.steps.NativeImageBuildRunner] That will result in a larger native image with debug symbols embedded in it.
[INFO] [io.quarkus.deployment.QuarkusAugmentor] Quarkus augmentation completed in 106997ms
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 02:04 min
[INFO] Finished at: 2021-07-04T08:53:44+02:00
[INFO] -----------------------------------------------------------------------
If the command is executed successfully, a new kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner
will be available in the target directory.
We can now run the application natively by executing the following command:
./target/kubernetes-maven-plugin-quarkus-1.0.0-SNAPSHOT-runner
Same as when we ran the application in JVM mode, the endpoint is now available at http://localhost:8080/quotes/random.
If GraalVM is not available in your system, but Docker is, the same command can be run inside a Docker container to build a Linux native binary:
./mvnw clean package -Pnative -Dquarkus.native.container-build=true
Kubernetes Maven Plugin (Eclipse JKube) integration
This is the final step in this tutorial. In this section, I'm going to show you how to integrate the project with Kubernetes Maven Plugin (Eclipse JKube).
The process is as simple as adding Eclipse JKube's Kubernetes Maven Plugin to our project's pom.xml
1<properties>
2 <!-- ... -->
3 <jkube.version>1.17.0</jkube.version>
4 </properties>
5 <!-- ... -->
6 <build>
7 <!-- ... -->
8 <plugins>
9 <!-- ... -->
10 <plugin>
11 <groupId>org.eclipse.jkube</groupId>
12 <artifactId>kubernetes-maven-plugin</artifactId>
13 <version>${jkube.version}</version>
14 </plugin>
15 </plugins>
16 </build>
The configuration is as straightforward as adding a <plugin>
entry with groupId
, artifactId
& version
to indicate that we want to use the Kubernetes Maven Plugin. In many cases, this may just be enough, as the plugin has a Zero-Config mode that takes care of defining most of the settings for us by analyzing the project's configuration inferring the recommended values.
Build Container (Docker) Image (k8s:build)
First, we need to remove the boiler-plate Dockerfiles that Quarkus provides for us in the src/main/docker
directory. Kubernetes Maven Plugin infers all of this configuration from the project, so there's no need to maintain these files and we can safely remove them.
Eclipse JKube's Zero-Config mode uses Generators and Enrichers which provide opinionated defaults. For this project, the only common setting we need to take care of is the one related to the authorization for the Docker Hub registry. In this case, we are configuring JKube to read the push credentials from the DOCKER_HUB_USER
and DOCKER_HUB_PASSWORD
environment variables.
For this purpose, you need to add the following to the plugin's configuration:
1<plugin>
2 <groupId>org.eclipse.jkube</groupId>
3 <artifactId>kubernetes-maven-plugin</artifactId>
4 <version>${jkube.version}</version>
5 <configuration>
6 <authConfig>
7 <push>
8 <username>${env.DOCKER_HUB_USER}</username>
9 <password>${env.DOCKER_HUB_PASSWORD}</password>
10 </push>
11 </authConfig>
12 </configuration>
13</plugin>
Since we developed the project with support both for Quarkus JVM and Native modes, we are going to generate 2 different container (Docker) images depending on the Maven profile we select.
Docker image running application with JVM
In Quarkus 2, fast-jar
is the default packaging for the JVM mode. This means, that unless stated otherwise (e.g. Maven Profile), the mvn package
command will output the necessary files for this mode. Since I'm going to publish these images to Docker Hub, I will name this image marcnuri/kubernetes-maven-plugin-quarkus:jvm
.
This will create an image for the marcnuri repository with the name kubernetes-maven-plugin-quarkus and jvm
tag.
In order to tweak JKube's Quarkus generator opinionated default for the image name, we need to set the jkube.generator.name
property. We can achieve this by adding the following entry to the pom.xml
global properties section:
1<properties>
2 <!-- ... -->
3 <jkube.version>1.17.0</jkube.version>
4 <jkube.generator.name>marcnuri/kubernetes-maven-plugin-quarkus:jvm</jkube.generator.name>
5 </properties>
We can now run the following command to build the Docker image:
./mvnw clean package k8s:build
[INFO] --- kubernetes-maven-plugin:1.17.0:build (default-cli) @ kubernetes-maven-plugin-quarkus ---
[INFO] k8s: Running in Kubernetes mode
[INFO] k8s: Building Docker image in Kubernetes mode
[INFO] k8s: Running generator quarkus
[INFO] k8s: quarkus: Using Docker image quay.io/jkube/jkube-java-binary-s2i:0.0.9 as base / builder
[INFO] k8s: [marcnuri/kubernetes-maven-plugin-quarkus:jvm] "quarkus": Created docker-build.tar in 7 seconds
[INFO] k8s: [marcnuri/kubernetes-maven-plugin-quarkus:jvm] "quarkus": Built image sha256:4947d
[INFO] k8s: [marcnuri/kubernetes-maven-plugin-quarkus:jvm] "quarkus": Tag with latest
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 16.113 s
[INFO] Finished at: 2021-07-04T16:22:27+02:00
[INFO] ------------------------------------------------------------------------
Docker image running application in native mode
The procedure for the native mode is very similar to the one we did earlier for JVM. In this case, I want to create an image with the following name: marcnuri/kubernetes-maven-plugin-quarkus:native
.
Since the project already contains a Maven profile for native, achieving this is as simple as overriding the jkube.generator.name
property for the native
profile.
1<profiles>
2 <profile>
3 <id>native</id>
4 <!-- ... -->
5 <properties>
6 <quarkus.package.type>native</quarkus.package.type>
7 <jkube.generator.name>marcnuri/kubernetes-maven-plugin-quarkus:native</jkube.generator.name>
8 </properties>
9 </profile>
10</profiles>
We can now run the following command to build the native Docker image:
./mvnw clean package k8s:build -Pnative
[INFO] --- kubernetes-maven-plugin:1.17.0:build (default-cli) @ kubernetes-maven-plugin-quarkus ---
[INFO] k8s: Running in Kubernetes mode
[INFO] k8s: Building Docker image in Kubernetes mode
[INFO] k8s: Running generator quarkus
[INFO] k8s: quarkus: Using Docker image registry.access.redhat.com/ubi8/ubi-minimal:8.1 as base / builder
[INFO] k8s: Pulling from ubi8/ubi-minimal
[INFO] k8s: Digest: sha256:df6f9e5d689e4a0b295ff12abc6e2ae2932a1f3e479ae1124ab76cf40c3a8cdd
[INFO] k8s: Status: Downloaded newer image for registry.access.redhat.com/ubi8/ubi-minimal:8.1
[INFO] k8s: Pulled registry.access.redhat.com/ubi8/ubi-minimal:8.1 in 3 seconds
[INFO] k8s: [marcnuri/kubernetes-maven-plugin-quarkus:native] "quarkus": Created docker-build.tar in 498 milliseconds
[INFO] k8s: [marcnuri/kubernetes-maven-plugin-quarkus:native] "quarkus": Built image sha256:5a1d5
[INFO] k8s: [marcnuri/kubernetes-maven-plugin-quarkus:native] "quarkus": Tag with latest
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 25.851 s
[INFO] Finished at: 2021-07-04T15:10:31Z
[INFO] ------------------------------------------------------------------------
Push image to Docker Hub (k8s:push)
Regardless of the packaging mode we choose (JVM or native), pushing the image into Docker Hub's registry is as easy as running the following command (provided that we have the required environment variables available):
./mvnw k8s:push
# or if a native image was built
./mvnw k8s:push -Pnative
Of course, this makes sense from a CI pipeline perspective. If you are running the command from your own secure local system, you can override the credentials this way:
./mvnw k8s:push -Djkube.docker.push.username=$username -Djkube.docker.push.password=$password
# or if a native image was built
./mvnw k8s:push -Pnative -Djkube.docker.push.username=$username -Djkube.docker.push.password=$password
In my case, I'm running this from a GitHub Actions Workflow:
Deploying the application to Kubernetes (k8s:apply)
Now that I've published my images to Docker Hub, I can safely deploy our application to Kubernetes.
The main advantage of JKube is that you don't need to deal with YAML and configuration files yourself. The plugin takes care of generating everything for you, so you only need to run the following command:
./mvnw k8s:resource k8s:apply
# or if a native image was built
./mvnw k8s:resource k8s:apply -Pnative
[INFO] --- kubernetes-maven-plugin:1.17.0:apply (default-cli) @ kubernetes-maven-plugin-quarkus ---
[INFO] k8s: Using Kubernetes at https://192.168.99.120:8443/ in namespace default with manifest D:\00-MN\projects\marcnuri-demo\kubernetes-maven-plugin-quarkus\target\classes\META-INF\jkube\kubernetes.yml
[INFO] k8s: Creating a Service from kubernetes.yml namespace default name kubernetes-maven-plugin-quarkus
[INFO] k8s: Created Service: target\jkube\applyJson\default\service-kubernetes-maven-plugin-quarkus-4.json
[INFO] k8s: Creating a Deployment from kubernetes.yml namespace default name kubernetes-maven-plugin-quarkus
[INFO] k8s: Created Deployment: target\jkube\applyJson\default\deployment-kubernetes-maven-plugin-quarkus-4.json
[INFO] k8s: HINT: Use the command `kubectl get pods -w` to watch your pods start up
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 8.678 s
[INFO] Finished at: 2021-07-06T06:13:35+02:00
[INFO] ------------------------------------------------------------------------
This is a very simple project, so I didn't create an Ingress to expose the service which means that now the application remains inaccessible. I'll cover the process of creating an Ingress in an additional post. However, if you are running the application in minikube and want to access your application, with JKube is as easy as running:
./mvnw k8s:resource k8s:apply -Djkube.enricher.jkube-service.type=NodePort
# or if a native image was built
./mvnw k8s:resource k8s:apply -Djkube.enricher.jkube-service.type=NodePort -Pnative
minikube service kubernetes-maven-plugin-quarkus
If everything goes well, a browser window will be opened and you'll be able to see a page like the following:
Conclusion
In this post, I've shown you how to develop and integrate a very simple Quarkus 2 application with GraalVM native image support and the Kubernetes Maven Plugin. In the first section, I demonstrated how to bootstrap the application and create a very simple REST endpoint that will return a random quote for each request. Next, I showed you how to configure the application to be fully compatible with GraalVM to be able to generate a native binary. Finally, I showed you how to configure Kubernetes Maven Plugin to be able to build container images and push them into Docker Hub's registry. In addition, I showed you how simple it is to deploy your application to Kubernetes using Eclipse JKube.
The full source code for this post can be found at GitHub.