Clojure is a really nice, dynamic programming language on the Java virtual machine. It gives you the expressive of a lisp, full interoperability with the whole Java/Kotlin/Scala ecosystem of libraries, a battle-hardened VM, and it’s elegant.

The downside is that it runs on the JVM and has to pay a heavy cold start penalty. We can fix that, using GraalVM, and have our cake, eat it, and space-time fold it too.

We will be using a few containers, Podman and GraalVM’s native-image tooling to build ahead-of-time compiled binaries that start up really, really fast.

Background project

We will need some kind of sample Clojure project to demonstrate our speed up. Luckily, I have created one over on GitHub/techblog-darjeeling. It’s a simple Clojure web service built with ring and compojure that replies with a random quote pulled from dictum.

Preparing the Clojure project

We need to make sure we compile our project ahead of time. A simple way to compile and package an uberjar is to use seancorfield/depstar. As I am using the clojure default tools in my project, I update deps.edn with a new alias.

:aliases {:depstar
          {:replace-deps
           {com.github.seancorfield/depstar {:mvn/version "2.0.206"}}
           :ns-default hf.depstar
           :exec-fn hf.depstar/uberjar
           :exec-args {
                       :jar app.jar
                       :aot true
                       :compile-ns [darjeeling.app]
                       :main-class "darjeeling.app"}}}

I find it easier to specify all my arguments upfront. Here we specify our main-class (to generate uberjar META-INF data), which Clojure namespace(s) to compile and what the output jar should be called.

We can then package the project using clj -X:depstar.

$ clj -X:depstar
[main] INFO hf.depstar.uberjar - Compiling darjeeling.app ...
2021-03-30 23:52:43.939:INFO::main: Logging initialized @3436ms to org.eclipse.jetty.util.log.StdErrLog

[main] INFO hf.depstar.uberjar - Building uber jar: app.jar

Presto. A simple uberjar which we can run with $ java -jar app.jar. Our little application requires at least one argument to start (for reasons explained later). Let’s see how fast it starts!

$ java -jar app.jar init
2021-03-30 23:54:37.324:INFO::main: Logging initialized @1089ms to org.eclipse.jetty.util.log.StdErrLog
2021-03-30 23:54:38.068:INFO:oejs.Server:main: jetty-9.4.31.v20200723; built: 2020-07-23T17:57:36.812Z; git: 450ba27947e13e66baa8cd1ce7e85a4461cacc1d; jvm 1.8.0_282-b08
2021-03-30 23:54:38.094:INFO:oejs.AbstractConnector:main: Started ServerConnector@19bfbe28{HTTP/1.1, (http/1.1)}{0.0.0.0:8080}
2021-03-30 23:54:38.096:INFO:oejs.Server:main: Started @1861ms

Hmm. A cold start-up of 1.8s isn’t great for what’s basically a glorified hello world app. Despite all the fun one can have building clojure applications, startups only get more and more expensive as we add dependencies that the JVM’s classloader needs to find. If we deploy this to something like Google Cloud Run where we are billed to the nearest 100 milliseconds or AWS Lambda (granularity 1 ms!), we’re paying for a relatively large overhead every time we spin up.

Containers and the deep magic

Setting up yet-another JVM installation is always a bit of a hassle in my opinion and adding GraalVM to the mix is not per se a lot of fun. So let’s use container technology to build, compile and package our resultant native image! This lets our file system stay nice and clean and lets us build this… well, anywhere!

We create a simple Container file to build an uberjar for our project.

FROM docker.io/clojure:openjdk-11-tools-deps-slim-buster AS builder
WORKDIR /usr/src/app
COPY . .
RUN clojure -X:depstar

We then pull a GraalVM Community Edition image and install their native-image tooling. We make sure to copy-over our uberjar to the native container.

FROM ghcr.io/graalvm/graalvm-ce:21.0.0 AS native
WORKDIR /usr/src/app
COPY --from=builder /usr/src/app/app.jar /usr/src/app/app.jar
RUN gu install native-image

native-image, reflection and you

Here, the JVM deep magic begins. Through some extensive googling and great posts on GitHub and medium, I have come up with a small set of flags to compile this clojure project. We have to make sure to set some flags in the clojure compiler and allow the native-image JVM memory use at least 3 GB so we can save a few minutes of our lives. The rest are various flags that enable some verbosity and disallow the native-image compiler from falling back if it cannot produce a native image.

Here, we hit our first snag. This specific project is using clj-http which wraps Apache HttpComponents to fetch data over HTTPS - unfortunately it uses reflection to load a log framework at runtime. This requires us to help the GraalVM native-image compiler and allow the use of runtime reflection or we will run into a runtime error. We must then create a reflection-config file here specifically for Apache-commons logging.

#!/bin/sh
mkdir -p META-INF/native-image
echo "[{
    "name" : "org.apache.commons.logging.impl.Jdk14Logger",
    "allDeclaredConstructors" : true,
    "allPublicConstructors" : true,
    "allDeclaredMethods" : true,
    "allPublicMethods" : true
  }, {
  "name": ...
  ]" | tee META-INF/native-image/logging.json
native-image -cp app.jar -jar app.jar \
       -H:Name=app -H:+ReportExceptionStackTraces \
       -J-Dclojure.spec.skip.macros=true -J-Dclojure.compiler.direct-linking=true -J-Xmx3G \
       --initialize-at-build-time --enable-http --enable-https --verbose --no-fallback --no-server\
       --report-unsupported-elements-at-runtime --native-image-info \
       -H:+StaticExecutableWithDynamicLibC -H:CCompilerOption=-pipe \
       --allow-incomplete-classpath --enable-url-protocols=http,https --enable-all-security-services
chmod +x app
echo "Size of generated native-image `ls -sh app`"

I chucked all of this into a shell file aptly named compile.sh. Let’s update our Container file to include this file in the native container.

COPY compile.sh .
RUN chmod +x compile.sh
RUN ./compile.sh
Jetty server wants to eval forever

Our second snag is that the GraalVM native-image compiler ends up evaluating all top-level clojure forms. This means that it invokes (darjeeling/-main) which would not usually finish as it’s a server process. We have a tiny work-around requiring an additional parameter to be supplied to this fn or we return immediately. Peeking at src/darjeeling/app.clj we see that it’s structured like this.

(defn -main [& args]
  (cond
    (not (nil? args))
  (...code that runs jetty-server)
    :else  (.println System/out "needs argument to start")))

This avoids the infinite eval during the GraalVM compilation step.

Production image

If we build the Container file at this point we will generate a a GraalVM container image that’s pretty large (>1GB) and contains the full GraalVM environment in addition to our app. We don’t really want to be working with large container images with stuff we do not require at runtime.

This last step is building our actual production image that contains nothing but the natively compiled program.

FROM gcr.io/distroless/base:latest
ENV PORT="8080"
ENV HOST="0.0.0.0"
EXPOSE 8080
COPY --from=native /usr/src/app/app /
CMD ["/app", "init"]

It will copy over the generated native app from the previous container to a distroless container and set the container default run command together with some environment vars. Now that’s done!

Pulling it all together

Now we have our whole Container file.

FROM docker.io/clojure:openjdk-11-tools-deps-slim-buster AS builder
WORKDIR /usr/src/app
COPY . .
RUN clojure -X:depstar
FROM ghcr.io/graalvm/graalvm-ce:21.0.0 AS native
WORKDIR /usr/src/app
COPY --from=builder /usr/src/app/app.jar /usr/src/app/app.jar
RUN gu install native-image
COPY compile.sh .
RUN chmod +x compile.sh
RUN ./compile.sh
FROM gcr.io/distroless/base:latest
ENV PORT="8080"
ENV HOST="0.0.0.0"
EXPOSE 8080
COPY --from=native /usr/src/app/app /
CMD ["/app", "init"]

All that’s left is to build it and run it. I’ve abbreviated the more verbose parts (e.g. the Clojure dependency tree) with ellipses in the excerpt below. I’ll be using podman but you can replace it with docker if you prefer.

$ podman build -t faster-clojure .
STEP 1: FROM docker.io/clojure:openjdk-11-tools-deps-slim-buster AS builder
STEP 2: WORKDIR /usr/src/app
--> Using cache d3a16119830bb594d201ba1f90457f82f9f7c110b94f031d8aecc1270b45d04f
--> d3a16119830
STEP 3: COPY . .
--> 04c1ec842f6
STEP 4: RUN clojure -X:depstar
Downloading: com/github/seancorfield/depstar/2.0.206/depstar-2.0.206.pom from clojars
Downloading: org/clojure/tools.logging/1.1.0/tools.logging-1.1.0.pom from central
...
[main] INFO hf.depstar.uberjar - Building uber jar: app.jar
--> a7e6d8899dd
STEP 5: FROM ghcr.io/graalvm/graalvm-ce:21.0.0 AS native
STEP 6: WORKDIR /usr/src/app
--> Using cache aac7fe82dcdd470436ec3e21d1def2222f660edf6e700559721b266eb7980706
--> aac7fe82dcd
STEP 7: COPY --from=builder /usr/src/app/app.jar /usr/src/app/app.jar
--> a9403fc04b3
STEP 8: RUN gu install native-image
Downloading: Component catalog from www.graalvm.org
Processing Component: Native Image
Downloading: Component native-image: Native Image  from github.com
Installing new component: Native Image (org.graalvm.native-image, version 21.0.0)
Refreshed alternative links in /usr/bin/
--> e07dacb1fad
STEP 9: COPY compile.sh .
STEP 10: RUN chmod +x compile.sh
--> 7c7f4ef5943
STEP 11: RUN ./compile.sh
Executing [
/opt/graalvm-ce-java11-21.0.0/bin/java \
-XX:+UseParallelGC \
-XX:+UnlockExperimentalVMOptions \
-XX:+EnableJVMCI \
...
# Building image for target platform: org.graalvm.nativeimage.Platform$LINUX_AMD64
# Using native toolchain:
#   Name: GNU project C and C++ compiler (gcc)
...
Size of generated native-image 64M app
--> ef589401b91
STEP 12: FROM gcr.io/distroless/base:latest
STEP 13: ENV PORT="8080"
--> Using cache ef2fd0c36adaadbdaa3cec8c4170476fc2d10645a2e479cb4fd1a9411b8a68ee
--> ef2fd0c36ad
STEP 14: ENV HOST="0.0.0.0"
--> Using cache edfe941ad0a5782e0d614b2c02eca78ec1dfc1c933b245effe11b97944fcde22
--> edfe941ad0a
STEP 15: EXPOSE 8080
--> Using cache 5a7288666d5c3ea54cb0e08e9322d04c474361ab5a19ce3ec497b3df14d0a8a5
--> 5a7288666d5
STEP 16: COPY --from=native /usr/src/app/app /
--> f67b6dfdaeb
STEP 17: CMD ["/app", "init"]
STEP 18: COMMIT faster-clojure
--> 4a805e66e6a

Now we have a nice container image that we can deploy and run like any other.

$ podman run -dt -p 8080:8080/tcp  --name blazing-darjeeling faster-clojure
772883419ebb9be1eea382769ac479a105acd0290100b50cc36f29d1c44f2e5f
$ curl -vvv localhost:8080
*   Trying ::1:8080...
* Connected to localhost (::1) port 8080 (#0)
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.75.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Date: Tue, 30 Mar 2021 23:17:28 GMT
< Content-Length: 142
< Server: Jetty(9.4.31.v20200723)
<
* Connection #0 to host localhost left intact
{"uuid":"HnvVH6ZuUm","text":"When I enjoy my surfing, I get good results, and I've always had fun in South Africa.","author":"Joel Parkinson"}

End results

As we can see from the build log, the output native image is quite a bit larger - 64 MB. That’s an over 457% size increase over the uberjar’s 14 MB. So, does the start-up speed merit it?

Let’s copy over the app from the container and see how fast it starts.

$ podman cp blazing-darjeeling:/app .
$ podman kill blazing-darjeeling
$ ./app init
2021-03-30 23:19:57.977:INFO:oejs.Server:main: jetty-9.4.31.v20200723; built: 2020-07-23T17:57:36.812Z; git: 450ba27947e13e66baa8cd1ce7e85a4461cacc1d; jvm 11.0.10
2021-03-30 23:19:57.978:INFO:oejs.AbstractConnector:main: Started ServerConnector@30d7aee{HTTP/1.1, (http/1.1)}{0.0.0.0:8080}
2021-03-30 23:19:57.978:INFO:oejs.Server:main: Started @2ms

So that means that on the same hardware, we’ve gone from having our cold startup hovering over 1800ms to 2 milliseconds. That’s a difference of 90000%.

As usual, your mileage may vary.

Further reading

Billy J. Beltran

Consultant at Redpill Linpro

Billy writes APIs, wrangles Apache Camels, massages data and evangelizes about using the right tool for the right problem (Clojure). M-x butterfly C-M-c user.

Thoughts on the CrowdStrike Outage

Unless you’ve been living under a rock, you probably know that last Friday a global crash of computer systems caused by ‘CrowdStrike’ led to widespread chaos and mayhem: flights were cancelled, shops closed their doors, even some hospitals and pharmacies were affected. When things like this happen, I first have a smug feeling “this would never happen at our place”, then I start thinking. Could it?

Broken Software Updates

Our department do take responsibility for keeping quite a lot ... [continue reading]

Alarms made right

Published on June 27, 2024

Just-Make-toolbox

Published on March 22, 2024