Level Up your Spring Boot Java Application with GraalVM

1
0
Send lab feedback

Ways to Level Up Your Java Application with GraalVM

Introduction

This lab is for developers looking to understand more about how to containerise GraalVM Native Image applications.

GraalVM Native Image can compile a Java application ahead-of-time into a native executable. Only the code that is required at run time is included in the executable, and, therefore, the application will use a fraction of resources required by the JVM, start in milliseconds, and deliver peak performance with no warmup. The native executable can be also packaged into a lightweight container image for faster and more efficient deployment.

In addition, there are Maven and Gradle plugins for Native Image so you can easily build, test, and run Java applications as native executables.

Estimated lab time: 60 minutes

Lab Objectives

In this lab you will:

  • Add a basic Spring Boot application to a Docker image and run it
  • Build a native executable from this application, using GraalVM Native Image
  • Add the native executable to a Docker image
  • Shrink your application Docker image size with GraalVM Native Image & Distroless containers
  • See how to use the GraalVM Native Build tools, Maven Plugin in particular
  • Use GitHub Actions to automate the build of a native executable as part of a CI/CD pipeline

NOTE: If you see the laptop icon in the lab, this means you need to do something such as enter a command. Keep an eye out for it.

# This is where you will need to do something

STEP 1: Connect to a Virtual Host and Check the Development Environment

First, connect to a remote host in Oracle Cloud - you will develop your application on an Oracle Cloud compute host.

Your development environment is provided by a remote host: an OCI Compute Instance with Oracle Linux 8, 4 cores, and 32GB of memory. The Luna Labs desktop environment will display before the remote host is ready, which can take up to two minutes. To check if the host is ready,

  1. Double-click the Luna Lab icon on the desktop to open the browser.

    Luna Desktop Icon

  2. The Resources tab will be displayed. Note that the cog shown next to the Resources title will spin while the compute instance is being provisioned in the cloud. When the instance is provisioned (this may take up to 2 minutes), you will see a checkmark:

    Luna Resource Tab

  3. A Visual Studio (VS) Code window will open and automatically connect to the VM instance that has been provisioned for you. Click Continue to accept the machine fingerprint.

    VS Code Accept

    The VS Code window opens, connected to your remote compute instance, with the source code for this lab.

    If you don't hit Continue VS Code will show you a dialogue, shown below. Hit Retry and VS Code will ask you to accept the machine fingerprint. Hit Continue, as in the above step.

    VS Code Retry Connection

    Issues With Connecting to the Remote Development Environment

    If you encounter any issues with connecting to the remote development environment in VS Code, that are not covered above, we suggest that you try the following:

    • Close VS Code
    • Copy the Configure Script from the Resources tab and paste it into the Luna Desktop Terminal again
    • Repeat the above instructions to connect to the Remote Devleopment environment

You are now successfully connected to a remote host in Oracle Cloud!

Next, open a terminal within VS Code. This terminal will allow you to interact with the remote host. A terminal can be opened in VS Code through the menu: Terminal > New Terminal. Use this terminal in the rest of the lab.

VS Code may prompt you to install plugins and extensions. Ignore/close the notification prompts as this lab doesn't need any plugins and extensions.

Note on the Development Environment

Your development environment comes preconfigured with one of the latest GraalVM distributions, Oracle GraalVM for JDK 17 , as the Java environment for this lab.

You can easily check that by running these commands in the terminal:

java -version

native-image --version

Note: Oracle Cloud Infrastructure (OCI) provides Oracle GraalVM at no additional cost. See Oracle GraalVM - Key Benefits to learn more about the benefits of Oracle GraalVM.

STEP 2: Review the Sample Application

In this step, you are going to compile and package a Java application with a very minimal REST-based API. Then you will containerise this application using Docker.

The source code and build scripts for this application were provisioned for you and opened in VS Code. Take a look.

The application is built on top of the Spring Boot framework with the “GraalVM Native Support” dependency . There are two ways to build a Spring Boot application ahead-of-time:

  • Using Cloud Native Buildpacks to generate a lightweight container containing a native executable.
  • Using GraalVM Native Build Tools to generate a native executable.

This labs demonstrates the second way.

The application has two classes, which can be found in src/main/java:

Source Folder

  • com.example.demo.DemoApplication: The main Spring Boot class that also defines the HTTP endpoint, /jibber
  • com.example.demo.Jabberwocky: A utility class that implements the logic of the application

So, what does the application do? If you call the endpoint REST /jibber, it will return some nonsense verse generated in the style of the Jabberwocky poem , by Lewis Carroll. The program achieves this by using a Markov Chain to model the original poem (this is essentially a statistical model).

The application ingests the text of the poem, from which it creates a statistical model. The application uses the RiTa library to do the heavy lifting — build and use Markov Chains.

Below are two snippets from the utility class com.example.demo.Jabberwocky that builds the model. The text variable contains the text of the original poem. This snippet shows how the model is created and then populated with text. This is called from the class constructor and defined to be a Singleton (so only one instance of the class ever gets created).

this.r = new RiMarkov(3);
this.r.addText(text);

Here you can see the method to generate new lines of verse from the model, based on the original text.

public String generate() {
    String[] lines = this.r.generate(10);
    StringBuffer b = new StringBuffer();
    for (int i=0; i< lines.length; i++) {
        b.append(lines[i]);
        b.append("<br/>\n");
    }
    return b.toString();
}

To build the application, you are going to use Maven. The pom.xml file was generated using Spring Initializr and supports using Native Image Build Tools .

  1. Build the application. From the root directory of the repository, run the following commands in your shell:

    mvn clean package

    This will generate an "executable" JAR file, one that contains all of the application's dependencies and also a correctly configured MANIFEST file.

  2. Run this JAR file and then "ping" the application's endpoint to see what you get in return — put the command into the background using & so that you get the prompt back.

    java -jar ./target/jibber-0.0.1-SNAPSHOT.jar &
  3. Call the end point using the curl command from the command line.

    When you post the command into your terminal, VS Code may prompt you to open the URL in a browser, just close the dialogue, as shown below.

    VS Code

    Run the following to test the HTTP endpoint:

    curl http://localhost:8080/jibber

    Did you get the some nonsense verse back? So now that you have built a working application, terminate it and move on to containerising it.

  4. Bring the application to the foreground so you can terminate it.

    fg

    Enter <ctrl-c> to now terminate the application.

    <ctrl-c>

STEP 3: Containerise Your Java Application with Docker

Containerising a Java application as a Docker container is straightforward. You can build a new Docker image based on one that contains a JDK distribution. So, for this lab you will use a container with the Oracle Linux 8 image and NFTC Oracle JDK 17: container-registry.oracle.com/java/jdk-no-fee-term:17-oraclelinux8.

The following is a breakdown of the Dockerfile, which describes how to build the Docker image. See the comments to explain the contents.

FROM container-registry.oracle.com/java/jdk-no-fee-term:17-oraclelinux8 # Base Image

ARG JAR_FILE                   # Pass in the JAR file as an argument to the image build

EXPOSE 8080                    # This image will need to expose TCP port 8080, as this is the port on which your app will listen

COPY ${JAR_FILE} app.jar       # Copy the JAR file from the `target` directory into the root of the image
ENTRYPOINT ["java"]            # Run Java when starting the container
CMD ["-jar","app.jar"]         # Pass in the parameters to the Java command that make it load and run your executable JAR file

The Dockerfile to containerise your Java application can be found in the directory, 00-containerise.

  1. To build a Docker image containing your application, run the following commands from your terminal:

    docker build -f ./00-containerise/Dockerfile \
                --build-arg JAR_FILE=./target/jibber-0.0.1-SNAPSHOT.jar \
                -t localhost/jibber:java.01 .

    Query Docker to look at your newly built image:

    docker images

    You should see a new image listed.

  2. Run this image as follows:

    docker run --rm -d --name "jibber-java" -p 8080:8080 localhost/jibber:java.01
  3. Then call the endpoint as you did before using the curl command:

    curl http://localhost:8080/jibber

    Did you see the nonsense verse?

  4. Now check how long it took your application to startup. You can extract this from the logs, as Spring Boot applications write the time to startup to the logs:

    docker logs jibber-java

    For example, the application started up in 1.64s. Here is the extract from the logs:

    2022-03-09 19:48:09.511  INFO 1 --- [           main] com.example.demo.DemoApplication         : Started DemoApplication in 1.64 seconds (process running for 2.032)
  5. Now terminate your container and move on:

    docker kill jibber-java
  6. You can also query Docker to get the size of the image. We have provided a script that does this for you. Run the following in your terminal:

    docker images

    This prints out details of the image, the size of the image in MBs is the last column, which is around 590MB.

STEP 4: Build a Native Executable

Recap what you have so far: built a Spring Boot application with a HTTP endpoint, and successfully containerised it. Now you will look at how you can create a native executable from your application. This native executable is going to start really fast and use fewer resources than its corresponding Java application.

You can use the native-image tool from the GraalVM installation to build a native executable. But, as you are using Maven already, apply the GraalVM Native Build Tools for Maven , which will conveniently allow you to carry on using Maven.

You need to make sure that you’re using spring-boot-starter-parent in order to inherit the out-of-the-box native profile and that the org.graalvm.buildtools:native-maven-plugin plugin is used.

You should see the following in the Maven pom.xml file:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.0</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>
<build>
    <plugins>
        <plugin>
            <groupId>org.graalvm.buildtools</groupId>
            <artifactId>native-maven-plugin</artifactId>
        </plugin>
        ...
    </plugins>
</build>

With the out-of-the-box native profile active, you can invoke the native:compile goal to trigger native-image compilation.

Notice that you can pass additional configuration arguments to the underlying native-image build tool using the <buildArgs> section. In individual buildArg tags, you can pass parameters exactly the same way as you do from a command line. This lets you use all of the parameters that work with the native-image tool from Maven.

In the pom.xml, we've declared a profile called baseline with an additional build argument -J-Xmx16G to provide more memory to Native Image as shown below:

<profiles>
    <profile>
        <id>baseline</id>
        <build>
            <plugins>
                <plugin>
                    <groupId>org.graalvm.buildtools</groupId>
                    <artifactId>native-maven-plugin</artifactId>
                    <configuration>
                        <imageName>${artifactId}</imageName>
                        <buildArgs combine.children="append">
                            <!-- Provide more memory to Native Image -->
                            <buildArg>-J-Xmx16G</buildArg>
                        </buildArgs>
                    </configuration>
                </plugin>
            </plugins>
        </build>
    </profile>
    ...
</profiles>
  1. Run the Maven build using the profiles (note that profile names are specified with the -P flag):

    mvn native:compile -Pnative -Pbaseline

    This will generate a native executable for the platform in the target directory, called jibber. It takes approximately 3-4 minutes to generate the native executable.

  2. Take a look at the size of the file:

    ls -lh target/jibber
  3. Run this native executable and test it. Execute the following command in your terminal to run the native executable and put it into the background, using &:

    ./target/jibber &

    Now you have a native executable of the application that starts really fast!

  4. Call the endpoint using the curl command:

    curl http://localhost:8080/jibber

    You should see a nonsense verse in the style of the poem Jabberwocky.

  5. Terminate the application before you move on. Bring the application into the foreground:

    fg

    Terminate it with <ctrl-c>:

    <ctrl-c>

STEP 5: Containerise your Native Executable

Now, since you have a native executable version of your application, and you have seen it working, containerise it.

There is a Dockerfile provided for packaging this native executable: it is in the directory native-image/containerisation/lab/01-native-image/Dockerfile. The contents are shown below, along with comments to explain each line.

FROM container-registry.oracle.com/os/oraclelinux:8-slim

ARG APP_FILE                 # Pass in the native executable
EXPOSE 8080                  # This image will need to expose TCP port 8080, as this is port your app will listen on

COPY ${APP_FILE} app         # Copy the native executable into the root directory and call it "app"
ENTRYPOINT ["/app"]          # Just run the native executable :)
  1. To build, run the following from your terminal:

    docker build -f ./01-native-image/Dockerfile \
                --build-arg APP_FILE=./target/jibber \
                -t localhost/jibber:native.01 .

    Take a look at the newly built image:

    docker images
  2. Run and test it as follows from the terminal:

    docker run --rm -d --name "jibber-native" -p 8080:8080 localhost/jibber:native.01
  3. Call the endpoint from the terminal using curl:

    curl http://localhost:8080/jibber

    Again, you should have seen more nonsense verse in the style of the poem Jabberwocky.

  4. You can take a look at how long the application took to startup by looking at the logs produced by the application as you did earlier. From your terminal, run the following and look for the startup time:

    docker logs jibber-native

    You should see a number similar to 0.034 seconds. That is a big improvement compared to the original of 1.64s!

    2022-03-09 19:44:12.642  INFO 1 --- [           main] com.example.demo.DemoApplication         : Started DemoApplication in 0.034 seconds (process running for 0.037)
  5. Terminate your container and move onto the next step:

    docker kill jibber-native
  6. But before you go to the next step, take a look at the size of the container produced:

    docker images

    The container image size is around 195MB. Quite a lot smaller than our original Java container.

STEP 6: Build a Mostly Static Executable and Package it in a Distroless Container

Recap, again, what you have done so far:

  1. Built a Spring Boot application with a HTTP endpoint, /jibber
  2. Successfully containerised it
  3. Built a native executable of your application using the Native Image Build Tools for Maven
  4. Containerised your native executable

It would be great if you could shrink your container size even further, because smaller containers are quicker to download and start. With GraalVM Native Image you have the ability to statically link system libraries into the native executable. Then you can package this statically linked native executable directly into an empty Docker image, also known as a scratch container. For example, Google's Distroless which contains the glibc library, some standard files, and SSL security certificates. The standard Distroless container is around 20MB in size.

A native executable can link everything except the standard C library, glibc. We call this a "mostly static executable". This is an alternative option to staticly linking everything.

You shouldn't produce a fully static executable using glibc, as glibc was designed to work as a shared library, but GraalVM Native Image can produce fully static linked executables using the musl C library, Details here .

So, next, build a mostly static executable and then package it into a Distroless container.

In the pom.xml, we've declared a profile called distroless with an additional build argument -H:+StaticExecutableWithDynamicLibC to produce a mostly static native executable as shown below:

<profiles>
    ...
    <profile>
        <id>distroless</id>
        <build>
            <plugins>
                <plugin>
                    <groupId>org.graalvm.buildtools</groupId>
                    <artifactId>native-maven-plugin</artifactId>
                    <configuration>
                        <imageName>${artifactId}-distroless</imageName>
                        <buildArgs combine.children="append">
                            <!-- Provide more memory to Native Image -->
                            <buildArg>-J-Xmx16G</buildArg>
                            <!-- Mostly static -->
                            <buildArg>-H:+StaticExecutableWithDynamicLibC</buildArg>
                        </buildArgs>
                    </configuration>
                </plugin>
            </plugins>
        </build>
    </profile>
</profiles>

The Dockerfile for this step can be found in the directory native-image/containerisation/lab/02-smaller-containers/Dockerfile. Take a look at the contents of the Dockerfile, which has comments to explain each line:

FROM gcr.io/distroless/base # The base image, which is Distroless

ARG APP_FILE                # Everything else is the same :)
EXPOSE 8080

COPY ${APP_FILE} app
ENTRYPOINT ["/app"]
  1. Build your executable as follows:

    mvn native:compile -Pnative -Pdistroless

    The generated mostly static native executable named jibber-distroless is in the target directory.

  2. Now package it into a Distroless container:

    docker build -f ./02-smaller-containers/Dockerfile \
                --build-arg APP_FILE=./target/jibber-distroless \
                -t localhost/jibber:distroless.01 .
  3. Take a look at the newly built Distroless image:

    docker images
  4. Now you can run and test it as follows:

    docker run --rm -d --name "jibber-distroless" -p 8080:8080 localhost/jibber:distroless.01

    curl http://localhost:8080/jibber

    Great! How small, or large, is your container?

  5. Use the script to check the image size:

    docker images

    The size is around 109MB! So you have shrunk the container by 44% (from almost 195 MB). A long way down from your starting size, for the Java container, of 589 MB.

  6. Terminate your container and move onto the next step:

    docker kill jibber-distroless

STEP 7: Using GraalVM Native Build Tools as Part of Your CI/CD Pipeline

In this part of the lab you will build a native executable using GitHub Actions. (GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform that is built into GitHub.) This means that when you push your code to GitHub, GitHub Actions will build it into a native executable.

You will use the GitHub CLI (gh) to interact with GitHub Actions. (This has already been installed for you.)

Prerequisite

In order to complete this step, you must have a GitHub Account. If you don't have one, then signup .

Each GitHub Actions workflow is defined by a single file within your Git repository in the .github/workflows directory. This lab includes a workflow in the file .github/workflows/main.yaml: it uses the GitHub Action for GraalVM . This is the easiest way to install and use the latest version of GraalVM within your workflows.

The contents of the workflow is provided below:

name: GraalVM Spring Boot Demo Github Actions Pipeline (EE)
on: [push, pull_request]
jobs:
  build: # <1>
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [macos-latest, ubuntu-latest]
    steps: # <2>
      - uses: actions/checkout@v2
      - uses: graalvm/setup-graalvm@v1
        with:
          java-version: '17'
          distribution: 'graalvm' # <3>
          github-token: ${{ secrets.GITHUB_TOKEN }}
      - name: Build and Test Java Code  # <3>
        run: |
          ./mvnw --no-transfer-progress native:compile -Pnative -Pbaseline -DskipNativeTests

      - name: Archive production artifacts # <4>
        uses: actions/upload-artifact@v3
        with:
          name: native-binaries-${{ matrix.os }}
          path: |
            target/jibber

1 The workflow runs two concurrent builds: macOS and Linux (Ubuntu). This builds a native executable for each of those platforms.

2 The most important step in this workflow is the installation of Oracle GraalVM for JDK17. To install GraalVM Community Edition, pass graalvm-community for a distribution type.

3 Build a native executable.

4 Upload the built executables as artifacts. These are stored against the GitHub Action Workflow run for a period of time and can be downloaded.

Steps

  1. Login to your Github account from the terminal, as follows:

    gh auth login

    You will be asked a series of questions before you are redirected to a browser where you can log into Github. Use the following responses to the prompts:

    • What account do you want to log into? GitHub.com (Default)
    • What is your preferred protocol for Git operations? HTTPS (Default)
    • Authenticate Git with your GitHub credentials? Y (Default)
    • How would you like to authenticate GitHub CLI? Login with a web browser (Default)

    Note: The final step of the web browser-based login will ask you for a device code. This is an eight-character code that is generated by the command gh auth login, and can be copied from the terminal.

    You should now be successfully authenticated locally and you should be able to use the GitHub CLI to create a repository within your GitHib account.

  2. Push your local code to your GitHub Account

    Initialize a Git repository, create a new repository on our GitHub account, and push your local code to it. Use the following commands from the terminal:

    git init -b main
    git add .
    git commit -m "First commit"
    gh repo create

    Use the following responses to the prompts:

    • What would you like to do? Push an existing local repository to GitHub
    • Path to local repository . (Default)
    • Repository name lunalab2023
    • Repository owner <your username> (Default)
    • Description GraalVM Luna Lab 2023
    • Visibility Public (Default)
    • Add a remote? Y (Default)
    • What should the new remote be called? origin (Default)
    • Would you like to push commits from the current branch to "origin"? Y (Default)
  3. View the Build in GitHub Actions:

    You can monitor the automated build by using the GitHub CLI. From the terminal run the following:

    gh run watch

    Select the current workflow run. The terminal will then be updated with the status of the GitHub Actions workflow, running remotely on GitHub. This may take a few minutes to complete. The screenshot below shows the status for a workflow run:

    GitHub Actions Terminal Output

  4. Download and Run the Executable on your laptop (Intel Mac or Intel Ubuntu).

    Now the action has run and built the native executables, you can access your repository and download these files.

    • From a web browser on your laptop, NOT from within the lab, go to GitHub and login.
    • Open your repositories. You should see the repository that you created during the lab.
    • Click on the link to view the repository on GitHub.com.
    • Open the GitHub Actions for this repository, by clicking Actions.
    • Click a workflow run.
    • Under Artifacts, download a ZIP file containing the native executable for your platform. (Click the link that corresponds to the name of your platform.)

    Now check within your downloads folder (this will vary according to your platform). You should see a file named native-binaries-<platform>-latest.zip. When you uncompress this, you will see a file named jibber.

    You should now be able to run this from the command line on your laptop.

    Note: You may need to change the permissions of the file before you can run it.

    On macOS, you may need to remove the "quarantine" attribute, as follows: xattr -r -d com.apple.quarantine /path/to/jibber.

Summary of GitHub Actions

  • Using the GitHub CLI, you authenticated yourself with GitHub
  • You created a repository within GitHub
  • You pushed your local code to the GitHub repository
  • You watched your action run, using the GitHub CLI
  • You downloaded a native executable to your laptop and ran it

Conclusion

We hope you enjoyed this lab and learnt a few things along the way. You looked at how you can containerise a Java application. Then, you saw how to use GraalVM Native Image to compile that Java application into a native executable, which starts significantly faster than its Java counterpart. You then containerised the native executable and saw that the size of the container image, with the native executable in it, is much smaller than the corresponding Java container image. Then, you looked at how to build mostly-statically linked native executables with Native Image. Finally, you saw how to use GitHub Actions to automate the build of a native executable as part of a CI/CD pipeline.

Learn More

SSR