logo RManniBucau Blog
    • Java Code Geeks

    (J)Link your Java application before putting it into Docker! Part 3/3

    , Romain Manni-Bucau, 2019-02-22, 8 min and 2 sec read

    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:

    • Romain Manni-Bucau

    In the same category:

    • Java/EE/Microprofile

    • Previous

    • All posts

    • Next

    Navigation

    • Java Code Geeks
    RManniBucau ©
    Search