We all know the JVM is a massive beast doing all kinds of things. But not a lot of people know how to tame that beast to build lightweight binaries and docker images!

When you look into a JVM, you find a lot of different things and many of them you don't actually care about when running the program on a server in production. However, cleaning that tree from all its fluff can be quite complicated and requires some magic.

The basic nixos package for OpenJDK 17 weighs 500MiB (360MiB as a container) which is quite a lot and I'd prefer if I didn't ship that everytime I build a Clojure application. I saw online quite a few lightweight docker images advertising a whopping 40MiB compressed using alpine linux and other stuff.

I tried a few of them and looked at their dependencies, it seems from afar that they just remove some libraries (sound, X dependencies and stuff) and link with alpine/musl which makes them quite lightweight.

The first try

There's a jre17_minimal package in nixos, let's use that!

Bummer, it crashes when running our jar. It's probably missing some components and looking for that seems to be complicated as I'm not a JVM expert.

What does OpenJDK 17 contain?

What of the basic JDK on NixOS ? There is a headless build which seems to remove those dependencies but the basic build is 500MiB big and its dependencies (using nix-tree only accounts for 40MiB). (All of this are packages on disk, so uncompressed).

What does this JDK contains to be so big ? Well, quite a few things :

  • a 50MiB src.zip which apparently helps the IDE navigate the java codebase
  • a 123MiB modules file which we'll come back to later
  • a 8MiB debugging symbols archive
  • a 232 MiB jmods folder
  • some man pages
  • some binaries other than java
  • etc

Do we actually need any of the above ? Well, not really for a server program at runtime. But googling those jmods file, I stumbled upon something that took my interest : building a Minimal JRE runtime that only contains things you need for your program. Count me in!

Building a minimal JRE

I read through https://jakewharton.com/using-jlink-to-cross-compile-minimal-jres/ which explains how to build a minimal JRE.

But I thought : we already tried the minimal JRE and it didn't work. So how does the NixOS minimal JRE works ?


{ stdenv
, jdk
, lib
, callPackage
, modules ? [ "java.base" ]
}:

let
  jre = stdenv.mkDerivation {
    pname = "${jdk.pname}-minimal-jre";
    version = jdk.version;

    buildInputs = [ jdk ];

    dontUnpack = true;

    # Strip more heavily than the default '-S', since if you're
    # using this derivation you probably care about this.
    stripDebugFlags = [ "--strip-unneeded" ];

    buildPhase = ''
      runHook preBuild

      jlink --module-path ${jdk}/lib/openjdk/jmods --add-modules ${lib.concatStringsSep "," modules} --output $out

      runHook postBuild
    '';

    dontInstall = true;
  };
in jre

Turns out, it's quite simple!

And looking at it, we can understand that the thing we were missing are those modules ! But this can be computed using jdeps !

So let's build some Nix to do that for us :

Building a minimal JRE automatically using Nix

mkJreMinimal = { jar, jdk ? pkgs.jdk17_headless }: let
  modules = pkgs.stdenvNoCC.mkDerivation {
    name = "dummy";

    src = jar;
    dontUnpack = true;

    buildPhase = ''
        cp $src .
        ${pkgs.unzip}/bin/unzip $src
        # we ignore some missing deps due to (I think) optional dependencies
        ${jdk}/bin/jdeps --print-module-deps --ignore-missing-deps . > $out
      '';
  };
in pkgs.stdenv.mkDerivation {
  name = "dummy-jre";

  buildInputs = [ jdk ];
  dontUnpack = true;

  # Strip more heavily than the default '-S', since if you're
  # using this derivation you probably care about this.
  stripDebugFlags = [ "--strip-unneeded" ];

  buildPhase = ''
      runHook preBuild

      jlink --module-path ${jdk}/lib/openjdk/jmods --add-modules $(cat ${modules}) --output $out

      runHook postBuild
    '';

  dontInstall = true;
};

This gives us a JRE that weighs 130MiB uncompressed! This is way better than earlier !

Building a docker image out of our minimal JRE

container-minimal-jre = myLib.mkContainer {
  name = "j4m3s/test";
  cmd = ["${myLib.mkJreMinimal { inherit jar; }}/bin/java -jar ${jar}"];
  inputs = [ (myLib.mkJreMinimal { inherit jar; }) ];
};

using our function :

mkContainer = { name, ... }@args: let
  inputs = args.inputs or [args.input];
  config = args.config or { cmd = args.cmd; };
in
  nix2container.buildImage {
    name = name;
    inherit config;
    layers = [
      (nix2container.buildLayer { deps = inputs; })
    ];
  };

Note that the above uses the wonderful https://github.com/nlewo/nix2container but this only builds a JSON file that describes the container, we also need to use nix2container skopeo fork to upload to a registry for instance.

How much does this container ends up weighing ? 100 MiB ! With 47 MiB for the JRE (we put our dependencies on another layer for better space optimization). So compared to the origin 360MiB, this is a 7x reduction! Quite nice.

One more thing

The dependencies themselves could be shrunk as well. The Glibc weighs 32MiB and most of this is actually locales stuff which I don't use 99% of the time (Chinese, japanese, korean, etc). But looking from afar I didn't any way to build a lighter glibc. Plus, if nixpkgs didn't build all the packages with that glibc as well we would have to recompile the JDK. This is of course doable but it would be nice if we could avoid that.

If anyone knows if we can strip those dependencies, I'd be quite interested! If you have any more ideas on how to strip this down any further, be sure to send a comment !