Jib Docker Image Build

1. Overview

EMS services use Google Jib to build container images — no Dockerfile, no Docker daemon. The image is produced by Maven’s package phase when the appropriate profile is active. Jib lays out the image optimally (dependencies in one layer, application classes in another) so incremental builds cache-hit the dependency layer.

Reference base: eclipse-temurin:17-jre-focal. Current target JVM: Java 17 (despite Java 21 in the parent-pom target; Jib image stays on 17 for compatibility with the Alpine-free Focal base). This will move to 21 when eclipse-temurin:21-jre-* stabilises on a Focal-equivalent base.

This page documents the Jib configuration, common operations, and how admin-portal’s image is built.

2. Configuration

From admin-service/pom.xml:

<plugin>
    <groupId>com.google.cloud.tools</groupId>
    <artifactId>jib-maven-plugin</artifactId>
    <configuration>
        <from>
            <image>${jib-maven-plugin.image}</image>
            <platforms>
                <platform>
                    <architecture>${jib-maven-plugin.architecture}</architecture>
                    <os>linux</os>
                </platform>
            </platforms>
        </from>
        <to>
            <image>docker.io/christhonie/event-admin-service:${project.version}</image>
            <auth>
                <username>christhonie</username>
                <password>${env.DOCKER_HUB_TOKEN}</password>
            </auth>
            <tags>
                <tag>${project.version}</tag>
                <tag>latest</tag>
            </tags>
        </to>
        <container>
            <mainClass>${main-class}</mainClass>
            <jvmFlags>
                <jvmFlag>-Djava.security.egd=file:/dev/./urandom</jvmFlag>
                <jvmFlag>-Dspring.profiles.active=prod</jvmFlag>
            </jvmFlags>
            <ports>
                <port>${server.port}</port>
            </ports>
            <labels>
                <org.opencontainers.image.title>${project.artifactId}</org.opencontainers.image.title>
                <org.opencontainers.image.version>${project.version}</org.opencontainers.image.version>
                <org.opencontainers.image.revision>${git.commit.id.full}</org.opencontainers.image.revision>
            </labels>
        </container>
        <extraDirectories>
            <paths>
                <path>
                    <from>src/main/jib</from>
                </path>
            </paths>
        </extraDirectories>
    </configuration>
</plugin>

Properties from the parent POM or service POM:

  • jib-maven-plugin.image = eclipse-temurin:17-jre-focal

  • jib-maven-plugin.architecture = amd64 (linux/amd64; crossbuild to arm64 via explicit override if needed)

  • main-class = za.co.idealogic.event.admin.AdminServiceApp (per service)

3. Build Commands

Build to local Docker daemon (for dev iteration with a local Kubernetes):

./mvnw -Pprod package jib:dockerBuild

Build and push directly to registry (CI, no daemon required):

./mvnw -Pprod package jib:build

Build to tarball (offline, sneakernet):

./mvnw -Pprod package jib:buildTar
# produces target/jib-image.tar

Push the tarball separately with docker load + docker push or skopeo copy tarball:…​.

4. Layering

Jib lays out four layers (bottom to top):

  1. Base image (eclipse-temurin:17-jre-focal) — shared across all EMS services

  2. Dependencies — the JARs under WEB-INF/lib for the application; changes rarely, big layer

  3. Resourcessrc/main/resources/ content (config, Liquibase changelogs, i18n)

  4. Classes — compiled application classes + static webapp output

A pure source change touches only layer 4; dependency changes touch layer 2 and bust downstream cache. Incremental builds cache-hit layers 1-3 typically.

This layering is important for registry + pull performance: small diffs on patches keep rollout fast.

5. src/main/jib/ Extra Directory

Everything placed in a directory referenced by Jib’s <extraDirectories> is copied into the image’s root filesystem at the corresponding path. Common uses:

  • Custom /etc/ssl/certs/ additions if the service needs to trust private CAs

  • OpenTelemetry javaagent download (see § OTel javaagent below)

  • Non-Maven static files that belong in the image but not on the classpath

Keep contents minimal — every file here inflates the image.

5.1. OTel javaagent — the EMS pattern

The OTel javaagent is always baked into every EMS service image, regardless of which Spring profile is active. Whether the agent actually exports data depends on runtime config (the otlp profile + endpoint setting), but the agent itself is always present so the image is consistent across environments.

The pattern is two cooperating Maven plugin executions, both in the main <build> section (not in any profile):

5.1.1. 1. Maven downloads the agent into the build output

<properties>
    <agent-extraction-root>${project.build.directory}/jib-agents</agent-extraction-root>
    <agent-install-location>/javaagent</agent-install-location>
    <opentelemetry-javaagent-filename>opentelemetry-javaagent.jar</opentelemetry-javaagent-filename>
    <opentelemetry-javaagent.version>2.10.0</opentelemetry-javaagent.version>
</properties>

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-dependency-plugin</artifactId>
    <executions>
        <execution>
            <id>copy-javaagent</id>
            <phase>package</phase>
            <goals><goal>copy</goal></goals>
            <configuration>
                <artifactItems>
                    <artifactItem>
                        <groupId>io.opentelemetry.javaagent</groupId>
                        <artifactId>opentelemetry-javaagent</artifactId>
                        <version>${opentelemetry-javaagent.version}</version>
                        <outputDirectory>${agent-extraction-root}</outputDirectory>
                        <destFileName>${opentelemetry-javaagent-filename}</destFileName>
                    </artifactItem>
                </artifactItems>
            </configuration>
        </execution>
    </executions>
</plugin>

Output: target/jib-agents/opentelemetry-javaagent.jar.

5.1.2. 2. Jib copies it into the image and wires the JVM flag

<plugin>
    <groupId>com.google.cloud.tools</groupId>
    <artifactId>jib-maven-plugin</artifactId>
    <configuration>
        <extraDirectories>
            <paths>
                <path>
                    <from>${agent-extraction-root}</from>
                    <into>${agent-install-location}</into>
                </path>
            </paths>
        </extraDirectories>
        <container>
            <environment>
                <OTEL_SERVICE_NAME>${project.artifactId}</OTEL_SERVICE_NAME>
            </environment>
            <jvmFlags>
                <jvmFlag>-javaagent:${agent-install-location}/${opentelemetry-javaagent-filename}</jvmFlag>
                <jvmFlag>-Dotel.logs.exporter=otlp</jvmFlag>
                <jvmFlag>-Dotel.traces.exporter=otlp</jvmFlag>
                <jvmFlag>-Dotel.metrics.exporter=otlp</jvmFlag>
                <!-- ... other JVM flags ... -->
            </jvmFlags>
        </container>
    </configuration>
</plugin>

In the running container, the agent is at /javaagent/opentelemetry-javaagent.jar and the JVM is started with -javaagent:/javaagent/opentelemetry-javaagent.jar. The exporter selectors (-Dotel.{logs,traces,metrics}.exporter=otlp) are explicit because the agent’s default behaviour for some signal types varies across versions.

5.1.3. Spring-side instrumentation lives in the otlp profile

The otlp Maven profile adds the Spring-side dependencies that change runtime behaviour by their presence — opentelemetry-spring-boot-starter and opentelemetry-instrumentation-annotations — and activates application-otlp.yml.

opentelemetry-logback-appender-1.0 is the exception and belongs in the main <dependencies>, not the profile: OpenTelemetryLoggingConfiguration lives in src/main/java and references it unconditionally, so confining it to a profile breaks a plain mvn verify. This was learned the hard way in admin-service (ADO-1009).

The profile must also be activated in CI. push-main.yml and push-dev.yml pass it through maven-profiles; a profile that exists and is never activated leaves application-otlp.yml with no consumer, which is exactly what happened in registration-portal’s first cut of ADO-1010. It which configures the exporter endpoint and disables Spring Web auto-instrumentation (the agent already covers it). See OpenTelemetry Configuration for the full Spring config.

5.1.4. Anti-patterns to avoid

Mistake What goes wrong

Maven downloads to src/main/jib/opt/otel/…​ and Jib <extraDirectories> points at a different path (e.g. src/main/docker/jib)

Agent silently absent from the image. JVM logs -javaagent: file not found at startup; OTel emits nothing. Easy to miss because the build succeeds.

Maven downloads to src/main/…​ (the source tree)

Pollutes source-control state. Agent jars belong in target/ because they are build outputs.

Putting the dependency:copy execution under the otlp profile only

Image only has the agent when the otlp profile was active at build time. Different image content per profile-combo. Rebuild matrix grows. Always download; gate the use of the agent at runtime via Spring profile + env vars.

Using a custom entrypoint.sh that conditionally adds -javaagent based on SPRING_PROFILES_ACTIVE

The Jib <jvmFlags> mechanism is the canonical way to set JVM options. The shell-script approach reinvents it, hides the JVM flag from jib config inspect, and creates a divergence between local-run and container-run.

Setting <entrypoint> at all, then expecting <jvmFlags> to apply

Jib ignores <jvmFlags> entirely when an <entrypoint> is set — the entrypoint replaces the whole command, flags included. Nothing warns. A -javaagent flag added to the POM in that state is silently dropped, and the image starts uninstrumented. See § No custom entrypoint below.

Hard-coding the agent path in a shell script instead of interpolating the Maven properties

The script literal and ${agent-install-location} agree only until someone changes one of them. A guard that checks for the jar converts that drift from silent breakage into a crash-looping pod, which is better but still not a fix. <jvmFlags> interpolates the properties directly, so there is genuinely one source of truth.

5.2. No custom entrypoint

EMS services do not set Jib’s <entrypoint>. Jib generates one from the Spring Boot extension, computing the layered classpath itself, and <jvmFlags> are honoured only in that mode.

This matters beyond tidiness. registration-portal carried a JHipster-generated entrypoint.sh until ADO-1010, and it was doing three things — none of which Kubernetes needed:

What the script did Why it was unnecessary

sleep ${JHIPSTER_SLEEP}

JHIPSTER_SLEEP was set to 0 in the same POM. A no-op.

file_env for seven variables, implementing Docker’s _FILE secret convention

The Helm chart injects every one of them through secretKeyRef as a plain environment variable. No _FILE variant appears anywhere in the chart or in any compose file in the repo.

exec java … -cp /app/resources/:/app/classes/:/app/libs/*

Exactly what Jib’s Spring Boot extension computes on its own, and it produces -cp @/app/jib-classpath-file rather than a hand-maintained list.

Removing it is what allows the agent flag to live in the POM at all.

5.3. Verify the agent against the artefact, not the source

Every failure mode here is silent: the build succeeds, the image publishes, and the service runs with no instrumentation. ems-mcp-server shipped that way for four months. Reading the POM is not evidence — unpack the image and look:

mvn -o package -DskipTests jib:buildTar -Djib.skip=false
mkdir -p /tmp/img && tar -xf target/jib-image.tar -C /tmp/img && cd /tmp/img

# 1. Is the agent in a layer, at the expected path?
for f in *.tar.gz; do tar -tzf "$f" | grep -q javaagent && \
  echo "$f: $(tar -tzf "$f" | grep javaagent | tr '\n' ' ')"; done

# 2. Does the generated entrypoint actually carry the flag?
python3 -c "import json; print(json.load(open('config.json'))['config']['Entrypoint'])"

A correct result looks like this — note that the flag appears in the entrypoint, which is the proof <jvmFlags> were honoured:

java -javaagent:/javaagent/opentelemetry-javaagent.jar
     -Dotel.instrumentation.logback-appender.enabled=false
     -noverify -XX:+AlwaysPreTouch -Djava.security.egd=file:/dev/./urandom
     -cp @/app/jib-classpath-file za.co.idealogic...App

mvn jib:build is a direct goal invocation: Maven runs that goal alone and executes no lifecycle phase, so the dependency:copy execution bound to package does not run.

Until 2026-08-24 the shared docker-build.yml workflow ran mvn jib:build on its own and only worked because the restored target/ cache still held the agent from an earlier job — a restore that is explicitly allowed to miss. On a miss, the image published with no agent and nothing failed.

The workflow now runs mvn package jib:build -DskipTests. If you invoke Jib by hand, do the same.

6. Registry Authentication

In CI (GitHub Actions), DOCKER_HUB_TOKEN is set as a repository secret and passed via env:

- name: Build + push image
  run: ./mvnw -Pprod package jib:build
  env:
    DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }}

Locally: run docker login once; Jib picks up the credentials from ~/.docker/config.json.

For Kubernetes pull: image pull secret named christhonie-docker is deployed in every namespace that pulls EMS images.

7. Tagging Strategy

Primary tag: <version> from project.version. For releases: e.g. 2.3.31-RELEASE. For snapshots: 2.3.32-SNAPSHOT.

Floating tag: latest. Points to the most recent push on any branch — useful for local dev iteration, never referenced in Helm values for prod (always pin to a version).

CI-only tag: <branch>-<shortsha> on feature branches, for preview deployments. Not currently automated; add per need.

8. Admin-Portal Specifics

admin-portal’s Jib config mirrors registration-portal’s, with obvious adjustments:

  • <image> target: docker.io/christhonie/event-admin-portal:${project.version}

  • <mainClass>za.co.idealogic.event.admin.portal.AdminPortalApp

  • <port>12506

  • <jvmFlag>-Dspring.profiles.active=prod,kubernetes</jvmFlag> default for the prod container

  • OTel javaagent: optional at launch — admin-portal is not as request-heavy as admin-service; add later if needed.

9. Image Size and Startup

Target image size (admin-service): ~400 MB. Dominated by the base image (~200 MB for temurin-17-jre-focal) + dependencies (~150 MB of Spring + Hibernate + Hazelcast) + app classes + extras.

Cold start: Spring context-up is 15-25 seconds depending on Liquibase migration count. Portals (no Liquibase-heavy paths) start faster — 10-15s typical.

If startup becomes a bottleneck, CRaC (Coordinated Restore at Checkpoint) or native-image (GraalVM) are options; neither is currently in use. Defer until there’s a concrete operational need.

10. Troubleshooting

10.1. jib:build fails with Unauthenticated to Docker Hub

DOCKER_HUB_TOKEN is missing or expired. Regenerate on Docker Hub; update the GitHub secret or local env.

10.2. Image pulls but container crashes on start

Check kubectl logs — most common cause is missing application-*.yml or a required env var not mounted. See Helm Chart Structure for the values the chart injects.

10.3. Incremental build is slow

Jib’s cache lives in ~/.cache/google-cloud-tools-java/jib/. Deleting it forces a full rebuild; rarely needed.

10.4. CRLF characters in JVM flags

Avoid multi-line <jvmFlag> values. Jib joins them and a stray CRLF produces opaque startup errors. Single-line values per flag.

11. Reference

File Role

admin-service/pom.xml

Full Jib config reference (inherits some from parent-pom)

admin-service/src/main/jib/

Extra directory — includes OTel javaagent in otlp profile

registration-portal/pom.xml

Portal-shaped Jib config (frontend-maven-plugin integration)

parent-pom/pom.xml

Jib plugin version pin, default base image property

13. Change History

Date Change

2026-04-24

Initial draft. Grounded in admin-service Jib configuration.

2026-08-24

ADO-1010. Recorded that Jib ignores <jvmFlags> when <entrypoint> is set — a mechanical fact, not a style preference, and the reason EMS services set no custom entrypoint. Added the § No custom entrypoint rationale from registration-portal’s conversion, the artefact-level verification recipe (unpack the image; reading the POM is not evidence), and the warning that mvn jib:build runs no lifecycle phase so the agent download bound to package is skipped. Corrected the claim that opentelemetry-logback-appender-1.0 belongs in the otlp profile — it must be in the main <dependencies>, and the profile must be activated in CI or application-otlp.yml has no consumer.