
Optionality
OpenShift vs Kubernetes: Pricing the Exit to Vanilla
OpenShift vs Kubernetes: plain Deployments port cleanly, but Routes, SCCs, BuildConfigs and ImageStreams do not. Where the exit cost really sits.
The question arrives disguised as a renewal: what would not renewing cost? It comes back either as "it is all Kubernetes underneath, so nothing" or as a six-figure estimate with no working behind it. Both treat the distribution as the unit of lock-in. The unit is the set of API objects your manifests name.
Distribution lock-in is quieter than cloud lock-in for one reason: the API looks identical until the moment you leave. kubectl get deployments works; the YAML is the YAML you know. What does not survive is a smaller, sharper list — countable, and vendor-documented.
What is actually OpenShift and what is actually Kubernetes
Start with the substrate. Red Hat is plain: "OpenShift Container Platform 4.22 release uses Kubernetes 1.35 with CRI-O runtime." Every upstream object in that release behaves as the upstream API says. Nothing in that layer is a migration task.
The workload dialect is five API groups, the giveaway being the apiVersion line in your manifests: route.openshift.io/v1 (Route), image.openshift.io/v1 (ImageStream), security.openshift.io/v1 (SecurityContextConstraints), build.openshift.io/v1 (BuildConfig), apps.openshift.io/v1 (DeploymentConfig). None resolve on upstream Kubernetes. The script below prints more than five, as expected: config.openshift.io, machine.openshift.io and the operator groups are platform APIs that leave with the cluster rather than objects you translate.
One of the five Red Hat has already told you to stop using: "As of OpenShift Container Platform 4.14, DeploymentConfig objects are deprecated... Only security-related and critical issues will be fixed", and "Use Deployment objects unless you need a capability that only DeploymentConfig objects provide." The cheapest line item on the exit, and payable whether you leave or not.
#!/usr/bin/env bash
# The numerator of the exit estimate: upstream objects alongside these are
# already portable and cost nothing.
# set -e plus pipefail would abort the whole script on the first kind this
# cluster does not serve, so every probe below absorbs its own failure -
# including the first one: discovery treats an unresolvable aggregated
# APIService as a hard error, so oc api-resources can exit non-zero on a
# cluster that is otherwise healthy.
set -euo pipefail
echo "== Resources this cluster serves from an openshift.io API group =="
{ oc api-resources -o name || true; } | { grep -E '\.openshift\.io$' || true; } | sort
echo "== Live counts for the kinds with no upstream equivalent =="
for kind in \
routes.route.openshift.io \
buildconfigs.build.openshift.io \
imagestreams.image.openshift.io \
deploymentconfigs.apps.openshift.io
do
count=$( { oc get "$kind" --all-namespaces --no-headers 2>/dev/null || true; } | wc -l)
printf '%-40s %s\n' "$kind" "$count"
done
echo "== Every SCC, then which one actually admitted each running pod =="
# Anything outside the shipped set is hand-written, and those are the expensive
# ones: each is a policy decision to re-express as a webhook rule. The
# annotation is the only honest answer to "which SCC applied here".
oc get scc -o custom-columns='NAME:.metadata.name,PRIV:.allowPrivilegedContainer,RUNASUSER:.runAsUser.type' || true
{ oc get pods --all-namespaces --no-headers \
-o 'custom-columns=NS:.metadata.namespace,POD:.metadata.name,SCC:.metadata.annotations.openshift\.io/scc' \
|| true; } | sort -k3Routes: the API you will miss most
Route is the object teams are fondest of and the least tidy to replace. Its TLS block requires a termination mode, and reencrypt holds the interesting field: destinationCACertificate carries "the ca certificate of the final destination" for "health checks on the secure connection" — and optional: absent it the router "may provide its own destination CA and perform hostname validation using the short service name".
Everything else per-route lives in haproxy.router.openshift.io annotations rather than fields, some with no counterpart in Ingress or the Gateway API Standard channel: the balance algorithm's "default value is source for TLS passthrough routes. For all other routes, the default is random". Where the platform owns the traffic that hatch is gone too — "Red Hat does not support adding a route annotation to an operator-managed route."
Passthrough is the mapping write-ups get backwards, and the 4.22 book is blunt: "OpenShift Container Platform does not support passthrough termination for Gateway API", which "is only supported in traditional OpenShift Container Platform routes." Nothing OpenShift-side to migrate onto: it stays a Route until the workload leaves. The destination is upstream, where a TLS listener in Passthrough mode carries TLSRoute — "GA and ... part of the Standard Channel since v1.5.0", routing on the "Server Name Indication (SNI) hostname".
Reencrypt goes wrong in both directions. destinationCACertificate is still a field on route.openshift.io/v1 Route in 4.22 with no counterpart on any Gateway API route object — "for upstream connections, BackendTLSPolicy is used", after termination rather than on the route. The book gives that job a mechanism: "you must combine listener edge termination with a BackendTLSPolicy CR attached to the service." Treating that as where a Route's backend CA lands is this article's comparison, not a published conversion.
The timeout annotation is the mapping to resist writing. OpenShift's own 4.22 book documents two HTTPRoute clocks and names neither as its replacement: request "represents the duration of the entire request-response transaction", while backendRequest covers the gateway-to-backend leg and "is classified as an extended feature (Support: Extended)". Upstream labels the request timeout extended-support too.
# ── OPENSHIFT: the Route you have ──────────────────────────────────────────
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: payments
namespace: payments
annotations:
# No field for this in Ingress or in the Gateway API Standard channel -
# only in an implementation's own policy CRD (Envoy Gateway's
# BackendTrafficPolicy, Istio's DestinationRule), which trades one
# vendor-specific surface for another rather than removing it.
haproxy.router.openshift.io/balance: leastconn
haproxy.router.openshift.io/timeout: 30s
spec:
host: payments.example.com
to:
kind: Service
name: payments
port:
targetPort: https
tls:
termination: reencrypt
# Optional: absent it, the router may supply its own destination CA.
destinationCACertificate: |
-----BEGIN CERTIFICATE-----
<PEM of the CA that signed the backend pod's serving certificate>
-----END CERTIFICATE-----
---
# ── EDGE/TERMINATE, as an Ingress ──────────────────────────────────────────
# An ALTERNATIVE to the Route above, NEVER both on this hostname - see Gate 4.
# The kind is upstream; openshift-default is not. Replace it on the far side.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: payments
namespace: payments
spec:
ingressClassName: openshift-default
tls:
- hosts: ["payments.example.com"]
secretName: payments-edge-tls
rules:
- host: payments.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: payments
port:
name: https
---
# ── EDGE/TERMINATE, as Gateway API ─────────────────────────────────────────
# Nothing below routes until this GatewayClass exists and reports Accepted -
# creating it installs the controller. controllerName must be exact.
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: openshift-default
spec:
controllerName: openshift.io/gateway-controller/v1
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: external
namespace: openshift-ingress
spec:
gatewayClassName: openshift-default
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: payments.example.com
tls:
mode: Terminate
certificateRefs:
# Must ALREADY EXIST in openshift-ingress - the Gateway's own
# namespace - or in one a ReferenceGrant permits. Not the same
# object as the payments-namespace Secret the Ingress names above.
- group: ""
kind: Secret
name: payments-edge-tls
# OMIT THIS AND THE ROUTE BELOW NEVER ATTACHES: unset, namespaces.from
# is implicitly Same, so a Gateway here accepts only openshift-ingress
# routes. Do not use from: All - Red Hat warns that setting "can enable
# hostname or domain hijacking". Label the namespace to match:
# oc label namespace payments gateway-access=payments-edge
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
gateway-access: payments-edge
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: payments
namespace: payments
spec:
parentRefs:
# Cross-namespace, so name the Gateway's namespace.
- name: external
namespace: openshift-ingress
sectionName: https
hostnames: ["payments.example.com"]
rules:
- backendRefs:
# The Service is assumed, not shown: it must expose port 443 and map
# it to the pod's named https port (containerPort 8443 further down).
- name: payments
port: 443
# Two clocks, and the 4.22 book names NEITHER as the annotation's
# replacement: request is the whole transaction, backendRequest the
# gateway-to-backend leg (Support: Extended). The inner clock belongs
# at or below the outer one. 30s here is the 4.22 book's own example
# value - it is NOT transcribed from the 30s annotation above, and the
# match is a coincidence you should not preserve. Re-decide both.
timeouts:
request: 30s
backendRequest: 10s
---
# ── REENCRYPT: THIS ARTICLE'S COMPARISON, NOT A PUBLISHED MIGRATION ────────
# destinationCACertificate is still a route.openshift.io/v1 field in 4.22 with
# no counterpart on any Gateway API route object; the 4.22 book does the same
# job with an edge-terminated listener PLUS this policy.
apiVersion: gateway.networking.k8s.io/v1
kind: BackendTLSPolicy
metadata:
name: payments-backend
namespace: payments # must equal the ConfigMap's namespace; a cross-namespace
# ref with a ReferenceGrant is not supported here
spec:
targetRefs:
- group: ""
kind: Service
name: payments
validation:
# Must match the HTTPRoute's hostname, not the short service name the
# Route falls back to. It is also the backend SNI, so the pod certificate
# must present it or list it under subjectAltNames - the 4.22 ingress
# chapter caps that list at five values, each typed Hostname or URI, and
# states the hostname is not itself evaluated as a SAN.
hostname: payments.example.com
caCertificateRefs:
# Key MUST be ca.crt, up to eight PEMs. Without it the ref is invalid
# and connections using it MUST fail with an HTTP 5xx.
- group: ""
kind: ConfigMap
name: payments-backend-caThree edges belong in the plan, not mid-cutover. None of that Gateway API YAML does anything until the GatewayClass exists, and creating it is what makes the Ingress Operator install "a lightweight Istio control plane" in openshift-ingress. A listener without allowedRoutes accepts its own namespace only, so a cross-namespace route silently never attaches. And "experimental Gateway API CRDs in the gateway.networking.k8s.io group remain restricted".
SecurityContextConstraints: the lock-in that made your images portable
This gate decides whether a migration lands, and it is not SCC syntax but an asymmetry between two admission controllers. OpenShift's is mutating: it will "generate field values for security context settings that were not specified on the request", then validates. That is the whole problem: a pod spec can be incomplete on OpenShift and still run, because admission finishes it.
Those values do not come from the manifest. Given "a RunAsUser strategy of MustRunAsRange with no minimum or maximum set", admission "looks for the openshift.io/sa.scc.uid-range annotation to populate range fields". The UID is a property of the project the pod landed in.
Upstream will not, and says so: Pod Security Admission "is a non-mutating admission controller, meaning it won't modify pods before validating them", so you must "either modify your workloads... or use a Mutating Admission Webhook". It is coarser too — it "only supports 3 standard levels" — so a hand-written SCC becomes a webhook you own, not a namespace label.
The consequence: a manifest that ran for years behaves differently after the move, with no line changed. Upstream's restricted standard forbids runAsUser of 0 but accepts "any non-zero value" or "undefined/null", so a spec leaving the UID unset passes admission with nothing filled in, and identity comes from the image. If that image resolves to UID 0 the failure lands at the kubelet: later, noisier, in a different log.
Two more behaviours. SCC choice is not a request: "Setting privileged: true in the pod specification does not necessarily select the privileged SCC" — the highest-prioritised one the requester may use with allowPrivilegedContainer: true wins. And same-version clusters differ: restricted "is no longer available to users of new OpenShift Container Platform 4.11 or later installations" without an explicit grant.
# Target: a pod spec that states every field admission would otherwise INVENT
# - allowPrivilegeEscalation, capabilities, seccomp, runAsNonRoot - and that is
# already legal under upstream restricted. runAsUser is the field you cannot
# finish this way: leave it unset and restricted-v2 still generates it from the
# namespace range, which is precisely why the admitted UID is not portable.
# Do this BEFORE you move anything.
---
apiVersion: v1
kind: Namespace
metadata:
name: payments
labels:
# These do NOT choose an SCC. Pod Security Admission is a separate
# validating controller that runs on OpenShift too, so it rejects pods
# still failing restricted AFTER SCC mutation - in a test namespace,
# which is the point of staging it here.
# OpenShift syncs warn and audit (never enforce) to the most privileged
# profile in the namespace. Per the 4.22 docs a label modified by hand from
# its synced value DISABLES sync for that label, so the values below are
# already sticky; the opt-out is belt-and-braces, not a prerequisite:
# oc label namespace payments \
# security.openshift.io/scc.podSecurityLabelSync=false
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: payments
namespace: payments
spec:
replicas: 3
selector:
matchLabels:
# DELIBERATELY NOT THE DEPLOYMENTCONFIG'S SELECTOR - though NOT because
# the two controllers would fight over pods. They cannot: library-go's
# MakeDeployment copies spec.selector into the ReplicationController's
# selector and then adds deploymentconfig=<dc> and deployment=<dc>-<n>,
# which this Deployment's pods never carry, and both controllers claim
# pods through metadata.ownerReferences, so neither adopts the other's.
# The real cutover hazard is the SERVICE: one pointed at a label both
# pod sets share load-balances across both while you are still testing.
# A distinct label here lets the Service select exactly one set. Scaling
# this Deployment to zero still does not clear the DeploymentConfig's
# own pods - that is the DeploymentConfig's own replica count, not a
# fight. Point the Service at this label at cutover.
app: payments-next
template:
metadata:
labels:
app: payments-next
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
# runAsUser is deliberately ABSENT. Upstream fills nothing in, so
# identity comes from the image's USER and the KUBELET fails a
# container that would run as UID 0; OpenShift populates it from the
# namespace's uid-range annotation. Pinning a UID is not portable
# either: restricted-v2 rejects one outside the range, upstream
# restricted takes any non-zero value.
containers:
- name: api
# Digest-pinned: ImageStream tag resolution does not come with you.
image: registry.example.com/payments/api@sha256:0000000000000000000000000000000000000000000000000000000000000000
ports:
- name: https
containerPort: 8443
securityContext:
allowPrivilegeEscalation: false # restricted permits only false
runAsNonRoot: true
# Extra hardening, NOT a restricted requirement - and the reason
# the emptyDir exists. Keep the pair or drop both.
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"] # restricted-v2 allows only NET_BIND_SERVICE back
seccompProfile:
type: RuntimeDefault
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}What this buys even if you never leave: a security posture readable in Git rather than inferred from a namespace annotation — a policy-as-code win before a portability one. Neither project publishes a field-by-field SCC-to-Pod-Security-Standards table, and no honest article invents it.
BuildConfigs, S2I and ImageStreams: which build objects have an upstream twin
"A BuildConfig object is the definition of the entire build process", built by one of "three primary build strategies... Docker build, Source-to-image (S2I) build, Custom build". It has no upstream equivalent kind, and neither does ImageStream. Worse than the Route position: no replacement object to map fields onto, only a different way of building software.
S2I has the genuine upstream analogue most comparisons miss. Shipwright publishes a source-to-image ClusterBuildStrategy — but its platform column reads "linux/amd64 only", unlike buildah, kaniko and ko. S2I "supports incremental builds, which re-use previously downloaded dependencies, previously built artifacts", and no document says how much survives the port.
Red Hat's own forward path is a product: "Builds is a Shipwright-based framework for building container images on OpenShift Container Platform" — currently 1.9, announced "on OpenShift Container Platform 4.18 and later" though the same notes' matrix caps it at 4.18-4.22, with 1.8 at 4.17-4.21. A product that runs on OpenShift.
Two paths off BuildConfig end on a conformant cluster: rebuild on upstream Shipwright or Tekton, or move builds off-cluster into whatever CI exists. Neither has a Red Hat conversion tool behind it — a cost with a long-game dividend, because a build that runs in CI runs anywhere, the same argument as owning your forge rather than renting it.
ImageStream deserves credit before you drop it: a tag "contains a history of images represented as a stack of all images the tag ever pointed to", which "allows for easy rollbacks". A plain registry tag gives none of that; the substitute is digest pinning plus what your deployment system records.
Version skew and the support window you are really buying
Here is the case for paying, as strongly as the case against. As of October 2026 upstream "maintains release branches for the most recent three minor releases (1.37, 1.36, 1.35)", the current being 1.37.1. OpenShift Container Platform 4.22 ships Kubernetes 1.35 — two minors behind. On the day of writing 4.23 had no GA release; its engineering candidate 4.23.0-ec.1 already lists "Kubernetes 1.36.3" — the two-minor gap belongs to the 4.22 GA line priced here, not the 4.23 stream.
What the gap buys is time, measured against upstream's own figure: patch series are supported "for a period of roughly fourteen (14) months", then EOL. Not long for a regulated estate with a change-freeze calendar.
Read the ladder off the life-cycle policy itself — summaries stop a rung early.
- 18 months, every minor — Maintenance Support "ends at 18 months after GA". No add-on, no qualifier. For an odd minor that is the whole ladder.
- 24 months, EUS only — plus Term 1, "an optional six months ... for all EUS denoted releases", "included with all Premium subscriptions and available as an optional add-on to Standard subscriptions". Red Hat "denotes even numbered minor releases" as EUS, so 4.22 qualifies and 4.21 never did.
- 36 months, EUS only — plus Term 2, "an optional twelve months" more, commencing at the end of Term 1. Most summaries stop here; it is not the ceiling.
- 48 months, EUS only — plus Term 3, a further "optional twelve months" sold only as an add-on, which Red Hat says lets customers "remain with the same release of Red Hat OpenShift for a total of 48 months". The 4.22 notes do not mention Term 3.
Every rung above 18 months is EUS, so planning the estate onto even minors is a standing decision. Footnote 11 says "The OCP 4.12 Extended Update Support Add-on Term 1, Term 2, and Term 3 are only available on the x86_64 architecture", then that "Commencing with OCP 4.14 the Extended Update Support Add-on Term 1 and Term 2 includes support across all supported architectures". The second sentence names Term 1 and Term 2, not Term 3 — do not assume a 48-month window on aarch64, ppc64le or s390x. Forty-eight months against roughly fourteen is a real reason to pay.
The subscription cannot be priced from public sources: Red Hat posts no list price for self-managed OpenShift Container Platform. The publicly posted cards are managed-service — AWS's ROSA page states fees accrue "at an hourly rate of $0.171 per 4 vCPU used by worker nodes", plus "a $0.25 per hour cluster fee" for hosted control planes. Fees on one cloud, no proxy for self-managed. Price this exit with the method from a general exit-cost model and your own contract.
What an auditor sees on each side of the move
Migration day changes the evidence, not just the manifests. SCC bindings are clearest: restricted-v2 granted to authenticated users is a platform-maintained statement that pods cannot escalate privilege: "if you delete a default SCC, it is regenerated by the Cluster Version Operator." The far-side equivalent is a namespace label plus a webhook you wrote — weaker until test evidence stands behind it.
The second artefact is newer. "Starting in 4.22... the operators that manage cluster DNS and cluster Ingress automatically install and maintain default deny-all NetworkPolicy objects in their respective namespaces" — and "any unmanaged or custom pods running in these namespaces will have their network traffic blocked." The same notes add "a new NetworkPolicy parameter for the openshift-cluster-version namespace" that "denies all ingress and egress traffic for pods that are not host-networked". Vendor-delivered default-deny on the platform's control paths.
Replicating that on vanilla is ordinary work — default-deny per namespace with targeted allow rules — but it must be standing *before* cutover. The audit question is whether the control was ever absent.
The exit, staged: no flag day required
Nothing above argues for a cutover weekend. Every step runs on the cluster you already have, in dependency order, each keeping a rollback that is not a restore — provided the replacement is built so the OpenShift object it supersedes cannot fight it.
- DeploymentConfig to Deployment. Cheapest, and already recommended — but give the new Deployment its own pod label. The controllers cannot fight over pods: the
ReplicationControllera DeploymentConfig creates addsdeploymentconfig=<dc>anddeployment=<dc>-<n>to its selector, and both claim pods throughownerReferences. The Service is the hazard — pointed at a label both pod sets share, it load-balances across both mid-test. Scaling the Deployment to zero still leaves the DeploymentConfig's pods. Abort if a workload needs a DeploymentConfig-only capability. - ImageStream and BuildConfig. Move builds to Shipwright, Tekton or off-cluster, pin deployments to digests, export the tag histories you would want mid-incident. Abort if
assemblebehaviour or incremental caching does not reproduce. - SCC to Pod Security Admission plus webhooks. The highest-leverage gate, done entirely on OpenShift: state every field admission would otherwise invent, then stand up the namespace labels and webhooks that replace it.
runAsUseris the exception — leave it unset andrestricted-v2still generates it, so never treat the admitted UID as portable. Abort if an image still resolves to UID 0 — that failure lands at the kubelet. - Route last, and not as one step. An HTTPRoute on a Gateway with its own address can run beside the live Route, so DNS is both the cutover and the rollback — but not for passthrough: 4.22 vendors only standard-channel Gateway API v1.4.1, and TLSRoute reached Standard in
v1.5.0, so that kind is absent, and a passthrough Route has no parallel stage: it stays a Route until the workload lands upstream. An Ingress cannot stage either: it is admitted by generating a Route, and the router keeps routes unique per host *and* path only inside one namespace — across namespaces the default policy rejects on the host alone, so staging next door fails too. Same path as the live Route and the generated one is rejectedHostAlreadyClaimed; a different path — the Route above sets none, the Ingress rule sets/— and both are admitted on one hostname, with no error saying which serves a request. Validate on another hostname, or delete the Route at cutover and accept that the rollback is recreating it. Abort if an annotation has no tested replacement; the balance algorithm has no portable field at all.
OKD is a parallel destination, not a step on this path. It keeps the entire dialect with no Red Hat subscription — legitimate if the objection is the bill, not the API — and sits a Kubernetes minor *ahead* of the paid line. But no upgrade edge into it is published: OKD's documented in-place update to 5.0 starts from an OKD 4.x release. Reaching it means a fresh install plus a workload move, with a Kubernetes 1.35-to-1.36 jump riding along.
# THREE SEPARATE FILES, shown in one listing. The --- lines are file
# boundaries, not documents in a single apply or one kustomize build.
#
# base/kustomization.yaml — UPSTREAM ONLY. A file here naming an openshift.io
# API group is the bug; CI should fail the build on it.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- deployment.yaml
- service.yaml
- networkpolicy-default-deny.yaml
---
# overlays/openshift/kustomization.yaml — every openshift.io object lives
# under here, so the dialect footprint is a readable file count.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: payments
resources:
- ../../base
- route.yaml # route.openshift.io/v1
components:
- ../../components/scc-uid-pinning
---
# overlays/vanilla/kustomization.yaml — buildable TODAY, long before anyone
# decides to leave: the exit plan is exercised by CI, not written under fire.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: payments
resources:
- ../../base
- gateway.yaml # gateway.networking.k8s.io/v1
- httproute.yaml
- backendtlspolicy.yaml
patches:
- path: psa-labels.yaml
target:
kind: Namespace
name: paymentsThe long game: buy the distribution, do not write in the dialect
The conclusion is not that OpenShift is a trap. A platform handing you default-deny networking on its own control paths, admission that makes third-party images behave, and a documented 48 months on an EUS release is selling something real. The mistake is letting the purchase write itself into every manifest.
So make it a standing engineering rule, not a migration project: every openshift.io object lives in one overlay, no workload depends on admission completing its pod spec, images carry a non-root USER, deployments pin digests rather than ImageStream tags. None of that is an exit — it is a cleaner way to run the platform you pay for, and the exit stays cheap as a side effect.
Then the footprint is a number you can report at any time — the only optionality that survives a renewal negotiation. A team that says "forty-one Routes, two hand-written SCCs, no BuildConfigs, overlay builds green" is negotiating; one that says "it is all Kubernetes, probably fine" is guessing. The same discipline runs below Kubernetes — an immutable node OS, bare-metal ingress on Gateway API — and above it, where KubeVirt decides whether your virtual machines join this conversation.
Measure the dialect quarterly, keep the vanilla overlay green in CI, and the distribution stops being a commitment and becomes a supplier.
§FAQ/Common questions
Frequently asked
Is OpenShift the same as Kubernetes?
OpenShift is Kubernetes plus a dialect. Red Hat states that "OpenShift Container Platform 4.22 release uses Kubernetes 1.35 with CRI-O runtime", and every upstream object in it behaves as the upstream API documents. What is additional is five OpenShift-only API groups — route.openshift.io (Route), security.openshift.io (SecurityContextConstraints), build.openshift.io (BuildConfig), image.openshift.io (ImageStream) and apps.openshift.io (DeploymentConfig, deprecated since 4.14 and receiving "only security-related and critical" fixes) — plus the admission behaviour around them. The practical test is your own manifests: anything whose apiVersion ends in openshift.io will not resolve on upstream Kubernetes, and everything else will. OKD is the exception that proves the point — it serves the same dialect, so it is not an exit from it.
What is the hardest part of migrating from OpenShift to vanilla Kubernetes?
Not Routes — SecurityContextConstraints, because the failure is silent. OpenShift's SCC admission is mutating: its documented algorithm is to "retrieve all SCCs available for use", then "generate field values for security context settings that were not specified on the request", then "validate the final settings". Upstream Pod Security Admission does the opposite and says so: it "is a non-mutating admission controller, meaning it won't modify pods before validating them", so "you will need to either modify your workloads to meet the Pod Security constraints, or use a Mutating Admission Webhook to make those changes". A manifest that has run for years on OpenShift can behave differently on vanilla without a line changing — for example a pod leaving runAsUser unset, which OpenShift populates from the namespace's openshift.io/sa.scc.uid-range annotation while upstream fills nothing in and lets the image's own USER decide. Pod Security Admission is also coarser: it "only supports 3 standard levels", so a hand-written SCC becomes a validating admission webhook rather than a namespace label.
What replaces an OpenShift Route on vanilla Kubernetes?
It depends on the termination mode, and one case has no field-level answer. Edge or terminate maps to an Ingress or to an HTTPRoute on an HTTPS Terminate listener. Passthrough maps to a TLSRoute on a TLS listener in Passthrough mode — upstream states that "the TLSRoute resource is GA and has been part of the Standard Channel since v1.5.0" and that it routes on "the Server Name Indication (SNI) hostname provided during the TLS handshake". Reencrypt is the awkward one: destinationCACertificate is still a field on route.openshift.io/v1 Route in 4.22 and has no counterpart field on HTTPRoute or TLSRoute, because Gateway API does the job with a different mechanism. OpenShift's own 4.22 ingress book says that "to use re-encrypt termination in Gateway API, you must combine listener edge termination with a BackendTLSPolicy CR attached to the service". Treating that policy as where a Route's backend CA ends up is a comparison between two APIs, not a migration Red Hat publishes. Separately, the haproxy.router.openshift.io/balance annotation has no counterpart in Ingress or in the Gateway API Standard channel — only in an implementation's own policy CRD, such as Envoy Gateway's BackendTrafficPolicy or Istio's DestinationRule.
Is OKD a way out of OpenShift lock-in?
It removes the subscription, not the dialect, and it is a separate cluster rather than an upgrade. OKD keeps Routes, SCCs, BuildConfigs and ImageStreams intact, and runs CentOS Stream CoreOS rather than RHEL CoreOS. Oddly, it is also a Kubernetes minor ahead of the paid line: build 5.0.0-okd-scos.0, described by its 17 September 2026 release notes, pins "Kubernetes 1.36.3: Upgraded from Kubernetes 1.35.6", and 5.0.0-okd-scos.1 of 30 September 2026 pins Kubernetes 1.36.4 — against OpenShift Container Platform 4.22's Kubernetes 1.35 and an upstream current minor of 1.37.1 as of October 2026. Those pins belong to each build, not to "OKD 5.0" as a standing property. The important operational limit is that no OpenShift-to-OKD upgrade edge is published: the OKD page states that "Updating an OKD cluster from a 4.x release to version 5.0 is an in-place update", on the stable-5.0 and candidate-5.0 channels — it does not offer an edge from OpenShift Container Platform. Reaching OKD from OpenShift means a fresh install plus a workload move, with a 1.35-to-1.36 Kubernetes jump on top — not a retargeted Cluster Version Operator.
How long is an OpenShift release supported, and is that worth the lock-in?
Longer than upstream, but only on the right minors. Maintenance Support "ends at 18 months after GA" and that applies to every minor — for an odd-numbered one it is the end of the line. The three Extended Update Support Add-On terms above it are all scoped to "EUS denoted releases", and Red Hat "denotes even numbered minor releases (eg 4.12, 4.14, 4.16) as Extended Update Support (EUS) releases", so 4.22 is eligible and 4.21 is not. Term 1 adds "an optional six months" and "is included with all Premium subscriptions and available as an optional add-on to Standard subscriptions", reaching the 24 months the 4.22 release notes state for even-numbered releases. Term 2 adds "an optional twelve months" more, to 36 — where most summaries stop. Term 3 adds a further "optional twelve months", which Red Hat says "enables customers to remain with the same release of Red Hat OpenShift for a total of 48 months". Upstream, by contrast, supports "active patch release series for a period of roughly fourteen (14) months". One caveat on architecture: footnote 11 says OCP 4.12's "Term 1, Term 2, and Term 3 are only available on the x86_64 architecture", and that "Commencing with OCP 4.14 the Extended Update Support Add-on Term 1 and Term 2 includes support across all supported architectures" — a sentence that does not name Term 3, so a 48-month all-architecture window is not something to assume.
Further reading
- Terraform vs CloudFormation: Who Holds the Record
- Vendor Lock-In in the Cloud: Pricing Your Exit as a Number
- Kubernetes Upgrade Debt: Skew, Dead APIs, Safe Paths
- Kyverno vs OPA Gatekeeper: Policy as Code at Cluster Scale
- MetalLB vs kube-vip: Bare-Metal Ingress with Gateway API
- KubeVirt: The VMware Exit for VMs You Cannot Containerise
- Repatriation Cost: A Five-Year TCO Model You Can Defend
- Talos Linux: The Security Case for an Immutable OS
- Git, GitHub, GitLab: What You Own Versus What You Rent
- Caddy, Traefik, NGINX, HAProxy: Who Writes Your Edge Config
Executive Briefing
Thirty minutes to clarify your infrastructure risk
Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.