, ,

Migrating a Stateless Application End to End (TKGI to OpenShift Series, Part 18)

Moving your first stateless namespace off TKGI is less about Velero than about what OpenShift admission does to the pods when they land. Here is the whole loop, including a restore that reported success while nothing started.

TKGI to OpenShift Series · Part 18 of 26
Key takeaways:
1. Back up a filtered subset, never a whole namespace. Your headline command is velero backup create wave1-web-20260816 –include-namespaces wave1-web –include-resources deployments,services,configmaps,secrets,ingresses –snapshot-volumes=false.
2. A restore that reports Completed tells you the objects landed. It says nothing about whether a single pod will start.
3. Admission is the whole game. Expect forbidden: unable to validate against any security context constraint on your first attempt and treat it as normal, not as a defect.
4. Restoring an Ingress makes OpenShift generate a managed Route automatically, and that Route inherits the hostname still pointing at your NSX-T load balancer. Build a second Route on a test hostname and cut DNS only after it serves.
5. Rollback is free at this stage. Nothing on TKGI is modified, so backing out is one oc delete project.
Who this is for: you finished Part 17, so Velero runs on the TKGI side, OADP runs on OpenShift, and both point at the same bucket. A smoke test namespace has already made one round trip. Now a real application with real users moves, and this is the first Part where something on TKGI has a customer behind it.

Migrating a stateless application is not a data problem, and treating it as one is why first attempts take a full day instead of forty minutes. There is nothing to copy except a few megabytes of YAML. Every hour you will lose goes to three things: objects you should never have carried across, an admission controller that rejects images which ran unquestioned on TKGI for years, and a hostname that resolves to the wrong load balancer. Velero and OADP, which stands for OpenShift API for Data Protection and is simply Velero packaged as an Operator, are the easy part of this Part.

My reference migration uses wave1-web: three Deployments, eleven pods, no persistent volume claims, an NSX-T backed LoadBalancer Service, and one Ingress serving a public hostname. It is deliberately the second namespace I moved, not the first, because wave1-api from Part 17 had no ingress path and taught me almost nothing about cutover.

Preflight and What Counts as Stateless

Stateless in a migration context is narrower than most teams assume. No PersistentVolumeClaim is necessary but not sufficient. A Deployment that writes to an emptyDir cache and rebuilds it on start is stateless. A Deployment that writes session data to local disk and expects it back is not, and it will pass every check below and then fail in production when a user gets bounced to a different replica. Ask the application owner one question before you touch anything: if I delete every pod right now, what is lost.

Pin your versions before the first command, because Velero only tests one minor version of skew and OADP moves on its own cadence.

# Versions this runbook was written and verified against TKGI 1.18.x, Kubernetes 1.30 on the three source clusters Velero CLI and server v1.16.1 on TKGI OpenShift Container Platform 4.19.9 OADP Operator 1.5.1, Velero 1.16 inside it oc client 4.19.9 Object storage: on premises S3, bucket tkgi-migration # Prove the namespace is actually stateless kubectl –context tkgi-prod -n wave1-web get pvc,statefulset No resources found in wave1-web namespace. kubectl –context tkgi-prod -n wave1-web get deploy,svc,ingress NAME READY UP-TO-DATE AVAILABLE AGE deployment.apps/web 6/6 6 6 287d deployment.apps/web-bff 3/3 3 3 287d deployment.apps/sidekick 2/2 2 2 119d NAME TYPE CLUSTER-IP PORT(S) AGE service/web LoadBalancer 10.100.14.63 443:31940/TCP 287d service/web-bff ClusterIP 10.100.9.201 8080/TCP 287d service/sidekick ClusterIP 10.100.11.88 9000/TCP 119d NAME CLASS HOSTS ADDRESS AGE ingress.networking.k8s.io/web nsx shop.example.com 10.20.30.41 287d # The check that predicts your failures, run it now not later kubectl –context tkgi-prod -n wave1-web get deploy -o jsonpath='{range .items[*]}{.metadata.name}{" runAsUser="}{.spec.template.spec.securityContext.runAsUser}{" n"}{end}’ web runAsUser=1001 web-bff runAsUser= sidekick runAsUser=0

That last command is worth more than the rest of the preflight combined. Two of my three Deployments carry a hardcoded UID, one of them root. On TKGI, with pod admission effectively switched off, nobody ever noticed. On OpenShift both will be rejected, and knowing that before you start converts a mysterious outage into a planned ten minute edit. Part 7 covers why the restricted-v2 Security Context Constraint behaves this way, so I will not repeat it here.

Part 7, Security Context Constraints and Pod Admission is the prerequisite reading for everything in step 3 below.

Step 1, Backing Up wave1-web on TKGI

Almost every tutorial you will find reaches for –include-namespaces and stops there. On a TKGI source that is the wrong default, and it is the single thing in this Part that contradicts the common advice most directly. A whole namespace backup drags across ServiceAccounts bound to UAA identities that do not exist on OpenShift, image pull Secrets for a registry you are about to re-point, Services carrying an allocated ClusterIP from a Service CIDR OpenShift does not use, NSX-T annotations that OVN-Kubernetes will ignore, and an Ingress whose host names a load balancer you plan to switch off. You then spend an afternoon deleting objects you deliberately created.

Restore what the application owns. Rebuild what the platform owns. That distinction is the reference artifact of this Part, and it is the table I still paste into every migration ticket.

ObjectRestore or rebuildWhy
Deployment, ReplicaSet ownerRestoreApplication owned. Expect to patch securityContext after landing.
ConfigMapRestoreApplication owned. Grep it for old cluster hostnames before you trust it.
Secret, application type OpaqueRestoreApplication owned, and Part 21 covers moving these properly.
Secret, type kubernetes.io/service-account-token or dockercfgRebuildBound to a TKGI ServiceAccount and a TKGI issuer. Worthless on OpenShift.
ServiceAccountRebuildOpenShift creates its own, and SCC grants attach to it. Restoring one confuses both.
Service, ClusterIP typeRestoreVelero strips the allocated ClusterIP for you. Headless Services keep None correctly.
Service, LoadBalancer typeRebuild as ClusterIPNCP provisioned the NSX-T virtual server. On OpenShift a Route replaces it.
IngressRebuild as RouteRestoring it auto generates a Route on the old public hostname. See step 4.
NetworkPolicyRebuildPart 16 already wrote the OVN-Kubernetes equivalents. Do not double up.
ResourceQuota, LimitRangeRebuildPart 14 set project level quotas. A restored quota silently overrides them.

With that decided, the backup command writes itself.

velero backup create wave1-web-20260816 –include-namespaces wave1-web –include-resources deployments,services,configmaps,secrets,ingresses,horizontalpodautoscalers –exclude-resources events,events.events.k8s.io,endpoints,endpointslices –snapshot-volumes=false –wait Backup request "wave1-web-20260816" submitted successfully. Waiting for backup to complete. You may safely press ctrl-c to stop waiting – your backup will continue in the background. …. Backup completed with status: Completed. velero backup describe wave1-web-20260816 Name: wave1-web-20260816 Namespace: velero Phase: Completed Started: 2026-08-16 09:14:02 +0000 UTC Completed: 2026-08-16 09:14:43 +0000 UTC Total items to be backed up: 31 Items backed up: 31 Backup Volumes: Velero-Native Snapshots: <none included>

Forty one seconds and thirty one objects. An unfiltered backup of the same namespace produced two hundred and nine objects, most of them endpoint slices and token Secrets that would have been noise at best and actively harmful at worst. Note –snapshot-volumes=false: there is nothing to snapshot, and leaving the default on wastes a CSI round trip against vSphere that can hang for minutes if the source storage provider is unhappy.

Step 2, Preparing an OpenShift Project That Will Accept the Pods

Create the project before the restore, not during it. If Velero creates the namespace it will do so without the annotations OpenShift relies on to allocate a UID range, and you will chase a much stranger class of failure. A project made by oc new-project gets its range immediately.

oc new-project wave1-web Now using project "wave1-web" on server https://api.ocp1.example.com:6443. oc get namespace wave1-web -o jsonpath='{.metadata.annotations}’ | tr ‘,’ ‘n’ {"openshift.io/sa.scc.mcs":"s0:c31,c5" "openshift.io/sa.scc.supplemental-groups":"1001120000/10000" "openshift.io/sa.scc.uid-range":"1001120000/10000"} # Registry credentials read from the environment, never typed into a manifest oc create secret docker-registry harbor-pull –docker-server=harbor.example.com –docker-username=$HARBOR_USER –docker-password=$HARBOR_PASS -n wave1-web secret/harbor-pull created oc secrets link default harbor-pull –for=pull -n wave1-web # Confirm the project default posture before anything lands oc auth can-i use scc/anyuid –as=system:serviceaccount:wave1-web:default -n wave1-web no

Read that UID range and write it down: 1001120000/10000. Any pod in this project that asks for a specific user outside 1001120000 through 1001129999 will be rejected, and my web Deployment asks for 1001. Two of these values are now on a collision course, and neither Velero nor OADP will warn you.

Step 3, Restoring with OADP, Then Fixing Admission

I use a Restore custom resource rather than the Velero CLI on the OpenShift side, for one practical reason: the file goes into git, and the next twenty five namespaces are a copy with one name changed. I also prefer oc over kubectl throughout the OpenShift half of this series, because oc understands Routes, Projects, SCC and oc adm policy, and dropping to kubectl for those means hand writing RBAC that one subcommand does correctly.

# wave1-web-restore-01.yaml apiVersion: velero.io/v1 kind: Restore metadata: name: wave1-web-restore-01 namespace: openshift-adp spec: backupName: wave1-web-20260816 includedNamespaces: – wave1-web namespaceMapping: wave1-web: wave1-web excludedResources: – serviceaccounts – networkpolicies – resourcequotas – limitranges restorePVs: false existingResourcePolicy: none oc apply -f wave1-web-restore-01.yaml restore.velero.io/wave1-web-restore-01 created oc -n openshift-adp get restore wave1-web-restore-01 -o jsonpath='{.status.phase}{"n"}’ Completed oc -n wave1-web get deploy NAME READY UP-TO-DATE AVAILABLE AGE sidekick 0/2 0 0 38s web 0/6 0 0 38s web-bff 0/3 0 0 38s

Phase Completed. Zero errors, zero warnings, zero running pods. This is the moment that catches people, and it is not a bug in either tool. Velero restored eleven object definitions faithfully; OpenShift admission then declined to create the pods those definitions asked for. Both statements are true simultaneously.

oc -n wave1-web get events –field-selector reason=FailedCreate LAST SEEN TYPE REASON OBJECT MESSAGE 21s Warning FailedCreate replicaset/web-5c9f8b7d4 Error creating: pods "web-5c9f8b7d4-" is forbidden: unable to validate against any security context constraint: [provider "restricted-v2": .spec.securityContext.runAsUser: Invalid value: 1001: must be in the ranges: [1001120000, 1001129999]] 19s Warning FailedCreate replicaset/sidekick-6b4d Error creating: pods "sidekick-6b4d-" is forbidden: unable to validate against any security context constraint: [provider "restricted-v2": .spec.securityContext.runAsUser: Invalid value: 0: must be in the ranges: [1001120000, 1001129999]] # Preferred fix. Remove the hardcoded UID and let OpenShift assign one from the range oc -n wave1-web patch deployment web –type=json -p='[{"op":"remove","path":"/spec/template/spec/securityContext/runAsUser"}]’ deployment.apps/web patched # Second best. Only where the image genuinely requires one fixed non root UID oc -n wave1-web create serviceaccount sidekick-sa oc adm policy add-scc-to-user nonroot-v2 -z sidekick-sa -n wave1-web clusterrole.rbac.authorization.k8s.io/system:openshift:scc:nonroot-v2 added: "sidekick-sa" oc -n wave1-web get deploy NAME READY UP-TO-DATE AVAILABLE AGE sidekick 2/2 2 2 6m41s web 6/6 6 6 6m41s web-bff 3/3 3 3 6m41s
Do not reach for anyuid: the first search result for this error will tell you to run oc adm policy add-scc-to-user anyuid and move on. That grant lets the workload run as root, and it is permanent, invisible in the Deployment, and inherited by whatever lands on that ServiceAccount next. Of the nine workloads in my wave 1, six needed nothing but the removal of a hardcoded UID, two needed nonroot-v2, and exactly one genuinely needed elevated access. Reaching for anyuid at the first error would have given all nine of them root and quietly undone the reason you moved to OpenShift.

Here is what that learning curve looked like in wall clock across my first four namespaces. Note that it got worse before it got better, because namespace two was the one with a hardcoded UID and a registry certificate problem stacked on top of each other.

Minutes from restore Completed to all replicas Ready Four stateless namespaces, migrated in this order, OCP 4.19.9 and OADP 1.5.1 27 min wave1-api 31 min wave1-web 9 min wave1-auth 4 min wave1-report 0 10 20 30
Almost none of this time is data movement. Restores themselves finished in under ninety seconds every time. What varies is how many admission and registry surprises each namespace carried.

Step 4, Ingress to Route and DNS Cutover

OpenShift automatically creates a managed Route object whenever an Ingress object is created, and deletes it again when the Ingress goes away. That behaviour is convenient in normal operation and dangerous during a migration, because the generated Route copies the host field verbatim. My restored Ingress named shop.example.com, a hostname that still resolves to an NSX-T virtual server carrying live production traffic. OpenShift now claims it too, and whichever answer DNS gives is the one your users get.

flowchart TD
  A[Ingress restored into project] --> B[OpenShift generates a managed Route]
  B --> C[Generated Route claims the live production hostname]
  C --> D[Create a second Route on a test hostname]
  D --> E[Validate through the OpenShift ingress controller]
  E --> F[Switch the DNS record to the OpenShift VIP]
  F --> G[Delete the restored Ingress and its managed Route]
  E --> H[Fallback, leave DNS pointing at NSX and investigate]
DNS is the only irreversible step in this Part, which is why it comes last and only after a test hostname has served real responses.
oc -n wave1-web get route NAME HOST/PORT SERVICES PORT TERMINATION WILDCARD web-gnztq shop.example.com web https passthrough None # Note the random five character suffix. It is generated, not yours, and it will # be different on every cluster you restore into. Never script against that name. oc -n wave1-web create route edge web-ocp –service=web –port=8443 –hostname=shop-ocp.apps.ocp1.example.com route.route.openshift.io/web-ocp created curl -sS -o /dev/null -w ‘%{http_code} %{time_total}n’ https://shop-ocp.apps.ocp1.example.com/healthz 200 0.084 # The failure I hit here, and it is a platform problem not an application one oc -n wave1-web describe pod web-7d94c6f8b-q2ltn | tail -2 Warning Failed 17s kubelet Failed to pull image "harbor.example.com/wave1/web:4.11.2": pinging container registry harbor.example.com: Get "https://harbor.example.com/v2/": x509: certificate signed by unknown authority oc create configmap harbor-ca -n openshift-config –from-file=harbor.example.com=/tmp/harbor-ca.crt configmap/harbor-ca created oc patch image.config.openshift.io/cluster –type=merge -p ‘{"spec":{"additionalTrustedCA":{"name":"harbor-ca"}}}’ image.config.openshift.io/cluster patched # Rollback for the whole Part. Nothing on TKGI was modified at any point. oc delete project wave1-web project.project.openshift.io "wave1-web" deleted

That Harbor certificate patch deserves its own warning. It is one line, it looks harmless, and it triggers a Machine Config rollout that drains and restarts every node in the cluster one at a time. On my six node cluster that took roughly forty minutes, during which the other pilot namespace I was mid way through migrating kept getting evicted. Do the registry trust configuration once, on day zero, before any workload arrives. Part 15 covers registry setup properly, and I should have finished it before starting this.

Rollback deserves a sentence of its own too, because it is genuinely easy here and it will not be in Part 19, where persistent volumes arrive. Until DNS moves, TKGI is still serving every request. Deleting the OpenShift project costs you nothing except the twenty minutes you spent on it. Take advantage of that while it lasts.

Verification, Rollback and Common Failures

Green means four things, and replica counts are only the first. Every Deployment reports its full replica count Ready. Every pod has restarted zero times after the first sixty seconds, which rules out a crash loop that replica count alone will hide for a while. Your test Route returns the same status code and a comparable response time as the production hostname. And oc get events in the project is empty of Warning entries. If any one of those four is off, do not touch DNS.

Six failures cover nearly everything I hit moving nine stateless workloads. Keep this next to the runbook.

SymptomError you will seeRemediation
Restore Completed, Deployment stuck at 0 replicasforbidden: unable to validate against any security context constraint: [provider restricted-v2: .spec.securityContext.runAsUser: Invalid value: 1001]Remove runAsUser from the pod template. Grant nonroot-v2 to a dedicated ServiceAccount only if the image truly needs a fixed UID.
Pods created but ImagePullBackOffx509: certificate signed by unknown authorityConfigMap of the Harbor CA in openshift-config, referenced from spec.additionalTrustedCA. Expect a rolling node restart.
Pods created but ImagePullBackOff with a 401unauthorized: authentication requiredThe restored dockercfg Secret is a TKGI artifact. Rebuild it and link it to the project default ServiceAccount for pull.
Restore PartiallyFailed on the ServiceService is invalid: spec.ports[0].nodePort: Invalid value: 31940: provided port is already allocatedExclude the LoadBalancer Service from the backup and recreate it as ClusterIP behind a Route.
Two Routes claim the same hostnameHostAlreadyClaimedDelete the restored Ingress, which removes its generated Route through the owner reference. Keep only the Route you wrote.
Application starts, then fails on its first outbound calldial tcp: lookup web-bff.wave1-web.svc.cluster.local: no such hostA ConfigMap carried a fully qualified TKGI service name. Grep every restored ConfigMap for the old cluster domain before you declare success.

One more verification step earns its keep, and it is embarrassingly low tech. Before DNS moves, grep every restored ConfigMap for three strings: your old pod CIDR, your old cluster DNS domain, and your old registry hostname. A loop over oc -n wave1-web get cm -o yaml piped through grep takes eleven seconds and needs no tooling. It has found something in four of the nine namespaces I have moved, and in three of those four the application would have started, passed every probe, and misbehaved only under real traffic.

Four questions come up on every wave, so here are my answers rather than a link to a mailing list thread.

Why use Velero when kubectl get -o yaml exports the same objects?
You can, and for one namespace of three Deployments the difference is small. It stops scaling around the fourth namespace. Velero gives you a timestamped copy in object storage that you can restore repeatedly with different exclusions while you iterate, it applies its own restore logic for details like allocated ClusterIPs, and it leaves a log another engineer can read. A folder of exported YAML gives you none of that, and it invites hand editing, which is precisely the untraceable manual patching I argued against above.
Should the OpenShift project keep the TKGI namespace name?
Keep it unless you have a concrete reason not to. Namespace names leak into service DNS, into ConfigMaps, into dashboards, into alert routing and into other teams’ firewall allowlists. That is why my restore maps wave1-web onto wave1-web rather than inventing something tidier. Use namespaceMapping for real collisions, such as consolidating three TKGI clusters that each own a namespace called web onto one larger OpenShift cluster, which is the consolidation Part 2 recommended. Renaming for neatness alone costs you a week of chasing references.
Do deprecated API versions bite on a stateless lift?
Less than people fear, more than zero. TKGI 1.18 runs Kubernetes 1.30, so genuinely ancient objects are long gone from your estate already. What still catches teams is an old HorizontalPodAutoscaler manifest pinned to autoscaling/v2beta2, or a PodDisruptionBudget on policy/v1beta1 inside a Helm chart nobody has re-rendered since 2021. Velero restores exactly what it captured. If your target API server no longer serves that version, the restore lands in PartiallyFailed and names the resource. Re-render the chart on the OpenShift side rather than editing the backup.
How much of this survives contact with a stateful application?
Steps 1, 2 and 4 barely change. Step 3 changes completely. restorePVs: false becomes the wrong answer, real data has to cross between two storage stacks, StorageClass names almost never match, and rollback stops being one oc delete project because the source database has kept accepting writes while you worked. Budget several times the effort per namespace. That is Part 19, and it is the hardest Part in this series.

Field Note from a Deployment That Restored Clean and Never Started

My worst day on this was not the SCC rejection, which is loud and self explaining once you have read it once. It was a Deployment that restored, started, passed its readiness probe, served my curl, and then broke for real users about ninety minutes later.

What I had missed was a ConfigMap key holding a comma separated allowlist of source CIDRs. On TKGI those CIDRs described the NSX-T pod network, 172.16.0.0/16. On OpenShift the OVN-Kubernetes cluster network is a different range entirely, and every internal call from web-bff to web was arriving from an address the application considered untrusted. It did not reject those calls. It downgraded them to an anonymous session, silently, exactly as it had been written to do six years earlier. My health check hit a path that did not care, so everything looked green.

Three hours to find, four minutes to fix, and the reason I now run a blunt grep across every restored ConfigMap for the old pod CIDR, the old cluster domain and the old registry hostname before I let anyone near DNS. That grep takes eleven seconds and it has caught something in four of the nine namespaces I have moved. Restore verification that only looks at pod status is verification of the platform, not of the application.

Clean end state: every Deployment at full replicas with zero restarts after the first minute, no Warning events in the project, no ServiceAccount or Secret in the project that came from the backup, exactly one Route per public hostname, DNS still pointing at NSX-T until you deliberately move it, and a documented one command rollback. If you are choosing VKS on VCF 9 rather than OpenShift, this loop is different enough that you want the TKGI to VKS guide instead.

Migrate Every Namespace Twice Before You Cut DNS

My recommendation from nine of these is simple and slightly unpopular with project managers. Run the full backup and restore loop, find your admission and configuration problems, then delete the project and run it again from the same backup with your fixes folded into the restore. Second pass on wave1-web took four minutes and produced a namespace with no accumulated manual patching in it, which means the runbook you hand to the next engineer actually works as written. A namespace you fixed by hand is a namespace nobody else can reproduce.

On Monday, take your smallest customer facing stateless namespace and run only the preflight from step 1: list its Deployments and print runAsUser for each. That one command tells you how much of this Part applies to your estate, and it costs nothing and changes nothing. Part 19 adds persistent volumes, where rollback stops being free.

TKGI to OpenShift Series · Part 18 of 26
« Previous: Part 17  |  Guide  |  Next: Part 19 »

References

About The Author


Discover more from Journal of Intelligent Infrastructure

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Architect’s Toolkit

About the Author

Dr. Pranay Jha is a Cloud and AI Consultant with 18+ years of experience in hybrid cloud, virtualization, and enterprise infrastructure transformation. He specializes in VMware technologies, multi-cloud strategy, and Generative AI solutions. He holds a PhD in Computer Applications with research focused on Cloud and AI, has published multiple research papers, and has been a VMware vExpert since 2016 and a VMUG Community Leader.

Discover more from Journal of Intelligent Infrastructure

Subscribe now to keep reading and get access to the full archive.

Continue reading