Clojure in Production with tools.deps

In this post I'll show you how my project is packaging and running Clojure apps in production, using tools.deps as a build tool. Credit where credit's due: my awesome colleague Alf Kristian St√łyle did the heavy lifting on this setup.

Our setup at a glance:

It is a very simple setup that requires very little additional tooling. We use only one library on top of tools.deps, badigeon. Badigeon has a very nice bundler that can collect all kinds of dependencies supported by tools.deps for further packaging. Importantly, this helps us with git libs, which we use for internal libraries and more. Since we already have it on the classpath, we also use Badigeon's AOT compiler, which is a thin wrapper over Clojure's that provides a few niceties like ensuring that the target directory exists before putting files in it.

deps.edn:

{:paths ["src"]
 :deps {org.clojure/clojure {:mvn/version "1.10.1-beta2"}}
 :aliases
 {:build
  {:extra-paths ["build"]
   :extra-deps
   {badigeon/badigeon
    {:git/url "https://github.com/EwenG/badigeon.git"
     :sha "dca97f9680a6ea204a2504c4414cafc4ba182a83"}}}}}

build/package.clj:

(ns package
  (:require [badigeon.bundle :refer [bundle make-out-path]]
            [badigeon.compile :as c]))

(defn -main []
  (bundle (make-out-path 'lib nil))
  (c/compile 'our-app.core {:compile-path "target/classes"}))

Now you can stage classes and jars like this:

clojure -A:build -m package

The Dockerfile uses a specific version of the relevant JDK image. Never use fleeting tags like latest for a production build - you want those to be predictable and repeatable:

FROM openjdk:11.0.2-slim

ADD target/lib/lib /app/lib
ADD target/classes /app/classes

WORKDIR /app

CMD java $JAVA_OPTS -cp "classes:lib/*" our-app.core

Externalizing JVM parameters with $JAVA_OPTS allows us to tweak runtime characteristics without building a new artifact. Here's an example of setting it from a Kubernetes deployment descriptor to configure JMX and heap memory:

containers:
  - name: our-service
    image: our.repo.com/our-app:ae31ade5ba
    env:
      - name: JAVA_OPTS
        value: "-Dcom.sun.management.jmxremote.rmi.port=9090
                -Dcom.sun.management.jmxremote=true
                -Dcom.sun.management.jmxremote.port=9090
                -Dcom.sun.management.jmxremote.ssl=false
                -Dcom.sun.management.jmxremote.authenticate=false
                -Dcom.sun.management.jmxremote.local.only=false
                -Djava.rmi.server.hostname=localhost
                -Xms128m
                -Xmx128m"

Tying it all together

We use a Makefile to tie everything together, so we can do things like:

make docker # AOT compiles first if sources have changed

Here's something to get you started:

VERSION:=$(shell git rev-parse --short=10 HEAD)

target:
    mkdir -p target

target/classes/our_app/core.class: deps.edn src/**/* target
    clojure -A:build -m package

build: target/classes/our_app/core.class

docker: target/classes/our_app/core.class
    docker build -t our-app:$(VERSION) .

clean:
    rm -fr target

.PHONY: build docker clean

This is a very straight-forward approach that uses little tooling, has few concepts to understand, no runtime component, and starts quickly.

Alternatives

There are several alternatives around for packaging Clojure apps. One of the first approaches we tried was using Capsule and One-JAR, through pack.alpha. pack.alpha makes it very easy to add packaging to your tools.deps project. It is very nice for building "skinny jars" for libraries but for single jar deployments, the resulting jar will likely include more than you bargained for.

Capsule jars are basically a lightly packaged build tool, and it boasts features like selecting the JVM version at startup, installing dependencies on the run and more. None of those features are desirable for reproducible application server deployments.

One-JAR loads all the bytecode into memory up front "making for improved runtime performance". The problem is, loading bytecode is very unlikely a bottleneck, and not in any real need of optimizing. Besides, an application likely ships with dependencies from which it only uses a few functions. Prematurely loading all that bytecode into memory is pretty much guaranteed to waste resources. This is especially true because One-JAR loads it into heap space.

I'm not saying that these tools don't have their use cases, I'm just saying that they didn't fit our requirements. They gave us too slow startup times, and the One-JAR solution landed us at a baseline heap size of a whopping 250 megabytes. The solution presented above puts us at just around 30MB after startup, and between 64MB and 96MB after running for a few days - and that's without any of the bytecode from before, which no is longer loaded into heap space.