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 !