Appendix Z: Running on OpenShift Local (CRC)
The Appendix T pipeline on OpenShift Local: Kafka from the AMQ Streams operator with pinned, manually approved versions, images built inside the cluster with binary S2I, a Helm chart that runs under restricted-v2 behind edge-TLS Routes, and a teardown that leaves the cluster empty.
Appendix T deployed the order pipeline (REST in, Kafka, a Redis-backed Content Enricher, Kafka out) to minikube. This appendix runs the same Camel Quarkus application on OpenShift Local (CRC), a single-node OpenShift 4 cluster in a VM on your workstation. The application code does not change. What changes is everything around it: how Kafka is installed, where the image is built, which security policy the pods run under, and how traffic gets in.
The scripts and the Helm chart are in openshift/. The application is
examples/38-kubernetes-deploy/quarkus/.
What changes from minikube
| Concern | minikube (Appendix T) | OpenShift Local (this appendix) |
|---|---|---|
| Kafka | Strimzi Helm chart, Kafka 4.3.1 | AMQ Streams operator from OperatorHub, pinned CSV, Kafka 4.2.0 |
| Image build | docker build on the host, minikube image load |
Binary S2I build in the cluster; no host container engine |
| Image source | containerd in the node, imagePullPolicy: Never |
The internal registry (ImageStream eip-k8s-deploy:v1) |
| Pod security | Kubernetes defaults | restricted-v2 SCC: random UID, no privilege escalation, all capabilities dropped |
| Host access | NodePort published on 127.0.0.1 | Route with edge TLS on *.apps-crc.testing |
| Manifests | Generated by quarkus-kubernetes |
Helm chart openshift/helm/eip |
| Clean-up | setup-minikube-stack.sh --clean |
teardown.sh: operators, CRDs, namespaces, then crc stop |
The env contract stays identical (KAFKA_BROKERS and QUARKUS_REDIS_HOSTS
override application.properties), so the same jar runs on both clusters.
Prerequisites
Fedora or RHEL, on bare metal or in a VM with nested virtualisation. CRC runs
the cluster in a libvirt VM, so the host needs KVM; crc setup checks and
configures it.
| Tool | Why |
|---|---|
crc (OpenShift Local) |
The cluster. Needs a pull secret from a free Red Hat Developer account |
oc |
Every script. Ships with crc: eval "$(crc oc-env)" |
helm 3.18+ or 4 |
The chart |
jq |
InstallPlan selection, evidence |
| JDK 25, Maven 3.9 | Packaging the application on the host |
skopeo |
Re-checking image tags (optional) |
No container engine is involved: the image is built inside the cluster.
One local cluster at a time. Stop any minikube profile first
(minikube stop -p eip); the scripts refuse to run while one is up. Two
clusters on one host compete for memory and fail in ways that look unrelated.
The CRC is dedicated to this project while it runs, and is cleaned after every use. Before a run, check that nothing else is installed:
oc get sub,csv -A
oc get ns --no-headers -o name | grep -v -E '^namespace/(openshift|kube-|default$|hostpath-provisioner$)'
If another project’s namespaces or operators are there, run that project’s own
teardown first. install-infra.sh refuses to share the AMQ Streams
Subscription with another project.
Sizing and first start
Sizing only takes effect while CRC is stopped:
crc config set cpus 6 # 8 with the platform tier
crc config set memory 20480 # 24576 with the platform tier
crc config set disk-size 80
crc setup
crc start --pull-secret-file ~/pull-secret.txt
eval "$(crc oc-env)"
crc setup also makes api.crc.testing and *.apps-crc.testing resolve on the
host, which is what makes Routes reachable without a tunnel.
Credentials
crc start writes a crc-admin context into your kubeconfig, and every script
uses it: require_crc in openshift/lib.sh switches to that context and refuses
any API server other than api.crc.testing. Nothing needs the kubeadmin
password. If you want the web console, run crc console --credentials yourself;
the scripts never print it, and the evidence directory is scrubbed for secrets
before it is kept.
Versions
Everything is pinned to an exact version. Operators install with
installPlanApproval: Manual and a startingCSV, and the scripts approve only
the InstallPlan naming that CSV, so an operator never moves without an edit.
| Component | Pin |
|---|---|
| AMQ Streams operator | amqstreams.v3.2.1-14, channel amq-streams-3.2.x |
Kafka (in the Kafka CR) |
4.2.0, metadataVersion: 4.2-IV1 (AMQ Streams 3.2 ships 4.1 and 4.2) |
| Custom Metrics Autoscaler (opt-in) | custom-metrics-autoscaler.v2.19.0-4, channel stable |
| Red Hat build of OpenTelemetry (opt-in) | opentelemetry-operator.v0.158.0-2, channel stable |
| S2I builder | registry.access.redhat.com/ubi10/openjdk-25:1.24-15 |
| Redis | docker.io/library/redis:8.10.2-alpine |
| otel-lgtm (opt-in) | docker.io/grafana/otel-lgtm:0.36.0 |
| Quarkus / Camel | 3.40.1 / 4.22.1 |
These were the newest stable pins on 2026-10-09. Operator CSVs are defined by
the cluster’s catalog, so they can only be confirmed on the cluster. Re-check
before each run and update the Subscription YAML and the *_CSV variable in
the script together:
for pkg in amq-streams openshift-custom-metrics-autoscaler-operator opentelemetry-product; do
echo "$pkg"
oc get packagemanifest "$pkg" -n openshift-marketplace \
-o jsonpath='{range .status.channels[*]}{" "}{.name}{" "}{.currentCSV}{"\n"}{end}'
done
Step 1: project and Kafka
./openshift/install-infra.sh
The script creates the eip project, installs AMQ Streams into
openshift-operators from openshift/infra/amq-streams-subscription.yaml,
approves the one InstallPlan for the pinned CSV, waits for it to report
Succeeded, and applies the Kafka cluster:
apiVersion: kafka.strimzi.io/v1
kind: KafkaNodePool
metadata:
name: dual-role
labels:
strimzi.io/cluster: eip
spec:
replicas: 1
roles: [controller, broker]
storage:
type: jbod
volumes:
- {id: 0, type: persistent-claim, size: 5Gi, deleteClaim: true}
---
apiVersion: kafka.strimzi.io/v1
kind: Kafka
metadata:
name: eip
spec:
kafka:
version: 4.2.0
metadataVersion: 4.2-IV1
listeners:
- {name: plain, port: 9092, type: internal, tls: false}
config:
auto.create.topics.enable: true
num.partitions: 3
offsets.topic.replication.factor: 1
transaction.state.log.replication.factor: 1
transaction.state.log.min.isr: 1
default.replication.factor: 1
min.insync.replicas: 1
AMQ Streams is Red Hat’s build of Strimzi, so this is the same v1 API as
Appendix T, with two differences: the Kafka version is one the installed
operator ships, and topics auto-create with three partitions instead of being
declared as KafkaTopic resources. With no Topic Operator there are no topic
finalizers for the teardown to wait on.
Two waits in that script are deliberate. Right after crc start, the
redhat-operators catalog reports ready about a minute before it serves any
packages, so the script waits for oc get packagemanifest amq-streams rather
than for the CatalogSource. And it waits for the Kafka CR’s Ready condition,
not a fixed sleep.
Step 2: build the image inside the cluster
./openshift/build-images.sh
Maven packages the application on the host; the cluster builds the image. The
Quarkus pom carries a profile that is inactive by default, so mvn verify and
the minikube image are unaffected:
<profile>
<id>openshift</id>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-openshift</artifactId>
</dependency>
</dependencies>
</profile>
The script runs:
mvn -B -q -f examples/38-kubernetes-deploy/quarkus/pom.xml package -DskipTests -Popenshift \
-Dquarkus.container-image.build=true \
-Dquarkus.container-image.group=eip \
-Dquarkus.container-image.tag=v1 \
-Dquarkus.openshift.version=v1 \
-Dquarkus.openshift.base-jvm-image=registry.access.redhat.com/ubi10/openjdk-25:1.24-15 \
-Dquarkus.openshift.namespace=eip \
-Dquarkus.kubernetes.deploy=false
The extension creates a BuildConfig (Source strategy, binary input) and two
ImageStreams, uploads the fast-jar from target/quarkus-app, and the cluster
runs the builder image’s S2I assemble script and pushes eip-k8s-deploy:v1 to
the internal registry. Five settings carry the weight:
quarkus-openshift, notquarkus-container-image-openshiftalone. TheBuildConfigcomes from the OpenShift manifest generator. With only the container-image extension, Maven succeeds in seconds and logs that no OpenShift manifests were generated, so no build runs.quarkus.openshift.versionsets the output tag. With onlyquarkus.container-image.tag, the ImageStream tag stays at the project version,1.0.0-SNAPSHOT.quarkus.openshift.namespacepicks the project. Without it the build runs in the kube context’s current namespace, andcrc startresets that todefault: Maven succeeds, the log saysin namespace:default, andeiphas noeip-k8s-deploy:v1.quarkus.kubernetes-client.namespaceand theKUBERNETES_NAMESPACEvariable do not change this on Quarkus 3.40.quarkus.kubernetes.deploy=false. Build and push only; the Helm chart owns the Deployment.- The builder image. The extension defaults to
ubi9/openjdk-25; the script pinsubi10/openjdk-25to an exact tag that carries the S2I labels.
The S2I image starts the application with its own run-java.sh, not with the
Containerfile from Appendix T. Heap sizing therefore comes from
JAVA_MAX_MEM_RATIO, which the chart sets to 50 percent of the container limit.
Step 3: deploy the chart
./openshift/deploy.sh
deploy.sh runs helm upgrade --install eip openshift/helm/eip against the
crc-admin context and waits on oc rollout status for every Deployment,
not on condition=Available, which the old ReplicaSet still satisfies during an
upgrade. The chart renders:
| Resource | Purpose |
|---|---|
ConfigMap eip-app-config |
KAFKA_BROKERS=eip-kafka-bootstrap:9092, QUARKUS_REDIS_HOSTS=redis://redis:6379, JAVA_MAX_MEM_RATIO=50 |
Deployment + Service eip-k8s-deploy |
The Camel application on port 8088, probes on /q/health/{started,ready,live} |
Route eip-k8s-deploy |
Edge TLS, plain HTTP redirected, https://eip-k8s-deploy-eip.apps-crc.testing |
Deployment + Service redis |
The customer cache |
Every container runs under the restricted-v2 SCC with the same security
context and nothing more:
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
The chart sets no runAsUser. OpenShift assigns each project a UID
range and gives every pod a UID from it; a fixed UID such as 185 or 1001 falls
outside the range and the pod is rejected. Both images here cope: the UBI
OpenJDK image is built for an arbitrary UID, and the official Redis image only
drops privileges when it starts as root, writing nothing outside its /data
emptyDir.
The pod template also carries a checksum of the ConfigMap, because environment
variables from a ConfigMap are read only at pod start: change a value and the
next deploy.sh rolls the pods.
Send an order through the Route. Use the cluster’s ingress CA rather than
--insecure:
oc get configmap default-ingress-cert -n openshift-config-managed \
-o jsonpath='{.data.ca-bundle\.crt}' > /tmp/crc-ingress-ca.crt
curl --cacert /tmp/crc-ingress-ca.crt -X POST \
"https://$(oc get route eip-k8s-deploy -n eip -o jsonpath='{.spec.host}')/api/orders" \
-H "Content-Type: application/json" \
-d '{"orderId":"ORD-001","customerId":"C-101","item":"Shipping Container","quantity":2}'
# {"status":"accepted"}
oc logs -n eip deploy/eip-k8s-deploy | grep 'Enriched order published'
Step 4: capture evidence
./openshift/capture-evidence.sh
The script writes what it observed to openshift/evidence/<date>/ and fails on
the first miss:
- Cluster, operator CSVs, the Kafka version and its
Readycondition, the builds, and one ImageStream tag per service. - Every pod
Readyunderrestricted-v2with a UID from the namespace range. - Every Route answering
200on its health path over edge TLS, checked with the ingress CA. - The pipeline end to end (
openshift/project-checks.sh): the cache loader has seeded Redis, an order posted through the Route is accepted, the processor logs it enriched with “Global Freight Ltd”, and the record is oneip.orders.enriched, read back from the broker pod withoc exec. - The platform tier, for whatever is installed.
- A secret scrub: no token, private key, pull secret or kubeadmin password in the evidence. A capture that fails the scrub is not kept.
Internal Services are reached with oc exec into a pod, by Service name;
never with oc port-forward or a tunnel.
Optional: the platform tier
Two additions mirror what the tutorial teaches elsewhere, and nothing more. Both are off until you install them:
./openshift/platform/install-platform.sh # both, in order
./openshift/platform/install-keda.sh # or one at a time
./openshift/platform/install-observability.sh
Kafka-lag autoscaling: the Competing Consumers pattern from
Chapter 14 with an
autoscaler deciding the consumer count. The Custom Metrics Autoscaler (Red Hat’s
build of KEDA) watches the lag of consumer group k8s-order-processor on
eip.orders.incoming and scales eip-k8s-deploy between one and three
replicas: three because the topic has three partitions, and a consumer group
never uses more consumers than partitions. The minimum stays at one because the
same pod serves the REST API. The trigger uses the bootstrap Service’s fully
qualified name: KEDA runs in openshift-keda, where the short name does not
resolve.
Tracing: the signals from
Appendix I without
touching the pom. The Red Hat build of OpenTelemetry injects the OpenTelemetry
Java agent into the annotated JVM pod, which sends traces to
grafana/otel-lgtm in the same project; Grafana gets its own edge-TLS Route.
otel-lgtm is the one pod outside restricted-v2: it owns its data directories
as UID 0, so it runs under anyuid through its own ServiceAccount, with
runAsUser: 0 and nothing else in its security context. The image’s built-in
collector configuration is used as shipped, health check on port 13133
included.
Service Mesh, GitOps, Ollama and native builds are out of scope here: no chapter of this tutorial teaches them, and each would add an operator to install, size and tear down for nothing the examples demonstrate.
Teardown
./openshift/teardown.sh # clean, then crc stop
./openshift/teardown.sh --keep-running # clean, leave CRC running
The order matters because of finalizers: custom resources go while their operator still runs.
- The Helm release, then the Kafka CR and its node pool, waiting until the Kafka pods are gone.
- The
eipproject: builds, ImageStreams and the Kafka volume with it. - The KedaController, if installed.
- Each operator: Subscription, CSV, InstallPlans, and the CRDs the CSV owns.
- Any remaining
strimzi.io,keda.shoropentelemetry.ioCRDs. - The operator namespaces (
openshift-keda,openshift-opentelemetry-operator).
It finishes with a leftover check, then crc stop (stop, not delete), so the
next start is quick.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Maven finishes in seconds and no image appears | Only quarkus-container-image-openshift on the classpath |
Build with -Popenshift (adds quarkus-openshift) |
ImageStream tag is 1.0.0-SNAPSHOT, not v1 |
Output tag follows quarkus.openshift.version |
build-images.sh sets it |
install_operator times out right after crc start |
The catalog serves no packages for about a minute | The script waits for the packagemanifest; re-run it |
| Pod rejected: UID not in range | A fixed runAsUser in a pod spec |
Never set one; the chart does not |
ScaledObject Ready=False, “no such host” |
Short Kafka Service name from openshift-keda |
Fully qualified bootstrapServers (the chart does this) |
| The pod lacks the Java agent after installing tracing | Deployment created before the Instrumentation CR |
install-observability.sh restarts annotated pods without the agent |
Project stuck in Terminating |
A Strimzi finalizer with the operator already gone | Run teardown.sh, which deletes Kafka resources first |
What you learned
- Operators with pinned versions:
installPlanApproval: Manual, astartingCSV, and approving only the InstallPlan for that CSV keep an operator’s version an explicit decision. - Images built where they run: binary S2I with
quarkus-openshiftturns a host-sidemvn packageinto an ImageStream tag with no host container engine and no exposed registry. - restricted-v2: a minimal security context and no fixed UID are all a well-built image needs.
- Routes instead of NodePorts: edge TLS on
*.apps-crc.testing, verified with the cluster’s ingress CA. - A cluster you can hand back: evidence with a secret scrub, and a
teardown that removes every operator, CRD and namespace before
crc stop.
Build, run, observe
./openshift/install-infra.sh
./openshift/build-images.sh && ./openshift/deploy.sh
The scripts provision AMQ Streams Kafka, build the Quarkus image in-cluster with S2I, and deploy the Helm chart behind an edge-TLS Route; capture-evidence.sh then records the order round-trip and the Redis enrichment under restricted-v2.
References
- Red Hat OpenShift Local documentation
- Quarkus: Deploying on OpenShift
- Strimzi documentation (AMQ Streams is Red Hat’s build of Strimzi)
- Managing security context constraints
- KEDA Apache Kafka scaler