Jlink custom jre with scala and sbt
In this post will learn how to pack scala play framework application with stripped-down custom JRE using sbt-native-packager with sbt-jlink-plugin.
We will use OpenJDK 11 as the base for custom JRE. I decided to write a blog-post because I struggled with jlink for myself and there’s not so much information on that topic - you can find a lot of articles for java, but those with scala and sbt are less popular. Let’s fix that!
Motivation
JDK 9 ships with the jlink tool which allows us to create stripped-down custom JRE that contains only specific JDK modules. We will find out how to build a play framework application where the JRE size is between 40Mb to 80MB depending upon what dependencies used by the app. Small deployment artifact size is crucial in the age of the containers.
Plan
- Create play framework app from the template or take an existing one
- Add sbt-native packager plugin and activate JLinkPlugin
- Customize JLinkPlugin params
- Build custom JRE
- Optionally add docker image build
- PROFIT!
Play App
Let’s create a play framework app from the template:
- Install sbt if you don’t have it
- Run
sbt new playframework/play-scala-seed.g8
.
At this point we have standard files and directories structure. Let’s go straight right off the bat and add sbt-native packager to the project.
Add this line in project/plugins.sbt:
addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.8.1")
And add JlinkPlugin to plugins list in build.sbt:
lazy val root = (project in file(".")).enablePlugins(PlayScala, JlinkPlugin)
Jdeps
At this point, we can run sbt stage to build out app but you will get an error:
[error] Exception in thread "main" java.lang.module.FindException: Module paranamer not found, required by com.fasterxml.jackson.module.paranamer
[error] at java.base/java.lang.module.Resolver.findFail(Resolver.java:877)
[error] at java.base/java.lang.module.Resolver.resolve(Resolver.java:191)
[error] at java.base/java.lang.module.Resolver.resolve(Resolver.java:140)
[error] at java.base/java.lang.module.Configuration.resolve(Configuration.java:422)
[error] at java.base/java.lang.module.Configuration.resolve(Configuration.java:256)
[error] at jdk.jdeps/com.sun.tools.jdeps.JdepsConfiguration.<init>(JdepsConfiguration.java:117)
[error] at jdk.jdeps/com.sun.tools.jdeps.JdepsConfiguration$Builder.build(JdepsConfiguration.java:563)
[error] at jdk.jdeps/com.sun.tools.jdeps.JdepsTask.buildConfig(JdepsTask.java:589)
[error] at jdk.jdeps/com.sun.tools.jdeps.JdepsTask.run(JdepsTask.java:543)
[error] at jdk.jdeps/com.sun.tools.jdeps.JdepsTask.run(JdepsTask.java:519)
[error] at jdk.jdeps/com.sun.tools.jdeps.Main.run(Main.java:64)
[error] at jdk.jdeps/com.sun.tools.jdeps.Main$JDepsToolProvider.run(Main.java:73)
[error] at java.base/java.util.spi.ToolProvider.run(ToolProvider.java:137)
[error] at ru.eldis.toollauncher.ToolLauncher.runTool(ToolLauncher.java:68)
[error] at ru.eldis.toollauncher.ToolLauncher.lambda$main$1(ToolLauncher.java:33)
[error] at ru.eldis.toollauncher.ToolLauncher.main(ToolLauncher.java:48)
This case is described on the page of sbt-native-packager jlink plugin:
This is often caused by depending on automatic modules. In the example above, com.faterxml.jackson.module.paranamer is an explicit module (as in, it is a JAR with a module descriptor) that defines a dependency on the paranamer module. However, there is no explicit paranamer module - instead, Jackson expects us to use the paranamer JAR file as an automatic module. To do this, the JAR has to be on the module path. At the moment JlinkPlugin does not put it there automatically, so we have to do that ourselves:
So we must add this block to build sbt:
jlinkModulePath := {
fullClasspath
.in(jlinkBuildImage)
.value
.filter { item =>
item.get(moduleID.key).exists { modId =>
modId.name == "paranamer"
}
}
.map(_.data)
}
I also recommend to add
Global / onChangedBuildSource := ReloadOnSourceChanges
to make sbt reload every time we change something in build.sbt. If we’ll run sbt stage one more time we would get this output:
[error] Dependee packages not found in classpath. You can use jlinkIgnoreMissingDependency to silence these.
[error] ch.qos.logback.classic -> javax.servlet.http
[error] ch.qos.logback.classic.boolex -> groovy.lang
[error] ch.qos.logback.classic.boolex -> org.codehaus.groovy.control
[error] ch.qos.logback.classic.boolex -> org.codehaus.groovy.reflection
[error] ch.qos.logback.classic.boolex -> org.codehaus.groovy.runtime
[error] ch.qos.logback.classic.boolex -> org.codehaus.groovy.runtime.callsite
[error] ch.qos.logback.classic.boolex -> org.codehaus.groovy.runtime.typehandling
[error] ch.qos.logback.classic.gaffer -> groovy.lang
[error] ch.qos.logback.classic.gaffer -> org.codehaus.groovy.control
[error] ch.qos.logback.classic.gaffer -> org.codehaus.groovy.control.customizers
[error] ch.qos.logback.classic.gaffer -> org.codehaus.groovy.reflection
[error] ch.qos.logback.classic.gaffer -> org.codehaus.groovy.runtime
[error] ch.qos.logback.classic.gaffer -> org.codehaus.groovy.runtime.callsite
[error] ch.qos.logback.classic.gaffer -> org.codehaus.groovy.runtime.typehandling
[error] ch.qos.logback.classic.gaffer -> org.codehaus.groovy.runtime.wrappers
[error] ch.qos.logback.classic.gaffer -> org.codehaus.groovy.transform
[error] ch.qos.logback.classic.helpers -> javax.servlet
[error] ch.qos.logback.classic.helpers -> javax.servlet.http
[error] ch.qos.logback.classic.selector.servlet -> javax.servlet
[error] ch.qos.logback.classic.servlet -> javax.servlet
[error] ch.qos.logback.core.boolex -> org.codehaus.janino
[error] ch.qos.logback.core.joran.conditional -> org.codehaus.commons.compiler
[error] ch.qos.logback.core.joran.conditional -> org.codehaus.janino
[error] ch.qos.logback.core.net -> javax.mail
[error] ch.qos.logback.core.net -> javax.mail.internet
[error] ch.qos.logback.core.status -> javax.servlet
[error] ch.qos.logback.core.status -> javax.servlet.http
[error] io.jsonwebtoken.impl -> android.util
[error] io.jsonwebtoken.impl.crypto -> org.bouncycastle.jce
[error] io.jsonwebtoken.impl.crypto -> org.bouncycastle.jce.spec
[error] javax.transaction -> javax.enterprise.context
[error] javax.transaction -> javax.enterprise.util
[error] javax.transaction -> javax.interceptor
[error] org.joda.time -> org.joda.convert
[error] org.joda.time.base -> org.joda.convert
We should add this mapping to jlink ignore missing dependency list:
jlinkIgnoreMissingDependency := JlinkIgnore.only(
"ch.qos.logback.classic" -> "javax.servlet.http",
"ch.qos.logback.classic.boolex" -> "groovy.lang",
"ch.qos.logback.classic.boolex" -> "org.codehaus.groovy.control",
"ch.qos.logback.classic.boolex" -> "org.codehaus.groovy.reflection",
"ch.qos.logback.classic.boolex" -> "org.codehaus.groovy.runtime",
"ch.qos.logback.classic.boolex" -> "org.codehaus.groovy.runtime.callsite",
"ch.qos.logback.classic.boolex" -> "org.codehaus.groovy.runtime.typehandling",
"ch.qos.logback.classic.gaffer" -> "groovy.lang",
"ch.qos.logback.classic.gaffer" -> "org.codehaus.groovy.control",
"ch.qos.logback.classic.gaffer" -> "org.codehaus.groovy.control.customizers",
"ch.qos.logback.classic.gaffer" -> "org.codehaus.groovy.reflection",
"ch.qos.logback.classic.gaffer" -> "org.codehaus.groovy.runtime",
"ch.qos.logback.classic.gaffer" -> "org.codehaus.groovy.runtime.callsite",
"ch.qos.logback.classic.gaffer" -> "org.codehaus.groovy.runtime.typehandling",
"ch.qos.logback.classic.gaffer" -> "org.codehaus.groovy.runtime.wrappers",
"ch.qos.logback.classic.gaffer" -> "org.codehaus.groovy.transform",
"ch.qos.logback.classic.helpers" -> "javax.servlet",
"ch.qos.logback.classic.helpers" -> "javax.servlet.http",
"ch.qos.logback.classic.selector.servlet" -> "javax.servlet",
"ch.qos.logback.classic.servlet" -> "javax.servlet",
"ch.qos.logback.core.boolex" -> "org.codehaus.janino",
"ch.qos.logback.core.joran.conditional" -> "org.codehaus.commons.compiler",
"ch.qos.logback.core.joran.conditional" -> "org.codehaus.janino",
"ch.qos.logback.core.net" -> "javax.mail",
"ch.qos.logback.core.net" -> "javax.mail.internet",
"ch.qos.logback.core.status" -> "javax.servlet",
"ch.qos.logback.core.status" -> "javax.servlet.http",
"io.jsonwebtoken.impl" -> "android.util",
"io.jsonwebtoken.impl.crypto" -> "org.bouncycastle.jce",
"io.jsonwebtoken.impl.crypto" -> "org.bouncycastle.jce.spec",
"javax.transaction" -> "javax.enterprise.context",
"javax.transaction" -> "javax.enterprise.util",
"javax.transaction" -> "javax.interceptor",
"org.joda.time" -> "org.joda.convert",
"org.joda.time.base" -> "org.joda.convert",
)
After we run sbt stage again we will get packaged application with custom jre in target folder
JRE directory size at this point is 80M
stage % du -sh *
28K bin
16K conf
80M jre
40M lib
2.6M share
Let’s try to make it smaller. Add params to jlink build:
jlinkOptions ++= Seq(
"--no-header-files",
"--no-man-pages",
"--compress=2"
)
Results after stage
:
stage % du -sh *
28K bin
16K conf
48M jre
40M lib
2.6M share
Much better! Let’s run our application and test if it works. Run sbt runProd which runs both stage and executes app binary using our custom JRE.
[test-jlink] $ runProd -Dplay.http.secret.key=changeme_for_prod_mode_to_work
and we got error:
Oops, cannot start the server.
java.lang.ExceptionInInitializerError
at akka.dispatch.AbstractNodeQueue.<clinit>(AbstractNodeQueue.java:181)
at akka.actor.LightArrayRevolverScheduler.<init>(LightArrayRevolverScheduler.scala:190)
...
at play.core.server.ProdServerStart$.main(ProdServerStart.scala:29)
at play.core.server.ProdServerStart.main(ProdServerStart.scala)
Caused by: java.lang.ExceptionInInitializerError
at akka.util.Unsafe.<clinit>(Unsafe.java:52)
at akka.dispatch.AbstractNodeQueue.<clinit>(AbstractNodeQueue.java:179)
... 42 more
Caused by: java.lang.NoClassDefFoundError: sun/misc/Unsafe
at akka.util.Unsafe.<clinit>(Unsafe.java:27)
... 43 more
Caused by: java.lang.ClassNotFoundException: sun.misc.Unsafe
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:581)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:522)
... 44 more
To make our app work we must add several additional modules to have classes on the classpath. Reason for this that sun.misc.Unsafe is in jdk.unsopported module. Let’s add it:
jlinkModules ++= Seq(
"jdk.crypto.ec",
"jdk.unsupported"
)
and re-run runProd.
Yay! Let’s test it in the browser.
Nice! We have a standard play framework app running on custom JRE build from OpenJDK.
So far so good! New problems arise when we start to add dependencies. Here are some examples:
[error] Exception in thread "main" java.lang.module.FindException: Module java.activation not found, required by java.xml.bind
[error] at java.base/java.lang.module.Resolver.findFail(Resolver.java:877)
[error] at java.base/java.lang.module.Resolver.resolve(Resolver.java:191)
[error] at java.base/java.lang.module.Resolver.resolve(Resolver.java:140)
[error] at java.base/java.lang.module.Configuration.resolve(Configuration.java:422)
[error] at java.base/java.lang.module.Configuration.resolve(Configuration.java:256)
[error] at jdk.jdeps/com.sun.tools.jdeps.JdepsConfiguration.<init>(JdepsConfiguration.java:117)
[error] at jdk.jdeps/com.sun.tools.jdeps.JdepsConfiguration$Builder.build(JdepsConfiguration.java:563)
[error] at jdk.jdeps/com.sun.tools.jdeps.JdepsTask.buildConfig(JdepsTask.java:589)
[error] at jdk.jdeps/com.sun.tools.jdeps.JdepsTask.run(JdepsTask.java:543)
[error] at jdk.jdeps/com.sun.tools.jdeps.JdepsTask.run(JdepsTask.java:519)
[error] at jdk.jdeps/com.sun.tools.jdeps.Main.run(Main.java:64)
[error] at jdk.jdeps/com.sun.tools.jdeps.Main$JDepsToolProvider.run(Main.java:73)
[error] at java.base/java.util.spi.ToolProvider.run(ToolProvider.java:137)
[error] at ru.eldis.toollauncher.ToolLauncher.runTool(ToolLauncher.java:68)
[error] at ru.eldis.toollauncher.ToolLauncher.lambda$main$1(ToolLauncher.java:33)
[error] at ru.eldis.toollauncher.ToolLauncher.main(ToolLauncher.java:48)
In this situation we must add javax.activation jar to module path:
jlinkModulePath := {
fullClasspath
.in(jlinkBuildImage)
.value
.filter { item =>
item.data.toString().contains("javax.activation")
}
.map(_.data)
}
Another example with graalvm.js dependency:
"org.graalvm.js" % "js" % "21.0.0.2"
[error] Exception in thread "main" java.lang.module.FindException: Module com.ibm.icu not found, required by org.graalvm.js
[error] at java.base/java.lang.module.Resolver.findFail(Resolver.java:877)
[error] at java.base/java.lang.module.Resolver.resolve(Resolver.java:191)
[error] at java.base/java.lang.module.Resolver.resolve(Resolver.java:140)
[error] at java.base/java.lang.module.Configuration.resolve(Configuration.java:422)
[error] at java.base/java.lang.module.Configuration.resolve(Configuration.java:256)
[error] at jdk.jdeps/com.sun.tools.jdeps.JdepsConfiguration.<init>(JdepsConfiguration.java:117)
[error] at jdk.jdeps/com.sun.tools.jdeps.JdepsConfiguration$Builder.build(JdepsConfiguration.java:563)
[error] at jdk.jdeps/com.sun.tools.jdeps.JdepsTask.buildConfig(JdepsTask.java:589)
[error] at jdk.jdeps/com.sun.tools.jdeps.JdepsTask.run(JdepsTask.java:543)
[error] at jdk.jdeps/com.sun.tools.jdeps.JdepsTask.run(JdepsTask.java:519)
[error] at jdk.jdeps/com.sun.tools.jdeps.Main.run(Main.java:64)
[error] at jdk.jdeps/com.sun.tools.jdeps.Main$JDepsToolProvider.run(Main.java:73)
[error] at java.base/java.util.spi.ToolProvider.run(ToolProvider.java:137)
[error] at ru.eldis.toollauncher.ToolLauncher.runTool(ToolLauncher.java:68)
[error] at ru.eldis.toollauncher.ToolLauncher.lambda$main$1(ToolLauncher.java:33)
[error] at ru.eldis.toollauncher.ToolLauncher.main(ToolLauncher.java:48)
In this case we must add icu4j jar file to module path, just like with “paranamer”
jlinkModulePath := {
fullClasspath
.in(jlinkBuildImage)
.value
.filter { item =>
item.get(moduleID.key).exists { modId =>
modId.name == "paranamer"
} ||
item.data.toString().contains("icu4j")
}
.map(_.data)
}
Docker
Having zipped application with custom JRE is good but in 2021 the’s a high chance that we will need docker images. Let’s do that! Add another plugin into the build file:
lazy val root = (project in file(".")).enablePlugins(PlayScala, JlinkPlugin, DockerPlugin)
Next step - set some params for docker plugin
import com.typesafe.sbt.packager.docker.DockerChmodType
import com.typesafe.sbt.SbtNativePackager.autoImport.NativePackagerHelper._
mappings in Universal ++= directory(baseDirectory.value / "jre")
dockerBaseImage := "debian:stable-slim"
dockerExposedPorts := Seq(9000)
dockerChmodType := DockerChmodType.UserGroupWriteExecute
dockerAdditionalPermissions += (DockerChmodType.UserGroupPlusExecute, "/opt/docker/jre/bin/java")
Here we add jre dir to docker image, doing chmod to jre binary, set exposed ports and base image. You can change base image to any other you preffer, but alpine linux will not work here with jdk11. See - jeps/386.
Now we can test out build with sbt command:
sbt docker:publishLocal
We got a docker image with our custom JRE bundled inside. The size of the resulting docker image is - 156 MB which is pretty good compared to those with full JRE 250-300 MB. In some situations docker image size can be more than 1GB, looking at you, graalvm docker image.
Let’s run this container and test how it works in the browser:
docker run -p 9000:9000 test-jlink:1.0-SNAPSHOT
Great, our application works and now we have a docker image with custom slim JRE which we can push to the registry and run on the server. One more thing to keep in mind - to run the app in docker(Linux) you must build it on a Linux host machine. If you build a docker image on mac os and then run it you will get an error:
test-jlink % docker run test-jlink:1.0-SNAPSHOT
/opt/docker/bin/test-jlink: 1: exec: /opt/docker/jre/bin/java: Exec format error
For local development purposes, if you need to build and run a docker image of your app - you can simply skip jlink step and use base image bundled with jre adoptopenjdk/openjdk11:alpine-jre
for example. And for production, you can build on CI with Linux and then run on Linux servers.
Or you can build custom jre inside docker, but that’s out of the scope of this article.
Source code on github: https://github.com/dkovalenko/scala-sbt-jlink-demo
Want to discuss?
Wrote a blog post about custom JRE generation using jdeps, jlink and sbt-native-packager. There's not much info on that topic. Successfully built custom JRE for #playframework. JRE size is just 48Mb, docker image just 156Mb! 🤟https://t.co/xZRfgPK5iz#sbt #scala #jlink #jigsaw
— Dmytro Kovalenko (@dkovalenko_fun) May 5, 2021