All articles

// Knowledge.log — 技術記事

OpenShift 4.22 with Java 25: Containerfile and probes on restricted-v3

Package Spring Boot 4.1.1 with Java 25 on UBI 9, pin the image, configure probes, and validate deployment under the restricted-v3 SCC.

Deploying a Java 25 application to OpenShift should not depend on discovering, after deployment, which JDK came in an ImageStream named only java. The more predictable path is to choose the image explicitly, pin its version, separate artifact preparation from the runtime image, and declare probes that answer the right question.

The baseline here is OpenShift Container Platform 4.22, Spring Boot 4.1.1, and Java 25 LTS. The Spring Boot 4.1.1 matrix requires Java 17 or later and declares compatibility through Java 26, so Java 25 is within the supported set. For the operating system and JDK, we will use the GA ubi9/openjdk-25 and ubi9/openjdk-25-runtime images from the Red Hat catalog.

The result will be a Containerfile with a pinned tag, non-root execution, and a Deployment with startupProbe, livenessProbe, and readinessProbe suitable for the restricted-v3 standard used by new OpenShift 4.20 and later installations. The verification commands below are for the build environment and the cluster that will actually receive the image.

Do not confuse an available image with a selected image

The ubi8/openjdk-17 image still exists as an S2I builder. That does not prove it is "the default Java" in OpenShift 4.22, nor is there a basis for claiming that every Java template in a cluster points to it. The catalog and installed ImageStreams can vary, especially in clusters upgraded over the years.

The fix is simple: do not depend on a generic name when the JDK version is part of the application's contract. Reference registry.access.redhat.com/ubi9/openjdk-25 in the Containerfile or configure that address explicitly in the BuildConfig. The ubi9/openjdk-25 image itself is an S2I image, with scripts in /usr/local/s2i, a deployments directory, and numeric user 185.

To observe what is being used, inspect the built manifest and the digest actually deployed instead of inferring it from the friendly name:

oc get deployment demo -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
oc get pod -l app=demo -o jsonpath='{range .items[*]}{.metadata.name}{"  "}{.status.containerStatuses[0].imageID}{"\n"}{end}'

Floating tags are convenient for experiments and terrible for explaining why two builds made on different days are not identical. The computer, with its usual lack of team spirit, merely did what it was told.

The UBI 9 OpenJDK 25 image contract

The ubi9/openjdk-25 builder image includes the JDK and the tools needed to prepare the application. In it, JAVA_HOME points to /usr/lib/jvm/java-25, JAVA_VERSION=25 is already defined, and the default user is UID 185. The JAVA_VERSION variable is informational: redefining it does not download, replace, or install another JDK.

The ubi9/openjdk-25-runtime image is smaller and intended for execution. It contains the runtime, not Maven or the complete JDK toolset; its JAVA_HOME points to the JRE supplied by the image. Use the builder to extract the jar layers and the runtime to start the application.

The catalog publishes latest, 1.24, and stamped tags from the 1.24 line. For production, pin at least 1.24; for stricter reproducibility, prefer a stamped tag or a reviewed digest at promotion time. The examples below use 1.24 to remain readable without leaving the base completely unpinned.

A two-stage, non-root Containerfile

First, generate the jar with a toolchain compatible with Spring Boot 4.1.1. The example assumes that the build produces target/demo.jar:

./mvnw clean package

Then use this Containerfile:

# syntax=docker/dockerfile:1
FROM registry.access.redhat.com/ubi9/openjdk-25:1.24 AS builder

USER 185
WORKDIR /home/default
COPY --chown=185:0 target/demo.jar /tmp/application.jar
RUN java -Djarmode=tools -jar /tmp/application.jar \
    extract --layers --destination /tmp/extracted

FROM registry.access.redhat.com/ubi9/openjdk-25-runtime:1.24

USER 185
WORKDIR /deployments
COPY --from=builder --chown=185:0 /tmp/extracted/dependencies/ ./
COPY --from=builder --chown=185:0 /tmp/extracted/spring-boot-loader/ ./
COPY --from=builder --chown=185:0 /tmp/extracted/snapshot-dependencies/ ./
COPY --from=builder --chown=185:0 /tmp/extracted/application/ ./

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/deployments/application.jar"]

There is no USER root in either stage. UID 185 is numeric, and the copied files belong to 185:0. This respects the image contract and avoids the classic permission fix that grants more privilege than the problem required.

OpenShift can run the container with an arbitrary UID allowed by the SCC. Therefore, every directory the application writes to must be writable by group 0, following Red Hat's guidance for creating images. The OpenJDK images already prepare their working directories for this model. If your application creates another directory, adjust it during the build with group 0 and equivalent permissions, without resorting to root at runtime.

There is also an important difference between the JVM options provided by the Red Hat images: JAVA_OPTS replaces the options generated by the image script, while JAVA_OPTS_APPEND adds options to the calculated set. If you use the image's default launcher, prefer JAVA_OPTS_APPEND for additional adjustments. The direct ENTRYPOINT above does not go through that launcher; in that case, use a standard JVM option such as JAVA_TOOL_OPTIONS, or declare the arguments explicitly. Mixing the two models tends to produce an interesting and unproductive investigation.

Verify the image locally before promoting it

Run these commands in the build environment with Podman. Replace podman with docker if that is the available engine:

podman pull registry.access.redhat.com/ubi9/openjdk-25:1.24
podman run --rm --entrypoint java \
  registry.access.redhat.com/ubi9/openjdk-25:1.24 -version
podman inspect --format '{{.Config.User}}' \
  registry.access.redhat.com/ubi9/openjdk-25:1.24

podman build -t demo-java25:local -f Containerfile .
podman run --rm --entrypoint java demo-java25:local -version
podman inspect --format '{{.Config.User}}' demo-java25:local

The expected observation is Java 25 and a user configured as 185 in both the base image and the final image. Do not copy an exact JDK patch string into permanent documentation: confirm the value of the tag that will be promoted. If a pull from registry.redhat.io returns 401, authenticate with a service account; the UBI images on registry.access.redhat.com are the unauthenticated path used here.

Spring Boot probes that measure different things

With Actuator on the classpath, Spring Boot 4.1.1 provides the Kubernetes groups at /actuator/health/liveness and /actuator/health/readiness. Liveness should answer whether the process can continue, not whether the database, broker, and every neighbor are happy. Pointing it at /actuator/health can restart a healthy JVM during an external outage and turn one problem into two.

For a deeper look at this separation, the post on health, liveness, and readiness in production explains the role of each signal.

The manifest below includes a startupProbe to prevent liveness and readiness from acting while the JVM is still starting:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: demo
  template:
    metadata:
      labels:
        app: demo
    spec:
      hostUsers: false
      securityContext:
        runAsNonRoot: true
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: app
          image: image-registry.example/demo:1.24
          ports:
            - name: http
              containerPort: 8080
          securityContext:
            allowPrivilegeEscalation: false
            runAsNonRoot: true
            capabilities:
              drop:
                - ALL
            seccompProfile:
              type: RuntimeDefault
          startupProbe:
            httpGet:
              path: /actuator/health/liveness
              port: http
            periodSeconds: 10
            failureThreshold: 30
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: http
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: http
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3

The startupProbe allows up to five minutes before failing: 30 attempts at 10-second intervals. Adjust that budget by measuring actual startup, not by increasing the number until the alert stops. When readiness fails, the pod is removed from the service endpoints; when liveness fails, the kubelet restarts the container according to the pod policy. The OpenShift 4.22 health check documentation describes this behavior.

If Actuator is on a separate management port, the probe can stay green while the main port is stuck. One fix is to set management.endpoint.health.probes.add-additional-paths=true and probe /livez and /readyz on the main port. In production, monitor readiness changes, restarts, and pod events:

oc get pods -l app=demo -w
oc describe pod -l app=demo
oc get events --sort-by=.lastTimestamp

restricted-v3 is not the old restricted

In new installations starting with OpenShift 4.20, the most restrictive default SCC for authenticated users is restricted-v3. It evolves restricted-v2 with user namespace isolation and requires hostUsers: false. The OpenShift 4.22 documentation is explicit on two important points: do not modify the default SCCs, and verify the SCC applied in the target cluster.

The manifest uses hostUsers: false, drops capabilities, blocks privilege escalation, and requires non-root execution. The image already declares a numeric USER. Do not pin runAsUser: 185 in the Deployment: allow the SCC to assign the UID accepted by the namespace. Also avoid ports below 1024 and directories writable only by the original owner.

Clusters upgraded from older versions may retain another configuration, such as restricted-v2. The correct observation comes from the pod admitted to the target cluster:

oc get scc
oc describe pod <nome-do-pod>
oc get pod <nome-do-pod> -o jsonpath='{.metadata.annotations.openshift\.io/scc}{"\n"}'

If admission fails, fix the image or the securityContext; do not edit restricted-v3 to accommodate the manifest. A loosened default SCC solves today's deployment and sends the bill to every other workload in the cluster.

Limits and promotion order

OpenShift 4.22 is the platform used in the example. Java 25 support comes from the Red Hat build of OpenJDK 25 life cycle, the Spring Boot Java matrix, and the published catalog image—not from a platform compatibility cell. Version 4.20 remains an older documented GA release, not the tutorial baseline.

Before promoting the manifest, read and verify these items in order:

  1. The OpenShift 4.22 release notes and the supported upgrade path for your cluster.
  2. The OpenJDK 25 support lifecycle for the RHEL base used by the image.
  3. The Spring Boot 4.1.1 matrix, which supports Java 17 through 26.
  4. The ubi9/openjdk-25 image tag or digest in the catalog; do not promote latest.
  5. The SCC actually applied to the pod in the target cluster.

DevDojo recommends adopting this set when the application has already been validated on Spring Boot 4.1.1, the pipeline can pin and inspect the UBI 9 image, and the manifest passes under the real SCC without extra privileges. Stop the promotion if the framework matrix, OpenJDK lifecycle, catalog tag, or cluster policy do not align.

The next step is short: pin ubi9/openjdk-25:1.24, build the image, confirm java -version locally, and add all three probes to the Deployment. Then validate the SCC and events in the OpenShift cluster that will receive the workload—because the target cluster is the only one that can answer for its own configuration.

javaopenshiftspring-boot

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles