(J)Link your Java application before putting it into Docker! Part 3/3
In the previous blogpost we built a custom native Java distribution. Let's put it into a docker image now thanks to JIB library!
JIB is a Java library Google created to create Open Container Images (OCI). It supports to build tar.gz, OCI and Docker images. One very nice thing is that it does not need Docker daemon to be available until you want to publish the image locally. In other words, you can publish images on Docker hub or any Docker registry without docker!
It also enables you to customize your layers and command very easily. It comes with a Gradle and Maven plugin but it is optimized for standard builds running on a JVM and not native images, so we will switch to a custom way to create the image.
To integrate it to our build we will use a Groovy script and gmavenplus-plugin. Here is the basic setup in a Maven project (in "demo-link" module if you read previous post):
<plugin>
<groupId>org.codehaus.gmavenplus</groupId>
<artifactId>gmavenplus-plugin</artifactId>
<version>1.6.2</version>
<executions>
<execution>
<id>create-image</id>
<phase>package</phase>
<goals>
<goal>execute</goal>
</goals>
<configuration>
<allowSystemExits>true</allowSystemExits>
<scripts>
<script>${project.basedir}/src/main/build/Docker.groovy</script>
</scripts>
</configuration>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>2.5.6</version>
<type>pom</type>
</dependency>
<dependency> <!-- Note: switch to jib-core >= 1.x if released when you read that snippet -->
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>1.0.0-rc1</version>
</dependency>
</dependencies>
</plugin>
The Docker.groovy file will:
- Create the needed variables for the build,
- Setup the image layer, entrypoint, metadata,
- Push it either to a local Docker daemon or a remote Docker registry
Jib build variables
First we need to know which docker account to use. In general it is a company/project account but in my case I will just use my personal account:
def dockerAccount = 'rmannibucau'
Then we need to define which tag (~version) of the image we will build. Personally I drop SNAPSHOT from the project version and replace it by a (sortable) timestamp or just use the version if it is a release:
def projectVersion = project.getVersion()
def tag = projectVersion.endsWith("-SNAPSHOT") ?
projectVersion.replace("-SNAPSHOT", "") + "_" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) :
projectVersion
From there we can build our image name (including the tag). The simplest way is to use the artifactId - and drop "-link" suffix for instance if it is the convention you used. Also supporting a docker.registry project property can enable you to switch between a local deployment (for dev and testing purposes) and remote deployment of the image:
def image = "${dockerAccount}/${project.getArtifactId().replace('-link', '')}"
def repository = project.properties['docker.registry']
final String imageName =
((repository == null || repository.trim().isEmpty()) ? "" : (repository + '/')) + image + ':' + tag
Finally we grab the produced binary. There are multiple ways to do it but I simply check the zip was produced so the build was successful (see previous post) and use the maven-jlink folder which is the already exploded flavor:
def buildDir = Paths.get(project.build.directory)
def zipToAdd = buildDir.resolve("${project.build.finalName}.zip")
if (!zipToAdd.toFile().exists()) {
throw new IllegalStateException("Missing file ${zipToAdd}")
}
def exploded = new File(project.build.directory, 'maven-jlink') // exploded distro
For consistency we will put the image in /opt/<docker account>/<artifact> but the base name can be anything you want:
def workingDir = AbsoluteUnixPath.get("/opt/${dockerAccount}/${project.artifactId}")
Set up Jib image builder: the base image
The first step with Jib is to get a Jib builder. Here we will start from a base image (FROM in Dockerfiles):
def builder = Jib.from(ImageReference.parse('rmannibucau/jlink-base:11'))
Since we have a full custom Java distribution, we can be tempted to start from scratch image of Docker. If you don't know this one, it is a shortcut to say "start from nothing" but in practise it is not compatible with Java, so you must start from an image with at least glic, libz, ... To find which ones you can use ldd on java:
ldd bin/java
linux-vdso.so.1 => (0x00007ffe14d7f000)
libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f47423c5000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f47421a6000)
libjli.so => /home/rmannibucau/dev/demo/demo-link/target/maven-jlink/bin/../lib/jli/libjli.so (0x00007f4741f95000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f4741d91000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f47419b1000)
/lib64/ld-linux-x86-64.so.2 (0x00007f47425e2000)
There is no official minimal docker for custom distributions so I built one (the one used as image reference) you can reuse if you want. To build it I just use a multi-stage Dockerfile and copied the binaries not provided by the custom java distribution and not available by default in an busybox distribution from a debian. Not very neat but fully functional and image is very light (4.6M).
Jib builder configuration
From now on it is easier, since we just fill the Jib builder with the needed information.
First we make sure to properly mark the build timestamp:
builder.setCreationTime(Instant.now())
Then we set the working directory to the application directory (optional but nicer when you connect into the application):
builder.setWorkingDirectory(workingDir)
Then to avoid surprises, we force the image locale:
builder.addEnvironmentVariable('LC_ALL', 'en_US.UTF8')
And we set some metadata about the image (using labels):
builder.setLabels([
'com.github.rmannibucau.groupId' : project.groupId,
'com.github.rmannibucau.artifactId': project.artifactId,
'com.github.rmannibucau.version' : project.version,
'com.github.rmannibucau.date' : new Date().toString()
])
Note that if you have gitcommit maven plugin or equivalent it is nice to add the git branch and commit if in these metadata.
Now we need to add all the distribution into the docker image in our work directory;
builder.addLayer(LayerConfiguration.builder()
.addEntryRecursive(exploded.toPath(), workingDir)
.build())
This is almost enough but we want to make sure java and keytool are executable so we add them with the right permissions after previous command:
builder.addLayer(LayerConfiguration.builder()
.addEntry(
exploded.toPath().resolve('bin/java'),
workingDir.resolve('bin/java'),
FilePermissions.fromOctalString('700'))
.build())
Finally we must set the right entry point (launching command):
builder.setEntrypoint([
workingDir.resolve('bin/java').toString(),
'-XX:+UseContainerSupport',
'--add-modules', 'com.github.rmannibucau.demo',
'com.github.rmannibucau.demo.Main'
])
This is exactly the same command than in the previous post except that we added UseContainerSupport option which will enable us to auto configure the JVM memory settings from the container settings. It is important to set it, otherwise the memory allocation is a bit of a pain for java application running into Docker. Note that the old CGroups option does not exist anymore in Java 11 too.
Create the image from jib builder
Based on the presence of the registry in the project properties, we will either build a local docker image pushed to the local daemon, or pushed remotely to the registry the image (with no need of docker running locally).
Before digging into how to do that let's just note that both options will rely on a cache folder for pulled images and layers. By default we will put it in target but feel free to customize the location to be able to speed up your build if you use big base images:
def cache = buildDir.resolve('maven/build/cache')
Since we resolved the registry (repository variable) earlier, now we just need to test it:
if (repository != null && !repository.trim().isEmpty()) {
Pushing the image to a local docker daemon (else branch of this test) is as simple as:
builder.containerize(Containerizer.to(DockerDaemonImage.named(imageName))
.setApplicationLayersCache(cache)
.setBaseImageLayersCache(cache)
.setToolName("Custom Jib Script of ${project.groupId}:${project.artifactId} Jib script")
.setEventHandlers(new EventHandlers()))
Now if you want to push to a remote registry, you just need to change the base container instantiation. However you also need a credential in general. We will resolve it using Maven standard credential mecanism, a.k.a servers (in settings.xml). The server name will be the registry value (same default as in Jib Maven plugin).
For this remote case we start by instantiating the target image reference:
def registryImage = RegistryImage.named(imageName)
Then we resolve the credential if it exists and decrypt the password value if needed before setting the credentials on the image:
def credentials = session.getSettings().getServer(repository)
if (credentials != null) {
def result = settingsDecrypter.decrypt(new DefaultSettingsDecryptionRequest(credentials))
credentials = result == null ? credentials : result.getServer()
registryImage.addCredential(credentials.username, credentials.password)
}
Finally we use the same code as for the local docker daemon case but using our registryImage:
builder.containerize(Containerizer.to(registryImage)
.setApplicationLayersCache(cache)
.setBaseImageLayersCache(cache)
.setToolName("Jib Build Script ${project.groupId}:${project.artifactId} Jib script")
.setEventHandlers(new EventHandlers()))
And here it is, we can build our docker image locally or not, and deploy our custom Java distribution through docker.
To build locally you can run:
mvn clean install -Ddocker.registry=
And to push to docker hub you can use:
mvn clean install -Ddocker.registry=registry.hub.docker.com/
Finally you can run the image which should be around 48M using this docker command:
docker run rmannibucau/demo:1.0.0_20190217165114
Docker.groovy
If you are impatient and don't want to read all the previous details here is the full Docker.groovy script:
import com.google.cloud.tools.jib.api.Containerizer
import com.google.cloud.tools.jib.api.DockerDaemonImage
import com.google.cloud.tools.jib.api.Jib
import com.google.cloud.tools.jib.api.RegistryImage
import com.google.cloud.tools.jib.configuration.FilePermissions
import com.google.cloud.tools.jib.configuration.LayerConfiguration
import com.google.cloud.tools.jib.event.EventHandlers
import com.google.cloud.tools.jib.filesystem.AbsoluteUnixPath
import com.google.cloud.tools.jib.image.ImageReference
import org.apache.maven.settings.crypto.DefaultSettingsDecryptionRequest
import java.nio.file.Paths
import java.text.SimpleDateFormat
import java.time.Instant
def dockerAccount = 'rmannibucau'
// prepare variables and files
def projectVersion = project.getVersion()
def tag = projectVersion.endsWith("-SNAPSHOT") ?
projectVersion.replace("-SNAPSHOT", "") + "_" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) :
projectVersion
def image = "${dockerAccount}/${project.getArtifactId().replace('-link', '')}"
def repository = project.properties['docker.registry']
final String imageName =
((repository == null || repository.trim().isEmpty()) ? "" : (repository + '/')) + image + ':' + tag
def buildDir = Paths.get(project.build.directory)
def zipToAdd = buildDir.resolve("${project.build.finalName}.zip")
if (!zipToAdd.toFile().exists()) {
throw new IllegalStateException("Missing file ${zipToAdd}")
}
def exploded = new File(project.build.directory, 'maven-jlink') // exploded distro
// setup jib
def workingDir = AbsoluteUnixPath.get("/opt/${dockerAccount}/${project.artifactId}")
def builder = Jib.from(ImageReference.parse('rmannibucau/jlink-base:11'))
builder.setCreationTime(Instant.now())
builder.setWorkingDirectory(workingDir)
builder.addEnvironmentVariable('LC_ALL', 'en_US.UTF8')
builder.setLabels([
'com.github.rmannibucau.groupId' : project.groupId,
'com.github.rmannibucau.artifactId': project.artifactId,
'com.github.rmannibucau.version' : project.version,
'com.github.rmannibucau.date' : new Date().toString()
])
// the distro
builder.addLayer(LayerConfiguration.builder()
.addEntryRecursive(exploded.toPath(), workingDir)
.build())
// ensure java is executable
builder.addLayer(LayerConfiguration.builder()
.addEntry(
exploded.toPath().resolve('bin/java'),
workingDir.resolve('bin/java'),
FilePermissions.fromOctalString('700'))
.build())
builder.setEntrypoint([
workingDir.resolve('bin/java').toString(),
'-XX:+UseContainerSupport',
'--add-modules', 'com.github.rmannibucau.log.access.core',
'com.github.rmannibucau.log.access.core.Launcher'
])
// build the actual image
def cache = buildDir.resolve('maven/build/cache')
if (repository != null && !repository.trim().isEmpty()) { // push
log.info("Creating docker image and pushing it on ${repository}")
final RegistryImage registryImage = RegistryImage.named(imageName)
def credentials = session.getSettings().getServer(repository)
if (credentials != null) {
def result = settingsDecrypter.decrypt(new DefaultSettingsDecryptionRequest(credentials))
credentials = result == null ? credentials : result.getServer()
registryImage.addCredential(credentials.username, credentials.password)
}
builder.containerize(Containerizer.to(registryImage)
.setApplicationLayersCache(cache)
.setBaseImageLayersCache(cache)
.setToolName("Docker script ${project.groupId}:${project.artifactId} Jib script")
.setEventHandlers(new EventHandlers()))
log.info("Pushed image='" + imageName + "', tag='" + tag + "'")
} else { // local
log.info("Creating docker image and pushing it locally")
def docker = DockerDaemonImage.named(imageName)
builder.containerize(Containerizer.to(docker)
.setApplicationLayersCache(cache)
.setBaseImageLayersCache(cache)
.setToolName("Docker script ${project.groupId}:${project.artifactId} Jib script")
.setEventHandlers(new EventHandlers()))
log.info("Built local image='${imageName}', tag='${tag}")
}
From the same author:
In the same category: