Home
Unchained
Engineering Blog

Building minimal and low CVE images for Java

Adrian Mouat, Staff DevRel Engineer

At Chainguard, we're always looking for ways to help communities improve their security practices, especially in conjunction with Chainguard Images. This time, we take a look at Java. In this blog, we'll walk through how users can port an existing Java application to use Chainguard Images and demonstrate the resultant improvement in size and security.

All the Chainguard Images in this blog post are available in the free Developer tier of Chainguard Images. For more information on what Chainguard Images can offer you, including our paid products, check out our Images directory.

The application we're looking at was created by my colleague Mauren Berti for a recent Chainguard Learning Labs session. The code can be found in the learning-labs GitHub repository. It's a Spring Boot example application that includes a Dockerfile build which uses the Docker Hub official Maven image.

The Dockerfile looks like:


FROM maven

WORKDIR /work

COPY src/ src/
COPY pom.xml pom.xml

RUN mvn clean package
RUN REPOSITORY=$(mvn help:evaluate -Dexpression=settings.localRepository -q -DforceStdout) && rm -rf ${REPOSITORY}

WORKDIR /app

RUN cp /work/target/java-demo-app-1.0.0.jar .

ENTRYPOINT ["java", "-jar", "java-demo-app-1.0.0.jar"]

We can build the image with:


docker build -t maven-app .

And run it with:

docker run --rm -d -p 8080:8080 maven-app

The app should now be running and you can interact with it on port 8080:

curl localhost:8080/hello
Hello, World!

Now, let’s look at the size of the image:


docker images maven-app
REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
maven-app    latest    8e2b870fe5dd   20 seconds ago   585MB

We can use Grype (or any other CVE scanner!) to investigate CVEs:


grype docker:maven-app
 ✔ Vulnerability DB                [no update available]
 ✔ Loaded image                                                                                                    maven-app:latest
 ✔ Parsed image                                             sha256:8e2b870fe5dd2f9cc8b36dc4427160fccf154570203b778b0870a2e3a1ef5ba4
 ✔ Cataloged contents                                              0d82981cf1618d9ae87dc38bd5365c89d0017a7745bbfc30a9147e94967ba5e7
   ├── ✔ Packages                        [254 packages]
   ├── ✔ File digests                    [6,313 files]
   ├── ✔ File metadata                   [6,313 locations]
   └── ✔ Executables                     [930 executables]
 ✔ Scanned for vulnerabilities     [82 vulnerability matches]
   ├── by severity: 0 critical, 0 high, 25 medium, 41 low, 16 negligible
   └── by status:   8 fixed, 74 not-fixed, 0 ignored

The image is 525 MB and has 82 CVEs. This is a reasonable result for a Java image, but let's see what happens if we move to use Chainguard Images. The first thing we can do is simply replace FROM maven with the Chainguard equivalent, leaving everything else the same:


FROM cgr.dev/chainguard/maven
…

(Note that you can also type FROM chainguard/maven as Chainguard Images are now available on the Docker Hub!)

Let's rebuild the image and give it a new tag:


docker build -t maven-app-cg .

Now, look at the size:


REPOSITORY     TAG       IMAGE ID       CREATED         SIZE
maven-app-cg   latest    bdbd76091dbf   4 minutes ago   342MB

And the CVE count:


grype docker:maven-app-cg
 ✔ Vulnerability DB                [no update available]
 ✔ Loaded image                                                                                                 maven-app-cg:latest
 ✔ Parsed image                                             sha256:bdbd76091dbf6b6ccfdfd187024abef00ea1792ac6642d50ac422864010116bb
 ✔ Cataloged contents                                              7c5d998d4197d49b72c63e9425455fae6a923ce14d74f586c8413bbcc4575b5f
   ├── ✔ Packages                        [143 packages]
   ├── ✔ File digests                    [739 files]
   ├── ✔ File metadata                   [739 locations]
   └── ✔ Executables                     [109 executables]
 ✔ Scanned for vulnerabilities     [0 vulnerability matches]
   ├── by severity: 0 critical, 0 high, 0 medium, 0 low, 0 negligible
   └── by status:   0 fixed, 0 not-fixed, 0 ignored
No vulnerabilities found

So by changing a single line, we've reduced the size by 243MB and went from 82 CVEs to zero. That's pretty good for such a simple change! However, we can still do more if we're willing to put in a little more effort.

The current image still includes build tooling and source code that we don't need in the production image. If we change to use a multi-stage build, we can get rid of several dependencies like these. This reduces software bloat and the attack surface in the image (if an application doesn't exist in the image, it can't be exploited). Here's what such a Dockerfile looks like:


FROM cgr.dev/chainguard/maven AS builder

WORKDIR /work

COPY src/ src/
COPY pom.xml pom.xml

RUN mvn clean package
RUN REPOSITORY=$(mvn help:evaluate -Dexpression=settings.localRepository -q -DforceStdout) && rm -rf ${REPOSITORY}

FROM cgr.dev/chainguard/jre AS runner

WORKDIR /app

COPY --from=builder /work/target/java-demo-app-1.0.0.jar .

ENTRYPOINT ["java", "-jar", "java-demo-app-1.0.0.jar"]

Note that there are two FROMlines, the first one creates the "builder" image much as before and the second (named "runner") copies the built JAR from the builder step into the smaller cgr.dev/chainguard/jreimage. The result is a container image that only contains the JRE and the built application we want to run.

Let's build this Dockerfile and see what we get:


docker build -t maven-app-multi-cg .

Look at the size again:


docker images maven-app-multi-cg
REPOSITORY           TAG       IMAGE ID       CREATED         SIZE
maven-app-multi-cg   latest    0657f7e2914a   2 minutes ago   325MB

And the CVEs:


grype docker:maven-app-multi-cg
 ✔ Vulnerability DB                [no update available]
 ✔ Loaded image                                                                                           maven-app-multi-cg:latest
 ✔ Parsed image                                             sha256:0657f7e2914abb384c60871ee2c5588eae2132ac0604b294b63c58c24d45a72e
 ✔ Cataloged contents                                              9429c21132b92721f52bce3b324978a082ebf33ce9a964846f6e183a7696069e
   ├── ✔ Packages                        [61 packages]
   ├── ✔ File digests                    [823 files]
   ├── ✔ File metadata                   [823 locations]
   └── ✔ Executables                     [86 executables]
 ✔ Scanned for vulnerabilities     [0 vulnerability matches]
   ├── by severity: 0 critical, 0 high, 0 medium, 0 low, 0 negligible
   └── by status:   0 fixed, 0 not-fixed, 0 ignored
No vulnerabilities found

The image still has zero CVEs but the size has dropped by a further 17 MB. Also note that the Grype result shows there are only 61 packages in the image, compared to the previous 143 (and 254 in the original). These packages all represent installed software that could potentially be used by an attacker against you in a “living off the land” method. They will also accumulate CVEs over time if not updated. By keeping the number of packages lower, we reduce complexity and improve security for container images.

All the instructions in this blog use the free, Developer tier of Chainguard Images that are available for you to use today. If you need a tagged or specific version of the JDK, JRE or Maven, please take a look at our Production Chainguard Images and contact us.

Share

Ready to Lock Down Your Supply Chain?

Talk to our customer obsessed, community-driven team.

Get Started